From ad6844f64ddf071d052c37e5485ac42826db1936 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Mon, 20 Apr 2026 22:46:02 -0700 Subject: [PATCH 01/70] Use main eslint config for copilot extension too - Moves copilot eslint config into top level eslint config - Adopts some standard rules - Suppress a few new shared rules --- .eslint-ignore | 21 +- build/hygiene.ts | 14 - eslint.config.js | 410 ++- extensions/copilot/.eslint-ignore | 31 - .../no-unexternalized-strings.ts | 192 -- extensions/copilot/eslint.config.mjs | 538 ---- extensions/copilot/package-lock.json | 2631 ++--------------- extensions/copilot/package.json | 10 - .../script/compareStestAlternativeRuns.ts | 1 + .../node/copilotcliSessionService.ts | 2 +- .../extension/common/modelContextProtocol.ts | 3 +- .../languages/cuda-cpp.tmLanguage.ts | 2 +- .../languages/javaScriptReact.tmLanguage.ts | 2 +- .../markdown-latex-combined.tmLanguage.ts | 2 +- .../panelShared/languages/rst.tmLanguage.ts | 2 +- .../vscode-node/lib/src/test/telemetry.ts | 1 + .../node/nextEditProviderTelemetry.ts | 2 + .../src/extension/intents/node/agentIntent.ts | 2 +- .../intents/node/newNotebookIntent.ts | 1 + .../node/debuggableCommandIdentifier.spec.ts | 1 + .../prompt/test/node/conversation.spec.ts | 2 +- .../vscode-node/languageContextService.ts | 2 + .../test/node/testEndpointProvider.ts | 2 - .../git/vscode-node/gitServiceImpl.ts | 1 + .../telemetry/common/ghTelemetrySender.ts | 2 +- .../platform/test/node/extensionContext.ts | 2 +- .../test/node/setupTestDetector.spec.ts | 1 + .../simulationExtHostContext.ts | 3 - .../simulationExtHostToolsService.ts | 2 - .../simulationWorkspaceExtHost.ts | 2 +- extensions/copilot/vite.config.ts | 1 - package-lock.json | 1627 +++++++++- package.json | 1 + 33 files changed, 2185 insertions(+), 3331 deletions(-) delete mode 100644 extensions/copilot/.eslint-ignore delete mode 100644 extensions/copilot/.eslintplugin/no-unexternalized-strings.ts delete mode 100644 extensions/copilot/eslint.config.mjs diff --git a/.eslint-ignore b/.eslint-ignore index 57d8bc4ba67ff..c16b228723218 100644 --- a/.eslint-ignore +++ b/.eslint-ignore @@ -1,9 +1,25 @@ **/build/*/**/*.js -**/dist/**/*.js +**/dist/**/* **/extensions/**/*.d.ts **/extensions/**/build/** **/extensions/**/colorize-fixtures/** -**/extensions/copilot/** +**/extensions/copilot/coverage/** +**/extensions/copilot/.esbuild/** +**/extensions/copilot/.simulation/** +**/extensions/copilot/.eslintplugin/** +**/extensions/copilot/chat-lib/** +**/extensions/copilot/test/simulation/fixtures/** +**/extensions/copilot/test/scenarios/** +**/extensions/copilot/test/aml/out/** +**/extensions/copilot/src/util/vs/** +**/extensions/copilot/src/platform/parser/test/node/fixtures/** +**/extensions/copilot/src/extension/test/node/fixtures/** +**/extensions/copilot/src/extension/prompts/node/test/fixtures/** +**/extensions/copilot/src/extension/typescriptContext/serverPlugin/fixtures/** +**/extensions/copilot/src/extension/typescriptContext/serverPlugin/lib/** +**/extensions/copilot/src/extension/typescriptContext/serverPlugin/dist/** +**/extensions/copilot/src/extension/completions-core/**/testdata/* +**/extensions/copilot/.vscode/extensions/test-extension/dist/** **/extensions/css-language-features/server/test/pathCompletionFixtures/** **/extensions/html-language-features/server/lib/jquery.d.ts **/extensions/html-language-features/server/src/test/pathCompletionFixtures/** @@ -36,4 +52,5 @@ **/test/automation/out/** **/typings/** **/.build/** +**/.vscode-test/** !.vscode diff --git a/build/hygiene.ts b/build/hygiene.ts index 8dd2927cabe70..71f500161343e 100644 --- a/build/hygiene.ts +++ b/build/hygiene.ts @@ -314,20 +314,6 @@ if (import.meta.main) { } } - // Run copilot pre-commit checks if copilot files are staged - if (some.some(f => f.startsWith('extensions/copilot/'))) { - console.log('Running copilot pre-commit checks...'); - const result = cp.spawnSync('npx', ['lint-staged'], { - cwd: path.join(process.cwd(), 'extensions', 'copilot'), - stdio: 'inherit', - shell: true, - }); - if (result.status !== 0) { - console.error('Copilot pre-commit checks failed.'); - process.exit(1); - } - } - console.log('Reading git index versions...'); createGitIndexVinyls(some) diff --git a/eslint.config.js b/eslint.config.js index ca35a088c17ef..464002c57cf3f 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -4,11 +4,14 @@ *--------------------------------------------------------------------------------------------*/ // @ts-check import fs from 'fs'; +import { builtinModules } from 'module'; import path from 'path'; import tseslint from 'typescript-eslint'; import stylisticTs from '@stylistic/eslint-plugin-ts'; import * as pluginLocal from './.eslint-plugin-local/index.ts'; +import * as pluginCopilotLocal from './extensions/copilot/.eslintplugin/index.ts'; +import pluginImport from 'eslint-plugin-import'; import pluginJsdoc from 'eslint-plugin-jsdoc'; import pluginHeader from 'eslint-plugin-header'; @@ -193,6 +196,7 @@ export default tseslint.config( 'src/bootstrap-node.ts', 'build/lib/extensions.ts', 'build/lib/test/render.test.ts', + 'extensions/copilot/**/*', 'extensions/debug-auto-launch/src/extension.ts', 'extensions/emmet/src/updateImageSize.ts', 'extensions/emmet/src/util.ts', @@ -2295,16 +2299,16 @@ export default tseslint.config( 'comma-dangle': ['warn', 'only-multiline'] } }, - // Extension main sources (excluding tests) + // Ban dynamic require() and import() calls in extensions to ensure tree-shaking works { files: [ - 'extensions/**/*.ts', + 'extensions/**/*.{ts,tsx}', ], ignores: [ 'extensions/**/*.test.ts', + 'extensions/copilot/**/*', ], rules: { - // Ban dynamic require() and import() calls in extensions to ensure tree-shaking works 'no-restricted-syntax': [ 'warn', { @@ -2386,6 +2390,406 @@ export default tseslint.config( '@typescript-eslint/consistent-generic-constructors': ['warn', 'constructor'], } }, + // copilot extension - main sources + { + files: [ + 'extensions/copilot/src/**/*.{ts,tsx}', + 'extensions/copilot/test/**/*.{ts,tsx}', + ], + ignores: [ + 'extensions/copilot/**/.esbuild.ts', + 'extensions/copilot/src/extension/completions-core/vscode-node/bridge/src/completionsTelemetryServiceBridge.ts', + ], + languageOptions: { + parser: tseslint.parser, + }, + plugins: { + 'import': pluginImport, + 'copilot-local': pluginCopilotLocal, + }, + rules: { + 'local/code-no-dangerous-type-assertions': 'off', + 'local/code-no-any-casts': 'off', + 'local/code-no-deep-import-of-internal': 'off', + 'no-restricted-imports': [ + 'warn', + // node: builtins + ...builtinModules, + // node: dependencies + '@humanwhocodes/gitignore-to-minimatch', + '@vscode/extension-telemetry', + 'applicationinsights', + 'ignore', + 'isbinaryfile', + 'minimatch', + 'source-map-support', + 'vscode-tas-client', + 'web-tree-sitter' + ], + 'import/no-restricted-paths': [ + 'warn', + { + zones: [ + { + target: '**/common/**', + from: [ + '**/vscode/**', + '**/node/**', + '**/vscode-node/**', + '**/worker/**', + '**/vscode-worker/**' + ] + }, + { + target: '**/vscode/**', + from: [ + '**/node/**', + '**/vscode-node/**', + '**/worker/**', + '**/vscode-worker/**' + ] + }, + { + target: '**/node/**', + from: [ + '**/vscode/**', + '**/vscode-node/**', + '**/worker/**', + '**/vscode-worker/**' + ] + }, + { + target: '**/vscode-node/**', + from: [ + '**/worker/**', + '**/vscode-worker/**' + ] + }, + { + target: '**/worker/**', + from: [ + '**/vscode/**', + '**/node/**', + '**/vscode-node/**', + '**/vscode-worker/**' + ] + }, + { + target: '**/vscode-worker/**', + from: [ + '**/node/**', + '**/vscode-node/**' + ] + }, + { + target: './extensions/copilot/src/', + from: './extensions/copilot/test/' + }, + { + target: './extensions/copilot/src/shared-fetch-utils', + from: ['./extensions/copilot/src/extension', './extensions/copilot/src/platform', './extensions/copilot/src/util', './extensions/copilot/src/lib'] + }, + { + target: './extensions/copilot/src/util', + from: ['./extensions/copilot/src/platform', './extensions/copilot/src/extension'] + }, + { + target: './extensions/copilot/src/platform', + from: ['./extensions/copilot/src/extension'] + }, + { + target: ['./extensions/copilot/test', '!./extensions/copilot/test/base/extHostContext/*.ts'], + from: ['**/vscode-node/**', '**/vscode-worker/**'] + }, + { + target: 'extensions/copilot/src/!(lib)/**', + from: './extensions/copilot/src/lib' + } + ] + } + ], + 'copilot-local/no-instanceof-uri': ['warn'], + 'copilot-local/no-test-imports': ['warn'], + 'copilot-local/no-runtime-import': [ + 'warn', + { + test: ['vscode'], + 'src/**/common/**/*': ['vscode'], + 'src/**/node/**/*': ['vscode'] + } + ], + 'copilot-local/no-funny-filename': ['warn'], + 'copilot-local/no-bad-gdpr-comment': ['warn'], + 'copilot-local/no-gdpr-event-name-mismatch': ['warn'], + 'copilot-local/no-unlayered-files': ['warn'], + 'copilot-local/no-restricted-copilot-pr-string': [ + 'warn', + { + className: 'GitHubPullRequestProviders', + string: 'Generate with Copilot' + } + ], + 'copilot-local/no-nls-localize': ['warn'], + } + }, + // copilot extension - allow node imports in node layer + { + files: [ + 'extensions/copilot/**/{vscode-node,node}/**/*.ts', + 'extensions/copilot/**/{vscode-node,node}/**/*.tsx', + ], + rules: { + 'no-restricted-imports': 'off' + } + }, + // copilot extension - override files (tests, build, etc.) + { + files: [ + 'extensions/copilot/test/**', + 'extensions/copilot/src/vscodeTypes.ts', + 'extensions/copilot/script/**', + 'extensions/copilot/src/extension/*.d.ts', + 'extensions/copilot/build/**', + ], + rules: { + 'copilot-local/no-unlayered-files': 'off', + 'no-restricted-imports': 'off' + } + }, + // copilot extension - TSX linebreak rule + { + files: [ + 'extensions/copilot/src/extension/**/*.tsx', + ], + plugins: { + 'copilot-local': pluginCopilotLocal, + }, + rules: { + 'copilot-local/no-missing-linebreak': 'warn' + } + }, + // copilot extension - test-only rule + { + files: [ + 'extensions/copilot/**/*.test.ts', + 'extensions/copilot/**/*.test.tsx', + ], + plugins: { + 'copilot-local': pluginCopilotLocal, + }, + rules: { + 'copilot-local/no-test-only': 'warn' + } + }, + // copilot extension - no-explicit-any + { + files: [ + 'extensions/copilot/src/**/*.ts', + ], + ignores: [ + 'extensions/copilot/src/util/vs/**/*.ts', + 'extensions/copilot/src/**/*.spec.ts', + 'extensions/copilot/src/extension/agents/copilotcli/node/nodePtyShim.ts', + 'extensions/copilot/src/extension/byok/common/anthropicMessageConverter.ts', + 'extensions/copilot/src/extension/byok/common/geminiFunctionDeclarationConverter.ts', + 'extensions/copilot/src/extension/byok/common/geminiMessageConverter.ts', + 'extensions/copilot/src/extension/byok/vscode-node/anthropicProvider.ts', + 'extensions/copilot/src/extension/byok/vscode-node/geminiNativeProvider.ts', + 'extensions/copilot/src/extension/byok/vscode-node/ollamaProvider.ts', + 'extensions/copilot/src/extension/chatSessions/vscode-node/copilotCloudSessionContentBuilder.ts', + 'extensions/copilot/src/extension/chatSessions/vscode-node/copilotCloudSessionsProvider.ts', + 'extensions/copilot/src/extension/codeBlocks/node/codeBlockProcessor.ts', + 'extensions/copilot/src/extension/codeBlocks/vscode-node/provider.ts', + 'extensions/copilot/src/extension/configuration/vscode-node/configurationMigration.ts', + 'extensions/copilot/src/extension/context/node/resolvers/genericInlineIntentInvocation.ts', + 'extensions/copilot/src/extension/context/node/resolvers/genericPanelIntentInvocation.ts', + 'extensions/copilot/src/extension/context/node/resolvers/inlineFixIntentInvocation.ts', + 'extensions/copilot/src/extension/context/node/resolvers/promptWorkspaceLabels.ts', + 'extensions/copilot/src/extension/contextKeys/vscode-node/contextKeys.contribution.ts', + 'extensions/copilot/src/extension/conversation/vscode-node/userActions.ts', + 'extensions/copilot/src/extension/extension/vscode/services.ts', + 'extensions/copilot/src/extension/inlineChat/node/rendererVisualization.ts', + 'extensions/copilot/src/extension/inlineChat/vscode-node/inlineChatCommands.ts', + 'extensions/copilot/src/extension/inlineEdits/common/observableWorkspaceRecordingReplayer.ts', + 'extensions/copilot/src/extension/inlineEdits/vscode-node/parts/vscodeWorkspace.ts', + 'extensions/copilot/src/extension/intents/node/editCodeIntent.ts', + 'extensions/copilot/src/extension/intents/node/editCodeStep.ts', + 'extensions/copilot/src/extension/intents/node/fixIntent.ts', + 'extensions/copilot/src/extension/intents/node/newIntent.ts', + 'extensions/copilot/src/extension/intents/node/searchIntent.ts', + 'extensions/copilot/src/extension/languageContextProvider/vscode-node/languageContextProviderService.ts', + 'extensions/copilot/src/extension/linkify/common/commands.ts', + 'extensions/copilot/src/extension/linkify/common/responseStreamWithLinkification.ts', + 'extensions/copilot/src/extension/linkify/test/node/util.ts', + 'extensions/copilot/src/extension/log/vscode-node/loggingActions.ts', + 'extensions/copilot/src/extension/log/vscode-node/requestLogTree.ts', + 'extensions/copilot/src/extension/mcp/test/vscode-node/util.ts', + 'extensions/copilot/src/extension/mcp/vscode-node/commands.ts', + 'extensions/copilot/src/extension/mcp/vscode-node/nuget.ts', + 'extensions/copilot/src/extension/onboardDebug/node/copilotDebugWorker/rpc.ts', + 'extensions/copilot/src/extension/onboardDebug/node/parseLaunchConfigFromResponse.ts', + 'extensions/copilot/src/extension/onboardDebug/vscode-node/copilotDebugCommandHandle.ts', + 'extensions/copilot/src/extension/prompt/common/toolCallRound.ts', + 'extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts', + 'extensions/copilot/src/extension/prompt/node/chatParticipantTelemetry.ts', + 'extensions/copilot/src/extension/prompt/node/editGeneration.ts', + 'extensions/copilot/src/extension/prompt/node/intents.ts', + 'extensions/copilot/src/extension/prompt/node/todoListContextProvider.ts', + 'extensions/copilot/src/extension/prompt/vscode-node/endpointProviderImpl.ts', + 'extensions/copilot/src/extension/prompt/vscode-node/requestLoggerImpl.ts', + 'extensions/copilot/src/extension/prompts/node/agent/promptRegistry.ts', + 'extensions/copilot/src/extension/prompts/node/base/promptElement.ts', + 'extensions/copilot/src/extension/prompts/node/base/promptRenderer.ts', + 'extensions/copilot/src/extension/prompts/node/test/utils.ts', + 'extensions/copilot/src/extension/replay/common/chatReplayResponses.ts', + 'extensions/copilot/src/extension/replay/node/replayParser.ts', + 'extensions/copilot/src/extension/replay/vscode-node/replayDebugSession.ts', + 'extensions/copilot/src/extension/review/node/githubReviewAgent.ts', + 'extensions/copilot/src/extension/test/node/services.ts', + 'extensions/copilot/src/extension/test/vscode-node/extension.test.ts', + 'extensions/copilot/src/extension/test/vscode-node/sanity.sanity-test.ts', + 'extensions/copilot/src/extension/test/vscode-node/session.test.ts', + 'extensions/copilot/src/extension/tools/common/toolSchemaNormalizer.ts', + 'extensions/copilot/src/extension/tools/common/toolsService.ts', + 'extensions/copilot/src/extension/typescriptContext/common/serverProtocol.ts', + 'extensions/copilot/src/extension/typescriptContext/serverPlugin/src/common/baseContextProviders.ts', + 'extensions/copilot/src/extension/typescriptContext/serverPlugin/src/common/contextProvider.ts', + 'extensions/copilot/src/extension/typescriptContext/serverPlugin/src/common/protocol.ts', + 'extensions/copilot/src/extension/typescriptContext/serverPlugin/src/common/typescripts.ts', + 'extensions/copilot/src/extension/typescriptContext/serverPlugin/src/common/utils.ts', + 'extensions/copilot/src/extension/typescriptContext/vscode-node/inspector.ts', + 'extensions/copilot/src/extension/typescriptContext/vscode-node/languageContextService.ts', + 'extensions/copilot/src/extension/workspaceRecorder/vscode-node/workspaceListenerService.ts', + 'extensions/copilot/src/extension/workspaceSemanticSearch/node/semanticSearchTextSearchProvider.ts', + 'extensions/copilot/src/lib/node/chatLibMain.ts', + 'extensions/copilot/src/platform/authentication/test/node/simulationTestCopilotTokenManager.ts', + 'extensions/copilot/src/platform/chat/common/blockedExtensionService.ts', + 'extensions/copilot/src/platform/chunking/common/chunkingEndpointClientImpl.ts', + 'extensions/copilot/src/platform/commands/common/mockRunCommandExecutionService.ts', + 'extensions/copilot/src/platform/commands/common/runCommandExecutionService.ts', + 'extensions/copilot/src/platform/commands/vscode/runCommandExecutionServiceImpl.ts', + 'extensions/copilot/src/platform/configuration/common/configurationService.ts', + 'extensions/copilot/src/platform/configuration/common/validator.ts', + 'extensions/copilot/src/platform/configuration/test/common/inMemoryConfigurationService.ts', + 'extensions/copilot/src/platform/configuration/vscode/configurationServiceImpl.ts', + 'extensions/copilot/src/platform/customInstructions/common/customInstructionsService.ts', + 'extensions/copilot/src/platform/debug/vscode/debugOutputListener.ts', + 'extensions/copilot/src/platform/diff/node/diffWorkerMain.ts', + 'extensions/copilot/src/platform/editing/common/notebookDocumentSnapshot.ts', + 'extensions/copilot/src/platform/editing/common/textDocumentSnapshot.ts', + 'extensions/copilot/src/platform/embeddings/common/embeddingsGrouper.ts', + 'extensions/copilot/src/platform/embeddings/common/embeddingsIndex.ts', + 'extensions/copilot/src/platform/embeddings/common/remoteEmbeddingsComputer.ts', + 'extensions/copilot/src/platform/endpoint/node/modelMetadataFetcher.ts', + 'extensions/copilot/src/platform/endpoint/test/node/openaiCompatibleEndpoint.ts', + 'extensions/copilot/src/platform/env/common/packagejson.ts', + 'extensions/copilot/src/platform/extensions/common/extensionsService.ts', + 'extensions/copilot/src/platform/filesystem/common/fileSystemService.ts', + 'extensions/copilot/src/platform/github/common/githubService.ts', + 'extensions/copilot/src/platform/github/common/nullOctokitServiceImpl.ts', + 'extensions/copilot/src/platform/inlineEdits/common/dataTypes/edit.ts', + 'extensions/copilot/src/platform/inlineEdits/common/dataTypes/textEditLengthHelper/length.ts', + 'extensions/copilot/src/platform/inlineEdits/common/editReason.ts', + 'extensions/copilot/src/platform/inlineEdits/common/statelessNextEditProvider.ts', + 'extensions/copilot/src/platform/inlineEdits/common/utils/observable.ts', + 'extensions/copilot/src/platform/languages/common/languageDiagnosticsService.ts', + 'extensions/copilot/src/platform/log/common/logExecTime.ts', + 'extensions/copilot/src/platform/log/common/logService.ts', + 'extensions/copilot/src/platform/log/vscode/outputChannelLogTarget.ts', + 'extensions/copilot/src/platform/nesFetch/common/completionsFetchService.ts', + 'extensions/copilot/src/platform/nesFetch/node/completionsFetchServiceImpl.ts', + 'extensions/copilot/src/platform/networking/common/fetch.ts', + 'extensions/copilot/src/platform/networking/common/fetcherService.ts', + 'extensions/copilot/src/platform/networking/common/networking.ts', + 'extensions/copilot/src/platform/networking/common/openai.ts', + 'extensions/copilot/src/platform/networking/node/baseFetchFetcher.ts', + 'extensions/copilot/src/platform/networking/node/chatStream.ts', + 'extensions/copilot/src/platform/networking/node/fetcherFallback.ts', + 'extensions/copilot/src/platform/networking/node/nodeFetchFetcher.ts', + 'extensions/copilot/src/platform/networking/node/nodeFetcher.ts', + 'extensions/copilot/src/platform/networking/node/stream.ts', + 'extensions/copilot/src/platform/networking/node/test/nodeFetcherService.ts', + 'extensions/copilot/src/platform/networking/vscode-node/electronFetcher.ts', + 'extensions/copilot/src/platform/networking/vscode-node/fetcherServiceImpl.ts', + 'extensions/copilot/src/platform/notification/common/notificationService.ts', + 'extensions/copilot/src/platform/notification/vscode/notificationServiceImpl.ts', + 'extensions/copilot/src/platform/openai/node/fetch.ts', + 'extensions/copilot/src/platform/parser/node/nodes.ts', + 'extensions/copilot/src/platform/parser/node/parserServiceImpl.ts', + 'extensions/copilot/src/platform/parser/node/parserWorker.ts', + 'extensions/copilot/src/platform/parser/node/treeSitterQueries.ts', + 'extensions/copilot/src/platform/remoteCodeSearch/common/githubCodeSearchService.ts', + 'extensions/copilot/src/platform/remoteSearch/node/codeOrDocsSearchClientImpl.ts', + 'extensions/copilot/src/platform/review/vscode/reviewServiceImpl.ts', + 'extensions/copilot/src/platform/scopeSelection/vscode-node/scopeSelectionImpl.ts', + 'extensions/copilot/src/platform/snippy/common/snippyTypes.ts', + 'extensions/copilot/src/platform/survey/vscode/surveyServiceImpl.ts', + 'extensions/copilot/src/platform/tasks/vscode/tasksService.ts', + 'extensions/copilot/src/platform/telemetry/common/failingTelemetryReporter.ts', + 'extensions/copilot/src/platform/telemetry/common/telemetryData.ts', + 'extensions/copilot/src/platform/telemetry/node/azureInsightsReporter.ts', + 'extensions/copilot/src/platform/telemetry/node/spyingTelemetryService.ts', + 'extensions/copilot/src/platform/terminal/common/terminalService.ts', + 'extensions/copilot/src/platform/terminal/vscode/terminalServiceImpl.ts', + 'extensions/copilot/src/platform/test/common/endpointTestFixtures.ts', + 'extensions/copilot/src/platform/test/common/testExtensionsService.ts', + 'extensions/copilot/src/platform/test/node/extensionContext.ts', + 'extensions/copilot/src/platform/test/node/fetcher.ts', + 'extensions/copilot/src/platform/test/node/services.ts', + 'extensions/copilot/src/platform/test/node/simulationWorkspace.ts', + 'extensions/copilot/src/platform/test/node/telemetry.ts', + 'extensions/copilot/src/platform/test/node/testWorkbenchService.ts', + 'extensions/copilot/src/platform/testing/common/nullWorkspaceMutationManager.ts', + 'extensions/copilot/src/platform/thinking/common/thinking.ts', + 'extensions/copilot/src/platform/tokenizer/node/tikTokenizerWorker.ts', + 'extensions/copilot/src/platform/tokenizer/node/tokenizer.ts', + 'extensions/copilot/src/platform/workbench/common/workbenchService.ts', + 'extensions/copilot/src/platform/workbench/vscode/workbenchServiceImpt.ts', + 'extensions/copilot/src/platform/workspaceChunkSearch/node/nullWorkspaceFileIndex.ts', + 'extensions/copilot/src/platform/workspaceChunkSearch/node/tfidfChunkSearch.ts', + 'extensions/copilot/src/platform/workspaceChunkSearch/node/workspaceFileIndex.ts', + 'extensions/copilot/src/platform/workspaceRecorder/common/resolvedRecording/resolvedRecording.ts', + 'extensions/copilot/src/util/common/async.ts', + 'extensions/copilot/src/util/common/cache.ts', + 'extensions/copilot/src/util/common/chatResponseStreamImpl.ts', + 'extensions/copilot/src/util/common/debounce.ts', + 'extensions/copilot/src/util/common/debugValueEditorGlobals.ts', + 'extensions/copilot/src/util/common/diff.ts', + 'extensions/copilot/src/util/common/progress.ts', + 'extensions/copilot/src/util/common/test/shims/chatTypes.ts', + 'extensions/copilot/src/util/common/test/shims/editing.ts', + 'extensions/copilot/src/util/common/test/shims/l10n.ts', + 'extensions/copilot/src/util/common/test/shims/notebookDocument.ts', + 'extensions/copilot/src/util/common/test/shims/vscodeTypesShim.ts', + 'extensions/copilot/src/util/common/test/simpleMock.ts', + 'extensions/copilot/src/util/common/timeTravelScheduler.ts', + 'extensions/copilot/src/util/common/types.ts', + 'extensions/copilot/src/util/node/worker.ts', + ], + languageOptions: { + parser: tseslint.parser, + }, + plugins: { + '@typescript-eslint': tseslint.plugin, + }, + rules: { + '@typescript-eslint/no-explicit-any': [ + 'warn', + { + 'fixToUnknown': true + } + ] + } + }, + // copilot extension - chatLibMain exception + { + files: [ + 'extensions/copilot/src/lib/node/chatLibMain.ts', + ], + rules: { + 'import/no-restricted-paths': 'off' + } + }, // Allow querySelector/querySelectorAll in test files - it's acceptable for test assertions { files: [ diff --git a/extensions/copilot/.eslint-ignore b/extensions/copilot/.eslint-ignore deleted file mode 100644 index 86f3fb98c20ae..0000000000000 --- a/extensions/copilot/.eslint-ignore +++ /dev/null @@ -1,31 +0,0 @@ -node_modules -dist -coverage -lint-staged.config.js -vite.config.ts -**/vscode.proposed.*.ts -**/vscode.d.ts -.esbuild/extension.esbuild.ts -test/simulation/fixtures/** -test/scenarios/** -.simulation/** -.eslintplugin/** -chat-lib/** -test/aml/out/** -.vscode-test/** - -# ignore vs -src/util/vs/** - -# ignore test fixtures -src/platform/parser/test/node/fixtures/** -src/extension/test/node/fixtures/** -src/extension/prompts/node/test/fixtures/** - -# TypeScript server plugin -src/extension/typescriptContext/serverPlugin/fixtures/** -src/extension/typescriptContext/serverPlugin/lib/** -src/extension/typescriptContext/serverPlugin/dist/** - -# Ignore Built test-extension -.vscode/extensions/test-extension/dist/** diff --git a/extensions/copilot/.eslintplugin/no-unexternalized-strings.ts b/extensions/copilot/.eslintplugin/no-unexternalized-strings.ts deleted file mode 100644 index a7065cb2a0db9..0000000000000 --- a/extensions/copilot/.eslintplugin/no-unexternalized-strings.ts +++ /dev/null @@ -1,192 +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 { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils'; -import * as eslint from 'eslint'; -import type * as ESTree from 'estree'; - -function isStringLiteral(node: TSESTree.Node | ESTree.Node | null | undefined): node is TSESTree.StringLiteral { - return !!node && node.type === AST_NODE_TYPES.Literal && typeof node.value === 'string'; -} - -function isDoubleQuoted(node: TSESTree.StringLiteral): boolean { - return node.raw[0] === '"' && node.raw[node.raw.length - 1] === '"'; -} - -/** - * Enable bulk fixing double-quoted strings to single-quoted strings with the --fix eslint flag - * - * Disabled by default as this is often not the desired fix. Instead the string should be localized. However it is - * useful for bulk conversations of existing code. - */ -const enableDoubleToSingleQuoteFixes = false; - - -export default new class NoUnexternalizedStrings implements eslint.Rule.RuleModule { - - private static _rNlsKeys = /^[_a-zA-Z0-9][ .\-_a-zA-Z0-9]*$/; - - readonly meta: eslint.Rule.RuleMetaData = { - messages: { - doubleQuoted: 'Only use double-quoted strings for externalized strings.', - badKey: 'The key \'{{key}}\' doesn\'t conform to a valid localize identifier.', - duplicateKey: 'Duplicate key \'{{key}}\' with different message value.', - badMessage: 'Message argument to \'{{message}}\' must be a string literal.' - }, - schema: false, - fixable: enableDoubleToSingleQuoteFixes ? 'code' : undefined, - }; - - create(context: eslint.Rule.RuleContext): eslint.Rule.RuleListener { - - const externalizedStringLiterals = new Map(); - const doubleQuotedStringLiterals = new Set(); - - function collectDoubleQuotedStrings(node: ESTree.Literal) { - if (isStringLiteral(node) && isDoubleQuoted(node)) { - doubleQuotedStringLiterals.add(node); - } - } - - function visitLocalizeCall(node: TSESTree.CallExpression) { - - // localize(key, message) - const [keyNode, messageNode] = node.arguments; - - // (1) - // extract key so that it can be checked later - let key: string | undefined; - if (isStringLiteral(keyNode)) { - doubleQuotedStringLiterals.delete(keyNode); - key = keyNode.value; - - } else if (keyNode.type === AST_NODE_TYPES.ObjectExpression) { - for (const property of keyNode.properties) { - if (property.type === AST_NODE_TYPES.Property && !property.computed) { - if (property.key.type === AST_NODE_TYPES.Identifier && property.key.name === 'key') { - if (isStringLiteral(property.value)) { - doubleQuotedStringLiterals.delete(property.value); - key = property.value.value; - break; - } - } - } - } - } - if (typeof key === 'string') { - let array = externalizedStringLiterals.get(key); - if (!array) { - array = []; - externalizedStringLiterals.set(key, array); - } - array.push({ call: node, message: messageNode }); - } - - // (2) - // remove message-argument from doubleQuoted list and make - // sure it is a string-literal - doubleQuotedStringLiterals.delete(messageNode); - if (!isStringLiteral(messageNode)) { - context.report({ - loc: messageNode.loc, - messageId: 'badMessage', - data: { message: context.getSourceCode().getText(node as ESTree.Node) } - }); - } - } - - function visitL10NCall(node: TSESTree.CallExpression) { - - // localize(key, message) - const [messageNode] = (node as TSESTree.CallExpression).arguments; // remove message-argument from doubleQuoted list and make - // sure it is a string-literal - if (isStringLiteral(messageNode)) { - doubleQuotedStringLiterals.delete(messageNode); - } else if (messageNode.type === AST_NODE_TYPES.ObjectExpression) { - for (const prop of messageNode.properties) { - if (prop.type === AST_NODE_TYPES.Property) { - if (prop.key.type === AST_NODE_TYPES.Identifier && prop.key.name === 'message') { - doubleQuotedStringLiterals.delete(prop.value); - break; - } - } - } - } - } - - function reportBadStringsAndBadKeys() { - // (1) - // report all strings that are in double quotes - for (const node of doubleQuotedStringLiterals) { - context.report({ - loc: node.loc, - messageId: 'doubleQuoted', - fix: enableDoubleToSingleQuoteFixes ? (fixer) => { - // Get the raw string content, unescaping any escaped quotes - const content = (node as ESTree.SimpleLiteral).raw! - .slice(1, -1) - .replace(/(? 1) { - for (let i = 1; i < values.length; i++) { - if (context.getSourceCode().getText(values[i - 1].message as ESTree.Node) !== context.getSourceCode().getText(values[i].message as ESTree.Node)) { - context.report({ loc: values[i].call.loc, messageId: 'duplicateKey', data: { key } }); - } - } - } - } - } - - return { - ['Literal']: (node: ESTree.Literal) => collectDoubleQuotedStrings(node), - ['ExpressionStatement[directive] Literal:exit']: (node: TSESTree.Literal) => doubleQuotedStringLiterals.delete(node), - - // localize(...) - ['CallExpression[callee.type="MemberExpression"][callee.object.name="nls"][callee.property.name="localize"]:exit']: (node: TSESTree.CallExpression) => visitLocalizeCall(node), - - // localize2(...) - ['CallExpression[callee.type="MemberExpression"][callee.object.name="nls"][callee.property.name="localize2"]:exit']: (node: TSESTree.CallExpression) => visitLocalizeCall(node), - - // vscode.l10n.t(...) - ['CallExpression[callee.type="MemberExpression"][callee.object.property.name="l10n"][callee.property.name="t"]:exit']: (node: TSESTree.CallExpression) => visitL10NCall(node), - - // l10n.t(...) - ['CallExpression[callee.object.name="l10n"][callee.property.name="t"]:exit']: (node: TSESTree.CallExpression) => visitL10NCall(node), - - ['CallExpression[callee.name="localize"][arguments.length>=2]:exit']: (node: TSESTree.CallExpression) => visitLocalizeCall(node), - ['CallExpression[callee.name="localize2"][arguments.length>=2]:exit']: (node: TSESTree.CallExpression) => visitLocalizeCall(node), - ['Program:exit']: reportBadStringsAndBadKeys, - }; - } -}; diff --git a/extensions/copilot/eslint.config.mjs b/extensions/copilot/eslint.config.mjs deleted file mode 100644 index 7edfb1982d931..0000000000000 --- a/extensions/copilot/eslint.config.mjs +++ /dev/null @@ -1,538 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -// @ts-check -import stylisticEslint from '@stylistic/eslint-plugin'; -import tsEslint from '@typescript-eslint/eslint-plugin'; -import tsParser from '@typescript-eslint/parser'; -import importEslint from 'eslint-plugin-import'; -import jsdocEslint from 'eslint-plugin-jsdoc'; -import fs from 'fs'; -import { builtinModules } from 'module'; -import path from 'path'; -import tseslint from 'typescript-eslint'; -import { fileURLToPath } from 'url'; - -import headerEslint from 'eslint-plugin-header'; -headerEslint.rules.header.meta.schema = false; - -import * as localEslint from './.eslintplugin/index.ts'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const ignores = fs.readFileSync(path.join(__dirname, '.eslint-ignore'), 'utf8') - .toString() - .split(/\r\n|\n/) - .filter(line => line && !line.startsWith('#')); - -export default tseslint.config( - // Global ignores - { - ignores: [ - ...ignores, - '!**/.eslint-plugin-local/**/*' - ], - }, - // All js/ts files - { - files: [ - '**/*.{js,jsx,mjs,cjs,ts,tsx}', - ], - ignores: [ - './src/extension/completions-core/**/testdata/*', - ], - languageOptions: { - parser: tsParser, - }, - plugins: { - '@stylistic': stylisticEslint, - 'header': headerEslint, - }, - rules: { - 'indent': [ - 'error', - 'tab', - { - ignoredNodes: [ - 'SwitchCase', - 'ClassDeclaration', - 'TemplateLiteral *', // Conflicts with tsfmt - 'CallExpression > ArrowFunctionExpression', // Conflicts with tsfmt - 'CallExpression > ArrowFunctionExpression > BlockStatement', // Conflicts with tsfmt - 'NewExpression > ArrowFunctionExpression', // Conflicts with tsfmt - 'NewExpression > ArrowFunctionExpression > BlockStatement' // Conflicts with tsfmt - ] - } - ], - 'constructor-super': 'error', - 'curly': 'error', - 'eqeqeq': 'error', - 'prefer-const': [ - 'error', - { - destructuring: 'all' - } - ], - 'no-buffer-constructor': 'error', - 'no-caller': 'error', - 'no-case-declarations': 'error', - 'no-debugger': 'error', - 'no-duplicate-case': 'error', - 'no-duplicate-imports': 'error', - 'no-eval': 'error', - 'no-async-promise-executor': 'error', - 'no-extra-semi': 'error', - 'no-new-wrappers': 'error', - 'no-redeclare': 'off', - 'no-sparse-arrays': 'error', - 'no-throw-literal': 'error', - 'no-unsafe-finally': 'error', - 'no-unused-labels': 'error', - 'no-restricted-globals': [ - 'error', - 'name', - 'length', - 'event', - 'closed', - 'external', - 'status', - 'origin', - 'orientation', - 'context' - ], // non-complete list of globals that are easy to access unintentionally - 'no-var': 'error', - 'semi': 'error', - 'header/header': [ - 'error', - 'block', - [ - '---------------------------------------------------------------------------------------------', - ' * Copyright (c) Microsoft Corporation. All rights reserved.', - ' * Licensed under the MIT License. See License.txt in the project root for license information.', - ' *--------------------------------------------------------------------------------------------' - ] - ] - }, - settings: { - 'import/resolver': { - typescript: { - extensions: ['.ts', '.tsx'] - } - } - }, - }, - // All ts files - { - files: [ - '**/*.{ts,tsx}', - ], - languageOptions: { - parser: tsParser, - }, - plugins: { - '@typescript-eslint': tsEslint, - '@stylistic': stylisticEslint, - 'jsdoc': jsdocEslint, - }, - rules: { - 'jsdoc/no-types': 'error', - '@stylistic/member-delimiter-style': 'error', - '@typescript-eslint/naming-convention': [ - 'error', - { - selector: 'class', - format: ['PascalCase'] - } - ], - }, - settings: { - 'import/resolver': { - typescript: { - extensions: ['.ts', '.tsx'] - } - } - }, - }, - // Main extension sources - { - files: [ - 'src/**/*.{ts,tsx}', - 'test/**/*.{ts,tsx}', - ], - ignores: [ - '**/.esbuild.ts', - './src/extension/completions-core/vscode-node/bridge/src/completionsTelemetryServiceBridge.ts', - ], - languageOptions: { - parser: tseslint.parser, - }, - plugins: { - 'import': importEslint, - 'local': localEslint, - }, - rules: { - 'no-restricted-imports': [ - 'error', - // node: builtins - ...builtinModules, - // node: dependencies - '@humanwhocodes/gitignore-to-minimatch', - '@vscode/extension-telemetry', - 'applicationinsights', - 'ignore', - 'isbinaryfile', - 'minimatch', - 'source-map-support', - 'vscode-tas-client', - 'web-tree-sitter' - ], - 'import/no-restricted-paths': [ - 'error', - { - zones: [ - { - target: '**/common/**', - from: [ - '**/vscode/**', - '**/node/**', - '**/vscode-node/**', - '**/worker/**', - '**/vscode-worker/**' - ] - }, - { - target: '**/vscode/**', - from: [ - '**/node/**', - '**/vscode-node/**', - '**/worker/**', - '**/vscode-worker/**' - ] - }, - { - target: '**/node/**', - from: [ - '**/vscode/**', - '**/vscode-node/**', - '**/worker/**', - '**/vscode-worker/**' - ] - }, - { - target: '**/vscode-node/**', - from: [ - '**/worker/**', - '**/vscode-worker/**' - ] - }, - { - target: '**/worker/**', - from: [ - '**/vscode/**', - '**/node/**', - '**/vscode-node/**', - '**/vscode-worker/**' - ] - }, - { - target: '**/vscode-worker/**', - from: [ - '**/node/**', - '**/vscode-node/**' - ] - }, - { - target: './src/', - from: './test/' - }, - { - target: './src/shared-fetch-utils', - from: ['./src/extension', './src/platform', './src/util', './src/lib'] - }, - { - target: './src/util', - from: ['./src/platform', './src/extension'] - }, - { - target: './src/platform', - from: ['./src/extension'] - }, - { - target: ['./test', '!./test/base/extHostContext/*.ts'], - from: ['**/vscode-node/**', '**/vscode-worker/**'] - }, - { - target: 'src/!(lib)/**', - from: './src/lib' - } - ] - } - ], - 'local/no-instanceof-uri': ['error'], - 'local/no-test-imports': ['error'], - 'local/no-runtime-import': [ - 'error', - { - test: ['vscode'], - 'src/**/common/**/*': ['vscode'], - 'src/**/node/**/*': ['vscode'] - } - ], - 'local/no-funny-filename': ['error'], - 'local/no-bad-gdpr-comment': ['error'], - 'local/no-gdpr-event-name-mismatch': ['error'], - 'local/no-unlayered-files': ['error'], - 'local/no-restricted-copilot-pr-string': [ - 'error', - { - className: 'GitHubPullRequestProviders', - string: 'Generate with Copilot' - } - ], - 'local/no-nls-localize': ['error'], - 'local/no-unexternalized-strings': ['error'], - } - }, - { - files: ['**/{vscode-node,node}/**/*.ts', '**/{vscode-node,node}/**/*.tsx'], - rules: { - 'no-restricted-imports': 'off' - } - }, - { - files: ['**/*.js'], - rules: { - 'jsdoc/no-types': 'off' - } - }, - { - files: ['src/extension/**/*.tsx'], - rules: { - 'local/no-missing-linebreak': 'error' - } - }, - { - files: ['**/*.test.ts', '**/*.test.tsx'], - rules: { - 'local/no-test-only': 'error' - } - }, - { - files: [ - 'test/**', - 'src/vscodeTypes.ts', - 'script/**', - 'src/extension/*.d.ts', - 'build/**' - ], - rules: { - 'local/no-unlayered-files': 'off', - 'no-restricted-imports': 'off' - } - }, - // no-explicit-any - { - files: [ - 'src/**/*.ts', - ], - ignores: [ - 'src/util/vs/**/*.ts', // vendored code - 'src/**/*.spec.ts', // allow in tests - './src/extension/byok/common/anthropicMessageConverter.ts', - './src/extension/byok/common/geminiFunctionDeclarationConverter.ts', - './src/extension/byok/common/geminiMessageConverter.ts', - './src/extension/byok/vscode-node/anthropicProvider.ts', - './src/extension/byok/vscode-node/geminiNativeProvider.ts', - './src/extension/byok/vscode-node/ollamaProvider.ts', - './src/extension/chatSessions/vscode-node/copilotCloudSessionContentBuilder.ts', - './src/extension/chatSessions/vscode-node/copilotCloudSessionsProvider.ts', - './src/extension/codeBlocks/node/codeBlockProcessor.ts', - './src/extension/codeBlocks/vscode-node/provider.ts', - './src/extension/configuration/vscode-node/configurationMigration.ts', - './src/extension/context/node/resolvers/genericInlineIntentInvocation.ts', - './src/extension/context/node/resolvers/genericPanelIntentInvocation.ts', - './src/extension/context/node/resolvers/inlineFixIntentInvocation.ts', - './src/extension/context/node/resolvers/promptWorkspaceLabels.ts', - './src/extension/contextKeys/vscode-node/contextKeys.contribution.ts', - './src/extension/conversation/vscode-node/userActions.ts', - './src/extension/extension/vscode/services.ts', - './src/extension/inlineChat/node/rendererVisualization.ts', - './src/extension/inlineChat/vscode-node/inlineChatCommands.ts', - './src/extension/inlineEdits/common/observableWorkspaceRecordingReplayer.ts', - './src/extension/inlineEdits/vscode-node/parts/vscodeWorkspace.ts', - './src/extension/intents/node/editCodeIntent.ts', - './src/extension/intents/node/editCodeStep.ts', - './src/extension/intents/node/fixIntent.ts', - './src/extension/intents/node/newIntent.ts', - './src/extension/intents/node/searchIntent.ts', - './src/extension/languageContextProvider/vscode-node/languageContextProviderService.ts', - './src/extension/linkify/common/commands.ts', - './src/extension/linkify/common/responseStreamWithLinkification.ts', - './src/extension/linkify/test/node/util.ts', - './src/extension/log/vscode-node/loggingActions.ts', - './src/extension/log/vscode-node/requestLogTree.ts', - './src/extension/mcp/test/vscode-node/util.ts', - './src/extension/mcp/vscode-node/commands.ts', - './src/extension/mcp/vscode-node/nuget.ts', - './src/extension/onboardDebug/node/copilotDebugWorker/rpc.ts', - './src/extension/onboardDebug/node/parseLaunchConfigFromResponse.ts', - './src/extension/onboardDebug/vscode-node/copilotDebugCommandHandle.ts', - './src/extension/prompt/common/toolCallRound.ts', - './src/extension/prompt/node/chatMLFetcher.ts', - './src/extension/prompt/node/chatParticipantTelemetry.ts', - './src/extension/prompt/node/editGeneration.ts', - './src/extension/prompt/node/intents.ts', - './src/extension/prompt/node/todoListContextProvider.ts', - './src/extension/prompt/vscode-node/endpointProviderImpl.ts', - './src/extension/prompt/vscode-node/requestLoggerImpl.ts', - './src/extension/prompts/node/agent/promptRegistry.ts', - './src/extension/prompts/node/base/promptElement.ts', - './src/extension/prompts/node/base/promptRenderer.ts', - './src/extension/prompts/node/test/utils.ts', - './src/extension/replay/common/chatReplayResponses.ts', - './src/extension/replay/node/replayParser.ts', - './src/extension/replay/vscode-node/replayDebugSession.ts', - './src/extension/review/node/githubReviewAgent.ts', - './src/extension/test/node/services.ts', - './src/extension/test/vscode-node/extension.test.ts', - './src/extension/test/vscode-node/sanity.sanity-test.ts', - './src/extension/test/vscode-node/session.test.ts', - './src/extension/tools/common/toolSchemaNormalizer.ts', - './src/extension/tools/common/toolsService.ts', - './src/extension/typescriptContext/common/serverProtocol.ts', - './src/extension/typescriptContext/serverPlugin/src/common/baseContextProviders.ts', - './src/extension/typescriptContext/serverPlugin/src/common/contextProvider.ts', - './src/extension/typescriptContext/serverPlugin/src/common/protocol.ts', - './src/extension/typescriptContext/serverPlugin/src/common/typescripts.ts', - './src/extension/typescriptContext/serverPlugin/src/common/utils.ts', - './src/extension/typescriptContext/vscode-node/inspector.ts', - './src/extension/typescriptContext/vscode-node/languageContextService.ts', - './src/extension/workspaceRecorder/vscode-node/workspaceListenerService.ts', - './src/extension/workspaceSemanticSearch/node/semanticSearchTextSearchProvider.ts', - './src/lib/node/chatLibMain.ts', - './src/platform/authentication/test/node/simulationTestCopilotTokenManager.ts', - './src/platform/chat/common/blockedExtensionService.ts', - './src/platform/chunking/common/chunkingEndpointClientImpl.ts', - './src/platform/commands/common/mockRunCommandExecutionService.ts', - './src/platform/commands/common/runCommandExecutionService.ts', - './src/platform/commands/vscode/runCommandExecutionServiceImpl.ts', - './src/platform/configuration/common/configurationService.ts', - './src/platform/configuration/common/validator.ts', - './src/platform/configuration/test/common/inMemoryConfigurationService.ts', - './src/platform/configuration/vscode/configurationServiceImpl.ts', - './src/platform/customInstructions/common/customInstructionsService.ts', - './src/platform/debug/vscode/debugOutputListener.ts', - './src/platform/diff/node/diffWorkerMain.ts', - './src/platform/editing/common/notebookDocumentSnapshot.ts', - './src/platform/editing/common/textDocumentSnapshot.ts', - './src/platform/embeddings/common/embeddingsGrouper.ts', - './src/platform/embeddings/common/embeddingsIndex.ts', - './src/platform/embeddings/common/remoteEmbeddingsComputer.ts', - './src/platform/endpoint/node/modelMetadataFetcher.ts', - './src/platform/endpoint/test/node/openaiCompatibleEndpoint.ts', - './src/platform/env/common/packagejson.ts', - './src/platform/extensions/common/extensionsService.ts', - './src/platform/filesystem/common/fileSystemService.ts', - './src/platform/github/common/githubService.ts', - './src/platform/github/common/nullOctokitServiceImpl.ts', - './src/platform/inlineEdits/common/dataTypes/edit.ts', - './src/platform/inlineEdits/common/dataTypes/textEditLengthHelper/length.ts', - './src/platform/inlineEdits/common/editReason.ts', - './src/platform/inlineEdits/common/statelessNextEditProvider.ts', - './src/platform/inlineEdits/common/utils/observable.ts', - './src/platform/languages/common/languageDiagnosticsService.ts', - './src/platform/log/common/logExecTime.ts', - './src/platform/log/common/logService.ts', - './src/platform/log/vscode/outputChannelLogTarget.ts', - './src/platform/nesFetch/common/completionsFetchService.ts', - './src/platform/nesFetch/node/completionsFetchServiceImpl.ts', - './src/platform/networking/common/fetch.ts', - './src/platform/networking/common/fetcherService.ts', - './src/platform/networking/common/networking.ts', - './src/platform/networking/common/openai.ts', - './src/platform/networking/node/baseFetchFetcher.ts', - './src/platform/networking/node/chatStream.ts', - './src/platform/networking/node/fetcherFallback.ts', - './src/platform/networking/node/nodeFetchFetcher.ts', - './src/platform/networking/node/nodeFetcher.ts', - './src/platform/networking/node/stream.ts', - './src/platform/networking/node/test/nodeFetcherService.ts', - './src/platform/networking/vscode-node/electronFetcher.ts', - './src/platform/networking/vscode-node/fetcherServiceImpl.ts', - './src/platform/notification/common/notificationService.ts', - './src/platform/notification/vscode/notificationServiceImpl.ts', - './src/platform/openai/node/fetch.ts', - './src/platform/parser/node/nodes.ts', - './src/platform/parser/node/parserServiceImpl.ts', - './src/platform/parser/node/parserWorker.ts', - './src/platform/parser/node/treeSitterQueries.ts', - './src/platform/remoteCodeSearch/common/githubCodeSearchService.ts', - './src/platform/remoteSearch/node/codeOrDocsSearchClientImpl.ts', - './src/platform/review/vscode/reviewServiceImpl.ts', - './src/platform/scopeSelection/vscode-node/scopeSelectionImpl.ts', - './src/platform/snippy/common/snippyTypes.ts', - './src/platform/survey/vscode/surveyServiceImpl.ts', - './src/platform/tasks/vscode/tasksService.ts', - './src/platform/telemetry/common/failingTelemetryReporter.ts', - './src/platform/telemetry/common/telemetryData.ts', - './src/platform/telemetry/node/azureInsightsReporter.ts', - './src/platform/telemetry/node/spyingTelemetryService.ts', - './src/platform/terminal/common/terminalService.ts', - './src/platform/terminal/vscode/terminalServiceImpl.ts', - './src/platform/test/common/endpointTestFixtures.ts', - './src/platform/test/common/testExtensionsService.ts', - './src/platform/test/node/extensionContext.ts', - './src/platform/test/node/fetcher.ts', - './src/platform/test/node/services.ts', - './src/platform/test/node/simulationWorkspace.ts', - './src/platform/test/node/telemetry.ts', - './src/platform/test/node/testWorkbenchService.ts', - './src/platform/testing/common/nullWorkspaceMutationManager.ts', - './src/platform/thinking/common/thinking.ts', - './src/platform/tokenizer/node/tikTokenizerWorker.ts', - './src/platform/tokenizer/node/tokenizer.ts', - './src/platform/workbench/common/workbenchService.ts', - './src/platform/workbench/vscode/workbenchServiceImpt.ts', - './src/platform/workspaceChunkSearch/node/nullWorkspaceFileIndex.ts', - './src/platform/workspaceChunkSearch/node/tfidfChunkSearch.ts', - './src/platform/workspaceChunkSearch/node/workspaceFileIndex.ts', - './src/platform/workspaceRecorder/common/resolvedRecording/resolvedRecording.ts', - './src/util/common/async.ts', - './src/util/common/cache.ts', - './src/util/common/chatResponseStreamImpl.ts', - './src/util/common/debounce.ts', - './src/util/common/debugValueEditorGlobals.ts', - './src/util/common/diff.ts', - './src/util/common/progress.ts', - './src/util/common/test/shims/chatTypes.ts', - './src/util/common/test/shims/editing.ts', - './src/util/common/test/shims/l10n.ts', - './src/util/common/test/shims/notebookDocument.ts', - './src/util/common/test/shims/vscodeTypesShim.ts', - './src/util/common/test/simpleMock.ts', - './src/util/common/timeTravelScheduler.ts', - './src/util/common/types.ts', - './src/util/node/worker.ts', - ], - languageOptions: { - parser: tseslint.parser, - }, - plugins: { - '@typescript-eslint': tseslint.plugin, - }, - rules: { - '@typescript-eslint/no-explicit-any': [ - 'warn', - { - 'fixToUnknown': true - } - ] - } - }, - { - files: ['./src/lib/node/chatLibMain.ts'], - rules: { - 'import/no-restricted-paths': 'off' - } - }, -); diff --git a/extensions/copilot/package-lock.json b/extensions/copilot/package-lock.json index 394b8dbcd09c1..a1c00a596d457 100644 --- a/extensions/copilot/package-lock.json +++ b/extensions/copilot/package-lock.json @@ -102,17 +102,10 @@ "dotenv": "^17.2.0", "electron": "^39.8.5", "esbuild": "0.27.2", - "eslint": "^9.30.0", - "eslint-import-resolver-typescript": "^4.4.4", - "eslint-plugin-header": "^3.1.1", - "eslint-plugin-import": "^2.32.0", - "eslint-plugin-jsdoc": "^51.3.4", - "eslint-plugin-no-only-tests": "^3.3.0", "fastq": "^1.19.1", "glob": "^11.1.0", "js-yaml": "^4.1.1", "keyv": "^5.3.2", - "lint-staged": "15.2.9", "minimist": "^1.2.8", "mobx": "^6.13.7", "mobx-react-lite": "^4.1.0", @@ -138,7 +131,6 @@ "ts-dedent": "^2.2.0", "tsx": "^4.20.3", "typescript": "^5.8.3", - "typescript-eslint": "^8.36.0", "typescript-formatter": "github:jrieken/typescript-formatter#497efb26bc40b5fa59a350e6eab17bce650a7e4b", "vite-plugin-top-level-await": "^1.5.0", "vite-plugin-wasm": "^3.5.0", @@ -156,15 +148,6 @@ "vscode": "^1.118.0" } }, - "node_modules/@aashutoshrathi/word-wrap": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz", - "integrity": "sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/@ampproject/remapping": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", @@ -844,40 +827,6 @@ "semver": "bin/semver.js" } }, - "node_modules/@emnapi/core": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.3.tgz", - "integrity": "sha512-4m62DuCE07lw01soJwPiBGC0nAww0Q+RY70VZ+n49yDIO13yyinhbWCeNnaob0lakDtWQzSdtNWzJeOJt2ma+g==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.0.2", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.5.0.tgz", - "integrity": "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.0.2.tgz", - "integrity": "sha512-5n3nTJblwRi8LlXkJ9eBzu+kZR8Yxcc7ubakyQTFzPMtIhFpUBRbsnc2Dv88IZDIbCDlBiWrknhB4Lsz7mg6BA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emotion/hash": { "version": "0.9.2", "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", @@ -885,23 +834,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@es-joy/jsdoccomment": { - "version": "0.52.0", - "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.52.0.tgz", - "integrity": "sha512-BXuN7BII+8AyNtn57euU2Yxo9yA/KUDNzrpXyi3pfqKmBhhysR6ZWOebFh3vyPoqA3/j1SOvGgucElMGwlXing==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.8", - "@typescript-eslint/types": "^8.34.1", - "comment-parser": "1.4.1", - "esquery": "^1.6.0", - "jsdoc-type-pratt-parser": "~4.1.0" - }, - "engines": { - "node": ">=20.11.0" - } - }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", @@ -1373,178 +1305,6 @@ "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, - "node_modules/@eslint/config-array": { - "version": "0.21.0", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", - "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^2.1.6", - "debug": "^4.3.1", - "minimatch": "^3.1.2" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/config-array/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/@eslint/config-helpers": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.0.tgz", - "integrity": "sha512-ViuymvFmcJi04qdZeDc2whTHryouGcDlaxPqarTD0ZE10ISpxGUVZGZDx4w01upyIynL3iu6IXH2bS1NhclQMw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/core": { - "version": "0.14.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.14.0.tgz", - "integrity": "sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", - "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/eslintrc/node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/@eslint/eslintrc/node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/@eslint/js": { - "version": "9.30.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.30.1.tgz", - "integrity": "sha512-zXhuECFlyep42KZUhWjfvsmXGX39W8K8LFb8AWXM9gSV9dQB+MrJGLKvW6Zw0Ggnbpw0VHTtrhFXYe3Gym18jg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - } - }, - "node_modules/@eslint/object-schema": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", - "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/plugin-kit": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.4.tgz", - "integrity": "sha512-Ul5l+lHEcw3L5+k8POx6r74mxEYKG5kOb6Xpy2gCRW6zweT6TEhAf8vhxGgjhqrd/VO/Dirhsb+1hNpD1ue9hw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.15.1", - "levn": "^0.4.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { - "version": "0.15.1", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.1.tgz", - "integrity": "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, "node_modules/@floating-ui/core": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.2.tgz", @@ -3434,44 +3194,6 @@ "hono": "^4" } }, - "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/node": { - "version": "0.16.6", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", - "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.3.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", - "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, "node_modules/@humanwhocodes/gitignore-to-minimatch": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@humanwhocodes/gitignore-to-minimatch/-/gitignore-to-minimatch-1.0.2.tgz", @@ -3481,33 +3203,6 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, "node_modules/@img/sharp-darwin-arm64": { "version": "0.34.5", "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", @@ -4243,19 +3938,6 @@ } } }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "0.2.11", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.11.tgz", - "integrity": "sha512-9DPkXtvHydrcOsopiYpUgPHpmj0HWZKMUnL2dZqpvC42lsratuBG06V5ipyno0fUek5VlFsNQ+AcFATSrJXgMA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.4.3", - "@emnapi/runtime": "^1.4.3", - "@tybys/wasm-util": "^0.9.0" - } - }, "node_modules/@nevware21/ts-async": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/@nevware21/ts-async/-/ts-async-0.5.4.tgz", @@ -5737,13 +5419,6 @@ "win32" ] }, - "node_modules/@rtsao/scc": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", - "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", - "dev": true, - "license": "MIT" - }, "node_modules/@secretlint/config-creator": { "version": "10.1.1", "resolved": "https://registry.npmjs.org/@secretlint/config-creator/-/config-creator-10.1.1.tgz", @@ -6398,17 +6073,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@tybys/wasm-util": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.9.0.tgz", - "integrity": "sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -6558,13 +6222,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/json5": { - "version": "0.0.29", - "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", - "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/keyv": { "version": "3.1.4", "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", @@ -7116,313 +6773,44 @@ "node": ">=18.0.0" } }, - "node_modules/@unrs/resolver-binding-android-arm-eabi": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.9.2.tgz", - "integrity": "sha512-tS+lqTU3N0kkthU+rYp0spAYq15DU8ld9kXkaKg9sbQqJNF+WPMuNHZQGCgdxrUOEO0j22RKMwRVhF1HTl+X8A==", - "cpu": [ - "arm" - ], + "node_modules/@vitest/coverage-v8": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz", + "integrity": "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ] + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "@bcoe/v8-coverage": "^1.0.2", + "ast-v8-to-istanbul": "^0.3.3", + "debug": "^4.4.1", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.1.7", + "magic-string": "^0.30.17", + "magicast": "^0.3.5", + "std-env": "^3.9.0", + "test-exclude": "^7.0.1", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "3.2.4", + "vitest": "3.2.4" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } }, - "node_modules/@unrs/resolver-binding-android-arm64": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.9.2.tgz", - "integrity": "sha512-MffGiZULa/KmkNjHeuuflLVqfhqLv1vZLm8lWIyeADvlElJ/GLSOkoUX+5jf4/EGtfwrNFcEaB8BRas03KT0/Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@unrs/resolver-binding-darwin-arm64": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.9.2.tgz", - "integrity": "sha512-dzJYK5rohS1sYl1DHdJ3mwfwClJj5BClQnQSyAgEfggbUwA9RlROQSSbKBLqrGfsiC/VyrDPtbO8hh56fnkbsQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@unrs/resolver-binding-darwin-x64": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.9.2.tgz", - "integrity": "sha512-gaIMWK+CWtXcg9gUyznkdV54LzQ90S3X3dn8zlh+QR5Xy7Y+Efqw4Rs4im61K1juy4YNb67vmJsCDAGOnIeffQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@unrs/resolver-binding-freebsd-x64": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.9.2.tgz", - "integrity": "sha512-S7QpkMbVoVJb0xwHFwujnwCAEDe/596xqY603rpi/ioTn9VDgBHnCCxh+UFrr5yxuMH+dliHfjwCZJXOPJGPnw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.9.2.tgz", - "integrity": "sha512-+XPUMCuCCI80I46nCDFbGum0ZODP5NWGiwS3Pj8fOgsG5/ctz+/zzuBlq/WmGa+EjWZdue6CF0aWWNv84sE1uw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.9.2.tgz", - "integrity": "sha512-sqvUyAd1JUpwbz33Ce2tuTLJKM+ucSsYpPGl2vuFwZnEIg0CmdxiZ01MHQ3j6ExuRqEDUCy8yvkDKvjYFPb8Zg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.9.2.tgz", - "integrity": "sha512-UYA0MA8ajkEDCFRQdng/FVx3F6szBvk3EPnkTTQuuO9lV1kPGuTB+V9TmbDxy5ikaEgyWKxa4CI3ySjklZ9lFA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm64-musl": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.9.2.tgz", - "integrity": "sha512-P/CO3ODU9YJIHFqAkHbquKtFst0COxdphc8TKGL5yCX75GOiVpGqd1d15ahpqu8xXVsqP4MGFP2C3LRZnnL5MA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.9.2.tgz", - "integrity": "sha512-uKStFlOELBxBum2s1hODPtgJhY4NxYJE9pAeyBgNEzHgTqTiVBPjfTlPFJkfxyTjQEuxZbbJlJnMCrRgD7ubzw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.9.2.tgz", - "integrity": "sha512-LkbNnZlhINfY9gK30AHs26IIVEZ9PEl9qOScYdmY2o81imJYI4IMnJiW0vJVtXaDHvBvxeAgEy5CflwJFIl3tQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.9.2.tgz", - "integrity": "sha512-vI+e6FzLyZHSLFNomPi+nT+qUWN4YSj8pFtQZSFTtmgFoxqB6NyjxSjAxEC1m93qn6hUXhIsh8WMp+fGgxCoRg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.9.2.tgz", - "integrity": "sha512-sSO4AlAYhSM2RAzBsRpahcJB1msc6uYLAtP6pesPbZtptF8OU/CbCPhSRW6cnYOGuVmEmWVW5xVboAqCnWTeHQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-x64-gnu": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.9.2.tgz", - "integrity": "sha512-jkSkwch0uPFva20Mdu8orbQjv2A3G88NExTN2oPTI1AJ+7mZfYW3cDCTyoH6OnctBKbBVeJCEqh0U02lTkqD5w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-x64-musl": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.9.2.tgz", - "integrity": "sha512-Uk64NoiTpQbkpl+bXsbeyOPRpUoMdcUqa+hDC1KhMW7aN1lfW8PBlBH4mJ3n3Y47dYE8qi0XTxy1mBACruYBaw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-wasm32-wasi": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.9.2.tgz", - "integrity": "sha512-EpBGwkcjDicjR/ybC0g8wO5adPNdVuMrNalVgYcWi+gYtC1XYNuxe3rufcO7dA76OHGeVabcO6cSkPJKVcbCXQ==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@napi-rs/wasm-runtime": "^0.2.11" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.9.2.tgz", - "integrity": "sha512-EdFbGn7o1SxGmN6aZw9wAkehZJetFPao0VGZ9OMBwKx6TkvDuj6cNeLimF/Psi6ts9lMOe+Dt6z19fZQ9Ye2fw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.9.2.tgz", - "integrity": "sha512-JY9hi1p7AG+5c/dMU8o2kWemM8I6VZxfGwn1GCtf3c5i+IKcMo2NQ8OjZ4Z3/itvY/Si3K10jOBQn7qsD/whUA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@unrs/resolver-binding-win32-x64-msvc": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.9.2.tgz", - "integrity": "sha512-ryoo+EB19lMxAd80ln9BVf8pdOAxLb97amrQ3SFN9OCRn/5M5wvwDgAe4i8ZjhpbiHoDeP8yavcTEnpKBo7lZg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@vitest/coverage-v8": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz", - "integrity": "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@ampproject/remapping": "^2.3.0", - "@bcoe/v8-coverage": "^1.0.2", - "ast-v8-to-istanbul": "^0.3.3", - "debug": "^4.4.1", - "istanbul-lib-coverage": "^3.2.2", - "istanbul-lib-report": "^3.0.1", - "istanbul-lib-source-maps": "^5.0.6", - "istanbul-reports": "^3.1.7", - "magic-string": "^0.30.17", - "magicast": "^0.3.5", - "std-env": "^3.9.0", - "test-exclude": "^7.0.1", - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@vitest/browser": "3.2.4", - "vitest": "3.2.4" - }, - "peerDependenciesMeta": { - "@vitest/browser": { - "optional": true - } - } - }, - "node_modules/@vitest/coverage-v8/node_modules/@bcoe/v8-coverage": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", - "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "node_modules/@vitest/coverage-v8/node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", "dev": true, "license": "MIT", "engines": { @@ -8506,22 +7894,6 @@ } } }, - "node_modules/ansi-escapes": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.0.0.tgz", - "integrity": "sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw==", - "dev": true, - "license": "MIT", - "dependencies": { - "environment": "^1.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -8859,15 +8231,6 @@ "streamx": "^2.15.0" } }, - "node_modules/are-docs-informative": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/are-docs-informative/-/are-docs-informative-0.0.2.tgz", - "integrity": "sha512-ixiS0nLNNG5jNQzgZJNoUpBKdo9yTYZMGJ+QgT2jmjR7G7+QHRCc4v6LQ3NgE7EBJq+o0ams3waJwkrlBom8Ig==", - "dev": true, - "engines": { - "node": ">=14" - } - }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -8900,97 +8263,14 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/array-includes": { - "version": "3.1.9", - "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", - "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.24.0", - "es-object-atoms": "^1.1.1", - "get-intrinsic": "^1.3.0", - "is-string": "^1.1.1", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.findlastindex": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", - "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.9", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "es-shim-unscopables": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.flat": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", - "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-shim-unscopables": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.flatmap": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", - "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-shim-unscopables": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/arraybuffer.prototype.slice": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", - "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-buffer-byte-length": "^1.0.1", + "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", @@ -9941,16 +9221,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/chai": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/chai/-/chai-5.2.0.tgz", @@ -10134,77 +9404,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/cli-truncate": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz", - "integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==", - "dev": true, - "license": "MIT", - "dependencies": { - "slice-ansi": "^5.0.0", - "string-width": "^7.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-truncate/node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/cli-truncate/node_modules/emoji-regex": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", - "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", - "dev": true, - "license": "MIT" - }, - "node_modules/cli-truncate/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-truncate/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -10319,13 +9518,6 @@ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, - "node_modules/colorette": { - "version": "2.0.20", - "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", - "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", - "dev": true, - "license": "MIT" - }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -10355,16 +9547,6 @@ "integrity": "sha512-aE2Y4MTFJ870NuB/+2z1cXBhSBBzRydVVjzhFC4gtenEhpnj15yu0qptWGJsO9YGrcPZ3ezX8AWb1VA391MKpQ==", "dev": true }, - "node_modules/comment-parser": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.1.tgz", - "integrity": "sha512-buhp5kePrmda3vhc5B9t7pUQXAb2Tnd0qgpkIhPhkHXxJpiPJ11H0ZEU0oBpJ2QztSbzG/ZxMj/CHsYJqRHmyg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 12.0.0" - } - }, "node_modules/compress-commons": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz", @@ -10968,12 +10150,6 @@ "node": ">=4.0.0" } }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true - }, "node_modules/deepmerge-ts": { "version": "7.1.5", "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", @@ -11564,19 +10740,6 @@ "node": ">=6" } }, - "node_modules/environment": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", - "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/err-code": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", @@ -11716,19 +10879,6 @@ "node": ">= 0.4" } }, - "node_modules/es-shim-unscopables": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", - "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/es-to-primitive": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", @@ -11844,501 +10994,87 @@ "source-map": "~0.6.1" } }, - "node_modules/eslint": { - "version": "9.30.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.30.1.tgz", - "integrity": "sha512-zmxXPNMOXmwm9E0yQLi5uqXHs7uq2UIiqEKo3Gq+3fwo1XrJ+hijAZImyF7hclW3E6oHz43Yk3RP8at6OTKflQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.0", - "@eslint/config-helpers": "^0.3.0", - "@eslint/core": "^0.14.0", - "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.30.1", - "@eslint/plugin-kit": "^0.3.1", - "@humanfs/node": "^0.16.6", - "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.2", - "@types/estree": "^1.0.6", - "@types/json-schema": "^7.0.15", - "ajv": "^6.12.4", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.6", - "debug": "^4.3.2", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.4.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "esquery": "^1.5.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^8.0.0", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3" - }, - "bin": { - "eslint": "bin/eslint.js" - }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "jiti": "*" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - } + "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint-import-context": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/eslint-import-context/-/eslint-import-context-0.1.8.tgz", - "integrity": "sha512-bq+F7nyc65sKpZGT09dY0S0QrOnQtuDVIfyTGQ8uuvtMIF7oHp6CEP3mouN0rrnYF3Jqo6Ke0BfU/5wASZue1w==", + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", "dev": true, - "license": "MIT", + "license": "BSD-2-Clause", "dependencies": { - "get-tsconfig": "^4.10.1", - "stable-hash-x": "^0.1.1" + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" }, "engines": { - "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://opencollective.com/eslint-import-context" - }, - "peerDependencies": { - "unrs-resolver": "^1.0.0" + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, - "peerDependenciesMeta": { - "unrs-resolver": { - "optional": true - } + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint-import-context/node_modules/stable-hash-x": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/stable-hash-x/-/stable-hash-x-0.1.1.tgz", - "integrity": "sha512-l0x1D6vhnsNUGPFVDx45eif0y6eedVC8nm5uACTrVFJFtl2mLRW17aWtVyxFCpn5t94VUPkjU8vSLwIuwwqtJQ==", + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", "dev": true, - "license": "MIT", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, "engines": { - "node": ">=12.0.0" + "node": ">=4" } }, - "node_modules/eslint-import-resolver-node": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", - "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, - "dependencies": { - "debug": "^3.2.7", - "is-core-module": "^2.13.0", - "resolve": "^1.22.4" + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" } }, - "node_modules/eslint-import-resolver-node/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", "dev": true, + "license": "MIT", "dependencies": { - "ms": "^2.1.1" + "@types/estree": "^1.0.0" } }, - "node_modules/eslint-import-resolver-typescript": { - "version": "4.4.4", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-4.4.4.tgz", - "integrity": "sha512-1iM2zeBvrYmUNTj2vSC/90JTHDth+dfOfiNKkxApWRsTJYNrc8rOdxxIf5vazX+BiAXTeOT0UvWpGI/7qIWQOw==", - "dev": true, - "license": "ISC", - "dependencies": { - "debug": "^4.4.1", - "eslint-import-context": "^0.1.8", - "get-tsconfig": "^4.10.1", - "is-bun-module": "^2.0.0", - "stable-hash-x": "^0.2.0", - "tinyglobby": "^0.2.14", - "unrs-resolver": "^1.7.11" - }, - "engines": { - "node": "^16.17.0 || >=18.6.0" - }, - "funding": { - "url": "https://opencollective.com/eslint-import-resolver-typescript" - }, - "peerDependencies": { - "eslint": "*", - "eslint-plugin-import": "*", - "eslint-plugin-import-x": "*" - }, - "peerDependenciesMeta": { - "eslint-plugin-import": { - "optional": true - }, - "eslint-plugin-import-x": { - "optional": true - } - } - }, - "node_modules/eslint-module-utils": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", - "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^3.2.7" - }, - "engines": { - "node": ">=4" - }, - "peerDependenciesMeta": { - "eslint": { - "optional": true - } - } - }, - "node_modules/eslint-module-utils/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/eslint-plugin-header": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-header/-/eslint-plugin-header-3.1.1.tgz", - "integrity": "sha512-9vlKxuJ4qf793CmeeSrZUvVClw6amtpghq3CuWcB5cUNnWHQhgcqy5eF8oVKFk1G3Y/CbchGfEaw3wiIJaNmVg==", - "dev": true, - "peerDependencies": { - "eslint": ">=7.7.0" - } - }, - "node_modules/eslint-plugin-import": { - "version": "2.32.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", - "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rtsao/scc": "^1.1.0", - "array-includes": "^3.1.9", - "array.prototype.findlastindex": "^1.2.6", - "array.prototype.flat": "^1.3.3", - "array.prototype.flatmap": "^1.3.3", - "debug": "^3.2.7", - "doctrine": "^2.1.0", - "eslint-import-resolver-node": "^0.3.9", - "eslint-module-utils": "^2.12.1", - "hasown": "^2.0.2", - "is-core-module": "^2.16.1", - "is-glob": "^4.0.3", - "minimatch": "^3.1.2", - "object.fromentries": "^2.0.8", - "object.groupby": "^1.0.3", - "object.values": "^1.2.1", - "semver": "^6.3.1", - "string.prototype.trimend": "^1.0.9", - "tsconfig-paths": "^3.15.0" - }, - "engines": { - "node": ">=4" - }, - "peerDependencies": { - "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" - } - }, - "node_modules/eslint-plugin-import/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/eslint-plugin-import/node_modules/doctrine": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", - "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", - "dev": true, - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/eslint-plugin-import/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/eslint-plugin-import/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/eslint-plugin-jsdoc": { - "version": "51.3.4", - "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-51.3.4.tgz", - "integrity": "sha512-maz6qa95+sAjMr9m5oRyfejc+mnyQWsWSe9oyv9371bh4/T0kWOMryJNO4h8rEd97wo/9lbzwi3OOX4rDhnAzg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@es-joy/jsdoccomment": "~0.52.0", - "are-docs-informative": "^0.0.2", - "comment-parser": "1.4.1", - "debug": "^4.4.1", - "escape-string-regexp": "^4.0.0", - "espree": "^10.4.0", - "esquery": "^1.6.0", - "parse-imports-exports": "^0.2.4", - "semver": "^7.7.2", - "spdx-expression-parse": "^4.0.0" - }, - "engines": { - "node": ">=20.11.0" - }, - "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0" - } - }, - "node_modules/eslint-plugin-jsdoc/node_modules/spdx-expression-parse": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-4.0.0.tgz", - "integrity": "sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "spdx-exceptions": "^2.1.0", - "spdx-license-ids": "^3.0.0" - } - }, - "node_modules/eslint-plugin-no-only-tests": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-no-only-tests/-/eslint-plugin-no-only-tests-3.3.0.tgz", - "integrity": "sha512-brcKcxGnISN2CcVhXJ/kEQlNa0MEfGRtwKtWA16SkqXHKitaKIMrfemJKLKX1YqDU5C/5JY3PvZXd5jEW04e0Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=5.0.0" - } - }, - "node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint/node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/eslint/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint/node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/eslint/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/eslint/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.15.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/espree/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true, "engines": { "node": ">=0.10.0" @@ -12363,13 +11099,6 @@ "node": ">=6" } }, - "node_modules/eventemitter3": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", - "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", - "dev": true, - "license": "MIT" - }, "node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -12618,19 +11347,6 @@ "node": ">= 6" } }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true - }, "node_modules/fast-uri": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.5.tgz", @@ -12721,19 +11437,6 @@ } } }, - "node_modules/file-entry-cache": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", - "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "flat-cache": "^4.0.0" - }, - "engines": { - "node": ">=16.0.0" - } - }, "node_modules/file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", @@ -12799,37 +11502,6 @@ "flat": "cli.js" } }, - "node_modules/flat-cache": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", - "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.4" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/flat-cache/node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" - } - }, - "node_modules/flatted": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", - "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", - "dev": true, - "license": "ISC" - }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -13242,18 +11914,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, "node_modules/glob/node_modules/minipass": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", @@ -13282,19 +11942,6 @@ "node": ">=10.0" } }, - "node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/globalthis": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", @@ -13776,23 +12423,6 @@ "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", "dev": true }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/import-in-the-middle": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-1.4.2.tgz", @@ -13820,6 +12450,7 @@ "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "dev": true, + "optional": true, "engines": { "node": ">=0.8.19" } @@ -13986,16 +12617,6 @@ "dev": true, "license": "MIT" }, - "node_modules/is-bun-module": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", - "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.7.1" - } - }, "node_modules/is-callable": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", @@ -14091,19 +12712,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-fullwidth-code-point": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", - "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-generator-function": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.0.tgz", @@ -14609,16 +13217,6 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/jsdoc-type-pratt-parser": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-4.1.0.tgz", - "integrity": "sha512-Hicd6JK5Njt2QB6XYFS7ok9e37O8AYk3jTcppG4YVQnYjOemymvTcmc7OWsmq/Qqj5TdRFO5/x/tIPmBeRtGHg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/json-bigint": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", @@ -14676,31 +13274,12 @@ "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", "license": "BSD-2-Clause" }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true - }, "node_modules/json-stringify-safe": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", - "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", - "dev": true, - "optional": true - }, - "node_modules/json5": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", - "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", - "dev": true, - "license": "MIT", - "dependencies": { - "minimist": "^1.2.0" - }, - "bin": { - "json5": "lib/cli.js" - } + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true, + "optional": true }, "node_modules/jsonc-parser": { "version": "3.3.1", @@ -14905,478 +13484,156 @@ "debug": "^4.0.1", "koa-compose": "^4.1.0" }, - "engines": { - "node": ">= 7.6.0" - } - }, - "node_modules/koa-send": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/koa-send/-/koa-send-5.0.1.tgz", - "integrity": "sha512-tmcyQ/wXXuxpDxyNXv5yNNkdAMdFRqwtegBXUaowiQzUKqJehttS0x2j0eOZDQAyloAth5w6wwBImnFzkUz3pQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.1.1", - "http-errors": "^1.7.3", - "resolve-path": "^1.4.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/koa-send/node_modules/depd": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", - "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/koa-send/node_modules/http-errors": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz", - "integrity": "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "depd": "~1.1.2", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": ">= 1.5.0 < 2", - "toidentifier": "1.0.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/koa-send/node_modules/statuses": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", - "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/koa-static": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/koa-static/-/koa-static-5.0.0.tgz", - "integrity": "sha512-UqyYyH5YEXaJrf9S8E23GoJFQZXkBVJ9zYYMPGz919MSX1KuvAcycIuS0ci150HCoPf4XQVhQ84Qf8xRPWxFaQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^3.1.0", - "koa-send": "^5.0.0" - }, - "engines": { - "node": ">= 7.6.0" - } - }, - "node_modules/koa-static/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/koa/node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/koa/node_modules/mime-types": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", - "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/lazystream": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", - "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", - "dev": true, - "license": "MIT", - "dependencies": { - "readable-stream": "^2.0.5" - }, - "engines": { - "node": ">= 0.6.3" - } - }, - "node_modules/leven": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", - "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/lie": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", - "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", - "dev": true, - "dependencies": { - "immediate": "~3.0.5" - } - }, - "node_modules/lilconfig": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", - "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/antonk52" - } - }, - "node_modules/lines-and-columns": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-2.0.4.tgz", - "integrity": "sha512-wM1+Z03eypVAVUCE7QdSqpVIvelbOakn1M0bPDoA4SGWPx3sNDVUiMo3L6To6WWGClB7VyXnhQ4Sn7gxiJbE6A==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - } - }, - "node_modules/linkify-it": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", - "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", - "dependencies": { - "uc.micro": "^2.0.0" - } - }, - "node_modules/lint-staged": { - "version": "15.2.9", - "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-15.2.9.tgz", - "integrity": "sha512-BZAt8Lk3sEnxw7tfxM7jeZlPRuT4M68O0/CwZhhaw6eeWu0Lz5eERE3m386InivXB64fp/mDID452h48tvKlRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "~5.3.0", - "commander": "~12.1.0", - "debug": "~4.3.6", - "execa": "~8.0.1", - "lilconfig": "~3.1.2", - "listr2": "~8.2.4", - "micromatch": "~4.0.7", - "pidtree": "~0.6.0", - "string-argv": "~0.3.2", - "yaml": "~2.5.0" - }, - "bin": { - "lint-staged": "bin/lint-staged.js" - }, - "engines": { - "node": ">=18.12.0" - }, - "funding": { - "url": "https://opencollective.com/lint-staged" - } - }, - "node_modules/lint-staged/node_modules/chalk": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz", - "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==", - "dev": true, - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/lint-staged/node_modules/debug": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", - "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/lint-staged/node_modules/execa": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", - "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", - "dev": true, - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^8.0.1", - "human-signals": "^5.0.0", - "is-stream": "^3.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^5.1.0", - "onetime": "^6.0.0", - "signal-exit": "^4.1.0", - "strip-final-newline": "^3.0.0" - }, - "engines": { - "node": ">=16.17" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/lint-staged/node_modules/get-stream": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", - "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", - "dev": true, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lint-staged/node_modules/human-signals": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", - "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", - "dev": true, - "engines": { - "node": ">=16.17.0" - } - }, - "node_modules/lint-staged/node_modules/is-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", - "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", - "dev": true, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lint-staged/node_modules/mimic-fn": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", - "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lint-staged/node_modules/npm-run-path": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.1.0.tgz", - "integrity": "sha512-sJOdmRGrY2sjNTRMbSvluQqg+8X7ZK61yvzBEIDhz4f8z1TZFYABsqjjCBd/0PUNE9M6QDgHJXQkGUEm7Q+l9Q==", - "dev": true, - "dependencies": { - "path-key": "^4.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": ">= 7.6.0" } }, - "node_modules/lint-staged/node_modules/onetime": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", - "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "node_modules/koa-send": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/koa-send/-/koa-send-5.0.1.tgz", + "integrity": "sha512-tmcyQ/wXXuxpDxyNXv5yNNkdAMdFRqwtegBXUaowiQzUKqJehttS0x2j0eOZDQAyloAth5w6wwBImnFzkUz3pQ==", "dev": true, + "license": "MIT", "dependencies": { - "mimic-fn": "^4.0.0" + "debug": "^4.1.1", + "http-errors": "^1.7.3", + "resolve-path": "^1.4.0" }, "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">= 8" } }, - "node_modules/lint-staged/node_modules/path-key": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", - "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "node_modules/koa-send/node_modules/depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", "dev": true, + "license": "MIT", "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">= 0.6" } }, - "node_modules/lint-staged/node_modules/strip-final-newline": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", - "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "node_modules/koa-send/node_modules/http-errors": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz", + "integrity": "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==", "dev": true, - "engines": { - "node": ">=12" + "license": "MIT", + "dependencies": { + "depd": "~1.1.2", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.1" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": ">= 0.6" } }, - "node_modules/lint-staged/node_modules/yaml": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.5.1.tgz", - "integrity": "sha512-bLQOjaX/ADgQ20isPJRvF0iRUHIxVhYvr53Of7wGcWlO2jvtUlH5m87DsmulFVxRpNLOnI4tB6p/oh8D7kpn9Q==", + "node_modules/koa-send/node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", "dev": true, - "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, + "license": "MIT", "engines": { - "node": ">= 14" + "node": ">= 0.6" } }, - "node_modules/listr2": { - "version": "8.2.5", - "resolved": "https://registry.npmjs.org/listr2/-/listr2-8.2.5.tgz", - "integrity": "sha512-iyAZCeyD+c1gPyE9qpFu8af0Y+MRtmKOncdGoA2S5EY8iFq99dmmvkNnHiWo+pj0s7yH7l3KPIgee77tKpXPWQ==", + "node_modules/koa-static": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/koa-static/-/koa-static-5.0.0.tgz", + "integrity": "sha512-UqyYyH5YEXaJrf9S8E23GoJFQZXkBVJ9zYYMPGz919MSX1KuvAcycIuS0ci150HCoPf4XQVhQ84Qf8xRPWxFaQ==", "dev": true, "license": "MIT", "dependencies": { - "cli-truncate": "^4.0.0", - "colorette": "^2.0.20", - "eventemitter3": "^5.0.1", - "log-update": "^6.1.0", - "rfdc": "^1.4.1", - "wrap-ansi": "^9.0.0" + "debug": "^3.1.0", + "koa-send": "^5.0.0" }, "engines": { - "node": ">=18.0.0" + "node": ">= 7.6.0" } }, - "node_modules/listr2/node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "node_modules/koa-static/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", "dev": true, "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" + "dependencies": { + "ms": "^2.1.1" } }, - "node_modules/listr2/node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "node_modules/koa/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "node": ">= 0.6" } }, - "node_modules/listr2/node_modules/emoji-regex": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", - "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", - "dev": true, - "license": "MIT" - }, - "node_modules/listr2/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "node_modules/koa/node_modules/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", "dev": true, "license": "MIT", "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" + "mime-db": "^1.54.0" }, "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">= 0.6" } }, - "node_modules/listr2/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", "dev": true, "license": "MIT", "dependencies": { - "ansi-regex": "^6.0.1" + "readable-stream": "^2.0.5" }, "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" + "node": ">= 0.6.3" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "engines": { + "node": ">=6" } }, - "node_modules/listr2/node_modules/wrap-ansi": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", - "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", "dev": true, - "license": "MIT", "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" - }, + "immediate": "~3.0.5" + } + }, + "node_modules/lines-and-columns": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-2.0.4.tgz", + "integrity": "sha512-wM1+Z03eypVAVUCE7QdSqpVIvelbOakn1M0bPDoA4SGWPx3sNDVUiMo3L6To6WWGClB7VyXnhQ4Sn7gxiJbE6A==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "dependencies": { + "uc.micro": "^2.0.0" } }, "node_modules/load-json-file": { @@ -15506,185 +13763,41 @@ "dev": true, "license": "MIT" }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true - }, "node_modules/lodash.once": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", "dev": true, - "license": "MIT" - }, - "node_modules/lodash.truncate": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", - "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.zip": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/lodash.zip/-/lodash.zip-4.2.0.tgz", - "integrity": "sha512-C7IOaBBK/0gMORRBd8OETNx3kmOkgIWIPvyDpZSCTwUrpYmgZwJkjZeOD8ww4xbOUOs4/attY+pciKvadNfFbg==", - "dev": true, - "license": "MIT" - }, - "node_modules/log-symbols": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", - "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", - "dev": true, - "dependencies": { - "chalk": "^4.1.0", - "is-unicode-supported": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", - "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-escapes": "^7.0.0", - "cli-cursor": "^5.0.0", - "slice-ansi": "^7.1.0", - "strip-ansi": "^7.1.0", - "wrap-ansi": "^9.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update/node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/log-update/node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/log-update/node_modules/emoji-regex": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", - "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", - "dev": true, - "license": "MIT" - }, - "node_modules/log-update/node_modules/is-fullwidth-code-point": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.0.0.tgz", - "integrity": "sha512-OVa3u9kkBbw7b8Xw5F9P+D/T9X+Z4+JruYVNapTjPYZYUznQ5YfWeFkOj606XYYW8yugTfC8Pj0hYqvi4ryAhA==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update/node_modules/slice-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.0.tgz", - "integrity": "sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "is-fullwidth-code-point": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } + "license": "MIT" }, - "node_modules/log-update/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "node_modules/lodash.truncate": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", + "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==", "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } + "license": "MIT" }, - "node_modules/log-update/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "node_modules/lodash.zip": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.zip/-/lodash.zip-4.2.0.tgz", + "integrity": "sha512-C7IOaBBK/0gMORRBd8OETNx3kmOkgIWIPvyDpZSCTwUrpYmgZwJkjZeOD8ww4xbOUOs4/attY+pciKvadNfFbg==", "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } + "license": "MIT" }, - "node_modules/log-update/node_modules/wrap-ansi": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", - "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", "dev": true, - "license": "MIT", "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" }, "engines": { - "node": ">=18" + "node": ">=10" }, "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/loglevel": { @@ -15911,12 +14024,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true - }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -16634,22 +14741,6 @@ "integrity": "sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==", "dev": true }, - "node_modules/napi-postinstall": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.2.4.tgz", - "integrity": "sha512-ZEzHJwBhZ8qQSbknHqYcdtQVr8zUgGyM/q6h6qAyhtyVMNrSgDhrC4disf03dYW0e+czXyLnZINnCTEkWy0eJg==", - "dev": true, - "license": "MIT", - "bin": { - "napi-postinstall": "lib/cli.js" - }, - "engines": { - "node": "^12.20.0 || ^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/napi-postinstall" - } - }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -17487,59 +15578,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/object.fromentries": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", - "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object.groupby": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", - "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/object.values": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", - "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -17627,23 +15665,6 @@ } } }, - "node_modules/optionator": { - "version": "0.9.3", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", - "integrity": "sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==", - "dev": true, - "dependencies": { - "@aashutoshrathi/word-wrap": "^1.2.3", - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, "node_modules/ora": { "version": "8.2.0", "resolved": "https://registry.npmjs.org/ora/-/ora-8.2.0.tgz", @@ -17901,29 +15922,6 @@ "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", "dev": true }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/parse-imports-exports": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/parse-imports-exports/-/parse-imports-exports-0.2.4.tgz", - "integrity": "sha512-4s6vd6dx1AotCx/RCI2m7t7GCh5bDRUtGNvRfHSP2wbBQdMi67pPe7mtzmgwcaQ8VKK/6IB7Glfyu3qdZJPybQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "parse-statements": "1.0.11" - } - }, "node_modules/parse-json": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-7.1.1.tgz", @@ -17975,13 +15973,6 @@ "semver": "bin/semver" } }, - "node_modules/parse-statements": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/parse-statements/-/parse-statements-1.0.11.tgz", - "integrity": "sha512-HlsyYdMBnbPQ9Jr/VgJ1YF4scnldvJpJxCVx6KgqPL4dxppsWrJHCIIxQXMJrqGnsRkNPATbeMJ8Yxu7JMsYcA==", - "dev": true, - "license": "MIT" - }, "node_modules/parse5": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", @@ -18168,18 +16159,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/pidtree": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.0.tgz", - "integrity": "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==", - "dev": true, - "bin": { - "pidtree": "bin/pidtree.js" - }, - "engines": { - "node": ">=0.10" - } - }, "node_modules/pify": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", @@ -18333,15 +16312,6 @@ "node": ">=10" } }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true, - "engines": { - "node": ">= 0.8.0" - } - }, "node_modules/prettier": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", @@ -18583,16 +16553,6 @@ "once": "^1.3.1" } }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/punycode.js": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", @@ -19014,16 +16974,6 @@ "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", "dev": true }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/resolve-path": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/resolve-path/-/resolve-path-1.4.0.tgz", @@ -19174,13 +17124,6 @@ "node": ">=0.10.0" } }, - "node_modules/rfdc": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", - "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", - "dev": true, - "license": "MIT" - }, "node_modules/rgb2hex": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/rgb2hex/-/rgb2hex-0.2.5.tgz", @@ -19986,36 +17929,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/slice-ansi": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", - "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.0.0", - "is-fullwidth-code-point": "^4.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } - }, - "node_modules/slice-ansi/node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/smart-buffer": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", @@ -20236,16 +18149,6 @@ "node": ">=16 || 14 >=14.17" } }, - "node_modules/stable-hash-x": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/stable-hash-x/-/stable-hash-x-0.2.0.tgz", - "integrity": "sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/stack-chain": { "version": "1.3.7", "resolved": "https://registry.npmjs.org/stack-chain/-/stack-chain-1.3.7.tgz", @@ -20331,15 +18234,6 @@ "safe-buffer": "~5.1.0" } }, - "node_modules/string-argv": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", - "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", - "dev": true, - "engines": { - "node": ">=0.6.19" - } - }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", @@ -21210,19 +19104,6 @@ "node": ">=6.10" } }, - "node_modules/tsconfig-paths": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", - "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/json5": "^0.0.29", - "json5": "^1.0.2", - "minimist": "^1.2.6", - "strip-bom": "^3.0.0" - } - }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -21765,18 +19646,6 @@ "node": "*" } }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "dependencies": { - "prelude-ls": "^1.2.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, "node_modules/type-detect": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", @@ -21939,29 +19808,6 @@ "node": ">=14.17" } }, - "node_modules/typescript-eslint": { - "version": "8.36.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.36.0.tgz", - "integrity": "sha512-fTCqxthY+h9QbEgSIBfL9iV6CvKDFuoxg6bHPNpJ9HIUzS+jy2lCEyCmGyZRWEBSaykqcDPf1SJ+BfCI8DRopA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/eslint-plugin": "8.36.0", - "@typescript-eslint/parser": "8.36.0", - "@typescript-eslint/utils": "8.36.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" - } - }, "node_modules/typescript-formatter": { "version": "7.2.2", "resolved": "git+ssh://git@github.com/jrieken/typescript-formatter.git#497efb26bc40b5fa59a350e6eab17bce650a7e4b", @@ -22084,41 +19930,6 @@ "node": ">= 0.8" } }, - "node_modules/unrs-resolver": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.9.2.tgz", - "integrity": "sha512-VUyWiTNQD7itdiMuJy+EuLEErLj3uwX/EpHQF8EOf33Dq3Ju6VW1GXm+swk6+1h7a49uv9fKZ+dft9jU7esdLA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "napi-postinstall": "^0.2.4" - }, - "funding": { - "url": "https://opencollective.com/unrs-resolver" - }, - "optionalDependencies": { - "@unrs/resolver-binding-android-arm-eabi": "1.9.2", - "@unrs/resolver-binding-android-arm64": "1.9.2", - "@unrs/resolver-binding-darwin-arm64": "1.9.2", - "@unrs/resolver-binding-darwin-x64": "1.9.2", - "@unrs/resolver-binding-freebsd-x64": "1.9.2", - "@unrs/resolver-binding-linux-arm-gnueabihf": "1.9.2", - "@unrs/resolver-binding-linux-arm-musleabihf": "1.9.2", - "@unrs/resolver-binding-linux-arm64-gnu": "1.9.2", - "@unrs/resolver-binding-linux-arm64-musl": "1.9.2", - "@unrs/resolver-binding-linux-ppc64-gnu": "1.9.2", - "@unrs/resolver-binding-linux-riscv64-gnu": "1.9.2", - "@unrs/resolver-binding-linux-riscv64-musl": "1.9.2", - "@unrs/resolver-binding-linux-s390x-gnu": "1.9.2", - "@unrs/resolver-binding-linux-x64-gnu": "1.9.2", - "@unrs/resolver-binding-linux-x64-musl": "1.9.2", - "@unrs/resolver-binding-wasm32-wasi": "1.9.2", - "@unrs/resolver-binding-win32-arm64-msvc": "1.9.2", - "@unrs/resolver-binding-win32-ia32-msvc": "1.9.2", - "@unrs/resolver-binding-win32-x64-msvc": "1.9.2" - } - }, "node_modules/untildify": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", @@ -22129,16 +19940,6 @@ "node": ">=8" } }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" - } - }, "node_modules/url-join": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz", diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index 9028db141f0ff..625da83c5addd 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -6283,8 +6283,6 @@ "watch:tsc-extension-web": "tsc --noEmit --watch --project tsconfig.worker.json", "watch:tsc-simulation-workbench": "tsc --noEmit --watch --project test/simulation/workbench/tsconfig.json", "typecheck": "tsc --noEmit --project tsconfig.json && tsc --noEmit --project test/simulation/workbench/tsconfig.json && tsc --noEmit --project tsconfig.worker.json && tsc --noEmit --project src/extension/completions-core/vscode-node/extension/src/copilotPanel/webView/tsconfig.json", - "lint": "eslint . --max-warnings=0", - "lint-staged": "eslint --max-warnings=0", "tsfmt": "npx tsfmt -r --verify", "test": "npm-run-all test:*", "test:extension": "vscode-test", @@ -6359,17 +6357,10 @@ "dotenv": "^17.2.0", "electron": "^39.8.5", "esbuild": "0.27.2", - "eslint": "^9.30.0", - "eslint-import-resolver-typescript": "^4.4.4", - "eslint-plugin-header": "^3.1.1", - "eslint-plugin-import": "^2.32.0", - "eslint-plugin-jsdoc": "^51.3.4", - "eslint-plugin-no-only-tests": "^3.3.0", "fastq": "^1.19.1", "glob": "^11.1.0", "js-yaml": "^4.1.1", "keyv": "^5.3.2", - "lint-staged": "15.2.9", "minimist": "^1.2.8", "mobx": "^6.13.7", "mobx-react-lite": "^4.1.0", @@ -6395,7 +6386,6 @@ "ts-dedent": "^2.2.0", "tsx": "^4.20.3", "typescript": "^5.8.3", - "typescript-eslint": "^8.36.0", "typescript-formatter": "github:jrieken/typescript-formatter#497efb26bc40b5fa59a350e6eab17bce650a7e4b", "vite-plugin-top-level-await": "^1.5.0", "vite-plugin-wasm": "^3.5.0", diff --git a/extensions/copilot/script/compareStestAlternativeRuns.ts b/extensions/copilot/script/compareStestAlternativeRuns.ts index 0ef10cc5160f9..4239afe27b3ad 100644 --- a/extensions/copilot/script/compareStestAlternativeRuns.ts +++ b/extensions/copilot/script/compareStestAlternativeRuns.ts @@ -2,6 +2,7 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +/* eslint-disable local/code-no-dangerous-type-assertions */ import { AssertionError } from 'assert'; import { execFile } from 'child_process'; diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts index 5b323ae81378c..72e6333914a37 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts @@ -70,7 +70,7 @@ export type ISessionOptions = { mcpServerMappings?: McpServerMappings; additionalWorkspaces?: IWorkspaceInfo[]; sessionParentId?: string; -} +}; export type IGetSessionOptions = ISessionOptions & { sessionId: string }; export type ICreateSessionOptions = ISessionOptions & { sessionId?: string }; diff --git a/extensions/copilot/src/extension/common/modelContextProtocol.ts b/extensions/copilot/src/extension/common/modelContextProtocol.ts index 4d05d046a4c8a..fc1d33efea7df 100644 --- a/extensions/copilot/src/extension/common/modelContextProtocol.ts +++ b/extensions/copilot/src/extension/common/modelContextProtocol.ts @@ -2,8 +2,7 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ - -/* eslint-disable local/no-unexternalized-strings */ +/* eslint-disable local/code-no-unexternalized-strings */ //#region proposals /** diff --git a/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/panelShared/languages/cuda-cpp.tmLanguage.ts b/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/panelShared/languages/cuda-cpp.tmLanguage.ts index 4c7dc372c8418..42d89a81c22d5 100644 --- a/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/panelShared/languages/cuda-cpp.tmLanguage.ts +++ b/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/panelShared/languages/cuda-cpp.tmLanguage.ts @@ -2,7 +2,7 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -/* eslint-disable local/no-unexternalized-strings */ +/* eslint-disable local/code-no-unexternalized-strings */ import { LanguageInput } from 'shiki/core'; // This file has been converted from https://github.com/NVIDIA/cuda-cpp-grammar/blob/master/syntaxes/cuda-cpp.tmLanguage.json diff --git a/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/panelShared/languages/javaScriptReact.tmLanguage.ts b/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/panelShared/languages/javaScriptReact.tmLanguage.ts index 1dfb59d1eb21e..26d6d0624311a 100644 --- a/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/panelShared/languages/javaScriptReact.tmLanguage.ts +++ b/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/panelShared/languages/javaScriptReact.tmLanguage.ts @@ -2,7 +2,7 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -/* eslint-disable local/no-unexternalized-strings */ +/* eslint-disable local/code-no-unexternalized-strings */ import { LanguageInput } from 'shiki/core'; // This file has been converted from https://github.com/microsoft/TypeScript-TmLanguage/blob/master/TypeScriptReact.tmLanguage diff --git a/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/panelShared/languages/markdown-latex-combined.tmLanguage.ts b/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/panelShared/languages/markdown-latex-combined.tmLanguage.ts index dfa4c24e3cb37..de4f7508f8d94 100644 --- a/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/panelShared/languages/markdown-latex-combined.tmLanguage.ts +++ b/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/panelShared/languages/markdown-latex-combined.tmLanguage.ts @@ -2,7 +2,7 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -/* eslint-disable local/no-unexternalized-strings */ +/* eslint-disable local/code-no-unexternalized-strings */ import { LanguageInput } from 'shiki/core'; // This file has been converted from https://github.com/jlelong/vscode-latex-basics/blob/master/syntaxes/markdown-latex-combined.tmLanguage.json diff --git a/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/panelShared/languages/rst.tmLanguage.ts b/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/panelShared/languages/rst.tmLanguage.ts index 999bb5ecad503..57235421a48a8 100644 --- a/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/panelShared/languages/rst.tmLanguage.ts +++ b/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/panelShared/languages/rst.tmLanguage.ts @@ -2,7 +2,7 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -/* eslint-disable local/no-unexternalized-strings */ +/* eslint-disable local/code-no-unexternalized-strings */ import { LanguageInput } from 'shiki/core'; // This file has been converted from https://github.com/trond-snekvik/vscode-rst/blob/master/syntaxes/rst.tmLanguage.json diff --git a/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/test/telemetry.ts b/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/test/telemetry.ts index b2eedee3c140a..a92ae657a1e03 100644 --- a/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/test/telemetry.ts +++ b/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/test/telemetry.ts @@ -116,6 +116,7 @@ export async function withInMemoryTelemetry( reporters.setReporter(reporter); reporters.setEnhancedReporter(enhancedReporter); const result = await work(accessor); + // eslint-disable-next-line local/code-no-accessor-after-await const queue = accessor.get(ICompletionsPromiseQueueService) as TestPromiseQueue; await queue.awaitPromises(); diff --git a/extensions/copilot/src/extension/inlineEdits/node/nextEditProviderTelemetry.ts b/extensions/copilot/src/extension/inlineEdits/node/nextEditProviderTelemetry.ts index 3cb27c243d3c5..12e4472ef96a1 100644 --- a/extensions/copilot/src/extension/inlineEdits/node/nextEditProviderTelemetry.ts +++ b/extensions/copilot/src/extension/inlineEdits/node/nextEditProviderTelemetry.ts @@ -753,6 +753,7 @@ class IdleDetector { if (isFirstSelectionRun) { isFirstSelectionRun = false; for (const doc of docs) { + // eslint-disable-next-line local/code-no-observable-get-in-reactive-context this._selectionSnapshots.set(doc.id.uri, doc.primarySelectionLine.get()); } return; @@ -770,6 +771,7 @@ class IdleDetector { // Find the doc whose selection line actually changed from what we last saw for (const doc of docs) { const currentDocId = doc.id.uri; + // eslint-disable-next-line local/code-no-observable-get-in-reactive-context const currentLine = doc.primarySelectionLine.get(); const previousLine = this._selectionSnapshots.get(currentDocId); diff --git a/extensions/copilot/src/extension/intents/node/agentIntent.ts b/extensions/copilot/src/extension/intents/node/agentIntent.ts index bbf7d45a66f26..925e651c85cf0 100644 --- a/extensions/copilot/src/extension/intents/node/agentIntent.ts +++ b/extensions/copilot/src/extension/intents/node/agentIntent.ts @@ -386,7 +386,7 @@ export class AgentIntentInvocation extends EditCodeIntentInvocation implements I @ILogService private readonly logService: ILogService, @IExperimentationService private readonly expService: IExperimentationService, @IAutomodeService private readonly automodeService: IAutomodeService, - @IOTelService override readonly otelService: IOTelService, + @IOTelService protected override readonly otelService: IOTelService, @ISessionTranscriptService private readonly sessionTranscriptService: ISessionTranscriptService, ) { super(intent, location, endpoint, request, intentOptions, instantiationService, codeMapperService, envService, promptPathRepresentationService, endpointProvider, workspaceService, toolsService, configurationService, editLogService, commandService, telemetryService, notebookService, otelService); diff --git a/extensions/copilot/src/extension/intents/node/newNotebookIntent.ts b/extensions/copilot/src/extension/intents/node/newNotebookIntent.ts index 9810e9008c68e..8d72c14d1dbbd 100644 --- a/extensions/copilot/src/extension/intents/node/newNotebookIntent.ts +++ b/extensions/copilot/src/extension/intents/node/newNotebookIntent.ts @@ -178,6 +178,7 @@ export class NewNotebookResponseProcessor { const sourceLines = filterFilePathFromCodeBlock2(streamLines(sourceStream.asyncIterable) .filter(LineFilters.createCodeBlockFilter()) .map(line => { + // eslint-disable-next-line local/code-no-unused-expressions newNotebook.value; // force the notebook to be created return line; })); diff --git a/extensions/copilot/src/extension/onboardDebug/test/node/debuggableCommandIdentifier.spec.ts b/extensions/copilot/src/extension/onboardDebug/test/node/debuggableCommandIdentifier.spec.ts index 6c6f62552a929..3af61b2732316 100644 --- a/extensions/copilot/src/extension/onboardDebug/test/node/debuggableCommandIdentifier.spec.ts +++ b/extensions/copilot/src/extension/onboardDebug/test/node/debuggableCommandIdentifier.spec.ts @@ -2,6 +2,7 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +/* eslint-disable local/code-no-unused-expressions */ import { SinonStub, stub } from 'sinon'; diff --git a/extensions/copilot/src/extension/prompt/test/node/conversation.spec.ts b/extensions/copilot/src/extension/prompt/test/node/conversation.spec.ts index 493aafc980f33..86180d4cc74cc 100644 --- a/extensions/copilot/src/extension/prompt/test/node/conversation.spec.ts +++ b/extensions/copilot/src/extension/prompt/test/node/conversation.spec.ts @@ -2,7 +2,7 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ - +/* eslint-disable local/code-no-unused-expressions */ import { describe, expect, it } from 'vitest'; import type { ChatResult } from 'vscode'; import { ChatVariablesCollection } from '../../common/chatVariablesCollection'; diff --git a/extensions/copilot/src/extension/typescriptContext/vscode-node/languageContextService.ts b/extensions/copilot/src/extension/typescriptContext/vscode-node/languageContextService.ts index 7f48633ba47c0..ada79c3660b1d 100644 --- a/extensions/copilot/src/extension/typescriptContext/vscode-node/languageContextService.ts +++ b/extensions/copilot/src/extension/typescriptContext/vscode-node/languageContextService.ts @@ -1397,6 +1397,7 @@ export class LanguageContextServiceImpl implements ILanguageContextService, vsco } contextItemResult.updateResponse(body, token); this.telemetrySender.sendRequestTelemetry(document, position, context, contextItemResult, timeTaken, { before: cacheState, after: this.runnableResultManager.getCacheState() }, undefined); + // eslint-disable-next-line local/code-no-unused-expressions isDebugging && forDebugging?.length; this._onCachePopulated.fire({ document, position, source: context.source, items: resolved, summary: contextItemResult }); } else if (protocol.ComputeContextResponse.isError(response)) { @@ -1524,6 +1525,7 @@ export class LanguageContextServiceImpl implements ILanguageContextService, vsco document, position, context, contextItemResult, Date.now() - startTime, { before: cacheState, after: cacheState }, cacheRequest ); + // eslint-disable-next-line local/code-no-unused-expressions isDebugging && forDebugging?.length; this._onContextComputed.fire({ document, position, source: context.source, items: itemsToYield, summary: contextItemResult diff --git a/extensions/copilot/src/platform/endpoint/test/node/testEndpointProvider.ts b/extensions/copilot/src/platform/endpoint/test/node/testEndpointProvider.ts index e06e47eba7d1a..48a119bb67ccc 100644 --- a/extensions/copilot/src/platform/endpoint/test/node/testEndpointProvider.ts +++ b/extensions/copilot/src/platform/endpoint/test/node/testEndpointProvider.ts @@ -3,8 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -/* eslint-disable import/no-restricted-paths */ - import type { ChatRequest, LanguageModelChat } from 'vscode'; import { CacheableRequest, SQLiteCache } from '../../../../../test/base/cache'; import { TestingCacheSalts } from '../../../../../test/base/salts'; diff --git a/extensions/copilot/src/platform/git/vscode-node/gitServiceImpl.ts b/extensions/copilot/src/platform/git/vscode-node/gitServiceImpl.ts index ccee88711f1e8..690e15a308bab 100644 --- a/extensions/copilot/src/platform/git/vscode-node/gitServiceImpl.ts +++ b/extensions/copilot/src/platform/git/vscode-node/gitServiceImpl.ts @@ -447,6 +447,7 @@ export class GitServiceImpl extends Disposable implements IGitService { onDidChangeStateSignal.read(reader); const selected = selectedObs.read(reader); + // eslint-disable-next-line local/code-no-observable-get-in-reactive-context const activeRepository = this.activeRepository.get(); if (activeRepository && !selected && !isEqual(activeRepository.rootUri, repository.rootUri)) { return; diff --git a/extensions/copilot/src/platform/telemetry/common/ghTelemetrySender.ts b/extensions/copilot/src/platform/telemetry/common/ghTelemetrySender.ts index 24226e4a0f349..248a5eabfef42 100644 --- a/extensions/copilot/src/platform/telemetry/common/ghTelemetrySender.ts +++ b/extensions/copilot/src/platform/telemetry/common/ghTelemetrySender.ts @@ -17,7 +17,7 @@ import { TelemetryData, eventPropertiesToSimpleObject } from '../common/telemetr export class BaseGHTelemetrySender implements ITelemetrySender { - protected _disposables: DisposableStore = new DisposableStore(); + protected readonly _disposables: DisposableStore = new DisposableStore(); private _standardTelemetryLogger: TelemetryLogger; private _enhancedTelemetryLogger?: TelemetryLogger; diff --git a/extensions/copilot/src/platform/test/node/extensionContext.ts b/extensions/copilot/src/platform/test/node/extensionContext.ts index f8ecedc2c1f21..71dcd65fa07f7 100644 --- a/extensions/copilot/src/platform/test/node/extensionContext.ts +++ b/extensions/copilot/src/platform/test/node/extensionContext.ts @@ -62,7 +62,7 @@ function constructGlobalStoragePath(globalStoragePath: string): URI { } export class MockExtensionContext implements BrandedService { - _serviceBrand = undefined; + declare _serviceBrand: undefined; extension = { id: 'GitHub.copilot-chat' } as any; extensionUri = URI.from({ scheme: 'file', path: '/mock-extension' }); extensionMode = ExtensionMode.Test; diff --git a/extensions/copilot/src/platform/testing/test/node/setupTestDetector.spec.ts b/extensions/copilot/src/platform/testing/test/node/setupTestDetector.spec.ts index 0e169594ec80e..cf8b6d91b7735 100644 --- a/extensions/copilot/src/platform/testing/test/node/setupTestDetector.spec.ts +++ b/extensions/copilot/src/platform/testing/test/node/setupTestDetector.spec.ts @@ -2,6 +2,7 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +/* eslint-disable local/code-no-unused-expressions */ import { beforeEach, expect, suite, test, vi } from 'vitest'; import type * as vscode from 'vscode'; diff --git a/extensions/copilot/test/base/extHostContext/simulationExtHostContext.ts b/extensions/copilot/test/base/extHostContext/simulationExtHostContext.ts index b47dc27a2b4e5..4ba0acf97d9a4 100644 --- a/extensions/copilot/test/base/extHostContext/simulationExtHostContext.ts +++ b/extensions/copilot/test/base/extHostContext/simulationExtHostContext.ts @@ -2,9 +2,6 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// Allow importing vscode here. eslint does not let us exclude this path: https://github.com/import-js/eslint-plugin-import/issues/2800 -/* eslint-disable import/no-restricted-paths */ - import { GitDiffService } from '../../../src/extension/prompt/vscode-node/gitDiffService'; import { IExtensionsService } from '../../../src/platform/extensions/common/extensionsService'; import { VSCodeExtensionsService } from '../../../src/platform/extensions/vscode/extensionsService'; diff --git a/extensions/copilot/test/base/extHostContext/simulationExtHostToolsService.ts b/extensions/copilot/test/base/extHostContext/simulationExtHostToolsService.ts index 0cd2eb1b96b87..259549d78757f 100644 --- a/extensions/copilot/test/base/extHostContext/simulationExtHostToolsService.ts +++ b/extensions/copilot/test/base/extHostContext/simulationExtHostToolsService.ts @@ -2,8 +2,6 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// Allow importing vscode here. eslint does not let us exclude this path: https://github.com/import-js/eslint-plugin-import/issues/2800 -/* eslint-disable import/no-restricted-paths */ import type { CancellationToken, ChatRequest, LanguageModelTool, LanguageModelToolInformation, LanguageModelToolInvocationOptions, LanguageModelToolResult } from 'vscode'; import { getToolName, ToolName } from '../../../src/extension/tools/common/toolNames'; diff --git a/extensions/copilot/test/base/extHostContext/simulationWorkspaceExtHost.ts b/extensions/copilot/test/base/extHostContext/simulationWorkspaceExtHost.ts index 455b7a6f16838..b6869227b4c30 100644 --- a/extensions/copilot/test/base/extHostContext/simulationWorkspaceExtHost.ts +++ b/extensions/copilot/test/base/extHostContext/simulationWorkspaceExtHost.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ // Allow importing vscode here. eslint does not let us exclude this path: https://github.com/import-js/eslint-plugin-import/issues/2800 -/* eslint-disable local/no-runtime-import */ +/* eslint-disable copilot-local/no-runtime-import */ import { writeFileSync } from 'fs'; import * as vscode from 'vscode'; diff --git a/extensions/copilot/vite.config.ts b/extensions/copilot/vite.config.ts index 44b5f01ea2279..c5c75abcc39cc 100644 --- a/extensions/copilot/vite.config.ts +++ b/extensions/copilot/vite.config.ts @@ -3,7 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// eslint-disable-next-line no-restricted-imports import * as path from 'path'; import { loadEnv } from 'vite'; import topLevelAwait from 'vite-plugin-top-level-await'; diff --git a/package-lock.json b/package-lock.json index 5ddc165b06578..6cc6dbbe6cf31 100644 --- a/package-lock.json +++ b/package-lock.json @@ -117,6 +117,7 @@ "eslint": "^9.36.0", "eslint-formatter-compact": "^8.40.0", "eslint-plugin-header": "3.1.1", + "eslint-plugin-import": "^2.32.0", "eslint-plugin-jsdoc": "^50.3.1", "event-stream": "3.3.4", "fancy-log": "^1.3.3", @@ -2488,6 +2489,13 @@ "node": ">=0.4.0" } }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true, + "license": "MIT" + }, "node_modules/@sec-ant/readable-stream": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", @@ -2743,6 +2751,13 @@ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/kerberos": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@types/kerberos/-/kerberos-1.1.2.tgz", @@ -5120,6 +5135,23 @@ "node": ">=12.17" } }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/array-differ": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/array-differ/-/array-differ-1.0.0.tgz", @@ -5138,6 +5170,29 @@ "node": ">=0.10.0" } }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/array-initial": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/array-initial/-/array-initial-1.1.0.tgz", @@ -5222,6 +5277,88 @@ "node": ">=0.10.0" } }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", + "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-shim-unscopables": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/arrify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz", @@ -5342,6 +5479,16 @@ "integrity": "sha512-z/WhQ5FPySLdvREByI2vZiTWwCnF0moMJ1hK9YQwDTHKh6I7/uSckMetoRGb5UBZPC1z0jlw+n/XCgjeH7y1AQ==", "dev": true }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/async-settle": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-settle/-/async-settle-1.0.0.tgz", @@ -5387,10 +5534,14 @@ } }, "node_modules/available-typed-arrays": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz", - "integrity": "sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, "engines": { "node": ">= 0.4" }, @@ -5961,16 +6112,16 @@ } }, "node_modules/call-bind": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", - "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", "dev": true, + "license": "MIT", "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "set-function-length": "^1.2.1" + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" }, "engines": { "node": ">= 0.4" @@ -7120,6 +7271,60 @@ "node": ">= 14" } }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/debounce": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.1.0.tgz", @@ -7363,15 +7568,21 @@ } }, "node_modules/define-properties": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz", - "integrity": "sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", "dev": true, + "license": "MIT", "dependencies": { - "object-keys": "^1.0.12" + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" }, "engines": { "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/define-property": { @@ -7502,6 +7713,19 @@ "randombytes": "^2.0.0" } }, + "node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -7885,6 +8109,75 @@ "is-arrayish": "^0.2.1" } }, + "node_modules/es-abstract": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -7935,6 +8228,37 @@ "node": ">= 0.4" } }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/es5-ext": { "version": "0.10.64", "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz", @@ -8110,51 +8434,179 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, - "node_modules/eslint-plugin-header": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-header/-/eslint-plugin-header-3.1.1.tgz", - "integrity": "sha512-9vlKxuJ4qf793CmeeSrZUvVClw6amtpghq3CuWcB5cUNnWHQhgcqy5eF8oVKFk1G3Y/CbchGfEaw3wiIJaNmVg==", + "node_modules/eslint-import-resolver-node": { + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.10.tgz", + "integrity": "sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==", "dev": true, - "peerDependencies": { - "eslint": ">=7.7.0" + "license": "MIT", + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.16.1", + "resolve": "^2.0.0-next.6" } }, - "node_modules/eslint-plugin-jsdoc": { - "version": "50.3.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-50.3.1.tgz", - "integrity": "sha512-SY9oUuTMr6aWoJggUS40LtMjsRzJPB5ZT7F432xZIHK3EfHF+8i48GbUBpwanrtlL9l1gILNTHK9o8gEhYLcKA==", + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", "dev": true, + "license": "MIT", "dependencies": { - "@es-joy/jsdoccomment": "~0.48.0", - "are-docs-informative": "^0.0.2", - "comment-parser": "1.4.1", - "debug": "^4.3.6", - "escape-string-regexp": "^4.0.0", - "espree": "^10.1.0", - "esquery": "^1.6.0", - "parse-imports": "^2.1.1", - "semver": "^7.6.3", - "spdx-expression-parse": "^4.0.0", - "synckit": "^0.9.1" + "ms": "^2.1.1" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/resolve": { + "version": "2.0.0-next.6", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", + "integrity": "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" }, "engines": { - "node": ">=18" + "node": ">= 0.4" }, - "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/eslint-plugin-jsdoc/node_modules/spdx-expression-parse": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-4.0.0.tgz", - "integrity": "sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ==", + "node_modules/eslint-module-utils": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", + "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", "dev": true, + "license": "MIT", "dependencies": { - "spdx-exceptions": "^2.1.0", - "spdx-license-ids": "^3.0.0" - } - }, - "node_modules/eslint-scope": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-header": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-header/-/eslint-plugin-header-3.1.1.tgz", + "integrity": "sha512-9vlKxuJ4qf793CmeeSrZUvVClw6amtpghq3CuWcB5cUNnWHQhgcqy5eF8oVKFk1G3Y/CbchGfEaw3wiIJaNmVg==", + "dev": true, + "peerDependencies": { + "eslint": ">=7.7.0" + } + }, + "node_modules/eslint-plugin-import": { + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.1", + "hasown": "^2.0.2", + "is-core-module": "^2.16.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.1", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.9", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-plugin-jsdoc": { + "version": "50.3.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-50.3.1.tgz", + "integrity": "sha512-SY9oUuTMr6aWoJggUS40LtMjsRzJPB5ZT7F432xZIHK3EfHF+8i48GbUBpwanrtlL9l1gILNTHK9o8gEhYLcKA==", + "dev": true, + "dependencies": { + "@es-joy/jsdoccomment": "~0.48.0", + "are-docs-informative": "^0.0.2", + "comment-parser": "1.4.1", + "debug": "^4.3.6", + "escape-string-regexp": "^4.0.0", + "espree": "^10.1.0", + "esquery": "^1.6.0", + "parse-imports": "^2.1.1", + "semver": "^7.6.3", + "spdx-expression-parse": "^4.0.0", + "synckit": "^0.9.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-jsdoc/node_modules/spdx-expression-parse": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-4.0.0.tgz", + "integrity": "sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ==", + "dev": true, + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/eslint-scope": { "version": "8.4.0", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", @@ -9307,12 +9759,19 @@ } }, "node_modules/for-each": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", - "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", "dev": true, + "license": "MIT", "dependencies": { - "is-callable": "^1.1.3" + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/for-in": { @@ -9500,6 +9959,37 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/geckodriver": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/geckodriver/-/geckodriver-6.1.0.tgz", @@ -9535,6 +10025,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -9637,6 +10137,24 @@ "once": "^1.3.1" } }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/get-uri": { "version": "6.0.5", "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", @@ -10141,13 +10659,14 @@ } }, "node_modules/globalthis": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.1.tgz", - "integrity": "sha512-mJPRTc/P39NH/iNG4mXa9aIhNymaQikTrnspeCa2ZuJ+mH2QN/rXwtX3XwKrHqWgUQFbNZKtHM105aHzJalElw==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", "dev": true, - "optional": true, + "license": "MIT", "dependencies": { - "define-properties": "^1.1.3" + "define-properties": "^1.2.1", + "gopd": "^1.0.1" }, "engines": { "node": ">= 0.4" @@ -11499,18 +12018,6 @@ "xtend": "~4.0.1" } }, - "node_modules/has": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", - "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", - "dev": true, - "dependencies": { - "function-bind": "^1.1.1" - }, - "engines": { - "node": ">= 0.4.0" - } - }, "node_modules/has-ansi": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz", @@ -11532,6 +12039,19 @@ "node": ">=0.10.0" } }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -11554,6 +12074,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -12062,6 +12598,21 @@ "node": ">= 0.8.0" } }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/interpret": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/interpret/-/interpret-1.4.0.tgz", @@ -12153,12 +12704,66 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0= sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", "dev": true }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -12171,6 +12776,23 @@ "node": ">=8" } }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-buffer": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", @@ -12178,10 +12800,11 @@ "dev": true }, "node_modules/is-callable": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.2.tgz", - "integrity": "sha512-dnMqspv5nU3LoewK2N/y7KLtxtakvTuaCsU9FU50/QDmdbHNy/4/JuRtMHqRU22o3q+W89YQndQEeCVwK+3qrA==", + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.4" }, @@ -12202,12 +12825,16 @@ } }, "node_modules/is-core-module": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.11.0.tgz", - "integrity": "sha512-RRjxlvLDkD1YJwDbroBHMb+cukurkDWNyHx7D3oNB5x9rb5ogcksMC5wHCadcXoo67gVr/+3GFySh3134zi6rw==", + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", "dev": true, + "license": "MIT", "dependencies": { - "has": "^1.0.3" + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -12235,15 +12862,50 @@ "node": ">=0.10.0" } }, - "node_modules/is-deflate": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-deflate/-/is-deflate-1.0.0.tgz", - "integrity": "sha512-YDoFpuZWu1VRXlsnlYMzKyVRITXj7Ej/V9gXQ2/pAe7X1J7M/RNOqaIYi6qUn+B7nGyB9pDXrv02dsB58d2ZAQ==", - "dev": true - }, - "node_modules/is-descriptor": { + "node_modules/is-data-view": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-deflate": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-deflate/-/is-deflate-1.0.0.tgz", + "integrity": "sha512-YDoFpuZWu1VRXlsnlYMzKyVRITXj7Ej/V9gXQ2/pAe7X1J7M/RNOqaIYi6qUn+B7nGyB9pDXrv02dsB58d2ZAQ==", + "dev": true + }, + "node_modules/is-descriptor": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==", "dev": true, "dependencies": { @@ -12311,6 +12973,22 @@ "node": ">=0.10.0" } }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -12321,10 +12999,18 @@ } }, "node_modules/is-generator-function": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.9.tgz", - "integrity": "sha512-ZJ34p1uvIfptHCN7sFTjGibB9/oBg17sHqzDLfuwhvmN/qLVvIQXRQ8licZQ35WJ8KuEQt/etnnzQFI9C9Ue/A==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, "engines": { "node": ">= 0.4" }, @@ -12382,6 +13068,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-negated-glob": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-negated-glob/-/is-negated-glob-1.0.0.tgz", @@ -12391,6 +13090,19 @@ "node": ">=0.10.0" } }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -12400,6 +13112,23 @@ "node": ">=0.12.0" } }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-plain-obj": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz", @@ -12424,6 +13153,25 @@ "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==", "dev": true }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-relative": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-relative/-/is-relative-1.0.0.tgz", @@ -12436,6 +13184,35 @@ "node": ">=0.10.0" } }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-stream": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", @@ -12445,13 +13222,49 @@ "node": ">=0.10.0" } }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-typed-array": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.12.tgz", - "integrity": "sha512-Z14TF2JNG8Lss5/HMqt0//T9JeHXttXy5pH/DBU4vi98ozO2btxzq9MwYDZYnKwU8nRsz/+GVFVRDq3DkVuSPg==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", "dev": true, + "license": "MIT", "dependencies": { - "which-typed-array": "^1.1.11" + "which-typed-array": "^1.1.16" }, "engines": { "node": ">= 0.4" @@ -12505,6 +13318,52 @@ "node": ">=0.10.0" } }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-windows": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", @@ -14399,6 +15258,35 @@ "node": "^16 || ^18 || >= 20" } }, + "node_modules/node-exports-info": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", + "integrity": "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array.prototype.flatmap": "^1.3.3", + "es-errors": "^1.3.0", + "object.entries": "^1.1.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/node-exports-info/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/node-fetch": { "version": "2.6.8", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.8.tgz", @@ -14917,14 +15805,17 @@ } }, "node_modules/object.assign": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.2.tgz", - "integrity": "sha512-ixT2L5THXsApyiUPYKmW+2EHpXXe5Ii3M+f4e+aJFAHao5amFRW6J0OO6c/LU8Be47utCx2GL89hxGB6XSmKuQ==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", "dev": true, + "license": "MIT", "dependencies": { - "call-bind": "^1.0.0", - "define-properties": "^1.1.3", - "has-symbols": "^1.0.1", + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", "object-keys": "^1.1.1" }, "engines": { @@ -14949,6 +15840,56 @@ "node": ">=0.10.0" } }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", + "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/object.map": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/object.map/-/object.map-1.0.1.tgz", @@ -14987,6 +15928,25 @@ "node": ">=0.10.0" } }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -15241,6 +16201,24 @@ "os-tmpdir": "^1.0.0" } }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/p-all": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/p-all/-/p-all-1.0.0.tgz", @@ -15801,6 +16779,16 @@ "node": ">=0.10.0" } }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/postcss": { "version": "7.0.39", "resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.39.tgz", @@ -16418,6 +17406,29 @@ "node": ">= 0.10" } }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/regex-not": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/regex-not/-/regex-not-1.0.2.tgz", @@ -16431,6 +17442,27 @@ "node": ">=0.10.0" } }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/remove-bom-buffer": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/remove-bom-buffer/-/remove-bom-buffer-3.0.0.tgz", @@ -16911,14 +17943,65 @@ "dev": true, "license": "MIT", "engines": { - "node": ">=18.0.0" + "node": ">=18.0.0" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", + "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-array-concat/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, "node_modules/safe-buffer": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, "node_modules/safe-regex": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz", @@ -16928,6 +18011,24 @@ "ret": "~0.1.10" } }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/safe-regex2": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.0.0.tgz", @@ -17161,6 +18262,37 @@ "node": ">= 0.4" } }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/set-value": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz", @@ -18013,6 +19145,20 @@ "ieee754": "^1.2.1" } }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/stream-combiner": { "version": "0.0.4", "resolved": "https://registry.npmjs.org/stream-combiner/-/stream-combiner-0.0.4.tgz", @@ -18165,6 +19311,65 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -18191,6 +19396,16 @@ "node": ">=8" } }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/strip-bom-string": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz", @@ -18858,6 +20073,32 @@ "code-block-writer": "^13.0.3" } }, + "node_modules/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "node_modules/tsconfig-paths/node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, "node_modules/tsec": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/tsec/-/tsec-0.2.7.tgz", @@ -19032,6 +20273,84 @@ "url": "https://opencollective.com/express" } }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/typedarray": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", @@ -19101,6 +20420,25 @@ "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", "dev": true }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/unc-path-regex": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/unc-path-regex/-/unc-path-regex-0.1.2.tgz", @@ -19922,6 +21260,80 @@ "node": ">= 8" } }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/which-module": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz", @@ -19929,16 +21341,19 @@ "dev": true }, "node_modules/which-typed-array": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.11.tgz", - "integrity": "sha512-qe9UWWpkeG5yzZ0tNYxDmd7vo58HDBc39mZ0xWWpolAGADdFOzkfamWLDxkOWcvHQKVmdTyQdLD4NOfjLWTKew==", + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", "dev": true, + "license": "MIT", "dependencies": { - "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.2", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "has-tostringtag": "^1.0.0" + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" }, "engines": { "node": ">= 0.4" diff --git a/package.json b/package.json index f9665ffa74777..95c0ca8337e58 100644 --- a/package.json +++ b/package.json @@ -196,6 +196,7 @@ "eslint": "^9.36.0", "eslint-formatter-compact": "^8.40.0", "eslint-plugin-header": "3.1.1", + "eslint-plugin-import": "^2.32.0", "eslint-plugin-jsdoc": "^50.3.1", "event-stream": "3.3.4", "fancy-log": "^1.3.3", From 766e6a96982dc31a5d4a549dbf8e162d8be80e0d Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Mon, 20 Apr 2026 23:14:43 -0700 Subject: [PATCH 02/70] More fixes Co-authored-by: Copilot --- build/filters.ts | 6 ++-- extensions/copilot/.esbuild.ts | 12 ++++---- .../extensions/test-extension/bootstrap.ts | 1 + .../.vscode/extensions/test-extension/main.ts | 2 +- .../extensions/visualization-runner/entry.js | 6 ++-- .../visualization-runner/extension.ts | 2 +- extensions/copilot/lint-staged.config.js | 30 ------------------- extensions/copilot/package.json | 1 + extensions/copilot/script/setup/getEnv.mts | 26 ++++++++-------- extensions/copilot/script/setup/getToken.mts | 2 ++ .../prompts/node/panel/startDebugging.tsx | 1 + .../tools/node/test/toolTestUtils.tsx | 1 + 12 files changed, 32 insertions(+), 58 deletions(-) delete mode 100644 extensions/copilot/lint-staged.config.js diff --git a/build/filters.ts b/build/filters.ts index f43780b6b182f..0c7c77d30092f 100644 --- a/build/filters.ts +++ b/build/filters.ts @@ -233,10 +233,8 @@ export const tsFormattingFilter = Object.freeze([ ]); export const eslintFilter = Object.freeze([ - '**/*.js', - '**/*.cjs', - '**/*.mjs', - '**/*.ts', + '**/*.{js,cjs,mjs}', + '**/*.{ts,tsx,mts,cts}', '.eslint-plugin-local/**/*.ts', ...readFileSync(join(import.meta.dirname, '..', '.eslint-ignore')) .toString() diff --git a/extensions/copilot/.esbuild.ts b/extensions/copilot/.esbuild.ts index 68dd47035789c..2377e0cc6e58b 100644 --- a/extensions/copilot/.esbuild.ts +++ b/extensions/copilot/.esbuild.ts @@ -45,11 +45,11 @@ const baseNodeBuildOptions = { ...(isDev ? [] : ['dotenv', 'source-map-support']) ], platform: 'node', - mainFields: ["module", "main"], // needed for jsonc-parser, + mainFields: ['module', 'main'], // needed for jsonc-parser, define: { 'process.env.APPLICATIONINSIGHTS_CONFIGURATION_CONTENT': JSON.stringify(JSON.stringify({ - proxyHttpUrl: "", - proxyHttpsUrl: "" + proxyHttpUrl: '', + proxyHttpsUrl: '' })) }, } satisfies esbuild.BuildOptions; @@ -232,7 +232,7 @@ const nodeSimulationBuildOptions = { const nodeSimulationWorkbenchUIBuildOptions = { ...baseNodeBuildOptions, platform: 'browser', // @ulugbekna: important to target 'browser' for correct bundling using 'window' - mainFields: ["browser", "module", "main"], + mainFields: ['browser', 'module', 'main'], entryPoints: [ { in: './test/simulation/workbench/simulationWorkbench.tsx', out: 'simulationWorkbench' }, ], @@ -277,8 +277,8 @@ const typeScriptServerPluginBuildOptions = { sourcesContent: false, treeShaking: true, external: [ - "typescript", - "typescript/lib/tsserverlibrary" + 'typescript', + 'typescript/lib/tsserverlibrary', ], entryPoints: [ { in: './src/extension/typescriptContext/serverPlugin/src/node/main.ts', out: 'main' }, diff --git a/extensions/copilot/.vscode/extensions/test-extension/bootstrap.ts b/extensions/copilot/.vscode/extensions/test-extension/bootstrap.ts index 1b51b71a8c039..53a4e27a73ddb 100644 --- a/extensions/copilot/.vscode/extensions/test-extension/bootstrap.ts +++ b/extensions/copilot/.vscode/extensions/test-extension/bootstrap.ts @@ -5,4 +5,5 @@ import * as vscode from 'vscode'; +// eslint-disable-next-line local/code-no-any-casts (globalThis).projectRoot = vscode.workspace.workspaceFolders?.at(0)?.uri.fsPath ?? __dirname; diff --git a/extensions/copilot/.vscode/extensions/test-extension/main.ts b/extensions/copilot/.vscode/extensions/test-extension/main.ts index b1d3c3d271653..8c0fedbeac8ca 100644 --- a/extensions/copilot/.vscode/extensions/test-extension/main.ts +++ b/extensions/copilot/.vscode/extensions/test-extension/main.ts @@ -218,7 +218,7 @@ export async function activate(context: vscode.ExtensionContext) { vscode.commands.executeCommand( 'debug-value-editor.debug-and-send-request', { - launchConfigName: "Test Visualization Runner STests", + launchConfigName: 'Test Visualization Runner STests', args: args, revealAvailablePropertiesView: true, } diff --git a/extensions/copilot/.vscode/extensions/visualization-runner/entry.js b/extensions/copilot/.vscode/extensions/visualization-runner/entry.js index 7fc321e99f65a..5ea90eb572ec0 100644 --- a/extensions/copilot/.vscode/extensions/visualization-runner/entry.js +++ b/extensions/copilot/.vscode/extensions/visualization-runner/entry.js @@ -4,15 +4,15 @@ *--------------------------------------------------------------------------------------------*/ require('tsx/cjs'); -const { enableHotReload, hotRequire } = require("@hediet/node-reload"); +const { enableHotReload, hotRequire } = require('@hediet/node-reload'); enableHotReload({ entryModule: module }); /** - * @param {import("vscode").ExtensionContext} context + * @param {import('vscode').ExtensionContext} context */ function activate(context) { - context.subscriptions.push(hotRequire(module, "./extension", ext => new ext.Extension())); + context.subscriptions.push(hotRequire(module, './extension', ext => new ext.Extension())); } module.exports = { activate }; diff --git a/extensions/copilot/.vscode/extensions/visualization-runner/extension.ts b/extensions/copilot/.vscode/extensions/visualization-runner/extension.ts index ad36bec57683d..19766ce69d10f 100644 --- a/extensions/copilot/.vscode/extensions/visualization-runner/extension.ts +++ b/extensions/copilot/.vscode/extensions/visualization-runner/extension.ts @@ -42,7 +42,7 @@ export class Extension extends Disposable { title: 'Visualize Test', command: 'debug-value-editor.debug-and-send-request', arguments: [{ - launchConfigName: "Test Visualization Runner", + launchConfigName: 'Test Visualization Runner', args: { fileName: document.fileName, path: t.path, diff --git a/extensions/copilot/lint-staged.config.js b/extensions/copilot/lint-staged.config.js deleted file mode 100644 index 5af6ac5f26222..0000000000000 --- a/extensions/copilot/lint-staged.config.js +++ /dev/null @@ -1,30 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -const ESLint = require('eslint').ESLint; - -const removeIgnoredFiles = async (files) => { - const eslint = new ESLint(); - const isIgnored = await Promise.all( - files.map((file) => { - return eslint.isPathIgnored(file); - }) - ); - const filteredFiles = files.filter((_, i) => !isIgnored[i]); - return filteredFiles.join(' '); -}; - -module.exports = { - '!({.esbuild.ts,test/simulation/fixtures/**,test/scenarios/**,.vscode/extensions/**,**/vscode.proposed.*})*{.ts,.js,.tsx}': async (files) => { - const filesToLint = await removeIgnoredFiles(files); - if (!filesToLint) { - return []; - } - return [ - `npm run tsfmt -- ${filesToLint}`, - `eslint --max-warnings=0 ${filesToLint}` - ]; - }, -}; diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index 625da83c5addd..904d97210bb42 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -6284,6 +6284,7 @@ "watch:tsc-simulation-workbench": "tsc --noEmit --watch --project test/simulation/workbench/tsconfig.json", "typecheck": "tsc --noEmit --project tsconfig.json && tsc --noEmit --project test/simulation/workbench/tsconfig.json && tsc --noEmit --project tsconfig.worker.json && tsc --noEmit --project src/extension/completions-core/vscode-node/extension/src/copilotPanel/webView/tsconfig.json", "tsfmt": "npx tsfmt -r --verify", + "lint": "npx eslint .", "test": "npm-run-all test:*", "test:extension": "vscode-test", "test:sanity": "vscode-test --sanity", diff --git a/extensions/copilot/script/setup/getEnv.mts b/extensions/copilot/script/setup/getEnv.mts index 32c6c56cc8973..1044e74b73f17 100644 --- a/extensions/copilot/script/setup/getEnv.mts +++ b/extensions/copilot/script/setup/getEnv.mts @@ -20,22 +20,22 @@ async function setupSecretClient(vaultUri: string) { } // Always add the Azure CLI as an option - credentialOptions.push(new AzureCliCredential({ tenantId: "72f988bf-86f1-41af-91ab-2d7cd011db47" })); + credentialOptions.push(new AzureCliCredential({ tenantId: '72f988bf-86f1-41af-91ab-2d7cd011db47' })); // Check if terminal is interactive, non-interactive environments can't use // InteractiveBrowserCredential and don't necessarily have access to a keychain // For SSH sessions into Azure VMs, keychain is not available, requires managed identity if (process.stdin.isTTY && !process.env.AZURE_CLIENT_ID && !process.env.CODESPACES) { - credentialOptions.push(new InteractiveBrowserCredential({ tenantId: "72f988bf-86f1-41af-91ab-2d7cd011db47" })); + credentialOptions.push(new InteractiveBrowserCredential({ tenantId: '72f988bf-86f1-41af-91ab-2d7cd011db47' })); } // Use DeviceCodeCredential in Codespaces if (process.env.CODESPACES) { const deviceCodeCredential = new DeviceCodeCredential({ - tenantId: "72f988bf-86f1-41af-91ab-2d7cd011db47", + tenantId: '72f988bf-86f1-41af-91ab-2d7cd011db47', userPromptCallback: (info) => { - console.log("To authenticate, visit:", info.verificationUri); - console.log("Enter the code:", info.userCode); + console.log('To authenticate, visit:', info.verificationUri); + console.log('Enter the code:', info.userCode); } }); credentialOptions.push(deviceCodeCredential); @@ -51,20 +51,20 @@ async function fetchSecret(secretClient: SecretClient, secretName: string): Prom } async function fetchSecrets(): Promise<{ [key: string]: string | undefined }> { - const keyVaultClient = await setupSecretClient("https://copilot-automation.vault.azure.net/"); + const keyVaultClient = await setupSecretClient('https://copilot-automation.vault.azure.net/'); const secrets: { [key: string]: string | undefined } = {}; - secrets["HMAC_SECRET"] = await fetchSecret(keyVaultClient, "hmac-secret"); + secrets['HMAC_SECRET'] = await fetchSecret(keyVaultClient, 'hmac-secret'); if (!process.stdin.isTTY) { // only in automation - secrets["GITHUB_OAUTH_TOKEN"] = await fetchSecret(keyVaultClient, "capi-oauth"); - secrets["VSCODE_COPILOT_CHAT_TOKEN"] = await fetchSecret(keyVaultClient, "copilot-token"); - secrets["BLACKBIRD_EMBEDDINGS_KEY"] = await fetchSecret(keyVaultClient, "vsc-aoai-key"); - secrets["BLACKBIRD_REDIS_CACHE_KEY"] = await fetchSecret(keyVaultClient, "blackbird-redis-cache-key"); + secrets['GITHUB_OAUTH_TOKEN'] = await fetchSecret(keyVaultClient, 'capi-oauth'); + secrets['VSCODE_COPILOT_CHAT_TOKEN'] = await fetchSecret(keyVaultClient, 'copilot-token'); + secrets['BLACKBIRD_EMBEDDINGS_KEY'] = await fetchSecret(keyVaultClient, 'vsc-aoai-key'); + secrets['BLACKBIRD_REDIS_CACHE_KEY'] = await fetchSecret(keyVaultClient, 'blackbird-redis-cache-key'); try { - secrets["ANTHROPIC_API_KEY"] = await fetchSecret(keyVaultClient, "anthropic-key"); - secrets["DEEPSEEK_API_KEY"] = await fetchSecret(keyVaultClient, "deepseek-key"); + secrets['ANTHROPIC_API_KEY'] = await fetchSecret(keyVaultClient, 'anthropic-key'); + secrets['DEEPSEEK_API_KEY'] = await fetchSecret(keyVaultClient, 'deepseek-key'); } catch (error) { console.log(red(`Failed to fetch optional evaluation tokens. Skipping...`)); } diff --git a/extensions/copilot/script/setup/getToken.mts b/extensions/copilot/script/setup/getToken.mts index 1eca497f8c0e9..6b9d82c24499e 100644 --- a/extensions/copilot/script/setup/getToken.mts +++ b/extensions/copilot/script/setup/getToken.mts @@ -46,6 +46,7 @@ async function main(): Promise { }, }; const request1 = await fetch(REQUEST1_URL, requestOptions); + // eslint-disable-next-line local/code-no-any-casts const response1 = (await request1.json()) as any; console.log(`Copy this code: ${response1.user_code}`); console.log('Then press any key to launch the authorization page, paste the code in and approve the access.'); @@ -69,6 +70,7 @@ async function main(): Promise { 'Content-Type': 'application/json', }, }; + // eslint-disable-next-line local/code-no-any-casts const response2 = (await (await fetch(REQUEST2_URL, requestOptions)).json()) as any; expiresIn -= response1.interval; await new Promise(resolve => setTimeout(resolve, 1000 * response1.interval)); diff --git a/extensions/copilot/src/extension/prompts/node/panel/startDebugging.tsx b/extensions/copilot/src/extension/prompts/node/panel/startDebugging.tsx index 637902f654ace..2cfa7e7507bcd 100644 --- a/extensions/copilot/src/extension/prompts/node/panel/startDebugging.tsx +++ b/extensions/copilot/src/extension/prompts/node/panel/startDebugging.tsx @@ -382,6 +382,7 @@ export class StartDebuggingPrompt extends PromptElement 0 && <>Below is a list of information from the Visual Studio Code documentation which might be relevant to the question.
} {state.docSearchResults && state.docSearchResults.map((result) => { if (result?.title && result.contents) { + // eslint-disable-next-line local/code-no-unused-expressions ##{result?.title?.trim()} - {result.path}
{result.contents} diff --git a/extensions/copilot/src/extension/tools/node/test/toolTestUtils.tsx b/extensions/copilot/src/extension/tools/node/test/toolTestUtils.tsx index c05af74e1f25d..c1350fe1c3ceb 100644 --- a/extensions/copilot/src/extension/tools/node/test/toolTestUtils.tsx +++ b/extensions/copilot/src/extension/tools/node/test/toolTestUtils.tsx @@ -24,6 +24,7 @@ export async function renderElementToString(accessor: ServicesAccessor, element: }; const endpoint = await accessor.get(IEndpointProvider).getChatEndpoint('copilot-base'); + // eslint-disable-next-line local/code-no-accessor-after-await const renderer = PromptRenderer.create(accessor.get(IInstantiationService), endpoint, clz, {}); const r = await renderer.render(); From a261ac606a5c6e1cdb0723230d8dfa4db3a46ae8 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Mon, 20 Apr 2026 23:21:22 -0700 Subject: [PATCH 03/70] Remove extra copilot lint check These lint checks should already be handled by the full pr lint checks --- .github/workflows/pr.yml | 8 -------- 1 file changed, 8 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 70c65ddc42662..7225387d12315 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -288,10 +288,6 @@ jobs: working-directory: extensions/copilot run: npm run typecheck - - name: Lint - working-directory: extensions/copilot - run: npm run lint - - name: Compile working-directory: extensions/copilot run: npm run compile @@ -387,10 +383,6 @@ jobs: working-directory: extensions/copilot run: npm run typecheck - - name: Lint - working-directory: extensions/copilot - run: npm run lint - - name: Compile working-directory: extensions/copilot run: npm run compile From fd19f3c030c58d49ad155b994d1c5c3b4410ec1b Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Wed, 22 Apr 2026 12:19:42 +0100 Subject: [PATCH 04/70] Add "Open in Agents" action and titlebar widget with styling --- .../browser/parts/titlebar/titlebarPart.ts | 13 ++ .../actions/media/openInAgents.css | 80 ++++++++++ .../actions/openInAgentsAction.ts | 146 ++++++++++++++++++ .../electron-browser/desktop.contribution.ts | 1 + 4 files changed, 240 insertions(+) create mode 100644 src/vs/workbench/electron-browser/actions/media/openInAgents.css create mode 100644 src/vs/workbench/electron-browser/actions/openInAgentsAction.ts diff --git a/src/vs/workbench/browser/parts/titlebar/titlebarPart.ts b/src/vs/workbench/browser/parts/titlebar/titlebarPart.ts index fb96d03d3a243..805e9f3e481c8 100644 --- a/src/vs/workbench/browser/parts/titlebar/titlebarPart.ts +++ b/src/vs/workbench/browser/parts/titlebar/titlebarPart.ts @@ -32,6 +32,7 @@ import { WindowTitle } from './windowTitle.js'; import { CommandCenterControl } from './commandCenterControl.js'; import { Categories } from '../../../../platform/action/common/actionCommonCategories.js'; import { HiddenItemStrategy, MenuWorkbenchToolBar, WorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; +import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; import { ACCOUNTS_ACTIVITY_ID, GLOBAL_ACTIVITY_ID } from '../../../common/activity.js'; import { AccountsActivityActionViewItem, isAccountsActionVisible, SimpleAccountActivityActionViewItem, SimpleGlobalActivityActionViewItem } from '../globalCompositeBar.js'; import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js'; @@ -607,6 +608,18 @@ export class BrowserTitlebarPart extends Part implements ITitlebarPart { private actionViewItemProvider(action: IAction, options: IBaseActionViewItemOptions): IActionViewItem | undefined { + // --- Custom view items registered via IActionViewItemService + const actionViewItemService = this.instantiationService.invokeFunction(accessor => accessor.get(IActionViewItemService)); + for (const menuId of [MenuId.TitleBar, MenuId.LayoutControlMenu]) { + const customViewItem = actionViewItemService.lookUp(menuId, action.id); + if (customViewItem) { + const result = customViewItem(action, options, this.instantiationService, getWindowId(this.element ? getWindow(this.element) : mainWindow)); + if (result) { + return result; + } + } + } + // --- Activity Actions if (!this.isAuxiliary) { if (action.id === GLOBAL_ACTIVITY_ID) { diff --git a/src/vs/workbench/electron-browser/actions/media/openInAgents.css b/src/vs/workbench/electron-browser/actions/media/openInAgents.css new file mode 100644 index 0000000000000..18fbda9c994dc --- /dev/null +++ b/src/vs/workbench/electron-browser/actions/media/openInAgents.css @@ -0,0 +1,80 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* "Open in Agents" titlebar widget — icon-only at rest, expands on hover/focus. */ +.monaco-workbench .open-in-agents-titlebar-widget { + display: inline-flex; + align-items: center; + height: 22px; + padding: 0 4px; + margin: 0 10px 0 2px; + border-radius: 5px; + cursor: pointer; + color: var(--vscode-titleBar-activeForeground); + -webkit-app-region: no-drag; + white-space: nowrap; + position: relative; +} + +/* Vertical separator drawn as an absolutely positioned pseudo-element so it isn't clipped by any ancestor `overflow: hidden`. */ +.monaco-workbench .open-in-agents-titlebar-widget::after { + content: ''; + position: absolute; + right: -6px; + top: 4px; + bottom: 4px; + width: 1px; + background-color: rgba(128, 128, 128, 0.5); + pointer-events: none; +} + +.monaco-workbench .open-in-agents-titlebar-widget > .open-in-agents-titlebar-widget-icon { + width: 16px; + height: 16px; + flex: 0 0 auto; + background-image: url('../../../browser/media/code-icon.svg'); + background-repeat: no-repeat; + background-position: center center; + background-size: contain; +} + +/* Quality-aware tinting: the bundled `code-icon.svg` is the stable (blue) shape. Insider/exploration builds rotate hue to approximate their brand color. */ +.monaco-workbench .open-in-agents-titlebar-widget[data-product-quality="insider"] > .open-in-agents-titlebar-widget-icon { + filter: hue-rotate(140deg) saturate(0.85); +} + +.monaco-workbench .open-in-agents-titlebar-widget[data-product-quality="exploration"] > .open-in-agents-titlebar-widget-icon { + filter: hue-rotate(-50deg) saturate(1.4); +} + +.monaco-workbench .open-in-agents-titlebar-widget > .open-in-agents-titlebar-widget-label { + display: inline-block; + max-width: 0; + opacity: 0; + margin-left: 0; + font-size: 12px; + line-height: 22px; + overflow: hidden; + white-space: nowrap; + transition: max-width 150ms ease, opacity 150ms ease, margin-left 150ms ease; +} + +.monaco-workbench .open-in-agents-titlebar-widget:hover, +.monaco-workbench .open-in-agents-titlebar-widget:focus { + background-color: var(--vscode-toolbar-hoverBackground); + outline: none; +} + +.monaco-workbench .open-in-agents-titlebar-widget:hover > .open-in-agents-titlebar-widget-label, +.monaco-workbench .open-in-agents-titlebar-widget:focus > .open-in-agents-titlebar-widget-label { + max-width: 200px; + opacity: 1; + margin-left: 6px; +} + +.monaco-workbench .open-in-agents-titlebar-widget:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} diff --git a/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts b/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts new file mode 100644 index 0000000000000..7d6a2ea372faf --- /dev/null +++ b/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts @@ -0,0 +1,146 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/openInAgents.css'; +import { $, append } from '../../../base/browser/dom.js'; +import { BaseActionViewItem, IBaseActionViewItemOptions } from '../../../base/browser/ui/actionbar/actionViewItems.js'; +import { IAction } from '../../../base/common/actions.js'; +import { Disposable } from '../../../base/common/lifecycle.js'; +import { localize, localize2 } from '../../../nls.js'; +import { Action2, MenuId, registerAction2 } from '../../../platform/actions/common/actions.js'; +import { IActionViewItemService } from '../../../platform/actions/browser/actionViewItemService.js'; +import { ContextKeyExpr } from '../../../platform/contextkey/common/contextkey.js'; +import { IsMacContext, IsWindowsContext } from '../../../platform/contextkey/common/contextkeys.js'; +import { IInstantiationService, ServicesAccessor } from '../../../platform/instantiation/common/instantiation.js'; +import { INativeHostService } from '../../../platform/native/common/native.js'; +import { IProductService } from '../../../platform/product/common/productService.js'; +import { IWorkspaceContextService, WorkbenchState } from '../../../platform/workspace/common/workspace.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../common/contributions.js'; +import { IsAuxiliaryWindowContext, IsSessionsWindowContext } from '../../common/contextkeys.js'; + +const OpenInAgentsActionId = 'workbench.action.openInAgents'; + +const OpenInAgentsVisibility = ContextKeyExpr.and( + IsSessionsWindowContext.toNegated(), + IsAuxiliaryWindowContext.toNegated(), + ContextKeyExpr.or(IsMacContext, IsWindowsContext), +); + +/** + * Action that launches the sibling Agents app via + * {@link INativeHostService.launchSiblingApp} with `--agents` and the current + * workspace folder/file. Mirrors the "Open in VS Code" action that lives in + * the Agents window's title bar. + */ +class OpenInAgentsAction extends Action2 { + + constructor() { + super({ + id: OpenInAgentsActionId, + title: localize2('openInAgents', 'Open in Agents'), + f1: true, + precondition: OpenInAgentsVisibility, + menu: [{ + // The titlebar tool bar concatenates: editor → layout → TitleBar → + // activity. Register on the layout menu with a very low order so we + // render as the leftmost item in the right-hand controls (before + // the layout icons). + id: MenuId.LayoutControlMenu, + group: 'navigation', + order: -1000, + when: OpenInAgentsVisibility, + }] + }); + } + + override async run(accessor: ServicesAccessor): Promise { + const nativeHostService = accessor.get(INativeHostService); + const productService = accessor.get(IProductService); + const workspaceContextService = accessor.get(IWorkspaceContextService); + + const hasSibling = !!( + productService.embedded?.darwinSiblingBundleIdentifier || + productService.embedded?.win32SiblingExeBasename || + productService.darwinSiblingBundleIdentifier || + productService.win32SiblingExeBasename + ); + if (!hasSibling) { + return; + } + + const args: string[] = ['--agents', '--new-window']; + + const workspace = workspaceContextService.getWorkspace(); + switch (workspaceContextService.getWorkbenchState()) { + case WorkbenchState.FOLDER: + if (workspace.folders.length > 0) { + args.push('--folder-uri', workspace.folders[0].uri.toString()); + } + break; + case WorkbenchState.WORKSPACE: + if (workspace.configuration) { + args.push('--file-uri', workspace.configuration.toString()); + } + break; + } + + await nativeHostService.launchSiblingApp(args); + } +} + +/** + * Renders the "Open in Agents" titlebar entry as the product-icon-only button + * that expands to reveal a label on hover/focus. The icon is tinted via CSS + * to match the host product quality (stable, insider, exploration). + */ +class OpenInAgentsTitleBarWidget extends BaseActionViewItem { + + constructor( + action: IAction, + options: IBaseActionViewItemOptions | undefined, + @IProductService private readonly productService: IProductService, + ) { + super(undefined, action, options); + } + + override render(container: HTMLElement): void { + super.render(container); + + container.classList.add('open-in-agents-titlebar-widget'); + container.setAttribute('role', 'button'); + container.tabIndex = 0; + + const quality = this.productService.quality ?? 'stable'; + container.setAttribute('data-product-quality', quality); + + const label = this.action.label || localize('openInAgents', 'Open in Agents'); + container.setAttribute('aria-label', label); + container.title = label; + + const icon = append(container, $('span.open-in-agents-titlebar-widget-icon')); + icon.setAttribute('aria-hidden', 'true'); + + const labelEl = append(container, $('span.open-in-agents-titlebar-widget-label')); + labelEl.textContent = label; + } +} + +class OpenInAgentsContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.openInAgents'; + + constructor( + @IActionViewItemService actionViewItemService: IActionViewItemService, + @IInstantiationService instantiationService: IInstantiationService, + ) { + super(); + this._register(actionViewItemService.register(MenuId.LayoutControlMenu, OpenInAgentsActionId, (action, options) => { + return instantiationService.createInstance(OpenInAgentsTitleBarWidget, action, options); + }, undefined)); + } +} + +registerAction2(OpenInAgentsAction); +registerWorkbenchContribution2(OpenInAgentsContribution.ID, OpenInAgentsContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/workbench/electron-browser/desktop.contribution.ts b/src/vs/workbench/electron-browser/desktop.contribution.ts index 544300939e9d1..3226ccc6ef83e 100644 --- a/src/vs/workbench/electron-browser/desktop.contribution.ts +++ b/src/vs/workbench/electron-browser/desktop.contribution.ts @@ -11,6 +11,7 @@ import { KeyMod, KeyCode } from '../../base/common/keyCodes.js'; import { isLinux, isMacintosh, isWindows } from '../../base/common/platform.js'; import { ConfigureRuntimeArgumentsAction, ToggleDevToolsAction, ReloadWindowWithExtensionsDisabledAction, OpenUserDataFolderAction, ShowGPUInfoAction, ShowContentTracingAction, StopTracing, StartTracing } from './actions/developerActions.js'; import { ZoomResetAction, ZoomOutAction, ZoomInAction, CloseWindowAction, SwitchWindowAction, QuickSwitchWindowAction, SwitchToMainWindowAction, NewWindowTabHandler, ShowPreviousWindowTabHandler, ShowNextWindowTabHandler, MoveWindowTabToNewWindowHandler, MergeWindowTabsHandlerHandler, ToggleWindowTabsBarHandler, ToggleWindowAlwaysOnTopAction, DisableWindowAlwaysOnTopAction, EnableWindowAlwaysOnTopAction, CloseOtherWindowsAction } from './actions/windowActions.js'; +import './actions/openInAgentsAction.js'; import { ContextKeyExpr } from '../../platform/contextkey/common/contextkey.js'; import { KeybindingsRegistry, KeybindingWeight } from '../../platform/keybinding/common/keybindingsRegistry.js'; import { CommandsRegistry } from '../../platform/commands/common/commands.js'; From 4681ecf3b515f3b4716fe02a2944eedd2027f96f Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Wed, 22 Apr 2026 12:28:51 +0100 Subject: [PATCH 05/70] Add "Open in Agents" action to titlebar and configure visibility settings --- .../browser/parts/titlebar/titlebarPart.ts | 9 ++++ .../actions/openInAgentsAction.ts | 50 ++++++++++++++++--- 2 files changed, 52 insertions(+), 7 deletions(-) diff --git a/src/vs/workbench/browser/parts/titlebar/titlebarPart.ts b/src/vs/workbench/browser/parts/titlebar/titlebarPart.ts index 805e9f3e481c8..12a149d4dfe08 100644 --- a/src/vs/workbench/browser/parts/titlebar/titlebarPart.ts +++ b/src/vs/workbench/browser/parts/titlebar/titlebarPart.ts @@ -698,6 +698,15 @@ export class BrowserTitlebarPart extends Part implements ITitlebarPart { } } + // --- Leading Global Actions (rendered before layout controls; opt-in via group '0_leading') + if (this.globalToolbarMenu) { + fillInActionBarActions( + this.globalToolbarMenu.getActions(), + actions, + actionGroup => actionGroup === '0_leading' + ); + } + // --- Layout Actions if (this.layoutToolbarMenu) { fillInActionBarActions( diff --git a/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts b/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts index 7d6a2ea372faf..f96aca48a0a0c 100644 --- a/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts +++ b/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts @@ -11,18 +11,24 @@ import { Disposable } from '../../../base/common/lifecycle.js'; import { localize, localize2 } from '../../../nls.js'; import { Action2, MenuId, registerAction2 } from '../../../platform/actions/common/actions.js'; import { IActionViewItemService } from '../../../platform/actions/browser/actionViewItemService.js'; +import { IConfigurationRegistry, Extensions as ConfigurationExtensions } from '../../../platform/configuration/common/configurationRegistry.js'; import { ContextKeyExpr } from '../../../platform/contextkey/common/contextkey.js'; import { IsMacContext, IsWindowsContext } from '../../../platform/contextkey/common/contextkeys.js'; import { IInstantiationService, ServicesAccessor } from '../../../platform/instantiation/common/instantiation.js'; import { INativeHostService } from '../../../platform/native/common/native.js'; import { IProductService } from '../../../platform/product/common/productService.js'; +import { Registry } from '../../../platform/registry/common/platform.js'; import { IWorkspaceContextService, WorkbenchState } from '../../../platform/workspace/common/workspace.js'; +import { ToggleTitleBarConfigAction } from '../../browser/parts/titlebar/titlebarActions.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../common/contributions.js'; import { IsAuxiliaryWindowContext, IsSessionsWindowContext } from '../../common/contextkeys.js'; +import { workbenchConfigurationNodeBase } from '../../common/configuration.js'; const OpenInAgentsActionId = 'workbench.action.openInAgents'; +const OpenInAgentsEnabledSetting = 'workbench.openInAgents.enabled'; const OpenInAgentsVisibility = ContextKeyExpr.and( + ContextKeyExpr.equals(`config.${OpenInAgentsEnabledSetting}`, true), IsSessionsWindowContext.toNegated(), IsAuxiliaryWindowContext.toNegated(), ContextKeyExpr.or(IsMacContext, IsWindowsContext), @@ -43,12 +49,18 @@ class OpenInAgentsAction extends Action2 { f1: true, precondition: OpenInAgentsVisibility, menu: [{ - // The titlebar tool bar concatenates: editor → layout → TitleBar → - // activity. Register on the layout menu with a very low order so we - // render as the leftmost item in the right-hand controls (before - // the layout icons). - id: MenuId.LayoutControlMenu, - group: 'navigation', + // Render in the global titlebar tool bar in the dedicated + // '0_leading' slot so we appear before the layout controls + // (and stay visible when layout controls are toggled off). + id: MenuId.TitleBar, + group: '0_leading', + order: -1000, + when: OpenInAgentsVisibility, + }, { + // Also surface inside the "Customize Layout..." submenu so users + // can toggle the entry on/off from the layout customization UI. + id: MenuId.LayoutControlMenuSubmenu, + group: '0_workbench_layout', order: -1000, when: OpenInAgentsVisibility, }] @@ -136,7 +148,7 @@ class OpenInAgentsContribution extends Disposable implements IWorkbenchContribut @IInstantiationService instantiationService: IInstantiationService, ) { super(); - this._register(actionViewItemService.register(MenuId.LayoutControlMenu, OpenInAgentsActionId, (action, options) => { + this._register(actionViewItemService.register(MenuId.TitleBar, OpenInAgentsActionId, (action, options) => { return instantiationService.createInstance(OpenInAgentsTitleBarWidget, action, options); }, undefined)); } @@ -144,3 +156,27 @@ class OpenInAgentsContribution extends Disposable implements IWorkbenchContribut registerAction2(OpenInAgentsAction); registerWorkbenchContribution2(OpenInAgentsContribution.ID, OpenInAgentsContribution, WorkbenchPhase.AfterRestored); + +// Toggle entry in titlebar context menu (right-click on titlebar) +registerAction2(class ToggleOpenInAgents extends ToggleTitleBarConfigAction { + constructor() { + super( + OpenInAgentsEnabledSetting, + localize('toggle.openInAgents', 'Open in Agents'), + localize('toggle.openInAgentsDescription', "Toggle visibility of the Open in Agents button in title bar"), + 6, + ); + } +}); + +// Configuration setting backing the toggle. +Registry.as(ConfigurationExtensions.Configuration).registerConfiguration({ + ...workbenchConfigurationNodeBase, + properties: { + [OpenInAgentsEnabledSetting]: { + type: 'boolean', + default: true, + markdownDescription: localize('openInAgentsEnabled', "Controls whether the Open in Agents button is shown in the title bar."), + } + } +}); From 9ee3bd63699b9c3f7dc380e535d8925c87ada663 Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Wed, 22 Apr 2026 12:33:24 +0100 Subject: [PATCH 06/70] Enhance "Open in Agents" titlebar widget with quality-tinted hover/focus backgrounds --- .../actions/media/openInAgents.css | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/vs/workbench/electron-browser/actions/media/openInAgents.css b/src/vs/workbench/electron-browser/actions/media/openInAgents.css index 18fbda9c994dc..15e546334c7ca 100644 --- a/src/vs/workbench/electron-browser/actions/media/openInAgents.css +++ b/src/vs/workbench/electron-browser/actions/media/openInAgents.css @@ -54,6 +54,7 @@ max-width: 0; opacity: 0; margin-left: 0; + color: var(--vscode-foreground); font-size: 12px; line-height: 22px; overflow: hidden; @@ -67,6 +68,22 @@ outline: none; } +/* Quality-tinted hover/focus background — blue (stable), green (insider), orange (exploration). */ +.monaco-workbench .open-in-agents-titlebar-widget[data-product-quality="stable"]:hover, +.monaco-workbench .open-in-agents-titlebar-widget[data-product-quality="stable"]:focus { + background-color: rgba(0, 122, 204, 0.18); +} + +.monaco-workbench .open-in-agents-titlebar-widget[data-product-quality="insider"]:hover, +.monaco-workbench .open-in-agents-titlebar-widget[data-product-quality="insider"]:focus { + background-color: rgba(36, 187, 26, 0.20); +} + +.monaco-workbench .open-in-agents-titlebar-widget[data-product-quality="exploration"]:hover, +.monaco-workbench .open-in-agents-titlebar-widget[data-product-quality="exploration"]:focus { + background-color: rgba(255, 140, 0, 0.22); +} + .monaco-workbench .open-in-agents-titlebar-widget:hover > .open-in-agents-titlebar-widget-label, .monaco-workbench .open-in-agents-titlebar-widget:focus > .open-in-agents-titlebar-widget-label { max-width: 200px; From 77481adb9c25b1757f07db6db1d6c527e40fa74e Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Wed, 22 Apr 2026 12:35:06 +0100 Subject: [PATCH 07/70] Enhance "Open in Agents" titlebar widget with grayscale effect at rest and color transition on hover/focus --- .../actions/media/openInAgents.css | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/electron-browser/actions/media/openInAgents.css b/src/vs/workbench/electron-browser/actions/media/openInAgents.css index 15e546334c7ca..c4ee53f0d71ec 100644 --- a/src/vs/workbench/electron-browser/actions/media/openInAgents.css +++ b/src/vs/workbench/electron-browser/actions/media/openInAgents.css @@ -38,14 +38,24 @@ background-repeat: no-repeat; background-position: center center; background-size: contain; + /* Desaturated at rest; full color on hover/focus (per quality below). */ + filter: grayscale(1) opacity(0.75); + transition: filter 150ms ease; } /* Quality-aware tinting: the bundled `code-icon.svg` is the stable (blue) shape. Insider/exploration builds rotate hue to approximate their brand color. */ -.monaco-workbench .open-in-agents-titlebar-widget[data-product-quality="insider"] > .open-in-agents-titlebar-widget-icon { +.monaco-workbench .open-in-agents-titlebar-widget[data-product-quality="stable"]:hover > .open-in-agents-titlebar-widget-icon, +.monaco-workbench .open-in-agents-titlebar-widget[data-product-quality="stable"]:focus > .open-in-agents-titlebar-widget-icon { + filter: none; +} + +.monaco-workbench .open-in-agents-titlebar-widget[data-product-quality="insider"]:hover > .open-in-agents-titlebar-widget-icon, +.monaco-workbench .open-in-agents-titlebar-widget[data-product-quality="insider"]:focus > .open-in-agents-titlebar-widget-icon { filter: hue-rotate(140deg) saturate(0.85); } -.monaco-workbench .open-in-agents-titlebar-widget[data-product-quality="exploration"] > .open-in-agents-titlebar-widget-icon { +.monaco-workbench .open-in-agents-titlebar-widget[data-product-quality="exploration"]:hover > .open-in-agents-titlebar-widget-icon, +.monaco-workbench .open-in-agents-titlebar-widget[data-product-quality="exploration"]:focus > .open-in-agents-titlebar-widget-icon { filter: hue-rotate(-50deg) saturate(1.4); } From ea0cc4e9b98e918706feb203eff7b7d8e34aadc7 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Wed, 22 Apr 2026 10:24:54 -0700 Subject: [PATCH 08/70] agentHost: settings followups - Unrelated: fix local agent host being broken on main following some overnight cleanups - Register a JSON schema for agent session settings (only when they're open since it has a perf cost) - When there are multiple custom approval buttons, show them in a dropdown similar to other tool calls. --- .../agentSessionSettings.contribution.ts | 5 +- .../agentSessionSettingsFileSystemProvider.ts | 243 +++++++++++++++++- .../browser/localAgentHost.contribution.ts | 14 +- ...tSessionSettingsFileSystemProvider.test.ts | 11 +- .../abstractToolConfirmationSubPart.ts | 54 +++- 5 files changed, 310 insertions(+), 17 deletions(-) diff --git a/src/vs/sessions/contrib/agentHost/browser/agentSessionSettings.contribution.ts b/src/vs/sessions/contrib/agentHost/browser/agentSessionSettings.contribution.ts index 70301200ade3b..657595bed09d1 100644 --- a/src/vs/sessions/contrib/agentHost/browser/agentSessionSettings.contribution.ts +++ b/src/vs/sessions/contrib/agentHost/browser/agentSessionSettings.contribution.ts @@ -15,7 +15,7 @@ import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase import { ChatSessionProviderIdContext } from '../../../common/contextkeys.js'; import { ISession } from '../../../services/sessions/common/session.js'; import { SessionItemContextMenuId } from '../../sessions/browser/views/sessionsList.js'; -import { agentSessionSettingsUri, AGENT_SESSION_SETTINGS_SCHEME, AgentSessionSettingsFileSystemProvider } from './agentSessionSettingsFileSystemProvider.js'; +import { agentSessionSettingsUri, AGENT_SESSION_SETTINGS_SCHEME, AgentSessionSettingsFileSystemProvider, AgentSessionSettingsSchemaRegistrar } from './agentSessionSettingsFileSystemProvider.js'; /** * Registers the {@link AgentSessionSettingsFileSystemProvider} with the @@ -32,7 +32,8 @@ class AgentSessionSettingsContribution extends Disposable implements IWorkbenchC ) { super(); - const provider = this._register(instantiationService.createInstance(AgentSessionSettingsFileSystemProvider)); + const schemaRegistrar = this._register(instantiationService.createInstance(AgentSessionSettingsSchemaRegistrar)); + const provider = this._register(instantiationService.createInstance(AgentSessionSettingsFileSystemProvider, schemaRegistrar)); this._register(fileService.registerProvider(AGENT_SESSION_SETTINGS_SCHEME, provider)); this._register(labelService.registerFormatter({ diff --git a/src/vs/sessions/contrib/agentHost/browser/agentSessionSettingsFileSystemProvider.ts b/src/vs/sessions/contrib/agentHost/browser/agentSessionSettingsFileSystemProvider.ts index 5221def9e2cbd..819574038e82b 100644 --- a/src/vs/sessions/contrib/agentHost/browser/agentSessionSettingsFileSystemProvider.ts +++ b/src/vs/sessions/contrib/agentHost/browser/agentSessionSettingsFileSystemProvider.ts @@ -6,7 +6,8 @@ import { VSBuffer } from '../../../../base/common/buffer.js'; import { Emitter } from '../../../../base/common/event.js'; import { parse, ParseError } from '../../../../base/common/json.js'; -import { Disposable, IDisposable } from '../../../../base/common/lifecycle.js'; +import { IJSONSchema } from '../../../../base/common/jsonSchema.js'; +import { Disposable, DisposableMap, DisposableStore, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; import { localize } from '../../../../nls.js'; import { @@ -24,10 +25,15 @@ import { IStat, IWatchOptions, } from '../../../../platform/files/common/files.js'; +import { Extensions as JSONExtensions, IJSONContributionRegistry } from '../../../../platform/jsonschemas/common/jsonContributionRegistry.js'; import { ILogService } from '../../../../platform/log/common/log.js'; +import { Registry } from '../../../../platform/registry/common/platform.js'; +import { ISessionConfigPropertySchema, ISessionConfigSchema } from '../../../../platform/agentHost/common/state/protocol/state.js'; +import { IResolveSessionConfigResult } from '../../../../platform/agentHost/common/state/protocol/commands.js'; import { ISession, toSessionId } from '../../../services/sessions/common/session.js'; import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; import { IAgentHostSessionsProvider, isAgentHostProvider } from '../../../common/agentHostSessionsProvider.js'; +import { ISessionsProvider } from '../../../services/sessions/common/sessionsProvider.js'; /** Scheme for the synthetic agent-host session settings files. */ export const AGENT_SESSION_SETTINGS_SCHEME = 'agent-session-settings'; @@ -149,6 +155,7 @@ export class AgentSessionSettingsFileSystemProvider extends Disposable implement readonly onDidChangeFile = this._onDidChangeFile.event; constructor( + private readonly _schemaRegistrar: AgentSessionSettingsSchemaRegistrar, @ISessionsProvidersService private readonly _sessionsProvidersService: ISessionsProvidersService, @ILogService private readonly _logService: ILogService, ) { @@ -200,6 +207,14 @@ export class AgentSessionSettingsFileSystemProvider extends Disposable implement } const { provider, sessionId } = this._resolve(parsed); const content = serializeSessionSettings(provider, sessionId); + + // Register the JSON schema on demand the first time a settings file + // is read. The registrar keeps it in sync from then on. + const session = provider.getSessions().find(s => s.sessionId === sessionId); + if (session) { + this._schemaRegistrar.ensureRegistered(session); + } + return VSBuffer.fromString(content).buffer; } @@ -279,3 +294,229 @@ export class AgentSessionSettingsFileSystemProvider extends Disposable implement return { provider, sessionId: parsed.sessionId }; } } + +/** + * Convert a session config property schema (protocol shape) into an + * {@link IJSONSchema} suitable for registration with the JSON language + * service. + */ +function convertPropertySchema(schema: ISessionConfigPropertySchema): IJSONSchema { + const out: IJSONSchema = { + type: schema.type, + title: schema.title, + description: schema.description, + default: schema.default, + }; + if (schema.enum && schema.enum.length > 0) { + out.enum = [...schema.enum]; + if (schema.enumDescriptions && schema.enumDescriptions.length > 0) { + out.enumDescriptions = [...schema.enumDescriptions]; + } + } + if (schema.type === 'array' && schema.items) { + out.items = convertPropertySchema(schema.items); + } + if (schema.type === 'object' && schema.properties) { + const properties: Record = {}; + for (const [key, value] of Object.entries(schema.properties)) { + properties[key] = convertPropertySchema(value); + } + out.properties = properties; + if (schema.required && schema.required.length > 0) { + out.required = [...schema.required]; + } + } + return out; +} + +/** + * Build a JSON schema describing the editable session-mutable, non-readOnly + * properties of an agent-host session config. The filter mirrors the one in + * {@link serializeSessionSettings} so validation matches the file contents + * produced by this provider. + */ +export function buildSessionSettingsJsonSchema(config: IResolveSessionConfigResult): IJSONSchema { + const properties: Record = {}; + const required: string[] = []; + for (const [key, schema] of Object.entries(config.schema.properties)) { + if (!schema.sessionMutable || schema.readOnly) { + continue; + } + properties[key] = convertPropertySchema(schema); + if (config.schema.required?.includes(key)) { + required.push(key); + } + } + const result: IJSONSchema = { + type: 'object', + properties, + additionalProperties: false, + }; + if (required.length > 0) { + result.required = required; + } + return result; +} + +/** + * Keeps per-session JSON schemas registered on the + * {@link IJSONContributionRegistry} so editors of the synthetic + * `agent-session-settings://…` files get completions, hover, and validation. + * + * Registration is lazy — {@link ensureRegistered} is called by + * {@link AgentSessionSettingsFileSystemProvider.readFile} the first time a + * session's settings document is read, so we avoid the JSON language + * service overhead for sessions that are never opened. Once registered, the + * schema is kept in sync via `onDidChangeSessionConfig` until the session + * or its provider is removed. + * + * A schema is rebuilt only when the session's underlying + * {@link ISessionConfigSchema} changes by identity (protocol config schemas + * are treated as immutable snapshots); value-only changes are ignored to + * avoid churning the JSON language service. + */ +export class AgentSessionSettingsSchemaRegistrar extends Disposable { + + private readonly _schemaRegistry = Registry.as(JSONExtensions.JSONContribution); + + /** Per-provider subscriptions (session listeners, config listeners). */ + private readonly _providerSubscriptions = this._register(new DisposableMap()); + + /** Per-session registered-schema disposables, keyed by the settings URI string. */ + private readonly _sessionSchemas = this._register(new DisposableMap()); + + /** + * Tracks the {@link ISessionConfigSchema} identity last used to register + * a schema for a given settings URI, so we can skip re-registration when + * only values have changed. + */ + private readonly _lastSchemaIdentity = new Map(); + + constructor( + @ISessionsProvidersService private readonly _sessionsProvidersService: ISessionsProvidersService, + ) { + super(); + + for (const provider of this._sessionsProvidersService.getProviders()) { + this._onProviderAdded(provider); + } + this._register(this._sessionsProvidersService.onDidChangeProviders(e => { + for (const provider of e.added) { + this._onProviderAdded(provider); + } + for (const provider of e.removed) { + this._onProviderRemoved(provider); + } + })); + } + + private _onProviderAdded(provider: ISessionsProvider): void { + if (!isAgentHostProvider(provider)) { + return; + } + const store = new DisposableStore(); + + // Note: we do NOT seed schemas eagerly here — registration is lazy and + // only happens on the first `readFile` for a given session via + // {@link ensureRegistered}. Registering schemas is relatively expensive + // for the JSON language service, so we avoid paying that cost for + // sessions whose settings files are never opened. + + store.add(provider.onDidChangeSessionConfig(sessionId => { + const schemaUri = this._schemaUriForSession(provider.id, sessionId); + // Only refresh if we already have a registration; otherwise the + // next `readFile` will pick up the latest schema on demand. + if (!schemaUri || !this._lastSchemaIdentity.has(schemaUri)) { + return; + } + const session = provider.getSessions().find(s => s.sessionId === sessionId); + if (session) { + this._refreshSchema(provider, session); + } + })); + + store.add(provider.onDidChangeSessions(e => { + for (const removed of e.removed) { + this._disposeSchema(removed); + } + })); + + // On provider disposal, drop all session schemas for this provider. + store.add(toDisposable(() => { + for (const session of provider.getSessions()) { + this._disposeSchema(session); + } + })); + + this._providerSubscriptions.set(provider.id, store); + } + + private _onProviderRemoved(provider: ISessionsProvider): void { + this._providerSubscriptions.deleteAndDispose(provider.id); + } + + /** + * Ensures a JSON schema is registered for the given session. Called + * lazily by the filesystem provider when a settings file is first read + * so we avoid the cost of registering schemas for sessions that are + * never opened. + * + * Once registered, the schema is kept in sync via + * `onDidChangeSessionConfig` until the session or its provider is + * removed. + */ + ensureRegistered(session: ISession): void { + const provider = this._sessionsProvidersService.getProvider(session.providerId); + if (!provider || !isAgentHostProvider(provider)) { + return; + } + this._refreshSchema(provider, session); + } + + private _schemaUriForSession(providerId: string, sessionId: string): string | undefined { + const provider = this._sessionsProvidersService.getProvider(providerId); + if (!provider || !isAgentHostProvider(provider)) { + return undefined; + } + const session = provider.getSessions().find(s => s.sessionId === sessionId); + return session ? agentSessionSettingsUri(session).toString() : undefined; + } + + private _refreshSchema(provider: IAgentHostSessionsProvider, session: ISession): void { + const config = provider.getSessionConfig(session.sessionId); + if (!config) { + return; + } + const settingsUri = agentSessionSettingsUri(session).toString(); + // Schema content is served via the `vscode://schemas/...` filesystem + // provider (see `SettingsFileSystemProvider`); the JSON language + // client only knows how to fetch schema content for that scheme. + // The settings-file URI is used as the fileMatch glob so the schema + // is applied to the actual editor document. + const schemaId = `vscode://schemas/agent-session-settings/${session.providerId}${session.resource.scheme}${session.resource.path}.jsonc`; + const identity = config.schema; + if (this._lastSchemaIdentity.get(settingsUri) === identity) { + return; + } + + const schema = buildSessionSettingsJsonSchema(config); + + // Dispose any prior registration first, otherwise the old cleanup + // disposable would delete the freshly registered schema. Clear the + // identity cache as a side effect so we always proceed to register. + this._sessionSchemas.deleteAndDispose(settingsUri); + + const store = new DisposableStore(); + this._schemaRegistry.registerSchema(schemaId, schema, store); + store.add(this._schemaRegistry.registerSchemaAssociation(schemaId, settingsUri)); + store.add(toDisposable(() => this._lastSchemaIdentity.delete(settingsUri))); + + this._sessionSchemas.set(settingsUri, store); + this._lastSchemaIdentity.set(settingsUri, identity); + } + + private _disposeSchema(session: ISession): void { + const schemaUri = agentSessionSettingsUri(session).toString(); + this._sessionSchemas.deleteAndDispose(schemaUri); + } +} diff --git a/src/vs/sessions/contrib/agentHost/browser/localAgentHost.contribution.ts b/src/vs/sessions/contrib/agentHost/browser/localAgentHost.contribution.ts index 7fe3b2cb654ca..bdba3fc6b6f60 100644 --- a/src/vs/sessions/contrib/agentHost/browser/localAgentHost.contribution.ts +++ b/src/vs/sessions/contrib/agentHost/browser/localAgentHost.contribution.ts @@ -8,7 +8,9 @@ import { AgentHostEnabledSettingId } from '../../../../platform/agentHost/common import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { AgentHostContribution } from '../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.js'; import { IAgentHostSessionWorkingDirectoryResolver } from '../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionWorkingDirectoryResolver.js'; +import { AgentHostTerminalContribution } from '../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostTerminalContribution.js'; import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; import { LocalAgentHostSessionsProvider } from './localAgentHostSessionsProvider.js'; @@ -16,11 +18,11 @@ import { LocalAgentHostSessionsProvider } from './localAgentHostSessionsProvider * Registers the {@link LocalAgentHostSessionsProvider} as a sessions provider * when `chat.agentHost.enabled` is true. * - * The existing {@link AgentHostContribution} (from `chat/electron-browser/chat.contribution.js`) - * handles all the heavy lifting — agent discovery, session handler registration, - * language model providers, customization harness — via {@link IChatSessionsService}. - * This contribution only bridges the session listing and lifecycle to the - * {@link ISessionsProvidersService} layer used by the Sessions app's UI. + * {@link AgentHostContribution} handles all the heavy lifting — agent discovery, + * session handler registration, language model providers, customization harness — + * via {@link IChatSessionsService}. This contribution only bridges the session + * listing and lifecycle to the {@link ISessionsProvidersService} layer used by + * the Sessions app's UI. */ class LocalAgentHostContribution extends Disposable implements IWorkbenchContribution { @@ -65,4 +67,6 @@ class LocalAgentHostContribution extends Disposable implements IWorkbenchContrib } } +registerWorkbenchContribution2(AgentHostContribution.ID, AgentHostContribution, WorkbenchPhase.AfterRestored); +registerWorkbenchContribution2(AgentHostTerminalContribution.ID, AgentHostTerminalContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(LocalAgentHostContribution.ID, LocalAgentHostContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/sessions/contrib/agentHost/test/browser/agentSessionSettingsFileSystemProvider.test.ts b/src/vs/sessions/contrib/agentHost/test/browser/agentSessionSettingsFileSystemProvider.test.ts index dea46bafb04d0..35c44351ef3c3 100644 --- a/src/vs/sessions/contrib/agentHost/test/browser/agentSessionSettingsFileSystemProvider.test.ts +++ b/src/vs/sessions/contrib/agentHost/test/browser/agentSessionSettingsFileSystemProvider.test.ts @@ -17,7 +17,7 @@ import type { IAgentHostSessionsProvider } from '../../../../common/agentHostSes import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; import type { ISession } from '../../../../services/sessions/common/session.js'; import type { ISessionsProvider } from '../../../../services/sessions/common/sessionsProvider.js'; -import { agentSessionSettingsUri, AgentSessionSettingsFileSystemProvider } from '../../browser/agentSessionSettingsFileSystemProvider.js'; +import { agentSessionSettingsUri, AgentSessionSettingsFileSystemProvider, AgentSessionSettingsSchemaRegistrar } from '../../browser/agentSessionSettingsFileSystemProvider.js'; const PROVIDER_ID = 'local-agent-host'; const RESOURCE_SCHEME = 'agent-host-copilot'; @@ -56,6 +56,7 @@ suite('AgentSessionSettingsFileSystemProvider', () => { const session = createSession(); const onDidChangeSessionConfigEmitter = store.add(new Emitter()); + const onDidChangeSessionsEmitter = store.add(new Emitter<{ added: readonly ISession[]; removed: readonly ISession[]; changed: readonly ISession[] }>()); const replaceCalls: Array<{ sessionId: string; values: Record }> = []; const sessionProvider: IMockAgentHostSessionsProvider = { @@ -64,6 +65,8 @@ suite('AgentSessionSettingsFileSystemProvider', () => { onDidChangeSessionConfigEmitter, replaceCalls, onDidChangeSessionConfig: onDidChangeSessionConfigEmitter.event, + onDidChangeSessions: onDidChangeSessionsEmitter.event, + getSessions: () => [session], getSessionConfig: (_sessionId: string) => sessionProvider.config, replaceSessionConfig: async (sessionId: string, values: Record) => { replaceCalls.push({ sessionId, values }); @@ -77,6 +80,7 @@ suite('AgentSessionSettingsFileSystemProvider', () => { setSessionConfigValue: async () => { /* unused by writeFile */ }, } as unknown as IMockAgentHostSessionsProvider; + const onDidChangeProvidersEmitter = store.add(new Emitter<{ added: readonly ISessionsProvider[]; removed: readonly ISessionsProvider[] }>()); const providersService: ISessionsProvidersService = { getProvider(providerId: string): T | undefined { if (registerProvider && providerId === PROVIDER_ID) { @@ -84,6 +88,8 @@ suite('AgentSessionSettingsFileSystemProvider', () => { } return undefined; }, + getProviders: () => registerProvider ? [sessionProvider as unknown as ISessionsProvider] : [], + onDidChangeProviders: onDidChangeProvidersEmitter.event, } as unknown as ISessionsProvidersService; const instantiationService = store.add(new TestInstantiationService(new ServiceCollection( @@ -91,7 +97,8 @@ suite('AgentSessionSettingsFileSystemProvider', () => { [ILogService, new NullLogService()], ))); - const fs = store.add(instantiationService.createInstance(AgentSessionSettingsFileSystemProvider)); + const schemaRegistrar = store.add(instantiationService.createInstance(AgentSessionSettingsSchemaRegistrar)); + const fs = store.add(instantiationService.createInstance(AgentSessionSettingsFileSystemProvider, schemaRegistrar)); return { fs, session, uri: agentSessionSettingsUri(session), sessionProvider }; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/abstractToolConfirmationSubPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/abstractToolConfirmationSubPart.ts index f24eb5c008e85..4323180d48037 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/abstractToolConfirmationSubPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/abstractToolConfirmationSubPart.ts @@ -10,6 +10,7 @@ import { localize } from '../../../../../../../nls.js'; import { IContextKeyService } from '../../../../../../../platform/contextkey/common/contextkey.js'; import { IInstantiationService } from '../../../../../../../platform/instantiation/common/instantiation.js'; import { IKeybindingService } from '../../../../../../../platform/keybinding/common/keybinding.js'; +import { ConfirmationOptionKind, IConfirmationOption } from '../../../../../../../platform/agentHost/common/state/protocol/state.js'; import { ChatContextKeys } from '../../../../common/actions/chatContextKeys.js'; import { ConfirmedReason, IChatToolInvocation, ToolConfirmKind } from '../../../../common/chatService/chatService.js'; import { ILanguageModelToolsService } from '../../../../common/tools/languageModelToolsService.js'; @@ -67,13 +68,7 @@ export abstract class AbstractToolConfirmationSubPart extends BaseChatToolInvoca let buttons: IChatConfirmationButton<(() => void)>[]; if (customOptions && customOptions.length > 0) { - buttons = customOptions.map((option, index) => ({ - label: option.label, - data: () => { - this.confirmWith(toolInvocation, { type: ToolConfirmKind.UserAction, selectedButton: option.id }); - }, - isSecondary: index > 0, - })); + buttons = this.buildCustomOptionButtons(toolInvocation, customOptions); } else { const allowTooltip = keybindingService.appendKeybinding(config.allowLabel, config.allowActionId); const skipTooltip = keybindingService.appendKeybinding(config.skipLabel, config.skipActionId); @@ -153,6 +148,51 @@ export abstract class AbstractToolConfirmationSubPart extends BaseChatToolInvoca IChatToolInvocation.confirmWith(toolInvocation, reason); } + private buildCustomOptionButtons(toolInvocation: IChatToolInvocation, options: readonly IConfirmationOption[]): IChatConfirmationButton<(() => void)>[] { + const approve: IConfirmationOption[] = []; + const deny: IConfirmationOption[] = []; + for (const option of options) { + (option.kind === ConfirmationOptionKind.Deny ? deny : approve).push(option); + } + + const makeAction = (option: IConfirmationOption): IChatConfirmationButton<(() => void)> => ({ + label: option.label, + data: () => { + this.confirmWith(toolInvocation, { type: ToolConfirmKind.UserAction, selectedButton: option.id }); + }, + }); + + const makeGroupButton = (group: IConfirmationOption[], isSecondary: boolean): IChatConfirmationButton<(() => void)> => { + const [primary, ...rest] = group; + const button: IChatConfirmationButton<(() => void)> = { + ...makeAction(primary), + isSecondary, + }; + if (rest.length > 0) { + const moreActions: (IChatConfirmationButton<(() => void)> | Separator)[] = []; + let prevGroup = primary.group; + for (const option of rest) { + if (option.group !== prevGroup) { + moreActions.push(new Separator()); + } + moreActions.push(makeAction(option)); + prevGroup = option.group; + } + button.moreActions = moreActions; + } + return button; + }; + + const buttons: IChatConfirmationButton<(() => void)>[] = []; + if (approve.length > 0) { + buttons.push(makeGroupButton(approve, false)); + } + if (deny.length > 0) { + buttons.push(makeGroupButton(deny, approve.length > 0)); + } + return buttons; + } + protected additionalPrimaryActions(): AbstractToolPrimaryAction[] { return []; } From 1108100ec70ed82a50693b2672dbb5ab24f3b0cf Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Wed, 22 Apr 2026 10:39:26 -0700 Subject: [PATCH 09/70] Add tests for agent session settings schema registration --- ...tSessionSettingsFileSystemProvider.test.ts | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/src/vs/sessions/contrib/agentHost/test/browser/agentSessionSettingsFileSystemProvider.test.ts b/src/vs/sessions/contrib/agentHost/test/browser/agentSessionSettingsFileSystemProvider.test.ts index 35c44351ef3c3..d6b4164350486 100644 --- a/src/vs/sessions/contrib/agentHost/test/browser/agentSessionSettingsFileSystemProvider.test.ts +++ b/src/vs/sessions/contrib/agentHost/test/browser/agentSessionSettingsFileSystemProvider.test.ts @@ -13,6 +13,8 @@ import type { IResolveSessionConfigResult } from '../../../../../platform/agentH import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js'; import { NullLogService, ILogService } from '../../../../../platform/log/common/log.js'; +import { Extensions as JSONExtensions, IJSONContributionRegistry } from '../../../../../platform/jsonschemas/common/jsonContributionRegistry.js'; +import { Registry } from '../../../../../platform/registry/common/platform.js'; import type { IAgentHostSessionsProvider } from '../../../../common/agentHostSessionsProvider.js'; import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; import type { ISession } from '../../../../services/sessions/common/session.js'; @@ -46,6 +48,7 @@ suite('AgentSessionSettingsFileSystemProvider', () => { interface IMockAgentHostSessionsProvider extends IAgentHostSessionsProvider { config: IResolveSessionConfigResult | undefined; readonly onDidChangeSessionConfigEmitter: Emitter; + readonly onDidChangeSessionsEmitter: Emitter<{ added: readonly ISession[]; removed: readonly ISession[]; changed: readonly ISession[] }>; readonly replaceCalls: Array<{ sessionId: string; values: Record }>; } @@ -63,6 +66,7 @@ suite('AgentSessionSettingsFileSystemProvider', () => { id: PROVIDER_ID, config: initialConfig, onDidChangeSessionConfigEmitter, + onDidChangeSessionsEmitter, replaceCalls, onDidChangeSessionConfig: onDidChangeSessionConfigEmitter.event, onDidChangeSessions: onDidChangeSessionsEmitter.event, @@ -221,4 +225,90 @@ suite('AgentSessionSettingsFileSystemProvider', () => { await fs.readFile(uri); }); }); + + suite('schema registration', () => { + const schemaRegistry = Registry.as(JSONExtensions.JSONContribution); + + function expectedSchemaId(session: ISession): string { + return `vscode://schemas/agent-session-settings/${session.providerId}${session.resource.scheme}${session.resource.path}.jsonc`; + } + + test('readFile lazily registers a schema + association for the session', async () => { + const { fs, uri, session } = createHarness({ + schema: { + type: 'object', + properties: { + autoApprove: { type: 'string', title: 'Auto Approve', sessionMutable: true, enum: ['default', 'autoApprove'] }, + }, + }, + values: { autoApprove: 'default' }, + }); + const schemaId = expectedSchemaId(session); + + // No registration before the file is read. + assert.strictEqual(schemaRegistry.hasSchemaContent(schemaId), false); + assert.strictEqual(schemaRegistry.getSchemaAssociations()[schemaId], undefined); + + await fs.readFile(uri); + + assert.strictEqual(schemaRegistry.hasSchemaContent(schemaId), true); + assert.deepStrictEqual(schemaRegistry.getSchemaAssociations()[schemaId], [uri.toString()]); + }); + + test('schema is refreshed when onDidChangeSessionConfig fires with a new schema identity', async () => { + const { fs, uri, session, sessionProvider } = createHarness({ + schema: { + type: 'object', + properties: { + autoApprove: { type: 'string', title: 'Auto Approve', sessionMutable: true, enum: ['default'] }, + }, + }, + values: { autoApprove: 'default' }, + }); + const schemaId = expectedSchemaId(session); + + // Trigger initial registration. + await fs.readFile(uri); + const initial = schemaRegistry.getSchemaContributions().schemas[schemaId]; + assert.ok(initial); + + // Swap in a new schema (identity change) and notify. + sessionProvider.config = { + schema: { + type: 'object', + properties: { + autoApprove: { type: 'string', title: 'Auto Approve', sessionMutable: true, enum: ['default', 'autoApprove'] }, + mode: { type: 'string', title: 'Mode', sessionMutable: true, enum: ['a', 'b'] }, + }, + }, + values: { autoApprove: 'default', mode: 'a' }, + }; + sessionProvider.onDidChangeSessionConfigEmitter.fire(session.sessionId); + + const refreshed = schemaRegistry.getSchemaContributions().schemas[schemaId]; + assert.notStrictEqual(refreshed, initial); + assert.ok(refreshed.properties?.['mode'], 'refreshed schema should include the newly added property'); + }); + + test('schema is disposed when the session is removed', async () => { + const { fs, uri, session, sessionProvider } = createHarness({ + schema: { + type: 'object', + properties: { + autoApprove: { type: 'string', title: 'Auto Approve', sessionMutable: true, enum: ['default'] }, + }, + }, + values: { autoApprove: 'default' }, + }); + const schemaId = expectedSchemaId(session); + + await fs.readFile(uri); + assert.strictEqual(schemaRegistry.hasSchemaContent(schemaId), true); + + sessionProvider.onDidChangeSessionsEmitter.fire({ added: [], removed: [session], changed: [] }); + + assert.strictEqual(schemaRegistry.hasSchemaContent(schemaId), false); + assert.strictEqual(schemaRegistry.getSchemaAssociations()[schemaId], undefined); + }); + }); }); From 1453f5b4e9bf48afa04851df38ae6f6e797d23f4 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Wed, 22 Apr 2026 14:35:39 -0700 Subject: [PATCH 10/70] agentHost: rationalize settings a bit In advance of host-level settings, replaces our ad-hoc setting handling with a IAgentConfigurationService. This handles inheritance correctly. We also have a 'schema builder' now that allows for better type safety as we produce and consume schema. - New `IAgentConfigurationService` with `getEffectiveValue`, `getEffectiveWorkingDirectory`, and `updateSessionConfig`. Owns the inheritance chain so every consumer composes layers identically. - New schema builder (`schemaProperty` / `createSchema`) with phantom TypeScript types + derived runtime validators. `assertValid` throws a `ProtocolError(InvalidParams)` annotated with the offending dotted path (e.g. `permissions.allow[2]`); `validate` is the boolean form; `values()` validates at write sites; `validateOrDefault()` sanitizes untrusted input at protocol boundaries. - `platformSessionSchema` centralizes the `autoApprove` + `permissions` property descriptors that `copilotAgent.resolveSessionConfig` previously duplicated; agents compose via `...platformSessionSchema .definition`. - `getEffectiveValue` validates each layer against a caller-supplied schema and falls through when a layer's value is malformed, logging the path-annotated reason. - Wire DI through `AgentService` (local `InstantiationService` scope) so `AgentSideEffects` and `SessionPermissionManager` resolve `ILogService` and `IAgentConfigurationService` via DI rather than plain-class plumbing. - `copilotAgent.resolveSessionConfig` collapses to a single schema construction + `validateOrDefault` call; duplicated schema literals removed. - Tests: 33 new unit tests covering schema validation, path-annotated errors, `validateOrDefault`, and the full precedence chain in `AgentConfigurationService` --- .../agentHost/common/agentHostSchema.ts | 304 ++++++++++++++++++ .../platform/agentHost/common/agentService.ts | 2 - .../agentHost/common/sessionConfigKeys.ts | 38 +++ .../node/agentConfigurationService.ts | 138 ++++++++ .../platform/agentHost/node/agentService.ts | 18 +- .../agentHost/node/agentSideEffects.ts | 6 +- .../agentHost/node/copilot/copilotAgent.ts | 129 +++----- .../agentHost/node/sessionPermissions.ts | 97 +++--- .../test/common/agentHostSchema.test.ts | 282 ++++++++++++++++ .../node/agentConfigurationService.test.ts | 172 ++++++++++ .../test/node/agentSideEffects.test.ts | 38 ++- .../agentHostPermissionPickerDelegate.ts | 26 +- .../agentHost/agentHostSessionConfigPicker.ts | 14 +- .../agentHost/agentHostSessionHandler.ts | 5 +- .../agentHostChatContribution.test.ts | 5 +- 15 files changed, 1080 insertions(+), 194 deletions(-) create mode 100644 src/vs/platform/agentHost/common/agentHostSchema.ts create mode 100644 src/vs/platform/agentHost/common/sessionConfigKeys.ts create mode 100644 src/vs/platform/agentHost/node/agentConfigurationService.ts create mode 100644 src/vs/platform/agentHost/test/common/agentHostSchema.test.ts create mode 100644 src/vs/platform/agentHost/test/node/agentConfigurationService.test.ts diff --git a/src/vs/platform/agentHost/common/agentHostSchema.ts b/src/vs/platform/agentHost/common/agentHostSchema.ts new file mode 100644 index 0000000000000..26c962710ad36 --- /dev/null +++ b/src/vs/platform/agentHost/common/agentHostSchema.ts @@ -0,0 +1,304 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize } from '../../../nls.js'; +import { SessionConfigKey } from './sessionConfigKeys.js'; +import type { ISessionConfigPropertySchema, ISessionConfigSchema } from './state/protocol/commands.js'; +import { JsonRpcErrorCodes, ProtocolError } from './state/sessionProtocol.js'; + +// ---- Schema builder -------------------------------------------------------- + +/** + * A schema property with a phantom TypeScript type and a precomputed + * runtime validator. + * + * The `` type parameter is the developer's assertion about the + * property's runtime shape; the validator derived from `protocol` + * (`type`, `enum`, `items`, `properties`, `required`) enforces it at + * runtime. + */ +export interface ISchemaProperty { + readonly protocol: ISessionConfigPropertySchema; + /** + * Returns `true` iff `value` conforms to {@link protocol}. Narrows + * the type to `T` for callers. The boolean form is preferred for + * control flow; use {@link assertValid} when you want a descriptive + * error for the offending path. + */ + validate(value: unknown): value is T; + /** + * Throws a {@link ProtocolError} with `JsonRpcErrorCodes.InvalidParams` + * describing the offending path (e.g. `'permissions.allow[2]'`) when + * `value` does not conform to {@link protocol}. Otherwise returns and + * narrows the type to `T`. + * + * @param path Dotted path prefix to embed in error messages. Defaults + * to empty (the value itself). + */ + assertValid(value: unknown, path?: string): asserts value is T; +} + +/** + * Defines a strongly-typed schema property whose runtime validator is + * derived from the supplied JSON-schema descriptor. + */ +export function schemaProperty(protocol: ISessionConfigPropertySchema): ISchemaProperty { + const assertFn = buildAssert(protocol); + const assertValid = (value: unknown, path: string = ''): asserts value is T => assertFn(value, path); + const validate = (value: unknown): value is T => { + try { + assertFn(value, ''); + return true; + } catch { + return false; + } + }; + return { protocol, validate, assertValid }; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type SchemaDefinition = Record>; + +export type SchemaValue

= P extends ISchemaProperty ? T : never; + +export type SchemaValues = { + [K in keyof D]?: SchemaValue; +}; + +/** + * A bundle of named schema properties plus helpers for serializing to the + * protocol shape, validating a values bag at write sites, and validating + * a single key at read sites. + */ +export interface ISchema { + readonly definition: D; + /** Returns the protocol-serializable schema for this bundle. */ + toProtocol(): ISessionConfigSchema; + /** + * Validates each known key in `values` against its schema and returns + * a new plain record. Throws a {@link ProtocolError} with a path like + * `'permissions.allow[2]'` when any supplied value fails validation. + * Unknown keys are passed through untouched for forward-compatibility. + */ + values(values: SchemaValues): Record; + /** + * Returns `true` iff `value` validates against the schema for `key`. + * Unknown keys return `false`. + */ + validate(key: K, value: unknown): value is SchemaValue; + /** + * Throws a {@link ProtocolError} describing the offending path when + * `value` does not validate against the schema for `key`, or when + * `key` is not defined in the schema. + */ + assertValid(key: K, value: unknown): asserts value is SchemaValue; + /** + * Returns a fully-typed values bag by validating each key of + * `defaults` against `values` and falling back to the default when + * the incoming value is missing or fails validation. + * + * Intended for sanitizing untrusted input at protocol boundaries + * (e.g. `resolveSessionConfig`), where callers want a complete + * type-safe object rather than a throw-on-first-error response. + * Keys that fail validation are silently replaced with their + * default; use {@link values} or {@link assertValid} when you want + * a descriptive {@link ProtocolError} instead. + */ + validateOrDefault }>(values: Record | undefined, defaults: T): T; +} + +export function createSchema(definition: D): ISchema { + return { + definition, + toProtocol(): ISessionConfigSchema { + const properties: Record = {}; + for (const key of Object.keys(definition)) { + properties[key] = definition[key].protocol; + } + return { type: 'object', properties }; + }, + values(values) { + const raw = values as Record; + for (const key of Object.keys(definition)) { + const value = raw[key]; + if (value === undefined) { + continue; + } + // Local with explicit annotation so TypeScript accepts the + // assertion-signature call (per TS4104). + const prop: ISchemaProperty = definition[key]; + prop.assertValid(value, key); + } + return { ...raw }; + }, + validate(key: K, value: unknown): value is SchemaValue { + const prop = definition[key]; + return prop ? prop.validate(value) : false; + }, + assertValid(key: K, value: unknown): asserts value is SchemaValue { + const prop: ISchemaProperty | undefined = definition[key]; + if (!prop) { + throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, `Unknown schema key '${key}'`); + } + // Re-bind post-narrowing to keep the call target explicitly typed + // (required for assertion-signature calls, TS4104). + const narrowed: ISchemaProperty = prop; + narrowed.assertValid(value, key); + }, + validateOrDefault }>(values: Record | undefined, defaults: T): T { + const result: Record = {}; + for (const key of Object.keys(defaults)) { + const raw = values?.[key]; + const prop = definition[key]; + result[key] = prop && raw !== undefined && prop.validate(raw) + ? raw + : (defaults as Record)[key]; + } + return result as T; + }, + }; +} + +// ---- Validator derivation -------------------------------------------------- + +/** + * A validator that throws a {@link ProtocolError} annotated with the + * offending path when `value` does not conform, or returns normally + * when it does. + */ +type AssertValidator = (value: unknown, path: string) => void; + +function buildAssert(schema: ISessionConfigPropertySchema): AssertValidator { + if (schema.type === 'object' && schema.properties) { + const propAsserts: Record = {}; + for (const key of Object.keys(schema.properties)) { + propAsserts[key] = buildAssert(schema.properties[key] as ISessionConfigPropertySchema); + } + const required = new Set(schema.required ?? []); + return (value, path) => { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw invalidParams(path, 'object', value); + } + const obj = value as Record; + for (const key of Object.keys(propAsserts)) { + const childPath = joinPath(path, key); + if (obj[key] === undefined) { + if (required.has(key)) { + throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, `Missing required property at '${childPath}'`); + } + continue; + } + propAsserts[key](obj[key], childPath); + } + }; + } + if (schema.type === 'array' && schema.items) { + const itemAssert = buildAssert(schema.items as ISessionConfigPropertySchema); + return (value, path) => { + if (!Array.isArray(value)) { + throw invalidParams(path, 'array', value); + } + for (let i = 0; i < value.length; i++) { + itemAssert(value[i], `${path}[${i}]`); + } + }; + } + return buildPrimitiveAssert(schema); +} + +function buildPrimitiveAssert(schema: ISessionConfigPropertySchema): AssertValidator { + const enumDynamic = schema.enumDynamic === true; + return (value, path) => { + switch (schema.type) { + case 'string': if (typeof value !== 'string') { throw invalidParams(path, 'string', value); } break; + case 'number': if (typeof value !== 'number') { throw invalidParams(path, 'number', value); } break; + case 'boolean': if (typeof value !== 'boolean') { throw invalidParams(path, 'boolean', value); } break; + case 'array': if (!Array.isArray(value)) { throw invalidParams(path, 'array', value); } break; + case 'object': if (typeof value !== 'object' || value === null || Array.isArray(value)) { throw invalidParams(path, 'object', value); } break; + } + if (schema.enum && !enumDynamic && !schema.enum.includes(value as string)) { + throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, `Invalid value at '${path || ''}': ${safeStringify(value)} is not one of [${schema.enum.map(v => JSON.stringify(v)).join(', ')}]`); + } + }; +} + +function invalidParams(path: string, expected: string, value: unknown): ProtocolError { + return new ProtocolError(JsonRpcErrorCodes.InvalidParams, `Invalid value at '${path || ''}': expected ${expected}, got ${safeStringify(value)}`); +} + +function joinPath(parent: string, key: string): string { + return parent ? `${parent}.${key}` : key; +} + +function safeStringify(value: unknown): string { + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +// ---- Platform-owned schema ------------------------------------------------- + +export type AutoApproveLevel = 'default' | 'autoApprove' | 'autopilot'; + +export interface IPermissionsValue { + readonly allow: readonly string[]; + readonly deny: readonly string[]; +} + +/** + * Session-config properties owned by the platform itself — i.e. consumed + * by the agent host rather than by any particular agent. + * + * Agents extend this schema by spreading `platformSessionSchema.definition` + * into their own {@link createSchema} call together with any + * provider-specific properties. + */ +export const platformSessionSchema = createSchema({ + [SessionConfigKey.AutoApprove]: schemaProperty({ + type: 'string', + title: localize('agentHost.sessionConfig.autoApprove', "Approvals"), + description: localize('agentHost.sessionConfig.autoApproveDescription', "Tool approval behavior for this session"), + enum: ['default', 'autoApprove', 'autopilot'], + enumLabels: [ + localize('agentHost.sessionConfig.autoApprove.default', "Default Approvals"), + localize('agentHost.sessionConfig.autoApprove.bypass', "Bypass Approvals"), + localize('agentHost.sessionConfig.autoApprove.autopilot', "Autopilot (Preview)"), + ], + enumDescriptions: [ + localize('agentHost.sessionConfig.autoApprove.defaultDescription', "Copilot uses your configured settings"), + localize('agentHost.sessionConfig.autoApprove.bypassDescription', "All tool calls are auto-approved"), + localize('agentHost.sessionConfig.autoApprove.autopilotDescription', "Autonomously iterates from start to finish"), + ], + default: 'default', + sessionMutable: true, + }), + [SessionConfigKey.Permissions]: schemaProperty({ + type: 'object', + title: localize('agentHost.sessionConfig.permissions', "Permissions"), + description: localize('agentHost.sessionConfig.permissionsDescription', "Per-tool session permissions. Updated automatically when approving a tool \"in this Session\"."), + properties: { + allow: { + type: 'array', + title: localize('agentHost.sessionConfig.permissions.allow', "Allowed tools"), + items: { + type: 'string', + title: localize('agentHost.sessionConfig.permissions.toolName', "Tool name"), + }, + }, + deny: { + type: 'array', + title: localize('agentHost.sessionConfig.permissions.deny', "Denied tools"), + items: { + type: 'string', + title: localize('agentHost.sessionConfig.permissions.toolName', "Tool name"), + }, + }, + }, + default: { allow: [], deny: [] }, + sessionMutable: true, + }), +}); diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index 73d43de1fb1ae..36366c5c36f16 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -148,8 +148,6 @@ export interface IAgentCreateSessionConfig { }; } -export const AgentHostSessionConfigBranchNameHintKey = 'branchNameHint'; - export interface IAgentResolveSessionConfigParams { readonly provider?: AgentProvider; readonly workingDirectory?: URI; diff --git a/src/vs/platform/agentHost/common/sessionConfigKeys.ts b/src/vs/platform/agentHost/common/sessionConfigKeys.ts new file mode 100644 index 0000000000000..c3371bc42d6f1 --- /dev/null +++ b/src/vs/platform/agentHost/common/sessionConfigKeys.ts @@ -0,0 +1,38 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Well-known keys used in the agent-host configuration value bag. + * + * The Agent Host Protocol's config schema is intentionally generic — agents + * are free to advertise any property names. These constants capture the + * names that the platform itself consumes (e.g. {@link SessionConfigKey.AutoApprove} + * drives tool auto-approval) or that clients interpret via convention + * (e.g. {@link SessionConfigKey.Branch}, {@link SessionConfigKey.Isolation}). + * + * Agents that opt into the corresponding behavior should use these exact + * property names in their `resolveSessionConfig` response. + */ +export const enum SessionConfigKey { + /** `'autoApprove'` — tool auto-approval level. */ + AutoApprove = 'autoApprove', + /** `'permissions'` — per-tool session allow/deny lists. */ + Permissions = 'permissions', + /** `'isolation'` — `'folder'` or `'worktree'`. */ + Isolation = 'isolation', + /** `'branch'` — base branch to work from. */ + Branch = 'branch', + /** `'branchNameHint'` — client-supplied hint used during worktree creation. */ + BranchNameHint = 'branchNameHint', +} + +/** + * The set of enum values the unified permission picker understands for the + * {@link SessionConfigKey.AutoApprove} property. + * + * `default` is the required baseline level; `autoApprove` and `autopilot` + * are optional (an agent may choose to advertise a subset). + */ +export const KNOWN_AUTO_APPROVE_VALUES: ReadonlySet = new Set(['default', 'autoApprove', 'autopilot']); diff --git a/src/vs/platform/agentHost/node/agentConfigurationService.ts b/src/vs/platform/agentHost/node/agentConfigurationService.ts new file mode 100644 index 0000000000000..4b381dedfe1a1 --- /dev/null +++ b/src/vs/platform/agentHost/node/agentConfigurationService.ts @@ -0,0 +1,138 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { createDecorator } from '../../instantiation/common/instantiation.js'; +import { ILogService } from '../../log/common/log.js'; +import type { ISchema, SchemaDefinition, SchemaValue } from '../common/agentHostSchema.js'; +import { ProtocolError } from '../common/state/sessionProtocol.js'; +import { ActionType } from '../common/state/sessionActions.js'; +import { parseSubagentSessionUri, type URI as ProtocolURI } from '../common/state/sessionState.js'; +import { AgentHostStateManager } from './agentHostStateManager.js'; + +export const IAgentConfigurationService = createDecorator('agentConfigurationService'); + +/** + * Cohesive read/write surface for agent-host configuration. + * + * All platform-layer consumers (tool auto-approval, side effects, future + * host-config editors) should read and mutate config values through this + * service rather than reaching into raw session state. The service owns + * the `session → parent session → host` inheritance chain so that + * host-level defaults, subagent inheritance, and per-session overrides + * compose the same way everywhere. + * + * Reads go through a caller-supplied {@link ISchema}: each raw value is + * validated against the property's schema before being returned, so a + * malformed value in one layer transparently falls back to the next. + */ +export interface IAgentConfigurationService { + readonly _serviceBrand: undefined; + + /** + * Returns the effective value of `key` for `session`, walking the + * `session → parent session → host` chain and returning the first + * layer that provides a value which validates against + * `schema.definition[key]`. Layers that provide a malformed value + * are logged and skipped. Returns `undefined` when no layer provides + * a valid value. + */ + getEffectiveValue( + session: ProtocolURI, + schema: ISchema, + key: K, + ): SchemaValue | undefined; + + /** + * Returns the effective working directory for a session, falling back + * to the parent (subagent) session's working directory when the + * session itself does not have one set. The host layer does not carry + * a working directory. + */ + getEffectiveWorkingDirectory(session: ProtocolURI): string | undefined; + + /** + * Merges a partial config patch into a session's values via a + * {@link ActionType.SessionConfigChanged} action. Keys not present in + * `patch` are left untouched. The patch is applied atomically through + * the state manager's reducer. + */ + updateSessionConfig(session: ProtocolURI, patch: Record): void; +} + +export class AgentConfigurationService extends Disposable implements IAgentConfigurationService { + declare readonly _serviceBrand: undefined; + + constructor( + private readonly _stateManager: AgentHostStateManager, + @ILogService private readonly _logService: ILogService, + ) { + super(); + } + + getEffectiveValue( + session: ProtocolURI, + schema: ISchema, + key: K, + ): SchemaValue | undefined { + for (const values of this._effectiveChain(session)) { + const raw = values[key]; + if (raw === undefined) { + continue; + } + try { + schema.assertValid(key, raw); + return raw; + } catch (err) { + const reason = err instanceof ProtocolError ? err.message : String(err); + this._logService.warn(`[AgentConfigurationService] Value for '${key}' on ${session} failed schema validation, falling back: ${reason}`); + } + } + return undefined; + } + + getEffectiveWorkingDirectory(session: ProtocolURI): string | undefined { + const own = this._stateManager.getSessionState(session)?.summary.workingDirectory; + if (own !== undefined) { + return own; + } + const parentInfo = parseSubagentSessionUri(session); + if (parentInfo) { + return this._stateManager.getSessionState(parentInfo.parentSession)?.summary.workingDirectory; + } + return undefined; + } + + updateSessionConfig(session: ProtocolURI, patch: Record): void { + this._stateManager.dispatchServerAction({ + type: ActionType.SessionConfigChanged, + session, + config: patch, + }); + } + + /** + * Yields the raw value bags that contribute to the effective config + * for `session`, in precedence order: session, parent subagent + * session (if any), host. + */ + private *_effectiveChain(session: ProtocolURI): Iterable> { + const own = this._stateManager.getSessionState(session)?.config?.values; + if (own) { + yield own; + } + const parentInfo = parseSubagentSessionUri(session); + if (parentInfo) { + const parent = this._stateManager.getSessionState(parentInfo.parentSession)?.config?.values; + if (parent) { + yield parent; + } + } + const host = this._stateManager.rootState.config?.values; + if (host) { + yield host; + } + } +} diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 07950deae16ed..614e3a47c3782 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -11,6 +11,8 @@ import { observableValue } from '../../../base/common/observable.js'; import { URI } from '../../../base/common/uri.js'; import { generateUuid } from '../../../base/common/uuid.js'; import { FileSystemProviderErrorCode, IFileService, toFileSystemProviderErrorCode } from '../../files/common/files.js'; +import { InstantiationService } from '../../instantiation/common/instantiationService.js'; +import { ServiceCollection } from '../../instantiation/common/serviceCollection.js'; import { ILogService } from '../../log/common/log.js'; import { AgentProvider, AgentSession, IAgent, IAgentCreateSessionConfig, IAgentMessageEvent, IAgentResolveSessionConfigParams, IAgentService, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSubagentStartedEvent, IAgentToolCompleteEvent, IAgentToolStartEvent, IAuthenticateParams, IAuthenticateResult } from '../common/agentService.js'; import { ISessionDataService } from '../common/sessionDataService.js'; @@ -19,6 +21,7 @@ import type { ICreateTerminalParams, IResolveSessionConfigResult, ISessionConfig import { AhpErrorCodes, AHP_SESSION_NOT_FOUND, ContentEncoding, JSON_RPC_INTERNAL_ERROR, ProtocolError, type IDirectoryEntry, type IResourceCopyParams, type IResourceCopyResult, type IResourceDeleteParams, type IResourceDeleteResult, type IResourceListResult, type IResourceMoveParams, type IResourceMoveResult, type IResourceReadResult, type IResourceWriteParams, type IResourceWriteResult, type IStateSnapshot } from '../common/state/sessionProtocol.js'; import { ResponsePartKind, SessionStatus, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildSubagentSessionUri, parseSubagentSessionUri, type IResponsePart, type ISessionConfigState, type ISessionFileDiff, type ISessionSummary, type IToolCallCompletedState, type IToolResultSubagentContent, type ITurn } from '../common/state/sessionState.js'; import { IProductService } from '../../product/common/productService.js'; +import { AgentConfigurationService, IAgentConfigurationService } from './agentConfigurationService.js'; import { AgentSideEffects } from './agentSideEffects.js'; import { AgentHostTerminalManager, type IAgentHostTerminalManager } from './agentHostTerminalManager.js'; import { ISessionDbUriFields, parseSessionDbUri } from './copilot/fileEditTracker.js'; @@ -91,11 +94,22 @@ export class AgentService extends Disposable implements IAgentService { this._stateManager = this._register(new AgentHostStateManager(_logService)); this._register(this._stateManager.onDidEmitEnvelope(e => this._onDidAction.fire(e))); this._register(this._stateManager.onDidEmitNotification(e => this._onDidNotification.fire(e))); - this._sideEffects = this._register(new AgentSideEffects(this._stateManager, { + + // Build a local instantiation scope so downstream components can + // consume {@link IAgentConfigurationService} (and later {@link ILogService}) + // via DI rather than being plumbed plain-class references. + const configurationService: IAgentConfigurationService = this._register(new AgentConfigurationService(this._stateManager, this._logService)); + const services = new ServiceCollection( + [ILogService, this._logService], + [IAgentConfigurationService, configurationService], + ); + const instantiationService = this._register(new InstantiationService(services, /*strict*/ true)); + + this._sideEffects = this._register(instantiationService.createInstance(AgentSideEffects, this._stateManager, { getAgent: session => this._findProviderForSession(session), sessionDataService: this._sessionDataService, agents: this._agents, - }, this._logService)); + })); // Terminal management — the terminal manager listens to the state // manager's action stream and dispatches PTY output back through it. diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index e187b4787942a..30c74fda5f18a 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -11,6 +11,7 @@ import { hasKey } from '../../../base/common/types.js'; import { URI } from '../../../base/common/uri.js'; import { generateUuid } from '../../../base/common/uuid.js'; import { ILogService } from '../../log/common/log.js'; +import { IInstantiationService } from '../../instantiation/common/instantiation.js'; import { IAgent, IAgentAttachment, IAgentProgressEvent, type IAgentToolCompleteEvent, type IAgentToolReadyEvent } from '../common/agentService.js'; import { IDiffComputeService } from '../common/diffComputeService.js'; import { ISessionDataService } from '../common/sessionDataService.js'; @@ -104,11 +105,12 @@ export class AgentSideEffects extends Disposable { constructor( private readonly _stateManager: AgentHostStateManager, private readonly _options: IAgentSideEffectsOptions, - private readonly _logService: ILogService, + @IInstantiationService instantiationService: IInstantiationService, + @ILogService private readonly _logService: ILogService, ) { super(); this._diffComputeService = this._register(new NodeWorkerDiffComputeService(this._logService)); - this._permissionManager = this._register(new SessionPermissionManager(this._stateManager, this._logService)); + this._permissionManager = this._register(instantiationService.createInstance(SessionPermissionManager, this._stateManager)); // Whenever the agents observable changes, publish to root state. this._register(autorun(reader => { diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 356f703aabead..8f7fb9dd6b35f 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -23,7 +23,9 @@ import { IFileService } from '../../../files/common/files.js'; import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; import { ILogService } from '../../../log/common/log.js'; import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js'; -import { AgentHostSessionConfigBranchNameHintKey, AgentSession, IAgent, IAgentAttachment, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDescriptor, IAgentDeltaEvent, IAgentMessageEvent, IAgentModelInfo, IAgentProgressEvent, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSessionProjectInfo, IAgentSubagentStartedEvent, IAgentToolCompleteEvent, IAgentToolStartEvent } from '../../common/agentService.js'; +import { AgentSession, IAgent, IAgentAttachment, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDescriptor, IAgentDeltaEvent, IAgentMessageEvent, IAgentModelInfo, IAgentProgressEvent, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSessionProjectInfo, IAgentSubagentStartedEvent, IAgentToolCompleteEvent, IAgentToolStartEvent } from '../../common/agentService.js'; +import { AutoApproveLevel, IPermissionsValue, ISchemaProperty, createSchema, platformSessionSchema, schemaProperty } from '../../common/agentHostSchema.js'; +import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { ISessionDataService, SESSION_DB_FILENAME } from '../../common/sessionDataService.js'; import type { IResolveSessionConfigResult, ISessionConfigCompletionsResult } from '../../common/state/protocol/commands.js'; import { IProtectedResourceMetadata, type IConfigSchema, type IModelSelection, type IToolDefinition } from '../../common/state/protocol/state.js'; @@ -31,7 +33,6 @@ import { AHP_AUTH_REQUIRED, ProtocolError } from '../../common/state/sessionProt import { CustomizationStatus, ICustomizationRef, SessionInputResponseKind, type IPendingMessage, type ISessionInputAnswer, type IToolCallResult, type PolicyState } from '../../common/state/sessionState.js'; import { IAgentHostGitService } from '../agentHostGitService.js'; import { IAgentHostTerminalManager } from '../agentHostTerminalManager.js'; -import { SessionPermissionManager } from '../sessionPermissions.js'; import { CopilotAgentSession, SessionWrapperFactory, type IActiveClientSnapshot } from './copilotAgentSession.js'; import { ICopilotSessionContext, projectFromCopilotContext } from './copilotGitProject.js'; import { parsedPluginsEqual, toSdkCustomAgents, toSdkHooks, toSdkMcpServers, toSdkSkillDirectories } from './copilotPluginConverters.js'; @@ -551,99 +552,57 @@ export class CopilotAgent extends Disposable implements IAgent { async resolveSessionConfig(params: IAgentResolveSessionConfigParams): Promise { const gitInfo = params.workingDirectory ? await this._getGitInfo(params.workingDirectory) : undefined; - const isolationValue = params.config?.isolation === 'folder' || params.config?.isolation === 'worktree' - ? params.config.isolation - : gitInfo ? 'worktree' : 'folder'; - - const autoApproveValue = params.config?.autoApprove === 'default' || params.config?.autoApprove === 'autoApprove' || params.config?.autoApprove === 'autopilot' - ? params.config.autoApprove - : 'default'; - - const values: Record = { - isolation: isolationValue, - autoApprove: autoApproveValue, - [SessionPermissionManager.PERMISSIONS_CONFIG_KEY]: params.config?.[SessionPermissionManager.PERMISSIONS_CONFIG_KEY] || { allow: [], deny: [] }, - }; - if (gitInfo) { - const branchForMode = isolationValue === 'worktree' ? gitInfo.defaultBranch : gitInfo.currentBranch; - values.branch = typeof params.config?.branch === 'string' && isolationValue === 'worktree' - ? params.config.branch - : branchForMode; - } - const properties: IResolveSessionConfigResult['schema']['properties'] = { - isolation: { - type: 'string', - title: localize('agentHost.sessionConfig.isolation', "Isolation"), - description: localize('agentHost.sessionConfig.isolationDescription', "Where the agent should make changes"), - enum: gitInfo ? ['folder', 'worktree'] : ['folder'], - enumLabels: gitInfo ? [localize('agentHost.sessionConfig.isolation.folder', "Folder"), localize('agentHost.sessionConfig.isolation.worktree', "Worktree")] : [localize('agentHost.sessionConfig.isolation.folder', "Folder")], - enumDescriptions: gitInfo ? [localize('agentHost.sessionConfig.isolation.folderDescription', "Work directly in the folder"), localize('agentHost.sessionConfig.isolation.worktreeDescription', "Create a Git worktree for isolation")] : [localize('agentHost.sessionConfig.isolation.folderDescription', "Work directly in the folder")], - default: gitInfo ? 'worktree' : 'folder', - readOnly: !gitInfo, - }, - autoApprove: { - type: 'string', - title: localize('agentHost.sessionConfig.autoApprove', "Approvals"), - description: localize('agentHost.sessionConfig.autoApproveDescription', "Tool approval behavior for this session"), - enum: ['default', 'autoApprove', 'autopilot'], - enumLabels: [ - localize('agentHost.sessionConfig.autoApprove.default', "Default Approvals"), - localize('agentHost.sessionConfig.autoApprove.bypass', "Bypass Approvals"), - localize('agentHost.sessionConfig.autoApprove.autopilot', "Autopilot (Preview)"), - ], - enumDescriptions: [ - localize('agentHost.sessionConfig.autoApprove.defaultDescription', "Copilot uses your configured settings"), - localize('agentHost.sessionConfig.autoApprove.bypassDescription', "All tool calls are auto-approved"), - localize('agentHost.sessionConfig.autoApprove.autopilotDescription', "Autonomously iterates from start to finish"), - ], - default: 'default', - sessionMutable: true, - }, - [SessionPermissionManager.PERMISSIONS_CONFIG_KEY]: { - type: 'object', - title: localize('agentHost.sessionConfig.permissions', "Permissions"), - description: localize('agentHost.sessionConfig.permissionsDescription', "Per-tool session permissions. Updated automatically when approving a tool \"in this Session\"."), - properties: { - allow: { - type: 'array', - title: localize('agentHost.sessionConfig.permissions.allow', "Allowed tools"), - items: { - type: 'string', - title: localize('agentHost.sessionConfig.permissions.toolName', "Tool name"), - }, - }, - deny: { - type: 'array', - title: localize('agentHost.sessionConfig.permissions.deny', "Denied tools"), - items: { - type: 'string', - title: localize('agentHost.sessionConfig.permissions.toolName', "Tool name"), - }, - }, - }, - default: { allow: [], deny: [] }, - sessionMutable: true, - }, - }; + const isolationProperty = schemaProperty<'folder' | 'worktree'>({ + type: 'string', + title: localize('agentHost.sessionConfig.isolation', "Isolation"), + description: localize('agentHost.sessionConfig.isolationDescription', "Where the agent should make changes"), + enum: gitInfo ? ['folder', 'worktree'] : ['folder'], + enumLabels: gitInfo ? [localize('agentHost.sessionConfig.isolation.folder', "Folder"), localize('agentHost.sessionConfig.isolation.worktree', "Worktree")] : [localize('agentHost.sessionConfig.isolation.folder', "Folder")], + enumDescriptions: gitInfo ? [localize('agentHost.sessionConfig.isolation.folderDescription', "Work directly in the folder"), localize('agentHost.sessionConfig.isolation.worktreeDescription', "Create a Git worktree for isolation")] : [localize('agentHost.sessionConfig.isolation.folderDescription', "Work directly in the folder")], + default: gitInfo ? 'worktree' : 'folder', + readOnly: !gitInfo, + }); + + // Resolve isolation first — downstream schema shapes (branch's + // read-only mode + enum restriction) depend on the effective value. + const isolationDefault: 'folder' | 'worktree' = gitInfo ? 'worktree' : 'folder'; + const isolationValue = isolationProperty.validate(params.config?.[SessionConfigKey.Isolation]) + ? params.config[SessionConfigKey.Isolation] as 'folder' | 'worktree' + : isolationDefault; + let branchProperty: ISchemaProperty | undefined; + let branchDefault: string | undefined; if (gitInfo) { const branchReadOnly = isolationValue === 'folder'; - const branchForMode = isolationValue === 'worktree' ? gitInfo.defaultBranch : gitInfo.currentBranch; - properties.branch = { + branchDefault = isolationValue === 'worktree' ? gitInfo.defaultBranch : gitInfo.currentBranch; + branchProperty = schemaProperty({ type: 'string', title: localize('agentHost.sessionConfig.branch', "Branch"), description: localize('agentHost.sessionConfig.branchDescription', "Base branch to work from"), - enum: [branchForMode], - enumLabels: [branchForMode], - default: branchForMode, + enum: [branchDefault], + enumLabels: [branchDefault], + default: branchDefault, enumDynamic: !branchReadOnly, readOnly: branchReadOnly, - }; + }); } + const sessionSchema = createSchema({ + [SessionConfigKey.Isolation]: isolationProperty, + ...platformSessionSchema.definition, + ...(branchProperty ? { [SessionConfigKey.Branch]: branchProperty } : {}), + }); + + const values = sessionSchema.validateOrDefault(params.config, { + [SessionConfigKey.Isolation]: isolationValue, + [SessionConfigKey.AutoApprove]: 'default' satisfies AutoApproveLevel, + [SessionConfigKey.Permissions]: { allow: [], deny: [] } satisfies IPermissionsValue, + ...(branchDefault !== undefined ? { [SessionConfigKey.Branch]: branchDefault } : {}), + }); + return { - schema: { type: 'object', properties }, + schema: sessionSchema.toProtocol(), values, }; } @@ -1002,12 +961,12 @@ export class CopilotAgent extends Disposable implements IAgent { } const worktreesRoot = getCopilotWorktreesRoot(repositoryRoot); - const branchNameHintRaw = config.config[AgentHostSessionConfigBranchNameHintKey]; + const branchNameHintRaw = config.config[SessionConfigKey.BranchNameHint]; const branchNameHint = typeof branchNameHintRaw === 'string' ? branchNameHintRaw : undefined; const branchName = getCopilotWorktreeBranchName(sessionId, branchNameHint); const worktree = URI.joinPath(worktreesRoot, getCopilotWorktreeName(branchName)); await fs.mkdir(worktreesRoot.fsPath, { recursive: true }); - const baseBranch = typeof config.config.branch === 'string' ? config.config.branch : undefined; + const baseBranch = typeof config.config[SessionConfigKey.Branch] === 'string' ? config.config[SessionConfigKey.Branch] as string : undefined; // `addWorktree`'s signature requires a startPoint, but historically the // runtime accepted undefined when `branch` was not set in config. Preserve // that behavior by passing through whatever value (or undefined) was set. diff --git a/src/vs/platform/agentHost/node/sessionPermissions.ts b/src/vs/platform/agentHost/node/sessionPermissions.ts index 3599f61a96ce3..4bd8703fab99b 100644 --- a/src/vs/platform/agentHost/node/sessionPermissions.ts +++ b/src/vs/platform/agentHost/node/sessionPermissions.ts @@ -10,14 +10,16 @@ import { URI } from '../../../base/common/uri.js'; import { localize } from '../../../nls.js'; import { ILogService } from '../../log/common/log.js'; import type { IAgentToolReadyEvent } from '../common/agentService.js'; +import { platformSessionSchema } from '../common/agentHostSchema.js'; +import { SessionConfigKey } from '../common/sessionConfigKeys.js'; import { ConfirmationOptionKind, type IConfirmationOption } from '../common/state/protocol/state.js'; import { ActionType, type IToolCallReadyAction } from '../common/state/sessionActions.js'; import { ResponsePartKind, ToolCallConfirmationReason, - parseSubagentSessionUri, type URI as ProtocolURI, } from '../common/state/sessionState.js'; +import { IAgentConfigurationService } from './agentConfigurationService.js'; import { AgentHostStateManager } from './agentHostStateManager.js'; import { CommandAutoApprover } from './commandAutoApprover.js'; @@ -34,6 +36,25 @@ export interface IToolApprovalEvent { readonly toolInput?: string; } +/** Standard per-tool confirmation options presented to the user. */ +const ALLOW_SESSION_OPTION_ID = 'allow-session'; +const CONFIRMATION_OPTIONS: readonly IConfirmationOption[] = [ + { id: ALLOW_SESSION_OPTION_ID, label: localize('sessionPermissions.allowSession', "Allow in this Session"), kind: ConfirmationOptionKind.Approve, group: 1 }, + { id: 'allow-once', label: localize('sessionPermissions.allowOnce', "Allow Once"), kind: ConfirmationOptionKind.Approve }, + { id: 'skip', label: localize('sessionPermissions.skip', "Skip"), kind: ConfirmationOptionKind.Deny, group: 2 }, +]; + +/** Default write-path glob rules applied to auto-approved edits. */ +const DEFAULT_EDIT_AUTO_APPROVE_PATTERNS: Readonly> = { + '**/*': true, + '**/.vscode/*.json': false, + '**/.git/**': false, + '**/{package.json,server.xml,build.rs,web.config,.gitattributes,.env}': false, + '**/*.{code-workspace,csproj,fsproj,vbproj,vcxproj,proj,targets,props}': false, + '**/*.lock': false, + '**/*-lock.{yaml,json}': false, +}; + /** * Single entry point for all tool-call approval logic in the agent host. * @@ -56,32 +77,14 @@ export interface IToolApprovalEvent { */ export class SessionPermissionManager extends Disposable { - static readonly PERMISSIONS_CONFIG_KEY = 'permissions'; - static readonly ALLOW_SESSION_OPTION_ID = 'allow-session'; - - private static readonly _CONFIRMATION_OPTIONS: readonly IConfirmationOption[] = [ - { id: SessionPermissionManager.ALLOW_SESSION_OPTION_ID, label: localize('sessionPermissions.allowSession', "Allow in this Session"), kind: ConfirmationOptionKind.Approve, group: 1 }, - { id: 'allow-once', label: localize('sessionPermissions.allowOnce', "Allow Once"), kind: ConfirmationOptionKind.Approve }, - { id: 'skip', label: localize('sessionPermissions.skip', "Skip"), kind: ConfirmationOptionKind.Deny, group: 2 }, - ]; - // ---- Edit auto-approve patterns ----------------------------------------- - private static readonly _DEFAULT_EDIT_AUTO_APPROVE_PATTERNS: Readonly> = { - '**/*': true, - '**/.vscode/*.json': false, - '**/.git/**': false, - '**/{package.json,server.xml,build.rs,web.config,.gitattributes,.env}': false, - '**/*.{code-workspace,csproj,fsproj,vbproj,vcxproj,proj,targets,props}': false, - '**/*.lock': false, - '**/*-lock.{yaml,json}': false, - }; - private readonly _commandAutoApprover: CommandAutoApprover; constructor( private readonly _stateManager: AgentHostStateManager, - private readonly _logService: ILogService, + @IAgentConfigurationService private readonly _configService: IAgentConfigurationService, + @ILogService private readonly _logService: ILogService, ) { super(); this._commandAutoApprover = this._register(new CommandAutoApprover(this._logService)); @@ -112,7 +115,8 @@ export class SessionPermissionManager extends Disposable { * 5. Shell command rules (tree-sitter parsed, default allow/deny) */ getAutoApproval(e: IToolApprovalEvent, sessionKey: ProtocolURI): ToolCallConfirmationReason | undefined { - const { autoApproveLevel, workDir } = this._getInheritedConfig(sessionKey); + const autoApproveLevel = this._configService.getEffectiveValue(sessionKey, platformSessionSchema, SessionConfigKey.AutoApprove); + const workDir = this._configService.getEffectiveWorkingDirectory(sessionKey); // 1. Session-level auto-approve if (autoApproveLevel === 'autoApprove' || autoApproveLevel === 'autopilot') { @@ -178,7 +182,7 @@ export class SessionPermissionManager extends Disposable { toolInput: e.toolInput, confirmationTitle: e.confirmationTitle, edits: e.edits, - options: SessionPermissionManager._CONFIRMATION_OPTIONS.slice(), + options: CONFIRMATION_OPTIONS.slice(), }; } return { @@ -200,7 +204,7 @@ export class SessionPermissionManager extends Disposable { * permission allow list so future calls are auto-approved. */ handleToolCallConfirmed(sessionKey: ProtocolURI, toolCallId: string, selectedOptionId: string | undefined): void { - if (selectedOptionId === SessionPermissionManager.ALLOW_SESSION_OPTION_ID) { + if (selectedOptionId === ALLOW_SESSION_OPTION_ID) { const toolName = this._getToolNameForToolCall(sessionKey, toolCallId); if (toolName) { this._addToolToSessionPermissions(sessionKey, toolName); @@ -210,16 +214,6 @@ export class SessionPermissionManager extends Disposable { // ---- Internal helpers --------------------------------------------------- - private _getInheritedConfig(sessionKey: ProtocolURI): { autoApproveLevel: unknown | undefined; workDir: string | undefined } { - const sessionState = this._stateManager.getSessionState(sessionKey); - const parentInfo = parseSubagentSessionUri(sessionKey); - const parentState = parentInfo ? this._stateManager.getSessionState(parentInfo.parentSession) : undefined; - return { - autoApproveLevel: sessionState?.config?.values?.autoApprove ?? parentState?.config?.values?.autoApprove, - workDir: sessionState?.summary.workingDirectory ?? parentState?.summary.workingDirectory, - }; - } - private _isPathInWorkingDirectory(filePath: string, workDir: string | undefined): boolean { if (!workDir) { return false; @@ -229,9 +223,8 @@ export class SessionPermissionManager extends Disposable { } private _isEditAutoApproved(filePath: string): boolean { - const patterns = SessionPermissionManager._DEFAULT_EDIT_AUTO_APPROVE_PATTERNS; let approved = true; - for (const [pattern, isApproved] of Object.entries(patterns)) { + for (const [pattern, isApproved] of Object.entries(DEFAULT_EDIT_AUTO_APPROVE_PATTERNS)) { if (isApproved !== approved && globMatch(pattern, filePath)) { approved = isApproved; } @@ -244,29 +237,14 @@ export class SessionPermissionManager extends Disposable { if (!toolName) { return false; } - const allowed = this._getPermissions(sessionKey).allow.includes(toolName); + const permissions = this._configService.getEffectiveValue(sessionKey, platformSessionSchema, SessionConfigKey.Permissions); + const allowed = permissions?.allow.includes(toolName) ?? false; if (allowed) { this._logService.trace(`[SessionPermissionManager] Auto-approving "${toolName}" via session permissions`); } return allowed; } - private _getPermissions(sessionKey: ProtocolURI): { allow: string[]; deny: string[] } { - const sessionState = this._stateManager.getSessionState(sessionKey); - const parentInfo = parseSubagentSessionUri(sessionKey); - const parentState = parentInfo ? this._stateManager.getSessionState(parentInfo.parentSession) : undefined; - const raw = sessionState?.config?.values?.[SessionPermissionManager.PERMISSIONS_CONFIG_KEY] - ?? parentState?.config?.values?.[SessionPermissionManager.PERMISSIONS_CONFIG_KEY]; - if (raw && typeof raw === 'object' && !Array.isArray(raw)) { - const obj = raw as Record; - return { - allow: Array.isArray(obj.allow) ? obj.allow.filter((v): v is string => typeof v === 'string') : [], - deny: Array.isArray(obj.deny) ? obj.deny.filter((v): v is string => typeof v === 'string') : [], - }; - } - return { allow: [], deny: [] }; - } - private _getToolNameForToolCall(sessionKey: ProtocolURI, toolCallId: string): string | undefined { const sessionState = this._stateManager.getSessionState(sessionKey); const parts = sessionState?.activeTurn?.responseParts; @@ -282,16 +260,15 @@ export class SessionPermissionManager extends Disposable { } private _addToolToSessionPermissions(sessionKey: ProtocolURI, toolName: string): void { - const permissions = this._getPermissions(sessionKey); + const permissions = this._configService.getEffectiveValue(sessionKey, platformSessionSchema, SessionConfigKey.Permissions) + ?? { allow: [], deny: [] }; if (permissions.allow.includes(toolName)) { return; } - permissions.allow.push(toolName); - this._stateManager.dispatchServerAction({ - type: ActionType.SessionConfigChanged, - session: sessionKey, - config: { - [SessionPermissionManager.PERMISSIONS_CONFIG_KEY]: permissions, + this._configService.updateSessionConfig(sessionKey, { + [SessionConfigKey.Permissions]: { + allow: [...permissions.allow, toolName], + deny: [...permissions.deny], }, }); this._logService.info(`[SessionPermissionManager] Added "${toolName}" to session permissions for ${sessionKey}`); diff --git a/src/vs/platform/agentHost/test/common/agentHostSchema.test.ts b/src/vs/platform/agentHost/test/common/agentHostSchema.test.ts new file mode 100644 index 0000000000000..889de00678b91 --- /dev/null +++ b/src/vs/platform/agentHost/test/common/agentHostSchema.test.ts @@ -0,0 +1,282 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { createSchema, platformSessionSchema, schemaProperty, type AutoApproveLevel, type IPermissionsValue } from '../../common/agentHostSchema.js'; +import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; +import { JsonRpcErrorCodes, ProtocolError } from '../../common/state/sessionProtocol.js'; + +suite('agentHostSchema', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + // ---- schemaProperty / individual validators --------------------------- + + suite('schemaProperty', () => { + + test('validates primitive types', () => { + const str = schemaProperty({ type: 'string', title: 's' }); + assert.strictEqual(str.validate('hello'), true); + assert.strictEqual(str.validate(42), false); + assert.strictEqual(str.validate(undefined), false); + assert.strictEqual(str.validate(null), false); + + const num = schemaProperty({ type: 'number', title: 'n' }); + assert.strictEqual(num.validate(42), true); + assert.strictEqual(num.validate('42'), false); + + const bool = schemaProperty({ type: 'boolean', title: 'b' }); + assert.strictEqual(bool.validate(true), true); + assert.strictEqual(bool.validate(0), false); + }); + + test('enforces enum values', () => { + const prop = schemaProperty<'a' | 'b'>({ + type: 'string', + title: 'letters', + enum: ['a', 'b'], + }); + assert.strictEqual(prop.validate('a'), true); + assert.strictEqual(prop.validate('b'), true); + assert.strictEqual(prop.validate('c'), false); + assert.strictEqual(prop.validate(42), false); + }); + + test('enumDynamic bypasses enum check but keeps type check', () => { + const prop = schemaProperty({ + type: 'string', + title: 'dyn', + enum: ['seed'], + enumDynamic: true, + }); + assert.strictEqual(prop.validate('seed'), true); + assert.strictEqual(prop.validate('anything-else'), true); + assert.strictEqual(prop.validate(42), false); + }); + + test('validates nested objects and required keys', () => { + const prop = schemaProperty<{ name: string; age?: number }>({ + type: 'object', + title: 'person', + properties: { + name: { type: 'string', title: 'name' }, + age: { type: 'number', title: 'age' }, + }, + required: ['name'], + }); + assert.strictEqual(prop.validate({ name: 'alice' }), true); + assert.strictEqual(prop.validate({ name: 'alice', age: 30 }), true); + assert.strictEqual(prop.validate({ age: 30 }), false); + assert.strictEqual(prop.validate({ name: 42 }), false); + assert.strictEqual(prop.validate([]), false); + assert.strictEqual(prop.validate(null), false); + }); + + test('validates arrays with item schema', () => { + const prop = schemaProperty({ + type: 'array', + title: 'names', + items: { type: 'string', title: 'name' }, + }); + assert.strictEqual(prop.validate(['a', 'b']), true); + assert.strictEqual(prop.validate([]), true); + assert.strictEqual(prop.validate(['a', 42]), false); + assert.strictEqual(prop.validate('a'), false); + }); + + test('assertValid throws ProtocolError with offending path for primitive mismatch', () => { + const prop = schemaProperty({ type: 'string', title: 's' }); + assert.throws( + () => prop.assertValid(42, 'myKey'), + (err: unknown) => err instanceof ProtocolError + && err.code === JsonRpcErrorCodes.InvalidParams + && err.message.includes('myKey') + && err.message.includes('string'), + ); + }); + + test('assertValid path annotates array index and nested property', () => { + const prop = schemaProperty<{ allow: string[] }>({ + type: 'object', + title: 'perms', + properties: { + allow: { + type: 'array', + title: 'allow', + items: { type: 'string', title: 'name' }, + }, + }, + }); + assert.throws( + () => prop.assertValid({ allow: ['ok', 42] }, 'permissions'), + (err: unknown) => err instanceof ProtocolError + && err.message.includes('permissions.allow[1]') + && err.message.includes('string'), + ); + }); + + test('assertValid path reports missing required property', () => { + const prop = schemaProperty<{ name: string }>({ + type: 'object', + title: 'person', + properties: { name: { type: 'string', title: 'name' } }, + required: ['name'], + }); + assert.throws( + () => prop.assertValid({}, 'person'), + (err: unknown) => err instanceof ProtocolError + && err.message.includes('person.name') + && err.message.toLowerCase().includes('required'), + ); + }); + + test('assertValid reports enum violation with the allowed set', () => { + const prop = schemaProperty<'a' | 'b'>({ + type: 'string', + title: 'letters', + enum: ['a', 'b'], + }); + assert.throws( + () => prop.assertValid('c', 'choice'), + (err: unknown) => err instanceof ProtocolError + && err.message.includes('choice') + && err.message.includes('"a"') + && err.message.includes('"b"'), + ); + }); + }); + + // ---- createSchema ------------------------------------------------------ + + suite('createSchema', () => { + + const fixture = () => createSchema({ + name: schemaProperty({ type: 'string', title: 'name' }), + count: schemaProperty({ type: 'number', title: 'count' }), + level: schemaProperty<'low' | 'high'>({ + type: 'string', + title: 'level', + enum: ['low', 'high'], + }), + }); + + test('toProtocol emits a JSON-Schema-compatible object', () => { + const schema = fixture(); + const protocol = schema.toProtocol(); + assert.strictEqual(protocol.type, 'object'); + assert.deepStrictEqual(Object.keys(protocol.properties), ['name', 'count', 'level']); + assert.strictEqual(protocol.properties.name.type, 'string'); + assert.deepStrictEqual(protocol.properties.level.enum, ['low', 'high']); + }); + + test('validate returns false for unknown keys', () => { + const schema = fixture(); + assert.strictEqual(schema.validate('name', 'ok'), true); + assert.strictEqual(schema.validate('name', 42), false); + assert.strictEqual(schema.validate('unknown' as 'name', 'ok'), false); + }); + + test('assertValid throws for unknown keys', () => { + const schema = fixture(); + assert.throws( + () => schema.assertValid('unknown' as 'name', 'x'), + (err: unknown) => err instanceof ProtocolError && err.message.includes('unknown'), + ); + }); + + test('values returns a shallow copy and passes through unknown keys', () => { + const schema = fixture(); + const input = { name: 'alice', count: 3, extra: 'forward-compat' }; + const out = schema.values(input); + assert.notStrictEqual(out, input); + assert.deepStrictEqual(out, input); + }); + + test('values skips undefined entries without throwing', () => { + const schema = fixture(); + const out = schema.values({ name: 'alice' }); + assert.deepStrictEqual(out, { name: 'alice' }); + }); + + test('values throws a path-annotated ProtocolError on invalid entry', () => { + const schema = fixture(); + assert.throws( + () => schema.values({ name: 42 as unknown as string }), + (err: unknown) => err instanceof ProtocolError + && err.code === JsonRpcErrorCodes.InvalidParams + && err.message.includes('name'), + ); + }); + + test('definition is preserved for spread-based composition', () => { + const base = createSchema({ + a: schemaProperty({ type: 'string', title: 'a' }), + }); + const extended = createSchema({ + ...base.definition, + b: schemaProperty({ type: 'number', title: 'b' }), + }); + assert.deepStrictEqual(Object.keys(extended.toProtocol().properties), ['a', 'b']); + assert.strictEqual(extended.validate('a', 'hi'), true); + assert.strictEqual(extended.validate('b', 3), true); + }); + }); + + // ---- validateOrDefault ------------------------------------------------- + + suite('validateOrDefault', () => { + + const fixture = () => createSchema({ + name: schemaProperty({ type: 'string', title: 'name' }), + count: schemaProperty({ type: 'number', title: 'count' }), + }); + + test('substitutes defaults for missing or invalid values', () => { + const schema = fixture(); + const defaults = { name: 'default', count: 0 }; + const result = schema.validateOrDefault({ name: 42, count: 5 }, defaults); + assert.deepStrictEqual(result, { name: 'default', count: 5 }); + }); + + test('passes through all-valid values', () => { + const schema = fixture(); + const result = schema.validateOrDefault({ name: 'alice', count: 3 }, { name: 'd', count: 0 }); + assert.deepStrictEqual(result, { name: 'alice', count: 3 }); + }); + + test('uses defaults when input is undefined', () => { + const schema = fixture(); + const result = schema.validateOrDefault(undefined, { name: 'd', count: 7 }); + assert.deepStrictEqual(result, { name: 'd', count: 7 }); + }); + + test('ignores keys not in defaults', () => { + const schema = fixture(); + const result = schema.validateOrDefault({ name: 'a', count: 1, ignored: true }, { name: 'd', count: 0 }); + assert.deepStrictEqual(result, { name: 'a', count: 1 }); + }); + }); + + // ---- platformSessionSchema sanity -------------------------------------- + + suite('platformSessionSchema', () => { + + test('validates the three autoApprove levels', () => { + const levels: AutoApproveLevel[] = ['default', 'autoApprove', 'autopilot']; + for (const level of levels) { + assert.strictEqual(platformSessionSchema.validate(SessionConfigKey.AutoApprove, level), true, level); + } + assert.strictEqual(platformSessionSchema.validate(SessionConfigKey.AutoApprove, 'bogus'), false); + }); + + test('validates permissions shape', () => { + const ok: IPermissionsValue = { allow: ['read'], deny: [] }; + assert.strictEqual(platformSessionSchema.validate(SessionConfigKey.Permissions, ok), true); + assert.strictEqual(platformSessionSchema.validate(SessionConfigKey.Permissions, { allow: [42], deny: [] }), false); + assert.strictEqual(platformSessionSchema.validate(SessionConfigKey.Permissions, { allow: [] }), true); + }); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/agentConfigurationService.test.ts b/src/vs/platform/agentHost/test/node/agentConfigurationService.test.ts new file mode 100644 index 0000000000000..b69d958ae3b76 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentConfigurationService.test.ts @@ -0,0 +1,172 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { URI } from '../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { NullLogService } from '../../../log/common/log.js'; +import { createSchema, schemaProperty } from '../../common/agentHostSchema.js'; +import type { ISessionConfigState, IRootConfigState } from '../../common/state/protocol/state.js'; +import { buildSubagentSessionUri, SessionStatus, type ISessionSummary } from '../../common/state/sessionState.js'; +import { AgentConfigurationService } from '../../node/agentConfigurationService.js'; +import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; + +suite('AgentConfigurationService', () => { + + const disposables = new DisposableStore(); + let manager: AgentHostStateManager; + let service: AgentConfigurationService; + + const schema = createSchema({ + level: schemaProperty<'low' | 'high'>({ + type: 'string', + title: 'level', + enum: ['low', 'high'], + }), + limit: schemaProperty({ type: 'number', title: 'limit' }), + }); + + function seedSessionConfig(sessionUri: string, values: Record): void { + const state = manager.getSessionState(sessionUri); + assert.ok(state, `Session not found: ${sessionUri}`); + const mutable = state as { config?: ISessionConfigState }; + mutable.config = { + schema: schema.toProtocol(), + values, + }; + } + + function seedRootConfig(values: Record): void { + const rootMutable = manager.rootState as { config?: IRootConfigState }; + rootMutable.config = { + schema: schema.toProtocol(), + values, + }; + } + + function makeSummary(resource: string, workingDirectory?: string): ISessionSummary { + return { + resource, + provider: 'copilot', + title: 't', + status: SessionStatus.Idle, + createdAt: Date.now(), + modifiedAt: Date.now(), + project: { uri: 'file:///project', displayName: 'Project' }, + workingDirectory, + }; + } + + setup(() => { + manager = disposables.add(new AgentHostStateManager(new NullLogService())); + service = disposables.add(new AgentConfigurationService(manager, new NullLogService())); + }); + + teardown(() => disposables.clear()); + + ensureNoDisposablesAreLeakedInTestSuite(); + + // ---- getEffectiveValue ------------------------------------------------ + + suite('getEffectiveValue', () => { + + test('returns session value when present', () => { + const uri = URI.from({ scheme: 'copilot', path: '/a' }).toString(); + manager.createSession(makeSummary(uri)); + seedSessionConfig(uri, { level: 'high' }); + assert.strictEqual(service.getEffectiveValue(uri, schema, 'level'), 'high'); + }); + + test('falls back to host value when session does not provide the key', () => { + const uri = URI.from({ scheme: 'copilot', path: '/a' }).toString(); + manager.createSession(makeSummary(uri)); + seedSessionConfig(uri, { limit: 5 }); + seedRootConfig({ level: 'low' }); + assert.strictEqual(service.getEffectiveValue(uri, schema, 'level'), 'low'); + }); + + test('inherits from parent subagent session', () => { + const parent = URI.from({ scheme: 'copilot', path: '/parent' }).toString(); + manager.createSession(makeSummary(parent)); + seedSessionConfig(parent, { level: 'high' }); + + const child = buildSubagentSessionUri(parent, 'toolcall-1'); + manager.createSession(makeSummary(child)); + + assert.strictEqual(service.getEffectiveValue(child, schema, 'level'), 'high'); + }); + + test('session value takes precedence over parent and host', () => { + const parent = URI.from({ scheme: 'copilot', path: '/parent' }).toString(); + manager.createSession(makeSummary(parent)); + seedSessionConfig(parent, { level: 'high' }); + + const child = buildSubagentSessionUri(parent, 'tc-2'); + manager.createSession(makeSummary(child)); + seedSessionConfig(child, { level: 'low' }); + seedRootConfig({ level: 'high' }); + + assert.strictEqual(service.getEffectiveValue(child, schema, 'level'), 'low'); + }); + + test('skips layers whose value fails schema validation and falls through', () => { + const uri = URI.from({ scheme: 'copilot', path: '/a' }).toString(); + manager.createSession(makeSummary(uri)); + seedSessionConfig(uri, { level: 'bogus' }); + seedRootConfig({ level: 'high' }); + assert.strictEqual(service.getEffectiveValue(uri, schema, 'level'), 'high'); + }); + + test('returns undefined when no layer provides a valid value', () => { + const uri = URI.from({ scheme: 'copilot', path: '/a' }).toString(); + manager.createSession(makeSummary(uri)); + seedSessionConfig(uri, {}); + assert.strictEqual(service.getEffectiveValue(uri, schema, 'level'), undefined); + }); + }); + + // ---- getEffectiveWorkingDirectory ------------------------------------- + + suite('getEffectiveWorkingDirectory', () => { + + test('returns session working directory when set', () => { + const uri = URI.from({ scheme: 'copilot', path: '/a' }).toString(); + manager.createSession(makeSummary(uri, 'file:///work')); + assert.strictEqual(service.getEffectiveWorkingDirectory(uri), 'file:///work'); + }); + + test('falls back to parent session working directory for subagents', () => { + const parent = URI.from({ scheme: 'copilot', path: '/parent' }).toString(); + manager.createSession(makeSummary(parent, 'file:///work/parent')); + + const child = buildSubagentSessionUri(parent, 'tc-3'); + manager.createSession(makeSummary(child)); + assert.strictEqual(service.getEffectiveWorkingDirectory(child), 'file:///work/parent'); + }); + + test('returns undefined when neither layer has a working directory', () => { + const uri = URI.from({ scheme: 'copilot', path: '/a' }).toString(); + manager.createSession(makeSummary(uri)); + assert.strictEqual(service.getEffectiveWorkingDirectory(uri), undefined); + }); + }); + + // ---- updateSessionConfig ---------------------------------------------- + + suite('updateSessionConfig', () => { + + test('merges the patch into the session config values', () => { + const uri = URI.from({ scheme: 'copilot', path: '/a' }).toString(); + manager.createSession(makeSummary(uri)); + seedSessionConfig(uri, { level: 'low', limit: 1 }); + + service.updateSessionConfig(uri, { limit: 42 }); + + const state = manager.getSessionState(uri); + assert.deepStrictEqual(state?.config?.values, { level: 'low', limit: 42 }); + }); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index dd39b7fcd3aac..06aec06f3124c 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -14,14 +14,17 @@ import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { FileService } from '../../../files/common/fileService.js'; import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js'; -import { NullLogService } from '../../../log/common/log.js'; +import { InstantiationService } from '../../../instantiation/common/instantiationService.js'; +import { ServiceCollection } from '../../../instantiation/common/serviceCollection.js'; +import { ILogService, NullLogService } from '../../../log/common/log.js'; import { AgentSession, IAgent } from '../../common/agentService.js'; import { ISessionDataService } from '../../common/sessionDataService.js'; import { ActionType, IActionEnvelope, ISessionAction } from '../../common/state/sessionActions.js'; import { buildSubagentSessionUri, PendingMessageKind, ResponsePartKind, SessionStatus, ToolCallStatus, ToolResultContentType } from '../../common/state/sessionState.js'; import { IProductService } from '../../../product/common/productService.js'; +import { AgentConfigurationService, IAgentConfigurationService } from '../../node/agentConfigurationService.js'; import { AgentService } from '../../node/agentService.js'; -import { AgentSideEffects } from '../../node/agentSideEffects.js'; +import { AgentSideEffects, IAgentSideEffectsOptions } from '../../node/agentSideEffects.js'; import { SessionDatabase } from '../../node/sessionDatabase.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; import { createNullSessionDataService, createSessionDataService } from '../common/sessionTestHelpers.js'; @@ -29,6 +32,21 @@ import { MockAgent } from './mockAgent.js'; // ---- Tests ------------------------------------------------------------------ +/** + * Constructs an {@link AgentSideEffects} with a minimal local instantiation + * scope that satisfies its {@link IAgentConfigurationService} / + * {@link ILogService} dependencies. + */ +function createTestSideEffects(disposables: DisposableStore, stateManager: AgentHostStateManager, options: IAgentSideEffectsOptions): AgentSideEffects { + const logService = new NullLogService(); + const configService = disposables.add(new AgentConfigurationService(stateManager, logService)); + const instantiationService = disposables.add(new InstantiationService(new ServiceCollection( + [ILogService, logService], + [IAgentConfigurationService, configService], + ), /*strict*/ true)); + return disposables.add(instantiationService.createInstance(AgentSideEffects, stateManager, options)); +} + suite('AgentSideEffects', () => { const disposables = new DisposableStore(); @@ -75,11 +93,11 @@ suite('AgentSideEffects', () => { disposables.add(toDisposable(() => agent.dispose())); stateManager = disposables.add(new AgentHostStateManager(new NullLogService())); agentList = observableValue('agents', [agent]); - sideEffects = disposables.add(new AgentSideEffects(stateManager, { + sideEffects = createTestSideEffects(disposables, stateManager, { getAgent: () => agent, agents: agentList, sessionDataService: createNullSessionDataService(), - }, new NullLogService())); + }); }); teardown(() => { @@ -110,11 +128,11 @@ suite('AgentSideEffects', () => { test('dispatches session/error when no agent is found', async () => { setupSession(); const emptyAgents = observableValue('agents', []); - const noAgentSideEffects = disposables.add(new AgentSideEffects(stateManager, { + const noAgentSideEffects = createTestSideEffects(disposables, stateManager, { getAgent: () => undefined, agents: emptyAgents, sessionDataService: {} as ISessionDataService, - }, new NullLogService())); + }); const envelopes: IActionEnvelope[] = []; disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); @@ -1166,11 +1184,11 @@ suite('AgentSideEffects', () => { const localStateManager = disposables.add(new AgentHostStateManager(new NullLogService())); const localAgent = new MockAgent(); disposables.add(toDisposable(() => localAgent.dispose())); - const localSideEffects = disposables.add(new AgentSideEffects(localStateManager, { + const localSideEffects = createTestSideEffects(disposables, localStateManager, { getAgent: () => localAgent, agents: observableValue('agents', [localAgent]), sessionDataService, - }, new NullLogService())); + }); localStateManager.createSession({ resource: sessionUri.toString(), @@ -1247,11 +1265,11 @@ suite('AgentSideEffects', () => { const localStateManager = disposables.add(new AgentHostStateManager(new NullLogService())); const localAgent = new MockAgent(); disposables.add(toDisposable(() => localAgent.dispose())); - const localSideEffects = disposables.add(new AgentSideEffects(localStateManager, { + const localSideEffects = createTestSideEffects(disposables, localStateManager, { getAgent: () => localAgent, agents: observableValue('agents', [localAgent]), sessionDataService, - }, new NullLogService())); + }); const session = localStateManager.createSession({ resource: sessionUri.toString(), diff --git a/src/vs/sessions/contrib/chat/browser/agentHost/agentHostPermissionPickerDelegate.ts b/src/vs/sessions/contrib/chat/browser/agentHost/agentHostPermissionPickerDelegate.ts index 75d660a448ecc..45b0249e2b18f 100644 --- a/src/vs/sessions/contrib/chat/browser/agentHost/agentHostPermissionPickerDelegate.ts +++ b/src/vs/sessions/contrib/chat/browser/agentHost/agentHostPermissionPickerDelegate.ts @@ -5,6 +5,7 @@ import { Disposable, DisposableMap } from '../../../../../base/common/lifecycle.js'; import { derived, IObservable, IReader, observableSignal } from '../../../../../base/common/observable.js'; +import { KNOWN_AUTO_APPROVE_VALUES, SessionConfigKey } from '../../../../../platform/agentHost/common/sessionConfigKeys.js'; import { ISessionConfigPropertySchema } from '../../../../../platform/agentHost/common/state/protocol/commands.js'; import { ChatPermissionLevel, isChatPermissionLevel } from '../../../../../workbench/contrib/chat/common/constants.js'; import { IPermissionPickerDelegate } from '../../../../contrib/copilotChatSessions/browser/permissionPicker.js'; @@ -13,25 +14,6 @@ import { ISessionsProvider } from '../../../../services/sessions/common/sessions import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; import { ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; -/** - * The well-known session-config property name for tool auto-approval. The - * Agent Host Protocol's session-config schema is intentionally generic — only - * this property *name* (and the enum values below) is a convention shared - * across implementations that want to opt into VS Code's unified - * permission-picker UI. Agents that don't advertise this exact shape fall - * back to the generic per-property picker. - */ -export const AUTO_APPROVE_PROPERTY = 'autoApprove'; - -/** - * The set of enum values the unified permission picker understands for the - * `autoApprove` property. Mirrors `ChatPermissionLevel` in - * `vs/workbench/contrib/chat/common/constants.ts`. - * - * `autopilot` is optional (an agent may choose not to advertise it). - * `default` is required as the baseline level. - */ -const KNOWN_AUTO_APPROVE_VALUES: ReadonlySet = new Set(['default', 'autoApprove', 'autopilot']); const REQUIRED_AUTO_APPROVE_VALUE = 'default'; /** @@ -107,7 +89,7 @@ export class AgentHostPermissionPickerDelegate extends Disposable implements IPe if (!provider) { return; } - provider.setSessionConfigValue(session.sessionId, AUTO_APPROVE_PROPERTY, level) + provider.setSessionConfigValue(session.sessionId, SessionConfigKey.AutoApprove, level) .catch(() => { /* best-effort */ }); } @@ -121,7 +103,7 @@ export class AgentHostPermissionPickerDelegate extends Disposable implements IPe if (!provider) { return ChatPermissionLevel.Default; } - const value = provider.getSessionConfig(session.sessionId)?.values[AUTO_APPROVE_PROPERTY]; + const value = provider.getSessionConfig(session.sessionId)?.values[SessionConfigKey.AutoApprove]; return isChatPermissionLevel(value) ? value : ChatPermissionLevel.Default; } @@ -135,7 +117,7 @@ export class AgentHostPermissionPickerDelegate extends Disposable implements IPe if (!provider) { return false; } - const schema = provider.getSessionConfig(session.sessionId)?.schema.properties[AUTO_APPROVE_PROPERTY]; + const schema = provider.getSessionConfig(session.sessionId)?.schema.properties[SessionConfigKey.AutoApprove]; return !!schema && isWellKnownAutoApproveSchema(schema); } diff --git a/src/vs/sessions/contrib/chat/browser/agentHost/agentHostSessionConfigPicker.ts b/src/vs/sessions/contrib/chat/browser/agentHost/agentHostSessionConfigPicker.ts index 747a1496ebdb7..818b09802dec8 100644 --- a/src/vs/sessions/contrib/chat/browser/agentHost/agentHostSessionConfigPicker.ts +++ b/src/vs/sessions/contrib/chat/browser/agentHost/agentHostSessionConfigPicker.ts @@ -23,7 +23,6 @@ import { IConfigurationService } from '../../../../../platform/configuration/com import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; -import { AgentHostSessionConfigBranchNameHintKey } from '../../../../../platform/agentHost/common/agentService.js'; import type { ISessionConfigPropertySchema, ISessionConfigValueItem } from '../../../../../platform/agentHost/common/state/protocol/commands.js'; import { ChatConfiguration } from '../../../../../workbench/contrib/chat/common/constants.js'; import { ChatContextKeyExprs } from '../../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; @@ -37,7 +36,8 @@ import type { ISessionsProvider } from '../../../../services/sessions/common/ses import { type IAgentHostSessionsProvider, isAgentHostProvider } from '../../../../common/agentHostSessionsProvider.js'; import { PermissionPicker } from '../../../copilotChatSessions/browser/permissionPicker.js'; import { AgentHostPermissionPickerActionItem } from './agentHostPermissionPickerActionItem.js'; -import { AgentHostPermissionPickerDelegate, AUTO_APPROVE_PROPERTY, isWellKnownAutoApproveSchema } from './agentHostPermissionPickerDelegate.js'; +import { AgentHostPermissionPickerDelegate, isWellKnownAutoApproveSchema } from './agentHostPermissionPickerDelegate.js'; +import { SessionConfigKey } from '../../../../../platform/agentHost/common/sessionConfigKeys.js'; const IsActiveSessionRemoteAgentHost = ContextKeyExpr.regex(ActiveSessionProviderIdContext.key, /^agenthost-/); const IsActiveSessionLocalAgentHost = ContextKeyExpr.equals(ActiveSessionProviderIdContext.key, 'local-agent-host'); @@ -149,7 +149,7 @@ function applyAutoApproveFiltering( property: string, configurationService: IConfigurationService, ): { readonly items: readonly IConfigPickerItem[]; readonly policyRestricted: boolean } { - if (property !== AUTO_APPROVE_PROPERTY) { + if (property !== SessionConfigKey.AutoApprove) { return { items, policyRestricted: false }; } const isAutopilotEnabled = configurationService.getValue(ChatConfiguration.AutopilotEnabled) !== false; @@ -213,7 +213,7 @@ async function confirmAutoApproveLevel(value: string, dialogService: IDialogServ * Applies warning/info CSS classes to a trigger element for auto-approve levels. */ function applyAutoApproveTriggerStyles(trigger: HTMLElement, property: string | undefined, value: unknown | undefined): void { - if (property === AUTO_APPROVE_PROPERTY) { + if (property === SessionConfigKey.AutoApprove) { trigger.classList.toggle('warning', value === 'autopilot'); trigger.classList.toggle('info', value === 'autoApprove'); } @@ -291,7 +291,7 @@ class AgentHostSessionConfigPicker extends Disposable { const isNewSession = provider.getCreateSessionConfig(session.sessionId) !== undefined; for (const [property, schema] of Object.entries(resolvedConfig.schema.properties)) { - if (property === AgentHostSessionConfigBranchNameHintKey) { + if (property === SessionConfigKey.BranchNameHint) { continue; } // Only render pickers for properties we know how to present. Today @@ -311,7 +311,7 @@ class AgentHostSessionConfigPicker extends Disposable { // `Menus.NewSessionControl`) handles it — skip it here to avoid // double-rendering. Non-conforming schemas still fall through to // the generic per-property picker below. - if (property === AUTO_APPROVE_PROPERTY && isWellKnownAutoApproveSchema(schema)) { + if (property === SessionConfigKey.AutoApprove && isWellKnownAutoApproveSchema(schema)) { continue; } const value = resolvedConfig.values[property] ?? schema.default; @@ -349,7 +349,7 @@ class AgentHostSessionConfigPicker extends Disposable { return; } - const isAutoApproveProperty = property === AUTO_APPROVE_PROPERTY; + const isAutoApproveProperty = property === SessionConfigKey.AutoApprove; const currentValue = provider.getSessionConfig(sessionId)?.values[property]; const actionItems = toActionItems(property, items, currentValue, policyRestricted); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index 83c5a8568a29e..d69151b98732a 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -15,7 +15,8 @@ import { autorun, derived, IObservable, observableValue, transaction } from '../ import { isEqual } from '../../../../../../base/common/resources.js'; import { URI } from '../../../../../../base/common/uri.js'; import { localize } from '../../../../../../nls.js'; -import { AgentHostSessionConfigBranchNameHintKey, AgentProvider, AgentSession, IAgentAttachment, type IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; +import { AgentProvider, AgentSession, IAgentAttachment, type IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; +import { SessionConfigKey } from '../../../../../../platform/agentHost/common/sessionConfigKeys.js'; import { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; import { ISessionTruncatedAction } from '../../../../../../platform/agentHost/common/state/protocol/actions.js'; import { ConfirmationOptionKind, ICustomizationRef, TerminalClaimKind, ToolResultContentType, type IConfirmationOption, type IProtectedResourceMetadata, type IToolDefinition } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; @@ -2259,7 +2260,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC /** Creates a new backend session and subscribes to its state. */ private async _createAndSubscribe(sessionResource: URI, model: IModelSelection | undefined, fork?: { session: URI; turnIndex: number; turnId: string }, sessionConfig?: Record, branchNameHint?: string): Promise { - const config = branchNameHint ? { ...sessionConfig, [AgentHostSessionConfigBranchNameHintKey]: branchNameHint } : sessionConfig; + const config = branchNameHint ? { ...sessionConfig, [SessionConfigKey.BranchNameHint]: branchNameHint } : sessionConfig; const workingDirectory = this._config.resolveWorkingDirectory?.(sessionResource) ?? this._workingDirectoryResolver.resolve(sessionResource) ?? this._workspaceContextService.getWorkspace().folders[0]?.uri; diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index e5056e5e59ee7..9222f043ab267 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -15,7 +15,7 @@ import { runWithFakedTimers } from '../../../../../../base/test/common/timeTrave import { timeout } from '../../../../../../base/common/async.js'; import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; -import { AgentHostSessionConfigBranchNameHintKey, IAgentCreateSessionConfig, IAgentHostService, IAgentSessionMetadata, AgentSession } from '../../../../../../platform/agentHost/common/agentService.js'; +import { IAgentCreateSessionConfig, IAgentHostService, IAgentSessionMetadata, AgentSession } from '../../../../../../platform/agentHost/common/agentService.js'; import { isSessionAction, type IActionEnvelope, type INotification, type ISessionAction, type ITerminalAction, type IToolCallConfirmedAction, type ITurnStartedAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import type { IStateSnapshot } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; import type { ICustomizationRef } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; @@ -50,6 +50,7 @@ import { ITerminalChatService } from '../../../../terminal/browser/terminal.js'; import { IAgentHostTerminalService } from '../../../../terminal/browser/agentHostTerminalService.js'; import { IAgentHostSessionWorkingDirectoryResolver } from '../../../browser/agentSessions/agentHost/agentHostSessionWorkingDirectoryResolver.js'; import { ILanguageModelToolsService } from '../../../common/tools/languageModelToolsService.js'; +import { SessionConfigKey } from '../../../../../../platform/agentHost/common/sessionConfigKeys.js'; // ---- Mock agent host service ------------------------------------------------ @@ -1819,7 +1820,7 @@ suite('AgentHostChatContribution', () => { await turnPromise; assert.strictEqual(agentHostService.createSessionCalls.length, 1); - assert.deepStrictEqual(agentHostService.createSessionCalls[0].config, { ...config, [AgentHostSessionConfigBranchNameHintKey]: 'add-agent-host-session-configuration-flow' }); + assert.deepStrictEqual(agentHostService.createSessionCalls[0].config, { ...config, [SessionConfigKey.BranchNameHint]: 'add-agent-host-session-configuration-flow' }); })); test('handler derives deterministic branch name hints from first request text', () => { From 9636727ca229f7e6ed7a2e3b6a3e76537cb493e5 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Wed, 22 Apr 2026 15:08:36 -0700 Subject: [PATCH 11/70] comments --- .../browser/agentSessionSettingsFileSystemProvider.ts | 4 ++-- .../browser/agentSessionSettingsFileSystemProvider.test.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/vs/sessions/contrib/agentHost/browser/agentSessionSettingsFileSystemProvider.ts b/src/vs/sessions/contrib/agentHost/browser/agentSessionSettingsFileSystemProvider.ts index 52ab20754d387..cd0caf3c3ace8 100644 --- a/src/vs/sessions/contrib/agentHost/browser/agentSessionSettingsFileSystemProvider.ts +++ b/src/vs/sessions/contrib/agentHost/browser/agentSessionSettingsFileSystemProvider.ts @@ -350,7 +350,7 @@ export function buildSessionSettingsJsonSchema(config: ResolveSessionConfigResul const result: IJSONSchema = { type: 'object', properties, - additionalProperties: false, + additionalProperties: true, }; if (required.length > 0) { result.required = required; @@ -493,7 +493,7 @@ export class AgentSessionSettingsSchemaRegistrar extends Disposable { // client only knows how to fetch schema content for that scheme. // The settings-file URI is used as the fileMatch glob so the schema // is applied to the actual editor document. - const schemaId = `vscode://schemas/agent-session-settings/${session.providerId}${session.resource.scheme}${session.resource.path}.jsonc`; + const schemaId = `vscode://schemas/agent-session-settings/${session.providerId}/${session.resource.scheme}/${session.resource.path}.jsonc`; const identity = config.schema; if (this._lastSchemaIdentity.get(settingsUri) === identity) { return; diff --git a/src/vs/sessions/contrib/agentHost/test/browser/agentSessionSettingsFileSystemProvider.test.ts b/src/vs/sessions/contrib/agentHost/test/browser/agentSessionSettingsFileSystemProvider.test.ts index 4f52eef17bf55..87e37a368536f 100644 --- a/src/vs/sessions/contrib/agentHost/test/browser/agentSessionSettingsFileSystemProvider.test.ts +++ b/src/vs/sessions/contrib/agentHost/test/browser/agentSessionSettingsFileSystemProvider.test.ts @@ -230,7 +230,7 @@ suite('AgentSessionSettingsFileSystemProvider', () => { const schemaRegistry = Registry.as(JSONExtensions.JSONContribution); function expectedSchemaId(session: ISession): string { - return `vscode://schemas/agent-session-settings/${session.providerId}${session.resource.scheme}${session.resource.path}.jsonc`; + return `vscode://schemas/agent-session-settings/${session.providerId}/${session.resource.scheme}/${session.resource.path}.jsonc`; } test('readFile lazily registers a schema + association for the session', async () => { From bb5dc7daf348a713040f9fea2887c7f1f4f1b090 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Wed, 22 Apr 2026 15:30:01 -0700 Subject: [PATCH 12/70] fix tests --- .../test/common/agentHostSchema.test.ts | 71 +++++++++---------- 1 file changed, 35 insertions(+), 36 deletions(-) diff --git a/src/vs/platform/agentHost/test/common/agentHostSchema.test.ts b/src/vs/platform/agentHost/test/common/agentHostSchema.test.ts index 889de00678b91..8a3f806f1536e 100644 --- a/src/vs/platform/agentHost/test/common/agentHostSchema.test.ts +++ b/src/vs/platform/agentHost/test/common/agentHostSchema.test.ts @@ -9,6 +9,22 @@ import { createSchema, platformSessionSchema, schemaProperty, type AutoApproveLe import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { JsonRpcErrorCodes, ProtocolError } from '../../common/state/sessionProtocol.js'; +/** + * Invokes `fn` and returns the thrown {@link ProtocolError}. Avoids + * passing an arrow-function validator to `assert.throws` — the unit-test + * assert shim does `actual instanceof expected` with that validator, and + * arrow functions have no `prototype` property, which WebKit rejects. + */ +function captureProtocolError(fn: () => void): ProtocolError { + try { + fn(); + } catch (err) { + assert.ok(err instanceof ProtocolError, `expected ProtocolError, got: ${err}`); + return err; + } + assert.fail('expected fn to throw, but it did not'); +} + suite('agentHostSchema', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -89,13 +105,10 @@ suite('agentHostSchema', () => { test('assertValid throws ProtocolError with offending path for primitive mismatch', () => { const prop = schemaProperty({ type: 'string', title: 's' }); - assert.throws( - () => prop.assertValid(42, 'myKey'), - (err: unknown) => err instanceof ProtocolError - && err.code === JsonRpcErrorCodes.InvalidParams - && err.message.includes('myKey') - && err.message.includes('string'), - ); + const err = captureProtocolError(() => prop.assertValid(42, 'myKey')); + assert.strictEqual(err.code, JsonRpcErrorCodes.InvalidParams); + assert.ok(err.message.includes('myKey'), err.message); + assert.ok(err.message.includes('string'), err.message); }); test('assertValid path annotates array index and nested property', () => { @@ -110,12 +123,9 @@ suite('agentHostSchema', () => { }, }, }); - assert.throws( - () => prop.assertValid({ allow: ['ok', 42] }, 'permissions'), - (err: unknown) => err instanceof ProtocolError - && err.message.includes('permissions.allow[1]') - && err.message.includes('string'), - ); + const err = captureProtocolError(() => prop.assertValid({ allow: ['ok', 42] }, 'permissions')); + assert.ok(err.message.includes('permissions.allow[1]'), err.message); + assert.ok(err.message.includes('string'), err.message); }); test('assertValid path reports missing required property', () => { @@ -125,12 +135,9 @@ suite('agentHostSchema', () => { properties: { name: { type: 'string', title: 'name' } }, required: ['name'], }); - assert.throws( - () => prop.assertValid({}, 'person'), - (err: unknown) => err instanceof ProtocolError - && err.message.includes('person.name') - && err.message.toLowerCase().includes('required'), - ); + const err = captureProtocolError(() => prop.assertValid({}, 'person')); + assert.ok(err.message.includes('person.name'), err.message); + assert.ok(err.message.toLowerCase().includes('required'), err.message); }); test('assertValid reports enum violation with the allowed set', () => { @@ -139,13 +146,10 @@ suite('agentHostSchema', () => { title: 'letters', enum: ['a', 'b'], }); - assert.throws( - () => prop.assertValid('c', 'choice'), - (err: unknown) => err instanceof ProtocolError - && err.message.includes('choice') - && err.message.includes('"a"') - && err.message.includes('"b"'), - ); + const err = captureProtocolError(() => prop.assertValid('c', 'choice')); + assert.ok(err.message.includes('choice'), err.message); + assert.ok(err.message.includes('"a"'), err.message); + assert.ok(err.message.includes('"b"'), err.message); }); }); @@ -181,10 +185,8 @@ suite('agentHostSchema', () => { test('assertValid throws for unknown keys', () => { const schema = fixture(); - assert.throws( - () => schema.assertValid('unknown' as 'name', 'x'), - (err: unknown) => err instanceof ProtocolError && err.message.includes('unknown'), - ); + const err = captureProtocolError(() => schema.assertValid('unknown' as 'name', 'x')); + assert.ok(err.message.includes('unknown'), err.message); }); test('values returns a shallow copy and passes through unknown keys', () => { @@ -203,12 +205,9 @@ suite('agentHostSchema', () => { test('values throws a path-annotated ProtocolError on invalid entry', () => { const schema = fixture(); - assert.throws( - () => schema.values({ name: 42 as unknown as string }), - (err: unknown) => err instanceof ProtocolError - && err.code === JsonRpcErrorCodes.InvalidParams - && err.message.includes('name'), - ); + const err = captureProtocolError(() => schema.values({ name: 42 as unknown as string })); + assert.strictEqual(err.code, JsonRpcErrorCodes.InvalidParams); + assert.ok(err.message.includes('name'), err.message); }); test('definition is preserved for spread-based composition', () => { From 926d65dad39b93baf068b1f6511ae83df087b6d5 Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Thu, 23 Apr 2026 10:13:17 +0100 Subject: [PATCH 13/70] Refactor "Open in Agents" titlebar widget styles for clarity and consistency --- .../actions/media/openInAgents.css | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/src/vs/workbench/electron-browser/actions/media/openInAgents.css b/src/vs/workbench/electron-browser/actions/media/openInAgents.css index c4ee53f0d71ec..c704d75b9a634 100644 --- a/src/vs/workbench/electron-browser/actions/media/openInAgents.css +++ b/src/vs/workbench/electron-browser/actions/media/openInAgents.css @@ -38,27 +38,16 @@ background-repeat: no-repeat; background-position: center center; background-size: contain; - /* Desaturated at rest; full color on hover/focus (per quality below). */ + /* Desaturated at rest; full color on hover/focus. */ filter: grayscale(1) opacity(0.75); transition: filter 150ms ease; } -/* Quality-aware tinting: the bundled `code-icon.svg` is the stable (blue) shape. Insider/exploration builds rotate hue to approximate their brand color. */ -.monaco-workbench .open-in-agents-titlebar-widget[data-product-quality="stable"]:hover > .open-in-agents-titlebar-widget-icon, -.monaco-workbench .open-in-agents-titlebar-widget[data-product-quality="stable"]:focus > .open-in-agents-titlebar-widget-icon { +.monaco-workbench .open-in-agents-titlebar-widget:hover > .open-in-agents-titlebar-widget-icon, +.monaco-workbench .open-in-agents-titlebar-widget:focus > .open-in-agents-titlebar-widget-icon { filter: none; } -.monaco-workbench .open-in-agents-titlebar-widget[data-product-quality="insider"]:hover > .open-in-agents-titlebar-widget-icon, -.monaco-workbench .open-in-agents-titlebar-widget[data-product-quality="insider"]:focus > .open-in-agents-titlebar-widget-icon { - filter: hue-rotate(140deg) saturate(0.85); -} - -.monaco-workbench .open-in-agents-titlebar-widget[data-product-quality="exploration"]:hover > .open-in-agents-titlebar-widget-icon, -.monaco-workbench .open-in-agents-titlebar-widget[data-product-quality="exploration"]:focus > .open-in-agents-titlebar-widget-icon { - filter: hue-rotate(-50deg) saturate(1.4); -} - .monaco-workbench .open-in-agents-titlebar-widget > .open-in-agents-titlebar-widget-label { display: inline-block; max-width: 0; From 284bbafa3d6f1703dc4f9c10fad1f8bfc12145dd Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Thu, 23 Apr 2026 10:17:37 +0100 Subject: [PATCH 14/70] Update "Open in Agents" icon to use sessions-icon.svg for improved clarity --- .../workbench/electron-browser/actions/media/openInAgents.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/electron-browser/actions/media/openInAgents.css b/src/vs/workbench/electron-browser/actions/media/openInAgents.css index c704d75b9a634..3f3241a7b5224 100644 --- a/src/vs/workbench/electron-browser/actions/media/openInAgents.css +++ b/src/vs/workbench/electron-browser/actions/media/openInAgents.css @@ -34,7 +34,7 @@ width: 16px; height: 16px; flex: 0 0 auto; - background-image: url('../../../browser/media/code-icon.svg'); + background-image: url('../../../../sessions/browser/media/sessions-icon.svg'); background-repeat: no-repeat; background-position: center center; background-size: contain; From 7e6bd0ad0bab0cdc7331178650fc248df66fbeee Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Thu, 23 Apr 2026 10:21:43 +0100 Subject: [PATCH 15/70] Enhance "Open in Agents" action to conditionally launch sibling app or open new window based on environment --- .../actions/openInAgentsAction.ts | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts b/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts index f96aca48a0a0c..b140d8c0c5dff 100644 --- a/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts +++ b/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts @@ -23,6 +23,7 @@ import { ToggleTitleBarConfigAction } from '../../browser/parts/titlebar/titleba import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../common/contributions.js'; import { IsAuxiliaryWindowContext, IsSessionsWindowContext } from '../../common/contextkeys.js'; import { workbenchConfigurationNodeBase } from '../../common/configuration.js'; +import { IWorkbenchEnvironmentService } from '../../services/environment/common/environmentService.js'; const OpenInAgentsActionId = 'workbench.action.openInAgents'; const OpenInAgentsEnabledSetting = 'workbench.openInAgents.enabled'; @@ -70,18 +71,9 @@ class OpenInAgentsAction extends Action2 { override async run(accessor: ServicesAccessor): Promise { const nativeHostService = accessor.get(INativeHostService); const productService = accessor.get(IProductService); + const environmentService = accessor.get(IWorkbenchEnvironmentService); const workspaceContextService = accessor.get(IWorkspaceContextService); - const hasSibling = !!( - productService.embedded?.darwinSiblingBundleIdentifier || - productService.embedded?.win32SiblingExeBasename || - productService.darwinSiblingBundleIdentifier || - productService.win32SiblingExeBasename - ); - if (!hasSibling) { - return; - } - const args: string[] = ['--agents', '--new-window']; const workspace = workspaceContextService.getWorkspace(); @@ -98,7 +90,21 @@ class OpenInAgentsAction extends Action2 { break; } - await nativeHostService.launchSiblingApp(args); + const hasSibling = !!( + productService.embedded?.darwinSiblingBundleIdentifier || + productService.embedded?.win32SiblingExeBasename || + productService.darwinSiblingBundleIdentifier || + productService.win32SiblingExeBasename + ); + + // In built builds with a sibling Agents app available, launch it. + // Otherwise (dev / OSS / no sibling), open a new agents window of + // the current Electron app. + if (environmentService.isBuilt && hasSibling) { + await nativeHostService.launchSiblingApp(args); + } else { + await nativeHostService.openAgentsWindow({ forceNewWindow: true }); + } } } From 08fc975ca46eb7a70a96337068fcfdb7fa57e02a Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Thu, 23 Apr 2026 10:30:47 +0100 Subject: [PATCH 16/70] Enhance "Open in Agents" action and titlebar widget for improved keyboard accessibility and focus handling Co-authored-by: Copilot --- .../browser/parts/titlebar/titlebarPart.ts | 3 +- .../actions/media/openInAgents.css | 12 +++---- .../actions/openInAgentsAction.ts | 36 +++++++++++-------- 3 files changed, 30 insertions(+), 21 deletions(-) diff --git a/src/vs/workbench/browser/parts/titlebar/titlebarPart.ts b/src/vs/workbench/browser/parts/titlebar/titlebarPart.ts index 12a149d4dfe08..5e3e4b6d266c6 100644 --- a/src/vs/workbench/browser/parts/titlebar/titlebarPart.ts +++ b/src/vs/workbench/browser/parts/titlebar/titlebarPart.ts @@ -720,7 +720,8 @@ export class BrowserTitlebarPart extends Part implements ITitlebarPart { if (this.globalToolbarMenu) { fillInActionBarActions( this.globalToolbarMenu.getActions(), - actions + actions, + actionGroup => actionGroup !== '0_leading' // already rendered before layout controls ); } diff --git a/src/vs/workbench/electron-browser/actions/media/openInAgents.css b/src/vs/workbench/electron-browser/actions/media/openInAgents.css index 3f3241a7b5224..fd000b8af16df 100644 --- a/src/vs/workbench/electron-browser/actions/media/openInAgents.css +++ b/src/vs/workbench/electron-browser/actions/media/openInAgents.css @@ -44,7 +44,7 @@ } .monaco-workbench .open-in-agents-titlebar-widget:hover > .open-in-agents-titlebar-widget-icon, -.monaco-workbench .open-in-agents-titlebar-widget:focus > .open-in-agents-titlebar-widget-icon { +.monaco-workbench .open-in-agents-titlebar-widget:focus-visible > .open-in-agents-titlebar-widget-icon { filter: none; } @@ -62,29 +62,29 @@ } .monaco-workbench .open-in-agents-titlebar-widget:hover, -.monaco-workbench .open-in-agents-titlebar-widget:focus { +.monaco-workbench .open-in-agents-titlebar-widget:focus-visible { background-color: var(--vscode-toolbar-hoverBackground); outline: none; } /* Quality-tinted hover/focus background — blue (stable), green (insider), orange (exploration). */ .monaco-workbench .open-in-agents-titlebar-widget[data-product-quality="stable"]:hover, -.monaco-workbench .open-in-agents-titlebar-widget[data-product-quality="stable"]:focus { +.monaco-workbench .open-in-agents-titlebar-widget[data-product-quality="stable"]:focus-visible { background-color: rgba(0, 122, 204, 0.18); } .monaco-workbench .open-in-agents-titlebar-widget[data-product-quality="insider"]:hover, -.monaco-workbench .open-in-agents-titlebar-widget[data-product-quality="insider"]:focus { +.monaco-workbench .open-in-agents-titlebar-widget[data-product-quality="insider"]:focus-visible { background-color: rgba(36, 187, 26, 0.20); } .monaco-workbench .open-in-agents-titlebar-widget[data-product-quality="exploration"]:hover, -.monaco-workbench .open-in-agents-titlebar-widget[data-product-quality="exploration"]:focus { +.monaco-workbench .open-in-agents-titlebar-widget[data-product-quality="exploration"]:focus-visible { background-color: rgba(255, 140, 0, 0.22); } .monaco-workbench .open-in-agents-titlebar-widget:hover > .open-in-agents-titlebar-widget-label, -.monaco-workbench .open-in-agents-titlebar-widget:focus > .open-in-agents-titlebar-widget-label { +.monaco-workbench .open-in-agents-titlebar-widget:focus-visible > .open-in-agents-titlebar-widget-label { max-width: 200px; opacity: 1; margin-left: 6px; diff --git a/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts b/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts index b140d8c0c5dff..e48441d6ad1e0 100644 --- a/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts +++ b/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts @@ -4,16 +4,17 @@ *--------------------------------------------------------------------------------------------*/ import './media/openInAgents.css'; -import { $, append } from '../../../base/browser/dom.js'; +import { $, addDisposableListener, append, EventHelper, EventType } from '../../../base/browser/dom.js'; +import { StandardKeyboardEvent } from '../../../base/browser/keyboardEvent.js'; import { BaseActionViewItem, IBaseActionViewItemOptions } from '../../../base/browser/ui/actionbar/actionViewItems.js'; import { IAction } from '../../../base/common/actions.js'; +import { KeyCode } from '../../../base/common/keyCodes.js'; import { Disposable } from '../../../base/common/lifecycle.js'; import { localize, localize2 } from '../../../nls.js'; import { Action2, MenuId, registerAction2 } from '../../../platform/actions/common/actions.js'; import { IActionViewItemService } from '../../../platform/actions/browser/actionViewItemService.js'; import { IConfigurationRegistry, Extensions as ConfigurationExtensions } from '../../../platform/configuration/common/configurationRegistry.js'; import { ContextKeyExpr } from '../../../platform/contextkey/common/contextkey.js'; -import { IsMacContext, IsWindowsContext } from '../../../platform/contextkey/common/contextkeys.js'; import { IInstantiationService, ServicesAccessor } from '../../../platform/instantiation/common/instantiation.js'; import { INativeHostService } from '../../../platform/native/common/native.js'; import { IProductService } from '../../../platform/product/common/productService.js'; @@ -32,14 +33,15 @@ const OpenInAgentsVisibility = ContextKeyExpr.and( ContextKeyExpr.equals(`config.${OpenInAgentsEnabledSetting}`, true), IsSessionsWindowContext.toNegated(), IsAuxiliaryWindowContext.toNegated(), - ContextKeyExpr.or(IsMacContext, IsWindowsContext), ); /** - * Action that launches the sibling Agents app via - * {@link INativeHostService.launchSiblingApp} with `--agents` and the current - * workspace folder/file. Mirrors the "Open in VS Code" action that lives in - * the Agents window's title bar. + * Action that opens the Agents application for the current workspace. + * + * In built builds where a sibling Agents app is registered (`darwinSiblingBundleIdentifier` + * / `win32SiblingExeBasename`), launches it via {@link INativeHostService.launchSiblingApp} + * with `--agents` and the current workspace folder/file. Otherwise falls back to opening + * a new in-process Agents window via {@link INativeHostService.openAgentsWindow}. */ class OpenInAgentsAction extends Action2 { @@ -109,9 +111,8 @@ class OpenInAgentsAction extends Action2 { } /** - * Renders the "Open in Agents" titlebar entry as the product-icon-only button - * that expands to reveal a label on hover/focus. The icon is tinted via CSS - * to match the host product quality (stable, insider, exploration). + * Renders the "Open in Agents" titlebar entry as an icon-only button that + * expands to reveal a label on hover / keyboard focus. */ class OpenInAgentsTitleBarWidget extends BaseActionViewItem { @@ -128,10 +129,7 @@ class OpenInAgentsTitleBarWidget extends BaseActionViewItem { container.classList.add('open-in-agents-titlebar-widget'); container.setAttribute('role', 'button'); - container.tabIndex = 0; - - const quality = this.productService.quality ?? 'stable'; - container.setAttribute('data-product-quality', quality); + container.setAttribute('data-product-quality', this.productService.quality ?? 'stable'); const label = this.action.label || localize('openInAgents', 'Open in Agents'); container.setAttribute('aria-label', label); @@ -142,6 +140,16 @@ class OpenInAgentsTitleBarWidget extends BaseActionViewItem { const labelEl = append(container, $('span.open-in-agents-titlebar-widget-label')); labelEl.textContent = label; + + // BaseActionViewItem only wires mouse / touch — add Enter / Space activation + // so the widget is usable via keyboard. + this._register(addDisposableListener(container, EventType.KEY_DOWN, e => { + const event = new StandardKeyboardEvent(e); + if (event.equals(KeyCode.Enter) || event.equals(KeyCode.Space)) { + EventHelper.stop(event, true); + this.onClick(e); + } + })); } } From 697bd2b2f4c83ceebaea8f9cc01bd27b50fd0096 Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Thu, 23 Apr 2026 10:39:26 +0100 Subject: [PATCH 17/70] Add IActionViewItemService to titlebar parts for enhanced action view item handling Co-authored-by: Copilot --- .../browser/parts/titlebar/titlebarPart.ts | 12 +++++++----- .../parts/titlebar/titlebarPart.ts | 16 ++++++++++------ 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/src/vs/workbench/browser/parts/titlebar/titlebarPart.ts b/src/vs/workbench/browser/parts/titlebar/titlebarPart.ts index 5e3e4b6d266c6..94dc83b3e9b72 100644 --- a/src/vs/workbench/browser/parts/titlebar/titlebarPart.ts +++ b/src/vs/workbench/browser/parts/titlebar/titlebarPart.ts @@ -311,7 +311,8 @@ export class BrowserTitlebarPart extends Part implements ITitlebarPart { @IHostService private readonly hostService: IHostService, @IEditorService editorService: IEditorService, @IMenuService private readonly menuService: IMenuService, - @IKeybindingService private readonly keybindingService: IKeybindingService + @IKeybindingService private readonly keybindingService: IKeybindingService, + @IActionViewItemService private readonly actionViewItemService: IActionViewItemService ) { super(id, { hasTitle: false }, themeService, storageService, layoutService); @@ -609,9 +610,8 @@ export class BrowserTitlebarPart extends Part implements ITitlebarPart { private actionViewItemProvider(action: IAction, options: IBaseActionViewItemOptions): IActionViewItem | undefined { // --- Custom view items registered via IActionViewItemService - const actionViewItemService = this.instantiationService.invokeFunction(accessor => accessor.get(IActionViewItemService)); for (const menuId of [MenuId.TitleBar, MenuId.LayoutControlMenu]) { - const customViewItem = actionViewItemService.lookUp(menuId, action.id); + const customViewItem = this.actionViewItemService.lookUp(menuId, action.id); if (customViewItem) { const result = customViewItem(action, options, this.instantiationService, getWindowId(this.element ? getWindow(this.element) : mainWindow)); if (result) { @@ -954,8 +954,9 @@ export class MainBrowserTitlebarPart extends BrowserTitlebarPart { @IEditorService editorService: IEditorService, @IMenuService menuService: IMenuService, @IKeybindingService keybindingService: IKeybindingService, + @IActionViewItemService actionViewItemService: IActionViewItemService, ) { - super(Parts.TITLEBAR_PART, mainWindow, editorGroupService.mainPart, contextMenuService, configurationService, environmentService, instantiationService, themeService, storageService, layoutService, contextKeyService, hostService, editorService, menuService, keybindingService); + super(Parts.TITLEBAR_PART, mainWindow, editorGroupService.mainPart, contextMenuService, configurationService, environmentService, instantiationService, themeService, storageService, layoutService, contextKeyService, hostService, editorService, menuService, keybindingService, actionViewItemService); } } @@ -989,9 +990,10 @@ export class AuxiliaryBrowserTitlebarPart extends BrowserTitlebarPart implements @IEditorService editorService: IEditorService, @IMenuService menuService: IMenuService, @IKeybindingService keybindingService: IKeybindingService, + @IActionViewItemService actionViewItemService: IActionViewItemService, ) { const id = AuxiliaryBrowserTitlebarPart.COUNTER++; - super(`workbench.parts.auxiliaryTitle.${id}`, getWindow(container), editorGroupsContainer, contextMenuService, configurationService, environmentService, instantiationService, themeService, storageService, layoutService, contextKeyService, hostService, editorService, menuService, keybindingService); + super(`workbench.parts.auxiliaryTitle.${id}`, getWindow(container), editorGroupsContainer, contextMenuService, configurationService, environmentService, instantiationService, themeService, storageService, layoutService, contextKeyService, hostService, editorService, menuService, keybindingService, actionViewItemService); } override get preventZoom(): boolean { diff --git a/src/vs/workbench/electron-browser/parts/titlebar/titlebarPart.ts b/src/vs/workbench/electron-browser/parts/titlebar/titlebarPart.ts index 6fe575a12dda6..9fd237004231f 100644 --- a/src/vs/workbench/electron-browser/parts/titlebar/titlebarPart.ts +++ b/src/vs/workbench/electron-browser/parts/titlebar/titlebarPart.ts @@ -13,6 +13,7 @@ import { INativeWorkbenchEnvironmentService } from '../../../services/environmen import { IHostService } from '../../../services/host/browser/host.js'; import { isMacintosh, isWindows, isLinux, isTahoeOrNewer } from '../../../../base/common/platform.js'; import { IMenuService, MenuId } from '../../../../platform/actions/common/actions.js'; +import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; import { BrowserTitlebarPart, BrowserTitleService, IAuxiliaryTitlebarPart } from '../../../browser/parts/titlebar/titlebarPart.js'; import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; import { IThemeService } from '../../../../platform/theme/common/themeService.js'; @@ -76,9 +77,10 @@ export class NativeTitlebarPart extends BrowserTitlebarPart { @IEditorGroupsService editorGroupService: IEditorGroupsService, @IEditorService editorService: IEditorService, @IMenuService menuService: IMenuService, - @IKeybindingService keybindingService: IKeybindingService + @IKeybindingService keybindingService: IKeybindingService, + @IActionViewItemService actionViewItemService: IActionViewItemService ) { - super(id, targetWindow, editorGroupsContainer, contextMenuService, configurationService, environmentService, instantiationService, themeService, storageService, layoutService, contextKeyService, hostService, editorService, menuService, keybindingService); + super(id, targetWindow, editorGroupsContainer, contextMenuService, configurationService, environmentService, instantiationService, themeService, storageService, layoutService, contextKeyService, hostService, editorService, menuService, keybindingService, actionViewItemService); this.tahoeOrNewer = isTahoeOrNewer(environmentService.os.release); @@ -300,9 +302,10 @@ export class MainNativeTitlebarPart extends NativeTitlebarPart { @IEditorGroupsService editorGroupService: IEditorGroupsService, @IEditorService editorService: IEditorService, @IMenuService menuService: IMenuService, - @IKeybindingService keybindingService: IKeybindingService + @IKeybindingService keybindingService: IKeybindingService, + @IActionViewItemService actionViewItemService: IActionViewItemService ) { - super(Parts.TITLEBAR_PART, mainWindow, editorGroupService.mainPart, contextMenuService, configurationService, environmentService, instantiationService, themeService, storageService, layoutService, contextKeyService, hostService, nativeHostService, editorGroupService, editorService, menuService, keybindingService); + super(Parts.TITLEBAR_PART, mainWindow, editorGroupService.mainPart, contextMenuService, configurationService, environmentService, instantiationService, themeService, storageService, layoutService, contextKeyService, hostService, nativeHostService, editorGroupService, editorService, menuService, keybindingService, actionViewItemService); } } @@ -329,10 +332,11 @@ export class AuxiliaryNativeTitlebarPart extends NativeTitlebarPart implements I @IEditorGroupsService editorGroupService: IEditorGroupsService, @IEditorService editorService: IEditorService, @IMenuService menuService: IMenuService, - @IKeybindingService keybindingService: IKeybindingService + @IKeybindingService keybindingService: IKeybindingService, + @IActionViewItemService actionViewItemService: IActionViewItemService ) { const id = AuxiliaryNativeTitlebarPart.COUNTER++; - super(`workbench.parts.auxiliaryTitle.${id}`, getWindow(container), editorGroupsContainer, contextMenuService, configurationService, environmentService, instantiationService, themeService, storageService, layoutService, contextKeyService, hostService, nativeHostService, editorGroupService, editorService, menuService, keybindingService); + super(`workbench.parts.auxiliaryTitle.${id}`, getWindow(container), editorGroupsContainer, contextMenuService, configurationService, environmentService, instantiationService, themeService, storageService, layoutService, contextKeyService, hostService, nativeHostService, editorGroupService, editorService, menuService, keybindingService, actionViewItemService); } override get preventZoom(): boolean { From 6eb7e6d34ef9adb5849f34288b61ebd63d47bc1f Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Thu, 23 Apr 2026 10:41:27 +0100 Subject: [PATCH 18/70] Refactor "Open in Agents" titlebar widget styles for improved font handling Co-authored-by: Copilot --- .../workbench/electron-browser/actions/media/openInAgents.css | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/vs/workbench/electron-browser/actions/media/openInAgents.css b/src/vs/workbench/electron-browser/actions/media/openInAgents.css index fd000b8af16df..903998e4172fa 100644 --- a/src/vs/workbench/electron-browser/actions/media/openInAgents.css +++ b/src/vs/workbench/electron-browser/actions/media/openInAgents.css @@ -54,8 +54,7 @@ opacity: 0; margin-left: 0; color: var(--vscode-foreground); - font-size: 12px; - line-height: 22px; + font: inherit; overflow: hidden; white-space: nowrap; transition: max-width 150ms ease, opacity 150ms ease, margin-left 150ms ease; From 795aa37037e06bfbafd7b9efcf4a672c7e8d4f2a Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Thu, 23 Apr 2026 10:47:51 +0100 Subject: [PATCH 19/70] Update vertical separator background color to use theme variable for consistency Co-authored-by: Copilot --- .../workbench/electron-browser/actions/media/openInAgents.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/electron-browser/actions/media/openInAgents.css b/src/vs/workbench/electron-browser/actions/media/openInAgents.css index 903998e4172fa..c457308436116 100644 --- a/src/vs/workbench/electron-browser/actions/media/openInAgents.css +++ b/src/vs/workbench/electron-browser/actions/media/openInAgents.css @@ -26,7 +26,7 @@ top: 4px; bottom: 4px; width: 1px; - background-color: rgba(128, 128, 128, 0.5); + background-color: var(--vscode-widget-border, rgba(128, 128, 128, 0.5)); pointer-events: none; } From 50dc3a0d269ecf37b3192359165aae8f638bbbb4 Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Thu, 23 Apr 2026 10:51:22 +0100 Subject: [PATCH 20/70] Add telemetry logging for "Open in Agents" action to track launch mode Co-authored-by: Copilot --- .../actions/openInAgentsAction.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts b/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts index e48441d6ad1e0..99401c21bdac5 100644 --- a/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts +++ b/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts @@ -19,6 +19,7 @@ import { IInstantiationService, ServicesAccessor } from '../../../platform/insta import { INativeHostService } from '../../../platform/native/common/native.js'; import { IProductService } from '../../../platform/product/common/productService.js'; import { Registry } from '../../../platform/registry/common/platform.js'; +import { ITelemetryService } from '../../../platform/telemetry/common/telemetry.js'; import { IWorkspaceContextService, WorkbenchState } from '../../../platform/workspace/common/workspace.js'; import { ToggleTitleBarConfigAction } from '../../browser/parts/titlebar/titlebarActions.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../common/contributions.js'; @@ -29,6 +30,15 @@ import { IWorkbenchEnvironmentService } from '../../services/environment/common/ const OpenInAgentsActionId = 'workbench.action.openInAgents'; const OpenInAgentsEnabledSetting = 'workbench.openInAgents.enabled'; +type OpenInAgentsMode = 'siblingApp' | 'newWindow'; + +type OpenInAgentsEvent = { mode: OpenInAgentsMode }; +type OpenInAgentsClassification = { + owner: 'osortega'; + comment: 'Tracks when the user opens the Agents application from the VS Code titlebar.'; + mode: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'How the Agents app was opened: siblingApp (launched separate Agents app) or newWindow (in-process agents window).' }; +}; + const OpenInAgentsVisibility = ContextKeyExpr.and( ContextKeyExpr.equals(`config.${OpenInAgentsEnabledSetting}`, true), IsSessionsWindowContext.toNegated(), @@ -75,6 +85,7 @@ class OpenInAgentsAction extends Action2 { const productService = accessor.get(IProductService); const environmentService = accessor.get(IWorkbenchEnvironmentService); const workspaceContextService = accessor.get(IWorkspaceContextService); + const telemetryService = accessor.get(ITelemetryService); const args: string[] = ['--agents', '--new-window']; @@ -102,7 +113,10 @@ class OpenInAgentsAction extends Action2 { // In built builds with a sibling Agents app available, launch it. // Otherwise (dev / OSS / no sibling), open a new agents window of // the current Electron app. - if (environmentService.isBuilt && hasSibling) { + const mode: OpenInAgentsMode = environmentService.isBuilt && hasSibling ? 'siblingApp' : 'newWindow'; + telemetryService.publicLog2('vscode.openInAgents', { mode }); + + if (mode === 'siblingApp') { await nativeHostService.launchSiblingApp(args); } else { await nativeHostService.openAgentsWindow({ forceNewWindow: true }); From b0e35cf1341cabfbb240bbb075f7fa9b2641b7bb Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Thu, 23 Apr 2026 10:54:18 +0100 Subject: [PATCH 21/70] Refactor titlebar action groups to use TitleBarLeadingActionsGroup for improved clarity and consistency Co-authored-by: Copilot --- .../browser/parts/titlebar/titlebarActions.ts | 8 ++++++++ .../workbench/browser/parts/titlebar/titlebarPart.ts | 8 ++++---- .../electron-browser/actions/openInAgentsAction.ts | 10 +++++----- 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/src/vs/workbench/browser/parts/titlebar/titlebarActions.ts b/src/vs/workbench/browser/parts/titlebar/titlebarActions.ts index 75bd4a21022c3..b1df33f2523fc 100644 --- a/src/vs/workbench/browser/parts/titlebar/titlebarActions.ts +++ b/src/vs/workbench/browser/parts/titlebar/titlebarActions.ts @@ -16,6 +16,14 @@ import { IsMainWindowFullscreenContext, IsCompactTitleBarContext, TitleBarStyleC import { CustomTitleBarVisibility, TitleBarSetting, TitlebarStyle } from '../../../../platform/window/common/window.js'; import { NotificationsPosition, NotificationsSettings } from '../../../common/notifications.js'; +/** + * Menu group for actions contributed to {@link MenuId.TitleBar} that should render + * **before** the layout controls (instead of trailing them like the default group). + * Use this group to surface a leading affordance that should remain visible even + * when layout controls are toggled off. + */ +export const TitleBarLeadingActionsGroup = '0_leading'; + // --- Context Menu Actions --- // export class ToggleTitleBarConfigAction extends Action2 { diff --git a/src/vs/workbench/browser/parts/titlebar/titlebarPart.ts b/src/vs/workbench/browser/parts/titlebar/titlebarPart.ts index 94dc83b3e9b72..76a523f257e8c 100644 --- a/src/vs/workbench/browser/parts/titlebar/titlebarPart.ts +++ b/src/vs/workbench/browser/parts/titlebar/titlebarPart.ts @@ -48,7 +48,7 @@ import { ResolvedKeybinding } from '../../../../base/common/keybindings.js'; import { EditorCommandsContextActionRunner } from '../editor/editorTabsControl.js'; import { IEditorCommandsContext, IEditorPartOptionsChangeEvent, IToolbarActions } from '../../../common/editor.js'; import { CodeWindow, mainWindow } from '../../../../base/browser/window.js'; -import { ACCOUNTS_ACTIVITY_TILE_ACTION, GLOBAL_ACTIVITY_TITLE_ACTION } from './titlebarActions.js'; +import { ACCOUNTS_ACTIVITY_TILE_ACTION, GLOBAL_ACTIVITY_TITLE_ACTION, TitleBarLeadingActionsGroup } from './titlebarActions.js'; import { IView } from '../../../../base/browser/ui/grid/grid.js'; import { createInstantHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegateFactory.js'; import { IBaseActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; @@ -698,12 +698,12 @@ export class BrowserTitlebarPart extends Part implements ITitlebarPart { } } - // --- Leading Global Actions (rendered before layout controls; opt-in via group '0_leading') + // --- Leading Global Actions (rendered before layout controls; opt-in via TitleBarLeadingActionsGroup) if (this.globalToolbarMenu) { fillInActionBarActions( this.globalToolbarMenu.getActions(), actions, - actionGroup => actionGroup === '0_leading' + actionGroup => actionGroup === TitleBarLeadingActionsGroup ); } @@ -721,7 +721,7 @@ export class BrowserTitlebarPart extends Part implements ITitlebarPart { fillInActionBarActions( this.globalToolbarMenu.getActions(), actions, - actionGroup => actionGroup !== '0_leading' // already rendered before layout controls + actionGroup => actionGroup !== TitleBarLeadingActionsGroup // already rendered before layout controls ); } diff --git a/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts b/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts index 99401c21bdac5..82df40669e275 100644 --- a/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts +++ b/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts @@ -21,7 +21,7 @@ import { IProductService } from '../../../platform/product/common/productService import { Registry } from '../../../platform/registry/common/platform.js'; import { ITelemetryService } from '../../../platform/telemetry/common/telemetry.js'; import { IWorkspaceContextService, WorkbenchState } from '../../../platform/workspace/common/workspace.js'; -import { ToggleTitleBarConfigAction } from '../../browser/parts/titlebar/titlebarActions.js'; +import { ToggleTitleBarConfigAction, TitleBarLeadingActionsGroup } from '../../browser/parts/titlebar/titlebarActions.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../common/contributions.js'; import { IsAuxiliaryWindowContext, IsSessionsWindowContext } from '../../common/contextkeys.js'; import { workbenchConfigurationNodeBase } from '../../common/configuration.js'; @@ -62,11 +62,11 @@ class OpenInAgentsAction extends Action2 { f1: true, precondition: OpenInAgentsVisibility, menu: [{ - // Render in the global titlebar tool bar in the dedicated - // '0_leading' slot so we appear before the layout controls - // (and stay visible when layout controls are toggled off). + // Render in the global titlebar tool bar in the dedicated leading + // slot so we appear before the layout controls (and stay visible + // when layout controls are toggled off). id: MenuId.TitleBar, - group: '0_leading', + group: TitleBarLeadingActionsGroup, order: -1000, when: OpenInAgentsVisibility, }, { From 54caed0743f082ec6ee354fb830934eba77149e2 Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Thu, 23 Apr 2026 11:35:43 +0100 Subject: [PATCH 22/70] Enhance "Open in Agents" titlebar widget with hover service for improved tooltip handling Co-authored-by: Copilot --- .../electron-browser/actions/openInAgentsAction.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts b/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts index 82df40669e275..797f402838f6f 100644 --- a/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts +++ b/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts @@ -5,6 +5,7 @@ import './media/openInAgents.css'; import { $, addDisposableListener, append, EventHelper, EventType } from '../../../base/browser/dom.js'; +import { getDefaultHoverDelegate } from '../../../base/browser/ui/hover/hoverDelegateFactory.js'; import { StandardKeyboardEvent } from '../../../base/browser/keyboardEvent.js'; import { BaseActionViewItem, IBaseActionViewItemOptions } from '../../../base/browser/ui/actionbar/actionViewItems.js'; import { IAction } from '../../../base/common/actions.js'; @@ -15,6 +16,7 @@ import { Action2, MenuId, registerAction2 } from '../../../platform/actions/comm import { IActionViewItemService } from '../../../platform/actions/browser/actionViewItemService.js'; import { IConfigurationRegistry, Extensions as ConfigurationExtensions } from '../../../platform/configuration/common/configurationRegistry.js'; import { ContextKeyExpr } from '../../../platform/contextkey/common/contextkey.js'; +import { IHoverService } from '../../../platform/hover/browser/hover.js'; import { IInstantiationService, ServicesAccessor } from '../../../platform/instantiation/common/instantiation.js'; import { INativeHostService } from '../../../platform/native/common/native.js'; import { IProductService } from '../../../platform/product/common/productService.js'; @@ -134,6 +136,7 @@ class OpenInAgentsTitleBarWidget extends BaseActionViewItem { action: IAction, options: IBaseActionViewItemOptions | undefined, @IProductService private readonly productService: IProductService, + @IHoverService private readonly hoverService: IHoverService, ) { super(undefined, action, options); } @@ -147,7 +150,7 @@ class OpenInAgentsTitleBarWidget extends BaseActionViewItem { const label = this.action.label || localize('openInAgents', 'Open in Agents'); container.setAttribute('aria-label', label); - container.title = label; + this._register(this.hoverService.setupManagedHover(getDefaultHoverDelegate('element'), container, label)); const icon = append(container, $('span.open-in-agents-titlebar-widget-icon')); icon.setAttribute('aria-hidden', 'true'); @@ -169,7 +172,7 @@ class OpenInAgentsTitleBarWidget extends BaseActionViewItem { class OpenInAgentsContribution extends Disposable implements IWorkbenchContribution { - static readonly ID = 'workbench.contrib.openInAgents'; + static readonly ID = 'workbench.contrib.openInAgents.desktop'; constructor( @IActionViewItemService actionViewItemService: IActionViewItemService, From 1b57186d6e04171e06488fd74291ed6c6bb74100 Mon Sep 17 00:00:00 2001 From: Lee Murray Date: Thu, 23 Apr 2026 11:47:41 +0100 Subject: [PATCH 23/70] Update src/vs/workbench/electron-browser/actions/openInAgentsAction.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/vs/workbench/electron-browser/actions/openInAgentsAction.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts b/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts index 797f402838f6f..3026aea37306b 100644 --- a/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts +++ b/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts @@ -89,7 +89,7 @@ class OpenInAgentsAction extends Action2 { const workspaceContextService = accessor.get(IWorkspaceContextService); const telemetryService = accessor.get(ITelemetryService); - const args: string[] = ['--agents', '--new-window']; + const args: string[] = ['--new-window']; const workspace = workspaceContextService.getWorkspace(); switch (workspaceContextService.getWorkbenchState()) { From fa54c5f4975f0ff538d577ddb825d4f034fd2df5 Mon Sep 17 00:00:00 2001 From: Lee Murray Date: Thu, 23 Apr 2026 11:48:04 +0100 Subject: [PATCH 24/70] Update src/vs/workbench/electron-browser/actions/openInAgentsAction.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/vs/workbench/electron-browser/actions/openInAgentsAction.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts b/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts index 3026aea37306b..08da750c74766 100644 --- a/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts +++ b/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts @@ -106,8 +106,6 @@ class OpenInAgentsAction extends Action2 { } const hasSibling = !!( - productService.embedded?.darwinSiblingBundleIdentifier || - productService.embedded?.win32SiblingExeBasename || productService.darwinSiblingBundleIdentifier || productService.win32SiblingExeBasename ); From ffcf1da5827d763e7e8976f4a98a4f023feb661f Mon Sep 17 00:00:00 2001 From: Lee Murray Date: Thu, 23 Apr 2026 11:48:42 +0100 Subject: [PATCH 25/70] Update src/vs/workbench/electron-browser/actions/openInAgentsAction.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/vs/workbench/electron-browser/actions/openInAgentsAction.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts b/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts index 08da750c74766..53d77d1a7891e 100644 --- a/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts +++ b/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts @@ -184,7 +184,7 @@ class OpenInAgentsContribution extends Disposable implements IWorkbenchContribut } registerAction2(OpenInAgentsAction); -registerWorkbenchContribution2(OpenInAgentsContribution.ID, OpenInAgentsContribution, WorkbenchPhase.AfterRestored); +registerWorkbenchContribution2(OpenInAgentsContribution.ID, OpenInAgentsContribution, WorkbenchPhase.BlockRestore); // Toggle entry in titlebar context menu (right-click on titlebar) registerAction2(class ToggleOpenInAgents extends ToggleTitleBarConfigAction { From 17d076e38fecb89149f1ee8e5559e1d791da9484 Mon Sep 17 00:00:00 2001 From: Lee Murray Date: Thu, 23 Apr 2026 11:49:29 +0100 Subject: [PATCH 26/70] Update src/vs/workbench/electron-browser/actions/openInAgentsAction.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/vs/workbench/electron-browser/actions/openInAgentsAction.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts b/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts index 53d77d1a7891e..d772df07f143f 100644 --- a/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts +++ b/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts @@ -60,7 +60,7 @@ class OpenInAgentsAction extends Action2 { constructor() { super({ id: OpenInAgentsActionId, - title: localize2('openInAgents', 'Open in Agents'), + title: localize2('openInAgents', "Open in Agents"), f1: true, precondition: OpenInAgentsVisibility, menu: [{ From fc43dbf8b82856bb89d206bddac9bda7d8e95409 Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Thu, 23 Apr 2026 11:51:09 +0100 Subject: [PATCH 27/70] Remove keyboard activation handling from "Open in Agents" titlebar widget for cleaner implementation Co-authored-by: Copilot --- .../electron-browser/actions/openInAgentsAction.ts | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts b/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts index d772df07f143f..188ab83ec9de2 100644 --- a/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts +++ b/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts @@ -4,12 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import './media/openInAgents.css'; -import { $, addDisposableListener, append, EventHelper, EventType } from '../../../base/browser/dom.js'; +import { $, append } from '../../../base/browser/dom.js'; import { getDefaultHoverDelegate } from '../../../base/browser/ui/hover/hoverDelegateFactory.js'; -import { StandardKeyboardEvent } from '../../../base/browser/keyboardEvent.js'; import { BaseActionViewItem, IBaseActionViewItemOptions } from '../../../base/browser/ui/actionbar/actionViewItems.js'; import { IAction } from '../../../base/common/actions.js'; -import { KeyCode } from '../../../base/common/keyCodes.js'; import { Disposable } from '../../../base/common/lifecycle.js'; import { localize, localize2 } from '../../../nls.js'; import { Action2, MenuId, registerAction2 } from '../../../platform/actions/common/actions.js'; @@ -155,16 +153,6 @@ class OpenInAgentsTitleBarWidget extends BaseActionViewItem { const labelEl = append(container, $('span.open-in-agents-titlebar-widget-label')); labelEl.textContent = label; - - // BaseActionViewItem only wires mouse / touch — add Enter / Space activation - // so the widget is usable via keyboard. - this._register(addDisposableListener(container, EventType.KEY_DOWN, e => { - const event = new StandardKeyboardEvent(e); - if (event.equals(KeyCode.Enter) || event.equals(KeyCode.Space)) { - EventHelper.stop(event, true); - this.onClick(e); - } - })); } } From c51bc51072ef53b2951800550579456301bbf889 Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Thu, 23 Apr 2026 12:17:34 +0100 Subject: [PATCH 28/70] Update OpenInAgents visibility to hide when AI features are disabled and correct localization key for the label Co-authored-by: Copilot --- .../workbench/electron-browser/actions/openInAgentsAction.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts b/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts index 188ab83ec9de2..27e37f6aa69fd 100644 --- a/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts +++ b/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts @@ -43,6 +43,9 @@ const OpenInAgentsVisibility = ContextKeyExpr.and( ContextKeyExpr.equals(`config.${OpenInAgentsEnabledSetting}`, true), IsSessionsWindowContext.toNegated(), IsAuxiliaryWindowContext.toNegated(), + // Hide when AI features are disabled (e.g. `chat.disableAIFeatures`). + // Raw key from ChatEntitlementContextKeys.Setup.disabledInWorkspace. + ContextKeyExpr.equals('chatSetupDisabledInWorkspace', false), ); /** @@ -144,7 +147,7 @@ class OpenInAgentsTitleBarWidget extends BaseActionViewItem { container.setAttribute('role', 'button'); container.setAttribute('data-product-quality', this.productService.quality ?? 'stable'); - const label = this.action.label || localize('openInAgents', 'Open in Agents'); + const label = this.action.label || localize('openInAgentsLabel', "Open in Agents"); container.setAttribute('aria-label', label); this._register(this.hoverService.setupManagedHover(getDefaultHoverDelegate('element'), container, label)); From fe1c3cc0132b525cc6ee8bfc4b8bc283a47be6b6 Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Thu, 23 Apr 2026 12:36:10 +0100 Subject: [PATCH 29/70] Refactor OpenInAgentsVisibility to simplify AI features visibility check Co-authored-by: Copilot --- .../workbench/electron-browser/actions/openInAgentsAction.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts b/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts index 27e37f6aa69fd..557c4f7d77017 100644 --- a/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts +++ b/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts @@ -43,9 +43,8 @@ const OpenInAgentsVisibility = ContextKeyExpr.and( ContextKeyExpr.equals(`config.${OpenInAgentsEnabledSetting}`, true), IsSessionsWindowContext.toNegated(), IsAuxiliaryWindowContext.toNegated(), - // Hide when AI features are disabled (e.g. `chat.disableAIFeatures`). - // Raw key from ChatEntitlementContextKeys.Setup.disabledInWorkspace. - ContextKeyExpr.equals('chatSetupDisabledInWorkspace', false), + // Hide when AI features are disabled. + ContextKeyExpr.notEquals('config.chat.disableAIFeatures', true), ); /** From ddf19034b3a64d695ed15291ec028b0a2fad542f Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Thu, 23 Apr 2026 17:13:59 +0100 Subject: [PATCH 30/70] Enhance OpenInAgents visibility logic to include product quality context and improve AI features handling Co-authored-by: Copilot --- .../electron-browser/actions/openInAgentsAction.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts b/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts index 557c4f7d77017..c26bdc1baca9c 100644 --- a/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts +++ b/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts @@ -13,7 +13,7 @@ import { localize, localize2 } from '../../../nls.js'; import { Action2, MenuId, registerAction2 } from '../../../platform/actions/common/actions.js'; import { IActionViewItemService } from '../../../platform/actions/browser/actionViewItemService.js'; import { IConfigurationRegistry, Extensions as ConfigurationExtensions } from '../../../platform/configuration/common/configurationRegistry.js'; -import { ContextKeyExpr } from '../../../platform/contextkey/common/contextkey.js'; +import { ContextKeyExpr, IContextKeyService, RawContextKey } from '../../../platform/contextkey/common/contextkey.js'; import { IHoverService } from '../../../platform/hover/browser/hover.js'; import { IInstantiationService, ServicesAccessor } from '../../../platform/instantiation/common/instantiation.js'; import { INativeHostService } from '../../../platform/native/common/native.js'; @@ -30,6 +30,10 @@ import { IWorkbenchEnvironmentService } from '../../services/environment/common/ const OpenInAgentsActionId = 'workbench.action.openInAgents'; const OpenInAgentsEnabledSetting = 'workbench.openInAgents.enabled'; +// Context key tracking the current product quality so we can restrict the +// "Open in Agents" entry to insider/exploration builds for now. +const OpenInAgentsProductQualityContext = new RawContextKey('openInAgentsProductQuality', ''); + type OpenInAgentsMode = 'siblingApp' | 'newWindow'; type OpenInAgentsEvent = { mode: OpenInAgentsMode }; @@ -45,6 +49,8 @@ const OpenInAgentsVisibility = ContextKeyExpr.and( IsAuxiliaryWindowContext.toNegated(), // Hide when AI features are disabled. ContextKeyExpr.notEquals('config.chat.disableAIFeatures', true), + // Hide in stable builds for now (insider, exploration and OSS dev are allowed). + ContextKeyExpr.notEquals(OpenInAgentsProductQualityContext.key, 'stable'), ); /** @@ -165,8 +171,11 @@ class OpenInAgentsContribution extends Disposable implements IWorkbenchContribut constructor( @IActionViewItemService actionViewItemService: IActionViewItemService, @IInstantiationService instantiationService: IInstantiationService, + @IContextKeyService contextKeyService: IContextKeyService, + @IProductService productService: IProductService, ) { super(); + OpenInAgentsProductQualityContext.bindTo(contextKeyService).set(productService.quality ?? ''); this._register(actionViewItemService.register(MenuId.TitleBar, OpenInAgentsActionId, (action, options) => { return instantiationService.createInstance(OpenInAgentsTitleBarWidget, action, options); }, undefined)); From 94c27ad40e1989759b99b7c6a2f3f1ff41ce1823 Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Thu, 23 Apr 2026 17:17:34 +0100 Subject: [PATCH 31/70] Update OpenInAgentsProductQualityContext comment to clarify visibility restrictions for stable builds Co-authored-by: Copilot --- .../workbench/electron-browser/actions/openInAgentsAction.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts b/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts index c26bdc1baca9c..3eb6e2f72cef9 100644 --- a/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts +++ b/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts @@ -30,8 +30,8 @@ import { IWorkbenchEnvironmentService } from '../../services/environment/common/ const OpenInAgentsActionId = 'workbench.action.openInAgents'; const OpenInAgentsEnabledSetting = 'workbench.openInAgents.enabled'; -// Context key tracking the current product quality so we can restrict the -// "Open in Agents" entry to insider/exploration builds for now. +// Context key tracking the current product quality so we can hide the +// "Open in Agents" entry in stable builds for now. const OpenInAgentsProductQualityContext = new RawContextKey('openInAgentsProductQuality', ''); type OpenInAgentsMode = 'siblingApp' | 'newWindow'; From 5aaa5bed3ae2766fd845103ed904f028610d8aca Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Thu, 23 Apr 2026 09:29:18 -0700 Subject: [PATCH 32/70] sessions: fix chat history picker not opening selected session (#312158) * sessions: route session opens through ISessionsManagementService The chat history picker (workbench.action.chat.history) showed sessions but clicking one didn't open it in the Agents window. The default session opener calls IChatWidgetService.openSession with ChatViewPaneTarget, which opens the ChatViewId view -- but in the Agents window that view is gated by a when clause on IsNewChatSessionContext / IsNewChatInSessionContext, so it stayed hidden. Register a SessionsOpenerParticipant that routes opens through ISessionsManagementService.openSession, which updates the active session/chat context keys so the right view becomes visible and loads the session. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentSessions: exclude completed sessions from history picker The chat history picker now hides sessions in the Completed state so the list focuses on sessions that are still active or need attention. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../contrib/chat/browser/chat.contribution.ts | 2 + .../chat/browser/sessionsOpenerParticipant.ts | 42 +++++++++++++++++++ .../agentSessions/agentSessionsPicker.ts | 4 +- 3 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 src/vs/sessions/contrib/chat/browser/sessionsOpenerParticipant.ts diff --git a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts index c00f663b3c61d..a4fe043454555 100644 --- a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts @@ -37,6 +37,7 @@ import { ChatViewPane } from '../../../../workbench/contrib/chat/browser/widgetH import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; import { AccessibleViewRegistry } from '../../../../platform/accessibility/browser/accessibleViewRegistry.js'; import { SessionsChatAccessibilityHelp } from './sessionsChatAccessibilityHelp.js'; +import { SessionsOpenerParticipantContribution } from './sessionsOpenerParticipant.js'; class NewChatInSessionsWindowAction extends Action2 { @@ -147,6 +148,7 @@ registerAction2(BranchChatSessionAction); // register workbench contributions registerWorkbenchContribution2(RegisterChatViewContainerContribution.ID, RegisterChatViewContainerContribution, WorkbenchPhase.BlockStartup); registerWorkbenchContribution2(RunScriptContribution.ID, RunScriptContribution, WorkbenchPhase.AfterRestored); +registerWorkbenchContribution2(SessionsOpenerParticipantContribution.ID, SessionsOpenerParticipantContribution, WorkbenchPhase.BlockStartup); // register services registerSingleton(IPromptsService, AgenticPromptsService, InstantiationType.Delayed); diff --git a/src/vs/sessions/contrib/chat/browser/sessionsOpenerParticipant.ts b/src/vs/sessions/contrib/chat/browser/sessionsOpenerParticipant.ts new file mode 100644 index 0000000000000..e9ebfd1820008 --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/sessionsOpenerParticipant.ts @@ -0,0 +1,42 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { ServicesAccessor } from '../../../../editor/browser/editorExtensions.js'; +import { IWorkbenchContribution } from '../../../../workbench/common/contributions.js'; +import { IAgentSession } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsModel.js'; +import { ISessionOpenerParticipant, ISessionOpenOptions, sessionOpenerRegistry } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsOpener.js'; +import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; + +/** + * Routes session open requests in the Agents window through the + * {@link ISessionsManagementService} so that the active session/chat state is + * properly updated. Without this, the default opener tries to load the chat + * directly into the `ChatViewId` view, which is hidden behind a `when` clause + * tied to the new-chat context keys and may simply do nothing. + */ +class SessionsOpenerParticipant implements ISessionOpenerParticipant { + + async handleOpenSession(accessor: ServicesAccessor, session: IAgentSession, openOptions?: ISessionOpenOptions): Promise { + const sessionsManagementService = accessor.get(ISessionsManagementService); + const target = sessionsManagementService.getSession(session.resource); + if (!target) { + return false; + } + + await sessionsManagementService.openSession(session.resource, { preserveFocus: openOptions?.editorOptions?.preserveFocus }); + return true; + } +} + +export class SessionsOpenerParticipantContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'sessions.sessionOpenerParticipant'; + + constructor() { + super(); + this._register(sessionOpenerRegistry.registerParticipant(new SessionsOpenerParticipant())); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.ts index d04feb219aa01..d9d3931bdb489 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.ts @@ -12,7 +12,7 @@ import { ICommandService } from '../../../../../platform/commands/common/command import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { IQuickInputButton, IQuickInputService, IQuickPickItem, IQuickPickSeparator } from '../../../../../platform/quickinput/common/quickInput.js'; import { ISessionOpenOptions, openSession } from './agentSessionsOpener.js'; -import { IAgentSession, isLocalAgentSessionItem } from './agentSessionsModel.js'; +import { AgentSessionStatus, IAgentSession, isLocalAgentSessionItem } from './agentSessionsModel.js'; import { IAgentSessionsService } from './agentSessionsService.js'; import { AgentSessionsSorter, groupAgentSessionsByDate, sessionDateFromNow } from './agentSessionsViewer.js'; import { AGENT_SESSION_DELETE_ACTION_ID, AGENT_SESSION_RENAME_ACTION_ID } from './agentSessions.js'; @@ -141,7 +141,7 @@ export class AgentSessionsPicker { private createPickerItems(filter: AgentSessionsFilter): (ISessionPickItem | IQuickPickSeparator)[] { const sessions = this.agentSessionsService.model.sessions - .filter(session => !filter.exclude(session)) + .filter(session => session.status !== AgentSessionStatus.Completed && !filter.exclude(session)) .sort(this.sorter.compare.bind(this.sorter)); const items: (ISessionPickItem | IQuickPickSeparator)[] = []; From 6aaf3c6b3d58e5d35928df7ec899f7f372f6b59e Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega <48293249+osortega@users.noreply.github.com> Date: Thu, 23 Apr 2026 09:34:28 -0700 Subject: [PATCH 33/70] sessions: Add mobile-compatible PWA layout for agent sessions (#309344) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * sessions: add mobile-compatible PWA layout for agent sessions Introduce a responsive mobile layout for the agent sessions window, enabling a native app-like experience when accessed via mobile browsers or installed as a PWA. The implementation uses mobile Part subclasses, a MobileTopBar chrome component, CSS overrides, and when-clause gating to progressively enhance the desktop sessions workbench for phone viewports. Key changes: Layout policy & viewport detection: - SessionsLayoutPolicy with observable viewport classification (phone <640px, tablet 640-1024, desktop >1024) - Context keys: ViewportClassContext, IsMobileLayoutContext, KeyboardVisibleContext - Runtime viewport class change detection in layout() Mobile chrome components: - MobileTopBar: hamburger + session title + new session (+) - MobileNavigationStack: history.pushState integration for Android back button - Sidebar drawer overlay with backdrop dismiss Mobile Part subclasses: - MobileChatBarPart, MobileSidebarPart, MobileAuxiliaryBarPart, MobilePanelPart override layout()/updateStyles() only - AgenticPaneCompositePartService conditionally instantiates mobile vs desktop Parts View gating: - Desktop-only views hidden on mobile via IsMobileLayoutContext: Changes, Files, Logs, Terminal, Code Review, Open in VS Code - Customization toolbar hidden via CSS on phone CSS & PWA: - Edge-to-edge chat (no card chrome on phone) - Safe area insets, touch targets (44px min), overscroll containment - PWA manifest with standalone display mode - viewport-fit=cover meta tag - Dynamic theme-color meta created programmatically Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Remove out-of-scope changes from mobile PR Remove agentHost WebSocket implementation, webHostDiscovery contribution, welcome/walkthrough OAuth flow changes, PWA manifest updates, and lock file noise — none of these are related to mobile layout components for the agents workbench. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Restore agentHost browser files incorrectly deleted These files exist in main and were not introduced by this PR. They were mistakenly removed during out-of-scope cleanup. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Remove unrelated changes and PWA support from mobile PR - Revert copilot package-lock.json to main (unrelated npm artifact) - Revert agentHost remoteAgentHostProtocolClient.ts (diffs field not mobile-related) - Revert agentHost sessionTransport.ts (protocol cleanup not mobile-related) - Remove theme-color meta element creation and dynamic updates (PWA) - Remove apple-touch-startup-image link injection (PWA) - Remove PWA & Viewport section from MOBILE.md - Keep viewport-fit=cover as it is needed for safe-area-inset CSS on mobile Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix mobile chat welcome page CSS selectors to match actual DOM classes The mobile phone-layout CSS targeted non-existent class names (chat-full-welcome*) instead of the actual DOM classes used by newChatViewPane.ts and newChatInput.ts. This caused the welcome page to render without the Copilot logo, centering, or bottom-pinned input on phone viewports. Also fixes the logo SVG path and adds dark/light theme support. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address PR review comments: titlebar visibility, CSS docs, inline styles - Fix isVisible(TITLEBAR_PART) to return false on phone layout where the grid titlebar is hidden and replaced by MobileTopBar - Fix computeContainerOffset() to use MobileTopBar height on phone layout so overlays (quick picks, hovers) are positioned correctly - Add CSS comment block documenting the .mobile-layout vs .phone-layout class hierarchy and when to use each - Add code comment in MobileAuxiliaryBarPart explaining why inline style clearing is needed (parent sets them via JS, CSS can't override) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address council review: fix mobile Part runtime transitions and disposal hygiene - Mobile Parts now dispatch at runtime (isPhoneLayout): phone uses mobile layout math, tablet/desktop delegates to super. Fixes 35px chat overlap after phone→landscape rotation across the 640px breakpoint. - Workbench calls updateStyles() on pane composite parts after viewport class transitions so card-chrome inline styles re-apply/clear correctly. - MobileNavigationStack: replace _suppressNextPop boolean with pending counter to handle concurrent popSilently calls and rapid back taps. - Drop redundant Part.prototype.layout.call in mobile Part overrides (AbstractPaneCompositePart.layout already cascades to Part.layout). - Virtual keyboard detection: use mainWindow.innerHeight as baseline instead of captured initialViewportHeight; threshold 150→100. - Sidebar drawer backdrop: managed via DisposableStore with addDisposableListener for its click handler. - MobileTopBar: switch raw addEventListener to addDisposableListener; register element removal before parent.prepend so exceptions still clean up. - Remove redundant matchMedia orientation listener; the window resize listener already handles orientation changes. * Use head.getElementsByTagName instead of querySelector for viewport meta * Scope mobile layout to phone only (remove .mobile-layout class) * Fix desktop/tablet regressions: keep auxiliaryBar visible and use original sizes * Gate phone layout on mobile OS (iOS/Android) instead of width alone --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/sessions/LAYOUT.md | 1 + src/vs/sessions/MOBILE.md | 154 ++++++++ src/vs/sessions/browser/layoutPolicy.ts | 159 ++++++++ src/vs/sessions/browser/media/style.css | 258 +++++++++++++ .../sessions/browser/mobileNavigationStack.ts | 113 ++++++ .../browser/paneCompositePartService.ts | 17 +- src/vs/sessions/browser/parts/chatBarPart.ts | 2 +- .../browser/parts/media/sidebarPart.css | 54 +++ .../browser/parts/media/titlebarpart.css | 34 ++ .../parts/mobile/mobileAuxiliaryBarPart.ts | 54 +++ .../browser/parts/mobile/mobileChatBarPart.ts | 58 +++ .../browser/parts/mobile/mobileChatShell.css | 359 ++++++++++++++++++ .../browser/parts/mobile/mobileLayout.ts | 26 ++ .../browser/parts/mobile/mobilePanelPart.ts | 50 +++ .../browser/parts/mobile/mobileSidebarPart.ts | 37 ++ .../browser/parts/mobile/mobileTopBar.ts | 71 ++++ src/vs/sessions/browser/workbench.ts | 332 ++++++++++++++-- src/vs/sessions/common/contextkeys.ts | 7 + .../changes/browser/changes.contribution.ts | 2 + .../chat/browser/openInVSCode.contribution.ts | 4 +- .../openInVSCode.contribution.ts | 4 +- .../browser/codeReview.contributions.ts | 2 + .../files/browser/files.contribution.ts | 5 +- .../sessions/browser/media/sessionsList.css | 20 + .../browser/sessionsTerminalContribution.ts | 4 +- 25 files changed, 1781 insertions(+), 46 deletions(-) create mode 100644 src/vs/sessions/MOBILE.md create mode 100644 src/vs/sessions/browser/layoutPolicy.ts create mode 100644 src/vs/sessions/browser/mobileNavigationStack.ts create mode 100644 src/vs/sessions/browser/parts/mobile/mobileAuxiliaryBarPart.ts create mode 100644 src/vs/sessions/browser/parts/mobile/mobileChatBarPart.ts create mode 100644 src/vs/sessions/browser/parts/mobile/mobileChatShell.css create mode 100644 src/vs/sessions/browser/parts/mobile/mobileLayout.ts create mode 100644 src/vs/sessions/browser/parts/mobile/mobilePanelPart.ts create mode 100644 src/vs/sessions/browser/parts/mobile/mobileSidebarPart.ts create mode 100644 src/vs/sessions/browser/parts/mobile/mobileTopBar.ts diff --git a/src/vs/sessions/LAYOUT.md b/src/vs/sessions/LAYOUT.md index e22625bd3b037..a62f326e01330 100644 --- a/src/vs/sessions/LAYOUT.md +++ b/src/vs/sessions/LAYOUT.md @@ -664,6 +664,7 @@ interface IPartVisibilityState { | Date | Change | |------|--------| +| 2026-04-23 | Updated mobile layout policy platform detection to use shared `platform.isMobile`, and reduced phone-layout CSS `!important` usage where selector specificity already provides stable overrides. | | 2026-04-22 | Increased the sessions titlebar account widget's GitHub profile image from `16px × 16px` to `18px × 18px` while keeping the existing `22px × 22px` control footprint and avatar border treatment. | | 2026-04-22 | Added sessions-only toast offset overrides so notification toasts now use `right: 15px` in the default bottom-right placement and `left: 15px` in the bottom-left placement, matching the notification center spacing. | | 2026-04-22 | Added a sessions-workbench notification offset override so the shared notification controllers no longer push top-right notifications down to `42px`; sessions now reapply a fixed `40px` top offset for top-right notification center/toast placement. | diff --git a/src/vs/sessions/MOBILE.md b/src/vs/sessions/MOBILE.md new file mode 100644 index 0000000000000..7b18d586d8c66 --- /dev/null +++ b/src/vs/sessions/MOBILE.md @@ -0,0 +1,154 @@ +# Mobile Agent Sessions — Architecture + +## Core Principle + +**Every feature accessible in the desktop window must be accessible on mobile — same functionality, different presentation.** Mobile is NOT "desktop minus stuff." It is a parallel UI layer where the same services, views, and actions are rendered through mobile-native interaction patterns. + +## Architecture + +### Mobile Part Subclasses + +Desktop Parts (`ChatBarPart`, `SidebarPart`, `PanelPart`, `AuxiliaryBarPart`) remain unchanged. Each has a **mobile subclass** that extends it and overrides only `layout()` and/or `updateStyles()`. `AgenticPaneCompositePartService` conditionally instantiates the mobile or desktop variant at startup based on viewport width (`< 640px` → phone). + +Each mobile Part checks the current layout class (via `isPhoneLayout(layoutService)`) at every call. When the viewport is phone it applies mobile behavior (full-cell layout, no card chrome, no session-bar subtraction). When the viewport is tablet/desktop — which happens when a real phone rotates past the 640px breakpoint — it delegates to the desktop `super` implementation. This means a `Mobile*Part` instance is safe to keep through a viewport-class transition without producing wrong layout math. + +This means: +- Desktop code has **zero** phone-layout checks — all mobile logic lives in mobile subclasses, `MobileTopBar`, and CSS. +- Phone-instantiated parts adapt correctly to rotation across the 640px breakpoint by delegating to `super`. + +After a viewport-class transition the workbench calls `updateStyles()` on each pane composite part so card-chrome inline styles get re-applied (desktop) or cleared (phone) for the new class. + +### View & Action Gating + +Views, menu items, and actions use `when` clauses with the `sessionsIsPhoneLayout` context key to control visibility in phone layout. This follows a **default-deny** approach for phone: + +- **Desktop-only features** add `when: IsPhoneLayoutContext.negate()` to their view descriptors and menu registrations. They simply don't appear on phone. +- **Phone-compatible features** (chat, sessions list) have no phone gate — they render on all viewports. +- **Phone-specific replacements** (when ready) register with `when: IsPhoneLayoutContext` and live in separate files under `parts/mobile/contributions/`. + +Tablet and larger viewports currently fall back to the desktop layout; no separate tablet design exists yet. + +Two registrations can target the same slot with opposite `when` clauses, pointing to different view classes in different files — giving full file separation with no internal branching. + +#### Current Gating Status + +| Feature | Phone Status | Mechanism | +|---------|--------------|-----------| +| Sessions list (sidebar) | ✅ Compatible | No gate | +| Chat views (ChatBar) | ✅ Compatible | No gate | +| Changes view (AuxiliaryBar) | ❌ Gated | `when: !sessionsIsPhoneLayout` on view descriptor | +| Files view (AuxiliaryBar) | ❌ Gated | `when: !sessionsIsPhoneLayout` on view descriptor | +| Logs view (Panel) | ❌ Gated | `when: !sessionsIsPhoneLayout` on view descriptor | +| Terminal actions | ❌ Gated | `when: !sessionsIsPhoneLayout` on menu item | +| "Open in VS Code" action | ❌ Gated | `when: !sessionsIsPhoneLayout` on menu item | +| Code review toolbar | ❌ Gated | `when: !sessionsIsPhoneLayout` on menu item | +| Customizations toolbar | ❌ Hidden | CSS `display: none` on phone | +| Titlebar | ❌ Hidden | Grid `visible: false` + CSS + MobileTopBar replacement | + +### Phone Layout + +On phone-sized viewports (`< 640px` width): + +``` +┌──────────────────────────────────┐ +│ [☰] Session Title [+] │ ← MobileTopBar (prepended before grid) +├──────────────────────────────────┤ +│ │ +│ Chat (edge-to-edge) │ ← Grid: ChatBarPart fills 100% +│ │ +│ │ +│ │ +│ ┌──────────────────────────┐ │ +│ │ Chat input │ │ ← Pinned to bottom +│ └──────────────────────────┘ │ +└──────────────────────────────────┘ +``` + +- **MobileTopBar** is a DOM element prepended above the grid. It has a hamburger (☰), session title, and new session (+) button. +- **Sidebar** is hidden by default and opens as an **85% width drawer overlay** with a backdrop when the hamburger is tapped. CSS makes its `split-view-view` absolutely positioned with `z-index: 250`. The workbench manually calls `sidebarPart.layout()` with drawer dimensions after opening. Closing the drawer clears the navigation stack. +- **Titlebar** is hidden in the grid (`visible: false`) and via CSS — replaced by MobileTopBar. +- **SessionCompositeBar** (chat tabs) is hidden via CSS. +- The grid uses `display: flex; flex-direction: column` and all `split-view-view:has(> .part)` containers are positioned absolutely at `100% width/height`. + +### Viewport Classification + +`SessionsLayoutPolicy` classifies the viewport: +- **phone**: `width < 640px` +- **tablet**: `640px ≤ width < 1024px` (treated as desktop; no phone-specific chrome) +- **desktop**: `width ≥ 1024px` + +The workbench toggles the `phone-layout` CSS class on `layout()` and creates/destroys mobile components when the viewport class changes at runtime (e.g., DevTools device emulation, or a real phone rotating across the 640px breakpoint). MobileTopBar lifecycle is managed via a `DisposableStore` that is cleared on viewport transitions to prevent leaks. + +### Context Keys + +| Key | Type | Purpose | +|-----|------|---------| +| `sessionsIsPhoneLayout` | `boolean` | `true` when the viewport is phone (< 640px) | +| `sessionsKeyboardVisible` | `boolean` | `true` when the virtual keyboard is visible | + +### Desktop → Mobile Component Mapping + +| Desktop Component | Mobile Equivalent | How Accessed | +|---|---|---| +| **Titlebar** (3-section toolbar) | **MobileTopBar** (☰ / title / +) | Always visible at top | +| **Sidebar** (sessions list) | Drawer overlay (85% width) | Hamburger button (☰) | +| **ChatBar** (chat widget) | Same Part, edge-to-edge, no card chrome | Default view (always visible) | +| **AuxiliaryBar** (files, changes) | Gated — not shown on mobile | Planned: mobile-specific view | +| **Panel** (terminal, output) | Gated — not shown on mobile | Planned: mobile-specific view | +| **SessionCompositeBar** (chat tabs) | Hidden on phone | — | +| **New Session** (sidebar button) | + button in MobileTopBar | Always visible in top bar | + +## File Map + +### Mobile Part Subclasses + +| File | Purpose | +|------|---------| +| `browser/parts/mobile/mobileChatBarPart.ts` | Extends `ChatBarPart`. Overrides `layout()` (no card margins) and `updateStyles()` (no inline card styles). | +| `browser/parts/mobile/mobileSidebarPart.ts` | Extends `SidebarPart`. Overrides `updateStyles()` (no inline card/title styles). | +| `browser/parts/mobile/mobileAuxiliaryBarPart.ts` | Extends `AuxiliaryBarPart`. Overrides `layout()` and `updateStyles()` (no card margins or inline styles). | +| `browser/parts/mobile/mobilePanelPart.ts` | Extends `PanelPart`. Overrides `layout()` and `updateStyles()` (no card margins or inline styles). | + +### Mobile Chrome Components + +| File | Purpose | +|------|---------| +| `browser/parts/mobile/mobileTopBar.ts` | Phone top bar: hamburger (☰), session title, new session (+). Emits `onDidClickHamburger`, `onDidClickNewSession`, `onDidClickTitle`. | +| `browser/parts/mobile/mobileChatShell.css` | **Single source of truth** for all phone-layout CSS: flex column layout, split-view-view absolute positioning, card chrome removal, part/content width overrides, sidebar title hiding, composite bar hiding, welcome page layout, sash hiding, button focus overrides, mobile pickers. | + +### Layout & Navigation + +| File | Purpose | +|------|---------| +| `browser/layoutPolicy.ts` | `SessionsLayoutPolicy`: observable viewport classification (phone/tablet/desktop), platform flags (isIOS, isAndroid, isTouchDevice), part visibility and size defaults. | +| `browser/mobileNavigationStack.ts` | `MobileNavigationStack`: Android back button integration via `history.pushState` / `popstate`. Supports `push()`, `pop()`, and `clear()`. | +| `common/contextkeys.ts` | Phone context keys: `IsPhoneLayoutContext`, `KeyboardVisibleContext`. | + +### Part Instantiation + +| File | Purpose | +|------|---------| +| `browser/paneCompositePartService.ts` | `AgenticPaneCompositePartService`: checks viewport width at construction time and instantiates `Mobile*Part` vs desktop `*Part` classes accordingly. | + +### Workbench Integration + +| File | Key Changes | +|------|-------------| +| `browser/workbench.ts` | Layout policy integration, MobileTopBar creation/destruction (via `DisposableStore`), sidebar drawer open/close with backdrop, viewport-class-change detection, window resize listener, grid height calculation (subtracts MobileTopBar height), titlebar grid visibility toggle, `ISessionsManagementService` for new session button. | +| `browser/parts/chatBarPart.ts` | `_lastLayout` changed from `private` to `protected` for mobile subclass access. | + +### Styling + +| File | Purpose | +|------|---------| +| `browser/parts/mobile/mobileChatShell.css` | All phone-layout CSS (see above). | +| `browser/parts/media/sidebarPart.css` | Sidebar drawer overlay CSS: 85% width, z-index 250, slide-in animation, backdrop. | +| `browser/media/style.css` | Mobile overscroll containment, 44px touch targets, quick pick bottom sheets, context menu action sheets, dialog sizing, notification positioning, hover card suppression, editor modal full-screen. | + +## Remaining Work + +- **Session title sync**: MobileTopBar shows hardcoded "New Session" — needs to subscribe to `sessionsManagementService.activeSession` and update title when session changes. +- **Files & Terminal access**: Should become phone-specific views gated with `when: IsPhoneLayoutContext`. +- **iOS keyboard handling**: Adjust layout when virtual keyboard appears (context key exists, but no layout response yet). +- **Session list inline actions**: Make always-visible on touch devices (no hover-to-reveal). +- **Customizations on mobile**: Currently hidden — needs a mobile-friendly alternative. diff --git a/src/vs/sessions/browser/layoutPolicy.ts b/src/vs/sessions/browser/layoutPolicy.ts new file mode 100644 index 0000000000000..ded7d705e8b4e --- /dev/null +++ b/src/vs/sessions/browser/layoutPolicy.ts @@ -0,0 +1,159 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../base/common/lifecycle.js'; +import { observableValue, derived, IObservable } from '../../base/common/observable.js'; +import { isIOS, isMobile } from '../../base/common/platform.js'; +import { isAndroid } from '../../base/browser/browser.js'; +import { Gesture } from '../../base/browser/touch.js'; + +/** Viewport classification based on container width. */ +export type ViewportClass = 'phone' | 'tablet' | 'desktop'; + +/** Default visibility for each workbench part. */ +export interface IPartVisibilityDefaults { + readonly sidebar: boolean; + readonly auxiliaryBar: boolean; + readonly panel: boolean; + readonly chatBar: boolean; + readonly editor: boolean; +} + +/** Default sizes (in pixels) for each workbench part. */ +export interface IPartSizeDefaults { + readonly sideBarSize: number; + readonly auxiliaryBarSize: number; + readonly panelSize: number; + readonly chatBarWidth: number; +} + +const PHONE_MAX_WIDTH = 640; +const TABLET_MAX_WIDTH = 1024; + +/** + * Whether the current platform is a phone/tablet OS. The phone layout is + * only applied on actual mobile devices so that resizing a desktop window + * below 640px does not switch the agents workbench into phone mode. + */ +const isMobilePlatform = isMobile; + +/** + * Classifies the viewport into one of three classes based on width. + * Phone and tablet classifications are gated on a mobile OS; desktop + * browsers and Electron always report `desktop` regardless of width. + */ +function classifyViewport(width: number): ViewportClass { + if (!isMobilePlatform) { + return 'desktop'; + } + if (width < PHONE_MAX_WIDTH) { + return 'phone'; + } + if (width < TABLET_MAX_WIDTH) { + return 'tablet'; + } + return 'desktop'; +} + +/** + * Observable-based viewport classification and layout policy for + * the Sessions workbench. Consumed by `SessionsWorkbench` to drive + * part visibility, sizing, and behavior based on viewport dimensions + * and platform. + */ +export class SessionsLayoutPolicy extends Disposable { + + // --- Platform flags (static, read once) --- + + /** Whether the current platform is iOS. */ + readonly isIOS: boolean; + + /** Whether the current platform is Android. */ + readonly isAndroid: boolean; + + /** Whether the current device supports touch input. */ + readonly isTouchDevice: boolean; + + // --- Observables --- + + private readonly _viewportClass = observableValue(this, 'desktop'); + + /** Current viewport class derived from the most recent `update()` call. */ + readonly viewportClass: IObservable = this._viewportClass; + + /** `true` when the viewport class is `phone`. */ + readonly isPhoneLayout: IObservable = derived(this, reader => { + return this._viewportClass.read(reader) === 'phone'; + }); + + constructor() { + super(); + + this.isIOS = isIOS; + this.isAndroid = isAndroid; + this.isTouchDevice = Gesture.isTouchDevice(); + } + + /** + * Update the viewport classification. Call this from the workbench + * `layout()` method whenever the container dimensions change. + * + * @param width Container width in pixels. + * @param height Container height in pixels (reserved for future use). + */ + update(width: number, _height: number): void { + const next = classifyViewport(width); + if (this._viewportClass.get() !== next) { + this._viewportClass.set(next, undefined); + } + } + + /** + * Returns the default part visibility for the given viewport class. + * If no class is supplied the current observed class is used. + */ + getPartVisibilityDefaults(viewportClass?: ViewportClass): IPartVisibilityDefaults { + const vc = viewportClass ?? this._viewportClass.get(); + switch (vc) { + case 'phone': + return { sidebar: false, auxiliaryBar: false, panel: false, chatBar: true, editor: false }; + case 'tablet': + case 'desktop': + // Tablet and desktop share the standard multi-part workbench defaults. + // A dedicated tablet layout has not been designed yet. + return { sidebar: true, auxiliaryBar: true, panel: false, chatBar: true, editor: false }; + } + } + + /** + * Returns the default part sizes for the given viewport dimensions. + * If no viewport class is supplied the current observed class is used. + * + * @param width Container width in pixels. + * @param height Container height in pixels (reserved for future use). + * @param viewportClass Optional explicit viewport class override. + */ + getPartSizes(width: number, _height: number, viewportClass?: ViewportClass): IPartSizeDefaults { + const vc = viewportClass ?? this._viewportClass.get(); + switch (vc) { + case 'phone': + return { + sideBarSize: 0, + auxiliaryBarSize: 0, + panelSize: 0, + chatBarWidth: width, + }; + case 'tablet': + case 'desktop': + // Tablet currently falls back to desktop sizing. + return { + sideBarSize: 300, + auxiliaryBarSize: 340, + panelSize: 300, + chatBarWidth: width - 300, + }; + } + } +} diff --git a/src/vs/sessions/browser/media/style.css b/src/vs/sessions/browser/media/style.css index 5b6c61e90d0a0..228ae5d448f30 100644 --- a/src/vs/sessions/browser/media/style.css +++ b/src/vs/sessions/browser/media/style.css @@ -651,3 +651,261 @@ .agent-sessions-workbench .badge > .badge-content { border-radius: 4px !important; } + +/* Phone-layout rules for parts, sashes, max-width constraints, and grid + background live in mobileChatShell.css — do not duplicate them here. */ + +/* + * Phone layout (< 640px) styles. Currently the only mobile form factor + * supported by the sessions workbench; tablet/larger viewports fall back + * to the desktop layout. + */ + +/* ---- Phone Layout: Overscroll Containment ---- */ + +/* Prevent body rubber-band on iOS and Chrome pull-to-refresh on Android */ +.agent-sessions-workbench.phone-layout .monaco-scrollable-element > .scrollable-element { + overscroll-behavior: contain; +} + +.agent-sessions-workbench.phone-layout .interactive-session { + overscroll-behavior: contain; +} + +.agent-sessions-workbench.phone-layout .monaco-list { + overscroll-behavior: contain; +} + +/* ---- Phone Layout: Touch Target Sizing ---- */ + +/* Ensure interactive elements meet 44px minimum touch target */ +.agent-sessions-workbench.phone-layout .action-item > .action-label { + min-height: 44px; + min-width: 44px; +} + +/* Touch action for tap responsiveness */ +.agent-sessions-workbench.phone-layout .action-item, +.agent-sessions-workbench.phone-layout button { + touch-action: manipulation; +} + +/* Disable text selection callout on interactive elements */ +.agent-sessions-workbench.phone-layout .action-item, +.agent-sessions-workbench.phone-layout .monaco-toolbar, +.agent-sessions-workbench.phone-layout .sidebar-footer { + -webkit-touch-callout: none; + user-select: none; + -webkit-user-select: none; +} + +/* Titlebar safe-area inset lives in mobileChatShell.css */ + +/* ---- Phone Layout: Mobile Quick Picks ---- */ + +/* Transform quick pick into full-width bottom sheet on phone */ +.agent-sessions-workbench.phone-layout .quick-input-widget { + top: auto !important; + bottom: 0 !important; + left: 0 !important; + right: 0 !important; + width: 100% !important; + max-width: 100% !important; + border-radius: 16px 16px 0 0; + padding-bottom: env(safe-area-inset-bottom); +} + +.agent-sessions-workbench.phone-layout .quick-input-widget .quick-input-list { + max-height: 50vh; +} + +.agent-sessions-workbench.phone-layout .quick-input-widget .quick-input-list .monaco-list-row { + min-height: 44px; +} + +/* ---- Phone Layout: Mobile Context Menus ---- */ + +/* Transform context menus into bottom action sheets on phone */ +.agent-sessions-workbench.phone-layout .context-view .monaco-menu { + position: fixed !important; + bottom: 0 !important; + left: 0 !important; + right: 0 !important; + top: auto !important; + width: 100% !important; + max-width: 100% !important; + border-radius: 16px 16px 0 0; + padding-bottom: env(safe-area-inset-bottom); +} + +.agent-sessions-workbench.phone-layout .context-view .monaco-menu .monaco-action-bar .action-item { + min-height: 44px; +} + +.agent-sessions-workbench.phone-layout .context-view .monaco-menu .monaco-action-bar .action-label { + font-size: 16px; + padding: 8px 16px; +} + +/* ---- Phone Layout: Mobile Dialogs ---- */ + +/* Make dialogs near-full-width with larger buttons on phone */ +.agent-sessions-workbench.phone-layout .monaco-dialog-box { + width: calc(100% - 32px); + max-width: calc(100% - 32px); +} + +.agent-sessions-workbench.phone-layout .monaco-dialog-box .dialog-buttons-row .monaco-button { + min-height: 44px; + font-size: 16px; +} + +/* ---- Phone Layout: Mobile Notifications ---- */ + +/* Full-width notification toasts at top of screen */ +.agent-sessions-workbench.phone-layout .notifications-toasts { + left: 8px !important; + right: 8px !important; + bottom: auto !important; + top: calc(env(safe-area-inset-top) + 48px) !important; + width: auto !important; +} + +.agent-sessions-workbench.phone-layout .notifications-toasts .notification-toast { + width: 100%; + max-width: 100%; +} + +.agent-sessions-workbench.phone-layout .notifications-toasts .notification-toast .notification-toast-container { + border-radius: 12px; +} + +/* ---- Phone Layout: Hover Cards ---- */ + +/* Disable delayed hover cards on touch devices — they never trigger */ +.agent-sessions-workbench.phone-layout .monaco-hover { + display: none; +} + +/* Exception: keep hovers that are explicitly triggered (e.g., info buttons) */ +.agent-sessions-workbench.phone-layout .monaco-hover.visible-on-mobile { + display: block; +} + +/* ---- Phone Layout: Mobile Editor Modal ---- */ + +/* Full-screen editor modal on phone — no margins, covers entire viewport */ +.agent-sessions-workbench.phone-layout .monaco-modal-editor-part { + top: 0 !important; + left: 0 !important; + right: 0 !important; + bottom: 0 !important; + width: 100% !important; + height: 100% !important; + border-radius: 0 !important; + margin: 0 !important; + animation: editor-slide-up 250ms ease-out; +} + +@keyframes editor-slide-up { + from { + transform: translateY(30%); + opacity: 0.5; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +/* Make the modal backdrop fully cover the screen on phone */ +.agent-sessions-workbench.phone-layout .monaco-modal-editor-block { + background: rgba(0, 0, 0, 0.7); +} + +/* Safe area padding for editor titlebar on phone */ +.agent-sessions-workbench.phone-layout .monaco-modal-editor-part .title { + padding-top: env(safe-area-inset-top); +} + +/* ---- Phone Layout: Input Auto-Zoom Prevention ---- */ + +/* iOS Safari zooms in on input focus when font-size < 16px. + Force minimum 16px on all input elements on phone. */ +.agent-sessions-workbench.phone-layout input, +.agent-sessions-workbench.phone-layout textarea, +.agent-sessions-workbench.phone-layout .monaco-inputbox input, +.agent-sessions-workbench.phone-layout .chat-input-container textarea { + font-size: 16px; +} + +/* ---- Phone Layout: Native Scroll Preservation ---- */ + +/* Ensure chat content uses momentum scrolling on phone. + The -webkit-overflow-scrolling property is needed for older iOS. */ +.agent-sessions-workbench.phone-layout .interactive-session .monaco-scrollable-element { + -webkit-overflow-scrolling: touch; +} + +/* ---- Phone Layout: Bottom Sheet Panel ---- */ + +/* Panel slides up from bottom as a sheet on phone */ +.agent-sessions-workbench.phone-layout .split-view-view:has(> .part.panel) { + position: absolute !important; + bottom: 0 !important; + left: 0 !important; + right: 0 !important; + height: 60vh !important; + z-index: 200; + animation: panel-slide-up 250ms ease-out; + border-radius: 16px 16px 0 0; + overflow: hidden; +} + +@keyframes panel-slide-up { + from { + transform: translateY(100%); + opacity: 0.5; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +/* Panel drag handle visual indicator */ +.agent-sessions-workbench.phone-layout .part.panel::before { + content: ''; + display: block; + width: 36px; + height: 5px; + background: var(--vscode-foreground); + opacity: 0.3; + border-radius: 3px; + margin: 8px auto 4px auto; +} + +/* ---- Phone Layout: Auxiliary Bar Overlay ---- */ + +/* Auxiliary bar slides in from the right as a full-height overlay on phone */ +.agent-sessions-workbench.phone-layout .split-view-view:has(> .part.auxiliarybar) { + position: absolute !important; + top: 0 !important; + right: 0 !important; + bottom: 0 !important; + width: 85vw !important; + max-width: 400px; + z-index: 200; + animation: auxbar-slide-in 250ms ease-out; +} + +@keyframes auxbar-slide-in { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} diff --git a/src/vs/sessions/browser/mobileNavigationStack.ts b/src/vs/sessions/browser/mobileNavigationStack.ts new file mode 100644 index 0000000000000..020022bf65c1f --- /dev/null +++ b/src/vs/sessions/browser/mobileNavigationStack.ts @@ -0,0 +1,113 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Emitter, Event } from '../../base/common/event.js'; +import { mainWindow } from '../../base/browser/window.js'; + +export type MobileNavigationLayer = 'sidebar' | 'editor' | 'panel' | 'auxbar'; + +interface MobileNavigationEntry { + readonly layer: MobileNavigationLayer; + readonly id: number; +} + +/** + * Manages a stack of open overlay layers (sidebar, editor modal, panel sheet, + * aux bar) and integrates with `history.pushState` / `popstate` so that the + * Android back button dismisses overlays in LIFO order. + */ +export class MobileNavigationStack extends Disposable { + + private readonly _stack: MobileNavigationEntry[] = []; + private _nextId = 0; + + private readonly _onDidPop = this._register(new Emitter()); + readonly onDidPop: Event = this._onDidPop.event; + + constructor() { + super(); + + this._register(Event.fromDOMEventEmitter(mainWindow, 'popstate')(e => { + this._onPopState(e); + })); + } + + push(layer: MobileNavigationLayer): void { + const id = this._nextId++; + this._stack.push({ layer, id }); + mainWindow.history.pushState({ layer, id }, ''); + } + + pop(): MobileNavigationLayer | undefined { + const entry = this._stack.pop(); + if (entry) { + this._onDidPop.fire(entry.layer); + } + return entry?.layer; + } + + peek(): MobileNavigationLayer | undefined { + return this._stack.length > 0 + ? this._stack[this._stack.length - 1].layer + : undefined; + } + + has(layer: MobileNavigationLayer): boolean { + return this._stack.some(e => e.layer === layer); + } + + clear(): void { + this._stack.length = 0; + } + + /** + * Removes the topmost entry matching `layer` from the stack (without + * firing {@link onDidPop}) and rewinds the browser history by one entry. + * Use this when a layer is closed by UI interaction (e.g., backdrop click) + * so the history and stack stay in sync without recursing back into + * close handlers. + * + * Concurrent silent pops are handled via a counter: each call increments + * {@link _pendingSilentPops} and the matching {@link _onPopState} decrements + * it, so rapid back-button taps or multiple overlay closes cannot leak + * suppression state across unrelated pops. + */ + popSilently(layer: MobileNavigationLayer): void { + for (let i = this._stack.length - 1; i >= 0; i--) { + if (this._stack[i].layer === layer) { + this._stack.splice(i, 1); + this._pendingSilentPops++; + mainWindow.history.back(); + return; + } + } + } + + private _pendingSilentPops = 0; + + private _onPopState(e: PopStateEvent): void { + if (this._pendingSilentPops > 0) { + this._pendingSilentPops--; + return; + } + + if (this._stack.length === 0) { + return; + } + + const top = this._stack[this._stack.length - 1]; + const state = e.state as { layer?: string; id?: number } | null; + + // Only pop if the event's state id matches expectations — + // the popstate must correspond to a state *before* our top entry, + // meaning the top entry's push was just undone. + if (state && typeof state.id === 'number' && state.id >= top.id) { + return; + } + + this.pop(); + } +} diff --git a/src/vs/sessions/browser/paneCompositePartService.ts b/src/vs/sessions/browser/paneCompositePartService.ts index 060cdfdedade6..1c41eb907db18 100644 --- a/src/vs/sessions/browser/paneCompositePartService.ts +++ b/src/vs/sessions/browser/paneCompositePartService.ts @@ -18,6 +18,12 @@ import { PanelPart } from './parts/panelPart.js'; import { SidebarPart } from './parts/sidebarPart.js'; import { AuxiliaryBarPart } from './parts/auxiliaryBarPart.js'; import { ChatBarPart } from './parts/chatBarPart.js'; +import { MobilePanelPart } from './parts/mobile/mobilePanelPart.js'; +import { MobileSidebarPart } from './parts/mobile/mobileSidebarPart.js'; +import { MobileAuxiliaryBarPart } from './parts/mobile/mobileAuxiliaryBarPart.js'; +import { MobileChatBarPart } from './parts/mobile/mobileChatBarPart.js'; +import { getClientArea } from '../../base/browser/dom.js'; +import { mainWindow } from '../../base/browser/window.js'; import { InstantiationType, registerSingleton } from '../../platform/instantiation/common/extensions.js'; export class AgenticPaneCompositePartService extends Disposable implements IPaneCompositePartService { @@ -37,10 +43,13 @@ export class AgenticPaneCompositePartService extends Disposable implements IPane ) { super(); - this.registerPart(ViewContainerLocation.Panel, instantiationService.createInstance(PanelPart)); - this.registerPart(ViewContainerLocation.Sidebar, instantiationService.createInstance(SidebarPart)); - this.registerPart(ViewContainerLocation.AuxiliaryBar, instantiationService.createInstance(AuxiliaryBarPart)); - this.registerPart(ViewContainerLocation.ChatBar, instantiationService.createInstance(ChatBarPart)); + const { width } = getClientArea(mainWindow.document.body); + const isPhoneLayout = width < 640; + + this.registerPart(ViewContainerLocation.Panel, instantiationService.createInstance(isPhoneLayout ? MobilePanelPart : PanelPart)); + this.registerPart(ViewContainerLocation.Sidebar, instantiationService.createInstance(isPhoneLayout ? MobileSidebarPart : SidebarPart)); + this.registerPart(ViewContainerLocation.AuxiliaryBar, instantiationService.createInstance(isPhoneLayout ? MobileAuxiliaryBarPart : AuxiliaryBarPart)); + this.registerPart(ViewContainerLocation.ChatBar, instantiationService.createInstance(isPhoneLayout ? MobileChatBarPart : ChatBarPart)); } private registerPart(location: ViewContainerLocation, part: IPaneCompositePart): void { diff --git a/src/vs/sessions/browser/parts/chatBarPart.ts b/src/vs/sessions/browser/parts/chatBarPart.ts index 29c7a30c249d2..273b79fce2b46 100644 --- a/src/vs/sessions/browser/parts/chatBarPart.ts +++ b/src/vs/sessions/browser/parts/chatBarPart.ts @@ -58,7 +58,7 @@ export class ChatBarPart extends AbstractPaneCompositePart { // TODO: should not private _sessionCompositeBar: ChatCompositeBar | undefined; - private _lastLayout: { readonly width: number; readonly height: number; readonly top: number; readonly left: number } | undefined; + protected _lastLayout: { readonly width: number; readonly height: number; readonly top: number; readonly left: number } | undefined; get preferredHeight(): number | undefined { return this.layoutService.mainContainerDimension.height * 0.4; diff --git a/src/vs/sessions/browser/parts/media/sidebarPart.css b/src/vs/sessions/browser/parts/media/sidebarPart.css index a30fd1d55f9dd..43e3e446665cc 100644 --- a/src/vs/sessions/browser/parts/media/sidebarPart.css +++ b/src/vs/sessions/browser/parts/media/sidebarPart.css @@ -67,3 +67,57 @@ max-width: 100%; cursor: default; } + +/* ---- Phone Layout: Sidebar Drawer Overlay ---- */ + +/* On phone, the sidebar is a drawer that slides over the chat. + It takes 85% width (max 360px) and sits on top of everything. */ +.agent-sessions-workbench.phone-layout .split-view-view:has(> .part.sidebar) { + position: absolute !important; + top: 0 !important; + left: 0 !important; + bottom: 0 !important; + width: 85% !important; + max-width: 360px !important; + height: 100% !important; + z-index: 250; + animation: sidebar-slide-in 200ms ease-out; +} + +/* The sidebar Part inside fills its container */ +.agent-sessions-workbench.phone-layout .part.sidebar { + width: 100%; + height: 100%; +} + +@keyframes sidebar-slide-in { + from { + transform: translateX(-100%); + } + to { + transform: translateX(0); + } +} + +/* Sidebar backdrop — applied via JS when sidebar is open on phone */ +.mobile-sidebar-backdrop { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 240; + animation: backdrop-fade-in 200ms ease-out; +} + +@keyframes backdrop-fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +/* Increase sidebar footer action button height for touch */ +.agent-sessions-workbench.phone-layout .part.sidebar > .sidebar-footer .sidebar-action-button { + min-height: 44px; + padding: 8px 12px; +} diff --git a/src/vs/sessions/browser/parts/media/titlebarpart.css b/src/vs/sessions/browser/parts/media/titlebarpart.css index db6da32ee33c2..b17ad7d23d265 100644 --- a/src/vs/sessions/browser/parts/media/titlebarpart.css +++ b/src/vs/sessions/browser/parts/media/titlebarpart.css @@ -130,3 +130,37 @@ .agent-sessions-workbench.mac .part.titlebar .window-controls-container { -webkit-app-region: drag; } + +/* ---- Phone Layout: Minimal Titlebar ---- */ + +/* On phone, ensure the titlebar left is visible (it holds the hamburger area) + even when sidebar is hidden. Override the nosidebar rule. */ +.agent-sessions-workbench.phone-layout.nosidebar .part.titlebar > .sessions-titlebar-container > .titlebar-left { + display: flex !important; +} + +/* But hide the toolbar content inside it — only structural element remains */ +.agent-sessions-workbench.phone-layout .part.titlebar > .sessions-titlebar-container > .titlebar-left > .left-toolbar-container { + display: none !important; +} + +/* Hide the window controls spacer on phone (no native traffic lights on mobile) */ +.agent-sessions-workbench.phone-layout .part.titlebar > .sessions-titlebar-container > .titlebar-left > .window-controls-container { + display: none !important; +} + +/* Keep the center (session title) visible and full-width on phone */ +.agent-sessions-workbench.phone-layout .part.titlebar > .sessions-titlebar-container > .titlebar-center { + flex: 1; + min-width: 0; +} + +/* On phone, hide ALL right-side action containers (session actions + layout actions) */ +.agent-sessions-workbench.phone-layout .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-actions-container { + display: none !important; +} + +/* Ensure safe area padding on top for notch */ +.agent-sessions-workbench.phone-layout .part.titlebar > .sessions-titlebar-container { + padding-top: env(safe-area-inset-top); +} diff --git a/src/vs/sessions/browser/parts/mobile/mobileAuxiliaryBarPart.ts b/src/vs/sessions/browser/parts/mobile/mobileAuxiliaryBarPart.ts new file mode 100644 index 0000000000000..fa402bc65e1f4 --- /dev/null +++ b/src/vs/sessions/browser/parts/mobile/mobileAuxiliaryBarPart.ts @@ -0,0 +1,54 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Parts } from '../../../../workbench/services/layout/browser/layoutService.js'; +import { AbstractPaneCompositePart } from '../../../../workbench/browser/parts/paneCompositePart.js'; +import { AuxiliaryBarPart } from '../auxiliaryBarPart.js'; +import { isPhoneLayout } from './mobileLayout.js'; + +/** + * Mobile variant of AuxiliaryBarPart. + * + * On phone-sized viewports the auxiliary bar fills the full grid cell + * without card margins or border insets. On tablet/desktop it falls + * back to the desktop behavior so runtime viewport transitions keep + * working. + */ +export class MobileAuxiliaryBarPart extends AuxiliaryBarPart { + + override updateStyles(): void { + // Always run the desktop implementation first so inline card styles + // are set on tablet/desktop transitions. In phone mode we then + // clear them so CSS can take over (inline styles have the highest + // specificity). + super.updateStyles(); + + if (!isPhoneLayout(this.layoutService)) { + return; + } + + const container = this.getContainer(); + if (container) { + container.style.backgroundColor = ''; + container.style.removeProperty('--part-background'); + container.style.removeProperty('--part-border-color'); + } + } + + override layout(width: number, height: number, top: number, left: number): void { + if (!isPhoneLayout(this.layoutService)) { + super.layout(width, height, top, left); + return; + } + + if (!this.layoutService.isVisible(Parts.AUXILIARYBAR_PART)) { + return; + } + + // Full dimensions — no card margins or border subtraction. + // AbstractPaneCompositePart.layout internally calls Part.layout. + AbstractPaneCompositePart.prototype.layout.call(this, width, height, top, left); + } +} diff --git a/src/vs/sessions/browser/parts/mobile/mobileChatBarPart.ts b/src/vs/sessions/browser/parts/mobile/mobileChatBarPart.ts new file mode 100644 index 0000000000000..f4f82dcd031cb --- /dev/null +++ b/src/vs/sessions/browser/parts/mobile/mobileChatBarPart.ts @@ -0,0 +1,58 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Parts } from '../../../../workbench/services/layout/browser/layoutService.js'; +import { AbstractPaneCompositePart } from '../../../../workbench/browser/parts/paneCompositePart.js'; +import { ChatBarPart } from '../chatBarPart.js'; +import { isPhoneLayout } from './mobileLayout.js'; + +/** + * Mobile variant of ChatBarPart. + * + * On phone-sized viewports the chat bar fills the full grid cell without + * card margins, border insets, or session-bar height adjustments. When + * the viewport transitions to tablet/desktop (e.g., device rotation + * crossing the phone breakpoint) this delegates to the desktop + * implementation so layout math stays correct. + */ +export class MobileChatBarPart extends ChatBarPart { + + override updateStyles(): void { + // Always run the desktop implementation first so inline styles are + // set on tablet/desktop transitions. In phone mode we then clear + // the card-specific inline styles so CSS can take over. + super.updateStyles(); + + if (!isPhoneLayout(this.layoutService)) { + return; + } + + const container = this.getContainer(); + if (container) { + container.style.backgroundColor = ''; + container.style.removeProperty('--part-background'); + container.style.removeProperty('--part-border-color'); + container.style.color = ''; + } + } + + override layout(width: number, height: number, top: number, left: number): void { + if (!isPhoneLayout(this.layoutService)) { + super.layout(width, height, top, left); + return; + } + + if (!this.layoutService.isVisible(Parts.CHATBAR_PART)) { + return; + } + + this._lastLayout = { width, height, top, left }; + + // Full dimensions — no card margins or session-bar subtraction. + // AbstractPaneCompositePart.layout internally calls Part.layout so + // there is no need to invoke Part.prototype.layout separately. + AbstractPaneCompositePart.prototype.layout.call(this, width, height, top, left); + } +} diff --git a/src/vs/sessions/browser/parts/mobile/mobileChatShell.css b/src/vs/sessions/browser/parts/mobile/mobileChatShell.css new file mode 100644 index 0000000000000..9e65c868ed321 --- /dev/null +++ b/src/vs/sessions/browser/parts/mobile/mobileChatShell.css @@ -0,0 +1,359 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* ---- Mobile Top Bar ---- */ + +.mobile-top-bar { + display: flex; + align-items: center; + height: 48px; + min-height: 48px; + padding: 0 4px; + padding-top: env(safe-area-inset-top); + background: var(--vscode-editor-background); + flex-shrink: 0; + -webkit-touch-callout: none; + user-select: none; + -webkit-user-select: none; + touch-action: manipulation; + z-index: 10; +} + +.mobile-top-bar .mobile-top-bar-button { + display: flex; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + border: none; + background: none; + color: var(--vscode-foreground); + cursor: pointer; + border-radius: 50%; + flex-shrink: 0; + touch-action: manipulation; + font-size: 18px; + padding: 0; +} + +.monaco-workbench .mobile-top-bar .mobile-top-bar-button:focus { + outline: none !important; +} + +.mobile-top-bar .mobile-top-bar-button:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} + +.mobile-top-bar .mobile-top-bar-button:active { + background: var(--vscode-toolbar-hoverBackground); +} + +.mobile-top-bar .mobile-session-title { + flex: 1; + min-width: 0; + text-align: center; + font-size: 16px; + font-weight: 500; + color: var(--vscode-foreground); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + padding: 0 4px; + cursor: pointer; +} + +.mobile-top-bar .mobile-session-title:active { + opacity: 0.7; +} + +/* ---- Phone Layout: Full-screen chat ---- */ + +/* On phone, stack the mobile top bar and grid vertically */ +.agent-sessions-workbench.phone-layout { + display: flex !important; + flex-direction: column !important; + overflow: hidden !important; +} + +/* On phone, split-view-views that directly contain a Part fill the full + grid area. Uses :has(> .part) to target only part containers — NOT + nested split-views inside parts' own content. */ +.agent-sessions-workbench.phone-layout .split-view-view:has(> .part) { + position: absolute !important; + top: 0 !important; + left: 0 !important; + width: 100% !important; + height: 100% !important; +} + +/* The grid's own branch nodes (NOT those inside parts) need full sizing. + Target only direct children of the grid root. */ +.agent-sessions-workbench.phone-layout > .monaco-grid-view > .monaco-grid-branch-node { + position: absolute !important; + top: 0 !important; + left: 0 !important; + width: 100% !important; + height: 100% !important; +} + +/* Split-view-views inside the grid root that contain branch nodes */ +.agent-sessions-workbench.phone-layout > .monaco-grid-view > .monaco-grid-branch-node > .monaco-split-view2 > .split-view-container > .split-view-view:has(> .monaco-grid-branch-node) { + position: absolute !important; + top: 0 !important; + left: 0 !important; + width: 100% !important; + height: 100% !important; +} + +/* Second-level grid branch nodes */ +.agent-sessions-workbench.phone-layout > .monaco-grid-view > .monaco-grid-branch-node > .monaco-split-view2 > .split-view-container > .split-view-view > .monaco-grid-branch-node { + position: absolute !important; + top: 0 !important; + left: 0 !important; + width: 100% !important; + height: 100% !important; +} + +/* Third-level (top-right section) */ +.agent-sessions-workbench.phone-layout > .monaco-grid-view > .monaco-grid-branch-node > .monaco-split-view2 > .split-view-container > .split-view-view > .monaco-grid-branch-node > .monaco-split-view2 > .split-view-container > .split-view-view:has(> .monaco-grid-branch-node) { + position: absolute !important; + top: 0 !important; + left: 0 !important; + width: 100% !important; + height: 100% !important; +} + +.agent-sessions-workbench.phone-layout > .monaco-grid-view > .monaco-grid-branch-node > .monaco-split-view2 > .split-view-container > .split-view-view > .monaco-grid-branch-node > .monaco-split-view2 > .split-view-container > .split-view-view > .monaco-grid-branch-node { + position: absolute !important; + top: 0 !important; + left: 0 !important; + width: 100% !important; + height: 100% !important; +} + +/* Remove card appearance from ALL parts on phone */ +.agent-sessions-workbench.phone-layout .part.chatbar, +.agent-sessions-workbench.phone-layout .part.sidebar, +.agent-sessions-workbench.phone-layout .part.auxiliarybar, +.agent-sessions-workbench.phone-layout .part.panel { + margin: 0 !important; + border: none !important; + border-radius: 0 !important; + box-shadow: none !important; + --part-border-color: transparent !important; + width: 100% !important; + height: 100% !important; +} + +/* Force content div inside parts to fill the part on phone. + Part.layoutContents() sets inline width/height via size(), which + may use the grid-allocated dimensions rather than the CSS-overridden + 100% dimensions. Override with !important. */ +.agent-sessions-workbench.phone-layout .part.chatbar > .content, +.agent-sessions-workbench.phone-layout .part.sidebar > .content, +.agent-sessions-workbench.phone-layout .part.auxiliarybar > .content, +.agent-sessions-workbench.phone-layout .part.panel > .content { + width: 100% !important; +} + +/* Hide the session composite bar (Copilot CLI / Approvals / Branch) on phone */ +.agent-sessions-workbench.phone-layout .session-composite-bar { + display: none !important; +} + +/* Ensure the grid view element doesn't overflow — flex child must shrink */ +.agent-sessions-workbench.phone-layout > .monaco-grid-view { + flex: 1 1 0% !important; + min-height: 0 !important; + overflow: hidden !important; + height: auto !important; + background-color: var(--vscode-editor-background); +} + +/* Remove max-width constraint on chat content */ +.agent-sessions-workbench.phone-layout .interactive-session .interactive-item-container { + max-width: none !important; +} + +.agent-sessions-workbench.phone-layout .interactive-session > .chat-suggest-next-widget { + max-width: none !important; +} + +.agent-sessions-workbench.phone-layout .interactive-session .interactive-input-part { + max-width: none !important; + padding-bottom: calc(10px + env(safe-area-inset-bottom)) !important; +} + +/* Chat input minimum font size to prevent iOS auto-zoom */ +.agent-sessions-workbench.phone-layout .interactive-session .chat-input-container textarea, +.agent-sessions-workbench.phone-layout .interactive-session .chat-input-container input { + font-size: 16px !important; +} + +/* Hide the desktop titlebar on phone — replaced by mobile top bar */ +.agent-sessions-workbench.phone-layout .part.titlebar { + display: none !important; +} + +/* Sidebar content and customization toolbar should stack and scroll */ +.agent-sessions-workbench.phone-layout .part.sidebar { + display: flex !important; + flex-direction: column !important; + overflow: hidden !important; +} + +.agent-sessions-workbench.phone-layout .part.sidebar > .composite.title { + display: none !important; +} + +.agent-sessions-workbench.phone-layout .part.sidebar > .content { + top: 0 !important; + flex: 1 !important; + min-height: 0 !important; + overflow-y: auto !important; + -webkit-overflow-scrolling: touch; +} + +/* Customization toolbar: hidden on phone (opens editors, not mobile-compatible) */ +.agent-sessions-workbench.phone-layout .part.sidebar .ai-customization-toolbar { + display: none !important; +} + +/* Make sidebar footer touch-friendly */ +.agent-sessions-workbench.phone-layout .part.sidebar > .sidebar-footer .sidebar-action-button { + min-height: 44px; + padding: 8px 12px; +} + +/* Hide the "+ Session" button in the sidebar on phone — replaced by top bar + button */ +.agent-sessions-workbench.phone-layout .agent-sessions-new-button-container { + display: none !important; +} + +/* Hide sashes on phone */ +.agent-sessions-workbench.phone-layout .monaco-sash { + display: none !important; + pointer-events: none !important; +} + +/* Overscroll containment */ +.agent-sessions-workbench.phone-layout .interactive-session { + overscroll-behavior: contain; +} + +.agent-sessions-workbench.phone-layout .monaco-list { + overscroll-behavior: contain; +} + +/* On phone, push the chat input to the bottom of the chat area */ +.agent-sessions-workbench.phone-layout .interactive-session .interactive-input-and-execute-toolbar { + margin-top: auto !important; +} + +/* ---- Phone Layout: Chat Welcome Page ---- */ + +/* Make the welcome page a flex column that fills the chat area */ +.agent-sessions-workbench.phone-layout .new-chat-widget-container { + display: flex !important; + flex-direction: column !important; + height: 100% !important; + padding: 8px 8px 0 8px !important; +} + +.agent-sessions-workbench.phone-layout .new-chat-widget-content { + display: flex !important; + flex-direction: column !important; + flex: 1 !important; + min-height: 0 !important; + max-width: 100% !important; + padding-bottom: 20px !important; +} + +/* Workspace picker centered vertically with icon above */ +.agent-sessions-workbench.phone-layout .new-session-workspace-picker-container { + flex: 1 !important; + display: flex !important; + flex-direction: column !important; + align-items: center !important; + justify-content: center !important; + max-width: 100% !important; +} + +/* Show the sessions logo above the workspace picker — same asset as the auth page */ +.agent-sessions-workbench.phone-layout .new-session-workspace-picker-container::before { + content: ''; + display: block; + width: 64px; + height: 64px; + margin-bottom: 16px; + background-image: url('../../media/sessions-logo-light.svg'); + background-size: contain; + background-repeat: no-repeat; + background-position: center; +} + +.vs .agent-sessions-workbench.phone-layout .new-session-workspace-picker-container::before, +.hc-light .agent-sessions-workbench.phone-layout .new-session-workspace-picker-container::before { + background-image: url('../../media/sessions-logo-dark.svg'); +} + +/* Center the picker text */ +.agent-sessions-workbench.phone-layout .session-workspace-picker { + display: flex !important; + flex-direction: column !important; + align-items: center !important; + gap: 8px !important; + font-size: 16px !important; +} + +.agent-sessions-workbench.phone-layout .session-workspace-picker-label { + font-size: 18px !important; + opacity: 0.6; +} + +/* Input slot pinned to the bottom */ +.agent-sessions-workbench.phone-layout .new-chat-input-container { + flex-shrink: 0 !important; + padding: 0 0 8px 0 !important; + max-width: 100% !important; +} + +/* Make the chat input full-width and edge-to-edge styled */ +.agent-sessions-workbench.phone-layout .sessions-chat-input-area { + border-radius: 16px !important; + max-width: 100% !important; +} + +/* Hide the local mode bar (Copilot CLI / Default Approvals / Branch) on phone */ +.agent-sessions-workbench.phone-layout .new-chat-bottom-container { + display: none !important; +} + +/* Also hide the sessions-chat-widget's DnD overlay on phone */ +.agent-sessions-workbench.phone-layout .sessions-chat-dnd-overlay { + display: none !important; +} + +/* Chat widget fills full width on phone */ +.agent-sessions-workbench.phone-layout .sessions-chat-widget { + width: 100% !important; +} + +/* allow-any-unicode-next-line */ +/* Compact chat toolbar on phone */ +.agent-sessions-workbench.phone-layout .sessions-chat-toolbar { + padding: 0 6px 0 6px !important; + max-height: 32px !important; + gap: 4px !important; +} + +/* Prevent card transitions from flashing on phone */ +.agent-sessions-workbench.phone-layout .part.chatbar, +.agent-sessions-workbench.phone-layout .part.sidebar, +.agent-sessions-workbench.phone-layout .part.auxiliarybar, +.agent-sessions-workbench.phone-layout .part.panel { + transition: none !important; +} diff --git a/src/vs/sessions/browser/parts/mobile/mobileLayout.ts b/src/vs/sessions/browser/parts/mobile/mobileLayout.ts new file mode 100644 index 0000000000000..3d7ca98d99a25 --- /dev/null +++ b/src/vs/sessions/browser/parts/mobile/mobileLayout.ts @@ -0,0 +1,26 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IWorkbenchLayoutService } from '../../../../workbench/services/layout/browser/layoutService.js'; + +/** + * CSS class applied to the sessions workbench main container when the + * viewport is classified as phone. Must stay in sync with + * `LayoutClasses.PHONE_LAYOUT` in `workbench.ts`. + */ +const PHONE_LAYOUT_CLASS = 'phone-layout'; + +/** + * Returns true when the sessions workbench currently has the phone + * layout class on its main container. + * + * Mobile Part subclasses are chosen once at construction time, but the + * viewport class can change at runtime (e.g., device rotation crossing + * the phone breakpoint). Parts use this to decide whether to apply + * mobile-specific layout math or defer to the desktop implementation. + */ +export function isPhoneLayout(layoutService: IWorkbenchLayoutService): boolean { + return layoutService.mainContainer.classList.contains(PHONE_LAYOUT_CLASS); +} diff --git a/src/vs/sessions/browser/parts/mobile/mobilePanelPart.ts b/src/vs/sessions/browser/parts/mobile/mobilePanelPart.ts new file mode 100644 index 0000000000000..2891360e96729 --- /dev/null +++ b/src/vs/sessions/browser/parts/mobile/mobilePanelPart.ts @@ -0,0 +1,50 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Parts } from '../../../../workbench/services/layout/browser/layoutService.js'; +import { AbstractPaneCompositePart } from '../../../../workbench/browser/parts/paneCompositePart.js'; +import { PanelPart } from '../panelPart.js'; +import { isPhoneLayout } from './mobileLayout.js'; + +/** + * Mobile variant of PanelPart. + * + * On phone-sized viewports the panel fills the full grid cell + * without card margins or border insets. On tablet/desktop it falls + * back to the desktop behavior so runtime viewport transitions keep + * working. + */ +export class MobilePanelPart extends PanelPart { + + override updateStyles(): void { + super.updateStyles(); + + if (!isPhoneLayout(this.layoutService)) { + return; + } + + const container = this.getContainer(); + if (container) { + container.style.backgroundColor = ''; + container.style.removeProperty('--part-background'); + container.style.removeProperty('--part-border-color'); + } + } + + override layout(width: number, height: number, top: number, left: number): void { + if (!isPhoneLayout(this.layoutService)) { + super.layout(width, height, top, left); + return; + } + + if (!this.layoutService.isVisible(Parts.PANEL_PART)) { + return; + } + + // Full dimensions — no card margins or border subtraction. + // AbstractPaneCompositePart.layout internally calls Part.layout. + AbstractPaneCompositePart.prototype.layout.call(this, width, height, top, left); + } +} diff --git a/src/vs/sessions/browser/parts/mobile/mobileSidebarPart.ts b/src/vs/sessions/browser/parts/mobile/mobileSidebarPart.ts new file mode 100644 index 0000000000000..f062ddfd2f4f8 --- /dev/null +++ b/src/vs/sessions/browser/parts/mobile/mobileSidebarPart.ts @@ -0,0 +1,37 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AbstractPaneCompositePart } from '../../../../workbench/browser/parts/paneCompositePart.js'; +import { SidebarPart } from '../sidebarPart.js'; +import { isPhoneLayout } from './mobileLayout.js'; + +/** + * Mobile variant of SidebarPart. + * + * On phone-sized viewports the sidebar skips card-specific inline styles + * so that CSS-only theming takes over. On tablet/desktop it falls back + * to the desktop behavior so runtime viewport transitions keep working. + */ +export class MobileSidebarPart extends SidebarPart { + + override updateStyles(): void { + // Run base theme wiring; this also cascades to AbstractPaneCompositePart. + super.updateStyles(); + + if (!isPhoneLayout(this.layoutService)) { + return; + } + + // Skip SidebarPart's card / title-area inline styles on phone. + AbstractPaneCompositePart.prototype.updateStyles.call(this); + + const container = this.getContainer(); + if (container) { + container.style.backgroundColor = ''; + container.style.color = ''; + container.style.outlineColor = ''; + } + } +} diff --git a/src/vs/sessions/browser/parts/mobile/mobileTopBar.ts b/src/vs/sessions/browser/parts/mobile/mobileTopBar.ts new file mode 100644 index 0000000000000..26a1aed9642d9 --- /dev/null +++ b/src/vs/sessions/browser/parts/mobile/mobileTopBar.ts @@ -0,0 +1,71 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './mobileChatShell.css'; +import { Disposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { $, addDisposableListener, append, EventType } from '../../../../base/browser/dom.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { localize } from '../../../../nls.js'; + +/** + * Mobile top bar component — a simple DOM element prepended to the + * workbench container on phone viewports. Replaces the desktop titlebar + * with a native-feeling mobile app bar. + * + * Layout: [hamburger] [session title] [+ new] + */ +export class MobileTopBar extends Disposable { + + readonly element: HTMLElement; + + private readonly sessionTitleElement: HTMLElement; + + private readonly _onDidClickHamburger = this._register(new Emitter()); + readonly onDidClickHamburger: Event = this._onDidClickHamburger.event; + + private readonly _onDidClickNewSession = this._register(new Emitter()); + readonly onDidClickNewSession: Event = this._onDidClickNewSession.event; + + private readonly _onDidClickTitle = this._register(new Emitter()); + readonly onDidClickTitle: Event = this._onDidClickTitle.event; + + constructor(parent: HTMLElement) { + super(); + + this.element = document.createElement('div'); + this.element.className = 'mobile-top-bar'; + + // Register DOM removal before appending so that any exception + // between this point and the end of the constructor still cleans + // up the element via disposal. + this._register(toDisposable(() => this.element.remove())); + parent.prepend(this.element); + + // Hamburger button + const hamburger = append(this.element, $('button.mobile-top-bar-button')); + hamburger.setAttribute('aria-label', 'Open sessions'); + const hamburgerIcon = append(hamburger, $('span')); + hamburgerIcon.classList.add(...ThemeIcon.asClassNameArray(Codicon.menu)); + this._register(addDisposableListener(hamburger, EventType.CLICK, () => this._onDidClickHamburger.fire())); + + // Session title + this.sessionTitleElement = append(this.element, $('div.mobile-session-title')); + this.sessionTitleElement.textContent = localize('mobileTopBar.newSession', "New Session"); + this._register(addDisposableListener(this.sessionTitleElement, EventType.CLICK, () => this._onDidClickTitle.fire())); + + // New session button (+) + const newSession = append(this.element, $('button.mobile-top-bar-button')); + newSession.setAttribute('aria-label', 'New session'); + const newSessionIcon = append(newSession, $('span')); + newSessionIcon.classList.add(...ThemeIcon.asClassNameArray(Codicon.plus)); + this._register(addDisposableListener(newSession, EventType.CLICK, () => this._onDidClickNewSession.fire())); + } + + setTitle(title: string): void { + this.sessionTitleElement.textContent = title; + } +} diff --git a/src/vs/sessions/browser/workbench.ts b/src/vs/sessions/browser/workbench.ts index e012ecf4941cc..3789111d77c67 100644 --- a/src/vs/sessions/browser/workbench.ts +++ b/src/vs/sessions/browser/workbench.ts @@ -7,12 +7,12 @@ import '../../workbench/browser/style.js'; import './media/style.css'; import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../base/common/lifecycle.js'; import { Emitter, Event, setGlobalLeakWarningThreshold } from '../../base/common/event.js'; -import { getActiveDocument, getActiveElement, getClientArea, getWindowId, getWindows, IDimension, isAncestorUsingFlowTo, isHTMLElement, size, Dimension, runWhenWindowIdle } from '../../base/browser/dom.js'; +import { getActiveDocument, getActiveElement, getClientArea, getWindowId, getWindows, IDimension, isAncestorUsingFlowTo, isHTMLElement, size, Dimension, runWhenWindowIdle, addDisposableListener, EventType } from '../../base/browser/dom.js'; import { DeferredPromise, RunOnceScheduler } from '../../base/common/async.js'; import { isFullscreen, onDidChangeFullscreen, isChrome, isFirefox, isSafari } from '../../base/browser/browser.js'; import { mark } from '../../base/common/performance.js'; import { onUnexpectedError, setUnexpectedErrorHandler } from '../../base/common/errors.js'; -import { isWindows, isLinux, isWeb, isNative, isMacintosh, isMobile } from '../../base/common/platform.js'; +import { isWindows, isLinux, isWeb, isNative, isMacintosh } from '../../base/common/platform.js'; import { Parts, Position, PanelAlignment, IWorkbenchLayoutService, SINGLE_WINDOW_PARTS, MULTI_WINDOW_PARTS, IPartVisibilityChangeEvent, positionToString } from '../../workbench/services/layout/browser/layoutService.js'; import { ILayoutOffsetInfo } from '../../platform/layout/browser/layoutService.js'; import { Part } from '../../workbench/browser/part.js'; @@ -63,12 +63,17 @@ import { SyncDescriptor } from '../../platform/instantiation/common/descriptors. import { TitleService } from './parts/titlebarPart.js'; import { SessionsExperimentalShellGradientBackgroundSettingId } from '../common/configuration.js'; import { IContextKeyService } from '../../platform/contextkey/common/contextkey.js'; -import { EditorMaximizedContext } from '../common/contextkeys.js'; +import { EditorMaximizedContext, IsPhoneLayoutContext, KeyboardVisibleContext } from '../common/contextkeys.js'; import { NotificationsPosition, NotificationsSettings, getNotificationsPosition } from '../../workbench/common/notifications.js'; +import { SessionsLayoutPolicy } from './layoutPolicy.js'; +import { MobileNavigationStack } from './mobileNavigationStack.js'; +import { MobileTopBar } from './parts/mobile/mobileTopBar.js'; +import { autorun } from '../../base/common/observable.js'; +import { ISessionsManagementService } from '../services/sessions/common/sessionsManagement.js'; //#region Workbench Options @@ -92,7 +97,8 @@ enum LayoutClasses { STATUSBAR_HIDDEN = 'nostatusbar', EXPERIMENTAL_SHELL_GRADIENT_BACKGROUND = 'experimental-shell-gradient-background', FULLSCREEN = 'fullscreen', - MAXIMIZED = 'maximized' + MAXIMIZED = 'maximized', + PHONE_LAYOUT = 'phone-layout' } //#endregion @@ -233,6 +239,10 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic if (this.isVisible(Parts.TITLEBAR_PART, mainWindow)) { top = this.getPart(Parts.TITLEBAR_PART).maximumHeight; quickPickTop = top; + } else if (this.mobileTopBarElement) { + // On phone layout the MobileTopBar replaces the titlebar + top = this.mobileTopBarElement.offsetHeight; + quickPickTop = top; } return { top, quickPickTop }; @@ -263,6 +273,10 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic private mainWindowFullscreen = false; private readonly maximized = new Set(); + private readonly layoutPolicy = this._register(new SessionsLayoutPolicy()); + private readonly mobileNavStack = this._register(new MobileNavigationStack()); + private mobileTopBarElement: HTMLElement | undefined; + private readonly mobileTopBarDisposables = this._register(new DisposableStore()); private _editorMaximized = false; private _editorLastNonMaximizedVisibility: IPartVisibilityState | undefined; @@ -281,6 +295,7 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic private editorService!: IEditorService; private paneCompositeService!: IPaneCompositePartService; private viewDescriptorService!: IViewDescriptorService; + private sessionsManagementService!: ISessionsManagementService; //#endregion @@ -292,6 +307,26 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic ) { super(); + // Sessions-scoped mobile viewport tweaks. These are applied here + // (rather than in the shared workbench.html) so that the regular + // code-web workbench — which does not handle safe-area insets — is + // not affected on notched mobile devices. + // The viewport `` tag is injected by the shared workbench.html, + // so we cannot use dom.ts `h()` to create it. Look it up by tag name + // and filter by the `name` attribute to avoid a selector query. + // eslint-disable-next-line no-restricted-syntax + const metaElements = mainWindow.document.head.getElementsByTagName('meta'); + let viewportMeta: HTMLMetaElement | undefined; + for (let i = 0; i < metaElements.length; i++) { + if (metaElements[i].name === 'viewport') { + viewportMeta = metaElements[i]; + break; + } + } + if (viewportMeta && !viewportMeta.content.includes('viewport-fit=')) { + viewportMeta.content = `${viewportMeta.content}, viewport-fit=cover`; + } + // Perf: measure workbench startup time mark('code/willStartWorkbench'); @@ -391,6 +426,41 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic editorMaximizedContext.set(this.isEditorMaximized()); })); + // Phone Layout Context Key + const contextKeyService = accessor.get(IContextKeyService); + const isPhoneLayoutCtx = IsPhoneLayoutContext.bindTo(contextKeyService); + this._register(autorun(reader => { + isPhoneLayoutCtx.set(this.layoutPolicy.viewportClass.read(reader) === 'phone'); + })); + + // Virtual keyboard detection via visualViewport API. + // Use `window.innerHeight` (layout viewport) as the baseline + // rather than a captured initial height. Layout viewport + // updates on orientation change and split-screen resizes, so + // comparing against it avoids stale baselines on landscape + // launches, Android split-screen, and iOS URL-bar collapse. + if (mainWindow.visualViewport) { + const keyboardVisibleCtx = KeyboardVisibleContext.bindTo(contextKeyService); + const KEYBOARD_HEIGHT_THRESHOLD_PX = 100; + + const onViewportResize = () => { + const vp = mainWindow.visualViewport; + if (!vp) { + return; + } + const heightDiff = mainWindow.innerHeight - vp.height; + keyboardVisibleCtx.set(heightDiff > KEYBOARD_HEIGHT_THRESHOLD_PX); + }; + + mainWindow.visualViewport.addEventListener('resize', onViewportResize); + this._register({ dispose: () => mainWindow.visualViewport?.removeEventListener('resize', onViewportResize) }); + } + + // Orientation changes produce a window `resize` event which + // is already handled by `registerLayoutListeners()`. No + // separate matchMedia listener is needed — the previous + // implementation caused a redundant second layout. + // Register Listeners this.registerListeners(lifecycleService, storageService, configurationService, hostService, dialogService); @@ -400,6 +470,11 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic // Workbench Layout this.createWorkbenchLayout(); + // Create mobile navigation after grid exists (so DOM order is correct) + if (this.layoutPolicy.viewportClass.get() === 'phone') { + this.createMobileTopBar(); + } + // Workbench Management this.createWorkbenchManagement(instantiationService); @@ -547,6 +622,18 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic setARIAContainer(this.mainContainer); setProgressAccessibilitySignalScheduler((msDelayTime: number, msLoopTime?: number) => instantiationService.createInstance(AccessibilityProgressSignalScheduler, msDelayTime, msLoopTime)); + // Initialize viewport classification before building layout classes + const initialDimension = getClientArea(this.parent); + this.layoutPolicy.update(initialDimension.width, initialDimension.height); + + // Apply initial part visibility from layout policy (phone hides sidebar, etc.) + const visibilityDefaults = this.layoutPolicy.getPartVisibilityDefaults(); + this.partVisibility.sidebar = visibilityDefaults.sidebar; + this.partVisibility.auxiliaryBar = visibilityDefaults.auxiliaryBar; + this.partVisibility.panel = visibilityDefaults.panel; + this.partVisibility.chatBar = visibilityDefaults.chatBar; + this.partVisibility.editor = visibilityDefaults.editor; + // State specific classes const platformClass = isWindows ? 'windows' : isLinux ? 'linux' : 'mac'; const workbenchClasses = coalesce([ @@ -593,6 +680,76 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic this.parent.appendChild(this.mainContainer); } + private createMobileTopBar(): void { + this.mobileTopBarDisposables.clear(); + const mobileTopBar = this.mobileTopBarDisposables.add(new MobileTopBar(this.mainContainer)); + this.mobileTopBarElement = mobileTopBar.element; + + // Hamburger: toggle sidebar drawer overlay + this.mobileTopBarDisposables.add(mobileTopBar.onDidClickHamburger(() => { + this.toggleMobileSidebarDrawer(); + })); + + // New session: open new chat view + this.mobileTopBarDisposables.add(mobileTopBar.onDidClickNewSession(() => { + this.sessionsManagementService.openNewSessionView(); + })); + } + + private sidebarDrawerBackdrop: HTMLElement | undefined; + private readonly sidebarDrawerBackdropDisposables = this._register(new DisposableStore()); + + private toggleMobileSidebarDrawer(): void { + const isOpen = this.partVisibility.sidebar; + if (isOpen) { + this.closeMobileSidebarDrawer(); + } else { + this.openMobileSidebarDrawer(); + } + } + + private openMobileSidebarDrawer(): void { + // Show backdrop — created fresh each open so its click listener is + // tracked by a DisposableStore and cleaned up on close. + if (!this.sidebarDrawerBackdrop) { + const backdrop = document.createElement('div'); + backdrop.className = 'mobile-sidebar-backdrop'; + this.sidebarDrawerBackdropDisposables.add(addDisposableListener(backdrop, EventType.CLICK, () => this.closeMobileSidebarDrawer())); + this.sidebarDrawerBackdropDisposables.add(toDisposable(() => backdrop.remove())); + this.sidebarDrawerBackdrop = backdrop; + } + this.mainContainer.appendChild(this.sidebarDrawerBackdrop); + + // Push a history entry so the Android back button dismisses the drawer. + // Must come before setSideBarHidden(false) so layoutMobileSidebar() sees + // the drawer state. + if (!this.mobileNavStack.has('sidebar')) { + this.mobileNavStack.push('sidebar'); + } + + // Show sidebar in grid — the actual drawer dimensions are applied by + // layoutMobileSidebar() from within layout(), which respects the + // "drawer" shape on phone (85% width, below the mobile top bar). + this.setSideBarHidden(false); + } + + private closeMobileSidebarDrawer(): void { + // Remove backdrop and dispose its listener. + this.sidebarDrawerBackdropDisposables.clear(); + this.sidebarDrawerBackdrop = undefined; + + // Hide sidebar in grid + this.setSideBarHidden(true); + + // Sync the navigation stack with the browser history: if there is a + // pending 'sidebar' entry (UI-initiated close), rewind history without + // firing onDidPop. If we're being called from the back-button path + // (onDidPop already fired), this is a no-op. + if (this.mobileNavStack.has('sidebar')) { + this.mobileNavStack.popSilently('sidebar'); + } + } + private createNotificationsHandlers( instantiationService: IInstantiationService, notificationService: NotificationService, @@ -742,6 +899,7 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic this.editorService = accessor.get(IEditorService); this.paneCompositeService = accessor.get(IPaneCompositePartService); this.viewDescriptorService = accessor.get(IViewDescriptorService); + this.sessionsManagementService = accessor.get(ISessionsManagementService); accessor.get(ITitleService); // Register layout listeners @@ -770,17 +928,15 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic // Initialize layout state (must be done before createWorkbenchLayout) this._mainContainerDimension = getClientArea(this.parent, new Dimension(800, 600)); + this.layoutPolicy.update(this._mainContainerDimension.width, this._mainContainerDimension.height); - // Default to list-detail on mobile web only. Desktop behavior stays unchanged, - // regardless of how narrow the window is resized. - if (isWeb && isMobile) { - this.partVisibility.sidebar = false; - this.partVisibility.auxiliaryBar = false; - } - } - - private isMobileWebLayout(): boolean { - return isWeb && isMobile; + // Update part visibility based on final viewport classification + const visDefaults = this.layoutPolicy.getPartVisibilityDefaults(); + this.partVisibility.sidebar = visDefaults.sidebar; + this.partVisibility.auxiliaryBar = visDefaults.auxiliaryBar; + this.partVisibility.panel = visDefaults.panel; + this.partVisibility.chatBar = visDefaults.chatBar; + this.partVisibility.editor = visDefaults.editor; } private areAllGroupsEmpty(): boolean { @@ -801,6 +957,11 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic this.layout(); } })); + + // Window resize — needed for device emulation and mobile viewport changes + const onWindowResize = () => this.layout(); + mainWindow.addEventListener('resize', onWindowResize); + this._register({ dispose: () => mainWindow.removeEventListener('resize', onWindowResize) }); } private updateFullscreenClass(): void { @@ -871,6 +1032,24 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic this.handleContainerDidLayout(this.mainContainer, this._mainContainerDimension); })); } + + // Wire up mobile nav stack: back-button pops close the corresponding part + this._register(this.mobileNavStack.onDidPop(layer => { + switch (layer) { + case 'sidebar': + this.closeMobileSidebarDrawer(); + break; + case 'panel': + this.setPanelHidden(true); + break; + case 'auxbar': + this.setAuxiliaryBarHidden(true); + break; + case 'editor': + // Editor modal close is handled by the editor service + break; + } + })); } createWorkbenchManagement(_instantiationService: IInstantiationService): void { @@ -890,25 +1069,43 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic private createGridDescriptor(): ISerializedGrid { const { width, height } = this._mainContainerDimension; - // Default sizes - const sideBarSize = 300; + return this.createDesktopGridDescriptor(width, height); + } + + /** + * Standard multi-part layout for all viewport classes. + * On phone, the titlebar is hidden via CSS and a MobileTopBar + * is prepended before the grid. Sidebar/panel/auxbar are hidden + * in the grid via partVisibility defaults. + */ + private createDesktopGridDescriptor(width: number, height: number): ISerializedGrid { + + // Default sizes from layout policy + const sizes = this.layoutPolicy.getPartSizes(width, height); + // For hidden parts, still provide a reasonable cached size for when they're shown later + const sideBarSize = this.partVisibility.sidebar ? sizes.sideBarSize : Math.max(sizes.sideBarSize, 250); + const auxiliaryBarSize = this.partVisibility.auxiliaryBar ? sizes.auxiliaryBarSize : Math.max(sizes.auxiliaryBarSize, 300); + const panelSize = this.partVisibility.panel ? sizes.panelSize : Math.max(sizes.panelSize, 250); const editorSize = 600; - const auxiliaryBarSize = 340; - const panelSize = 300; const titleBarHeight = this.titleBarPartView?.minimumHeight ?? 30; - // Calculate right section width and chat bar width - const rightSectionWidth = Math.max(0, width - sideBarSize); - const chatBarWidth = Math.max(0, rightSectionWidth - auxiliaryBarSize - editorSize); + // Calculate right section width — when sidebar is hidden it takes no space + const effectiveSideBarWidth = this.partVisibility.sidebar ? sideBarSize : 0; + const rightSectionWidth = Math.max(0, width - effectiveSideBarWidth); + const effectiveAuxBarWidth = this.partVisibility.auxiliaryBar ? auxiliaryBarSize : 0; + const effectiveEditorWidth = this.partVisibility.editor ? editorSize : 0; + const chatBarWidth = Math.max(0, rightSectionWidth - effectiveAuxBarWidth - effectiveEditorWidth); const contentHeight = Math.max(0, height - titleBarHeight); const topRightHeight = Math.max(0, contentHeight - panelSize); + const isPhone = this.layoutPolicy.viewportClass.get() === 'phone'; + const titleBarNode: ISerializedLeafNode = { type: 'leaf', data: { type: Parts.TITLEBAR_PART }, size: titleBarHeight, - visible: true + visible: !isPhone }; const sideBarNode: ISerializedLeafNode = { @@ -988,16 +1185,78 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic //#region Layout Methods + private _previousViewportClass: string | undefined; + layout(): void { this._mainContainerDimension = getClientArea( this.mainWindowFullscreen ? mainWindow.document.body : this.parent ); + + // Update viewport classification and toggle mobile CSS classes + const previousClass = this._previousViewportClass; + this.layoutPolicy.update(this._mainContainerDimension.width, this._mainContainerDimension.height); + const currentClass = this.layoutPolicy.viewportClass.get(); + this.mainContainer.classList.toggle(LayoutClasses.PHONE_LAYOUT, currentClass === 'phone'); + + // When viewport class changes at runtime (e.g., device emulation toggle), + // update part visibility and create/destroy mobile components + if (previousClass !== undefined && previousClass !== currentClass) { + if (currentClass === 'phone' && !this.mobileTopBarElement) { + this.createMobileTopBar(); + // Hide titlebar in grid on phone (replaced by MobileTopBar) + this.workbenchGrid.setViewVisible(this.titleBarPartView, false); + // On phone, only chat is visible — hide everything else first + const defaults = this.layoutPolicy.getPartVisibilityDefaults(); + if (this.partVisibility.sidebar !== defaults.sidebar) { + this.setSideBarHidden(!defaults.sidebar); + } + if (this.partVisibility.auxiliaryBar !== defaults.auxiliaryBar) { + this.setAuxiliaryBarHidden(!defaults.auxiliaryBar); + } + if (this.partVisibility.panel !== defaults.panel) { + this.setPanelHidden(!defaults.panel); + } + } else if (currentClass !== 'phone' && this.mobileTopBarElement) { + // Remove mobile components when leaving phone layout + this.mobileTopBarDisposables.clear(); + this.mobileTopBarElement = undefined; + // Restore titlebar in grid + this.workbenchGrid.setViewVisible(this.titleBarPartView, true); + // Restore desktop part visibility + const defaults = this.layoutPolicy.getPartVisibilityDefaults(); + if (this.partVisibility.sidebar !== defaults.sidebar) { + this.setSideBarHidden(!defaults.sidebar); + } + if (this.partVisibility.chatBar !== defaults.chatBar) { + this.setChatBarHidden(!defaults.chatBar); + } + if (this.partVisibility.auxiliaryBar !== defaults.auxiliaryBar) { + this.setAuxiliaryBarHidden(!defaults.auxiliaryBar); + } + if (this.partVisibility.panel !== defaults.panel) { + this.setPanelHidden(!defaults.panel); + } + } + + // Re-run updateStyles() on pane composite parts so that + // mobile Part subclasses can re-apply or clear card-chrome + // inline styles based on the new `.phone-layout` class. + for (const partId of [Parts.CHATBAR_PART, Parts.SIDEBAR_PART, Parts.AUXILIARYBAR_PART, Parts.PANEL_PART]) { + this.parts.get(partId)?.updateStyles(); + } + } + this._previousViewportClass = currentClass; + this.logService.trace(`Workbench#layout, height: ${this._mainContainerDimension.height}, width: ${this._mainContainerDimension.width}`); size(this.mainContainer, this._mainContainerDimension.width, this._mainContainerDimension.height); + // On phone, subtract the mobile top bar height from the grid + const mobileTopBarHeight = this.mobileTopBarElement?.offsetHeight ?? 0; + const gridHeight = this._mainContainerDimension.height - mobileTopBarHeight; + // Layout the grid widget - this.workbenchGrid.layout(this._mainContainerDimension.width, this._mainContainerDimension.height); + this.workbenchGrid.layout(this._mainContainerDimension.width, gridHeight); this.layoutMobileSidebar(); // Emit as event @@ -1011,7 +1270,10 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic return; } - if (!this.isMobileWebLayout() || !this.partVisibility.sidebar) { + // Only phone uses the overlay drawer shape. Tablet/desktop let the + // grid position the sidebar normally, so clear any inline styles. + const isPhone = this.layoutPolicy.viewportClass.get() === 'phone'; + if (!isPhone || !this.partVisibility.sidebar) { sidebarContainer.classList.remove('mobile-overlay-sidebar'); sidebarContainer.style.position = ''; sidebarContainer.style.top = ''; @@ -1022,17 +1284,19 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic return; } - const titleBarHeight = this.workbenchGrid.getViewSize(this.titleBarPartView).height; - const mobileWidth = this._mainContainerDimension.width; - const mobileHeight = Math.max(0, this._mainContainerDimension.height - titleBarHeight); + // Phone drawer: 85% width (capped at 360px), positioned below the + // mobile top bar (the grid titlebar is hidden on phone). + const topBarHeight = this.mobileTopBarElement?.offsetHeight ?? 48; + const drawerWidth = Math.min(Math.floor(this._mainContainerDimension.width * 0.85), 360); + const drawerHeight = Math.max(0, this._mainContainerDimension.height - topBarHeight); sidebarContainer.classList.add('mobile-overlay-sidebar'); sidebarContainer.style.position = 'fixed'; - sidebarContainer.style.top = `${titleBarHeight}px`; + sidebarContainer.style.top = `${topBarHeight}px`; sidebarContainer.style.left = '0'; - sidebarContainer.style.width = `${mobileWidth}px`; - sidebarContainer.style.height = `${mobileHeight}px`; + sidebarContainer.style.width = `${drawerWidth}px`; + sidebarContainer.style.height = `${drawerHeight}px`; sidebarContainer.style.zIndex = '30'; - sidebarPart.layout(mobileWidth, mobileHeight, titleBarHeight, 0); + sidebarPart.layout(drawerWidth, drawerHeight, topBarHeight, 0); } private handleContainerDidLayout(container: HTMLElement, dimension: IDimension): void { @@ -1053,7 +1317,8 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic !this.partVisibility.auxiliaryBar ? LayoutClasses.AUXILIARYBAR_HIDDEN : undefined, !this.partVisibility.chatBar ? LayoutClasses.CHATBAR_HIDDEN : undefined, LayoutClasses.STATUSBAR_HIDDEN, // agents window never has a status bar - this.mainWindowFullscreen ? LayoutClasses.FULLSCREEN : undefined + this.mainWindowFullscreen ? LayoutClasses.FULLSCREEN : undefined, + this.layoutPolicy.viewportClass.get() === 'phone' ? LayoutClasses.PHONE_LAYOUT : undefined, ]); } @@ -1163,7 +1428,8 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic isVisible(part: Parts, targetWindow?: Window): boolean { switch (part) { case Parts.TITLEBAR_PART: - return true; // Always visible + // On phone layout the grid titlebar is hidden (replaced by MobileTopBar) + return this.layoutPolicy.viewportClass.get() !== 'phone'; case Parts.SIDEBAR_PART: return this.partVisibility.sidebar; case Parts.AUXILIARYBAR_PART: diff --git a/src/vs/sessions/common/contextkeys.ts b/src/vs/sessions/common/contextkeys.ts index 385069bd358d7..a42408dbc50da 100644 --- a/src/vs/sessions/common/contextkeys.ts +++ b/src/vs/sessions/common/contextkeys.ts @@ -38,3 +38,10 @@ export const SessionsWelcomeVisibleContext = new RawContextKey('session export const EditorMaximizedContext = new RawContextKey('editorMaximized', false, localize('editorMaximized', "Whether the editor area is maximized")); //#endregion + +//#region < --- Mobile Layout --- > + +export const IsPhoneLayoutContext = new RawContextKey('sessionsIsPhoneLayout', false, localize('sessionsIsPhoneLayout', "Whether the current layout is the phone layout")); +export const KeyboardVisibleContext = new RawContextKey('sessionsKeyboardVisible', false, localize('sessionsKeyboardVisible', "Whether the virtual keyboard is visible")); + +//#endregion diff --git a/src/vs/sessions/contrib/changes/browser/changes.contribution.ts b/src/vs/sessions/contrib/changes/browser/changes.contribution.ts index dfbd37604e9d0..2b4c7516dc002 100644 --- a/src/vs/sessions/contrib/changes/browser/changes.contribution.ts +++ b/src/vs/sessions/contrib/changes/browser/changes.contribution.ts @@ -13,6 +13,7 @@ import { IViewContainersRegistry, ViewContainerLocation, IViewsRegistry, Extensi import { CHANGES_VIEW_CONTAINER_ID, CHANGES_VIEW_ID } from '../common/changes.js'; import { ChangesViewPane, ChangesViewPaneContainer } from './changesView.js'; import { ChangesTitleBarContribution } from './changesTitleBarWidget.js'; +import { IsPhoneLayoutContext } from '../../../common/contextkeys.js'; import './changesViewActions.js'; import './checksActions.js'; import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; @@ -54,6 +55,7 @@ viewsRegistry.registerViews([{ canMoveView: false, weight: 100, order: 1, + when: IsPhoneLayoutContext.negate(), windowEnablement: WindowEnablement.Sessions, }], changesViewContainer); diff --git a/src/vs/sessions/contrib/chat/browser/openInVSCode.contribution.ts b/src/vs/sessions/contrib/chat/browser/openInVSCode.contribution.ts index 022b52b7c64d8..0dd1cb31396db 100644 --- a/src/vs/sessions/contrib/chat/browser/openInVSCode.contribution.ts +++ b/src/vs/sessions/contrib/chat/browser/openInVSCode.contribution.ts @@ -16,7 +16,7 @@ import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import { IProductService } from '../../../../platform/product/common/productService.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { IsAuxiliaryWindowContext } from '../../../../workbench/common/contextkeys.js'; -import { SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js'; +import { IsPhoneLayoutContext, SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js'; import { logSessionsInteraction } from '../../../common/sessionsTelemetry.js'; import { Menus } from '../../../browser/menus.js'; import { CopilotCLISessionType } from '../../../services/sessions/common/session.js'; @@ -42,7 +42,7 @@ registerAction2(class OpenSessionWorktreeInVSCodeAction extends Action2 { id: Menus.TitleBarSessionMenu, group: 'navigation', order: 9, - when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated()), + when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated(), IsPhoneLayoutContext.negate()), }] }); } diff --git a/src/vs/sessions/contrib/chat/electron-browser/openInVSCode.contribution.ts b/src/vs/sessions/contrib/chat/electron-browser/openInVSCode.contribution.ts index 067188f31e1c8..85ed0bbdbf6cf 100644 --- a/src/vs/sessions/contrib/chat/electron-browser/openInVSCode.contribution.ts +++ b/src/vs/sessions/contrib/chat/electron-browser/openInVSCode.contribution.ts @@ -16,7 +16,7 @@ import { INativeHostService } from '../../../../platform/native/common/native.js import { IProductService } from '../../../../platform/product/common/productService.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { IsAuxiliaryWindowContext } from '../../../../workbench/common/contextkeys.js'; -import { SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js'; +import { IsPhoneLayoutContext, SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js'; import { logSessionsInteraction } from '../../../common/sessionsTelemetry.js'; import { Menus } from '../../../browser/menus.js'; import { CopilotCLISessionType } from '../../../services/sessions/common/session.js'; @@ -44,7 +44,7 @@ registerAction2(class OpenSessionWorktreeInVSCodeAction extends Action2 { id: Menus.TitleBarSessionMenu, group: 'navigation', order: 9, - when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated()), + when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated(), IsPhoneLayoutContext.negate()), }] }); } diff --git a/src/vs/sessions/contrib/codeReview/browser/codeReview.contributions.ts b/src/vs/sessions/contrib/codeReview/browser/codeReview.contributions.ts index cb943ea95b252..6653b98cd8c86 100644 --- a/src/vs/sessions/contrib/codeReview/browser/codeReview.contributions.ts +++ b/src/vs/sessions/contrib/codeReview/browser/codeReview.contributions.ts @@ -14,6 +14,7 @@ import { InstantiationType, registerSingleton } from '../../../../platform/insta import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { IsSessionsWindowContext } from '../../../../workbench/common/contextkeys.js'; +import { IsPhoneLayoutContext } from '../../../common/contextkeys.js'; import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; import { CHAT_CATEGORY } from '../../../../workbench/contrib/chat/browser/actions/chatActions.js'; import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; @@ -52,6 +53,7 @@ function registerSessionCodeReviewAction(tooltip: string, icon: ThemeIcon): Disp when: ContextKeyExpr.and( IsSessionsWindowContext, ChatContextKeys.agentSessionType.notEqualsTo(CopilotCloudSessionType.id), + IsPhoneLayoutContext.negate(), ), }, ], diff --git a/src/vs/sessions/contrib/files/browser/files.contribution.ts b/src/vs/sessions/contrib/files/browser/files.contribution.ts index 28b17329f7b9c..9277f6924b36e 100644 --- a/src/vs/sessions/contrib/files/browser/files.contribution.ts +++ b/src/vs/sessions/contrib/files/browser/files.contribution.ts @@ -19,6 +19,7 @@ import { IViewsService } from '../../../../workbench/services/views/common/views import { WorkspaceFolderCountContext } from '../../../../workbench/common/contextkeys.js'; import { SESSIONS_FILES_EMPTY_VIEW_ID, SESSIONS_FILES_VIEW_ID, SessionsExplorerEmptyView, SessionsExplorerView } from './filesView.js'; import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; +import { IsPhoneLayoutContext } from '../../../common/contextkeys.js'; export const SESSIONS_FILES_CONTAINER_ID = 'workbench.sessions.auxiliaryBar.filesContainer'; @@ -60,7 +61,7 @@ class RegisterFilesViewContribution implements IWorkbenchContribution { ctorDescriptor: new SyncDescriptor(SessionsExplorerView), canToggleVisibility: false, canMoveView: false, - when: WorkspaceFolderCountContext.notEqualsTo('0'), + when: ContextKeyExpr.and(WorkspaceFolderCountContext.notEqualsTo('0'), IsPhoneLayoutContext.negate()), windowEnablement: WindowEnablement.Sessions, }], filesViewContainer); @@ -72,7 +73,7 @@ class RegisterFilesViewContribution implements IWorkbenchContribution { ctorDescriptor: new SyncDescriptor(SessionsExplorerEmptyView), canToggleVisibility: false, canMoveView: false, - when: WorkspaceFolderCountContext.isEqualTo('0'), + when: ContextKeyExpr.and(WorkspaceFolderCountContext.isEqualTo('0'), IsPhoneLayoutContext.negate()), windowEnablement: WindowEnablement.Sessions, }], filesViewContainer); } diff --git a/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css b/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css index 884c9db5f6f87..91d1d4d225ba4 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css +++ b/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css @@ -403,3 +403,23 @@ background-position: -120% 0; } } + +/* ---- Mobile Layout: Touch Adaptations ---- */ + +/* Always show inline toolbar on mobile (no hover dependency) */ +.agent-sessions-workbench.phone-layout .sessions-list .monaco-list-row .actions { + display: flex !important; + visibility: visible !important; + opacity: 1 !important; +} + +/* Touch feedback on session list items */ +.agent-sessions-workbench.phone-layout .sessions-list .monaco-list-row:active { + background-color: var(--vscode-list-hoverBackground) !important; +} + +/* Disable webkit touch callout on list items */ +.agent-sessions-workbench.phone-layout .sessions-list .monaco-list-row { + -webkit-touch-callout: none; + touch-action: manipulation; +} diff --git a/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts b/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts index 11dfd1e6446e1..640613a6630cd 100644 --- a/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts +++ b/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts @@ -19,7 +19,7 @@ import { TerminalCapability } from '../../../../platform/terminal/common/capabil import { IPathService } from '../../../../workbench/services/path/common/pathService.js'; import { Menus } from '../../../browser/menus.js'; import { isAgentHostProvider } from '../../../common/agentHostSessionsProvider.js'; -import { SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js'; +import { SessionsWelcomeVisibleContext, IsPhoneLayoutContext } from '../../../common/contextkeys.js'; import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { CopilotCLISessionType, ISession } from '../../../services/sessions/common/session.js'; import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; @@ -392,7 +392,7 @@ class OpenSessionInTerminalAction extends Action2 { id: Menus.TitleBarSessionMenu, group: 'navigation', order: 10, - when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated()), + when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated(), IsPhoneLayoutContext.negate()), }] }); } From b6e9f6e830ec9fec8453020aa5192f6d47555a2d Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Thu, 23 Apr 2026 09:47:17 -0700 Subject: [PATCH 34/70] agentHost: don't use a AH terminal by default for local connections Downside is that it's not shared with clients by default. But in exchange it uses the richer shell features we have through VS Code. Probably worth it? --- src/vs/sessions/common/agentHostSessionsProvider.ts | 6 ++++-- .../browser/agentSessionSettings.contribution.ts | 3 ++- .../agentHost/browser/localAgentHostSessionsProvider.ts | 4 ++-- .../chat/browser/agentHost/agentHostModelPicker.ts | 5 +++-- .../browser/agentHost/agentHostSessionConfigPicker.ts | 6 +++--- .../terminal/browser/sessionsTerminalContribution.ts | 8 ++++++-- 6 files changed, 20 insertions(+), 12 deletions(-) diff --git a/src/vs/sessions/common/agentHostSessionsProvider.ts b/src/vs/sessions/common/agentHostSessionsProvider.ts index 58862cd264b0f..b1bdfe7fef46c 100644 --- a/src/vs/sessions/common/agentHostSessionsProvider.ts +++ b/src/vs/sessions/common/agentHostSessionsProvider.ts @@ -68,8 +68,10 @@ export interface IAgentHostSessionsProvider extends ISessionsProvider { clearSessionConfig(sessionId: string): void; } -const LOCAL_AGENT_HOST_PROVIDER_ID = 'local-agent-host'; -const REMOTE_AGENT_HOST_PROVIDER_PREFIX = 'agenthost-'; +export const LOCAL_AGENT_HOST_PROVIDER_ID = 'local-agent-host'; +export const REMOTE_AGENT_HOST_PROVIDER_PREFIX = 'agenthost-'; +export const REMOTE_AGENT_HOST_PROVIDER_RE = /^agenthost-/; +export const ANY_AGENT_HOST_PROVIDER_RE = /^(local-agent-host|agenthost-)/; /** * Checks whether a provider is an agent host provider based on its diff --git a/src/vs/sessions/contrib/agentHost/browser/agentSessionSettings.contribution.ts b/src/vs/sessions/contrib/agentHost/browser/agentSessionSettings.contribution.ts index 657595bed09d1..dc712d1b4ff2e 100644 --- a/src/vs/sessions/contrib/agentHost/browser/agentSessionSettings.contribution.ts +++ b/src/vs/sessions/contrib/agentHost/browser/agentSessionSettings.contribution.ts @@ -16,6 +16,7 @@ import { ChatSessionProviderIdContext } from '../../../common/contextkeys.js'; import { ISession } from '../../../services/sessions/common/session.js'; import { SessionItemContextMenuId } from '../../sessions/browser/views/sessionsList.js'; import { agentSessionSettingsUri, AGENT_SESSION_SETTINGS_SCHEME, AgentSessionSettingsFileSystemProvider, AgentSessionSettingsSchemaRegistrar } from './agentSessionSettingsFileSystemProvider.js'; +import { ANY_AGENT_HOST_PROVIDER_RE } from '../../../common/agentHostSessionsProvider.js'; /** * Registers the {@link AgentSessionSettingsFileSystemProvider} with the @@ -57,7 +58,7 @@ registerAction2(class OpenSessionSettingsAction extends Action2 { id: SessionItemContextMenuId, group: '2_settings', order: 1, - when: ContextKeyExpr.regex(ChatSessionProviderIdContext.key, /^(local-agent-host|agenthost-)/), + when: ContextKeyExpr.regex(ChatSessionProviderIdContext.key, ANY_AGENT_HOST_PROVIDER_RE), }] }); } diff --git a/src/vs/sessions/contrib/agentHost/browser/localAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/agentHost/browser/localAgentHostSessionsProvider.ts index 587aa5c248fb0..f492f082af181 100644 --- a/src/vs/sessions/contrib/agentHost/browser/localAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/agentHost/browser/localAgentHostSessionsProvider.ts @@ -22,8 +22,8 @@ import { BaseAgentHostSessionsProvider } from './baseAgentHostSessionsProvider.j import { buildAgentHostSessionWorkspace } from '../../../common/agentHostSessionWorkspace.js'; import { ISessionWorkspace, ISessionWorkspaceBrowseAction } from '../../../services/sessions/common/session.js'; import { toAgentHostUri } from '../../../../platform/agentHost/common/agentHostUri.js'; +import { LOCAL_AGENT_HOST_PROVIDER_ID } from '../../../common/agentHostSessionsProvider.js'; -const LOCAL_PROVIDER_ID = 'local-agent-host'; const LOCAL_RESOURCE_SCHEME_PREFIX = 'agent-host-'; /** @@ -36,7 +36,7 @@ const LOCAL_RESOURCE_SCHEME_PREFIX = 'agent-host-'; */ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvider { - readonly id = LOCAL_PROVIDER_ID; + readonly id = LOCAL_AGENT_HOST_PROVIDER_ID; readonly label: string; readonly icon: ThemeIcon = Codicon.vm; readonly browseActions: readonly ISessionWorkspaceBrowseAction[]; diff --git a/src/vs/sessions/contrib/chat/browser/agentHost/agentHostModelPicker.ts b/src/vs/sessions/contrib/chat/browser/agentHost/agentHostModelPicker.ts index 38c6a51d4782d..76b080d849f62 100644 --- a/src/vs/sessions/contrib/chat/browser/agentHost/agentHostModelPicker.ts +++ b/src/vs/sessions/contrib/chat/browser/agentHost/agentHostModelPicker.ts @@ -21,10 +21,11 @@ import { type ISession } from '../../../../services/sessions/common/session.js'; import { ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; import { Menus } from '../../../../browser/menus.js'; +import { LOCAL_AGENT_HOST_PROVIDER_ID, REMOTE_AGENT_HOST_PROVIDER_RE } from '../../../../common/agentHostSessionsProvider.js'; const IsActiveSessionAgentHost = ContextKeyExpr.or( - ContextKeyExpr.equals(ActiveSessionProviderIdContext.key, 'local-agent-host'), - ContextKeyExpr.regex(ActiveSessionProviderIdContext.key, /^agenthost-/), + ContextKeyExpr.equals(ActiveSessionProviderIdContext.key, LOCAL_AGENT_HOST_PROVIDER_ID), + ContextKeyExpr.regex(ActiveSessionProviderIdContext.key, REMOTE_AGENT_HOST_PROVIDER_RE), ); // -- Agent Host Model Picker Action -- diff --git a/src/vs/sessions/contrib/chat/browser/agentHost/agentHostSessionConfigPicker.ts b/src/vs/sessions/contrib/chat/browser/agentHost/agentHostSessionConfigPicker.ts index 0d5ef522787c1..0d1e14b769cb0 100644 --- a/src/vs/sessions/contrib/chat/browser/agentHost/agentHostSessionConfigPicker.ts +++ b/src/vs/sessions/contrib/chat/browser/agentHost/agentHostSessionConfigPicker.ts @@ -34,13 +34,13 @@ import { ActiveSessionProviderIdContext } from '../../../../common/contextkeys.j import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; import { ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; import type { ISessionsProvider } from '../../../../services/sessions/common/sessionsProvider.js'; -import { type IAgentHostSessionsProvider, isAgentHostProvider } from '../../../../common/agentHostSessionsProvider.js'; +import { type IAgentHostSessionsProvider, isAgentHostProvider, LOCAL_AGENT_HOST_PROVIDER_ID, REMOTE_AGENT_HOST_PROVIDER_RE } from '../../../../common/agentHostSessionsProvider.js'; import { PermissionPicker } from '../../../copilotChatSessions/browser/permissionPicker.js'; import { AgentHostPermissionPickerActionItem } from './agentHostPermissionPickerActionItem.js'; import { AgentHostPermissionPickerDelegate, AUTO_APPROVE_PROPERTY, isWellKnownAutoApproveSchema } from './agentHostPermissionPickerDelegate.js'; -const IsActiveSessionRemoteAgentHost = ContextKeyExpr.regex(ActiveSessionProviderIdContext.key, /^agenthost-/); -const IsActiveSessionLocalAgentHost = ContextKeyExpr.equals(ActiveSessionProviderIdContext.key, 'local-agent-host'); +const IsActiveSessionRemoteAgentHost = ContextKeyExpr.regex(ActiveSessionProviderIdContext.key, REMOTE_AGENT_HOST_PROVIDER_RE); +const IsActiveSessionLocalAgentHost = ContextKeyExpr.equals(ActiveSessionProviderIdContext.key, LOCAL_AGENT_HOST_PROVIDER_ID); registerAction2(class extends Action2 { constructor() { diff --git a/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts b/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts index 640613a6630cd..b25fa07f82d64 100644 --- a/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts +++ b/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts @@ -18,7 +18,7 @@ import { ITerminalInstance, ITerminalService } from '../../../../workbench/contr import { TerminalCapability } from '../../../../platform/terminal/common/capabilities/capabilities.js'; import { IPathService } from '../../../../workbench/services/path/common/pathService.js'; import { Menus } from '../../../browser/menus.js'; -import { isAgentHostProvider } from '../../../common/agentHostSessionsProvider.js'; +import { isAgentHostProvider, LOCAL_AGENT_HOST_PROVIDER_ID } from '../../../common/agentHostSessionsProvider.js'; import { SessionsWelcomeVisibleContext, IsPhoneLayoutContext } from '../../../common/contextkeys.js'; import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { CopilotCLISessionType, ISession } from '../../../services/sessions/common/session.js'; @@ -86,13 +86,17 @@ export class SessionsTerminalContribution extends Disposable implements IWorkben super(); const profileOverride = derived(reader => { - const profiles = this._agentHostTerminalService.profiles.read(reader); const session = this._sessionsManagementService.activeSession.read(reader); + if (!session || session.providerId === LOCAL_AGENT_HOST_PROVIDER_ID) { + return; // no need to override local default profiles with the local AH + } + const address = this._getSessionAgentHostAddress(session); if (!address) { return; } + const profiles = this._agentHostTerminalService.profiles.read(reader); return profiles.find(p => p.address === address) ?? this._agentHostTerminalService.getProfileForConnection(address); }); From 36d37fe75355a3b46eba8e7e6e1784c62a5d8df3 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Thu, 23 Apr 2026 09:59:33 -0700 Subject: [PATCH 35/70] sessions: fix workspace picker and session options resetting on new chat widget (#312072) * sessions: fix workspace picker and session options resetting on new chat widget * comments --- .../contrib/chat/browser/newChatViewPane.ts | 33 +++++++++++++++++-- .../browser/sessionsManagementService.ts | 19 ++++++++++- 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts index 567bc815b1bfb..3d8f79b24cbcd 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts @@ -88,9 +88,11 @@ class NewChatWidget extends Disposable { this._newChatInput.render(chatWidgetContent, parent); - // Create initial session — wait for providers if none registered yet + // Create initial session — wait for providers if none registered yet. + // Skip if an active session already exists (restored by openNewSessionView + // from a pending new session when navigating back from another session). const restoredProject = this._workspacePicker.selectedProject; - if (restoredProject) { + if (!this._syncWorkspacePickerFromActiveSession() && restoredProject) { if (this.sessionsProvidersService.getProviders().length > 0) { this._createNewSession(restoredProject, this._newChatInput.sessionTypePicker.selectedType); } else { @@ -106,6 +108,33 @@ class NewChatWidget extends Disposable { chatWidgetContainer.classList.add('revealed'); } + /** + * If a pending session was restored by {@link openNewSessionView}, sync + * the workspace picker to match the session's workspace. The picker may + * have restored a workspace from a different provider (e.g. remote vs + * local), so overwrite it with the session's actual workspace without + * firing the event (which would trigger {@link _onWorkspaceSelected} and + * create a new session). + * + * @returns `true` if an active session was found and the picker was synced. + */ + private _syncWorkspacePickerFromActiveSession(): boolean { + const activeSession = this.sessionsManagementService.activeSession.get(); + if (!activeSession) { + return false; + } + + const sessionWorkspace = activeSession.workspace.get(); + if (sessionWorkspace) { + this._workspacePicker.setSelectedWorkspace( + { providerId: activeSession.providerId, workspace: sessionWorkspace }, + /* fireEvent */ false, + ); + } + + return true; + } + private _createNewSession(selection: IWorkspaceSelection, sessionTypeId: string | undefined): void { this.sessionsManagementService.createNewSession(selection.providerId, selection.workspace.repositories[0].uri, sessionTypeId); } diff --git a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts index f362a407405d1..0d2f936d85a70 100644 --- a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts +++ b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts @@ -54,6 +54,9 @@ class SessionsManagementService extends Disposable implements ISessionsManagemen readonly activeSession: IObservable = this._activeSession; private readonly _activeProviderId = observableValue(this, undefined); readonly activeProviderId: IObservable = this._activeProviderId; + + /** Tracks the pending new session so it can be restored by {@link openNewSessionView}. */ + private _pendingNewSession: ISession | undefined; private readonly isNewChatSessionContext: IContextKey; private readonly _isNewChatInSessionContext: IContextKey; private readonly _activeSessionProviderId: IContextKey; @@ -169,6 +172,13 @@ class SessionsManagementService extends Disposable implements ISessionsManagemen this._onDidChangeSessions.fire(e); const currentActive = this._activeSession.get(); + // Clear stale pending session if the provider removed it + if (e.removed.length && this._pendingNewSession) { + if (e.removed.some(r => r.sessionId === this._pendingNewSession!.sessionId)) { + this._pendingNewSession = undefined; + } + } + if (!currentActive) { return; } @@ -268,6 +278,7 @@ class SessionsManagementService extends Disposable implements ISessionsManagemen } unsetNewSession(): void { + this._pendingNewSession = undefined; this.setActiveSession(undefined); } @@ -288,11 +299,13 @@ class SessionsManagementService extends Disposable implements ISessionsManagemen } } const session = provider.createNewSession(repositoryUri, sessionTypeId); + this._pendingNewSession = session; this.setActiveSession(session); return session; } async sendAndCreateChat(session: ISession, options: ISendRequestOptions): Promise { + this._pendingNewSession = undefined; this.isNewChatSessionContext.set(false); this._isNewChatInSessionContext.set(false); @@ -330,6 +343,7 @@ class SessionsManagementService extends Disposable implements ISessionsManagemen } async sendRequest(session: ISession, chat: IChat, options: ISendRequestOptions): Promise { + this._pendingNewSession = undefined; this.isNewChatSessionContext.set(false); this._isNewChatInSessionContext.set(false); @@ -355,7 +369,10 @@ class SessionsManagementService extends Disposable implements ISessionsManagemen if (this.isNewChatSessionContext.get()) { return; } - this.setActiveSession(undefined); + // Restore the pending new session if one exists, so pickers + // re-derive their state from the still-alive session object. + // Otherwise clear active session (first time / after send). + this.setActiveSession(this._pendingNewSession ?? undefined); this.isNewChatSessionContext.set(true); this._isNewChatInSessionContext.set(false); } From 831512a731077f6ec979f380e4e8853f35fb6405 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Thu, 23 Apr 2026 10:04:14 -0700 Subject: [PATCH 36/70] chat: don't auto-send queued prompts when confirmation is pending (#312167) - Prevents queued prompts from being automatically sent when the response contains a confirmation that hasn't been acted on yet - Adds a check for unresolved confirmation parts in the response before processing pending requests Fixes https://github.com/microsoft/vscode/issues/306522 (Commit message generated by Copilot) --- .../contrib/chat/common/chatService/chatServiceImpl.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index f7f2ad013775a..9fe7079c924a7 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -1403,7 +1403,9 @@ export class ChatService extends Disposable implements IChatService { this.chatEntitlementService.markAnonymousRateLimited(); } - shouldProcessPending = !rawResult.errorDetails && !token.isCancellationRequested; + shouldProcessPending = !rawResult.errorDetails + && !token.isCancellationRequested + && !request.response?.response.value.some(v => v.kind === 'confirmation' && !v.isUsed); request.response?.complete(); if (agentOrCommandFollowups) { From cb149cf916a152688c08e1583e4a0fb31b63e51f Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Thu, 23 Apr 2026 18:11:18 +0100 Subject: [PATCH 37/70] Enhance OpenInAgents visibility logic to include chat sentiment context for AI features Co-authored-by: Copilot --- .../electron-browser/actions/openInAgentsAction.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts b/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts index 3eb6e2f72cef9..278f7873eb51c 100644 --- a/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts +++ b/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts @@ -26,6 +26,7 @@ import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase import { IsAuxiliaryWindowContext, IsSessionsWindowContext } from '../../common/contextkeys.js'; import { workbenchConfigurationNodeBase } from '../../common/configuration.js'; import { IWorkbenchEnvironmentService } from '../../services/environment/common/environmentService.js'; +import { ChatEntitlementContextKeys } from '../../services/chat/common/chatEntitlementService.js'; const OpenInAgentsActionId = 'workbench.action.openInAgents'; const OpenInAgentsEnabledSetting = 'workbench.openInAgents.enabled'; @@ -47,8 +48,11 @@ const OpenInAgentsVisibility = ContextKeyExpr.and( ContextKeyExpr.equals(`config.${OpenInAgentsEnabledSetting}`, true), IsSessionsWindowContext.toNegated(), IsAuxiliaryWindowContext.toNegated(), - // Hide when AI features are disabled. - ContextKeyExpr.notEquals('config.chat.disableAIFeatures', true), + // Hide when chat sentiment indicates AI features should not be shown + // (mirrors the gating used by the Copilot status bar entry). + ChatEntitlementContextKeys.Setup.hidden.negate(), + ChatEntitlementContextKeys.Setup.disabled.negate(), + ChatEntitlementContextKeys.Setup.disabledInWorkspace.negate(), // Hide in stable builds for now (insider, exploration and OSS dev are allowed). ContextKeyExpr.notEquals(OpenInAgentsProductQualityContext.key, 'stable'), ); From 6e9ba04ab353197482a1d2a2a894857295115db7 Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Thu, 23 Apr 2026 18:13:02 +0100 Subject: [PATCH 38/70] Update OpenInAgentsVisibility to refine AI features visibility logic based on user signals and workspace trust Co-authored-by: Copilot --- .../workbench/electron-browser/actions/openInAgentsAction.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts b/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts index 278f7873eb51c..492281c57b03b 100644 --- a/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts +++ b/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts @@ -48,11 +48,12 @@ const OpenInAgentsVisibility = ContextKeyExpr.and( ContextKeyExpr.equals(`config.${OpenInAgentsEnabledSetting}`, true), IsSessionsWindowContext.toNegated(), IsAuxiliaryWindowContext.toNegated(), - // Hide when chat sentiment indicates AI features should not be shown - // (mirrors the gating used by the Copilot status bar entry). + // Hide whenever the user has signaled (or policy/workspace trust dictates) + // that AI features should not be shown in this window/workspace. ChatEntitlementContextKeys.Setup.hidden.negate(), ChatEntitlementContextKeys.Setup.disabled.negate(), ChatEntitlementContextKeys.Setup.disabledInWorkspace.negate(), + ChatEntitlementContextKeys.Setup.untrusted.negate(), // Hide in stable builds for now (insider, exploration and OSS dev are allowed). ContextKeyExpr.notEquals(OpenInAgentsProductQualityContext.key, 'stable'), ); From bc0bd8896fb3997cf92f5f303f5539ed481b54c0 Mon Sep 17 00:00:00 2001 From: Henning Dieterichs Date: Thu, 23 Apr 2026 17:52:19 +0200 Subject: [PATCH 39/70] Fixes multi diff flickering --- .../multiDiffEditorViewModel.ts | 62 +++++++++++++------ 1 file changed, 43 insertions(+), 19 deletions(-) diff --git a/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorViewModel.ts b/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorViewModel.ts index fd4c9bae7ba9f..22c2a2e13ff1c 100644 --- a/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorViewModel.ts +++ b/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorViewModel.ts @@ -4,7 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import { Disposable, DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; -import { IObservable, ITransaction, constObservable, derived, derivedObservableWithWritableCache, mapObservableArrayCached, observableFromValueWithChangeEvent, observableValue, transaction } from '../../../../base/common/observable.js'; +import { IObservable, ITransaction, ObservablePromise, ObservableResolvedPromise, constObservable, derived, derivedObservableWithWritableCache, mapObservableArrayCached, observableFromValueWithChangeEvent, observableValue, transaction } from '../../../../base/common/observable.js'; +import { timeout } from '../../../../base/common/async.js'; import { URI } from '../../../../base/common/uri.js'; import { ContextKeyValue } from '../../../../platform/contextkey/common/contextkey.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; @@ -20,14 +21,20 @@ import { IDocumentDiffItem, IMultiDiffEditorModel } from './model.js'; export class MultiDiffEditorViewModel extends Disposable { private readonly _documents: IObservable[] | 'loading'>; - private readonly _documentsArr; + private readonly _documentsArr = derived(this, reader => { + const result = this._documents.read(reader); + if (result === 'loading') { return []; } + return result; + }); public readonly isLoading; public readonly items: IObservable; - public readonly focusedDiffItem; - public readonly activeDiffItem; + public readonly focusedDiffItem = derived(this, reader => this.items.read(reader).find(i => i.isFocused.read(reader))); + public readonly activeDiffItem = derivedObservableWithWritableCache(this, + (reader, lastValue) => this.focusedDiffItem.read(reader) ?? (lastValue && this.items.read(reader).indexOf(lastValue) !== -1) ? lastValue : undefined + ); public async waitForDiffs(): Promise { for (const d of this.items.get()) { @@ -57,24 +64,33 @@ export class MultiDiffEditorViewModel extends Disposable { constructor( public readonly model: IMultiDiffEditorModel, - private readonly _instantiationService: IInstantiationService, + private readonly _instantiationService: IInstantiationService ) { super(); this._documents = observableFromValueWithChangeEvent(this.model, this.model.documents); - this._documentsArr = derived(this, reader => { - const result = this._documents.read(reader); - if (result === 'loading') { return []; } - return result; - }); - this.isLoading = derived(this, reader => this._documents.read(reader) === 'loading'); - this.items = mapObservableArrayCached( + + const allItems = mapObservableArrayCached( this, this._documentsArr, (d, store) => store.add(this._instantiationService.createInstance(DocumentDiffItemViewModel, d, this)) ).recomputeInitiallyAndOnChange(this._store); - this.focusedDiffItem = derived(this, reader => this.items.read(reader).find(i => i.isFocused.read(reader))); - this.activeDiffItem = derivedObservableWithWritableCache(this, - (reader, lastValue) => this.focusedDiffItem.read(reader) ?? (lastValue && this.items.read(reader).indexOf(lastValue) !== -1) ? lastValue : undefined + + const waitForNewDiffs: IObservable> = derived(this, reader => { + const next = allItems.read(reader); + const unresolved = next.filter(i => !i.waitForInitialDiffOr1s.promiseResult.read(undefined)); + if (unresolved.length === 0) { + return ObservablePromise.resolved(next); + } + return new ObservablePromise( + Promise.all(unresolved.map(i => i.waitForInitialDiffOr1s.promise)).then(() => next) + ); + }); + + const resolved = new ObservableResolvedPromise(waitForNewDiffs, [] as readonly DocumentDiffItemViewModel[], this._store); + + this.items = resolved.lastResolved; + this.isLoading = derived(this, reader => + this._documents.read(reader) === 'loading' || resolved.isResolving.read(reader) ); } } @@ -87,6 +103,7 @@ export class DocumentDiffItemViewModel extends Disposable { public get diffEditorViewModel(): IDiffEditorViewModel { return this.diffEditorViewModelRef.object; } + public readonly waitForInitialDiffOr1s: ObservablePromise; public readonly collapsed = observableValue(this, false); public readonly lastTemplateData = observableValue<{ contentHeight: number; selections: Selection[] | undefined }>( @@ -106,9 +123,9 @@ export class DocumentDiffItemViewModel extends Disposable { this._isFocusedSource.set(source, tx); } - private readonly documentDiffItemRef: RefCounted; + private readonly _documentDiffItemRef: RefCounted; public get documentDiffItem(): IDocumentDiffItem { - return this.documentDiffItemRef.object; + return this._documentDiffItemRef.object; } public readonly isAlive = observableValue(this, true); @@ -125,7 +142,7 @@ export class DocumentDiffItemViewModel extends Disposable { this.isAlive.set(false, undefined); })); - this.documentDiffItemRef = this._register(documentDiffItem.createNewRef(this)); + this._documentDiffItemRef = this._register(documentDiffItem.createNewRef(this)); function updateOptions(options: IDiffEditorOptions): IDiffEditorOptions { return { @@ -146,7 +163,7 @@ export class DocumentDiffItemViewModel extends Disposable { const diffEditorViewModelStore = new DisposableStore(); const originalTextModel = this.documentDiffItem.original ?? diffEditorViewModelStore.add(this._modelService.createModel('', null)); const modifiedTextModel = this.documentDiffItem.modified ?? diffEditorViewModelStore.add(this._modelService.createModel('', null)); - diffEditorViewModelStore.add(this.documentDiffItemRef.createNewRef(this)); + diffEditorViewModelStore.add(this._documentDiffItemRef.createNewRef(this)); this.diffEditorViewModelRef = this._register(RefCounted.createWithDisposable( this._instantiationService.createInstance(DiffEditorViewModel, { @@ -156,6 +173,13 @@ export class DocumentDiffItemViewModel extends Disposable { diffEditorViewModelStore, this )); + + this.waitForInitialDiffOr1s = new ObservablePromise( + Promise.race([ + this.diffEditorViewModel.waitForDiff(), + timeout(1000), + ]) + ); } public getKey(): string { From 6e870f3eea7f2c628ffba70e4aa038d16cdbc0b9 Mon Sep 17 00:00:00 2001 From: Henning Dieterichs Date: Thu, 23 Apr 2026 17:53:02 +0200 Subject: [PATCH 40/70] Adds ObservableResolvedPromise --- .../base/common/observableInternal/index.ts | 2 +- .../observableInternal/utils/promise.ts | 42 ++++++++++++++++++- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/src/vs/base/common/observableInternal/index.ts b/src/vs/base/common/observableInternal/index.ts index 58f26cbfa5b08..805f7d75e7d6c 100644 --- a/src/vs/base/common/observableInternal/index.ts +++ b/src/vs/base/common/observableInternal/index.ts @@ -11,7 +11,7 @@ export { type IObservable, type IObservableWithChange, type IObserver, type IRea export { disposableObservableValue } from './observables/observableValue.js'; export { derived, derivedDisposable, derivedHandleChanges, derivedOpts, derivedWithSetter, derivedWithStore } from './observables/derived.js'; export { type IDerivedReader } from './observables/derivedImpl.js'; -export { ObservableLazy, ObservableLazyPromise, ObservablePromise, PromiseResult, } from './utils/promise.js'; +export { ObservableLazy, ObservableLazyPromise, ObservablePromise, ObservableResolvedPromise, PromiseResult, } from './utils/promise.js'; export { derivedWithCancellationToken, waitForState } from './utils/utilsCancellation.js'; export { debouncedObservable, debouncedObservable2, derivedObservableWithCache, diff --git a/src/vs/base/common/observableInternal/utils/promise.ts b/src/vs/base/common/observableInternal/utils/promise.ts index a6493858f6c56..965105b97e261 100644 --- a/src/vs/base/common/observableInternal/utils/promise.ts +++ b/src/vs/base/common/observableInternal/utils/promise.ts @@ -2,7 +2,9 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { IObservable } from '../base.js'; +import { DisposableStore } from '../../lifecycle.js'; +import { IObservable, ISettableObservable } from '../base.js'; +import { autorun } from '../reactions/autorun.js'; import { transaction } from '../transaction.js'; import { derived } from '../observables/derived.js'; import { observableValue } from '../observables/observableValue.js'; @@ -110,6 +112,44 @@ export class PromiseResult { } } +/** + * Tracks a changing {@link ObservablePromise}, exposing the last resolved value + * and whether a newer promise is still pending. + */ +export class ObservableResolvedPromise { + private readonly _lastResolved: ISettableObservable; + public readonly lastResolved: IObservable; + + private readonly _isResolving = observableValue(this, false); + public readonly isResolving: IObservable = this._isResolving; + + private _runningPromise: ObservablePromise | undefined; + + constructor( + source: IObservable>, + initialValue: T, + store: DisposableStore, + ) { + this._lastResolved = observableValue(this, initialValue); + this.lastResolved = this._lastResolved; + + store.add(autorun(reader => { + const current = source.read(reader); + this._runningPromise = current; + + const result = current.promiseResult.read(reader); + if (result) { + if (current === this._runningPromise) { + this._isResolving.set(false, undefined); + this._lastResolved.set(result.getDataOrThrow(), undefined); + } + } else { + this._isResolving.set(true, undefined); + } + })); + } +} + /** * A lazy promise whose state is observable. */ From 659d444a95c2ca37a3ad3f501e421cbe38849285 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Thu, 23 Apr 2026 19:26:21 +0200 Subject: [PATCH 41/70] Agents: fix sessions workspace picker (#312136) * Agents: fix sessions workspace picker - Filter copilot worktrees (folders starting with 'copilot-') from VS Code recents - Preserve VS Code recents order instead of sorting alphabetically - Fix overflow with description + group title in inline-description mode - Fix submenu chevron alignment using flex layout without negative margins - Cap picker width at 600px (matching quick pick) with stable fixed-width layout that skips DOM measurement to prevent width fluctuating with window height - Add 30px bottom gap so picker doesn't grow flush to window edge - Add scrollbar clearance: row width calc(100% - 10px) keeps content clear of the 10px overlay scrollbar track - Fix group title / toolbar spacing in inline-description mode - Document entry ordering in _buildItems() Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix _isCopilotWorktree: use basename() to handle trailing slashes Manual path.lastIndexOf('/') returns empty string when the URI path has a trailing slash, causing the filter to miss those folders. Use the existing basename() helper from resources.ts which handles all URI path edge cases correctly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Limit VS Code recents to top 10 entries Only show the 10 most recent folders from VS Code history in the workspace picker to keep the list focused and manageable. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address PR feedback: rename maxWidth to fixedWidth, fix spaceAbove gap - Rename maxWidth option to fixedWidth to accurately reflect its behavior (fixed width, not a cap on measured width) - Fix spaceAbove calculation: remove bottomGap from spaceAbove since the 30px gap is only needed at the bottom of the viewport - _isCopilotWorktree already uses basename() from a prior commit Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Limit to top 10 provider-supported VS Code recents Move the cap from _loadVSCodeRecentFolders (before provider resolution) into _getVSCodeRecentWorkspaces (after provider filtering), so unsupported folders don't consume slots in the limit. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Also slice at load time to avoid iterating all recents Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix scrollbar spacing: use item-level margins instead of row width reduction Replace width: calc(100% - 10px) on rows with margin-right: 10px on the toolbar, submenu chevron, and group-title (when no actions follow). This avoids shrinking the row itself which caused code action list screenshot regressions, while still providing scrollbar clearance. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../actionWidget/browser/actionList.ts | 28 ++++++++-- .../actionWidget/browser/actionWidget.css | 21 ++++++- .../chat/browser/sessionWorkspacePicker.ts | 56 ++++++++++++------- 3 files changed, 78 insertions(+), 27 deletions(-) diff --git a/src/vs/platform/actionWidget/browser/actionList.ts b/src/vs/platform/actionWidget/browser/actionList.ts index cb0397aaaa42a..c40d17705da2a 100644 --- a/src/vs/platform/actionWidget/browser/actionList.ts +++ b/src/vs/platform/actionWidget/browser/actionList.ts @@ -453,6 +453,14 @@ export interface IActionListOptions { */ readonly minWidth?: number; + /** + * Fixed width for the action list. When set, DOM-based width measurement is + * skipped and this value is used directly, preventing width fluctuations caused + * by scrollbar presence (which changes with window height). Use this for pickers + * that should have a stable, fixed width (e.g. the workspace picker at 600px). + */ + readonly fixedWidth?: number; + /** * Optional handler for markdown links activated in item descriptions or hovers. * When unset, links open via the opener service with command links allowed. @@ -1608,6 +1616,7 @@ export class ActionList extends Disposable { private _cachedMaxWidth: number | undefined; private _hasLaidOut = false; private _showAbove: boolean | undefined; + private readonly _options: IActionListOptions | undefined; get domNode(): HTMLElement { return this._widget.domNode; @@ -1646,6 +1655,7 @@ export class ActionList extends Disposable { ) { super(); this._anchor = anchor; + this._options = options; this._widget = this._register(instantiationService.createInstance( ActionListWidget, @@ -1710,7 +1720,6 @@ export class ActionList extends Disposable { const listHeight = this._widget.computeListHeight(); const filterHeight = this._widget.filterContainer ? 36 : 0; - const padding = 10; const targetWindow = dom.getWindow(this.domNode); let availableHeight; @@ -1718,8 +1727,9 @@ export class ActionList extends Disposable { const viewportHeight = targetWindow.innerHeight; const anchorRect = getAnchorRect(this._anchor); const anchorTopInViewport = anchorRect.top - targetWindow.pageYOffset; - const spaceBelow = viewportHeight - anchorTopInViewport - anchorRect.height - padding; - const spaceAbove = anchorTopInViewport - padding; + const bottomGap = 30; + const spaceBelow = viewportHeight - anchorTopInViewport - anchorRect.height - bottomGap; + const spaceAbove = anchorTopInViewport; // Lock the direction on first layout based on whether the full // unconstrained list fits below. Once decided, the dropdown stays @@ -1730,6 +1740,7 @@ export class ActionList extends Disposable { } availableHeight = this._showAbove ? spaceAbove : spaceBelow; } else { + const padding = 10; const windowHeight = this._layoutService.getContainer(targetWindow).clientHeight; const widgetTop = this.domNode.getBoundingClientRect().top; availableHeight = widgetTop > 0 ? windowHeight - widgetTop - padding : windowHeight * 0.7; @@ -1749,7 +1760,16 @@ export class ActionList extends Disposable { const listHeight = this.computeHeight(); this._widget.layout(listHeight); - this._cachedMaxWidth = this._widget.computeMaxWidth(minWidth); + // When a fixedWidth is provided, skip DOM measurement entirely. + // DOM-based measurement varies with scrollbar presence (which depends on + // the list height), causing the width to fluctuate as the window is resized. + let computedWidth: number; + if (this._options?.fixedWidth !== undefined) { + computedWidth = this._options.fixedWidth; + } else { + computedWidth = this._widget.computeMaxWidth(minWidth); + } + this._cachedMaxWidth = computedWidth; this._widget.layout(listHeight, this._cachedMaxWidth); return this._cachedMaxWidth; diff --git a/src/vs/platform/actionWidget/browser/actionWidget.css b/src/vs/platform/actionWidget/browser/actionWidget.css index ec8b5e9d65da7..600690a9ad2f5 100644 --- a/src/vs/platform/actionWidget/browser/actionWidget.css +++ b/src/vs/platform/actionWidget/browser/actionWidget.css @@ -59,7 +59,6 @@ white-space: nowrap; cursor: pointer; touch-action: none; - width: 100%; border-radius: var(--vscode-cornerRadius-medium); } @@ -233,6 +232,7 @@ .action-widget .monaco-list-row.action .group-title { color: var(--vscode-descriptionForeground); margin-left: 0.5em; + margin-right: 6px; font-size: 12px; flex-shrink: 0; } @@ -253,19 +253,34 @@ /* Inline description mode — description rendered right after the label */ .action-widget .inline-description .monaco-list-row.action { + /* Override the row gap so group-title and toolbar sit flush */ + gap: 0; + .title { - flex: initial; - flex-shrink: 1; + flex: 0 1 auto; min-width: 0; + margin-left: 6px; } .description { flex: 1; min-width: 0; + overflow: hidden; + text-overflow: ellipsis; } .action-list-item-toolbar { + margin-left: 4px; + margin-right: 10px; + } + + /* When description is hidden (e.g. items with only a submenu), push the + * submenu chevron to the far right using an auto left margin. When + * description is visible it already consumes all available space via + * flex:1, so the auto margin has no additional effect. */ + .action-list-submenu-indicator { margin-left: auto; + margin-right: 10px; } } diff --git a/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts b/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts index b95ca7d4746fc..db8fe377fde78 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts @@ -11,7 +11,7 @@ import { Emitter, Event } from '../../../../base/common/event.js'; import { MarkdownString } from '../../../../base/common/htmlContent.js'; import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { URI, UriComponents } from '../../../../base/common/uri.js'; -import { Schemas } from '../../../../base/common/network.js'; +import { basename } from '../../../../base/common/resources.js'; import { isNative } from '../../../../base/common/platform.js'; import { localize } from '../../../../nls.js'; import { IActionWidgetService } from '../../../../platform/actionWidget/browser/actionWidget.js'; @@ -229,8 +229,8 @@ export class WorkspacePicker extends Disposable { }; const listOptions = showFilter - ? { showFilter: true, filterPlaceholder: localize('workspacePicker.filter', "Search Workspaces..."), reserveSubmenuSpace: false, inlineDescription: true, showGroupTitleOnFirstItem: true } - : { reserveSubmenuSpace: false, inlineDescription: true, showGroupTitleOnFirstItem: true }; + ? { showFilter: true, filterPlaceholder: localize('workspacePicker.filter', "Search Workspaces..."), reserveSubmenuSpace: false, inlineDescription: true, showGroupTitleOnFirstItem: true, fixedWidth: 600 } + : { reserveSubmenuSpace: false, inlineDescription: true, showGroupTitleOnFirstItem: true, fixedWidth: 600 }; triggerElement.setAttribute('aria-expanded', 'true'); this.actionWidgetService.show( @@ -329,6 +329,16 @@ export class WorkspacePicker extends Disposable { return this.sessionsProvidersService.getProviders().flatMap(p => p.browseActions); } + /** + * Builds the picker items list from recent workspaces. + * + * Ordering: + * 1. Own recents (from sessions picker storage) come first, followed by + * VS Code recent folders — both retain their original storage order. + * 2. Items are grouped by provider/group title. Groups are sorted by + * first-appearance index so the first group encountered stays on top. + * 3. Within each group the original insertion order is preserved (stable sort). + */ protected _buildItems(): IActionListItem[] { const items: IActionListItem[] = []; @@ -363,13 +373,15 @@ export class WorkspacePicker extends Disposable { } } - // Sort by group name, then by label within each group - workspaceEntries.sort((a, b) => { - const groupCmp = a.groupTitle.localeCompare(b.groupTitle); - if (groupCmp !== 0) { - return groupCmp; + // Group entries by groupTitle, preserving the original order within each group + const groupOrder = new Map(); + workspaceEntries.forEach((entry, index) => { + if (!groupOrder.has(entry.groupTitle)) { + groupOrder.set(entry.groupTitle, index); } - return a.workspace.label.localeCompare(b.workspace.label); + }); + workspaceEntries.sort((a, b) => { + return (groupOrder.get(a.groupTitle) ?? 0) - (groupOrder.get(b.groupTitle) ?? 0); }); // Add items with separators between groups @@ -849,16 +861,7 @@ export class WorkspacePicker extends Disposable { } return { providerId: stored.providerId, workspace }; }) - .filter((w): w is { providerId: string; workspace: ISessionWorkspace } => w !== undefined) - .sort((a, b) => { - // Local folders first, then remote repositories, alphabetical within each group - const aIsLocal = a.workspace.repositories[0]?.uri.scheme === Schemas.file; - const bIsLocal = b.workspace.repositories[0]?.uri.scheme === Schemas.file; - if (aIsLocal !== bIsLocal) { - return aIsLocal ? -1 : 1; - } - return a.workspace.label.localeCompare(b.workspace.label); - }); + .filter((w): w is { providerId: string; workspace: ISessionWorkspace } => w !== undefined); } protected _removeRecentWorkspace(selection: IWorkspaceSelection): void { @@ -915,7 +918,17 @@ export class WorkspacePicker extends Disposable { const recentlyOpened = await this.workspacesService.getRecentlyOpened(); this._vsCodeRecentFolderUris = recentlyOpened.workspaces .filter(isRecentFolder) - .map(f => f.folderUri); + .map(f => f.folderUri) + .filter(uri => !this._isCopilotWorktree(uri)) + .slice(0, 10); + } + + /** + * Returns whether the given URI points to a copilot-managed folder + * (a folder whose name starts with `copilot-`). + */ + private _isCopilotWorktree(uri: URI): boolean { + return basename(uri).startsWith('copilot-'); } /** @@ -948,6 +961,9 @@ export class WorkspacePicker extends Disposable { result.push({ providerId: provider.id, workspace }); } } + if (result.length >= 10) { + break; + } } return result; From d75dc0e522f1056796b5018ad68f6a22c486b393 Mon Sep 17 00:00:00 2001 From: Lee Murray Date: Thu, 23 Apr 2026 18:31:19 +0100 Subject: [PATCH 42/70] Agents: Improve session item styling (#312165) style: update session item styling for improved layout and appearance Co-authored-by: mrleemurray --- .../sessions/contrib/sessions/browser/media/sessionsList.css | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css b/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css index 91d1d4d225ba4..e6236ef89b20f 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css +++ b/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css @@ -225,10 +225,11 @@ display: none; gap: 8px; margin-top: 4px; - padding: 4px 8px; + margin-left: -6px; + padding: 4px 4px 4px 6px; box-sizing: border-box; border: 1px solid var(--vscode-contrastBorder, var(--vscode-widget-border, transparent)); - border-radius: 4px; + border-radius: 8px; background-color: var(--vscode-editor-background); color: var(--vscode-editor-foreground); align-items: center; From 6f35a19326111b6520547e8f9f1ec91838ca27f5 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Thu, 23 Apr 2026 11:13:57 -0700 Subject: [PATCH 43/70] agent host: skip permission for write_/read_/stop_/list_ shell helpers (#312075) * agent host: skip permission for write_/read_/stop_/list_ shell helpers The Copilot SDK's built-in shell helpers (write_bash, read_bash, stop_bash, list_bash and the powershell equivalents) never call permissions. only the primary bash/powershell tool does. Whenrequest the agent host re-registers them as external tools (via overridesBuiltInTool: true), they were getting routed through the generic permission flow and surfacing a meaningless dialog. Mark all four secondary shell helpers with skipPermission: true so the SDK auto-approves them, mirroring upstream Copilot CLI/extension behavior. Also drops write_/read_ shell tools from the 'terminal' tool kind: they send arbitrary text to a running program (a 'y', a password, REPL input, not a fresh shell so syntax-highlighting themcommand etc.) as a terminal block was misleading. Includes a real-SDK regression test that fails if the skipPermission flag is removed, plus unit-test coverage for the secondary-tool display paths. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * address Copilot review comments - Use first line of command for write_* display messages (newline safety) - Approval loop: only swallow timeout errors; propagate other failures 'bash_shutdown' in test comment - Assert write_bash was actually invoked (non-vacuousness check) (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../node/copilot/copilotShellTools.ts | 4 + .../node/copilot/copilotToolDisplay.ts | 44 +++- .../test/node/copilotToolDisplay.test.ts | 171 ++++++++++++- .../toolApprovalRealSdk.integrationTest.ts | 227 ++++++++++++++++++ 4 files changed, 443 insertions(+), 3 deletions(-) diff --git a/src/vs/platform/agentHost/node/copilot/copilotShellTools.ts b/src/vs/platform/agentHost/node/copilot/copilotShellTools.ts index 7c45e1a8faa9f..c46d2c75c8945 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotShellTools.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotShellTools.ts @@ -476,6 +476,7 @@ export function createShellTools( }, }, overridesBuiltInTool: true, + skipPermission: true, handler: (args) => { const shells = shellManager.listShells(); const shell = args.shell_id @@ -503,6 +504,7 @@ export function createShellTools( required: ['command'], }, overridesBuiltInTool: true, + skipPermission: true, handler: (args) => { const shells = shellManager.listShells(); const shell = shells[shells.length - 1]; @@ -524,6 +526,7 @@ export function createShellTools( }, }, overridesBuiltInTool: true, + skipPermission: true, handler: (args) => { if (args.shell_id) { const success = shellManager.shutdownShell(args.shell_id); @@ -546,6 +549,7 @@ export function createShellTools( description: `List active ${shellType} shell instances.`, parameters: { type: 'object', properties: {} }, overridesBuiltInTool: true, + skipPermission: true, handler: () => { const shells = shellManager.listShells(); if (shells.length === 0) { diff --git a/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts b/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts index b843dd74ac733..a1428617afff2 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts @@ -150,6 +150,18 @@ const SHELL_TOOL_NAMES: ReadonlySet = new Set([ CopilotToolName.PowerShell, ]); +/** Set of tool names that write input to an interactive shell session. */ +const WRITE_SHELL_TOOL_NAMES: ReadonlySet = new Set([ + CopilotToolName.WriteBash, + CopilotToolName.WritePowerShell, +]); + +/** Set of tool names that read output from an interactive shell session. */ +const READ_SHELL_TOOL_NAMES: ReadonlySet = new Set([ + CopilotToolName.ReadBash, + CopilotToolName.ReadPowerShell, +]); + /** Set of tool names that spawn subagent sessions. */ const SUBAGENT_TOOL_NAMES: ReadonlySet = new Set([ 'task', @@ -242,6 +254,19 @@ export function getInvocationMessage(toolName: string, displayName: string, para return localize('toolInvoke.shell', "Running {0} command", displayName); } + if (WRITE_SHELL_TOOL_NAMES.has(toolName)) { + const args = parameters as ICopilotShellToolArgs | undefined; + if (args?.command) { + const firstLine = args.command.split('\n')[0]; + return md(localize('toolInvoke.writeShellCmd', "Sending {0} to shell", appendEscapedMarkdownInlineCode(truncate(firstLine, 80)))); + } + return localize('toolInvoke.writeShell', "Sending input to shell"); + } + + if (READ_SHELL_TOOL_NAMES.has(toolName)) { + return localize('toolInvoke.readShell', "Reading shell output"); + } + switch (toolName) { case CopilotToolName.View: { const args = parameters as ICopilotViewToolArgs | undefined; @@ -308,6 +333,19 @@ export function getPastTenseMessage(toolName: string, displayName: string, param return localize('toolComplete.shell', "Ran {0} command", displayName); } + if (WRITE_SHELL_TOOL_NAMES.has(toolName)) { + const args = parameters as ICopilotShellToolArgs | undefined; + if (args?.command) { + const firstLine = args.command.split('\n')[0]; + return md(localize('toolComplete.writeShellCmd', "Sent {0} to shell", appendEscapedMarkdownInlineCode(truncate(firstLine, 80)))); + } + return localize('toolComplete.writeShell', "Sent input to shell"); + } + + if (READ_SHELL_TOOL_NAMES.has(toolName)) { + return localize('toolComplete.readShell', "Read shell output"); + } + switch (toolName) { case CopilotToolName.View: { const args = parameters as ICopilotViewToolArgs | undefined; @@ -365,7 +403,7 @@ export function getToolInputString(toolName: string, parameters: Record | undefined)?.args; @@ -439,7 +477,9 @@ export function getSubagentMetadata(parameters: Record | undefi */ export function getShellLanguage(toolName: string): string { switch (toolName) { - case CopilotToolName.PowerShell: return 'powershell'; + case CopilotToolName.PowerShell: + case CopilotToolName.WritePowerShell: + case CopilotToolName.ReadPowerShell: return 'powershell'; default: return 'shellscript'; } } diff --git a/src/vs/platform/agentHost/test/node/copilotToolDisplay.test.ts b/src/vs/platform/agentHost/test/node/copilotToolDisplay.test.ts index 5aee3be444229..86d2c10c713ff 100644 --- a/src/vs/platform/agentHost/test/node/copilotToolDisplay.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotToolDisplay.test.ts @@ -6,7 +6,7 @@ import assert from 'assert'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { getInvocationMessage, getPastTenseMessage, getPermissionDisplay, type ITypedPermissionRequest } from '../../node/copilot/copilotToolDisplay.js'; +import { getInvocationMessage, getPastTenseMessage, getPermissionDisplay, getShellLanguage, getToolInputString, getToolKind, type ITypedPermissionRequest } from '../../node/copilot/copilotToolDisplay.js'; suite('getPermissionDisplay — cd-prefix stripping', () => { @@ -124,3 +124,172 @@ suite('view tool — view_range display', () => { assert.ok(!invocation({ path: '/repo/file.ts', view_range: 'whatever' }).includes(',')); }); }); + +// ---- write_/read_ shell tool display --------------------------------------- +// +// Coverage for the secondary shell helpers (write_bash, read_bash, and their +// powershell siblings). These never appear in a permission dialog (they're +// registered with `skipPermission: true` — see copilotShellTools.ts), but they +// still flow through the tool-execution display pipeline. + +suite('copilotToolDisplay — write_/read_ shell tools', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + suite('getToolKind', () => { + + test('returns terminal for bash', () => { + assert.strictEqual(getToolKind('bash'), 'terminal'); + }); + + test('returns terminal for powershell', () => { + assert.strictEqual(getToolKind('powershell'), 'terminal'); + }); + + test('returns undefined for write_bash (sending input to a running program, not launching a terminal)', () => { + assert.strictEqual(getToolKind('write_bash'), undefined); + }); + + test('returns undefined for write_powershell', () => { + assert.strictEqual(getToolKind('write_powershell'), undefined); + }); + + test('returns undefined for read_bash (reading output, not launching a terminal)', () => { + assert.strictEqual(getToolKind('read_bash'), undefined); + }); + + test('returns undefined for read_powershell', () => { + assert.strictEqual(getToolKind('read_powershell'), undefined); + }); + + test('returns subagent for task', () => { + assert.strictEqual(getToolKind('task'), 'subagent'); + }); + + test('returns undefined for view', () => { + assert.strictEqual(getToolKind('view'), undefined); + }); + }); + + suite('getShellLanguage', () => { + + test('bash returns shellscript', () => { + assert.strictEqual(getShellLanguage('bash'), 'shellscript'); + }); + + test('powershell returns powershell', () => { + assert.strictEqual(getShellLanguage('powershell'), 'powershell'); + }); + + test('write_bash returns shellscript', () => { + assert.strictEqual(getShellLanguage('write_bash'), 'shellscript'); + }); + + test('write_powershell returns powershell', () => { + assert.strictEqual(getShellLanguage('write_powershell'), 'powershell'); + }); + + test('read_bash returns shellscript', () => { + assert.strictEqual(getShellLanguage('read_bash'), 'shellscript'); + }); + + test('read_powershell returns powershell', () => { + assert.strictEqual(getShellLanguage('read_powershell'), 'powershell'); + }); + }); + + suite('getInvocationMessage', () => { + + function getText(msg: ReturnType): string { + return typeof msg === 'string' ? msg : msg.markdown; + } + + test('write_bash with command includes the command text', () => { + const msg = getInvocationMessage('write_bash', 'Write Shell Input', { command: 'echo hello' }); + assert.ok(getText(msg).includes('echo hello'), `expected 'echo hello' in: ${getText(msg)}`); + }); + + test('write_bash without command returns a non-empty fallback message', () => { + const msg = getInvocationMessage('write_bash', 'Write Shell Input', undefined); + assert.ok(getText(msg).length > 0); + assert.ok(!getText(msg).includes('undefined')); + }); + + test('write_powershell with command includes the command text', () => { + const msg = getInvocationMessage('write_powershell', 'Write Shell Input', { command: 'Get-Date' }); + assert.ok(getText(msg).includes('Get-Date'), `expected 'Get-Date' in: ${getText(msg)}`); + }); + + test('read_bash returns a non-empty message', () => { + const msg = getInvocationMessage('read_bash', 'Read Shell Output', undefined); + assert.ok(getText(msg).length > 0); + }); + + test('read_powershell returns a non-empty message', () => { + const msg = getInvocationMessage('read_powershell', 'Read Shell Output', undefined); + assert.ok(getText(msg).length > 0); + }); + + test('write_bash message differs from bash message (distinct wording)', () => { + const writeBashMsg = getText(getInvocationMessage('write_bash', 'Write Shell Input', { command: 'echo hi' })); + const bashMsg = getText(getInvocationMessage('bash', 'Bash', { command: 'echo hi' })); + // Both include the command, but the surrounding text should differ + assert.notStrictEqual(writeBashMsg, bashMsg); + }); + }); + + suite('getPastTenseMessage', () => { + + function getText(msg: ReturnType): string { + return typeof msg === 'string' ? msg : msg.markdown; + } + + test('write_bash with command includes the command text', () => { + const msg = getPastTenseMessage('write_bash', 'Write Shell Input', { command: 'echo hello' }, true); + assert.ok(getText(msg).includes('echo hello'), `expected 'echo hello' in: ${getText(msg)}`); + }); + + test('write_bash without command returns a non-empty fallback message', () => { + const msg = getPastTenseMessage('write_bash', 'Write Shell Input', undefined, true); + assert.ok(getText(msg).length > 0); + }); + + test('write_powershell with command includes the command text', () => { + const msg = getPastTenseMessage('write_powershell', 'Write Shell Input', { command: 'Get-Date' }, true); + assert.ok(getText(msg).includes('Get-Date'), `expected 'Get-Date' in: ${getText(msg)}`); + }); + + test('read_bash success returns a non-empty message', () => { + const msg = getPastTenseMessage('read_bash', 'Read Shell Output', undefined, true); + assert.ok(getText(msg).length > 0); + }); + + test('write_bash failure returns a non-empty error message', () => { + const msg = getPastTenseMessage('write_bash', 'Write Shell Input', { command: 'echo hello' }, false); + assert.ok(getText(msg).length > 0); + }); + }); + + suite('getToolInputString', () => { + + test('write_bash extracts command field', () => { + assert.strictEqual(getToolInputString('write_bash', { command: 'echo hello' }, undefined), 'echo hello'); + }); + + test('write_powershell extracts command field', () => { + assert.strictEqual(getToolInputString('write_powershell', { command: 'Get-Date' }, undefined), 'Get-Date'); + }); + + test('write_bash falls back to rawArguments when no command field', () => { + assert.strictEqual(getToolInputString('write_bash', {}, '{"command":"echo hello"}'), '{"command":"echo hello"}'); + }); + + test('write_bash returns undefined when both parameters and rawArguments are absent', () => { + assert.strictEqual(getToolInputString('write_bash', undefined, undefined), undefined); + }); + + test('read_bash with no parameters returns undefined', () => { + assert.strictEqual(getToolInputString('read_bash', undefined, undefined), undefined); + }); + }); +}); 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 abf7cc2ed1889..f062eb973f5f6 100644 --- a/src/vs/platform/agentHost/test/node/protocol/toolApprovalRealSdk.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/protocol/toolApprovalRealSdk.integrationTest.ts @@ -253,6 +253,149 @@ function terminalText(state: TerminalState): string { return removeAnsiEscapeCodes(state.content.map(part => part.type === 'command' ? `${part.commandLine}\n${part.output}` : part.value).join('')); } +/** Looks up the toolName for a toolCallReady by joining against the matching toolCallStart. */ +function findToolNameForCall(c: TestProtocolClient, toolCallId: string): string | undefined { + return c.receivedNotifications(n => isActionNotification(n, 'session/toolCallStart')) + .map(n => getActionEnvelope(n).action as SessionToolCallStartAction) + .find(a => a.toolCallId === toolCallId)?.toolName; +} + +interface IApprovalRule { + /** Tool name this rule applies to (e.g. `'bash'`, `'write_bash'`). */ + toolName: string; + /** Optional predicate over the tool input. If omitted, any input matches. */ + matchInput?: (toolInput: string | undefined) => boolean; + /** + * Optional inspector run for every matched call before approval. + * Push assertion failure messages onto `errors` to fail the test. + */ + inspect?: (info: { + action: SessionToolCallReadyAction; + errors: string[]; + }) => void; +} + +interface IBackgroundApprovalLoopOptions { + /** Starting clientSeq for dispatched toolCallConfirmed actions. Avoids collisions with the test's own dispatches. */ + approvalSeqStart: number; + /** + * Allow-list of tool calls the loop is permitted to auto-approve. Each + * pending confirmation must match exactly one rule (by `toolName` plus + * optional `matchInput` predicate). Calls that don't match are recorded + * as errors and denied — the loop refuses to rubber-stamp anything the + * test didn't anticipate (e.g. an unexpected `rm` from the model). + */ + allow: readonly IApprovalRule[]; +} + +interface IBackgroundApprovalLoop { + /** Errors collected during the run (unmatched tool calls + inspector failures). */ + readonly errors: readonly string[]; + /** Tool names that were observed and approved at least once. */ + readonly approvedToolNames: ReadonlySet; + /** + * Tool names for every permission request observed by the loop, regardless + * of whether they matched the allow-list. Useful for asserting that a + * tool with `skipPermission: true` never triggered a permission flow. + */ + readonly observedToolNames: ReadonlySet; + /** Stops the loop and waits for it to drain. */ + stop(): Promise; +} + +/** + * Starts a background loop that auto-approves pending tool call confirmations + * during a real-SDK turn, but only if they match the supplied allow-list. + * Anything outside the allow-list is denied and recorded as an error so the + * test fails loudly instead of silently approving model-chosen tool calls. + * + * Implementation note: `waitForNotification` does NOT consume notifications from + * the client's queue, so we dedupe by `serverSeq`. + */ +function startBackgroundApprovalLoop(c: TestProtocolClient, options: IBackgroundApprovalLoopOptions): IBackgroundApprovalLoop { + const errors: string[] = []; + const approvedToolNames = new Set(); + const observedToolNames = new Set(); + const processedSeqs = new Set(); + let active = true; + let approvalSeq = options.approvalSeqStart; + + const loop = (async () => { + while (active) { + try { + const ready = await c.waitForNotification(n => { + if (!isActionNotification(n, 'session/toolCallReady')) { + return false; + } + return !processedSeqs.has(getActionEnvelope(n).serverSeq); + }, 2_000); + const envelope = getActionEnvelope(ready); + processedSeqs.add(envelope.serverSeq); + const action = envelope.action as SessionToolCallReadyAction & { session: string; turnId: string }; + if (action.confirmed) { + continue; + } + + const toolName = findToolNameForCall(c, action.toolCallId); + if (toolName) { + observedToolNames.add(toolName); + } + const matchingRule = options.allow.find(rule => + rule.toolName === toolName + && (rule.matchInput?.(action.toolInput) ?? true)); + + if (!matchingRule) { + errors.push(`unexpected tool call: toolName=${toolName ?? ''} input=${JSON.stringify(action.toolInput)}`); + c.notify('dispatchAction', { + clientSeq: ++approvalSeq, + action: { + type: 'session/toolCallConfirmed', + session: action.session, + turnId: action.turnId, + toolCallId: action.toolCallId, + approved: false, + }, + }); + continue; + } + + matchingRule.inspect?.({ action, errors }); + approvedToolNames.add(matchingRule.toolName); + + c.notify('dispatchAction', { + clientSeq: ++approvalSeq, + action: { + type: 'session/toolCallConfirmed', + session: action.session, + turnId: action.turnId, + toolCallId: action.toolCallId, + approved: true, + }, + }); + } catch (e) { + // Only ignore the expected 2-second poll timeout. Any other error + // (e.g. 'Client closed', exception from matchingRule.inspect) is a + // real failure — record it so the test fails deterministically. + const msg = e instanceof Error ? e.message : String(e); + if (!msg.includes('Timed out') && !msg.includes('timed out')) { + errors.push(`approval loop error: ${msg}`); + active = false; + } + } + } + })(); + + return { + errors, + approvedToolNames, + observedToolNames, + async stop(): Promise { + active = false; + await loop; + }, + }; +} + (REAL_SDK_ENABLED ? suite : suite.skip)('Protocol WebSocket — Real Copilot SDK', function () { let server: IServerHandle; @@ -873,4 +1016,88 @@ function terminalText(state: TerminalState): string { } } }); + + // ---- write_bash skipPermission regression test -------------------------- + + test('write_bash never triggers a permission request (skipPermission flag)', async function () { + this.timeout(180_000); + + // What this test verifies: + // `write_bash` (and `read_bash` / `bash_shutdown` / `list_bash`) are + // registered as external tools with `skipPermission: true`, mirroring + // the SDK's built-in shell helpers which never call `permissions.request`. + // This regression test catches accidental removal of that flag — if it's + // removed, the SDK will route write_bash through our permission flow and + // the test will fail with `observedToolNames` containing 'write_bash'. + // + // How it works: + // 1. Allow-list permits ONLY `bash` (the interactive prompt). write_bash + // is intentionally absent from the allow list. + // 2. The model is instructed to use `write_bash`. If any permission + // request appears for write_bash, the loop records it in + // `observedToolNames` and we fail the assertion. + // 3. We assert that bash actually ran AND that write_bash appeared in + // toolCallStart notifications (so the test is non-vacuous — the model + // actually tried to use the tool, not just piped input via bash). + + const tempDir = mkdtempSync(`${tmpdir()}/ahp-write-bash-skip-perm-`); + tempDirs.push(tempDir); + const sessionUri = await createRealSession(client, 'real-sdk-write-bash-skip-perm', createdSessions, URI.file(tempDir).toString()); + + const approvalLoop = startBackgroundApprovalLoop(client, { + approvalSeqStart: 100, + allow: [ + { + // Setup bash command — the interactive `read` prompt. + toolName: 'bash', + matchInput: input => !!input && input.includes('read') && input.includes('Got:'), + }, + // Note: write_bash is intentionally NOT in the allow list. With + // skipPermission: true, the SDK won't ask us — so the test passes. + // Without it, the SDK would ask, the loop would deny + record an + // error, and the test would fail loudly. + ], + }); + + dispatchTurn(client, sessionUri, 'turn-write-bash-skip-perm', + 'You MUST demonstrate the `write_bash` tool. Steps, in order:\n' + + '1. Use the `bash` tool to run exactly: read -p "Enter: " v; echo "Got: $v"\n' + + ' This will block waiting for stdin.\n' + + '2. While that bash call is waiting, you MUST use the `write_bash` tool to send the input "hello\\n" to it.\n' + + ' Do NOT pipe the input via the original bash command. Do NOT use `echo hello | ...`.\n' + + ' You MUST go through the `write_bash` tool — that is the entire point of this task.\n' + + '3. After the shell prints "Got: hello", reply with the single word "done".', + 1); + + await client.waitForNotification( + n => isActionNotification(n, 'session/turnComplete') || isActionNotification(n, 'session/error'), + 150_000, + ); + await approvalLoop.stop(); + + // Sanity check: the bash setup command actually ran. Otherwise the + // model ignored the prompt and the write_bash assertion below is vacuous. + assert.ok(approvalLoop.approvedToolNames.has('bash'), + `expected the model to invoke bash for setup; observed approved tools: ${[...approvalLoop.approvedToolNames].join(', ') || ''}`); + + // Non-vacuousness check: write_bash must have actually been invoked + // (seen in a toolCallStart notification). If the model piped input via + // the original bash command instead of using write_bash, this fails. + const writeBashStarts = client.receivedNotifications(n => isActionNotification(n, 'session/toolCallStart')) + .map(n => getActionEnvelope(n).action as { toolName?: string }) + .filter(a => a.toolName === 'write_bash'); + assert.ok(writeBashStarts.length > 0, + `expected write_bash to be invoked at least once (toolCallStart), but it was never called. The model may have piped input via the original bash command instead.`); + + // The actual regression check: write_bash must never reach our + // permission handler. If this fails, `skipPermission: true` was likely + // removed from copilotShellTools.ts. + assert.ok(!approvalLoop.observedToolNames.has('write_bash'), + `write_bash should be auto-approved by the SDK (skipPermission: true) and never trigger a permission request, but the test observed one. Observed permission requests: ${[...approvalLoop.observedToolNames].join(', ')}`); + + // Any other unexpected permission requests (e.g. an unrelated tool the + // model decided to use) would also have been recorded as errors. + assert.deepStrictEqual(approvalLoop.errors, [], + `unexpected approval-loop errors: ${approvalLoop.errors.join('; ')}`); + }); }); From 7734175f84f53984f8cae3ce6131711e882c7a05 Mon Sep 17 00:00:00 2001 From: Paul Date: Thu, 23 Apr 2026 11:15:59 -0700 Subject: [PATCH 44/70] Fix invalid json files detected for claude hook paths (#312016) --- .../pickers/askForPromptSourceFolder.ts | 4 +- .../config/promptFileLocations.ts | 2 +- .../promptSyntax/utils/promptFilesLocator.ts | 66 +++++++++---------- .../utils/promptFilesLocator.test.ts | 61 +++++++++++++++++ 4 files changed, 96 insertions(+), 37 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/askForPromptSourceFolder.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/askForPromptSourceFolder.ts index dc664a1ffb0b6..ea8724e0e58b8 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/askForPromptSourceFolder.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/askForPromptSourceFolder.ts @@ -60,8 +60,8 @@ export async function askForPromptSourceFolder( // create list of source folder locations const foldersList = resolvedFolders.map(resolved => { - const folderUri = resolved.parent; - const isDefault = defaultFolder && isEqual(folderUri, defaultFolder.parent); + const folderUri = resolved.searchRoot; + const isDefault = defaultFolder && isEqual(folderUri, defaultFolder.searchRoot); const sourceDescription = getSourceDescription(resolved.source); const detail = (existingFolder && isEqual(folderUri, existingFolder)) ? localize('current.folder', "Current Location") : undefined; diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts index 8a7e9b3012c90..0fa39870f848b 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts @@ -128,7 +128,7 @@ export interface IPromptSourceFolder { */ export interface IResolvedPromptSourceFolder { readonly uri: URI; - readonly parent: URI; // matches the URI when no glob pattern is used + readonly searchRoot: URI; // matches the URI when no glob pattern is used readonly filePattern: string | undefined; // the part of the path with the glob pattern, or undefined if no glob pattern is used readonly source: PromptFolderSource; readonly storage: PromptsStorage.local | PromptsStorage.user; 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 1b9e2005c6867..ed80727c467e8 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts @@ -9,7 +9,7 @@ import { ResourceSet } from '../../../../../../base/common/map.js'; import * as nls from '../../../../../../nls.js'; import { FileOperationError, FileOperationResult, IFileService } from '../../../../../../platform/files/common/files.js'; import { getPromptFileLocationsConfigKey, isTildePath, PromptsConfig } from '../config/config.js'; -import { basename, dirname, isEqual, isEqualOrParent, joinPath, extname } from '../../../../../../base/common/resources.js'; +import { basename, dirname, isEqual, isEqualOrParent, joinPath } from '../../../../../../base/common/resources.js'; import { IWorkspaceContextService, IWorkspaceFolder } from '../../../../../../platform/workspace/common/workspace.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { AGENTS_SOURCE_FOLDER, getPromptFileExtension, getPromptFileType, LEGACY_MODE_FILE_EXTENSION, getCleanPromptName, AGENT_FILE_EXTENSION, getPromptFileDefaultLocations, SKILL_FILENAME, IPromptSourceFolder, IResolvedPromptSourceFolder } from '../config/promptFileLocations.js'; @@ -61,7 +61,7 @@ export class PromptFilesLocator { const userDataPromptsHome = this.userDataService.currentProfile.promptsHome; this.userDataFolder = { uri: userDataPromptsHome, - parent: userDataPromptsHome, + searchRoot: userDataPromptsHome, filePattern: undefined, source: PromptFileSource.UserData, storage: PromptsStorage.user, @@ -183,10 +183,10 @@ export class PromptFilesLocator { const paths = new ResourceSet(); - for (const { parent, filePattern } of absoluteLocations) { + for (const { searchRoot, filePattern } of absoluteLocations) { const files = (filePattern === undefined) - ? await this.resolveFilesAtLocation(parent, type, token) // if the location does not contain a glob pattern, resolve the location directly - : await this.searchFilesInLocation(parent, filePattern, token); + ? await this.resolveFilesAtLocation(searchRoot, type, token) // if the location does not contain a glob pattern, resolve the location directly + : await this.searchFilesInLocation(searchRoot, filePattern, token); for (const file of files) { if (getPromptFileType(file) === type) { paths.add(file); @@ -213,10 +213,10 @@ export class PromptFilesLocator { const updateExternalFolderWatchers = () => { externalFolderWatchers.clear(); for (const folder of parentFolders) { - if (!this.getWorkspaceFolder(folder.parent)) { + if (!this.getWorkspaceFolder(folder.searchRoot)) { // if the folder is not part of the workspace, we need to watch it const recursive = folder.filePattern !== undefined || type === PromptsType.instructions; // instructions can be in subfolders, so watch recursively - externalFolderWatchers.add(this.fileService.watch(folder.parent, { recursive, excludes: [] })); + externalFolderWatchers.add(this.fileService.watch(folder.searchRoot, { recursive, excludes: [] })); } } }; @@ -253,7 +253,7 @@ export class PromptFilesLocator { eventEmitter.fire(); return; } - if (parentFolders.some(folder => e.affects(folder.parent))) { + if (parentFolders.some(folder => e.affects(folder.searchRoot))) { eventEmitter.fire(); return; } @@ -281,16 +281,16 @@ export class PromptFilesLocator { // Convert to absolute locations with metadata const absoluteLocations = await this.toAbsoluteLocations(PromptsType.hook, allowedHookFolders); - // Deduplicate by parent URI, keeping the first occurrence + // Deduplicate by search root, keeping the first occurrence const seen = new ResourceSet(); const result: IResolvedPromptSourceFolder[] = []; for (const location of absoluteLocations) { // For hook configs, entries are directories unless the path ends with .json (specific file) // Default entries have filePattern, user entries don't but are still directories - // location.parent points to the directory in both cases, so we can just use that - if (!seen.has(location.parent)) { - seen.add(location.parent); - result.push({ ...location, uri: location.parent, filePattern: undefined }); + // searchRoot already points to the correct directory or specific file to use in both cases + if (!seen.has(location.searchRoot)) { + seen.add(location.searchRoot); + result.push({ ...location, uri: location.searchRoot, filePattern: undefined }); } } @@ -470,8 +470,8 @@ export class PromptFilesLocator { const uri = joinPath(userHome, configuredLocation.substring(2)); if (!seen.has(uri)) { seen.add(uri); - const { parent, filePattern } = getParentFolder(type, uri); - result.push({ uri, parent, filePattern, source: sourceFolder.source, storage: sourceFolder.storage, displayPath: configuredLocation, isDefault }); + const { searchRoot, filePattern } = resolveSearchLocation(type, uri); + result.push({ uri, searchRoot: searchRoot, filePattern, source: sourceFolder.source, storage: sourceFolder.storage, displayPath: configuredLocation, isDefault }); } continue; } @@ -486,16 +486,16 @@ export class PromptFilesLocator { } if (!seen.has(uri)) { seen.add(uri); - const { parent, filePattern } = getParentFolder(type, uri); - result.push({ uri, parent, filePattern, source: sourceFolder.source, storage: sourceFolder.storage, displayPath: configuredLocation, isDefault }); + const { searchRoot, filePattern } = resolveSearchLocation(type, uri); + result.push({ uri, searchRoot: searchRoot, filePattern, source: sourceFolder.source, storage: sourceFolder.storage, displayPath: configuredLocation, isDefault }); } } else { for (const folder of rootFolders) { const absolutePath = joinPath(folder, configuredLocation); if (!seen.has(absolutePath)) { seen.add(absolutePath); - const { parent, filePattern } = getParentFolder(type, absolutePath); - result.push({ uri: absolutePath, parent, filePattern, source: sourceFolder.source, storage: sourceFolder.storage, displayPath: configuredLocation, isDefault }); + const { searchRoot, filePattern } = resolveSearchLocation(type, absolutePath); + result.push({ uri: absolutePath, searchRoot: searchRoot, filePattern, source: sourceFolder.source, storage: sourceFolder.storage, displayPath: configuredLocation, isDefault }); } } } @@ -852,33 +852,31 @@ export function isValidGlob(pattern: string): boolean { return false; } -interface IParentFolderResult { - readonly parent: URI; +interface ISearchLocationResult { + readonly searchRoot: URI; readonly filePattern?: string; } /** - * Finds the first parent of the provided location that does not contain a `glob pattern`. + * Resolves the search root and optional file pattern for the provided location. + * For paths with glob patterns, finds the deepest non-glob ancestor directory. * - * Asumes that the location that is provided has a valid path (is abstract) + * Assumes that the location that is provided has a valid path (is abstract) * * ## Examples * * ```typescript * assert.strictDeepEqual( - * getParentFolder(PromptsType.prompt, URI.file('/home/user/{folder1,folder2}/file.md')), - * { parent: URI.file('/home/user'), filePattern: '{folder1,folder2}/file.md' }, - * 'Must find correct non-glob parent dirname.', + * resolveSearchLocation(PromptsType.prompt, URI.file('/home/user/{folder1,folder2}/file.md')), + * { searchRoot: URI.file('/home/user'), filePattern: '{folder1,folder2}/file.md' }, + * 'Must find correct non-glob search root.', * ); * ``` */ -function getParentFolder(type: PromptsType, location: URI): IParentFolderResult { - if (type === PromptsType.hook && extname(location) === '.json') { - location = dirname(location); - } +function resolveSearchLocation(type: PromptsType, location: URI): ISearchLocationResult { if (type !== PromptsType.instructions && type !== PromptsType.prompt) { // only instructions and prompts support glob patterns, so we can return the location as is - return { parent: location }; + return { searchRoot: location }; } const segments = location.path.split('/'); @@ -889,16 +887,16 @@ function getParentFolder(type: PromptsType, location: URI): IParentFolderResult if (i === segments.length) { // the path does not contain a glob pattern, so we can // just find all prompt files in the provided location - return { parent: location }; + return { searchRoot: location }; } const parent = location.with({ path: segments.slice(0, i).join('/') }); if (i === segments.length - 1 && segments[i] === '*' || segments[i] === ``) { - return { parent }; + return { searchRoot: parent }; } // the path contains a glob pattern, so we search in last folder that does not contain a glob pattern return { - parent, + searchRoot: parent, filePattern: segments.slice(i).join('/') }; } 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 deb5e1dc13916..ad91e945248d4 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 @@ -2871,6 +2871,67 @@ suite('PromptFilesLocator', () => { }); }); + suite('listFiles with PromptsType.hook', () => { + testT('only returns targeted json files, not sibling json files', async () => { + configValues[PromptsConfig.HOOKS_LOCATION_KEY] = { + '.claude/settings.json': true, + '.claude/settings.local.json': true, + '~/.claude/settings.json': true, + '.github/hooks': true, + '~/.copilot/hooks': true, + }; + setWorkspaceFolders(['/Users/legomushroom/repos/vscode']); + await mockFiles(fileService, [ + // targeted files that should be found + { path: '/Users/legomushroom/repos/vscode/.claude/settings.json', contents: ['{}'] }, + { path: '/Users/legomushroom/repos/vscode/.claude/settings.local.json', contents: ['{}'] }, + // sibling files in .claude/ that should NOT be found + { path: '/Users/legomushroom/repos/vscode/.claude/config.json', contents: ['{}'] }, + { path: '/Users/legomushroom/repos/vscode/.claude/stats-cache.json', contents: ['{}'] }, + // hook directory files that should be found + { path: '/Users/legomushroom/repos/vscode/.github/hooks/pre-commit.json', contents: ['{}'] }, + ]); + const locator = instantiationService.createInstance(PromptFilesLocator); + + const files = await locator.listFiles(PromptsType.hook, PromptsStorage.local, CancellationToken.None); + assert.deepStrictEqual( + files.map(f => f.path).sort(), + [ + '/Users/legomushroom/repos/vscode/.claude/settings.json', + '/Users/legomushroom/repos/vscode/.claude/settings.local.json', + '/Users/legomushroom/repos/vscode/.github/hooks/pre-commit.json', + ], + ); + }); + + testT('returns hook files from user home specific json paths', async () => { + configValues[PromptsConfig.HOOKS_LOCATION_KEY] = { + '~/.claude/settings.json': true, + '~/.copilot/hooks': true, + }; + setWorkspaceFolders(['/Users/legomushroom/repos/vscode']); + await mockFiles(fileService, [ + // targeted user file + { path: '/Users/legomushroom/.claude/settings.json', contents: ['{}'] }, + // sibling files that should NOT be found + { path: '/Users/legomushroom/.claude/config.json', contents: ['{}'] }, + { path: '/Users/legomushroom/.claude/stats-cache.json', contents: ['{}'] }, + // hook directory files + { path: '/Users/legomushroom/.copilot/hooks/my-hook.json', contents: ['{}'] }, + ]); + const locator = instantiationService.createInstance(PromptFilesLocator); + + const files = await locator.listFiles(PromptsType.hook, PromptsStorage.user, CancellationToken.None); + assert.deepStrictEqual( + files.map(f => f.path).sort(), + [ + '/Users/legomushroom/.claude/settings.json', + '/Users/legomushroom/.copilot/hooks/my-hook.json', + ], + ); + }); + }); + suite('getSourceDescription', () => { test('returns descriptions for all known folder sources', () => { const folderSources: PromptFileSource[] = [ From 1acbb74717d9338e3467b759e7e96342079cf167 Mon Sep 17 00:00:00 2001 From: Andrew Chan Date: Thu, 23 Apr 2026 11:40:09 -0700 Subject: [PATCH 45/70] aux window - fix setTimeout leak (#311824) --- src/vs/workbench/browser/window.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/vs/workbench/browser/window.ts b/src/vs/workbench/browser/window.ts index bff3f2cf1b0e9..09cddff295647 100644 --- a/src/vs/workbench/browser/window.ts +++ b/src/vs/workbench/browser/window.ts @@ -154,6 +154,8 @@ export abstract class BaseWindow extends Disposable { didClear = true; (window as { vscodeOriginalClearTimeout?: typeof window.clearTimeout }).vscodeOriginalClearTimeout?.apply(this, [handle]); timeoutDisposables.delete(timeoutDisposable); + // Remove from the window's DisposableStore without re-disposing (we're already inside dispose) + disposables.deleteAndLeak(timeoutDisposable); }); disposables.add(timeoutDisposable); From 72409b7d2d97f5d0403abb2d84199923dc18bfca Mon Sep 17 00:00:00 2001 From: Raymond Zhao <7199958+rzhao271@users.noreply.github.com> Date: Thu, 23 Apr 2026 11:44:55 -0700 Subject: [PATCH 46/70] fix: do not create GH releases for Copilot (#312192) --- build/azure-pipelines/product-copilot-recovery.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/build/azure-pipelines/product-copilot-recovery.yml b/build/azure-pipelines/product-copilot-recovery.yml index 056bdedaed7b3..8b120eb1d604e 100644 --- a/build/azure-pipelines/product-copilot-recovery.yml +++ b/build/azure-pipelines/product-copilot-recovery.yml @@ -70,5 +70,6 @@ extends: generateNotice: true publishExtension: ${{ parameters.publishExtension }} - ghReleasePublishVSIX: true + ghCreateTag: ${{ parameters.publishExtension }} ghTagPrefix: 'copilot/' + ghCreateRelease: false From ca67f6613f03fd051721a99971cc777eae4d8e1f Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Fri, 24 Apr 2026 04:51:27 +1000 Subject: [PATCH 47/70] chat: improve model matching logic by ensuring at least two models are loaded (#312188) * chat: improve model matching logic by ensuring at least two models are loaded Co-authored-by: Copilot * fix(chat): enhance model loading checks to prevent inaccurate matching Co-authored-by: Copilot --------- Co-authored-by: Copilot --- .../contrib/chat/browser/widget/input/chatInputPart.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index 61885a725c0b4..763d5df58a5a8 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -1259,7 +1259,15 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } const tryMatch = () => { + // wait for at least 2 models to load, + // Otherwise matching is not useful and may be inaccurate due to the fact that we have auto const models = this.getModels(); + if (models.length === 0) { + return; + } + if (models.length === 1 && models[0].metadata.id.toLocaleLowerCase() === 'auto') { + return; + } // Try exact identifier match first (e.g. "copilot/gpt-4o") let match = models.find(m => m.identifier === lastModelId); if (!match) { From a46152f2a076b78757140a209098519af734c7d9 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Thu, 23 Apr 2026 11:56:31 -0700 Subject: [PATCH 48/70] plugins: deduplicate canonical command ID when skill name matches plugin name (#312196) When a plugin's skill name matches the plugin name, the canonical command ID was producing a redundant format like `plugin-name:plugin-name`. This change collapses that to just `plugin-name` so users can invoke `/plugin-name` instead of `/plugin-name:plugin-name`. - Adds a check in getCanonicalPluginCommandId to return just the prefix when it equals the normalized command name (Commit message generated by Copilot) --- .../contrib/chat/common/plugins/agentPluginService.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts b/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts index bc52f6a253269..35b78139444a9 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts @@ -66,6 +66,13 @@ export function getCanonicalPluginCommandId(plugin: { readonly uri: URI }, comma return normalizedCommand; } + // When the skill name matches the plugin name, use just the plugin + // name so the user can invoke `/plugin-name` instead of the redundant + // `/plugin-name:plugin-name`. + if (prefix === normalizedCommand) { + return prefix; + } + return `${prefix}:${normalizedCommand}`; } From f3b2321e2ce940f677f8f9f4b88d2ff070c9b6b0 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Thu, 23 Apr 2026 11:56:02 -0700 Subject: [PATCH 49/70] sessions: fix missing remote toggle for local agent host, duplicate permissions setting --- .../tunnelHost/electron-browser/tunnelHost.contribution.ts | 2 +- .../contrib/chat/browser/actions/chatExecuteActions.ts | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/vs/sessions/contrib/tunnelHost/electron-browser/tunnelHost.contribution.ts b/src/vs/sessions/contrib/tunnelHost/electron-browser/tunnelHost.contribution.ts index d1500e1379af8..b001326bfefb1 100644 --- a/src/vs/sessions/contrib/tunnelHost/electron-browser/tunnelHost.contribution.ts +++ b/src/vs/sessions/contrib/tunnelHost/electron-browser/tunnelHost.contribution.ts @@ -84,7 +84,7 @@ registerAction2(class ToggleRemoteConnectionsAction extends Action2 { ChatContextKeys.enabled, ChatContextKeys.location.isEqualTo(ChatAgentLocation.Chat), ChatContextKeys.inQuickChat.negate(), - ChatContextKeys.lockedCodingAgentId.isEqualTo(AgentSessionProviders.AgentHostCopilot), + ContextKeyExpr.regex(ChatContextKeys.lockedCodingAgentId.key, /^agent-host-/), ) } }); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts index 765f80e7299f9..0c3d168f16829 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts @@ -457,7 +457,11 @@ export class OpenPermissionPickerAction extends Action2 { ChatContextKeys.location.isEqualTo(ChatAgentLocation.Chat), ChatContextKeys.chatModeKind.notEqualsTo(ChatModeKind.Ask), ChatContextKeys.inQuickChat.negate(), - ChatContextKeys.lockedCodingAgentId.notEqualsTo(AgentSessionProviders.Cloud), + ContextKeyExpr.or( + ChatContextKeys.lockedToCodingAgent.negate(), + ChatContextKeys.lockedCodingAgentId.isEqualTo(AgentSessionProviders.Background), + ChatContextKeys.lockedCodingAgentId.isEqualTo(AgentSessionProviders.Claude), + ), ) } }); From d50009c281163672e16b59877a772a572da92f45 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Thu, 23 Apr 2026 15:02:05 -0400 Subject: [PATCH 50/70] Mandate space after colon for prompt regex to prevent false positives (#312180) fixes #312148 --- .../browser/tools/monitoring/outputMonitor.ts | 25 ++++++++++++------- .../test/browser/outputMonitor.test.ts | 18 +++++++++++-- 2 files changed, 32 insertions(+), 11 deletions(-) diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts index 6b1f790bd7434..83981f28dcca5 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts @@ -488,18 +488,25 @@ export function detectsInputRequiredPattern(cursorLine: string): boolean { // Same as above but allows a preceding '?' or ':' and optional wrappers e.g. // "Continue? (y/n)" or "Overwrite: [yes/no]" /[?:]\s*(?:\(|\[)?\s*y(?:es)?\s*\/\s*n(?:o)?\s*(?:\]|\))?\s+$/i, - // Confirmation prompts ending with (y) e.g. "Ok to proceed? (y)" - /\(y\)\s*$/i, - // Line ends with ':' - /:\s*$/, + // Confirmation prompts ending with (y) followed by trailing space, e.g. "Ok to proceed? (y) " + // The trailing space indicates the cursor is positioned after the prompt awaiting input, as + // opposed to normal command output that happens to contain "(y)" followed by a newline. + /\(y\) +$/i, + // Line ends with ':' followed by at least one space. The trailing space indicates a + // waiting prompt (cursor positioned after the colon). A bare ':\n' at end of buffer is + // usually non-prompt output (e.g. a header or log line) and must not match. + /: +$/, // Prompt with parenthesized default value e.g. "package name: (test) " or "version: (1.0.0) " - /:\s*\([^)]*\)\s*$/, + /:\s*\([^)]*\) +$/, // Line contains (END) which is common in pagers /\(END\)$/, - // Password prompt - /password[:]?$/i, - // Line ends with '?' - /\?\s*(?:\([a-z\s]+\))?$/i, + // Password prompt (must be followed by optional colon and trailing space to indicate + // an active prompt; otherwise normal output containing the word "password" would match). + /password:? +$/i, + // Line ends with '?' followed by at least one space (optionally followed by a + // parenthesized hint like "Continue? (yes/no) "). Requiring trailing space avoids + // matching arbitrary command output where a line happens to end with '?'. + /\? *(?:\([a-z\s]+\))? +$/i, // "Press a key" or "Press any key" /press a(?:ny)? key/i, ].some(e => e.test(cursorLine)); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/outputMonitor.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/outputMonitor.test.ts index 0430a4d4d1111..4e712f6db7dc4 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/outputMonitor.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/outputMonitor.test.ts @@ -284,6 +284,13 @@ suite('OutputMonitor', () => { assert.strictEqual(detectsInputRequiredPattern('Enter your name: '), true); assert.strictEqual(detectsInputRequiredPattern('Password: '), true); assert.strictEqual(detectsInputRequiredPattern('File to overwrite: '), true); + + // Non-prompts: a trailing colon without a following space is typical of normal + // command output (headers, log lines ending with ':' before a newline) and must + // not be treated as an input prompt. + assert.strictEqual(detectsInputRequiredPattern('Running tests:'), false); + assert.strictEqual(detectsInputRequiredPattern('Results:\n'), false); + assert.strictEqual(detectsInputRequiredPattern('Summary:'), false); }); test('detects prompts with parenthesized default values', () => { @@ -294,9 +301,16 @@ suite('OutputMonitor', () => { }); test('detects trailing questions', () => { - assert.strictEqual(detectsInputRequiredPattern('Continue?'), true); + assert.strictEqual(detectsInputRequiredPattern('Continue? '), true); assert.strictEqual(detectsInputRequiredPattern('Proceed? '), true); - assert.strictEqual(detectsInputRequiredPattern('Are you sure?'), true); + assert.strictEqual(detectsInputRequiredPattern('Are you sure? '), true); + + // Non-prompts: a trailing '?' without a following space is typical of + // normal command output (log lines, error messages) and must not be + // treated as an input prompt. + assert.strictEqual(detectsInputRequiredPattern('Continue?'), false); + assert.strictEqual(detectsInputRequiredPattern('Are you sure?\n'), false); + assert.strictEqual(detectsInputRequiredPattern('What happened?'), false); }); test('detects press any key prompts', () => { From f7d03d515ba90801021c335a536f6c7570003482 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Thu, 23 Apr 2026 12:03:01 -0700 Subject: [PATCH 51/70] fix: remove unused AgentSessionProviders import The import was declared but never used in the file, causing a TS6133 compilation error. --- .../tunnelHost/electron-browser/tunnelHost.contribution.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/vs/sessions/contrib/tunnelHost/electron-browser/tunnelHost.contribution.ts b/src/vs/sessions/contrib/tunnelHost/electron-browser/tunnelHost.contribution.ts index b001326bfefb1..69fbe51444247 100644 --- a/src/vs/sessions/contrib/tunnelHost/electron-browser/tunnelHost.contribution.ts +++ b/src/vs/sessions/contrib/tunnelHost/electron-browser/tunnelHost.contribution.ts @@ -16,7 +16,6 @@ import { INotificationService, Severity } from '../../../../platform/notificatio import { Registry } from '../../../../platform/registry/common/platform.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { IOutputService } from '../../../../workbench/services/output/common/output.js'; -import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; import { ChatAgentLocation } from '../../../../workbench/contrib/chat/common/constants.js'; import { ITunnelHostService } from '../common/tunnelHost.js'; From 6516a20d4382235ec2bb5cadcebf9b4973399362 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Thu, 23 Apr 2026 21:25:28 +0200 Subject: [PATCH 52/70] add tests (#312200) Co-authored-by: Copilot --- src/vs/platform/secrets/common/secrets.ts | 11 +++- .../secrets/test/common/secrets.test.ts | 62 ++++++++++++++++++- 2 files changed, 69 insertions(+), 4 deletions(-) diff --git a/src/vs/platform/secrets/common/secrets.ts b/src/vs/platform/secrets/common/secrets.ts index d73ad20bdd57c..1840cdd6b284d 100644 --- a/src/vs/platform/secrets/common/secrets.ts +++ b/src/vs/platform/secrets/common/secrets.ts @@ -119,6 +119,10 @@ export class BaseSecretStorageService extends Disposable implements ISecretStora super(); } + protected useSharedStorage(key: string): boolean { + return isWindows && CROSS_APP_SHARED_SECRET_KEYS.includes(key); + } + /** * @Note initialize must be called first so that this can be resolved properly * otherwise it will return 'unknown'. @@ -178,7 +182,8 @@ export class BaseSecretStorageService extends Disposable implements ISecretStora const fullKey = secretStorageKey(key); this._logService.trace('[secrets] deleting secret for key:', fullKey); - storageService.remove(fullKey, StorageScope.APPLICATION); + const scope = this.useSharedStorage(key) ? StorageScope.APPLICATION_SHARED : StorageScope.APPLICATION; + storageService.remove(fullKey, scope); this._logService.trace('[secrets] deleted secret for key:', fullKey); }); } @@ -194,7 +199,7 @@ export class BaseSecretStorageService extends Disposable implements ISecretStora } private getValueFromStorage(key: string, fullKey: string, storageService: IStorageService): string | undefined { - if (isWindows && CROSS_APP_SHARED_SECRET_KEYS.includes(key)) { + if (this.useSharedStorage(key)) { this._logService.trace(`[SecretStorageService] Fetching value for cross-app shared secret: ${fullKey}`); return storageService.get(fullKey, StorageScope.APPLICATION_SHARED); } @@ -202,7 +207,7 @@ export class BaseSecretStorageService extends Disposable implements ISecretStora } private setValueInStorage(key: string, fullKey: string, value: string, storageService: IStorageService): void { - if (isWindows && CROSS_APP_SHARED_SECRET_KEYS.includes(key)) { + if (this.useSharedStorage(key)) { this._logService.trace(`[SecretStorageService] Setting value for cross-app shared secret: ${fullKey}`); storageService.store(fullKey, value, StorageScope.APPLICATION_SHARED, StorageTarget.MACHINE); return; diff --git a/src/vs/platform/secrets/test/common/secrets.test.ts b/src/vs/platform/secrets/test/common/secrets.test.ts index e1603df791f36..ba8feee0f46fe 100644 --- a/src/vs/platform/secrets/test/common/secrets.test.ts +++ b/src/vs/platform/secrets/test/common/secrets.test.ts @@ -8,7 +8,7 @@ import * as sinon from 'sinon'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { IEncryptionService, KnownStorageProvider } from '../../../encryption/common/encryptionService.js'; import { NullLogService } from '../../../log/common/log.js'; -import { BaseSecretStorageService } from '../../common/secrets.js'; +import { BaseSecretStorageService, CROSS_APP_SHARED_SECRET_KEYS } from '../../common/secrets.js'; import { InMemoryStorageService } from '../../../storage/common/storage.js'; class TestEncryptionService implements IEncryptionService { @@ -217,4 +217,64 @@ suite('secrets', () => { assert.strictEqual(spyNoEncryptionService.decrypt.callCount, 0); }); }); + + suite('BaseSecretStorageService cross-app shared secrets', () => { + + class TestSharedSecretStorageService extends BaseSecretStorageService { + protected override useSharedStorage(key: string): boolean { + return CROSS_APP_SHARED_SECRET_KEYS.includes(key); + } + } + + let service: BaseSecretStorageService; + let storageService: InMemoryStorageService; + let sandbox: sinon.SinonSandbox; + + setup(() => { + sandbox = sinon.createSandbox(); + storageService = store.add(new InMemoryStorageService()); + service = store.add(new TestSharedSecretStorageService( + false, + storageService, + sandbox.spy(new TestEncryptionService()), + store.add(new NullLogService())) + ); + }); + + teardown(() => { + sandbox.restore(); + }); + + test('shared keys are stored and read from APPLICATION_SHARED', async () => { + const sharedKey = CROSS_APP_SHARED_SECRET_KEYS[0]; + const value = 'shared-secret-value'; + await service.set(sharedKey, value); + const result = await service.get(sharedKey); + assert.strictEqual(result, value); + + // Non-shared key should still work via APPLICATION scope + const regularKey = 'regular-secret'; + await service.set(regularKey, 'regular-value'); + assert.strictEqual(await service.get(regularKey), 'regular-value'); + }); + + test('onDidChangeSecret fires for APPLICATION_SHARED changes', async () => { + const sharedKey = CROSS_APP_SHARED_SECRET_KEYS[0]; + let eventFired = false; + store.add(service.onDidChangeSecret(changedKey => { + assert.strictEqual(changedKey, sharedKey); + eventFired = true; + })); + await service.set(sharedKey, 'value'); + assert.strictEqual(eventFired, true); + }); + + test('deleting a shared key removes it', async () => { + const sharedKey = CROSS_APP_SHARED_SECRET_KEYS[0]; + await service.set(sharedKey, 'value'); + assert.strictEqual(await service.get(sharedKey), 'value'); + await service.delete(sharedKey); + assert.strictEqual(await service.get(sharedKey), undefined); + }); + }); }); From 9ae42a50d8fe5619caa416e8492ac18d889b9f9f Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Fri, 24 Apr 2026 05:34:45 +1000 Subject: [PATCH 53/70] fix(config): enable session controller by default in CLI settings (#312197) * fix(config): enable session controller by default in CLI settings * eol --- extensions/copilot/package.json | 2 +- .../test/vscode-node/sanity.sanity-test.ts | 23 ------------------- .../common/configurationService.ts | 2 +- 3 files changed, 2 insertions(+), 25 deletions(-) diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index c5024d36f75d8..3f5e6a440337d 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -4685,7 +4685,7 @@ }, "github.copilot.chat.cli.sessionController.enabled": { "type": "boolean", - "default": false, + "default": true, "markdownDescription": "%github.copilot.config.cli.sessionController.enabled%", "tags": [ "advanced" diff --git a/extensions/copilot/src/extension/test/vscode-node/sanity.sanity-test.ts b/extensions/copilot/src/extension/test/vscode-node/sanity.sanity-test.ts index 3de34aada4b08..b5e9dbfedd75a 100644 --- a/extensions/copilot/src/extension/test/vscode-node/sanity.sanity-test.ts +++ b/extensions/copilot/src/extension/test/vscode-node/sanity.sanity-test.ts @@ -31,16 +31,11 @@ suite('Copilot Chat Sanity Test', function () { let realContext: vscode.ExtensionContext; let sandbox: sinon.SinonSandbox; const fakeToken = CancellationToken.None; - const sessionItemProviders = new Map(); // Before everything, activate the extension suiteSetup(async function () { sandbox = sinon.createSandbox(); sandbox.stub(vscode.commands, 'registerCommand').returns({ dispose: () => { } }); sandbox.stub(vscode.workspace, 'registerFileSystemProvider').returns({ dispose: () => { } }); - sandbox.stub(vscode.chat, 'registerChatSessionItemProvider').callsFake((scheme, sessionItemProvider) => { - sessionItemProviders.set(scheme, sessionItemProvider); - return { dispose: () => { } }; - }); const extension = vscode.extensions.getExtension('Github.copilot-chat'); assert.ok(extension, 'Extension is not available'); realContext = await extension.activate(); @@ -163,24 +158,6 @@ suite('Copilot Chat Sanity Test', function () { }); }); - test('Copilot CLI lists sessions', async function () { - assert.ok(realInstaAccessor); - - await realInstaAccessor.invokeFunction(async (accessor) => { - - const instaService = accessor.get(IInstantiationService); - const conversationFeature = instaService.createInstance(ConversationFeature); - try { - conversationFeature.activated = true; - const provider = sessionItemProviders.get('copilotcli'); - assert.ok(provider); - await provider.provideChatSessionItems(CancellationToken.None); - } finally { - conversationFeature.activated = false; - } - }); - }); - test.skip('E2E Production Inline Chat Test', async function () { assert.ok(realInstaAccessor); diff --git a/extensions/copilot/src/platform/configuration/common/configurationService.ts b/extensions/copilot/src/platform/configuration/common/configurationService.ts index 4b81d0a95cdfd..f440a451d2854 100644 --- a/extensions/copilot/src/platform/configuration/common/configurationService.ts +++ b/extensions/copilot/src/platform/configuration/common/configurationService.ts @@ -621,7 +621,7 @@ export namespace ConfigKey { export const CLIBranchSupport = defineSetting('chat.cli.branchSupport.enabled', ConfigType.Simple, false); export const CLIIsolationOption = defineSetting('chat.cli.isolationOption.enabled', ConfigType.Simple, true); export const CLIAutoCommitEnabled = defineSetting('chat.cli.autoCommit.enabled', ConfigType.Simple, true); - export const CLISessionController = defineSetting('chat.cli.sessionController.enabled', ConfigType.Simple, false); + export const CLISessionController = defineSetting('chat.cli.sessionController.enabled', ConfigType.Simple, true); 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); From d67820df03e46e01f8abdeea940c9d785297c19a Mon Sep 17 00:00:00 2001 From: Ladislau Szomoru <3372902+lszomoru@users.noreply.github.com> Date: Thu, 23 Apr 2026 19:53:14 +0000 Subject: [PATCH 54/70] Agents - fix dev tools warning (#312206) --- src/vs/sessions/contrib/changes/browser/changes.contribution.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/sessions/contrib/changes/browser/changes.contribution.ts b/src/vs/sessions/contrib/changes/browser/changes.contribution.ts index 2b4c7516dc002..fc2d4f6f6ef0a 100644 --- a/src/vs/sessions/contrib/changes/browser/changes.contribution.ts +++ b/src/vs/sessions/contrib/changes/browser/changes.contribution.ts @@ -27,7 +27,7 @@ const changesViewContainer = viewContainersRegistry.registerViewContainer({ title: localize2('changes', 'Changes'), icon: changesViewIcon, order: 10, - ctorDescriptor: new SyncDescriptor(ChangesViewPaneContainer, [CHANGES_VIEW_CONTAINER_ID, { mergeViewWithContainerWhenSingleView: true }]), + ctorDescriptor: new SyncDescriptor(ChangesViewPaneContainer), storageId: CHANGES_VIEW_CONTAINER_ID, hideIfEmpty: false, openCommandActionDescriptor: { From aca128644d9cb8e50d8197ba492f0e8655789a30 Mon Sep 17 00:00:00 2001 From: Peng Lyu Date: Thu, 23 Apr 2026 12:55:31 -0700 Subject: [PATCH 55/70] Update integration id for cli. --- .../chatSessions/copilotcli/node/copilotcliSessionService.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts index 3a47cf7d6ee94..82cf0a4455407 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts @@ -49,6 +49,7 @@ import { CopilotCliBridgeSpanProcessor } from './copilotCliBridgeSpanProcessor'; import { CopilotCLISession, ICopilotCLISession } from './copilotcliSession'; import { ICopilotCLISkills } from './copilotCLISkills'; import { ICopilotCLIMCPHandler, McpServerMappings, remapCustomAgentTools } from './mcpHandler'; +import { INTEGRATION_ID } from '../../../../platform/endpoint/common/licenseAgreement'; const COPILOT_CLI_WORKSPACE_JSON_FILE_KEY = 'github.copilot.cli.workspaceSessionFile'; @@ -680,6 +681,7 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS const allOptions: SessionOptions = { clientName: 'vscode', + integrationId: INTEGRATION_ID }; const workingDirectory = getWorkingDirectory(options.workspace); From 208150fac064d4b2f4c7ce767d6caf8202d1230a Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Thu, 23 Apr 2026 13:25:40 -0700 Subject: [PATCH 56/70] fix: prevent endless rAF loop in layout when widget is hidden (#312183) * fix: prevent endless rAF loop in layout when widget is hidden The layout methods in mcpListWidget, pluginListWidget, and aiCustomizationListWidget would defer to requestAnimationFrame when searchAndButtonContainer.offsetHeight was 0, assuming the browser hadn't reflowed yet. When the widget is created while permanently hidden (e.g. in component explorer or when the view isn't visible), offsetHeight stays 0 forever, causing an infinite rAF loop. Add a _layoutDeferred flag so the deferral happens at most once. If offsetHeight is still 0 after the retry, proceed with zero heights instead of the next real layout call when the widgetlooping becomes visible will compute correct dimensions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: keep layout retry guard across deferred layout Keep _layoutDeferred set while the deferred layout call runs so a second offsetHeight === 0 measurement cannot schedule another requestAnimationFrame. This makes the retry truly one-shot in all three AI customization list widgets. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../aiCustomizationListWidget.ts | 21 ++++++++++++------- .../browser/aiCustomization/mcpListWidget.ts | 21 ++++++++++++------- .../aiCustomization/pluginListWidget.ts | 21 ++++++++++++------- 3 files changed, 42 insertions(+), 21 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts index 3514479e362ca..8f70afe369c7e 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts @@ -526,6 +526,7 @@ export class AICustomizationListWidget extends Disposable { private displayEntries: IListEntry[] = []; private searchQuery: string = ''; private readonly collapsedGroups = new Set(); + private _layoutDeferred = false; private readonly dropdownActionDisposables = this._register(new DisposableStore()); private _loadItemsSeq = 0; @@ -1496,14 +1497,20 @@ export class AICustomizationListWidget extends Disposable { this.searchInput.layout(); // Measure sibling elements to calculate the remaining space for the list. - // When offsetHeight returns 0 the container just became visible - // after display:none and the browser hasn't reflowed yet — defer - // layout to the next frame so measurements are accurate. - // Skip the retry when the element is hidden (display:none parent) - // since rAF will never produce a non-zero measurement. + // When offsetHeight returns 0 the container may have just become visible + // after display:none and the browser hasn't reflowed yet — defer layout + // once so measurements are accurate. Only retry once to avoid an endless + // loop when the widget is created while permanently hidden. const searchBarHeight = this.searchAndButtonContainer.offsetHeight; - if (searchBarHeight === 0 && this.element.offsetParent !== null) { - DOM.getWindow(this.element).requestAnimationFrame(() => this.layout(height, width)); + if (searchBarHeight === 0 && !this._layoutDeferred) { + this._layoutDeferred = true; + DOM.getWindow(this.element).requestAnimationFrame(() => { + try { + this.layout(height, width); + } finally { + this._layoutDeferred = false; + } + }); return; } const footerHeight = this.sectionHeader.offsetHeight; diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts index 5a23afd396ae5..c2eddfa466f9c 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts @@ -388,6 +388,7 @@ export class McpListWidget extends Disposable { private browseMode: boolean = false; private lastHeight: number = 0; private lastWidth: number = 0; + private _layoutDeferred = false; private readonly collapsedGroups = new Set(); private galleryCts: CancellationTokenSource | undefined; private readonly delayedFilter = new Delayer(200); @@ -953,14 +954,20 @@ export class McpListWidget extends Disposable { this.element.style.height = `${height}px`; // Measure sibling elements to calculate the list height. - // When offsetHeight returns 0 the container just became visible - // after display:none and the browser hasn't reflowed yet — defer - // layout to the next frame so measurements are accurate. - // Skip the retry when the element is hidden (display:none parent) - // since rAF will never produce a non-zero measurement. + // When offsetHeight returns 0 the container may have just become visible + // after display:none and the browser hasn't reflowed yet — defer layout + // once so measurements are accurate. Only retry once to avoid an endless + // loop when the widget is created while permanently hidden. const searchBarHeight = this.searchAndButtonContainer.offsetHeight; - if (searchBarHeight === 0 && this.element.offsetParent !== null) { - DOM.getWindow(this.element).requestAnimationFrame(() => this.layout(this.lastHeight, this.lastWidth)); + if (searchBarHeight === 0 && !this._layoutDeferred) { + this._layoutDeferred = true; + DOM.getWindow(this.element).requestAnimationFrame(() => { + try { + this.layout(this.lastHeight, this.lastWidth); + } finally { + this._layoutDeferred = false; + } + }); return; } const footerHeight = this.sectionHeader.offsetHeight; diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts index f82a81076993c..4556b8c5c9f56 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts @@ -353,6 +353,7 @@ export class PluginListWidget extends Disposable { private browseMode: boolean = false; private lastHeight: number = 0; private lastWidth: number = 0; + private _layoutDeferred = false; private readonly collapsedGroups = new Set(); private marketplaceCts: CancellationTokenSource | undefined; private readonly delayedFilter = new Delayer(200); @@ -846,14 +847,20 @@ export class PluginListWidget extends Disposable { this.element.style.height = `${height}px`; // Measure sibling elements to calculate the list height. - // When offsetHeight returns 0 the container just became visible - // after display:none and the browser hasn't reflowed yet — defer - // layout to the next frame so measurements are accurate. - // Skip the retry when the element is hidden (display:none parent) - // since rAF will never produce a non-zero measurement. + // When offsetHeight returns 0 the container may have just become visible + // after display:none and the browser hasn't reflowed yet — defer layout + // once so measurements are accurate. Only retry once to avoid an endless + // loop when the widget is created while permanently hidden. const searchBarHeight = this.searchAndButtonContainer.offsetHeight; - if (searchBarHeight === 0 && this.element.offsetParent !== null) { - DOM.getWindow(this.element).requestAnimationFrame(() => this.layout(this.lastHeight, this.lastWidth)); + if (searchBarHeight === 0 && !this._layoutDeferred) { + this._layoutDeferred = true; + DOM.getWindow(this.element).requestAnimationFrame(() => { + try { + this.layout(this.lastHeight, this.lastWidth); + } finally { + this._layoutDeferred = false; + } + }); return; } const footerHeight = this.sectionHeader.offsetHeight; From dcc7279e0d76b68c57d79af2bebe72410bc7ffab Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega <48293249+osortega@users.noreply.github.com> Date: Thu, 23 Apr 2026 13:58:31 -0700 Subject: [PATCH 57/70] Agents web: improve connection stability and terminal reconnection (#312189) * sessions: improve connection stability and terminal reconnection - WebSocketClientTransport: fail-fast on send to dead socket with force-close and _closeFired guard to prevent double onClose events - AgentHostPty: add reconnect() method with 10s hydration timeout and buffer clear before snapshot replay - AgentHostTerminalService: add reconnectTerminals() scoped by oldClientId, track active ptys with dispose cleanup - RemoteAgentHostContribution: trigger terminal reconnection on clientId change during reconnect - Telemetry: add socket/close, socket/sendDropped, socket/visibilityResumed, terminal/recovery events - Fix isMeasurement on boolean fields in existing telemetry * Update src/vs/sessions/common/sessionsTelemetry.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * address review: gate terminal reconnect on clientId change, add reconnect tests --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../browser/webSocketClientTransport.ts | 34 ++++- src/vs/sessions/common/sessionsTelemetry.ts | 103 ++++++++++++++- .../browser/remoteAgentHost.contribution.ts | 32 ++++- .../contrib/terminal/browser/agentHostPty.ts | 80 +++++++++++- .../browser/agentHostTerminalService.ts | 51 +++++++- .../test/browser/agentHostPty.test.ts | 119 ++++++++++++++++++ 6 files changed, 408 insertions(+), 11 deletions(-) diff --git a/src/vs/platform/agentHost/browser/webSocketClientTransport.ts b/src/vs/platform/agentHost/browser/webSocketClientTransport.ts index 6d01fe3e41614..6b0c02128c069 100644 --- a/src/vs/platform/agentHost/browser/webSocketClientTransport.ts +++ b/src/vs/platform/agentHost/browser/webSocketClientTransport.ts @@ -34,6 +34,9 @@ export class WebSocketClientTransport extends Disposable implements IClientTrans private _ws: WebSocket | undefined; private _malformedFrames = 0; + /** Guards against firing onClose more than once. */ + private _closeFired = false; + get isOpen(): boolean { return this._ws?.readyState === WebSocket.OPEN; } @@ -42,6 +45,7 @@ export class WebSocketClientTransport extends Disposable implements IClientTrans private readonly _address: string, private readonly _connectionToken?: string, ) { + // TODO: @osortega remove console.logs super(); } @@ -138,20 +142,44 @@ export class WebSocketClientTransport extends Disposable implements IClientTrans }); ws.addEventListener('close', () => { - this._onClose.fire(); + if (!this._closeFired) { + this._closeFired = true; + this._onClose.fire(); + } }); ws.addEventListener('error', () => { // Error always precedes close - closing is handled in the close handler. - this._onClose.fire(); + // Only fire if close hasn't already been fired (e.g. from send failure). + if (!this._closeFired) { + this._closeFired = true; + this._onClose.fire(); + } }); }); } - send(message: ProtocolMessage | AhpServerNotification | JsonRpcResponse): void { + /** + * Send a message to the remote end. Returns `true` if the message was + * sent, `false` if it was dropped (socket not open). On failure, the + * transport is force-closed so reconnection is triggered immediately + * rather than silently losing messages. + */ + send(message: ProtocolMessage | AhpServerNotification | JsonRpcResponse): boolean { if (this._ws?.readyState === WebSocket.OPEN) { this._ws.send(JSON.stringify(message)); + return true; + } + console.warn( + `[WebSocketClientTransport] Message dropped: readyState=${this._ws?.readyState ?? 'no-socket'}` + ); + // Force-close and fire onClose exactly once to trigger reconnection + this._ws?.close(4001, 'send-on-dead-socket'); + if (!this._closeFired) { + this._closeFired = true; + this._onClose.fire(); } + return false; } override dispose(): void { diff --git a/src/vs/sessions/common/sessionsTelemetry.ts b/src/vs/sessions/common/sessionsTelemetry.ts index ed404d3272de3..04926331fc9b4 100644 --- a/src/vs/sessions/common/sessionsTelemetry.ts +++ b/src/vs/sessions/common/sessionsTelemetry.ts @@ -138,10 +138,10 @@ type TunnelConnectAttemptEvent = { type TunnelConnectAttemptClassification = { owner: 'osortega'; comment: 'Tracks individual agent-host tunnel connect attempts for performance and reliability.'; - isReconnect: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether this attempt was part of a reconnect cycle (true) or an initial connect (false).' }; + isReconnect: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether this attempt was part of a reconnect cycle (true) or an initial connect (false).' }; attempt: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Attempt number within the current connect session (1-based).' }; durationMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Duration of this individual attempt in milliseconds.' }; - success: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Whether this individual attempt succeeded.' }; + success: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Whether this individual attempt succeeded.' }; errorCategory: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Category of error when the attempt failed (relayConnectionFailed, auth, authExpired, network, other); empty on success.' }; }; @@ -166,10 +166,10 @@ type TunnelConnectResolvedEvent = { type TunnelConnectResolvedClassification = { owner: 'osortega'; comment: 'Tracks overall agent-host tunnel connect session outcomes for reliability.'; - isReconnect: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether the resolved session was a reconnect cycle (true) or an initial connect (false).' }; + isReconnect: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the resolved session was a reconnect cycle (true) or an initial connect (false).' }; totalAttempts: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Total number of attempts made before resolution.' }; totalDurationMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Total elapsed time from session start to resolution in milliseconds.' }; - success: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Whether the connect session ultimately succeeded.' }; + success: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Whether the connect session ultimately succeeded.' }; failureReason: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Reason the session terminated without connecting (hostOffline, maxAttemptsReached, auth, authExpired); empty on success.' }; }; @@ -182,3 +182,98 @@ export function logTunnelConnectResolved(telemetryService: ITelemetryService, da failureReason: data.failureReason ?? '', }); } + +// --- Socket lifecycle telemetry --- + +export type SocketCloseTrigger = + | 'server' + | 'sendOnDeadSocket' + | 'visibility' + | 'offline' + | 'malformedFrames' + | 'disposed' + | 'error'; + +type SocketCloseEvent = { + closeCode: number; + wasClean: boolean; + lifetimeMs: number; + messagesSent: number; + messagesReceived: number; + messagesDropped: number; + trigger: string; +}; + +type SocketCloseClassification = { + owner: 'osortega'; + comment: 'Tracks WebSocket close events for agent host connections to measure connection reliability.'; + closeCode: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'WebSocket close code.' }; + wasClean: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Whether the close was clean.' }; + lifetimeMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'How long the socket was alive in milliseconds.' }; + messagesSent: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Total messages sent.' }; + messagesReceived: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Total messages received.' }; + messagesDropped: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Total messages dropped due to non-OPEN socket.' }; + trigger: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'What triggered the close (server, sendOnDeadSocket, visibility, offline, malformedFrames, disposed, error).' }; +}; + +export function logSocketClose(telemetryService: ITelemetryService, data: { closeCode: number; wasClean: boolean; lifetimeMs: number; messagesSent: number; messagesReceived: number; messagesDropped: number; trigger: SocketCloseTrigger }): void { + telemetryService.publicLog2('vscodeAgents.socket/close', data); +} + +// --- Send dropped telemetry --- + +type SendDroppedEvent = { + readyState: number; + timeSinceLastReceiveMs: number; + timeSinceLastSendMs: number; +}; + +type SendDroppedClassification = { + owner: 'osortega'; + comment: 'Tracks when a message is silently dropped due to a non-OPEN WebSocket, indicating a zombie socket.'; + readyState: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'WebSocket readyState at drop time (0=CONNECTING, 1=OPEN, 2=CLOSING, 3=CLOSED).' }; + timeSinceLastReceiveMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Milliseconds since last received message.' }; + timeSinceLastSendMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Milliseconds since last sent message.' }; +}; + +export function logSendDropped(telemetryService: ITelemetryService, data: { readyState: number; timeSinceLastReceiveMs: number; timeSinceLastSendMs: number }): void { + telemetryService.publicLog2('vscodeAgents.socket/sendDropped', data); +} + +// --- Visibility resumed telemetry --- + +type VisibilityResumedEvent = { + hiddenDurationMs: number; + socketAlive: boolean; + forceClosed: boolean; +}; + +type VisibilityResumedClassification = { + owner: 'osortega'; + comment: 'Tracks tab visibility resume events to measure zombie socket detection effectiveness.'; + hiddenDurationMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'How long the tab was hidden in milliseconds.' }; + socketAlive: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Whether the socket was alive after zombie detection check.' }; + forceClosed: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Whether the socket was force-closed on resume.' }; +}; + +export function logVisibilityResumed(telemetryService: ITelemetryService, data: { hiddenDurationMs: number; socketAlive: boolean; forceClosed: boolean }): void { + telemetryService.publicLog2('vscodeAgents.socket/visibilityResumed', data); +} + +// --- Terminal recovery telemetry --- + +type TerminalRecoveryEvent = { + recoveredCount: number; + totalCount: number; +}; + +type TerminalRecoveryClassification = { + owner: 'osortega'; + comment: 'Tracks terminal reconnection outcomes after agent host disconnect.'; + recoveredCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Number of terminals successfully reconnected.' }; + totalCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Total number of active terminals at reconnect time.' }; +}; + +export function logTerminalRecovery(telemetryService: ITelemetryService, data: { recoveredCount: number; totalCount: number }): void { + telemetryService.publicLog2('vscodeAgents.terminal/recovery', data); +} diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts index 408fe16327d15..6cbf7a107ff17 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts @@ -41,6 +41,9 @@ import { createRemoteAgentHarnessDescriptor, RemoteAgentCustomizationItemProvide import { RemoteAgentHostSessionsProvider } from './remoteAgentHostSessionsProvider.js'; import { SyncedCustomizationBundler } from './syncedCustomizationBundler.js'; import { ISSHRemoteAgentHostService } from '../../../../platform/agentHost/common/sshRemoteAgentHost.js'; +import { IAgentHostTerminalService } from '../../../../workbench/contrib/terminal/browser/agentHostTerminalService.js'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; +import { logTerminalRecovery } from '../../../common/sessionsTelemetry.js'; /** Per-connection state bundle, disposed when a connection is removed. */ class ConnectionState extends Disposable { @@ -99,6 +102,8 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc @ICustomizationHarnessService private readonly _customizationHarnessService: ICustomizationHarnessService, @IStorageService private readonly _storageService: IStorageService, @IAgentPluginService private readonly _agentPluginService: IAgentPluginService, + @IAgentHostTerminalService private readonly _agentHostTerminalService: IAgentHostTerminalService, + @ITelemetryService private readonly _telemetryService: ITelemetryService, ) { super(); @@ -260,11 +265,34 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc } const existing = this._connections.get(connectionInfo.address); if (existing) { + const nameChanged = existing.name !== connectionInfo.name; + const clientIdChanged = existing.loggedConnection.clientId !== connectionInfo.clientId; + // If the name or clientId changed, tear down and re-register - if (existing.name !== connectionInfo.name || existing.loggedConnection.clientId !== connectionInfo.clientId) { - this._logService.info(`[RemoteAgentHost] Reconnecting contribution for ${connectionInfo.address}: oldClientId=${existing.loggedConnection.clientId}, newClientId=${connectionInfo.clientId}, nameChanged=${existing.name !== connectionInfo.name}`); + if (nameChanged || clientIdChanged) { + this._logService.info(`[RemoteAgentHost] Reconnecting contribution for ${connectionInfo.address}: oldClientId=${existing.loggedConnection.clientId}, newClientId=${connectionInfo.clientId}, nameChanged=${nameChanged}`); + const oldClientId = existing.loggedConnection.clientId; this._connections.deleteAndDispose(connectionInfo.address); this._setupConnection(connectionInfo); + + // Reconnect active terminals only when the backing + // client changed. Name-only updates don't invalidate + // subscriptions and would cause unnecessary buffer + // clear/replay flicker. + if (clientIdChanged) { + const newConnection = this._remoteAgentHostService.getConnection(connectionInfo.address); + if (newConnection) { + this._agentHostTerminalService.reconnectTerminals(newConnection, oldClientId).then( + ({ recovered, total }) => { + if (total > 0) { + this._logService.info(`[RemoteAgentHost] Terminal reconnection: ${recovered}/${total} recovered`); + logTerminalRecovery(this._telemetryService, { recoveredCount: recovered, totalCount: total }); + } + }, + err => this._logService.warn('[RemoteAgentHost] Terminal reconnection failed', err) + ); + } + } } } else { this._setupConnection(connectionInfo); diff --git a/src/vs/workbench/contrib/terminal/browser/agentHostPty.ts b/src/vs/workbench/contrib/terminal/browser/agentHostPty.ts index 30c9e0cee5c3f..0daf8a902e1db 100644 --- a/src/vs/workbench/contrib/terminal/browser/agentHostPty.ts +++ b/src/vs/workbench/contrib/terminal/browser/agentHostPty.ts @@ -120,7 +120,7 @@ export class AgentHostPty extends BasePty implements ITerminalChildProcess { constructor( id: number, - private readonly _connection: IAgentConnection, + private _connection: IAgentConnection, private readonly _terminalUri: URI, private readonly _options?: IAgentHostPtyOptions, ) { @@ -378,6 +378,84 @@ export class AgentHostPty extends BasePty implements ITerminalChildProcess { // Not applicable } + /** + * Reconnect this pty to a new agent host connection. Tears down the + * old subscription and re-subscribes with the new connection, replaying + * content from the server-side snapshot. Terminal output during the + * disconnect gap is a stream (not state), so some loss is expected. + * + * @returns `true` if reconnection succeeded, `false` otherwise. + */ + async reconnect(newConnection: IAgentConnection): Promise { + // Clean up old subscription + this._subscriptionDisposables.clear(); + this._subscriptionRef?.dispose(); + this._subscriptionRef = undefined; + + // Swap connection + this._connection = newConnection; + + try { + // Re-subscribe to the terminal state + this._subscriptionRef = this._connection.getSubscription(StateComponents.Terminal, this._terminalUri); + const subscription = this._subscriptionRef.object; + + // Wait for hydration with a timeout — the terminal may no longer + // exist on the server (e.g. agent process restarted). + if (subscription.value === undefined) { + const RECONNECT_HYDRATE_TIMEOUT_MS = 10_000; + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + listener.dispose(); + reject(new Error('Reconnect hydration timed out')); + }, RECONNECT_HYDRATE_TIMEOUT_MS); + const listener = subscription.onDidChange(() => { + clearTimeout(timer); + listener.dispose(); + resolve(); + }); + this._subscriptionDisposables.add(listener); + }); + } + + const state = subscription.value as TerminalState; + + if (state.supportsCommandDetection && !this._supportsCommandDetection) { + this._supportsCommandDetection = true; + this._onSupportsCommandDetection.fire(); + } + + // Clear the terminal buffer before replaying to avoid duplicate + // content. ESC[2J clears the screen, ESC[3J clears scrollback, + // ESC[H moves cursor to home position. + this.handleData('\x1b[2J\x1b[3J\x1b[H'); + this._replayContent(state.content); + + // Update cwd/title if they changed + if (state.cwd) { + this._properties.cwd = state.cwd.toString(); + } + if (state.title) { + this._properties.title = state.title; + } + + // Wire up action listener for streaming updates + this._subscriptionDisposables.add(subscription.onDidApplyAction(envelope => { + this._handleAction(envelope); + })); + + return true; + } catch (err) { + console.warn('[AgentHostPty] Reconnection failed:', err instanceof Error ? err.message : String(err)); + return false; + } + } + + /** The terminal URI this pty is subscribed to. */ + get terminalUri(): URI { + return this._terminalUri; + } + override dispose(): void { this._subscriptionRef?.dispose(); this._subscriptionRef = undefined; diff --git a/src/vs/workbench/contrib/terminal/browser/agentHostTerminalService.ts b/src/vs/workbench/contrib/terminal/browser/agentHostTerminalService.ts index 19a9fba7ab215..6de7432acc951 100644 --- a/src/vs/workbench/contrib/terminal/browser/agentHostTerminalService.ts +++ b/src/vs/workbench/contrib/terminal/browser/agentHostTerminalService.ts @@ -78,6 +78,13 @@ export interface IAgentHostTerminalService { */ createTerminalForEntry(address: string, options?: IAgentHostTerminalCreateOptions): Promise; + /** + * Reconnects all active terminals that belonged to {@link oldClientId} + * to a new agent host connection. Only terminals matching the old + * client are touched — terminals from other hosts are left alone. + */ + reconnectTerminals(newConnection: IAgentConnection, oldClientId: string): Promise<{ recovered: number; total: number }>; + /** * Attaches to an existing server-side terminal by subscribing to its * state without creating a new process. @@ -104,6 +111,11 @@ export class AgentHostTerminalService extends Disposable implements IAgentHostTe /** Revived terminal instances, keyed by terminal URI string. */ private readonly _revivedInstances = new Map(); + /** + * Active AgentHostPty instances with their owning connection clientId, + * keyed by terminal URI string. Used for reconnection scoping. + */ + private readonly _activePtys = new Map(); private readonly _reviveSequencer = new SequencerByKey(); constructor( @@ -279,8 +291,9 @@ export class AgentHostTerminalService extends Disposable implements IAgentHostTe async createTerminal(connection: IAgentConnection, options?: IAgentHostTerminalCreateOptions): Promise { const terminalUri = URI.from({ scheme: 'agenthost-terminal', path: `/${generateUuid()}` }); const name = options?.name ?? localize('agentHostTerminal.default', "Agent Host Terminal"); + const key = terminalUri.toString(); - return this._terminalService.createTerminal({ + const instance = await this._terminalService.createTerminal({ config: { customPtyImplementation: (id, cols, rows) => { const pty = new AgentHostPty(id, connection, terminalUri, { @@ -290,6 +303,7 @@ export class AgentHostTerminalService extends Disposable implements IAgentHostTe if (cols > 0 && rows > 0) { pty.resize(cols, rows); } + this._activePtys.set(key, { pty, clientId: connection.clientId }); return pty; }, name, @@ -298,6 +312,12 @@ export class AgentHostTerminalService extends Disposable implements IAgentHostTe }, location: options?.location, }); + + this._register(instance.onDisposed(() => { + this._activePtys.delete(key); + })); + + return instance; } async reviveTerminal(connection: IAgentConnection, terminalUri: URI, terminalToolSessionId: string): Promise { @@ -328,6 +348,7 @@ export class AgentHostTerminalService extends Disposable implements IAgentHostTe commandSource.connect(instance, pty); } + this._activePtys.set(key, { pty, clientId: connection.clientId }); return pty; }, name: localize('agentHostTerminal.tool', "Agent Host Terminal"), @@ -341,8 +362,36 @@ export class AgentHostTerminalService extends Disposable implements IAgentHostTe instance.store.add(store); this._register(instance.onDisposed(() => { this._revivedInstances.delete(key); + this._activePtys.delete(key); })); return instance; } + + async reconnectTerminals(newConnection: IAgentConnection, oldClientId: string): Promise<{ recovered: number; total: number }> { + // Only reconnect terminals that belonged to the old connection + // identified by oldClientId. In multi-host setups, other hosts' + // terminals are left untouched. + const entries = [...this._activePtys.entries()].filter( + ([, entry]) => entry.clientId === oldClientId + ); + const total = entries.length; + let recovered = 0; + const promises: Promise[] = []; + for (const [key, entry] of entries) { + promises.push( + entry.pty.reconnect(newConnection).then(success => { + if (success) { + recovered++; + // Update the clientId to the new connection + entry.clientId = newConnection.clientId; + } else { + console.warn(`[AgentHostTerminalService] Failed to reconnect terminal: ${key}`); + } + }) + ); + } + await Promise.all(promises); + return { recovered, total }; + } } diff --git a/src/vs/workbench/contrib/terminal/test/browser/agentHostPty.test.ts b/src/vs/workbench/contrib/terminal/test/browser/agentHostPty.test.ts index e474f44d569ca..18370f0de2fe7 100644 --- a/src/vs/workbench/contrib/terminal/test/browser/agentHostPty.test.ts +++ b/src/vs/workbench/contrib/terminal/test/browser/agentHostPty.test.ts @@ -341,4 +341,123 @@ suite('AgentHostPty', () => { const cwd = await pty.getInitialCwd(); assert.strictEqual(cwd, '/home/user'); }); + + test('reconnect() re-subscribes with new connection and replays content', async () => { + const conn1 = new MockAgentConnection({ content: [{ type: 'unclassified', value: 'old output\n' }] }); + disposables.add(conn1); + const pty = disposables.add(new AgentHostPty(1, conn1, terminalUri)); + + await pty.start(); + + // Create a new connection with different content (simulating server-side changes during disconnect) + const conn2 = new MockAgentConnection({ + content: [{ type: 'unclassified', value: 'old output\nnew output after reconnect\n' }], + cwd: '/home/reconnected', + title: 'Reconnected Terminal', + }); + disposables.add(conn2); + + const dataReceived: string[] = []; + disposables.add(pty.onProcessData!(e => { + dataReceived.push(typeof e === 'string' ? e : e.data); + })); + + const result = await pty.reconnect(conn2); + + assert.strictEqual(result, true, 'reconnect() should succeed'); + // Should have clear sequence + replayed content + assert.ok(dataReceived.some(d => d.includes('\x1b[2J')), 'should clear buffer before replay'); + assert.ok(dataReceived.some(d => d.includes('new output after reconnect')), 'should replay new content'); + + const cwd = await pty.getCwd(); + assert.strictEqual(cwd, '/home/reconnected'); + }); + + test('reconnect() streams new actions from new connection', async () => { + const conn1 = new MockAgentConnection(); + disposables.add(conn1); + const pty = disposables.add(new AgentHostPty(1, conn1, terminalUri)); + await pty.start(); + + const conn2 = new MockAgentConnection(); + disposables.add(conn2); + + const dataReceived: string[] = []; + disposables.add(pty.onProcessData!(e => { + dataReceived.push(typeof e === 'string' ? e : e.data); + })); + + await pty.reconnect(conn2); + dataReceived.length = 0; // clear replay data + + // New actions from conn2 should be received + conn2.fireAction({ type: ActionType.TerminalData, terminal: terminalUri.toString(), data: 'post-reconnect data' }); + + assert.deepStrictEqual(dataReceived, ['post-reconnect data']); + + // Old connection actions should NOT be received + conn1.fireAction({ type: ActionType.TerminalData, terminal: terminalUri.toString(), data: 'stale data' }); + assert.deepStrictEqual(dataReceived, ['post-reconnect data']); + }); + + test('reconnect() times out when subscription never hydrates', async () => { + const conn1 = new MockAgentConnection(); + disposables.add(conn1); + const pty = disposables.add(new AgentHostPty(1, conn1, terminalUri)); + await pty.start(); + + // Create a connection whose subscription never fires onDidChange + const conn2 = new MockAgentConnection(); + disposables.add(conn2); + // Override getSubscription to return a subscription that never hydrates + conn2.getSubscription = (_kind: StateComponents, _resource: URI): IReference> => { + const onDidChange = new Emitter(); + const onDidApplyAction = new Emitter(); + disposables.add(onDidChange); + disposables.add(onDidApplyAction); + const sub: IAgentSubscription = { + value: undefined, // never hydrated + verifiedValue: undefined, + onDidChange: onDidChange.event, + onWillApplyAction: Event.None, + onDidApplyAction: onDidApplyAction.event, + }; + return { + object: sub as IAgentSubscription, + dispose: () => { onDidChange.dispose(); onDidApplyAction.dispose(); }, + }; + }; + + // Suppress the expected console.warn from reconnect failure + const origWarn = console.warn; + console.warn = () => { }; + try { + const result = await pty.reconnect(conn2); + assert.strictEqual(result, false, 'reconnect() should fail on timeout'); + } finally { + console.warn = origWarn; + } + }).timeout(15000); // Allow for the 10s hydration timeout + + test('reconnect() dispatches input to new connection', async () => { + const conn1 = new MockAgentConnection(); + disposables.add(conn1); + const pty = disposables.add(new AgentHostPty(1, conn1, terminalUri)); + await pty.start(); + + const conn2 = new MockAgentConnection(); + disposables.add(conn2); + await pty.reconnect(conn2); + + pty.input('after reconnect'); + await new Promise(resolve => setTimeout(resolve, 10)); + + const inputActions = conn2.dispatchedActions.filter(a => a.type === ActionType.TerminalInput); + assert.strictEqual(inputActions.length, 1); + assert.strictEqual((inputActions[0] as { data: string }).data, 'after reconnect'); + + // conn1 should not have received the input + const oldInputActions = conn1.dispatchedActions.filter(a => a.type === ActionType.TerminalInput); + assert.strictEqual(oldInputActions.length, 0); + }); }); From cf82f857de741c3b62f41d7ba95e364cf014ed61 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Thu, 23 Apr 2026 14:16:49 -0700 Subject: [PATCH 58/70] Add basic github lexical search Fixes #312210 This is using the old search endpoint. We'll start switching over the new one once it's ready Co-authored-by: Copilot --- extensions/copilot/package.json | 35 ++- extensions/copilot/package.nls.json | 6 +- .../src/extension/tools/common/toolNames.ts | 9 +- .../src/extension/tools/node/allTools.ts | 3 +- ...l.tsx => githubRepoSemanticSearchTool.tsx} | 16 +- .../tools/node/githubTextSearchTool.tsx | 185 +++++++++++++++ .../src/platform/github/common/githubAPI.ts | 7 +- .../common/adoCodeSearchService.ts | 6 +- .../common/githubCodeSearchService.ts | 219 +++++++++++++++++- .../common/remoteCodeSearch.ts | 20 +- .../node/codeSearch/codeSearchChunkSearch.ts | 8 +- .../node/codeSearch/codeSearchRepo.ts | 13 +- .../base/simuliationWorkspaceChunkSearch.ts | 13 +- 13 files changed, 492 insertions(+), 48 deletions(-) rename extensions/copilot/src/extension/tools/node/{githubRepoTool.tsx => githubRepoSemanticSearchTool.tsx} (91%) create mode 100644 extensions/copilot/src/extension/tools/node/githubTextSearchTool.tsx diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index c5024d36f75d8..21e06a22636ea 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -1051,6 +1051,38 @@ ] } }, + { + "name": "copilot_githubTextSearch", + "legacyToolReferenceFullNames": [ + "githubTextSearch" + ], + "toolReferenceName": "githubTextSearch", + "displayName": "%github.copilot.tools.githubTextSearch.name%", + "modelDescription": "Lexically searches a GitHub repository or organization for files containing specific keywords or code patterns. Use this when looking for exact strings, function names, or identifiers in a GitHub repo or org. Unlike the semantic search tool, this uses keyword matching rather than meaning-based search.", + "userDescription": "%github.copilot.tools.githubTextSearch.userDescription%", + "icon": "$(search)", + "inputSchema": { + "type": "object", + "properties": { + "scope": { + "type": "string", + "description": "The GitHub scope to search. Use 'owner/repo' to search a single repository, or an org name (no slash) to search across an entire organization." + }, + "query": { + "type": "string", + "description": "The keyword search query. Supports GitHub code search syntax such as 'language:typescript', 'extension:ts', 'path:src/', etc." + }, + "maxResults": { + "type": "number", + "description": "Optional. The maximum number of search results to return. Defaults to 100." + } + }, + "required": [ + "scope", + "query" + ] + } + }, { "name": "copilot_switchAgent", "toolReferenceName": "switchAgent", @@ -1271,7 +1303,8 @@ "icon": "$(globe)", "tools": [ "fetch", - "githubRepo" + "githubRepo", + "githubTextSearch" ] } ], diff --git a/extensions/copilot/package.nls.json b/extensions/copilot/package.nls.json index 122540aab726f..ade5bdaa46191 100644 --- a/extensions/copilot/package.nls.json +++ b/extensions/copilot/package.nls.json @@ -274,8 +274,10 @@ "github.copilot.tools.searchResults.name": "Search View Results", "github.copilot.tools.searchResults.description": "Get the results of the search view", "github.copilot.config.getSearchViewResultsSkill.enabled": "Enable the Search View Results skill and disable the corresponding tool.", - "github.copilot.tools.githubRepo.name": "Search GitHub Repository", - "github.copilot.tools.githubRepo.userDescription": "Search a GitHub repository for relevant source code snippets. You can specify a repository using `owner/repo`", + "github.copilot.tools.githubRepo.name": "Semantic Search GitHub Repository", + "github.copilot.tools.githubRepo.userDescription": "Semantic Search a GitHub repository for relevant source code snippets. You can specify a repository using `owner/repo`", + "github.copilot.tools.githubTextSearch.name": "GitHub Text Search", + "github.copilot.tools.githubTextSearch.userDescription": "Text search a GitHub repository or organization for files containing specific keywords or code patterns.", "github.copilot.config.autoFix": "Automatically fix diagnostics for edited files.", "github.copilot.config.rateLimitAutoSwitchToAuto": "Automatically switch to the Auto model and retry when you hit a per-model rate limit.", "github.copilot.tools.createNewWorkspace.userDescription": "Scaffold a new workspace in VS Code", diff --git a/extensions/copilot/src/extension/tools/common/toolNames.ts b/extensions/copilot/src/extension/tools/common/toolNames.ts index d6db8f32bc2be..412c1e50e0914 100644 --- a/extensions/copilot/src/extension/tools/common/toolNames.ts +++ b/extensions/copilot/src/extension/tools/common/toolNames.ts @@ -47,7 +47,8 @@ export enum ToolName { FindTestFiles = 'test_search', GetProjectSetupInfo = 'get_project_setup_info', SearchViewResults = 'get_search_view_results', - GithubRepo = 'github_repo', + GithubSemanticRepoSearch = 'github_repo', + GithubTextSearch = 'github_text_search', CreateDirectory = 'create_directory', RunVscodeCmd = 'run_vscode_command', CoreManageTodoList = 'manage_todo_list', @@ -132,7 +133,8 @@ export enum ContributedToolName { FindTestFiles = 'copilot_findTestFiles', GetProjectSetupInfo = 'copilot_getProjectSetupInfo', SearchViewResults = 'copilot_getSearchResults', - GithubRepo = 'copilot_githubRepo', + GithubSemanticRepoSearch = 'copilot_githubRepo', + GithubTextSearch = 'copilot_githubTextSearch', CreateAndRunTask = 'copilot_createAndRunTask', CreateDirectory = 'copilot_createDirectory', RunVscodeCmd = 'copilot_runVscodeCommand', @@ -223,7 +225,8 @@ export const toolCategories: Record = { // Web Interaction [ToolName.FetchWebPage]: ToolCategory.WebInteraction, - [ToolName.GithubRepo]: ToolCategory.WebInteraction, + [ToolName.GithubSemanticRepoSearch]: ToolCategory.WebInteraction, + [ToolName.GithubTextSearch]: ToolCategory.WebInteraction, [ToolName.CoreOpenBrowserPage]: ToolCategory.WebInteraction, [ToolName.CoreClickElement]: ToolCategory.WebInteraction, [ToolName.CoreScreenshotPage]: ToolCategory.WebInteraction, diff --git a/extensions/copilot/src/extension/tools/node/allTools.ts b/extensions/copilot/src/extension/tools/node/allTools.ts index 22d768a4b8488..8f1170fb9f182 100644 --- a/extensions/copilot/src/extension/tools/node/allTools.ts +++ b/extensions/copilot/src/extension/tools/node/allTools.ts @@ -15,7 +15,8 @@ import './findTextInFilesTool'; import './getErrorsTool'; import './getNotebookCellOutputTool'; import './getSearchViewResultsTool'; -import './githubRepoTool'; +import './githubRepoSemanticSearchTool.tsx'; +import './githubTextSearchTool'; import './insertEditTool'; import './installExtensionTool'; import './listDirTool'; diff --git a/extensions/copilot/src/extension/tools/node/githubRepoTool.tsx b/extensions/copilot/src/extension/tools/node/githubRepoSemanticSearchTool.tsx similarity index 91% rename from extensions/copilot/src/extension/tools/node/githubRepoTool.tsx rename to extensions/copilot/src/extension/tools/node/githubRepoSemanticSearchTool.tsx index 09c83d6022ec0..2b17061d3717a 100644 --- a/extensions/copilot/src/extension/tools/node/githubRepoTool.tsx +++ b/extensions/copilot/src/extension/tools/node/githubRepoSemanticSearchTool.tsx @@ -38,9 +38,8 @@ interface PrepareError { readonly details?: string; } -export class GithubRepoTool implements ICopilotTool { - public static readonly toolName = ToolName.GithubRepo; - +export class GithubRepoSemanticSearchTool implements ICopilotTool { + public static readonly toolName = ToolName.GithubSemanticRepoSearch; constructor( @IRunCommandExecutionService _commandService: IRunCommandExecutionService, @@ -61,14 +60,15 @@ export class GithubRepoTool implements ICopilotTool { throw new Error('No embedding models available'); } - const searchResults = await this._githubCodeSearch.searchRepo({ silent: true }, embeddingType, { githubRepoId, localRepoRoot: undefined, indexedCommit: undefined }, options.input.query, 64, {}, new TelemetryCorrelationId('github-repo-tool'), token); + const searchResults = await this._githubCodeSearch.semanticSearch({ silent: true }, embeddingType, { kind: 'repo', githubRepoId, localRepoRoot: undefined, indexedCommit: undefined }, options.input.query, 64, {}, new TelemetryCorrelationId('github-repo-tool'), token); - // Map the chunks to URIs - // TODO: Won't work for proxima or branches not called main + // Map the chunks to URIs using the remote URL and ref from the search response + const repoBaseUrl = searchResults.remoteUrl ?? `https://github.com/${toGithubNwo(githubRepoId)}`; + const ref = searchResults.refName ?? 'main'; const chunks = searchResults.chunks.map((entry): FileChunkAndScore => ({ chunk: { ...entry.chunk, - file: URI.joinPath(URI.parse('https://github.com'), toGithubNwo(githubRepoId), 'tree', 'main', entry.chunk.file.path).with({ + file: URI.joinPath(URI.parse(repoBaseUrl), 'tree', ref, entry.chunk.file.path).with({ fragment: `L${entry.chunk.range.startLineNumber}-L${entry.chunk.range.endLineNumber}`, }), }, @@ -229,4 +229,4 @@ class GithubChunkSearchResults extends PromptElement { + public static readonly toolName = ToolName.GithubTextSearch; + + constructor( + @IInstantiationService private readonly _instantiationService: IInstantiationService, + @IGithubCodeSearchService private readonly _githubCodeSearch: IGithubCodeSearchService, + ) { } + + async invoke(options: vscode.LanguageModelToolInvocationOptions, token: CancellationToken): Promise { + const scope = parseScope(options.input.scope); + if (!scope) { + throw new Error(l10n.t`Invalid input. Could not parse 'scope' argument`); + } + + const maxResults = options.input.maxResults ?? 100; + + const searchResults = await this._githubCodeSearch.lexicalSearch( + { silent: true }, + scope, + options.input.query, + maxResults, + {}, + new TelemetryCorrelationId('github-text-search-tool'), + token, + ); + + const chunks = searchResults.chunks.map((entry): FileChunkAndScore => { + let file = entry.file; + if (file.scheme === 'githubRepoResult') { + // Path format: /owner/repo/relative/file/path + const parts = file.path.split('/').filter(Boolean); + if (parts.length >= 3) { + const nwo = `${parts[0]}/${parts[1]}`; + const relativePath = parts.slice(2).join('/'); + file = URI.joinPath(URI.parse('https://github.com'), nwo, 'tree', 'main', '/' + relativePath).with({ + fragment: entry.range.startLineNumber > 0 + ? `L${entry.range.startLineNumber}-L${entry.range.endLineNumber}` + : undefined, + }); + } + } + return { chunk: { ...entry, file }, distance: undefined }; + }); + + let references: PromptReference[] = []; + const json = await renderPromptElementJSON(this._instantiationService, GithubTextSearchResults, { + chunks, + referencesOut: references, + }); + const result = new ExtendedLanguageModelToolResult([ + new LanguageModelPromptTsxPart(json), + ]); + + references = getUniqueReferences(references); + const scopeLabel = options.input.scope; + result.toolResultMessage = references.length === 0 ? + new MarkdownString(l10n.t`Searched ${scopeLabel} for "${options.input.query}", no results`) : + references.length === 1 ? + new MarkdownString(l10n.t`Searched ${scopeLabel} for "${options.input.query}", 1 result`) : + new MarkdownString(l10n.t`Searched ${scopeLabel} for "${options.input.query}", ${references.length} results`); + result.toolResultDetails = references + .map(r => r.anchor) + .filter(r => isUri(r) || isLocation(r)); + return result; + } + + async prepareInvocation(options: vscode.LanguageModelToolInvocationPrepareOptions, _token: vscode.CancellationToken): Promise { + if (!options.input.scope) { + throw new Error(l10n.t`Invalid input. No 'scope' argument provided`); + } + if (!parseScope(options.input.scope)) { + throw new Error(l10n.t`Invalid input. Could not parse 'scope' argument`); + } + return { + invocationMessage: l10n.t("Searching '{0}' for '{1}'", options.input.scope, options.input.query), + }; + } +} + +function parseScope(scope: string): GithubCodeSearchScope | undefined { + if (!scope) { + return undefined; + } + if (scope.includes('/')) { + const repoId = GithubRepoId.parse(scope); + if (!repoId) { + return undefined; + } + return { kind: 'repo', githubRepoId: repoId, localRepoRoot: undefined, indexedCommit: undefined }; + } + + return { kind: 'org', org: scope }; +} + +interface GithubTextSearchResultsProps extends BasePromptElementProps { + readonly chunks: FileChunkAndScore[]; + readonly referencesOut: PromptReference[]; +} + +class GithubTextSearchResults extends PromptElement { + override render(_state: void, _sizing: PromptSizing): PromptPiece | undefined { + const references: PromptReference[] = []; + const seenFiles = new Set(); + + const renderedChunks = this.props.chunks + .filter(x => x.chunk.text) + .map(chunk => { + const fileKey = chunk.chunk.file.toString(); + if (!seenFiles.has(fileKey)) { + seenFiles.add(fileKey); + references.push(new PromptReference(chunk.chunk.file)); + } + + const githubInfo = parseGithubFileUrl(chunk.chunk.file); + const displayPath = githubInfo?.path ?? chunk.chunk.file.toString(); + const nwoLabel = githubInfo?.nwo; + + const lineInfo = chunk.chunk.range.startLineNumber > 0 + ? ` starting at line ${chunk.chunk.range.startLineNumber}` + : ''; + + const headerText = nwoLabel + ? `Text match excerpt from \`${nwoLabel}\` in \`${displayPath}\`${lineInfo}:` + : `Text match excerpt in \`${displayPath}\`${lineInfo}:`; + + return + {headerText}
+ {createFencedCodeBlock(getLanguageId(chunk.chunk.file), chunk.chunk.text, undefined, displayPath)}

+
; + }); + + this.props.referencesOut.push(...references); + + return <> + + {renderedChunks} + ; + } +} + +function parseGithubFileUrl(uri: URI): { nwo: string; path: string } | undefined { + if (uri.scheme === 'https' && uri.authority === 'github.com') { + const parts = uri.path.split('/').filter(Boolean); + // Pattern: /owner/repo/tree/branch/...path + if (parts.length >= 4 && parts[2] === 'tree') { + return { + nwo: `${parts[0]}/${parts[1]}`, + path: parts.slice(4).join('/'), + }; + } + } + return undefined; +} + +ToolRegistry.registerTool(GithubTextSearchTool); diff --git a/extensions/copilot/src/platform/github/common/githubAPI.ts b/extensions/copilot/src/platform/github/common/githubAPI.ts index dcd7b8cafc260..86b9893e182d2 100644 --- a/extensions/copilot/src/platform/github/common/githubAPI.ts +++ b/extensions/copilot/src/platform/github/common/githubAPI.ts @@ -133,6 +133,8 @@ export interface GitHubAPIRequestOptions { version?: string; type?: 'json' | 'text'; userAgent?: string; + accept?: string; + additionalHeaders?: { [key: string]: string }; returnStatusCodeOnError?: boolean; silent404?: boolean; callSite?: string; @@ -147,9 +149,10 @@ export async function makeGitHubAPIRequest( method: 'GET' | 'POST', token: string | undefined, options?: GitHubAPIRequestOptions) { - const { body, version, type = 'json', userAgent, returnStatusCodeOnError = false, silent404 = false, callSite = 'github-api-rest' } = options ?? {}; + const { body, version, type = 'json', userAgent, accept, additionalHeaders, returnStatusCodeOnError = false, silent404 = false, callSite = 'github-api-rest' } = options ?? {}; const headers: { [key: string]: string } = { - 'Accept': 'application/vnd.github+json', + 'Accept': accept ?? 'application/vnd.github+json', + ...additionalHeaders, }; if (token) { headers['Authorization'] = `Bearer ${token}`; diff --git a/extensions/copilot/src/platform/remoteCodeSearch/common/adoCodeSearchService.ts b/extensions/copilot/src/platform/remoteCodeSearch/common/adoCodeSearchService.ts index 7fe8f22869c20..9df8122ddee5e 100644 --- a/extensions/copilot/src/platform/remoteCodeSearch/common/adoCodeSearchService.ts +++ b/extensions/copilot/src/platform/remoteCodeSearch/common/adoCodeSearchService.ts @@ -26,7 +26,7 @@ import { measureExecTime } from '../../log/common/logExecTime'; import { ILogService } from '../../log/common/logService'; import { getRequest, postRequest } from '../../networking/common/networking'; import { ITelemetryService } from '../../telemetry/common/telemetry'; -import { CodeSearchOptions, CodeSearchResult, RemoteCodeSearchError, RemoteCodeSearchIndexState, RemoteCodeSearchIndexStatus } from './remoteCodeSearch'; +import { CodeSearchOptions, RemoteCodeSearchError, RemoteCodeSearchIndexState, RemoteCodeSearchIndexStatus, SemanticCodeSearchResult } from './remoteCodeSearch'; interface ResponseShape { @@ -100,7 +100,7 @@ export interface IAdoCodeSearchService { options: CodeSearchOptions, telemetryInfo: TelemetryCorrelationId, token: CancellationToken, - ): Promise; + ): Promise; } /** @@ -251,7 +251,7 @@ export class AdoCodeSearchService extends Disposable implements IAdoCodeSearchSe options: CodeSearchOptions, telemetryInfo: TelemetryCorrelationId, token: CancellationToken - ): Promise { + ): Promise { const totalSw = new StopWatch(); const authToken = await this.getAdoAuthToken(auth.silent); diff --git a/extensions/copilot/src/platform/remoteCodeSearch/common/githubCodeSearchService.ts b/extensions/copilot/src/platform/remoteCodeSearch/common/githubCodeSearchService.ts index 9ecdcc7041ec2..52b87af651ce5 100644 --- a/extensions/copilot/src/platform/remoteCodeSearch/common/githubCodeSearchService.ts +++ b/extensions/copilot/src/platform/remoteCodeSearch/common/githubCodeSearchService.ts @@ -13,19 +13,20 @@ import { URI } from '../../../util/vs/base/common/uri'; import { Range } from '../../../util/vs/editor/common/core/range'; import { createDecorator, IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation'; import { IAuthenticationService } from '../../authentication/common/authentication'; -import { FileChunkAndScore } from '../../chunking/common/chunk'; +import { FileChunk, FileChunkAndScore } from '../../chunking/common/chunk'; import { stripChunkTextMetadata, truncateToMaxUtf8Length } from '../../chunking/common/chunkingStringUtils'; import { EmbeddingType } from '../../embeddings/common/embeddingsComputer'; import { ICAPIClientService } from '../../endpoint/common/capiClient'; import { IEnvService } from '../../env/common/envService'; import { GithubRepoId, toGithubNwo } from '../../git/common/gitService'; +import { makeGitHubAPIRequest } from '../../github/common/githubAPI'; import { getGithubMetadataHeaders } from '../../github/common/githubApiFetcherService'; import { IIgnoreService } from '../../ignore/common/ignoreService'; import { ILogService } from '../../log/common/logService'; -import { Response } from '../../networking/common/fetcherService'; +import { IFetcherService, Response } from '../../networking/common/fetcherService'; import { postRequest } from '../../networking/common/networking'; import { ITelemetryService } from '../../telemetry/common/telemetry'; -import { CodeSearchOptions, CodeSearchResult, RemoteCodeSearchError, RemoteCodeSearchIndexState, RemoteCodeSearchIndexStatus } from './remoteCodeSearch'; +import { CodeSearchOptions, LexicalCodeSearchResult, RemoteCodeSearchError, RemoteCodeSearchIndexState, RemoteCodeSearchIndexStatus, SemanticCodeSearchResult } from './remoteCodeSearch'; interface ResponseShape { @@ -46,6 +47,7 @@ type SemanticSearchResult = { location: { path: string; // file path commit_sha: string; + ref_name: string; repo: { nwo: string; url: string; @@ -54,11 +56,19 @@ type SemanticSearchResult = { }; export interface GithubCodeSearchRepoInfo { + readonly kind: 'repo'; readonly githubRepoId: GithubRepoId; readonly localRepoRoot: URI | undefined; readonly indexedCommit: string | undefined; } +export interface GithubCodeSearchOrgInfo { + readonly kind: 'org'; + readonly org: string; +} + +export type GithubCodeSearchScope = GithubCodeSearchRepoInfo | GithubCodeSearchOrgInfo; + export const IGithubCodeSearchService = createDecorator('IGithubCodeSearchService'); export interface IGithubCodeSearchService { @@ -89,16 +99,29 @@ export interface IGithubCodeSearchService { * * The repo must have been indexed first. Make sure to check {@link getRemoteIndexState} or call {@link triggerIndexing}. */ - searchRepo( + semanticSearch( authOptions: { readonly silent: boolean }, embeddingType: EmbeddingType, - repo: GithubCodeSearchRepoInfo, + scope: GithubCodeSearchRepoInfo, + query: string, + maxResults: number, + options: CodeSearchOptions, + telemetryInfo: TelemetryCorrelationId, + token: CancellationToken, + ): Promise; + + /** + * Lexical searches a given github repo or org for relevant code snippets + */ + lexicalSearch( + authOptions: { readonly silent: boolean }, + scope: GithubCodeSearchScope, query: string, maxResults: number, options: CodeSearchOptions, telemetryInfo: TelemetryCorrelationId, token: CancellationToken, - ): Promise; + ): Promise; } export class GithubCodeSearchService implements IGithubCodeSearchService { @@ -109,6 +132,7 @@ export class GithubCodeSearchService implements IGithubCodeSearchService { @IAuthenticationService private readonly _authenticationService: IAuthenticationService, @ICAPIClientService private readonly _capiClientService: ICAPIClientService, @IEnvService private readonly _envService: IEnvService, + @IFetcherService private readonly _fetcherService: IFetcherService, @IIgnoreService private readonly _ignoreService: IIgnoreService, @ILogService private readonly _logService: ILogService, @ITelemetryService private readonly _telemetryService: ITelemetryService, @@ -252,7 +276,7 @@ export class GithubCodeSearchService implements IGithubCodeSearchService { return Result.ok(true); } - async searchRepo( + async semanticSearch( auth: { readonly silent: boolean }, embeddingType: EmbeddingType, repo: GithubCodeSearchRepoInfo, @@ -261,7 +285,7 @@ export class GithubCodeSearchService implements IGithubCodeSearchService { options: CodeSearchOptions, telemetryInfo: TelemetryCorrelationId, token: CancellationToken - ): Promise { + ): Promise { const authToken = await this.getGithubAccessToken(auth.silent); if (!authToken) { throw new Error('No valid auth token'); @@ -342,6 +366,80 @@ export class GithubCodeSearchService implements IGithubCodeSearchService { return result; } + async lexicalSearch( + auth: { readonly silent: boolean }, + scope: GithubCodeSearchScope, + query: string, + maxResults: number, + options: CodeSearchOptions, + telemetryInfo: TelemetryCorrelationId, + token: CancellationToken + ): Promise { + const authToken = await this.getGithubAccessToken(auth.silent); + if (!authToken) { + throw new Error('No valid auth token'); + } + + const scopeQualifier = scope.kind === 'org' ? `org:${scope.org}` : `repo:${toGithubNwo(scope.githubRepoId)}`; + const searchQuery = `${query} ${scopeQualifier}`; + const routeSlug = `search/code?q=${encodeURIComponent(searchQuery)}&per_page=${maxResults}`; + + const body = await raceCancellationError(makeGitHubAPIRequest( + this._fetcherService, + this._logService, + this._telemetryService, + this._capiClientService.dotcomAPIURL, + routeSlug, + 'GET', + authToken, + { + accept: 'application/vnd.github.text-match+json', + additionalHeaders: getGithubMetadataHeaders(telemetryInfo.callTracker, this._envService), + callSite: 'github-code-search-lexical', + }, + ), token); + + if (!body) { + /* __GDPR__ + "githubCodeSearch.lexicalSearch.error" : { + "owner": "mjbvz", + "comment": "Information about failed lexical code searches", + "workspaceSearchSource": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Caller of the search" }, + "workspaceSearchCorrelationId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Correlation id for the search" } + } + */ + this._telemetryService.sendMSFTTelemetryEvent('githubCodeSearch.lexicalSearch.error', { + workspaceSearchSource: telemetryInfo.callTracker.toString(), + workspaceSearchCorrelationId: telemetryInfo.correlationId, + }); + + throw new Error(`Code search lexical search failed`); + } + if (!Array.isArray(body.items)) { + throw new Error(`Code search lexical search unexpected response json shape`); + } + + const result = await raceCancellationError(parseLexicalSearchResponse(body, scope, options, this._ignoreService), token); + + /* __GDPR__ + "githubCodeSearch.lexicalSearch.success" : { + "owner": "mjbvz", + "comment": "Information about successful lexical code searches", + "workspaceSearchSource": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Caller of the search" }, + "workspaceSearchCorrelationId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Correlation id for the search" }, + "resultCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "Total number of returned items from the search" } + } + */ + this._telemetryService.sendMSFTTelemetryEvent('githubCodeSearch.lexicalSearch.success', { + workspaceSearchSource: telemetryInfo.callTracker.toString(), + workspaceSearchCorrelationId: telemetryInfo.correlationId, + }, { + resultCount: body.items.length, + }); + + return result; + } + private async getGithubAccessToken(silent: boolean) { return (await this._authenticationService.getGitHubSession('permissive', { silent }))?.accessToken ?? (await this._authenticationService.getGitHubSession('any', { silent }))?.accessToken; @@ -370,7 +468,7 @@ export class GithubCodeSearchService implements IGithubCodeSearchService { } } -export async function parseGithubCodeSearchResponse(body: ResponseShape, repo: GithubCodeSearchRepoInfo, options: CodeSearchOptions & { skipVerifyRepo?: boolean }, ignoreService: IIgnoreService): Promise { +export async function parseGithubCodeSearchResponse(body: ResponseShape, repo: GithubCodeSearchRepoInfo, options: CodeSearchOptions & { skipVerifyRepo?: boolean }, ignoreService: IIgnoreService): Promise { let outOfSync = false; const outChunks: FileChunkAndScore[] = []; @@ -415,5 +513,106 @@ export async function parseGithubCodeSearchResponse(body: ResponseShape, repo: G }); })); - return { chunks: outChunks, outOfSync }; + // Extract the remote URL and ref name from the first result + const firstResult = body.results[0]; + let remoteUrl: string | undefined; + let refName: string | undefined; + if (firstResult) { + // Derive the web URL from the API URL (e.g. https://api.github.com/repos/o/r -> https://github.com/o/r) + const apiUrl = firstResult.location.repo.url; + const nwo = firstResult.location.repo.nwo; + try { + const parsed = URI.parse(apiUrl); + const host = parsed.authority === 'api.github.com' ? 'github.com' : parsed.authority.replace(/^api\./, ''); + remoteUrl = `https://${host}/${nwo}`; + } catch { + // Fall back to constructing from nwo + remoteUrl = `https://github.com/${nwo}`; + } + + // Extract branch name from ref_name (e.g. "refs/heads/main" -> "main") + const rawRef = firstResult.location.ref_name; + if (rawRef?.startsWith('refs/heads/')) { + refName = rawRef.slice('refs/heads/'.length); + } else if (rawRef) { + refName = rawRef; + } + } + + return { chunks: outChunks, outOfSync, remoteUrl, refName }; +} + +interface LexicalSearchResponseShape { + readonly total_count: number; + readonly incomplete_results: boolean; + readonly items: readonly LexicalSearchItem[]; +} + +type LexicalSearchItem = { + readonly path: string; + readonly repository: { + readonly full_name: string; + }; + readonly text_matches?: readonly { + readonly fragment: string; + readonly matches: readonly { readonly text: string; readonly indices: readonly [number, number] }[]; + readonly object_type: string; + readonly property: string; + }[]; + readonly score: number; +}; + +export async function parseLexicalSearchResponse(body: LexicalSearchResponseShape, scope: GithubCodeSearchScope & { skipVerifyRepo?: boolean }, options: CodeSearchOptions & { skipVerifyRepo?: boolean }, ignoreService: IIgnoreService): Promise { + const outChunks: FileChunk[] = []; + + await Promise.all(body.items.map(async (item): Promise => { + if (!options.skipVerifyRepo && scope.kind === 'repo' && item.repository.full_name.toLowerCase() !== toGithubNwo(scope.githubRepoId)) { + return; + } + if (!options.skipVerifyRepo && scope.kind === 'org' && item.repository.full_name.toLowerCase().split('/')[0] !== scope.org.toLowerCase()) { + return; + } + + const localRepoRoot = scope.kind === 'repo' ? scope.localRepoRoot : undefined; + let fileUri: URI; + if (localRepoRoot) { + fileUri = URI.joinPath(localRepoRoot, item.path); + if (await ignoreService.isCopilotIgnored(fileUri)) { + return; + } + } else { + fileUri = URI.from({ + scheme: 'githubRepoResult', + path: '/' + item.repository.full_name + '/' + item.path + }); + } + + if (!shouldInclude(fileUri, options.globPatterns)) { + return; + } + + const textMatches = item.text_matches?.filter(m => m.property === 'content'); + if (textMatches && textMatches.length > 0) { + for (const match of textMatches) { + outChunks.push({ + file: fileUri, + text: match.fragment, + rawText: undefined, + range: new Range(0, 0, 0, 0), + isFullFile: false, + }); + } + } else { + // No text matches, include the file as a whole-file result + outChunks.push({ + file: fileUri, + text: '', + rawText: undefined, + range: new Range(0, 0, 0, 0), + isFullFile: true, + }); + } + })); + + return { chunks: outChunks, outOfSync: false }; } diff --git a/extensions/copilot/src/platform/remoteCodeSearch/common/remoteCodeSearch.ts b/extensions/copilot/src/platform/remoteCodeSearch/common/remoteCodeSearch.ts index e53ba6da08f3b..3066f82630ec6 100644 --- a/extensions/copilot/src/platform/remoteCodeSearch/common/remoteCodeSearch.ts +++ b/extensions/copilot/src/platform/remoteCodeSearch/common/remoteCodeSearch.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import { GlobIncludeOptions } from '../../../util/common/glob'; -import { FileChunkAndScore } from '../../chunking/common/chunk'; +import { FileChunk, FileChunkAndScore } from '../../chunking/common/chunk'; export enum RemoteCodeSearchIndexStatus { /** The repo index is built and ready to use */ @@ -29,11 +29,23 @@ export type RemoteCodeSearchError = | { readonly type: 'generic-error'; readonly error: Error } ; -export interface CodeSearchResult { - readonly chunks: readonly FileChunkAndScore[]; - +interface BaseCodeSearchResult { /** Tracks if the commit sha code search used differs from the one we used to compute the local diff */ readonly outOfSync: boolean; + + /** The web URL of the remote repo, e.g. `https://github.com/microsoft/vscode` */ + readonly remoteUrl?: string; + + /** The branch name the results are from, e.g. `main` */ + readonly refName?: string; +} + +export interface SemanticCodeSearchResult extends BaseCodeSearchResult { + readonly chunks: readonly FileChunkAndScore[]; +} + +export interface LexicalCodeSearchResult extends BaseCodeSearchResult { + readonly chunks: readonly FileChunk[]; } export interface CodeSearchOptions { diff --git a/extensions/copilot/src/platform/workspaceChunkSearch/node/codeSearch/codeSearchChunkSearch.ts b/extensions/copilot/src/platform/workspaceChunkSearch/node/codeSearch/codeSearchChunkSearch.ts index c148434c72934..66cf005867aa0 100644 --- a/extensions/copilot/src/platform/workspaceChunkSearch/node/codeSearch/codeSearchChunkSearch.ts +++ b/extensions/copilot/src/platform/workspaceChunkSearch/node/codeSearch/codeSearchChunkSearch.ts @@ -33,7 +33,7 @@ import { Change } from '../../../git/vscode/git'; import { logExecTime, LogExecTime } from '../../../log/common/logExecTime'; import { ILogService } from '../../../log/common/logService'; import { IAdoCodeSearchService } from '../../../remoteCodeSearch/common/adoCodeSearchService'; -import { CodeSearchResult } from '../../../remoteCodeSearch/common/remoteCodeSearch'; +import { SemanticCodeSearchResult } from '../../../remoteCodeSearch/common/remoteCodeSearch'; import { ICodeSearchAuthenticationService } from '../../../remoteCodeSearch/node/codeSearchRepoAuth'; import { isGitHubRemoteRepository } from '../../../remoteRepositories/common/utils'; import { IExperimentationService } from '../../../telemetry/common/nullExperimentationService'; @@ -555,13 +555,13 @@ export class CodeSearchChunkSearch extends Disposable { localSearchCts.cancel(); throw e; }) - : Promise.resolve({ chunks: [], outOfSync: false }); + : Promise.resolve({ chunks: [], outOfSync: false }); const localSearchOperation = raceTimeout(this.searchLocalDiff(diffArray, sizing, query, options, innerTelemetryInfo, localSearchCts.token), this.localDiffSearchTimeout, () => { localSearchCts.cancel(); }); - let codeSearchResults: CodeSearchResult | undefined; + let codeSearchResults: SemanticCodeSearchResult | undefined; let localResults: DiffSearchResult | undefined; try { codeSearchResults = await raceCancellationError(codeSearchOperation, token); @@ -720,7 +720,7 @@ export class CodeSearchChunkSearch extends Disposable { */ this._telemetryService.sendMSFTTelemetryEvent('codeSearchChunkSearch.perf.doCodeSearchWithRetry', { status }, { execTime }); }) - private async doCodeSearch(query: WorkspaceChunkQueryWithEmbeddings, repos: readonly CodeSearchRepo[], sizing: StrategySearchSizing, options: WorkspaceChunkSearchOptions, telemetryInfo: TelemetryCorrelationId, token: CancellationToken): Promise { + private async doCodeSearch(query: WorkspaceChunkQueryWithEmbeddings, repos: readonly CodeSearchRepo[], sizing: StrategySearchSizing, options: WorkspaceChunkSearchOptions, telemetryInfo: TelemetryCorrelationId, token: CancellationToken): Promise { const results = await Promise.all(repos.map(repo => { return repo.searchRepo({ silent: true }, this._embeddingType, query.queryText, sizing.maxResultCountHint, options, telemetryInfo, token); })); diff --git a/extensions/copilot/src/platform/workspaceChunkSearch/node/codeSearch/codeSearchRepo.ts b/extensions/copilot/src/platform/workspaceChunkSearch/node/codeSearch/codeSearchRepo.ts index 8de868ca13d98..11ad3d58c41ab 100644 --- a/extensions/copilot/src/platform/workspaceChunkSearch/node/codeSearch/codeSearchRepo.ts +++ b/extensions/copilot/src/platform/workspaceChunkSearch/node/codeSearch/codeSearchRepo.ts @@ -16,7 +16,7 @@ import { measureExecTime } from '../../../log/common/logExecTime'; import { ILogService } from '../../../log/common/logService'; import { IAdoCodeSearchService } from '../../../remoteCodeSearch/common/adoCodeSearchService'; import { IGithubCodeSearchService } from '../../../remoteCodeSearch/common/githubCodeSearchService'; -import { CodeSearchResult, RemoteCodeSearchError, RemoteCodeSearchIndexState, RemoteCodeSearchIndexStatus } from '../../../remoteCodeSearch/common/remoteCodeSearch'; +import { RemoteCodeSearchError, RemoteCodeSearchIndexState, RemoteCodeSearchIndexStatus, SemanticCodeSearchResult } from '../../../remoteCodeSearch/common/remoteCodeSearch'; import { ITelemetryService } from '../../../telemetry/common/telemetry'; import { WorkspaceChunkSearchOptions } from '../../common/workspaceChunkSearch'; import { RepoInfo } from './repoTracker'; @@ -149,7 +149,7 @@ export interface CodeSearchRepo extends IDisposable { options: WorkspaceChunkSearchOptions, telemetryInfo: TelemetryCorrelationId, token: CancellationToken - ): Promise; + ): Promise; triggerRemoteIndexingOfRepo(triggerReason: BuildIndexTriggerReason, telemetryInfo: TelemetryCorrelationId): Promise>; @@ -232,7 +232,7 @@ abstract class BaseRemoteCodeSearchRepo extends Disposable implements CodeSearch this._onDidChangeStatus.fire(this._state.status); } - public abstract searchRepo(authOptions: { silent: boolean }, embeddingType: EmbeddingType, resolvedQuery: string, maxResultCountHint: number, options: WorkspaceChunkSearchOptions, telemetryInfo: TelemetryCorrelationId, token: CancellationToken): Promise; + public abstract searchRepo(authOptions: { silent: boolean }, embeddingType: EmbeddingType, resolvedQuery: string, maxResultCountHint: number, options: WorkspaceChunkSearchOptions, telemetryInfo: TelemetryCorrelationId, token: CancellationToken): Promise; public abstract triggerRemoteIndexingOfRepo(triggerReason: BuildIndexTriggerReason, telemetryInfo: TelemetryCorrelationId): Promise>; public abstract prepareSearch(telemetryInfo: TelemetryCorrelationId, token: CancellationToken): Promise; @@ -381,8 +381,9 @@ export class GithubCodeSearchRepo extends BaseRemoteCodeSearchRepo { super(repoInfo, remoteInfo, logService, telemetryService); } - public override async searchRepo(authOptions: { silent: boolean }, embeddingType: EmbeddingType, resolvedQuery: string, maxResultCountHint: number, options: WorkspaceChunkSearchOptions, telemetryInfo: TelemetryCorrelationId, token: CancellationToken): Promise { - const result = await this._githubCodeSearchService.searchRepo(authOptions, embeddingType, { + public override async searchRepo(authOptions: { silent: boolean }, embeddingType: EmbeddingType, resolvedQuery: string, maxResultCountHint: number, options: WorkspaceChunkSearchOptions, telemetryInfo: TelemetryCorrelationId, token: CancellationToken): Promise { + const result = await this._githubCodeSearchService.semanticSearch(authOptions, embeddingType, { + kind: 'repo', githubRepoId: this._githubRepoId, localRepoRoot: this.repoInfo.rootUri, indexedCommit: undefined, // TODO @@ -502,7 +503,7 @@ export class AdoCodeSearchRepo extends BaseRemoteCodeSearchRepo { super(repoInfo, remoteInfo, logService, telemetryService); } - public searchRepo(authOptions: { silent: boolean }, _embeddingType: EmbeddingType, resolvedQuery: string, maxResultCountHint: number, options: WorkspaceChunkSearchOptions, telemetryInfo: TelemetryCorrelationId, token: CancellationToken): Promise { + public searchRepo(authOptions: { silent: boolean }, _embeddingType: EmbeddingType, resolvedQuery: string, maxResultCountHint: number, options: WorkspaceChunkSearchOptions, telemetryInfo: TelemetryCorrelationId, token: CancellationToken): Promise { return this._adoCodeSearchService.searchRepo(authOptions, { adoRepoId: this._adoRepoId, localRepoRoot: this.repoInfo.rootUri, diff --git a/extensions/copilot/test/base/simuliationWorkspaceChunkSearch.ts b/extensions/copilot/test/base/simuliationWorkspaceChunkSearch.ts index 1bfd2125363e9..c79ae2e2384a3 100644 --- a/extensions/copilot/test/base/simuliationWorkspaceChunkSearch.ts +++ b/extensions/copilot/test/base/simuliationWorkspaceChunkSearch.ts @@ -7,8 +7,8 @@ import { EmbeddingType } from '../../src/platform/embeddings/common/embeddingsCo import { GithubRepoId } from '../../src/platform/git/common/gitService'; import { IIgnoreService } from '../../src/platform/ignore/common/ignoreService'; import { ILogService } from '../../src/platform/log/common/logService'; -import { GithubCodeSearchRepoInfo, IGithubCodeSearchService, parseGithubCodeSearchResponse } from '../../src/platform/remoteCodeSearch/common/githubCodeSearchService'; -import { CodeSearchResult, RemoteCodeSearchError, RemoteCodeSearchIndexState, RemoteCodeSearchIndexStatus } from '../../src/platform/remoteCodeSearch/common/remoteCodeSearch'; +import { GithubCodeSearchScope, IGithubCodeSearchService, parseGithubCodeSearchResponse } from '../../src/platform/remoteCodeSearch/common/githubCodeSearchService'; +import { LexicalCodeSearchResult, RemoteCodeSearchError, RemoteCodeSearchIndexState, RemoteCodeSearchIndexStatus, SemanticCodeSearchResult } from '../../src/platform/remoteCodeSearch/common/remoteCodeSearch'; import { WorkspaceChunkQuery, WorkspaceChunkSearchOptions } from '../../src/platform/workspaceChunkSearch/common/workspaceChunkSearch'; import { BuildIndexTriggerReason, TriggerIndexingError } from '../../src/platform/workspaceChunkSearch/node/codeSearch/codeSearchRepo'; import { IWorkspaceChunkSearchService, WorkspaceChunkSearchResult, WorkspaceChunkSearchSizing, WorkspaceIndexState } from '../../src/platform/workspaceChunkSearch/node/workspaceChunkSearchService'; @@ -34,7 +34,11 @@ class SimulationGithubCodeSearchService extends Disposable implements IGithubCod super(); } - async searchRepo(authOptions: { silent: boolean }, embeddingType: EmbeddingType, repo: GithubCodeSearchRepoInfo, query: string, maxResults: number, options: WorkspaceChunkSearchOptions, _telemetryInfo: TelemetryCorrelationId, token: CancellationToken): Promise { + async lexicalSearch(_authOptions: { silent: boolean }, _scope: GithubCodeSearchScope, _query: string, _maxResults: number, _options: WorkspaceChunkSearchOptions, _telemetryInfo: TelemetryCorrelationId, _token: CancellationToken): Promise { + throw new Error('Method not implemented.'); + } + + async semanticSearch(authOptions: { silent: boolean }, embeddingType: EmbeddingType, repo: GithubCodeSearchScope & { kind: 'repo' }, query: string, maxResults: number, options: WorkspaceChunkSearchOptions, _telemetryInfo: TelemetryCorrelationId, token: CancellationToken): Promise { this._logService.trace(`SimulationGithubCodeSearchService::searchRepo(${repo.githubRepoId}, ${query})`); const response = await fetch(searchEndpoint, { method: 'POST', @@ -97,7 +101,8 @@ export class SimulationCodeSearchChunkSearchService extends Disposable implement async searchFileChunks(sizing: WorkspaceChunkSearchSizing, query: WorkspaceChunkQuery, options: WorkspaceChunkSearchOptions, telemetryInfo: TelemetryCorrelationId, progress: Progress | undefined, token: CancellationToken): Promise { const repo = new GithubRepoId('test-org', 'test-repo'); try { - const results = await this._githubCodeSearchService.searchRepo({ silent: true }, EmbeddingType.text3small_512, { + const results = await this._githubCodeSearchService.semanticSearch({ silent: true }, EmbeddingType.text3small_512, { + kind: 'repo', githubRepoId: repo, indexedCommit: undefined, localRepoRoot: undefined, From 6c8f319b328ac11fa2f0838a2ebc914fb28ec2e7 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Thu, 23 Apr 2026 17:19:53 -0400 Subject: [PATCH 59/70] wrap bash compound commands in `bash -c` before nohup (#312202) fix issue with nohup --- .../commandLineBackgroundDetachRewriter.ts | 49 +++++++- ...ommandLineBackgroundDetachRewriter.test.ts | 114 ++++++++++++++++++ 2 files changed, 158 insertions(+), 5 deletions(-) diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineRewriter/commandLineBackgroundDetachRewriter.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineRewriter/commandLineBackgroundDetachRewriter.ts index 842fe8807f423..4f766dc5d2587 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineRewriter/commandLineBackgroundDetachRewriter.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineRewriter/commandLineBackgroundDetachRewriter.ts @@ -7,7 +7,7 @@ import { Disposable } from '../../../../../../../base/common/lifecycle.js'; import { OperatingSystem } from '../../../../../../../base/common/platform.js'; import { IConfigurationService } from '../../../../../../../platform/configuration/common/configuration.js'; import { TerminalChatAgentToolsSettingId } from '../../../common/terminalChatAgentToolsConfiguration.js'; -import { isPowerShell } from '../../runInTerminalHelpers.js'; +import { isFish, isPowerShell } from '../../runInTerminalHelpers.js'; import type { ICommandLineRewriter, ICommandLineRewriterOptions, ICommandLineRewriterResult } from './commandLineRewriter.js'; /** @@ -46,13 +46,31 @@ export class CommandLineBackgroundDetachRewriter extends Disposable implements I } private _rewriteForPosix(options: ICommandLineRewriterOptions): ICommandLineRewriterResult { + const trimmed = options.commandLine.trimEnd(); + + // nohup only accepts a simple external command as its argument — it cannot exec + // compound statements (for/while/if/case) or shell builtins (eval/set/export/source). + // Wrap those in ` -c '...'` so the whole construct runs as a single executable. + let commandToWrap = trimmed; + if (this._needsShellCWrapper(trimmed)) { + if (isFish(options.shell, options.os)) { + // Fish does not support the POSIX '\'' escape inside single-quoted strings. + // Use a double-quoted string and escape backslash and double-quote instead. + const escaped = trimmed.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + commandToWrap = `${options.shell} -c "${escaped}"`; + } else { + // bash/zsh: escape single quotes for use inside a single-quoted shell -c '...' string. + const escaped = trimmed.replace(/'/g, `'\\''`); + commandToWrap = `${options.shell} -c '${escaped}'`; + } + } + // If the command already ends with a single trailing `&` (background operator, // as opposed to `&&` for command chaining), don't append another one. - const trimmed = options.commandLine.trimEnd(); - const endsWithBackgroundAmp = /(?:^|[^&])&$/.test(trimmed); + const endsWithBackgroundAmp = /(?:^|[^&])&$/.test(commandToWrap); const rewritten = endsWithBackgroundAmp - ? `nohup ${trimmed}` - : `nohup ${options.commandLine} &`; + ? `nohup ${commandToWrap}` + : `nohup ${commandToWrap} &`; return { rewritten, reasoning: 'Wrapped background command with nohup to survive terminal shutdown', @@ -60,6 +78,27 @@ export class CommandLineBackgroundDetachRewriter extends Disposable implements I }; } + /** + * Returns true when the command uses shell compound constructs or builtins that + * `nohup` cannot exec directly. Such commands must be wrapped in ` -c '...'` before + * being passed to nohup. + */ + private _needsShellCWrapper(commandLine: string): boolean { + const trimmed = commandLine.trimStart(); + return ( + // Bash compound command keywords — syntax constructs that are not executables. + /^(for|while|until|if|case|select|function)\b/.test(trimmed) || + // Shell builtins — these only run meaningfully inside the current shell; nohup + // cannot exec them (eval, set, export, source, unset, declare, etc.). + /^(eval|set|export|source|unset|declare|typeset|local|readonly|alias)\b/.test(trimmed) || + // `. file` (dot-source builtin). Exclude `./script` (relative path) by requiring + // whitespace after the dot. + /^\.\s/.test(trimmed) || + // Compound groupings: subshell `( ... )` or brace group `{ ...; }`. + /^[{(]/.test(trimmed) + ); + } + private _rewriteForPowerShell(options: ICommandLineRewriterOptions): ICommandLineRewriterResult | undefined { if (!isPowerShell(options.shell, options.os)) { return undefined; diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/commandLineBackgroundDetachRewriter.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/commandLineBackgroundDetachRewriter.test.ts index cddcc474d7195..0dbd2ca5e5e45 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/commandLineBackgroundDetachRewriter.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/commandLineBackgroundDetachRewriter.test.ts @@ -94,6 +94,96 @@ suite('CommandLineBackgroundDetachRewriter', () => { }); }); + suite('POSIX shell -c wrapping for compound commands and builtins', () => { + test('for loop should be wrapped using bash shell path', () => { + deepStrictEqual(rewriter.rewrite(createOptions('for i in $(seq 1 90); do echo $i; sleep 1; done', '/bin/bash', OperatingSystem.Linux, true)), { + rewritten: `nohup /bin/bash -c 'for i in $(seq 1 90); do echo $i; sleep 1; done' &`, + reasoning: 'Wrapped background command with nohup to survive terminal shutdown', + forDisplay: 'for i in $(seq 1 90); do echo $i; sleep 1; done', + }); + }); + + test('while loop should be wrapped in shell -c', () => { + deepStrictEqual(rewriter.rewrite(createOptions('while true; do sleep 1; done', '/bin/bash', OperatingSystem.Linux, true)), { + rewritten: `nohup /bin/bash -c 'while true; do sleep 1; done' &`, + reasoning: 'Wrapped background command with nohup to survive terminal shutdown', + forDisplay: 'while true; do sleep 1; done', + }); + }); + + test('if statement should be wrapped in shell -c', () => { + deepStrictEqual(rewriter.rewrite(createOptions('if [ -f file ]; then cat file; fi', '/bin/bash', OperatingSystem.Linux, true)), { + rewritten: `nohup /bin/bash -c 'if [ -f file ]; then cat file; fi' &`, + reasoning: 'Wrapped background command with nohup to survive terminal shutdown', + forDisplay: 'if [ -f file ]; then cat file; fi', + }); + }); + + test('eval builtin should be wrapped in shell -c', () => { + deepStrictEqual(rewriter.rewrite(createOptions('eval $SETUP_ENV && opam install coq --yes', '/bin/bash', OperatingSystem.Linux, true)), { + rewritten: `nohup /bin/bash -c 'eval $SETUP_ENV && opam install coq --yes' &`, + reasoning: 'Wrapped background command with nohup to survive terminal shutdown', + forDisplay: 'eval $SETUP_ENV && opam install coq --yes', + }); + }); + + test('set builtin should be wrapped in shell -c', () => { + deepStrictEqual(rewriter.rewrite(createOptions('set -e; cmd1; cmd2', '/bin/bash', OperatingSystem.Linux, true)), { + rewritten: `nohup /bin/bash -c 'set -e; cmd1; cmd2' &`, + reasoning: 'Wrapped background command with nohup to survive terminal shutdown', + forDisplay: 'set -e; cmd1; cmd2', + }); + }); + + test('export builtin should be wrapped in shell -c', () => { + deepStrictEqual(rewriter.rewrite(createOptions('export PATH="/usr/local/bin:$PATH"; myapp', '/bin/bash', OperatingSystem.Linux, true)), { + rewritten: `nohup /bin/bash -c 'export PATH="/usr/local/bin:$PATH"; myapp' &`, + reasoning: 'Wrapped background command with nohup to survive terminal shutdown', + forDisplay: 'export PATH="/usr/local/bin:$PATH"; myapp', + }); + }); + + test('dot-source builtin should be wrapped in shell -c', () => { + deepStrictEqual(rewriter.rewrite(createOptions('. /etc/profile; myapp', '/bin/bash', OperatingSystem.Linux, true)), { + rewritten: `nohup /bin/bash -c '. /etc/profile; myapp' &`, + reasoning: 'Wrapped background command with nohup to survive terminal shutdown', + forDisplay: '. /etc/profile; myapp', + }); + }); + + test('relative path ./script should NOT be wrapped in shell -c', () => { + deepStrictEqual(rewriter.rewrite(createOptions('./start.sh', '/bin/bash', OperatingSystem.Linux, true)), { + rewritten: 'nohup ./start.sh &', + reasoning: 'Wrapped background command with nohup to survive terminal shutdown', + forDisplay: './start.sh', + }); + }); + + test('brace group should be wrapped in shell -c', () => { + deepStrictEqual(rewriter.rewrite(createOptions('{ cmd1; cmd2; }', '/bin/bash', OperatingSystem.Linux, true)), { + rewritten: `nohup /bin/bash -c '{ cmd1; cmd2; }' &`, + reasoning: 'Wrapped background command with nohup to survive terminal shutdown', + forDisplay: '{ cmd1; cmd2; }', + }); + }); + + test('single quotes in command should be properly escaped', () => { + deepStrictEqual(rewriter.rewrite(createOptions(`for f in *.txt; do echo 'file:' $f; done`, '/bin/bash', OperatingSystem.Linux, true)), { + rewritten: `nohup /bin/bash -c 'for f in *.txt; do echo '\\''file:'\\'' $f; done' &`, + reasoning: 'Wrapped background command with nohup to survive terminal shutdown', + forDisplay: `for f in *.txt; do echo 'file:' $f; done`, + }); + }); + + test('simple external command should NOT be wrapped in shell -c', () => { + deepStrictEqual(rewriter.rewrite(createOptions('python3 app.py', '/bin/bash', OperatingSystem.Linux, true)), { + rewritten: 'nohup python3 app.py &', + reasoning: 'Wrapped background command with nohup to survive terminal shutdown', + forDisplay: 'python3 app.py', + }); + }); + }); + suite('POSIX (zsh)', () => { test('should wrap with nohup', () => { deepStrictEqual(rewriter.rewrite(createOptions('node server.js', '/bin/zsh', OperatingSystem.Linux, true)), { @@ -102,6 +192,14 @@ suite('CommandLineBackgroundDetachRewriter', () => { forDisplay: 'node server.js', }); }); + + test('for loop should be wrapped using zsh shell path', () => { + deepStrictEqual(rewriter.rewrite(createOptions('for i in $(seq 1 10); do echo $i; done', '/bin/zsh', OperatingSystem.Linux, true)), { + rewritten: `nohup /bin/zsh -c 'for i in $(seq 1 10); do echo $i; done' &`, + reasoning: 'Wrapped background command with nohup to survive terminal shutdown', + forDisplay: 'for i in $(seq 1 10); do echo $i; done', + }); + }); }); suite('POSIX (fish)', () => { @@ -112,6 +210,22 @@ suite('CommandLineBackgroundDetachRewriter', () => { forDisplay: 'ruby app.rb', }); }); + + test('for loop should be wrapped using fish shell path with double-quote escaping', () => { + deepStrictEqual(rewriter.rewrite(createOptions('for i in (seq 1 10); echo $i; end', '/usr/bin/fish', OperatingSystem.Linux, true)), { + rewritten: `nohup /usr/bin/fish -c "for i in (seq 1 10); echo $i; end" &`, + reasoning: 'Wrapped background command with nohup to survive terminal shutdown', + forDisplay: 'for i in (seq 1 10); echo $i; end', + }); + }); + + test('compound command with double quotes should be escaped for fish', () => { + deepStrictEqual(rewriter.rewrite(createOptions('for f in *.txt; echo "file: $f"; end', '/usr/bin/fish', OperatingSystem.Linux, true)), { + rewritten: `nohup /usr/bin/fish -c "for f in *.txt; echo \\"file: $f\\"; end" &`, + reasoning: 'Wrapped background command with nohup to survive terminal shutdown', + forDisplay: 'for f in *.txt; echo "file: $f"; end', + }); + }); }); suite('Windows (PowerShell)', () => { From 669a8d3e54e233ef74fbd43530bbe37c007ada38 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Thu, 23 Apr 2026 14:24:02 -0700 Subject: [PATCH 60/70] Include line number --- .../src/extension/tools/node/githubTextSearchTool.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/extensions/copilot/src/extension/tools/node/githubTextSearchTool.tsx b/extensions/copilot/src/extension/tools/node/githubTextSearchTool.tsx index a8f01b740cdb9..4fcc1e85bca19 100644 --- a/extensions/copilot/src/extension/tools/node/githubTextSearchTool.tsx +++ b/extensions/copilot/src/extension/tools/node/githubTextSearchTool.tsx @@ -145,9 +145,7 @@ class GithubTextSearchResults extends PromptElement 0 - ? ` starting at line ${chunk.chunk.range.startLineNumber}` - : ''; + const lineInfo = ` starting at line ${chunk.chunk.range.startLineNumber}`; const headerText = nwoLabel ? `Text match excerpt from \`${nwoLabel}\` in \`${displayPath}\`${lineInfo}:` @@ -155,7 +153,7 @@ class GithubTextSearchResults extends PromptElement {headerText}
- {createFencedCodeBlock(getLanguageId(chunk.chunk.file), chunk.chunk.text, undefined, displayPath)}

+ {createFencedCodeBlock(getLanguageId(chunk.chunk.file), chunk.chunk.text)}

; }); From 5f818f4ca4eaa33513b669aa9241991ec1d6599f Mon Sep 17 00:00:00 2001 From: Martin Aeschlimann Date: Thu, 23 Apr 2026 23:31:08 +0200 Subject: [PATCH 61/70] use CustomizationHarnessService to get and resolve slash command (#312176) * use CustomizationHarnessService to get and resolve slash command * Update src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputEditorContrib.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/vs/workbench/contrib/chat/test/common/customizationHarnessService.test.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/vs/workbench/contrib/chat/common/customizationHarnessService.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../promptFiles/common/promptsService.ts | 4 +- .../test/common/mockPromptsService.ts | 8 +- .../promptFiles/vscode/promptsServiceImpl.ts | 2 +- .../browser/customizationHarnessService.ts | 11 +- .../aiCustomizationManagement.contribution.ts | 12 +- .../aiCustomizationManagementEditor.ts | 11 +- .../customizationHarnessService.ts | 11 +- .../browser/aiCustomization/mcpListWidget.ts | 5 +- .../chatSessions/chatSessions.contribution.ts | 14 +- .../contrib/chat/browser/widget/chatWidget.ts | 10 +- .../input/editor/chatInputCompletions.ts | 9 +- .../input/editor/chatInputEditorContrib.ts | 13 +- .../common/customizationHarnessService.ts | 176 ++++++++++++------ .../aiCustomizationListWidget.test.ts | 1 + .../customizationHarnessService.test.ts | 92 ++++++--- .../service/mockPromptsService.ts | 2 +- .../aiCustomizationListWidget.fixture.ts | 6 +- ...aiCustomizationManagementEditor.fixture.ts | 60 +++--- 18 files changed, 284 insertions(+), 163 deletions(-) diff --git a/extensions/copilot/src/platform/promptFiles/common/promptsService.ts b/extensions/copilot/src/platform/promptFiles/common/promptsService.ts index b2494cb03502c..40c585ba2e5ff 100644 --- a/extensions/copilot/src/platform/promptFiles/common/promptsService.ts +++ b/extensions/copilot/src/platform/promptFiles/common/promptsService.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type { ChatCustomAgent, ChatHook, ChatInstruction, ChatPlugin, ChatSkill } from 'vscode'; +import type { ChatCustomAgent, ChatHook, ChatInstruction, ChatPlugin, ChatSkill, ChatSlashCommand } from 'vscode'; import { createServiceIdentifier } from '../../../util/common/services'; import { Event } from '../../../util/vs/base/common/event'; import { CancellationToken } from '../../../util/vs/base/common/cancellation'; @@ -46,7 +46,7 @@ export interface IPromptsService { * Returns the slash command prompt files. These are prompts and skills * from all sources (workspace, user, and extension-provided). */ - getSlashCommands(token: CancellationToken): Promise; + getSlashCommands(token: CancellationToken): Promise; /** * An event that fires when the list of {@link instructions instructions} changes. diff --git a/extensions/copilot/src/platform/promptFiles/test/common/mockPromptsService.ts b/extensions/copilot/src/platform/promptFiles/test/common/mockPromptsService.ts index 61308268ce3b6..2063c60db0969 100644 --- a/extensions/copilot/src/platform/promptFiles/test/common/mockPromptsService.ts +++ b/extensions/copilot/src/platform/promptFiles/test/common/mockPromptsService.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type { ChatCustomAgent, ChatHook, ChatInstruction, ChatPlugin, ChatSkill } from 'vscode'; +import type { ChatCustomAgent, ChatHook, ChatInstruction, ChatPlugin, ChatSkill, ChatSlashCommand } from 'vscode'; import { CancellationToken } from '../../../../util/vs/base/common/cancellation'; import { Emitter, Event } from '../../../../util/vs/base/common/event'; import { Disposable } from '../../../../util/vs/base/common/lifecycle'; @@ -31,7 +31,7 @@ export class MockPromptsService extends Disposable implements IPromptsService { readonly onDidChangePlugins: Event = this._onDidChangePlugins.event; private _customAgents: readonly ChatCustomAgent[] = []; - private _slashCommands: readonly ParsedPromptFile[] = []; + private _slashCommands: readonly ChatSlashCommand[] = []; private _instructions: readonly ChatInstruction[] = []; private _skills: readonly ChatSkill[] = []; private _hooks: readonly ChatHook[] = []; @@ -47,7 +47,7 @@ export class MockPromptsService extends Disposable implements IPromptsService { this._onDidChangeCustomAgents.fire(); } - setSlashCommands(commands: readonly ParsedPromptFile[]): void { + setSlashCommands(commands: readonly ChatSlashCommand[]): void { this._slashCommands = commands; } @@ -87,7 +87,7 @@ export class MockPromptsService extends Disposable implements IPromptsService { return Promise.resolve(this._customAgents); } - getSlashCommands(_token: CancellationToken): Promise { + getSlashCommands(_token: CancellationToken): Promise { return Promise.resolve(this._slashCommands); } diff --git a/extensions/copilot/src/platform/promptFiles/vscode/promptsServiceImpl.ts b/extensions/copilot/src/platform/promptFiles/vscode/promptsServiceImpl.ts index f0c6ff8c03b46..368177afac887 100644 --- a/extensions/copilot/src/platform/promptFiles/vscode/promptsServiceImpl.ts +++ b/extensions/copilot/src/platform/promptFiles/vscode/promptsServiceImpl.ts @@ -52,7 +52,7 @@ export class PromptsServiceImpl extends Disposable implements IPromptsService { return Promise.resolve(vscode.chat.getCustomAgents(token)); } - getSlashCommands(token: CancellationToken): Promise { + getSlashCommands(token: CancellationToken): Promise { return Promise.resolve(vscode.chat.getSlashCommands(token)); } diff --git a/src/vs/sessions/contrib/chat/browser/customizationHarnessService.ts b/src/vs/sessions/contrib/chat/browser/customizationHarnessService.ts index ad6bc69c5bfe8..a6ec791d253a7 100644 --- a/src/vs/sessions/contrib/chat/browser/customizationHarnessService.ts +++ b/src/vs/sessions/contrib/chat/browser/customizationHarnessService.ts @@ -3,9 +3,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { - CustomizationHarnessServiceBase, -} from '../../../../workbench/contrib/chat/common/customizationHarnessService.js'; +import { CustomizationHarnessServiceBase } from '../../../../workbench/contrib/chat/common/customizationHarnessService.js'; +import { IPromptsService } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; /** * Sessions-window override of the customization harness service. @@ -15,7 +14,9 @@ import { * and AHP remote servers register directly via `registerExternalHarness()`. */ export class SessionsCustomizationHarnessService extends CustomizationHarnessServiceBase { - constructor() { - super([], ''); + constructor( + @IPromptsService promptsService: IPromptsService + ) { + super([], '', promptsService); } } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts index e99c916b65a80..2f602be4ac8c8 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts @@ -32,8 +32,7 @@ import { EditorInput } from '../../../../common/editor/editorInput.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { IAICustomizationWorkspaceService } from '../../common/aiCustomizationWorkspaceService.js'; -import { localChatSessionType } from '../../common/chatSessionsService.js'; -import { CustomizationHarness, ICustomizationHarnessService } from '../../common/customizationHarnessService.js'; +import { ICustomizationHarnessService } from '../../common/customizationHarnessService.js'; import { getChatSessionType } from '../../common/model/chatUri.js'; import { IAgentPluginService } from '../../common/plugins/agentPluginService.js'; import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; @@ -763,12 +762,9 @@ class AICustomizationManagementActionsContribution extends Disposable implements const sessionResource = chatWidgetService.lastFocusedWidget?.viewModel?.sessionResource; if (sessionResource) { const sessionType = getChatSessionType(sessionResource); - const harnessId = sessionType === localChatSessionType - ? CustomizationHarness.VSCode - : sessionType; - const available = harnessService.availableHarnesses.get(); - if (available.some(h => h.id === harnessId)) { - harnessService.setActiveHarness(harnessId); + const harness = harnessService.findHarnessById(sessionType); + if (harness) { + harnessService.setActiveHarness(sessionType); } } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts index 2fa8262672846..877bc8e99c5d7 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts @@ -83,10 +83,11 @@ import { IWorkbenchMcpServer } from '../../../mcp/common/mcpTypes.js'; import { AgentPluginEditor } from '../agentPluginEditor/agentPluginEditor.js'; import { AgentPluginEditorInput } from '../agentPluginEditor/agentPluginEditorInput.js'; import { IAgentPluginItem } from '../agentPluginEditor/agentPluginItems.js'; -import { ICustomizationHarnessService, CustomizationHarness, matchesWorkspaceSubpath } from '../../common/customizationHarnessService.js'; +import { ICustomizationHarnessService, matchesWorkspaceSubpath } from '../../common/customizationHarnessService.js'; import { ChatConfiguration } from '../../common/constants.js'; import { AICustomizationWelcomePage } from './aiCustomizationWelcomePage.js'; import { IViewsService } from '../../../../services/views/common/viewsService.js'; +import { SessionType } from '../../common/chatSessionsService.js'; const $ = DOM.$; @@ -499,7 +500,7 @@ export class AICustomizationManagementEditor extends EditorPane { let hidden: Set; if (this.isHarnessSelectorEnabled) { const activeId = this.harnessService.activeHarness.get(); - const descriptor = this.harnessService.availableHarnesses.get().find(h => h.id === activeId); + const descriptor = this.harnessService.findHarnessById(activeId); hidden = new Set(descriptor?.hiddenSections ?? []); } else { hidden = new Set(); // Local harness has no hidden sections @@ -605,12 +606,12 @@ export class AICustomizationManagementEditor extends EditorPane { // setActiveHarness(VSCode) is a safe no-op since the CLI harness // remains active — filtering stays correct for that window. if (!this.isHarnessSelectorEnabled) { - this.harnessService.setActiveHarness(CustomizationHarness.VSCode); + this.harnessService.setActiveHarness(SessionType.Local); } this.editorDisposables.add(this.configurationService.onDidChangeConfiguration(e => { if (e.affectsConfiguration(ChatConfiguration.ChatCustomizationHarnessSelectorEnabled)) { if (!this.isHarnessSelectorEnabled) { - this.harnessService.setActiveHarness(CustomizationHarness.VSCode); + this.harnessService.setActiveHarness(SessionType.Local); } } })); @@ -649,7 +650,7 @@ export class AICustomizationManagementEditor extends EditorPane { this.harnessDropdownButton.setAttribute('aria-label', localize('selectHarness', "Select customization target")); this.harnessDropdownButton.setAttribute('aria-haspopup', 'listbox'); this.editorDisposables.add(this.hoverService.setupManagedHover(getDefaultHoverDelegate('element'), this.harnessDropdownButton, () => { - const descriptor = this.harnessService.availableHarnesses.get().find(h => h.id === this.harnessService.activeHarness.get()); + const descriptor = this.harnessService.findHarnessById(this.harnessService.activeHarness.get()); return descriptor?.label ?? ''; })); diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationHarnessService.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationHarnessService.ts index 198a8c52c6fad..519a41f869a18 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationHarnessService.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationHarnessService.ts @@ -5,13 +5,13 @@ import { InstantiationType, registerSingleton } from '../../../../../platform/instantiation/common/extensions.js'; import { - CustomizationHarness, CustomizationHarnessServiceBase, ICustomizationHarnessService, createVSCodeHarnessDescriptor, } from '../../common/customizationHarnessService.js'; -import { PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; +import { IPromptsService, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; import { BUILTIN_STORAGE } from '../../common/aiCustomizationWorkspaceService.js'; +import { SessionType } from '../../common/chatSessionsService.js'; /** * Core implementation of the customization harness service. @@ -20,11 +20,14 @@ import { BUILTIN_STORAGE } from '../../common/aiCustomizationWorkspaceService.js * (e.g. Copilot CLI) are contributed by extensions via the provider API. */ class CustomizationHarnessService extends CustomizationHarnessServiceBase { - constructor() { + constructor( + @IPromptsService promptsService: IPromptsService + ) { const localExtras = [PromptsStorage.extension, BUILTIN_STORAGE]; super( [createVSCodeHarnessDescriptor(localExtras)], - CustomizationHarness.VSCode, + SessionType.Local, + promptsService, ); } } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts index c2eddfa466f9c..a97d31543aaa8 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts @@ -40,9 +40,10 @@ import { formatDisplayName, truncateToFirstLine } from './aiCustomizationListWid import { getDefaultHoverDelegate } from '../../../../../base/browser/ui/hover/hoverDelegateFactory.js'; import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; import { IAICustomizationWorkspaceService } from '../../common/aiCustomizationWorkspaceService.js'; -import { ICustomizationHarnessService, CustomizationHarness } from '../../common/customizationHarnessService.js'; +import { ICustomizationHarnessService } from '../../common/customizationHarnessService.js'; import { CustomizationGroupHeaderRenderer, ICustomizationGroupHeaderEntry, CUSTOMIZATION_GROUP_HEADER_HEIGHT, CUSTOMIZATION_GROUP_HEADER_HEIGHT_WITH_SEPARATOR } from './customizationGroupHeaderRenderer.js'; import { AgentPluginItemKind, IAgentPluginItem } from '../agentPluginEditor/agentPluginItems.js'; +import { SessionType } from '../../common/chatSessionsService.js'; const $ = DOM.$; @@ -164,7 +165,7 @@ class McpServerItemRenderer implements IListRenderer { const activeId = this.harnessService.activeHarness.read(reader); - templateData.bridgedBadge.style.display = activeId !== CustomizationHarness.VSCode ? '' : 'none'; + templateData.bridgedBadge.style.display = activeId !== SessionType.Local ? '' : 'none'; })); templateData.disposables.add(this.hoverService.setupManagedHover( getDefaultHoverDelegate('mouse'), diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts index 0c326d98dca0a..e6cbe34da226f 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts @@ -49,10 +49,10 @@ import { assertNever } from '../../../../../base/common/assert.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { Target } from '../../common/promptSyntax/promptTypes.js'; import { slashReg } from '../../common/requestParser/chatRequestParser.js'; -import { IPromptsService } from '../../common/promptSyntax/service/promptsService.js'; import { OffsetRange } from '../../../../../editor/common/core/ranges/offsetRange.js'; import { ILanguageModelToolsService } from '../../common/tools/languageModelToolsService.js'; import { IChatModel } from '../../common/model/chatModel.js'; +import { ICustomizationHarnessService } from '../../common/customizationHarnessService.js'; const extensionPoint = ExtensionsRegistry.registerExtensionPoint({ extensionPoint: 'chatSessions', @@ -557,7 +557,7 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ async run(accessor: ServicesAccessor, chatOptions?: { resource: UriComponents; prompt: string; attachedContext?: IChatRequestVariableEntry[] }): Promise { const chatService = accessor.get(IChatService); - const promptsService = accessor.get(IPromptsService); + const customizationHarnessService = accessor.get(ICustomizationHarnessService); const toolsService = accessor.get(ILanguageModelToolsService); const { type } = contribution; @@ -567,7 +567,7 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ const resource = URI.revive(chatOptions.resource); const ref = await chatService.acquireOrLoadSession(resource, ChatAgentLocation.Chat, CancellationToken.None, 'ChatSessionsContribution#sendPrompt'); try { - const promptFile = await resolvePromptSlashCommand(chatOptions.prompt, contribution.type, promptsService, toolsService); + const promptFile = await resolvePromptSlashCommand(chatOptions.prompt, contribution.type, customizationHarnessService, toolsService); if (promptFile) { attachedContext = [promptFile, ...(attachedContext ?? [])]; } @@ -1318,7 +1318,7 @@ async function openChatSession(accessor: ServicesAccessor, openOptions: NewChatS const logService = accessor.get(ILogService); const editorGroupService = accessor.get(IEditorGroupsService); const editorService = accessor.get(IEditorService); - const promptsService = accessor.get(IPromptsService); + const customizationHarnessService = accessor.get(ICustomizationHarnessService); const toolsService = accessor.get(ILanguageModelToolsService); // Determine resource to open @@ -1374,7 +1374,7 @@ async function openChatSession(accessor: ServicesAccessor, openOptions: NewChatS } let attachedContext = chatSendOptions.attachedContext; - const promptFile = await resolvePromptSlashCommand(chatSendOptions.prompt, openOptions.type, promptsService, toolsService); + const promptFile = await resolvePromptSlashCommand(chatSendOptions.prompt, openOptions.type, customizationHarnessService, toolsService); if (promptFile) { attachedContext = [promptFile, ...(attachedContext ?? [])]; } @@ -1410,12 +1410,12 @@ function normalizeSessionOptions(options: ReadonlyChatSessionOptionsMap | Readon /** * Returns the variable entry for a slash command if the prompt starts with a slash command that can be resolved to a prompt file, otherwise returns undefined. */ -async function resolvePromptSlashCommand(prompt: string, sessionType: string, promptsService: IPromptsService, toolsService: ILanguageModelToolsService): Promise { +async function resolvePromptSlashCommand(prompt: string, sessionType: string, customizationHarnessService: ICustomizationHarnessService, toolsService: ILanguageModelToolsService): Promise { const slashMatch = prompt.match(slashReg); // starts with a slash command, add the corresponding prompt file to the context if it exists if (slashMatch) { // need to resolve the slash command to get the prompt file - const slashCommand = await promptsService.resolvePromptSlashCommand(slashMatch[1], sessionType, CancellationToken.None); + const slashCommand = await customizationHarnessService.resolvePromptSlashCommand(slashMatch[1], sessionType, CancellationToken.None); if (slashCommand) { const parseResult = slashCommand.parsedPromptFile; // add the prompt file to the context diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index c10c8d9bc7170..494623317332d 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -85,6 +85,7 @@ import { ChatContentMarkdownRenderer } from './chatContentMarkdownRenderer.js'; import { IAgentSessionsService } from '../agentSessions/agentSessionsService.js'; import { IChatDebugService } from '../../common/chatDebugService.js'; import { getChatSessionType } from '../../common/model/chatUri.js'; +import { ICustomizationHarnessService } from '../../common/customizationHarnessService.js'; const $ = dom.$; @@ -404,6 +405,7 @@ export class ChatWidget extends Disposable implements IChatWidget { @IChatEditingService chatEditingService: IChatEditingService, @ITelemetryService private readonly telemetryService: ITelemetryService, @IPromptsService private readonly promptsService: IPromptsService, + @ICustomizationHarnessService private readonly customizationHarnessService: ICustomizationHarnessService, @ILanguageModelToolsService private readonly toolsService: ILanguageModelToolsService, @IChatModeService private readonly chatModeService: IChatModeService, @IChatLayoutService private readonly chatLayoutService: IChatLayoutService, @@ -2291,7 +2293,7 @@ export class ChatWidget extends Disposable implements IChatWidget { }); } - private async _applyPromptFileIfSet(requestInput: IChatRequestInputOptions): Promise { + private async _applyPromptFileIfSet(requestInput: IChatRequestInputOptions, sessionResource: URI): Promise { // first check if the input has a prompt slash command const agentSlashPromptPart = this.parsedInput.parts.find((r): r is ChatRequestSlashPromptPart => r instanceof ChatRequestSlashPromptPart); if (!agentSlashPromptPart) { @@ -2302,10 +2304,10 @@ export class ChatWidget extends Disposable implements IChatWidget { // Track them now so tip exclusions still update for commands like /init. this.chatTipService.recordSlashCommandUsage(agentSlashPromptPart.name); - const sessionType = this.viewModel ? getChatSessionType(this.viewModel.model.sessionResource) : undefined; + const sessionType = getChatSessionType(sessionResource); // need to resolve the slash command to get the prompt file - const slashCommand = await this.promptsService.resolvePromptSlashCommand(agentSlashPromptPart.name, sessionType, CancellationToken.None); + const slashCommand = await this.customizationHarnessService.resolvePromptSlashCommand(agentSlashPromptPart.name, sessionType, CancellationToken.None); if (!slashCommand) { return; } @@ -2417,7 +2419,7 @@ export class ChatWidget extends Disposable implements IChatWidget { } // process the prompt command - await this._applyPromptFileIfSet(requestInputs); + await this._applyPromptFileIfSet(requestInputs, this.viewModel.sessionResource); if (this.viewOptions.enableWorkingSet !== undefined && this.input.currentModeKind === ChatModeKind.Edit) { const uniqueWorkingSetEntries = new ResourceSet(); // NOTE: this is used for bookkeeping so the UI can avoid rendering references in the UI that are already shown in the working set diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts index b128fe75fe673..702c7bc935b55 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts @@ -54,8 +54,8 @@ import { IDynamicVariable } from '../../../../common/attachments/chatVariables.j import { ChatAgentLocation, ChatModeKind, isSupportedChatFileScheme } from '../../../../common/constants.js'; import { isToolSet } from '../../../../common/tools/languageModelToolsService.js'; import { IChatSessionsService } from '../../../../common/chatSessionsService.js'; -import { ICustomizationHarnessService, getActiveHarnessSlashCommands } from '../../../../common/customizationHarnessService.js'; -import { IPromptsService, matchesSessionType } from '../../../../common/promptSyntax/service/promptsService.js'; +import { ICustomizationHarnessService } from '../../../../common/customizationHarnessService.js'; +import { matchesSessionType } from '../../../../common/promptSyntax/service/promptsService.js'; import { ChatSubmitAction, IChatExecuteActionContext } from '../../../actions/chatExecuteActions.js'; import { IChatWidget, IChatWidgetService } from '../../../chat.js'; import { resizeImage } from '../../../chatImageUtils.js'; @@ -81,7 +81,6 @@ class SlashCommandCompletions extends Disposable { @ILanguageFeaturesService private readonly languageFeaturesService: ILanguageFeaturesService, @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, @IChatSlashCommandService private readonly chatSlashCommandService: IChatSlashCommandService, - @IPromptsService private readonly promptsService: IPromptsService, @ICustomizationHarnessService private readonly harnessService: ICustomizationHarnessService, @IChatService chatService: IChatService, @IChatSessionsService chatSessionsService: IChatSessionsService, @@ -236,7 +235,8 @@ class SlashCommandCompletions extends Disposable { return; } - const promptCommands = await getActiveHarnessSlashCommands(this.harnessService, this.promptsService, token); + const currentSessionType = getChatSessionType(widget.viewModel.model.sessionResource); + const promptCommands = await this.harnessService.getSlashCommands(currentSessionType, token); if (promptCommands.length === 0) { return null; } @@ -245,7 +245,6 @@ class SlashCommandCompletions extends Disposable { return null; } - const currentSessionType = getChatSessionType(widget.viewModel.model.sessionResource); const userInvocableCommands = promptCommands .filter(c => c.userInvocable) .filter(c => matchesSessionType(c.sessionTypes, currentSessionType)); diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputEditorContrib.ts b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputEditorContrib.ts index 4c7de690f60de..4d2b991c20c4f 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputEditorContrib.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputEditorContrib.ts @@ -22,7 +22,6 @@ import { localize } from '../../../../../../../nls.js'; import { chatSlashCommandBackground, chatSlashCommandForeground } from '../../../../common/widget/chatColors.js'; import { ChatRequestAgentPart, ChatRequestAgentSubcommandPart, ChatRequestDynamicVariablePart, ChatRequestSlashCommandPart, ChatRequestSlashPromptPart, ChatRequestTextPart, ChatRequestToolPart, ChatRequestToolSetPart, IParsedChatRequestPart, chatAgentLeader, chatSubcommandLeader } from '../../../../common/requestParser/chatParserTypes.js'; import { agentReg, slashReg, variableReg } from '../../../../common/requestParser/chatRequestParser.js'; -import { IPromptsService } from '../../../../common/promptSyntax/service/promptsService.js'; import { IChatWidget } from '../../../chat.js'; import { ChatWidget } from '../../chatWidget.js'; import { dynamicVariableDecorationType } from '../../../attachments/chatDynamicVariables.js'; @@ -32,6 +31,7 @@ import { CancellationToken } from '../../../../../../../base/common/cancellation import { ThrottledDelayer } from '../../../../../../../base/common/async.js'; import { IEditorService } from '../../../../../../services/editor/common/editorService.js'; import { getChatSessionType } from '../../../../common/model/chatUri.js'; +import { ICustomizationHarnessService } from '../../../../common/customizationHarnessService.js'; const decorationDescription = 'chat'; const placeholderDecorationType = 'chat-session-detail'; @@ -87,7 +87,7 @@ class InputEditorDecorations extends Disposable { @IThemeService private readonly themeService: IThemeService, @IChatAgentService private readonly chatAgentService: IChatAgentService, @ILabelService private readonly labelService: ILabelService, - @IPromptsService private readonly promptsService: IPromptsService, + @ICustomizationHarnessService private readonly customizationHarnessService: ICustomizationHarnessService, @IEditorService private readonly editorService: IEditorService, ) { super(); @@ -137,7 +137,12 @@ class InputEditorDecorations extends Disposable { void this.editorService.openEditor({ resource: mouseDownPromptSlashCommand.uri }); })); this._register(this.chatAgentService.onDidChangeAgents(() => this.triggerInputEditorDecorationsUpdate())); - this._register(this.promptsService.onDidChangeSlashCommands(() => this.triggerInputEditorDecorationsUpdate())); + this._register(this.customizationHarnessService.onDidChangeSlashCommands((e) => { + const sessionResource = this.widget.viewModel?.sessionResource; + if (sessionResource && e.sessionType === getChatSessionType(sessionResource)) { + this.triggerInputEditorDecorationsUpdate(); + } + })); this._register(autorun(reader => { // Watch for changes to the current mode and its properties const currentMode = this.widget.input.currentModeObs.read(reader); @@ -313,7 +318,7 @@ class InputEditorDecorations extends Disposable { const slashPromptPart = parsedRequest.find((p): p is ChatRequestSlashPromptPart => p instanceof ChatRequestSlashPromptPart); // first, fetch all async context - const promptSlashCommand = slashPromptPart ? await this.promptsService.resolvePromptSlashCommand(slashPromptPart.name, getChatSessionType(viewModel.sessionResource), token) : undefined; + const promptSlashCommand = slashPromptPart ? await this.customizationHarnessService.resolvePromptSlashCommand(slashPromptPart.name, getChatSessionType(viewModel.sessionResource), token) : undefined; if (token.isCancellationRequested) { // a new update came in while we were waiting return; diff --git a/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts b/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts index 4971dae55ce5e..a7fa2f855fffb 100644 --- a/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts +++ b/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts @@ -6,7 +6,7 @@ import { Codicon } from '../../../../base/common/codicons.js'; import { IObservable, ISettableObservable, observableValue } from '../../../../base/common/observable.js'; import { IDisposable } from '../../../../base/common/lifecycle.js'; -import { Event } from '../../../../base/common/event.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; import { joinPath } from '../../../../base/common/resources.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { URI } from '../../../../base/common/uri.js'; @@ -15,8 +15,9 @@ import { createDecorator } from '../../../../platform/instantiation/common/insta import { AICustomizationManagementSection, IStorageSourceFilter } from './aiCustomizationWorkspaceService.js'; import { PromptsType } from './promptSyntax/promptTypes.js'; import { AGENT_MD_FILENAME } from './promptSyntax/config/promptFileLocations.js'; -import { IChatPromptSlashCommand, IPromptsService, PromptsStorage } from './promptSyntax/service/promptsService.js'; +import { IChatPromptSlashCommand, IPromptsService, IResolvedChatPromptSlashCommand, matchesSessionType, PromptsStorage } from './promptSyntax/service/promptsService.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { SessionType } from './chatSessionsService.js'; export const ICustomizationHarnessService = createDecorator('customizationHarnessService'); @@ -54,20 +55,13 @@ export interface ISectionOverride { readonly fileExtension?: string; } -/** - * Identifies the AI harness (execution environment) that customizations - * are filtered for. Storage answers "where did this come from?"; harness - * answers "who consumes it?". - */ -export enum CustomizationHarness { - VSCode = 'vscode', - CLI = 'cli', -} - /** * Describes a single harness option for the UI toggle. */ export interface IHarnessDescriptor { + /** + * The harness/session-type identifier. + */ readonly id: string; readonly label: string; readonly icon: ThemeIcon; @@ -235,11 +229,17 @@ export interface ICustomizationHarnessService { */ readonly availableHarnesses: IObservable; + /** + * Finds the descriptor of the harness with the given id, or `undefined` if no such harness exists. + * @param sessionType The harness id (sessionType) + */ + findHarnessById(sessionType: string): IHarnessDescriptor | undefined; + /** * Changes the active harness. The new id must be present in * `availableHarnesses`. */ - setActiveHarness(id: string): void; + setActiveHarness(sessionType: string): void; /** * Convenience: returns the storage source filter for the active harness @@ -258,6 +258,25 @@ export interface ICustomizationHarnessService { * Returns a disposable that removes the harness when disposed. */ registerExternalHarness(descriptor: IHarnessDescriptor): IDisposable; + + + /** + * Fires when one of the provided slash commands changes. + */ + readonly onDidChangeSlashCommands: Event<{ readonly sessionType: string }>; + + /** + * Returns the prompt and skill slash commands for the given session type. + * Provider-backed harnesses contribute their own items directly; the default + * VS Code harness falls back to the core prompts service. + */ + getSlashCommands(sessionType: string, token: CancellationToken): Promise; + + /** + * Resolves a slash command to its full metadata, including the parsed prompt file for prompt commands. + * Provider-backed harnesses resolve their own items directly; the default VS Code harness falls back to the core prompts service. + */ + resolvePromptSlashCommand(name: string, sessionType: string, token: CancellationToken): Promise; } /** @@ -272,42 +291,6 @@ export interface ICustomizationSlashCommand { readonly sessionTypes?: readonly string[]; } -/** - * Returns the prompt and skill slash commands for the currently active harness. - * Provider-backed harnesses contribute their own items directly; the default - * VS Code harness falls back to the core prompts service. - */ -export async function getActiveHarnessSlashCommands( - harnessService: ICustomizationHarnessService, - promptsService: Pick, - token: CancellationToken, -): Promise { - const itemProvider = harnessService.getActiveDescriptor().itemProvider; - if (!itemProvider) { - return await promptsService.getPromptSlashCommands(token); - } - - const items = await itemProvider.provideChatSessionCustomizations(token); - if (!items) { - return []; - } - const result = []; - for (const item of items) { - if ((item.enabled !== false) && (item.type === PromptsType.prompt || item.type === PromptsType.skill)) { - result.push({ - uri: item.uri, - type: item.type as PromptsType.prompt | PromptsType.skill, - name: item.name, - description: item.description, - userInvocable: true, - storage: item.storage ?? PromptsStorage.local, - when: undefined - }); - } - } - return result; -} - // #region Shared filter constants /** @@ -372,7 +355,7 @@ function buildAllSources(extras: readonly string[]): readonly string[] { export function createVSCodeHarnessDescriptor(extras: readonly string[]): IHarnessDescriptor { const filter: IStorageSourceFilter = { sources: buildAllSources(extras) }; return { - id: CustomizationHarness.VSCode, + id: SessionType.Local, label: localize('harness.local', "Local"), icon: ThemeIcon.fromId(Codicon.vm.id), supportsTroubleshoot: true, @@ -437,7 +420,7 @@ function createRestrictedHarnessDescriptor( */ export function createCliHarnessDescriptor(cliUserRoots: readonly URI[], extras: readonly string[]): IHarnessDescriptor { return createRestrictedHarnessDescriptor( - CustomizationHarness.CLI, + SessionType.CopilotCLI, localize('harness.cli', "Copilot CLI"), ThemeIcon.fromId(Codicon.copilot.id), cliUserRoots, @@ -496,6 +479,9 @@ export function matchesInstructionFileFilter(filePath: string, filters: readonly */ export class CustomizationHarnessServiceBase implements ICustomizationHarnessService { declare readonly _serviceBrand: undefined; + private readonly _onDidChangeSlashCommands = new Emitter<{ readonly sessionType: string }>(); + readonly onDidChangeSlashCommands = this._onDidChangeSlashCommands.event; + private readonly _providerListeners: IDisposable[] = []; private readonly _activeHarness: ISettableObservable; readonly activeHarness: IObservable; @@ -508,12 +494,15 @@ export class CustomizationHarnessServiceBase implements ICustomizationHarnessSer constructor( staticHarnesses: readonly IHarnessDescriptor[], defaultHarness: string, + private readonly promptsService: IPromptsService, ) { this._staticHarnesses = staticHarnesses; + this.promptsService = promptsService; this._activeHarness = observableValue(this, defaultHarness); this.activeHarness = this._activeHarness; this._availableHarnesses = observableValue(this, [...this._staticHarnesses]); this.availableHarnesses = this._availableHarnesses; + this._rebindProviderListeners(); } private _getAllHarnesses(): readonly IHarnessDescriptor[] { @@ -528,6 +517,30 @@ export class CustomizationHarnessServiceBase implements ICustomizationHarnessSer private _refreshAvailableHarnesses(): void { this._availableHarnesses.set(this._getAllHarnesses(), undefined); + this._rebindProviderListeners(); + } + + private _rebindProviderListeners(): void { + for (const listener of this._providerListeners) { + listener.dispose(); + } + this._providerListeners.length = 0; + for (const harness of this._getAllHarnesses()) { + const provider = harness.itemProvider; + if (!provider) { + this._providerListeners.push(this.promptsService.onDidChangeSlashCommands(() => this._onDidChangeSlashCommands.fire({ sessionType: harness.id }))); + } else { + this._providerListeners.push(provider.onDidChange(() => this._onDidChangeSlashCommands.fire({ sessionType: harness.id }))); + } + } + } + + dispose(): void { + for (const listener of this._providerListeners) { + listener.dispose(); + } + this._providerListeners.length = 0; + this._onDidChangeSlashCommands.dispose(); } registerExternalHarness(descriptor: IHarnessDescriptor): IDisposable { @@ -552,8 +565,13 @@ export class CustomizationHarnessServiceBase implements ICustomizationHarnessSer }; } + findHarnessById(id: string): IHarnessDescriptor | undefined { + return this._getAllHarnesses().find(h => h.id === id); + } + setActiveHarness(id: string): void { - if (this._getAllHarnesses().some(h => h.id === id)) { + const harness = this.findHarnessById(id); + if (harness) { this._activeHarness.set(id, undefined); } } @@ -576,6 +594,60 @@ export class CustomizationHarnessServiceBase implements ICustomizationHarnessSer } return all.find(h => h.id === activeId) ?? all[0]; } + + async getSlashCommands(sessionType: string, token: CancellationToken): Promise { + const harness = this.findHarnessById(sessionType); + if (!harness || !harness.itemProvider) { + const commands = await this.promptsService.getPromptSlashCommands(token); + return commands.filter(command => matchesSessionType(command.sessionTypes, sessionType)); + } + + const items = await harness.itemProvider.provideChatSessionCustomizations(token); + if (!items) { + return []; + } + const result = []; + for (const item of items) { + if ((item.enabled !== false) && (item.type === PromptsType.prompt || item.type === PromptsType.skill)) { + result.push({ + uri: item.uri, + type: item.type as PromptsType.prompt | PromptsType.skill, + name: item.name, + description: item.description, + userInvocable: true, // todo we need a way for providers to specify this if some items aren't user-invocable` + storage: item.storage ?? PromptsStorage.local, + when: undefined, + sessionTypes: [sessionType], + }); + } + } + return result; + } + + public async resolvePromptSlashCommand(name: string, sessionType: string, token: CancellationToken): Promise { + const harness = this.findHarnessById(sessionType); + if (!harness || !harness.itemProvider) { + return this.promptsService.resolvePromptSlashCommand(name, sessionType, token); + } + + const items = await harness.itemProvider.provideChatSessionCustomizations(token); + const item = items?.find(cmd => cmd.name === name); + if (item) { + const parsedPromptFile = await this.promptsService.parseNew(item.uri, token); + return { + uri: item.uri, + type: item.type as PromptsType.prompt | PromptsType.skill, + name: item.name, + description: item.description, + userInvocable: parsedPromptFile.header?.userInvocable ?? true, + storage: item.storage ?? PromptsStorage.local, + when: undefined, + sessionTypes: [sessionType], + parsedPromptFile, + }; + } + return undefined; + } } // #endregion diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationListWidget.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationListWidget.test.ts index 19c4aac1883a9..fb38770250231 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationListWidget.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationListWidget.test.ts @@ -198,6 +198,7 @@ suite('aiCustomizationListWidget', () => { setActiveHarness: () => { }, getStorageSourceFilter: () => ({ sources: [] }), getActiveDescriptor: () => descriptor, + findHarnessById: (id) => id === descriptor.id ? descriptor : undefined, registerExternalHarness: () => ({ dispose() { } }), }); diff --git a/src/vs/workbench/contrib/chat/test/common/customizationHarnessService.test.ts b/src/vs/workbench/contrib/chat/test/common/customizationHarnessService.test.ts index fc3fcd88f280d..4eb4cdffc88f3 100644 --- a/src/vs/workbench/contrib/chat/test/common/customizationHarnessService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/customizationHarnessService.test.ts @@ -8,10 +8,12 @@ import { Emitter } from '../../../../../base/common/event.js'; import { URI } from '../../../../../base/common/uri.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { CustomizationHarness, CustomizationHarnessServiceBase, createVSCodeHarnessDescriptor, getActiveHarnessSlashCommands, ICustomizationItemProvider, IHarnessDescriptor, matchesWorkspaceSubpath } from '../../common/customizationHarnessService.js'; +import { CustomizationHarnessServiceBase, createVSCodeHarnessDescriptor, ICustomizationItemProvider, IHarnessDescriptor, matchesWorkspaceSubpath } from '../../common/customizationHarnessService.js'; import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; import { IPromptsService, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { SessionType } from '../../common/chatSessionsService.js'; +import { MockPromptsService } from './promptSyntax/service/mockPromptsService.js'; suite('CustomizationHarnessService', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); @@ -20,10 +22,39 @@ suite('CustomizationHarnessService', () => { if (harnesses.length === 0) { harnesses = [createVSCodeHarnessDescriptor([PromptsStorage.extension])]; } - return new CustomizationHarnessServiceBase(harnesses, harnesses[0].id); + const promptsService: IPromptsService = new MockPromptsService(); + const service = new CustomizationHarnessServiceBase(harnesses, harnesses[0].id, promptsService); + store.add(service); + return service; } suite('registerExternalHarness', () => { + test('forwards item provider changes via onDidChangeSlashCommands with sessionType', () => { + const service = createService(); + const emitter = new Emitter(); + store.add(emitter); + const harnessId = 'test-harness'; + const externalDescriptor: IHarnessDescriptor = { + id: harnessId, + label: 'Test Harness', + icon: ThemeIcon.fromId('extensions'), + getStorageSourceFilter: () => ({ sources: [PromptsStorage.local] }), + itemProvider: { + onDidChange: emitter.event, + provideChatSessionCustomizations: async () => [], + }, + }; + + store.add(service.registerExternalHarness(externalDescriptor)); + + let firedSessionType: string | undefined; + const listener = store.add(service.onDidChangeSlashCommands(e => firedSessionType = e.sessionType)); + store.add(listener); + + emitter.fire(); + assert.strictEqual(firedSessionType, harnessId); + }); + test('adds harness to available list', () => { const service = createService(); assert.strictEqual(service.availableHarnesses.get().length, 1); @@ -90,7 +121,7 @@ suite('CustomizationHarnessService', () => { assert.strictEqual(service.activeHarness.get(), 'test-ext'); reg.dispose(); - assert.strictEqual(service.activeHarness.get(), CustomizationHarness.VSCode); + assert.strictEqual(service.activeHarness.get(), SessionType.Local); }); test('allows switching to external harness', () => { @@ -299,12 +330,16 @@ suite('CustomizationHarnessService', () => { }); }); - suite('getActiveHarnessSlashCommands', () => { + suite('getSlashCommands', () => { test('uses the active harness provider for prompt and skill items', async () => { + + + const testSessionType = 'test-session-type'; + const emitter = new Emitter(); store.add(emitter); const service = createService({ - id: 'test-ext', + id: testSessionType, label: 'Test Extension', icon: ThemeIcon.fromId('extensions'), getStorageSourceFilter: () => ({ sources: [PromptsStorage.local] }), @@ -319,14 +354,7 @@ suite('CustomizationHarnessService', () => { }, }); - const promptsService: Pick = { - getPromptSlashCommands: async () => { - assert.fail('expected harness itemProvider to be used'); - }, - isValidSlashCommandName: name => name !== 'disabled', - }; - - const commands = await getActiveHarnessSlashCommands(service, promptsService, CancellationToken.None); + const commands = await service.getSlashCommands(testSessionType, CancellationToken.None); assert.deepStrictEqual(commands.map(command => ({ name: command.name, type: command.type })), [ { name: 'fix', type: PromptsType.prompt }, { name: 'lint', type: PromptsType.skill }, @@ -334,20 +362,32 @@ suite('CustomizationHarnessService', () => { }); test('falls back to promptsService when the active harness has no provider', async () => { - const service = createService(); - const promptsService: Pick = { - getPromptSlashCommands: async () => ([ - { uri: URI.parse('file:///workspace/.github/prompts/explain.prompt.md'), name: 'explain', type: PromptsType.prompt, storage: PromptsStorage.local, userInvocable: false, when: undefined, sessionTypes: ['chat'] }, - { uri: URI.parse('file:///workspace/.github/skills/review/SKILL.md'), name: 'review', type: PromptsType.skill, storage: PromptsStorage.user, userInvocable: true, when: undefined }, - ]), - isValidSlashCommandName: () => true, - }; - const commands = await getActiveHarnessSlashCommands(service, promptsService, CancellationToken.None); - assert.deepStrictEqual(commands.map(command => ({ name: command.name, type: command.type, userInvocable: command.userInvocable, sessionTypes: command.sessionTypes })), [ - { name: 'explain', type: PromptsType.prompt, userInvocable: false, sessionTypes: ['chat'] }, - { name: 'review', type: PromptsType.skill, userInvocable: true, sessionTypes: undefined }, - ]); + const testSessionType = 'test-session-type'; + const promptsService = new class extends MockPromptsService { + override async getPromptSlashCommands() { + return [ + { uri: URI.parse('file:///workspace/.github/prompts/explain.prompt.md'), name: 'explain', type: PromptsType.prompt, storage: PromptsStorage.local, userInvocable: false, when: undefined, sessionTypes: [testSessionType] }, + { uri: URI.parse('file:///workspace/.github/skills/review/SKILL.md'), name: 'review', type: PromptsType.skill, storage: PromptsStorage.user, userInvocable: true, when: undefined }, + ]; + } + override isValidSlashCommandName() { return true; } + }; + const service = new CustomizationHarnessServiceBase([createVSCodeHarnessDescriptor([PromptsStorage.extension])], SessionType.Local, promptsService); + store.add(service); + { + const commands = await service.getSlashCommands(testSessionType, CancellationToken.None); + assert.deepStrictEqual(commands.map(command => ({ name: command.name, type: command.type, userInvocable: command.userInvocable, sessionTypes: command.sessionTypes })), [ + { name: 'explain', type: PromptsType.prompt, userInvocable: false, sessionTypes: [testSessionType] }, + { name: 'review', type: PromptsType.skill, userInvocable: true, sessionTypes: undefined }, + ]); + } + { + const commands = await service.getSlashCommands(SessionType.Local, CancellationToken.None); + assert.deepStrictEqual(commands.map(command => ({ name: command.name, type: command.type, userInvocable: command.userInvocable, sessionTypes: command.sessionTypes })), [ + { name: 'review', type: PromptsType.skill, userInvocable: true, sessionTypes: undefined }, + ]); + } }); }); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.ts index e5c41a4623d85..c4e34dcec8916 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.ts @@ -45,7 +45,6 @@ export class MockPromptsService implements IPromptsService { isValidSlashCommandName(_command: string): boolean { return false; } // eslint-disable-next-line @typescript-eslint/no-explicit-any resolvePromptSlashCommand(command: string, _sessionType: string | undefined, _token: CancellationToken): Promise { throw new Error('Not implemented'); } - get onDidChangeSlashCommands(): Event { throw new Error('Not implemented'); } // eslint-disable-next-line @typescript-eslint/no-explicit-any getPromptSlashCommands(_token: CancellationToken): Promise { throw new Error('Not implemented'); } getPromptSlashCommandName(uri: URI, _token: CancellationToken): Promise { throw new Error('Not implemented'); } @@ -72,4 +71,5 @@ export class MockPromptsService implements IPromptsService { onDidChangePromptFiles: Event = Event.None; onDidChangeSkills: Event = Event.None; onDidChangeHooks: Event = Event.None; + onDidChangeSlashCommands: Event = Event.None; } diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationListWidget.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationListWidget.fixture.ts index ce66bde954d78..d668b9522f483 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationListWidget.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationListWidget.fixture.ts @@ -13,9 +13,9 @@ import { IFileService } from '../../../../../platform/files/common/files.js'; import { IListService, ListService } from '../../../../../platform/list/browser/listService.js'; import { IWorkspace, IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; import { IAICustomizationWorkspaceService, IStorageSourceFilter } from '../../../../contrib/chat/common/aiCustomizationWorkspaceService.js'; -import { CustomizationHarness, ICustomizationHarnessService, IHarnessDescriptor, createVSCodeHarnessDescriptor } from '../../../../contrib/chat/common/customizationHarnessService.js'; +import { ICustomizationHarnessService, IHarnessDescriptor, createVSCodeHarnessDescriptor } from '../../../../contrib/chat/common/customizationHarnessService.js'; import { IAgentPluginService } from '../../../../contrib/chat/common/plugins/agentPluginService.js'; -import { IChatSessionsService } from '../../../../contrib/chat/common/chatSessionsService.js'; +import { IChatSessionsService, SessionType } from '../../../../contrib/chat/common/chatSessionsService.js'; import { PromptsType } from '../../../../contrib/chat/common/promptSyntax/promptTypes.js'; import { IPromptsService, AgentInstructionFileType, PromptsStorage, IPromptPath, IAgentInstructionFile } from '../../../../contrib/chat/common/promptSyntax/service/promptsService.js'; import { AICustomizationManagementSection } from '../../../../contrib/chat/browser/aiCustomization/aiCustomizationManagement.js'; @@ -114,7 +114,7 @@ function createMockWorkspaceService(): IAICustomizationWorkspaceService { function createMockHarnessService(): ICustomizationHarnessService { const descriptor = createVSCodeHarnessDescriptor([PromptsStorage.extension]); return new class extends mock() { - override readonly activeHarness = observableValue('activeHarness', CustomizationHarness.VSCode); + override readonly activeHarness = observableValue('activeHarness', SessionType.Local); override readonly availableHarnesses = observableValue('harnesses', [descriptor]); override getStorageSourceFilter() { return defaultFilter; } override getActiveDescriptor() { return descriptor; } diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts index 0e26f3d707678..0f1e585f29aab 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts @@ -31,8 +31,8 @@ import { IPathService } from '../../../../services/path/common/pathService.js'; import { IWorkingCopyService } from '../../../../services/workingCopy/common/workingCopyService.js'; import { IWebviewService } from '../../../../contrib/webview/browser/webview.js'; import { IAICustomizationWorkspaceService, AICustomizationManagementSection } from '../../../../contrib/chat/common/aiCustomizationWorkspaceService.js'; -import { CustomizationHarness, ICustomizationHarnessService, IHarnessDescriptor, createVSCodeHarnessDescriptor, createCliHarnessDescriptor, getCliUserRoots } from '../../../../contrib/chat/common/customizationHarnessService.js'; -import { IChatSessionsService } from '../../../../contrib/chat/common/chatSessionsService.js'; +import { ICustomizationHarnessService, IHarnessDescriptor, createVSCodeHarnessDescriptor, createCliHarnessDescriptor, getCliUserRoots } from '../../../../contrib/chat/common/customizationHarnessService.js'; +import { IChatSessionsService, SessionType } from '../../../../contrib/chat/common/chatSessionsService.js'; import { PromptsType } from '../../../../contrib/chat/common/promptSyntax/promptTypes.js'; import { IPromptsService, AgentInstructionFileType, PromptsStorage, IAgentSkill, IChatPromptSlashCommand, IAgentInstructionFile } from '../../../../contrib/chat/common/promptSyntax/service/promptsService.js'; import { ParsedPromptFile } from '../../../../contrib/chat/common/promptSyntax/promptFileParser.js'; @@ -183,8 +183,8 @@ function createMockPromptsService(files: IFixtureFile[], agentInstructions: IAge }(); } -function createMockHarnessService(activeHarness: CustomizationHarness, descriptors: readonly IHarnessDescriptor[]): ICustomizationHarnessService { - const active = observableValue('activeHarness', activeHarness); +function createMockHarnessService(activeHarnessId: string, descriptors: readonly IHarnessDescriptor[]): ICustomizationHarnessService { + const active = observableValue('activeHarness', activeHarnessId); return new class extends mock() { override readonly activeHarness = active; override readonly availableHarnesses = constObservable(descriptors); @@ -346,7 +346,7 @@ const mcpRuntimeServers = [ ]; interface IRenderEditorOptions { - readonly harness: CustomizationHarness; + readonly harnessId: string; readonly isSessionsWindow?: boolean; readonly managementSections?: readonly AICustomizationManagementSection[]; readonly availableHarnesses?: readonly IHarnessDescriptor[]; @@ -447,7 +447,7 @@ async function renderEditor(ctx: ComponentFixtureContext, options: IRenderEditor const instantiationService = createEditorServices(ctx.disposableStore, { colorTheme: ctx.theme, additionalServices: (reg) => { - const harnessService = createMockHarnessService(options.harness, availableHarnesses); + const harnessService = createMockHarnessService(options.harnessId, availableHarnesses); const agentFeedbackService = createMockAgentFeedbackService(); const codeReviewService = createMockCodeReviewService(); registerWorkbenchServices(reg); @@ -675,7 +675,7 @@ async function renderMcpBrowseMode(ctx: ComponentFixtureContext): Promise } }()); reg.defineInstance(ICustomizationHarnessService, new class extends mock() { - override readonly activeHarness = observableValue('activeHarness', CustomizationHarness.VSCode); + override readonly activeHarness = observableValue('activeHarness', SessionType.Local); override getActiveDescriptor() { return createVSCodeHarnessDescriptor([PromptsStorage.extension, BUILTIN_STORAGE]); } override registerExternalHarness() { return { dispose() { } }; } }()); @@ -783,7 +783,7 @@ async function renderPluginBrowseMode(ctx: ComponentFixtureContext): Promise() { - override readonly activeHarness = observableValue('activeHarness', CustomizationHarness.VSCode); + override readonly activeHarness = observableValue('activeHarness', SessionType.Local); override getActiveDescriptor() { return createVSCodeHarnessDescriptor([PromptsStorage.extension, BUILTIN_STORAGE]); } override registerExternalHarness() { return { dispose() { } }; } }()); @@ -891,7 +891,7 @@ function renderMcpDisabled(ctx: ComponentFixtureContext, byPolicy: boolean): voi } }()); reg.defineInstance(ICustomizationHarnessService, new class extends mock() { - override readonly activeHarness = observableValue('activeHarness', CustomizationHarness.VSCode); + override readonly activeHarness = observableValue('activeHarness', SessionType.Local); override getActiveDescriptor() { return createVSCodeHarnessDescriptor([PromptsStorage.extension, BUILTIN_STORAGE]); } override registerExternalHarness() { return { dispose() { } }; } }()); @@ -916,7 +916,7 @@ function renderPluginDisabled(ctx: ComponentFixtureContext, byPolicy: boolean): reg.define(IListService, ListService); reg.defineInstance(IConfigurationService, createDisabledConfigService(ChatConfiguration.PluginsEnabled, false, byPolicy)); reg.defineInstance(ICustomizationHarnessService, new class extends mock() { - override readonly activeHarness = observableValue('activeHarness', CustomizationHarness.VSCode); + override readonly activeHarness = observableValue('activeHarness', SessionType.Local); override getActiveDescriptor() { return createVSCodeHarnessDescriptor([PromptsStorage.extension, BUILTIN_STORAGE]); } override registerExternalHarness() { return { dispose() { } }; } }()); @@ -947,21 +947,21 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { // Welcome page — default state with no section selected WelcomePage: defineComponentFixture({ labels: { kind: 'screenshot' }, - render: ctx => renderEditor(ctx, { harness: CustomizationHarness.VSCode }), + render: ctx => renderEditor(ctx, { harnessId: SessionType.Local }), }), // Full editor with Local (VS Code) harness — all sections visible, harness dropdown, // Generate buttons, AGENTS.md shortcut, all storage groups LocalHarness: defineComponentFixture({ labels: { kind: 'screenshot' }, - render: ctx => renderEditor(ctx, { harness: CustomizationHarness.VSCode, selectedSection: AICustomizationManagementSection.Agents }), + render: ctx => renderEditor(ctx, { harnessId: SessionType.Local, selectedSection: AICustomizationManagementSection.Agents }), }), // Full editor with Copilot CLI harness — no prompts section, CLI-specific // root files and instruction filtering under .github/.copilot paths. CliHarness: defineComponentFixture({ labels: { kind: 'screenshot' }, - render: ctx => renderEditor(ctx, { harness: CustomizationHarness.CLI, selectedSection: AICustomizationManagementSection.Agents }), + render: ctx => renderEditor(ctx, { harnessId: SessionType.CopilotCLI, selectedSection: AICustomizationManagementSection.Agents }), }), // Sessions-window variant of the full editor with workspace override UX @@ -969,7 +969,7 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { Sessions: defineComponentFixture({ labels: { kind: 'screenshot' }, render: ctx => renderEditor(ctx, { - harness: CustomizationHarness.CLI, + harnessId: SessionType.CopilotCLI, isSessionsWindow: true, selectedSection: AICustomizationManagementSection.Agents, availableHarnesses: [ @@ -991,7 +991,7 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { SessionsSkillsTab: defineComponentFixture({ labels: { kind: 'screenshot' }, render: ctx => renderEditor(ctx, { - harness: CustomizationHarness.CLI, + harnessId: SessionType.CopilotCLI, isSessionsWindow: true, selectedSection: AICustomizationManagementSection.Skills, availableHarnesses: [ @@ -1017,7 +1017,7 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { McpServersTab: defineComponentFixture({ labels: { kind: 'screenshot' }, render: ctx => renderEditor(ctx, { - harness: CustomizationHarness.VSCode, + harnessId: SessionType.Local, selectedSection: AICustomizationManagementSection.McpServers, }), }), @@ -1026,7 +1026,7 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { AgentsTab: defineComponentFixture({ labels: { kind: 'screenshot' }, render: ctx => renderEditor(ctx, { - harness: CustomizationHarness.VSCode, + harnessId: SessionType.Local, selectedSection: AICustomizationManagementSection.Agents, }), }), @@ -1035,7 +1035,7 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { SkillsTab: defineComponentFixture({ labels: { kind: 'screenshot' }, render: ctx => renderEditor(ctx, { - harness: CustomizationHarness.VSCode, + harnessId: SessionType.Local, selectedSection: AICustomizationManagementSection.Skills, }), }), @@ -1044,7 +1044,7 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { InstructionsTab: defineComponentFixture({ labels: { kind: 'screenshot' }, render: ctx => renderEditor(ctx, { - harness: CustomizationHarness.VSCode, + harnessId: SessionType.Local, selectedSection: AICustomizationManagementSection.Instructions, }), }), @@ -1053,7 +1053,7 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { HooksTab: defineComponentFixture({ labels: { kind: 'screenshot' }, render: ctx => renderEditor(ctx, { - harness: CustomizationHarness.VSCode, + harnessId: SessionType.Local, selectedSection: AICustomizationManagementSection.Hooks, }), }), @@ -1062,7 +1062,7 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { PromptsTab: defineComponentFixture({ labels: { kind: 'screenshot' }, render: ctx => renderEditor(ctx, { - harness: CustomizationHarness.VSCode, + harnessId: SessionType.Local, selectedSection: AICustomizationManagementSection.Prompts, }), }), @@ -1071,7 +1071,7 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { PluginsTab: defineComponentFixture({ labels: { kind: 'screenshot' }, render: ctx => renderEditor(ctx, { - harness: CustomizationHarness.VSCode, + harnessId: SessionType.Local, selectedSection: AICustomizationManagementSection.Plugins, }), }), @@ -1117,7 +1117,7 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { PromptsTabScrolled: defineComponentFixture({ labels: { kind: 'screenshot' }, render: ctx => renderEditor(ctx, { - harness: CustomizationHarness.VSCode, + harnessId: SessionType.Local, selectedSection: AICustomizationManagementSection.Prompts, scrollToBottom: true, }), @@ -1126,7 +1126,7 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { McpServersTabScrolled: defineComponentFixture({ labels: { kind: 'screenshot' }, render: ctx => renderEditor(ctx, { - harness: CustomizationHarness.VSCode, + harnessId: SessionType.Local, selectedSection: AICustomizationManagementSection.McpServers, scrollToBottom: true, }), @@ -1135,7 +1135,7 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { PluginsTabScrolled: defineComponentFixture({ labels: { kind: 'screenshot' }, render: ctx => renderEditor(ctx, { - harness: CustomizationHarness.VSCode, + harnessId: SessionType.Local, selectedSection: AICustomizationManagementSection.Plugins, scrollToBottom: true, }), @@ -1145,7 +1145,7 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { McpServersTabNarrow: defineComponentFixture({ labels: { kind: 'screenshot' }, render: ctx => renderEditor(ctx, { - harness: CustomizationHarness.VSCode, + harnessId: SessionType.Local, selectedSection: AICustomizationManagementSection.McpServers, width: 550, height: 400, @@ -1155,7 +1155,7 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { AgentsTabNarrow: defineComponentFixture({ labels: { kind: 'screenshot' }, render: ctx => renderEditor(ctx, { - harness: CustomizationHarness.VSCode, + harnessId: SessionType.Local, selectedSection: AICustomizationManagementSection.Agents, width: 550, height: 400, @@ -1167,7 +1167,7 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { AgentsItemEditor: defineComponentFixture({ labels: { kind: 'screenshot' }, render: ctx => renderEditor(ctx, { - harness: CustomizationHarness.VSCode, + harnessId: SessionType.Local, selectedSection: AICustomizationManagementSection.Agents, openFirstItem: true, }), @@ -1177,7 +1177,7 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { McpServerDetail: defineComponentFixture({ labels: { kind: 'screenshot' }, render: ctx => renderEditor(ctx, { - harness: CustomizationHarness.VSCode, + harnessId: SessionType.Local, selectedSection: AICustomizationManagementSection.McpServers, openFirstItem: true, }), @@ -1187,7 +1187,7 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { PluginDetail: defineComponentFixture({ labels: { kind: 'screenshot' }, render: ctx => renderEditor(ctx, { - harness: CustomizationHarness.VSCode, + harnessId: SessionType.Local, selectedSection: AICustomizationManagementSection.Plugins, openFirstItem: true, }), From 471bb0acba1d961ff2ec326019fac9248607348e Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Thu, 23 Apr 2026 17:35:11 -0400 Subject: [PATCH 62/70] fix: cancel CTS synchronously in OutputMonitor constructor to prevent disposable leak (#312211) fix disposable leak --- .../browser/tools/monitoring/outputMonitor.ts | 28 ++++++++++++++++--- .../test/browser/outputMonitor.test.ts | 28 +++++++++++++++++++ 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts index 83981f28dcca5..2f3560955802b 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts @@ -99,12 +99,32 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { this._command = command; this._invocationContext = invocationContext; - this._register(toDisposable(() => this._currentMonitoringCts?.dispose())); - // Start async to ensure listeners are set up + // Create the CTS synchronously so it is available for cancellation if the + // OutputMonitor is disposed before the deferred _startMonitoring fires. + // The registered disposable must cancel (not just dispose) the CTS so that + // the async monitoring loop's token becomes isCancellationRequested=true and + // the loop exits promptly — CancellationTokenSource.dispose() alone does + // not set isCancellationRequested. + const cts = new CancellationTokenSource(token); + this._currentMonitoringCts = cts; + this._register(toDisposable(() => { + this._currentMonitoringCts?.cancel(); + this._currentMonitoringCts?.dispose(); + })); + + // Start async to ensure listeners are set up. + // Capture `cts` locally so that if continueMonitoringAsync replaces + // _currentMonitoringCts before this fires, we detect the replacement + // and avoid starting a duplicate monitoring loop. _startMonitoring + // handles a cancelled token correctly by firing onDidFinishCommand in + // its finally block, so we always call it when we're still the current + // CTS (even if the token has since been cancelled). timeout(0).then(() => { - this._currentMonitoringCts = new CancellationTokenSource(token); - this._startMonitoring(command, invocationContext, this._currentMonitoringCts.token); + if (this._currentMonitoringCts !== cts) { + return; + } + this._startMonitoring(command, invocationContext, cts.token); }); } diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/outputMonitor.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/outputMonitor.test.ts index 4e712f6db7dc4..3bad6b3848110 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/outputMonitor.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/outputMonitor.test.ts @@ -389,6 +389,34 @@ suite('OutputMonitor', () => { }); }); + suite('disposable leak regression', () => { + test('disposing before timeout(0) fires does not leak idle input listener', async () => { + // Regression: disposing immediately (before the deferred _startMonitoring fires) + // must not leak the FunctionDisposable created by onDidInputData. + // The CTS must be cancelled synchronously so that when timeout(0) fires and + // _setupIdleInputListener runs, isCancellationRequested is already true. + return runWithFakedTimers({}, async () => { + monitor = store.add(instantiationService.createInstance(OutputMonitor, execution, undefined, createTestContext('1'), cts.token, 'test command')); + // Dispose immediately, before the deferred _startMonitoring callback fires. + monitor.dispose(); + await new Promise(resolve => setTimeout(resolve, 0)); + // ensureNoDisposablesAreLeakedInTestSuite will catch any leaked disposable. + }); + }); + + test('disposing after monitoring completes does not leak idle input listener', async () => { + // Verifies the finally block in _startMonitoring clears _userInputListener before + // firing onDidFinishCommand. Any undisposed FunctionDisposable from onDidInputData + // would be caught by ensureNoDisposablesAreLeakedInTestSuite. + return runWithFakedTimers({}, async () => { + execution.isActive = async () => false; + monitor = store.add(instantiationService.createInstance(OutputMonitor, execution, undefined, createTestContext('1'), cts.token, 'test command')); + await Event.toPromise(monitor.onDidFinishCommand); + monitor.dispose(); + }); + }); + }); + suite('detectsGenericPressAnyKeyPattern', () => { test('detects generic press any key prompts from scripts', () => { assert.strictEqual(detectsGenericPressAnyKeyPattern('Press any key to continue...'), true); From c059845dcadff4336b878aabb115c1071f9406bb Mon Sep 17 00:00:00 2001 From: Logan Ramos Date: Thu, 23 Apr 2026 17:41:48 -0400 Subject: [PATCH 63/70] Improve hover rendering (#312223) * Improve hover rendering * Fix comment --- .../actionWidget/browser/actionList.ts | 6 +++++- .../actionWidget/browser/actionWidget.css | 4 ++-- .../browser/widget/input/chatModelPicker.ts | 19 ++++++++++++------- 3 files changed, 19 insertions(+), 10 deletions(-) diff --git a/src/vs/platform/actionWidget/browser/actionList.ts b/src/vs/platform/actionWidget/browser/actionList.ts index c40d17705da2a..f7ace1168baf1 100644 --- a/src/vs/platform/actionWidget/browser/actionList.ts +++ b/src/vs/platform/actionWidget/browser/actionList.ts @@ -1478,11 +1478,15 @@ export class ActionListWidget extends Disposable { const hoverHeaderHeight = hoverHeader ? hoverHeader.offsetHeight : 0; const totalPanelHeight = totalHeight + hoverHeaderHeight; const viewportHeight = targetWindow.innerHeight; - let top = anchorRect.top - parentRect.top - 4; + const anchorHeight = anchorRect.height; + let top = anchorRect.top - parentRect.top + (anchorHeight - totalPanelHeight) / 2; const panelBottom = parentRect.top + top + totalPanelHeight; if (panelBottom > viewportHeight) { top -= (panelBottom - viewportHeight + 8); } + if (parentRect.top + top < 0) { + top = -parentRect.top; + } this._submenuContainer.style.top = `${top}px`; } diff --git a/src/vs/platform/actionWidget/browser/actionWidget.css b/src/vs/platform/actionWidget/browser/actionWidget.css index 600690a9ad2f5..248b94ceea8a0 100644 --- a/src/vs/platform/actionWidget/browser/actionWidget.css +++ b/src/vs/platform/actionWidget/browser/actionWidget.css @@ -390,8 +390,8 @@ .action-list-submenu-hover-header { padding: 4px 8px; - line-height: 1.5em; - min-width: 200px; + line-height: 1.4em; + font-size: 12px; max-width: var(--vscode-hover-maxWidth, 500px); word-wrap: break-word; } 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 ee9c7f4a7eb15..35c64cfda5c22 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts @@ -92,6 +92,7 @@ function createModelItem( action: IActionWidgetDropdownAction & { section?: string }, model?: ILanguageModelChatMetadataAndIdentifier, ): IActionListItem { + const hoverContent = model ? getModelHoverContent(model) : undefined; return { item: action, kind: ActionListItemKind.Action, @@ -100,8 +101,9 @@ function createModelItem( group: { title: '', icon: action.icon ?? ThemeIcon.fromId(action.checked ? Codicon.check.id : Codicon.blank.id) }, hideIcon: false, section: action.section, - hover: model ? { content: getModelHoverContent(model) } : undefined, - submenuActions: action.toolbarActions, + hover: hoverContent ? { content: hoverContent } : undefined, + tooltip: action.tooltip, + submenuActions: action.toolbarActions?.length ? action.toolbarActions : undefined, }; } @@ -793,27 +795,30 @@ export class ModelPickerWidget extends Disposable { } -function getModelHoverContent(model: ILanguageModelChatMetadataAndIdentifier): MarkdownString { +function getModelHoverContent(model: ILanguageModelChatMetadataAndIdentifier): MarkdownString | undefined { const isAuto = isAutoModel(model); const markdown = new MarkdownString('', { isTrusted: true, supportThemeIcons: true }); - markdown.appendMarkdown(`**${model.metadata.name}**`); + let hasContent = false; if (model.metadata.tooltip) { - markdown.appendMarkdown(`\n\n`); if (model.metadata.statusIcon) { markdown.appendMarkdown(`$(${model.metadata.statusIcon.id}) `); } markdown.appendMarkdown(`${model.metadata.tooltip}`); + hasContent = true; } if (!isAuto && (model.metadata.maxInputTokens || model.metadata.maxOutputTokens)) { - markdown.appendMarkdown(`\n\n`); + if (hasContent) { + markdown.appendMarkdown(`\n\n`); + } const totalTokens = (model.metadata.maxInputTokens ?? 0) + (model.metadata.maxOutputTokens ?? 0); markdown.appendMarkdown(`${localize('models.contextSize', 'Context Size')}: `); markdown.appendMarkdown(`${formatTokenCount(totalTokens)}`); + hasContent = true; } - return markdown; + return hasContent ? markdown : undefined; } From 853d2d9a4f27d1b55989358da0c2118a4b9046cf Mon Sep 17 00:00:00 2001 From: Kyle Cutler <67761731+kycutler@users.noreply.github.com> Date: Thu, 23 Apr 2026 14:42:08 -0700 Subject: [PATCH 64/70] Refactor browser creation flow, add ownership model (#312166) * Refactor browser creation flow, add ownership model Co-authored-by: Copilot * Opening in background Co-authored-by: Copilot * feedback Co-authored-by: Copilot * simplify Co-authored-by: Copilot * fix Co-authored-by: Copilot --------- Co-authored-by: Copilot --- .../src/singlefolder-tests/browser.test.ts | 17 ++ .../browserView/common/browserView.ts | 76 ++++-- .../browserView/electron-main/browserView.ts | 91 ++++--- .../electron-main/browserViewGroup.ts | 2 +- .../electron-main/browserViewMainService.ts | 122 ++++++--- .../api/browser/mainThreadBrowsers.ts | 17 +- .../browser/browserView.contribution.ts | 16 +- .../browserView/common/browserEditorInput.ts | 93 +++---- .../contrib/browserView/common/browserView.ts | 237 ++++++++---------- .../electron-browser/browserEditor.ts | 42 +--- .../browserView.contribution.ts | 12 +- .../browserViewWorkbenchService.ts | 178 +++++++++++-- .../features/browserTabManagementFeatures.ts | 28 +-- .../tools/browserToolHelpers.ts | 10 +- .../tools/browserTools.contribution.ts | 17 +- .../electron-browser/tools/openBrowserTool.ts | 4 +- .../tools/openBrowserToolNonAgentic.ts | 4 +- .../tools/screenshotBrowserTool.ts | 6 +- 18 files changed, 574 insertions(+), 398 deletions(-) diff --git a/extensions/vscode-api-tests/src/singlefolder-tests/browser.test.ts b/extensions/vscode-api-tests/src/singlefolder-tests/browser.test.ts index 0791391e6af24..2f2e41dad9a0c 100644 --- a/extensions/vscode-api-tests/src/singlefolder-tests/browser.test.ts +++ b/extensions/vscode-api-tests/src/singlefolder-tests/browser.test.ts @@ -73,6 +73,23 @@ import { assertNoRpc, closeAllEditors } from '../utils'; assert.strictEqual(window.browserTabs.length, countBefore - 1); }); + test('Closing via workbench.action.closeActiveEditor removes tab from browserTabs', async () => { + const tab = await window.openBrowserTab('about:blank'); + assert.ok(window.browserTabs.includes(tab)); + + const closed = new Promise(resolve => { + const disposable = window.onDidCloseBrowserTab(t => { + disposable.dispose(); + resolve(t); + }); + }); + + await commands.executeCommand('workbench.action.closeActiveEditor'); + const firedTab = await closed; + assert.ok(firedTab); + assert.ok(!window.browserTabs.includes(tab)); + }); + test('Can move a browser tab to a new group and close it successfully', async () => { const tab = await window.openBrowserTab('about:blank'); assert.ok(window.browserTabs.includes(tab)); diff --git a/src/vs/platform/browserView/common/browserView.ts b/src/vs/platform/browserView/common/browserView.ts index ecb40a9e856c0..af4f4acd46027 100644 --- a/src/vs/platform/browserView/common/browserView.ts +++ b/src/vs/platform/browserView/common/browserView.ts @@ -5,7 +5,6 @@ import { Event } from '../../../base/common/event.js'; import { VSBuffer } from '../../../base/common/buffer.js'; -import { UriComponents } from '../../../base/common/uri.js'; import { localize } from '../../../nls.js'; const commandPrefix = 'workbench.action.browser'; @@ -81,6 +80,49 @@ export interface IBrowserViewCaptureScreenshotOptions { pageRect?: { x: number; y: number; width: number; height: number }; } +/** + * Identifies who owns a browser view's lifecycle. + * The owner is set at creation time and never changes. + */ +export interface IBrowserViewOwner { + /** The main code window ID that owns this view's lifecycle. */ + readonly mainWindowId: number; +} + +/** + * Summary information about a browser view, including its current state and + * ownership. Returned by the main service when listing or creating views. + */ +export interface IBrowserViewInfo { + readonly id: string; + readonly owner: IBrowserViewOwner; + readonly state: IBrowserViewState; +} + +/** + * Editor opening hints passed from the main process to the workbench. + */ +export interface IBrowserViewOpenOptions { + readonly preserveFocus?: boolean; + readonly background?: boolean; + readonly pinned?: boolean; + /** The parent view ID. Used by the workbench to place the new tab in the same editor group. */ + readonly parentViewId?: string; + /** When set, open in an auxiliary (new) window with these bounds. */ + readonly auxiliaryWindow?: { x?: number; y?: number; width?: number; height?: number }; +} + +export interface IBrowserViewCreatedEvent { + readonly info: IBrowserViewInfo; + readonly openOptions: IBrowserViewOpenOptions; +} + +export interface IBrowserViewCreateOptions { + readonly owner: IBrowserViewOwner; + readonly scope: BrowserViewStorageScope; + readonly initialState?: Partial; +} + export interface IBrowserViewState { url: string; title: string; @@ -161,19 +203,6 @@ export interface IBrowserViewFaviconChangeEvent { favicon: string | undefined; } -export enum BrowserNewPageLocation { - Foreground = 'foreground', - Background = 'background', - NewWindow = 'newWindow' -} -export interface IBrowserViewNewPageRequest { - resource: UriComponents; - url: string; - location: BrowserNewPageLocation; - // Only applicable if location is NewWindow - position?: { x?: number; y?: number; width?: number; height?: number }; -} - export interface IBrowserViewFindInPageOptions { recompute?: boolean; forward?: boolean; @@ -214,6 +243,11 @@ export function browserZoomAccessibilityLabel(zoomFactor: number): string { export const browserViewIsolatedWorldId = 999; export interface IBrowserViewService { + /** + * Fires when a new browser view is created from an internal source (e.g. CDP or window.open). + */ + onDidCreateBrowserView: Event; + /** * Dynamic events that return an Event for a specific browser view ID. */ @@ -225,17 +259,21 @@ export interface IBrowserViewService { onDynamicDidKeyCommand(id: string): Event; onDynamicDidChangeTitle(id: string): Event; onDynamicDidChangeFavicon(id: string): Event; - onDynamicDidRequestNewPage(id: string): Event; onDynamicDidFindInPage(id: string): Event; onDynamicDidClose(id: string): Event; /** - * Get or create a browser view instance + * Get all known browser views with their ownership and state information. + */ + getBrowserViews(windowId?: number): Promise; + + /** + * Get or create a browser view instance. Does not fire `onDidCreateBrowserView`. + * * @param id The browser view identifier - * @param scope The storage scope for the browser view. Ignored if the view already exists. - * @param workspaceId Workspace identifier for session isolation. Only used if scope is 'workspace'. + * @param options Creation options. If a view with the given ID already exists, these options are ignored. */ - getOrCreateBrowserView(id: string, scope: BrowserViewStorageScope, workspaceId?: string): Promise; + getOrCreateBrowserView(id: string, options: IBrowserViewCreateOptions): Promise; /** * Destroy a browser view instance diff --git a/src/vs/platform/browserView/electron-main/browserView.ts b/src/vs/platform/browserView/electron-main/browserView.ts index 2472e8bba1573..609f4b48de273 100644 --- a/src/vs/platform/browserView/electron-main/browserView.ts +++ b/src/vs/platform/browserView/electron-main/browserView.ts @@ -8,18 +8,24 @@ import { Disposable } from '../../../base/common/lifecycle.js'; import { Emitter, Event } from '../../../base/common/event.js'; import { VSBuffer } from '../../../base/common/buffer.js'; import { CancellationToken } from '../../../base/common/cancellation.js'; -import { IBrowserViewBounds, IBrowserViewDevToolsStateEvent, IBrowserViewFocusEvent, IBrowserViewKeyDownEvent, IBrowserViewState, IBrowserViewNavigationEvent, IBrowserViewLoadingEvent, IBrowserViewLoadError, IBrowserViewTitleChangeEvent, IBrowserViewFaviconChangeEvent, IBrowserViewNewPageRequest, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions, IBrowserViewFindInPageResult, IBrowserViewVisibilityEvent, BrowserNewPageLocation, browserViewIsolatedWorldId, browserZoomFactors, browserZoomDefaultIndex, IElementData } from '../common/browserView.js'; +import { IBrowserViewBounds, IBrowserViewDevToolsStateEvent, IBrowserViewFocusEvent, IBrowserViewKeyDownEvent, IBrowserViewState, IBrowserViewNavigationEvent, IBrowserViewLoadingEvent, IBrowserViewLoadError, IBrowserViewTitleChangeEvent, IBrowserViewFaviconChangeEvent, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions, IBrowserViewFindInPageResult, IBrowserViewVisibilityEvent, browserViewIsolatedWorldId, browserZoomFactors, browserZoomDefaultIndex, IElementData, IBrowserViewOwner, IBrowserViewOpenOptions } from '../common/browserView.js'; import { BrowserViewElementInspector } from './browserViewElementInspector.js'; import { IWindowsMainService } from '../../windows/electron-main/windows.js'; import { ICodeWindow } from '../../window/electron-main/window.js'; import { IAuxiliaryWindowsMainService } from '../../auxiliaryWindow/electron-main/auxiliaryWindows.js'; -import { BrowserViewUri } from '../common/browserViewUri.js'; import { BrowserViewDebugger } from './browserViewDebugger.js'; import { ILogService } from '../../log/common/log.js'; import { BrowserSession } from './browserSession.js'; import { IAuxiliaryWindow } from '../../auxiliaryWindow/electron-main/auxiliaryWindow.js'; -import { hasKey } from '../../../base/common/types.js'; import { SCAN_CODE_STR_TO_EVENT_KEY_CODE } from '../../../base/common/keyCodes.js'; +import { ITelemetryService } from '../../telemetry/common/telemetry.js'; +import { logBrowserOpen } from '../common/browserViewTelemetry.js'; + +enum NewPageLocation { + Foreground = 'foreground', + Background = 'background', + NewWindow = 'newWindow' +} /** * Represents a single browser view instance with its WebContentsView and all associated logic. @@ -37,7 +43,9 @@ export class BrowserView extends Disposable { readonly debugger: BrowserViewDebugger; private readonly _inspector: BrowserViewElementInspector; - private _window: ICodeWindow | IAuxiliaryWindow | undefined; + + private _ownerWindow: ICodeWindow; + private _currentWindow: ICodeWindow | IAuxiliaryWindow | undefined; private _isDisposed = false; private static readonly MAX_CONSOLE_LOG_ENTRIES = 1000; @@ -67,9 +75,6 @@ export class BrowserView extends Disposable { private readonly _onDidChangeFavicon = this._register(new Emitter()); readonly onDidChangeFavicon: Event = this._onDidChangeFavicon.event; - private readonly _onDidRequestNewPage = this._register(new Emitter()); - readonly onDidRequestNewPage: Event = this._onDidRequestNewPage.event; - private readonly _onDidFindInPage = this._register(new Emitter()); readonly onDidFindInPage: Event = this._onDidFindInPage.event; @@ -78,13 +83,15 @@ export class BrowserView extends Disposable { constructor( public readonly id: string, + public readonly owner: IBrowserViewOwner, public readonly session: BrowserSession, - createChildView: (options?: Electron.WebContentsViewConstructorOptions) => BrowserView, + createChildView: (url: string, electronOptions: Electron.WebContentsViewConstructorOptions | undefined, openOptions: IBrowserViewOpenOptions) => BrowserView, openContextMenu: (view: BrowserView, params: Electron.ContextMenuParams) => void, options: Electron.WebContentsViewConstructorOptions | undefined, @IWindowsMainService private readonly windowsMainService: IWindowsMainService, @IAuxiliaryWindowsMainService private readonly auxiliaryWindowsMainService: IAuxiliaryWindowsMainService, - @ILogService private readonly logService: ILogService + @ILogService private readonly logService: ILogService, + @ITelemetryService private readonly telemetryService: ITelemetryService, ) { super(); @@ -108,12 +115,18 @@ export class BrowserView extends Disposable { }); this._view.setBackgroundColor('#FFFFFF'); + this._ownerWindow = this.windowsMainService.getWindowById(owner.mainWindowId)!; + if (!this._ownerWindow) { + throw new Error(`Window with ID ${owner.mainWindowId} not found`); + } + this._register(this._ownerWindow.onDidClose(() => this.dispose())); + this._view.webContents.setWindowOpenHandler((details) => { const location = (() => { switch (details.disposition) { - case 'background-tab': return BrowserNewPageLocation.Background; - case 'foreground-tab': return BrowserNewPageLocation.Foreground; - case 'new-window': return BrowserNewPageLocation.NewWindow; + case 'background-tab': return NewPageLocation.Background; + case 'foreground-tab': return NewPageLocation.Foreground; + case 'new-window': return NewPageLocation.NewWindow; default: return undefined; } })(); @@ -126,15 +139,21 @@ export class BrowserView extends Disposable { return { action: 'allow', createWindow: (options) => { - const childView = createChildView(options); - const resource = BrowserViewUri.forId(childView.id); - - // Fire event for the workbench to open this view - this._onDidRequestNewPage.fire({ - resource, - url: details.url, - location, - position: { x: options.x, y: options.y, width: options.width, height: options.height } + logBrowserOpen(this.telemetryService, (() => { + switch (location) { + case NewPageLocation.NewWindow: return 'browserLinkNewWindow'; + case NewPageLocation.Background: return 'browserLinkBackground'; + case NewPageLocation.Foreground: return 'browserLinkForeground'; + } + })()); + + const childView = createChildView(details.url, options, { + pinned: true, + background: location === NewPageLocation.Background, + parentViewId: id, + auxiliaryWindow: location === NewPageLocation.NewWindow + ? { x: options.x, y: options.y, width: options.width, height: options.height } + : undefined, }); // Return the webContents so Electron can complete the window.open() call @@ -386,12 +405,12 @@ export class BrowserView extends Disposable { }); } - private consumePopupPermission(location: BrowserNewPageLocation): boolean { + private consumePopupPermission(location: NewPageLocation): boolean { switch (location) { - case BrowserNewPageLocation.Foreground: - case BrowserNewPageLocation.Background: + case NewPageLocation.Foreground: + case NewPageLocation.Background: return true; - case BrowserNewPageLocation.NewWindow: + case NewPageLocation.NewWindow: // Each user gesture allows one popup window within 1 second if (this._lastUserGestureTimestamp > Date.now() - 1000) { this._lastUserGestureTimestamp = -Infinity; @@ -442,11 +461,11 @@ export class BrowserView extends Disposable { * Update the layout bounds of this view */ layout(bounds: IBrowserViewBounds): void { - if (this._window?.win?.id !== bounds.windowId) { + if (this._currentWindow?.win?.id !== bounds.windowId) { const newWindow = this._windowById(bounds.windowId); if (newWindow) { - this._window?.win?.contentView.removeChildView(this._view); - this._window = newWindow; + this._currentWindow?.win?.contentView.removeChildView(this._view); + this._currentWindow = newWindow; newWindow.win?.contentView.addChildView(this._view); } } @@ -476,7 +495,7 @@ export class BrowserView extends Disposable { // If the view is focused, pass focus back to the window when hiding if (!visible && this._view.webContents.isFocused()) { - this._window?.win?.webContents.focus(); + this._currentWindow?.win?.webContents.focus(); } this._view.setVisible(visible); @@ -596,7 +615,7 @@ export class BrowserView extends Disposable { */ async focus(force?: boolean): Promise { // By default, only focus the view if its window is already focused. - if (!force && !this._window?.win?.isFocused()) { + if (!force && !this._currentWindow?.win?.isFocused()) { return; } this._view.webContents.focus(); @@ -676,15 +695,7 @@ export class BrowserView extends Disposable { * This can be an auxiliary window, depending on where the view is currently hosted. */ getElectronWindow(): Electron.BrowserWindow | undefined { - return this._window?.win ?? undefined; - } - - /** - * Get the main code window hosting this browser view, if any. This is used for routing commands from the browser view to the correct window. - * If the browser view is hosted in an auxiliary window, this will return the parent code window of that auxiliary window. - */ - getTopCodeWindow(): ICodeWindow | undefined { - return this._window && hasKey(this._window, { parentId: true }) ? this._codeWindowById(this._window.parentId) : undefined; + return this._currentWindow?.win ?? undefined; } override dispose(): void { @@ -697,7 +708,7 @@ export class BrowserView extends Disposable { this.debugger.dispose(); // Remove from parent window - this._window?.win?.contentView.removeChildView(this._view); + this._currentWindow?.win?.contentView.removeChildView(this._view); // Fire close event BEFORE disposing emitters. This signals the view has been destroyed. this._onDidClose.fire(); diff --git a/src/vs/platform/browserView/electron-main/browserViewGroup.ts b/src/vs/platform/browserView/electron-main/browserViewGroup.ts index 901487e3f4ea2..7a903bae3f34e 100644 --- a/src/vs/platform/browserView/electron-main/browserViewGroup.ts +++ b/src/vs/platform/browserView/electron-main/browserViewGroup.ts @@ -202,7 +202,7 @@ export class BrowserViewGroup extends Disposable implements ICDPBrowserTarget, I throw new Error(`Unknown browser context ${browserContextId}`); } - const target = await this.browserViewMainService.createTarget(url, browserContextId, windowId); + const target = await this.browserViewMainService.createTarget(url, windowId, browserContextId); if (target instanceof BrowserView) { await this.addView(target.id); return this.viewTargets.get(target.id)!; diff --git a/src/vs/platform/browserView/electron-main/browserViewMainService.ts b/src/vs/platform/browserView/electron-main/browserViewMainService.ts index e644063ae95c6..5c60f78d5f66e 100644 --- a/src/vs/platform/browserView/electron-main/browserViewMainService.ts +++ b/src/vs/platform/browserView/electron-main/browserViewMainService.ts @@ -3,17 +3,16 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Event } from '../../../base/common/event.js'; +import { Emitter, Event } from '../../../base/common/event.js'; import { Disposable, DisposableMap } from '../../../base/common/lifecycle.js'; import { VSBuffer } from '../../../base/common/buffer.js'; import { CancellationToken, CancellationTokenSource } from '../../../base/common/cancellation.js'; -import { IBrowserViewBounds, IBrowserViewState, IBrowserViewService, BrowserViewStorageScope, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions, BrowserViewCommandId, IElementData } from '../common/browserView.js'; +import { IBrowserViewBounds, IBrowserViewState, IBrowserViewService, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions, BrowserViewCommandId, IElementData, IBrowserViewOwner, IBrowserViewInfo, IBrowserViewCreatedEvent, IBrowserViewOpenOptions, IBrowserViewCreateOptions } from '../common/browserView.js'; import { clipboard, Menu, MenuItem } from 'electron'; import { IEnvironmentMainService } from '../../environment/electron-main/environmentMainService.js'; import { createDecorator, IInstantiationService } from '../../instantiation/common/instantiation.js'; import { BrowserView } from './browserView.js'; import { generateUuid } from '../../../base/common/uuid.js'; -import { BrowserViewUri } from '../common/browserViewUri.js'; import { IWindowsMainService } from '../../windows/electron-main/windows.js'; import { BrowserSession } from './browserSession.js'; import { IApplicationStorageMainService } from '../../storage/electron-main/storageMainService.js'; @@ -21,7 +20,6 @@ import { IntegratedBrowserOpenSource, logBrowserOpen } from '../common/browserVi import { ITelemetryService } from '../../telemetry/common/telemetry.js'; import { localize } from '../../../nls.js'; import { INativeHostMainService } from '../../native/electron-main/nativeHostMainService.js'; -import { ITextEditorOptions } from '../../editor/common/editor.js'; import { htmlAttributeEncodeValue } from '../../../base/common/strings.js'; export const IBrowserViewMainService = createDecorator('browserViewMainService'); @@ -31,8 +29,8 @@ export interface IBrowserViewMainService extends IBrowserViewService { tryGetBrowserView(id: string): BrowserView | undefined; - /** Create a new target, open it in an editor, and return it. */ - createTarget(url: string, browserContextId?: string, windowId?: number): Promise; + /** Create a new target and return it. */ + createTarget(url: string, mainWindowId: number, browserContextId?: string): Promise; } export class BrowserViewMainService extends Disposable implements IBrowserViewMainService { @@ -50,6 +48,9 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa private readonly _activeTokens = new Map(); private _keybindings: { [commandId: string]: string } = Object.create(null); + private readonly _onDidCreateBrowserView = this._register(new Emitter()); + readonly onDidCreateBrowserView: Event = this._onDidCreateBrowserView.event; + constructor( @IEnvironmentMainService private readonly environmentMainService: IEnvironmentMainService, @IInstantiationService private readonly instantiationService: IInstantiationService, @@ -61,36 +62,48 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa super(); } - async getOrCreateBrowserView(id: string, scope: BrowserViewStorageScope, workspaceId?: string): Promise { + async getOrCreateBrowserView(id: string, options: IBrowserViewCreateOptions): Promise { if (this.browserViews.has(id)) { - // Note: scope will be ignored if the view already exists. - // Browser views cannot be moved between sessions after creation. + // Note: options will be ignored if the view already exists. const view = this.browserViews.get(id)!; return view.getState(); } + const ownerWindow = this.windowsMainService.getWindowById(options.owner.mainWindowId); + if (!ownerWindow) { + throw new Error(`Owner window with ID ${options.owner.mainWindowId} not found`); + } + const browserSession = BrowserSession.getOrCreate( id, - scope, + options.scope, this.environmentMainService.workspaceStorageHome, - workspaceId + ownerWindow.openedWorkspace?.id ); - const view = this.createBrowserView(id, browserSession); - return view.getState(); + const view = this.createBrowserView(id, options.owner, browserSession); + + if (options.initialState?.url) { + void view.loadURL(options.initialState.url); + } + + return { + ...view.getState(), + ...options.initialState + }; } tryGetBrowserView(id: string): BrowserView | undefined { return this.browserViews.get(id); } - async createTarget(url: string, browserContextId?: string, windowId?: number): Promise { + async createTarget(url: string, mainWindowId: number, browserContextId?: string): Promise { const browserSession = browserContextId ? BrowserSession.get(browserContextId) : undefined; return this.openNew(url, { + owner: { mainWindowId }, session: browserSession, - windowId, - editorOptions: { preserveFocus: true }, + openOptions: { preserveFocus: true }, source: 'cdpCreated' }); } @@ -106,6 +119,25 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa return view; } + private _getViewInfo(view: BrowserView): IBrowserViewInfo { + return { + id: view.id, + owner: view.owner, + state: view.getState() + }; + } + + async getBrowserViews(windowId?: number): Promise { + const result: IBrowserViewInfo[] = []; + for (const [, view] of this.browserViews) { + if (windowId !== undefined && view.owner.mainWindowId !== windowId) { + continue; + } + result.push(this._getViewInfo(view)); + } + return result; + } + onDynamicDidNavigate(id: string) { return this._getBrowserView(id).onDidNavigate; } @@ -138,10 +170,6 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa return this._getBrowserView(id).onDidChangeFavicon; } - onDynamicDidRequestNewPage(id: string) { - return this._getBrowserView(id).onDidRequestNewPage; - } - onDynamicDidFindInPage(id: string) { return this._getBrowserView(id).onDidFindInPage; } @@ -283,7 +311,7 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa /** * Create a browser view backed by the given {@link BrowserSession}. */ - private createBrowserView(id: string, browserSession: BrowserSession, options?: Electron.WebContentsViewConstructorOptions): BrowserView { + private createBrowserView(id: string, owner: IBrowserViewOwner, browserSession: BrowserSession, options?: Electron.WebContentsViewConstructorOptions): BrowserView { if (this.browserViews.has(id)) { throw new Error(`Browser view with id ${id} already exists`); } @@ -293,9 +321,24 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa const view = this.instantiationService.createInstance( BrowserView, id, + owner, browserSession, - // Recursive factory for nested windows (child views share the same session) - (childOptions) => this.createBrowserView(generateUuid(), browserSession, childOptions), + // Recursive factory for nested windows (child views share the same session and owner). + (url, electronOptions, openOptions) => { + const child = this.createBrowserView(generateUuid(), owner, browserSession, electronOptions); + + if (url) { + void child.loadURL(url).catch(() => { }); + } + + const info = this._getViewInfo(child); + this._onDidCreateBrowserView.fire({ + info: url ? { ...info, state: { ...info.state, url } } : info, + openOptions + }); + + return child; + }, (v, params) => this.showContextMenu(v, params), options ); @@ -311,32 +354,31 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa private async openNew( url: string, { + owner, session, - windowId, - editorOptions, + openOptions, source }: { + owner: IBrowserViewOwner; session: BrowserSession | undefined; - windowId: number | undefined; - editorOptions: ITextEditorOptions; + openOptions: IBrowserViewOpenOptions; source: IntegratedBrowserOpenSource; } ): Promise { const targetId = generateUuid(); - const view = this.createBrowserView(targetId, session || BrowserSession.getOrCreateEphemeral(targetId)); + const view = this.createBrowserView(targetId, owner, session || BrowserSession.getOrCreateEphemeral(targetId)); - const window = windowId !== undefined ? this.windowsMainService.getWindowById(windowId) : this.windowsMainService.getFocusedWindow(); - if (!window) { - throw new Error(`Window ${windowId} not found`); + if (url) { + void view.loadURL(url).catch(() => { }); } - logBrowserOpen(this.telemetryService, source); - // Request the workbench to open the editor - window.sendWhenReady('vscode:runAction', CancellationToken.None, { - id: '_workbench.open', - args: [BrowserViewUri.forId(targetId), [undefined, { ...editorOptions, viewState: { url } }], undefined] + // Fire creation event so the workbench can open an editor tab + const info = this._getViewInfo(view); + this._onDidCreateBrowserView.fire({ + info: url ? { ...info, state: { ...info.state, url } } : info, + openOptions }); return view; @@ -358,9 +400,9 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa label: localize('browser.contextMenu.openLinkInNewTab', 'Open Link in New Tab'), click: () => { void this.openNew(params.linkURL, { + owner: view.owner, session: view.session, - windowId: view.getTopCodeWindow()?.id, - editorOptions: { preserveFocus: true, inactive: true }, + openOptions: { preserveFocus: true, background: true }, source: 'browserLinkBackground' }); } @@ -389,9 +431,9 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa label: localize('browser.contextMenu.openImageInNewTab', 'Open Image in New Tab'), click: () => { void this.openNew(params.srcURL!, { + owner: view.owner, session: view.session, - windowId: view.getTopCodeWindow()?.id, - editorOptions: { preserveFocus: true, inactive: true }, + openOptions: { preserveFocus: true, background: true }, source: 'browserLinkBackground' }); } diff --git a/src/vs/workbench/api/browser/mainThreadBrowsers.ts b/src/vs/workbench/api/browser/mainThreadBrowsers.ts index 2c0e0b7875b15..e3debe8ddb0f0 100644 --- a/src/vs/workbench/api/browser/mainThreadBrowsers.ts +++ b/src/vs/workbench/api/browser/mainThreadBrowsers.ts @@ -7,7 +7,7 @@ import { Disposable, DisposableMap, DisposableStore, IDisposable, toDisposable } import { IEditorService } from '../../services/editor/common/editorService.js'; import { IExtHostContext, extHostNamedCustomer } from '../../services/extensions/common/extHostCustomers.js'; import { BrowserTabDto, ExtHostBrowsersShape, ExtHostContext, MainContext, MainThreadBrowsersShape } from '../common/extHost.protocol.js'; -import { IBrowserViewCDPService } from '../../contrib/browserView/common/browserView.js'; +import { IBrowserViewCDPService, IBrowserViewWorkbenchService } from '../../contrib/browserView/common/browserView.js'; import { BrowserViewUri } from '../../../platform/browserView/common/browserViewUri.js'; import { generateUuid } from '../../../base/common/uuid.js'; import { EditorGroupColumn, columnToEditorGroup } from '../../services/editor/common/editorGroupColumn.js'; @@ -29,25 +29,24 @@ export class MainThreadBrowsers extends Disposable implements MainThreadBrowsers extHostContext: IExtHostContext, @IEditorService private readonly editorService: IEditorService, @IBrowserViewCDPService private readonly cdpService: IBrowserViewCDPService, + @IBrowserViewWorkbenchService private readonly browserViewService: IBrowserViewWorkbenchService, @IEditorGroupsService private readonly editorGroupsService: IEditorGroupsService, @IConfigurationService private readonly configurationService: IConfigurationService, ) { super(); this._proxy = extHostContext.getProxy(ExtHostContext.ExtHostBrowsers); - // Track open browser editors - this._register(this.editorService.onWillOpenEditor((e) => { - if (e.editor instanceof BrowserEditorInput) { - this._track(e.editor); + // Track open browser editors via the workbench service + this._register(this.browserViewService.onDidChangeBrowserViews(() => { + for (const editor of this.browserViewService.getKnownBrowserViews().values()) { + this._track(editor); } })); this._register(this.editorService.onDidActiveEditorChange(() => this._syncActiveBrowserTab())); // Initial sync - for (const input of this.editorService.editors) { - if (input instanceof BrowserEditorInput) { - this._track(input); - } + for (const editor of this.browserViewService.getKnownBrowserViews().values()) { + this._track(editor); } this._syncActiveBrowserTab(); } diff --git a/src/vs/workbench/contrib/browserView/browser/browserView.contribution.ts b/src/vs/workbench/contrib/browserView/browser/browserView.contribution.ts index a16c153f57eda..51fa4d8732953 100644 --- a/src/vs/workbench/contrib/browserView/browser/browserView.contribution.ts +++ b/src/vs/workbench/contrib/browserView/browser/browserView.contribution.ts @@ -7,18 +7,28 @@ import { registerSingleton, InstantiationType } from '../../../../platform/insta import { IBrowserViewWorkbenchService, IBrowserViewCDPService, IBrowserViewModel } from '../common/browserView.js'; import { Event } from '../../../../base/common/event.js'; import { CDPEvent, CDPRequest, CDPResponse } from '../../../../platform/browserView/common/cdp/types.js'; +import { IBrowserViewState } from '../../../../platform/browserView/common/browserView.js'; +import { BrowserEditorInput } from '../common/browserEditorInput.js'; class WebBrowserViewWorkbenchService implements IBrowserViewWorkbenchService { declare readonly _serviceBrand: undefined; - async getOrCreateBrowserViewModel(_id: string): Promise { - throw new Error('Integrated Browser is not available in web.'); + readonly onDidChangeBrowserViews = Event.None; + + private readonly _known = new Map(); + + getKnownBrowserViews(): Map { + return this._known; } - async getBrowserViewModel(_id: string): Promise { + getOrCreateLazy(_id: string, _state: IBrowserViewState): BrowserEditorInput { throw new Error('Integrated Browser is not available in web.'); } + getBrowserViewModel(_id: string): IBrowserViewModel | undefined { + return undefined; + } + async clearGlobalStorage(): Promise { } async clearWorkspaceStorage(): Promise { } } diff --git a/src/vs/workbench/contrib/browserView/common/browserEditorInput.ts b/src/vs/workbench/contrib/browserView/common/browserEditorInput.ts index 5ab9c4265f68d..77cc549c182a7 100644 --- a/src/vs/workbench/contrib/browserView/common/browserEditorInput.ts +++ b/src/vs/workbench/contrib/browserView/common/browserEditorInput.ts @@ -9,19 +9,19 @@ import { ThemeIcon } from '../../../../base/common/themables.js'; import { URI } from '../../../../base/common/uri.js'; import { generateUuid } from '../../../../base/common/uuid.js'; import { BrowserViewUri } from '../../../../platform/browserView/common/browserViewUri.js'; -import { IBrowserEditorViewState } from './browserView.js'; +import { IBrowserEditorViewState, IBrowserViewWorkbenchService } from './browserView.js'; import { EditorInputCapabilities, IEditorSerializer, IUntypedEditorInput, Verbosity } from '../../../common/editor.js'; import { EditorInput } from '../../../common/editor/editorInput.js'; import { IThemeService } from '../../../../platform/theme/common/themeService.js'; import { TAB_ACTIVE_FOREGROUND } from '../../../common/theme.js'; import { localize } from '../../../../nls.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; -import { IBrowserViewWorkbenchService, IBrowserViewModel } from '../common/browserView.js'; +import { IBrowserViewModel } from '../common/browserView.js'; import { hasKey } from '../../../../base/common/types.js'; -import { ILifecycleService, ShutdownReason } from '../../../services/lifecycle/common/lifecycle.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { logBrowserOpen } from '../../../../platform/browserView/common/browserViewTelemetry.js'; import { LRUCachedFunction } from '../../../../base/common/cache.js'; +import { DisposableStore } from '../../../../base/common/lifecycle.js'; const LOADING_SPINNER_SVG = (color: string | undefined) => ` @@ -51,32 +51,53 @@ export class BrowserEditorInput extends EditorInput { private readonly _id: string; private _initialData: IBrowserEditorInputData; + private _model: IBrowserViewModel | undefined; private _modelPromise: Promise | undefined; + private _modelStore = this._register(new DisposableStore()); constructor( options: IBrowserEditorInputData, + private _resolveModel: () => Promise, @IThemeService private readonly themeService: IThemeService, - @IBrowserViewWorkbenchService private readonly browserViewWorkbenchService: IBrowserViewWorkbenchService, - @ILifecycleService private readonly lifecycleService: ILifecycleService, @IInstantiationService private readonly instantiationService: IInstantiationService, @ITelemetryService private readonly telemetryService: ITelemetryService ) { super(); this._id = options.id; this._initialData = options; + } - this._register(this.lifecycleService.onWillShutdown((e) => { - if (this._model) { - // For reloads, we simply hide / re-show the view. - if (e.reason === ShutdownReason.RELOAD) { - void this._model.setVisible(false); - } else { - this._model.dispose(); - this._model = undefined; - } - } + get model(): IBrowserViewModel | undefined { + return this._model; + } + + set model(model: IBrowserViewModel) { + if (this._model === model) { + return; + } + + this._modelStore.clear(); + this._model = model; + + // Set up cleanup when the model is disposed + this._modelStore.add(this._model.onWillDispose(() => { + this._modelStore.clear(); + this._model = undefined; })); + + // Auto-close editor when webcontents closes + this._modelStore.add(this._model.onDidClose(() => { + this.dispose(); + })); + + // Listen for label-relevant changes to fire onDidChangeLabel + this._modelStore.add(this._model.onDidChangeTitle(() => this._onDidChangeLabel.fire())); + this._modelStore.add(this._model.onDidChangeFavicon(() => this._onDidChangeLabel.fire())); + this._modelStore.add(this._model.onDidChangeLoadingState(() => this._onDidChangeLabel.fire())); + this._modelStore.add(this._model.onDidNavigate(() => this._onDidChangeLabel.fire())); + + this._onDidChangeLabel.fire(); } get id() { @@ -114,32 +135,9 @@ export class BrowserEditorInput extends EditorInput { override async resolve(): Promise { if (!this._model && !this._modelPromise) { this._modelPromise = (async () => { - this._model = await this.browserViewWorkbenchService.getOrCreateBrowserViewModel(this._id); + this._model = await this._resolveModel(); this._modelPromise = undefined; - // Set up cleanup when the model is disposed - this._register(this._model.onWillDispose(() => { - this._model = undefined; - })); - - // Auto-close editor when webcontents closes - this._register(this._model.onDidClose(() => { - this.dispose(); - })); - - // Listen for label-relevant changes to fire onDidChangeLabel - this._register(this._model.onDidChangeTitle(() => this._onDidChangeLabel.fire())); - this._register(this._model.onDidChangeFavicon(() => this._onDidChangeLabel.fire())); - this._register(this._model.onDidChangeLoadingState(() => this._onDidChangeLabel.fire())); - this._register(this._model.onDidNavigate(() => this._onDidChangeLabel.fire())); - - // Navigate to initial URL if provided - if (this._initialData.url) { - this._model.setInitialURL(this._initialData.url, this._initialData.title, this._initialData.favicon); - } - - this._onDidChangeLabel.fire(); - return this._model; })(); } @@ -263,11 +261,13 @@ export class BrowserEditorInput extends EditorInput { override copy(): EditorInput { logBrowserOpen(this.telemetryService, 'copyToNewWindow'); - return this.instantiationService.createInstance(BrowserEditorInput, { - id: generateUuid(), - url: this.url, - title: this.title, - favicon: this.favicon + return this.instantiationService.invokeFunction((accessor) => { + const browserViewWorkbenchService = accessor.get(IBrowserViewWorkbenchService); + return browserViewWorkbenchService.getOrCreateLazy(generateUuid(), { + url: this.url, + title: this.title, + favicon: this.favicon + }); }); } @@ -327,7 +327,10 @@ export class BrowserEditorSerializer implements IEditorSerializer { deserialize(instantiationService: IInstantiationService, serializedEditor: string): EditorInput | undefined { try { const data: IBrowserEditorInputData = JSON.parse(serializedEditor); - return instantiationService.createInstance(BrowserEditorInput, data); + return instantiationService.invokeFunction((accessor) => { + const browserViewWorkbenchService = accessor.get(IBrowserViewWorkbenchService); + return browserViewWorkbenchService.getOrCreateLazy(data.id, data); + }); } catch { return undefined; } diff --git a/src/vs/workbench/contrib/browserView/common/browserView.ts b/src/vs/workbench/contrib/browserView/common/browserView.ts index 849efe3775c19..fcf544b9db45d 100644 --- a/src/vs/workbench/contrib/browserView/common/browserView.ts +++ b/src/vs/workbench/contrib/browserView/common/browserView.ts @@ -14,6 +14,7 @@ import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { localize } from '../../../../nls.js'; import { IPlaywrightService } from '../../../../platform/browserView/common/playwrightService.js'; +import type { BrowserEditorInput } from './browserEditorInput.js'; import { IBrowserViewBounds, IBrowserViewNavigationEvent, @@ -23,7 +24,6 @@ import { IBrowserViewKeyDownEvent, IBrowserViewTitleChangeEvent, IBrowserViewFaviconChangeEvent, - IBrowserViewNewPageRequest, IBrowserViewDevToolsStateEvent, IBrowserViewService, BrowserViewStorageScope, @@ -33,15 +33,15 @@ import { IBrowserViewVisibilityEvent, IBrowserViewCertificateError, IElementData, + IBrowserViewOwner, browserZoomDefaultIndex, - browserZoomFactors + browserZoomFactors, + IBrowserViewState } from '../../../../platform/browserView/common/browserView.js'; -import { IWorkspaceContextService, WorkbenchState } from '../../../../platform/workspace/common/workspace.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { isLocalhostAuthority } from '../../../../platform/url/common/trustedDomains.js'; -import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; -import { IWorkspaceTrustManagementService } from '../../../../platform/workspace/common/workspaceTrust.js'; import { IAgentNetworkFilterService } from '../../../../platform/networkFilter/common/networkFilterService.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; import { IBrowserZoomService } from './browserZoomService.js'; /** Extracts the host from a URL string for zoom tracking purposes. */ @@ -97,19 +97,20 @@ export interface IBrowserViewWorkbenchService { readonly _serviceBrand: undefined; /** - * Get or create a browser view model for the given ID - * @param id The browser view identifier - * @returns A browser view model that proxies to the main process + * Fires when the set of known browser views changes. */ - getOrCreateBrowserViewModel(id: string): Promise; + readonly onDidChangeBrowserViews: Event; /** - * Get an existing browser view model for the given ID - * @param id The browser view identifier - * @returns A browser view model that proxies to the main process - * @throws If no browser view exists for the given ID + * Get all known browser views. */ - getBrowserViewModel(id: string): Promise; + getKnownBrowserViews(): Map; + + /** + * Get an existing browser view for the given ID, or create a new one if it doesn't exist. + * The underlying browser view is not created until the editor is opened or the model is resolved. + */ + getOrCreateLazy(id: string, initialState?: IBrowserEditorViewState): BrowserEditorInput; /** * Clear all storage data for the global browser session @@ -159,6 +160,7 @@ export interface IBrowserViewCDPService { */ export interface IBrowserViewModel extends IDisposable { readonly id: string; + readonly owner: IBrowserViewOwner; readonly url: string; readonly title: string; readonly favicon: string | undefined; @@ -186,15 +188,11 @@ export interface IBrowserViewModel extends IDisposable { readonly onDidKeyCommand: Event; readonly onDidChangeTitle: Event; readonly onDidChangeFavicon: Event; - readonly onDidRequestNewPage: Event; readonly onDidFindInPage: Event; readonly onDidChangeVisibility: Event; readonly onDidClose: Event; readonly onWillDispose: Event; - initialize(create: boolean): Promise; - setInitialURL(url: string, title?: string, favicon?: string): void; - layout(bounds: IBrowserViewBounds): Promise; setVisible(visible: boolean): Promise; loadURL(url: string): Promise; @@ -249,129 +247,49 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { constructor( readonly id: string, + readonly owner: IBrowserViewOwner, + initialState: IBrowserViewState, private readonly browserViewService: IBrowserViewService, - @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, - @IWorkspaceTrustManagementService private readonly workspaceTrustManagementService: IWorkspaceTrustManagementService, @ITelemetryService private readonly telemetryService: ITelemetryService, - @IConfigurationService private readonly configurationService: IConfigurationService, @IPlaywrightService private readonly playwrightService: IPlaywrightService, @IDialogService private readonly dialogService: IDialogService, @IStorageService private readonly storageService: IStorageService, @IBrowserZoomService private readonly zoomService: IBrowserZoomService, @IAgentNetworkFilterService private readonly agentNetworkFilterService: IAgentNetworkFilterService, + @ILogService private readonly logService: ILogService, ) { super(); - } - - get url(): string { return this._url; } - get title(): string { return this._title; } - get favicon(): string | undefined { return this._favicon; } - get loading(): boolean { return this._loading; } - get focused(): boolean { return this._focused; } - get visible(): boolean { return this._visible; } - get isDevToolsOpen(): boolean { return this._isDevToolsOpen; } - get canGoBack(): boolean { return this._canGoBack; } - get canGoForward(): boolean { return this._canGoForward; } - get screenshot(): VSBuffer | undefined { return this._screenshot; } - get error(): IBrowserViewLoadError | undefined { return this._error; } - get certificateError(): IBrowserViewCertificateError | undefined { return this._certificateError; } - get storageScope(): BrowserViewStorageScope { return this._storageScope; } - get sharedWithAgent(): boolean { return this._sharedWithAgent; } - get zoomFactor(): number { return browserZoomFactors[this._browserZoomIndex]; } - get canZoomIn(): boolean { return this._browserZoomIndex < browserZoomFactors.length - 1; } - get canZoomOut(): boolean { return this._browserZoomIndex > 0; } - - get onDidNavigate(): Event { - return this.browserViewService.onDynamicDidNavigate(this.id); - } - - get onDidChangeLoadingState(): Event { - return this.browserViewService.onDynamicDidChangeLoadingState(this.id); - } - - get onDidChangeFocus(): Event { - return this.browserViewService.onDynamicDidChangeFocus(this.id); - } - - get onDidChangeDevToolsState(): Event { - return this.browserViewService.onDynamicDidChangeDevToolsState(this.id); - } - - get onDidKeyCommand(): Event { - return this.browserViewService.onDynamicDidKeyCommand(this.id); - } - - get onDidChangeTitle(): Event { - return this.browserViewService.onDynamicDidChangeTitle(this.id); - } - - get onDidChangeFavicon(): Event { - return this.browserViewService.onDynamicDidChangeFavicon(this.id); - } - - get onDidRequestNewPage(): Event { - return this.browserViewService.onDynamicDidRequestNewPage(this.id); - } - - get onDidFindInPage(): Event { - return this.browserViewService.onDynamicDidFindInPage(this.id); - } - - get onDidChangeVisibility(): Event { - return this.browserViewService.onDynamicDidChangeVisibility(this.id); - } - - get onDidClose(): Event { - return this.browserViewService.onDynamicDidClose(this.id); - } - - /** - * Initialize the model with the current state from the main process. - * @param create Whether to create the browser view if it doesn't already exist. - * @throws If the browser view doesn't exist and `create` is false, or if initialization fails - */ - async initialize(create: boolean): Promise { - const dataStorageSetting = this.configurationService.getValue( - 'workbench.browser.dataStorage' - ) ?? BrowserViewStorageScope.Global; - - // Wait for trust initialization before determining storage scope - await this.workspaceTrustManagementService.workspaceTrustInitialized; - const isWorkspaceUntrusted = - this.workspaceContextService.getWorkbenchState() !== WorkbenchState.EMPTY && - !this.workspaceTrustManagementService.isWorkspaceTrusted(); - - // Always use ephemeral sessions for untrusted workspaces - const dataStorage = isWorkspaceUntrusted ? BrowserViewStorageScope.Ephemeral : dataStorageSetting; - - const workspaceId = this.workspaceContextService.getWorkspace().id; - const state = create - ? await this.browserViewService.getOrCreateBrowserView(this.id, dataStorage, workspaceId) - : await this.browserViewService.getState(this.id); - - this._url = state.url; - this._title = state.title; - this._loading = state.loading; - this._focused = state.focused; - this._visible = state.visible; - this._isDevToolsOpen = state.isDevToolsOpen; - this._canGoBack = state.canGoBack; - this._canGoForward = state.canGoForward; - this._screenshot = state.lastScreenshot; - this._favicon = state.lastFavicon; - this._error = state.lastError; - this._certificateError = state.certificateError; - this._storageScope = state.storageScope; - this._sharedWithAgent = await this.playwrightService.isPageTracked(this.id); - this._browserZoomIndex = state.browserZoomIndex; + // Initialize state + this._url = initialState.url; + this._title = initialState.title; + this._loading = initialState.loading; + this._focused = initialState.focused; + this._visible = initialState.visible; + this._isDevToolsOpen = initialState.isDevToolsOpen; + this._canGoBack = initialState.canGoBack; + this._canGoForward = initialState.canGoForward; + this._screenshot = initialState.lastScreenshot; + this._favicon = initialState.lastFavicon; + this._error = initialState.lastError; + this._certificateError = initialState.certificateError; + this._storageScope = initialState.storageScope; + this._browserZoomIndex = initialState.browserZoomIndex; this._isEphemeral = this._storageScope === BrowserViewStorageScope.Ephemeral; this._zoomHost = parseZoomHost(this._url); + // Sync initial zoom and sharing state (async, but emits events) const effectiveZoomIndex = this.zoomService.getEffectiveZoomIndex(this._zoomHost, this._isEphemeral); if (effectiveZoomIndex !== this._browserZoomIndex) { - await this.setBrowserZoomIndex(effectiveZoomIndex); + void this.setBrowserZoomIndex(effectiveZoomIndex).catch(e => { + this.logService.warn(`[BrowserViewModel] Failed to set initial zoom:`, e); + }); } + void this.playwrightService.isPageTracked(this.id).then(shared => this._setSharedWithAgent(shared)).catch(e => { + this.logService.warn(`[BrowserViewModel] Failed to check initial page tracking:`, e); + }); + + // Set up state synchronization this._register(this.zoomService.onDidChangeZoom(({ host, isEphemeralChange }) => { if (isEphemeralChange && !this._isEphemeral) { @@ -380,12 +298,10 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { if (host === undefined || host === this._zoomHost) { void this.setBrowserZoomIndex( this.zoomService.getEffectiveZoomIndex(this._zoomHost, this._isEphemeral) - ); + ).catch(() => { }); } })); - // Set up state synchronization - this._register(this.onDidNavigate(e => { // Clear favicon on navigation to a different host if (URL.parse(e.url)?.host !== URL.parse(this._url)?.host) { @@ -437,17 +353,62 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { })); } - setInitialURL(url: string, title?: string, favicon?: string): void { - if (this._url !== url) { - this._url = url; - this._title = title || ''; - this._favicon = favicon; - this._loading = true; - this._error = undefined; - this._certificateError = undefined; + get url(): string { return this._url; } + get title(): string { return this._title; } + get favicon(): string | undefined { return this._favicon; } + get loading(): boolean { return this._loading; } + get focused(): boolean { return this._focused; } + get visible(): boolean { return this._visible; } + get isDevToolsOpen(): boolean { return this._isDevToolsOpen; } + get canGoBack(): boolean { return this._canGoBack; } + get canGoForward(): boolean { return this._canGoForward; } + get screenshot(): VSBuffer | undefined { return this._screenshot; } + get error(): IBrowserViewLoadError | undefined { return this._error; } + get certificateError(): IBrowserViewCertificateError | undefined { return this._certificateError; } + get storageScope(): BrowserViewStorageScope { return this._storageScope; } + get sharedWithAgent(): boolean { return this._sharedWithAgent; } + get zoomFactor(): number { return browserZoomFactors[this._browserZoomIndex]; } + get canZoomIn(): boolean { return this._browserZoomIndex < browserZoomFactors.length - 1; } + get canZoomOut(): boolean { return this._browserZoomIndex > 0; } - void this.loadURL(url); // Non-blocking - } + get onDidNavigate(): Event { + return this.browserViewService.onDynamicDidNavigate(this.id); + } + + get onDidChangeLoadingState(): Event { + return this.browserViewService.onDynamicDidChangeLoadingState(this.id); + } + + get onDidChangeFocus(): Event { + return this.browserViewService.onDynamicDidChangeFocus(this.id); + } + + get onDidChangeDevToolsState(): Event { + return this.browserViewService.onDynamicDidChangeDevToolsState(this.id); + } + + get onDidKeyCommand(): Event { + return this.browserViewService.onDynamicDidKeyCommand(this.id); + } + + get onDidChangeTitle(): Event { + return this.browserViewService.onDynamicDidChangeTitle(this.id); + } + + get onDidChangeFavicon(): Event { + return this.browserViewService.onDynamicDidChangeFavicon(this.id); + } + + get onDidFindInPage(): Event { + return this.browserViewService.onDynamicDidFindInPage(this.id); + } + + get onDidChangeVisibility(): Event { + return this.browserViewService.onDynamicDidChangeVisibility(this.id); + } + + get onDidClose(): Event { + return this.browserViewService.onDynamicDidClose(this.id); } async layout(bounds: IBrowserViewBounds): Promise { diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts index 4d4af6bf91e40..4b7789a3dee78 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts @@ -14,15 +14,14 @@ import { RawContextKey, IContextKey, IContextKeyService } from '../../../../plat import { MenuId } from '../../../../platform/actions/common/actions.js'; import { IInstantiationService, IConstructorSignature, BrandedService } from '../../../../platform/instantiation/common/instantiation.js'; import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js'; -import { AUX_WINDOW_GROUP, IEditorService } from '../../../services/editor/common/editorService.js'; import { EditorPane } from '../../../browser/parts/editor/editorPane.js'; import { IEditorOpenContext } from '../../../common/editor.js'; import { BrowserEditorInput } from '../common/browserEditorInput.js'; -import { IBrowserEditorViewState, IBrowserViewModel } from '../../browserView/common/browserView.js'; +import { IBrowserViewModel } from '../../browserView/common/browserView.js'; import { IThemeService } from '../../../../platform/theme/common/themeService.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { IStorageService } from '../../../../platform/storage/common/storage.js'; -import { IBrowserViewKeyDownEvent, IBrowserViewNavigationEvent, IBrowserViewLoadError, IBrowserViewCertificateError, BrowserNewPageLocation } from '../../../../platform/browserView/common/browserView.js'; +import { IBrowserViewKeyDownEvent, IBrowserViewNavigationEvent, IBrowserViewLoadError, IBrowserViewCertificateError } from '../../../../platform/browserView/common/browserView.js'; import { IEditorGroup } from '../../../services/editor/common/editorGroupsService.js'; import { IEditorOptions } from '../../../../platform/editor/common/editor.js'; import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; @@ -39,10 +38,9 @@ import { ChatContextKeys } from '../../chat/common/actions/chatContextKeys.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { encodeBase64, VSBuffer } from '../../../../base/common/buffer.js'; import { SiteInfoWidget } from './siteInfoWidget.js'; -import { logBrowserOpen } from '../../../../platform/browserView/common/browserViewTelemetry.js'; -import { URI } from '../../../../base/common/uri.js'; import { Emitter } from '../../../../base/common/event.js'; import { ILayoutService } from '../../../../platform/layout/browser/layoutService.js'; +import { ILifecycleService, ShutdownReason } from '../../../services/lifecycle/common/lifecycle.js'; export const CONTEXT_BROWSER_CAN_GO_BACK = new RawContextKey('browserCanGoBack', false, localize('browser.canGoBack', "Whether the browser can go back")); export const CONTEXT_BROWSER_CAN_GO_FORWARD = new RawContextKey('browserCanGoForward', false, localize('browser.canGoForward', "Whether the browser can go forward")); @@ -376,10 +374,17 @@ export class BrowserEditor extends EditorPane { @ILogService private readonly logService: ILogService, @IInstantiationService private readonly instantiationService: IInstantiationService, @IContextKeyService private readonly contextKeyService: IContextKeyService, - @IEditorService private readonly editorService: IEditorService, @ILayoutService private readonly layoutService: ILayoutService, + @ILifecycleService private readonly lifecycleService: ILifecycleService, ) { super(BrowserEditorInput.EDITOR_ID, group, telemetryService, themeService, storageService); + + // Be sure to hide the view when the workbench is reloading, as `clearInput()` may not be called. + this._register(this.lifecycleService.onWillShutdown((e) => { + if (e.reason === ShutdownReason.RELOAD) { + this._model?.setVisible(false); + } + })); } protected override createEditor(parent: HTMLElement): void { @@ -598,31 +603,6 @@ export class BrowserEditor extends EditorPane { } })); - this._inputDisposables.add(this._model.onDidRequestNewPage(({ resource, url, location, position }) => { - logBrowserOpen(this.telemetryService, (() => { - switch (location) { - case BrowserNewPageLocation.Background: return 'browserLinkBackground'; - case BrowserNewPageLocation.Foreground: return 'browserLinkForeground'; - case BrowserNewPageLocation.NewWindow: return 'browserLinkNewWindow'; - } - })()); - - const targetGroup = location === BrowserNewPageLocation.NewWindow ? AUX_WINDOW_GROUP : this.group; - const viewState: IBrowserEditorViewState = { url }; - this.editorService.openEditor({ - resource: URI.revive(resource), - options: { - pinned: true, - inactive: location === BrowserNewPageLocation.Background, - auxiliary: { - bounds: position, - compact: true - }, - viewState - } - }, targetGroup); - })); - this._inputDisposables.add(this.overlayManager!.onDidChangeOverlayState(() => { this.checkOverlays(); })); diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts index be1e6c7d1cb58..e8fc1087cc783 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts @@ -11,7 +11,6 @@ import { EditorExtensions, IEditorFactoryRegistry } from '../../../common/editor import { BrowserEditor } from './browserEditor.js'; import { BrowserEditorInput, BrowserEditorSerializer } from '../common/browserEditorInput.js'; import { BrowserViewUri } from '../../../../platform/browserView/common/browserViewUri.js'; -import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { registerSingleton, InstantiationType } from '../../../../platform/instantiation/common/extensions.js'; import { IEditorResolverService, RegisteredEditorPriority } from '../../../services/editor/common/editorResolverService.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../common/contributions.js'; @@ -50,7 +49,7 @@ class BrowserEditorResolverContribution implements IWorkbenchContribution { constructor( @IEditorResolverService editorResolverService: IEditorResolverService, - @IInstantiationService instantiationService: IInstantiationService + @IBrowserViewWorkbenchService browserViewWorkbenchService: IBrowserViewWorkbenchService, ) { editorResolverService.registerEditor( `${Schemas.vscodeBrowser}:/**`, @@ -70,10 +69,7 @@ class BrowserEditorResolverContribution implements IWorkbenchContribution { throw new Error(`Invalid browser view resource: ${resource.toString()}`); } - const browserInput = instantiationService.createInstance(BrowserEditorInput, { - ...options?.viewState, - id: parsed.id - }); + const browserInput = browserViewWorkbenchService.getOrCreateLazy(parsed.id, options?.viewState); // Start resolving the input right away. This will create the browser view. // This allows browser views to be loaded in the background. @@ -82,8 +78,8 @@ class BrowserEditorResolverContribution implements IWorkbenchContribution { return { editor: browserInput, options: { - ...options, - pinned: !!browserInput.url // pin if navigated + pinned: !!browserInput.url, // pin if navigated + ...options } }; } diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserViewWorkbenchService.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserViewWorkbenchService.ts index d189013b3adb7..4645abef4945e 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserViewWorkbenchService.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserViewWorkbenchService.ts @@ -3,15 +3,22 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { BrowserViewCommandId, IBrowserViewService, ipcBrowserViewChannelName } from '../../../../platform/browserView/common/browserView.js'; -import { IBrowserViewWorkbenchService, IBrowserViewModel, BrowserViewModel } from '../common/browserView.js'; +import { BrowserViewCommandId, BrowserViewStorageScope, IBrowserViewOpenOptions, IBrowserViewOwner, IBrowserViewService, IBrowserViewState, ipcBrowserViewChannelName } from '../../../../platform/browserView/common/browserView.js'; +import { IBrowserViewWorkbenchService, IBrowserViewModel, BrowserViewModel, IBrowserEditorViewState } from '../common/browserView.js'; import { IMainProcessService } from '../../../../platform/ipc/common/mainProcessService.js'; import { ProxyChannel } from '../../../../base/parts/ipc/common/ipc.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; -import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; -import { Event } from '../../../../base/common/event.js'; +import { IWorkspaceContextService, WorkbenchState } from '../../../../platform/workspace/common/workspace.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; +import { AUX_WINDOW_GROUP, IEditorService } from '../../../services/editor/common/editorService.js'; +import { mainWindow } from '../../../../base/browser/window.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { IWorkspaceTrustManagementService } from '../../../../platform/workspace/common/workspaceTrust.js'; +import { BrowserEditorInput } from '../common/browserEditorInput.js'; +import { IEditorGroupsService } from '../../../services/editor/common/editorGroupsService.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; /** Command IDs whose accelerators are shown in browser view context menus. */ const browserViewContextMenuCommands = [ @@ -24,28 +31,85 @@ export class BrowserViewWorkbenchService extends Disposable implements IBrowserV declare readonly _serviceBrand: undefined; private readonly _browserViewService: IBrowserViewService; - private readonly _models = new Map(); + private readonly _known = new Map(); + private readonly _mainWindowId: number; + + private readonly _onDidChangeBrowserViews = this._register(new Emitter()); + readonly onDidChangeBrowserViews: Event = this._onDidChangeBrowserViews.event; constructor( @IMainProcessService mainProcessService: IMainProcessService, @IInstantiationService private readonly instantiationService: IInstantiationService, @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, - @IKeybindingService private readonly keybindingService: IKeybindingService + @IKeybindingService private readonly keybindingService: IKeybindingService, + @IEditorService private readonly editorService: IEditorService, + @IEditorGroupsService private readonly editorGroupsService: IEditorGroupsService, + @IConfigurationService private readonly configurationService: IConfigurationService, + @IWorkspaceTrustManagementService private readonly workspaceTrustManagementService: IWorkspaceTrustManagementService, + @ILogService private readonly logService: ILogService ) { super(); const channel = mainProcessService.getChannel(ipcBrowserViewChannelName); this._browserViewService = ProxyChannel.toService(channel); + this._mainWindowId = mainWindow.vscodeWindowId; this.sendKeybindings(); this._register(this.keybindingService.onDidUpdateKeybindings(() => this.sendKeybindings())); + + // Start asynchronously creating models for all views we already own. + void this._initializeExistingViews().catch(e => { + this.logService.error('[BrowserViewWorkbenchService] Failed to initialize existing browser views.', e); + }); + + // Listen for new browser views + this._register(this._browserViewService.onDidCreateBrowserView(e => { + if (e.info.owner.mainWindowId !== this._mainWindowId) { + return; // Not for this window + } + + // Eagerly create the model from the state we already have + this._createModel(e.info.id, e.info.owner, e.info.state); + + const editor = this._known.get(e.info.id); + if (editor) { + this._openEditorForCreatedView(editor, e.openOptions); + } + })); } - async getOrCreateBrowserViewModel(id: string): Promise { - return this._getBrowserViewModel(id, true); + getKnownBrowserViews(): Map { + return this._known; } - async getBrowserViewModel(id: string): Promise { - return this._getBrowserViewModel(id, false); + getOrCreateLazy(id: string, initialState?: IBrowserEditorViewState, model?: IBrowserViewModel): BrowserEditorInput { + if (!this._known.has(id)) { + const input = this.instantiationService.createInstance(BrowserEditorInput, { id, ...initialState }, async () => { + const state = await this._browserViewService.getOrCreateBrowserView( + id, + { + owner: this._getDefaultOwner(), + scope: await this._resolveStorageScope(), + initialState: { + url: initialState?.url, + title: initialState?.title, + lastFavicon: initialState?.favicon + } + } + ); + return this._createModel(id, this._getDefaultOwner(), state); + }); + input.onWillDispose(() => { + this._known.delete(id); + this._onDidChangeBrowserViews.fire(); + }); + if (model) { + input.model = model; + } + this._known.set(id, input); + this._onDidChangeBrowserViews.fire(); + } + + return this._known.get(id)!; } async clearGlobalStorage(): Promise { @@ -57,31 +121,91 @@ export class BrowserViewWorkbenchService extends Disposable implements IBrowserV return this._browserViewService.clearWorkspaceStorage(workspaceId); } - private async _getBrowserViewModel(id: string, create: boolean): Promise { - let model = this._models.get(id); - if (model) { - return model; - } + private _getDefaultOwner(): IBrowserViewOwner { + return { mainWindowId: this._mainWindowId }; + } + + private async _resolveStorageScope(): Promise { + const dataStorageSetting = this.configurationService.getValue( + 'workbench.browser.dataStorage' + ) ?? BrowserViewStorageScope.Global; - model = this.instantiationService.createInstance(BrowserViewModel, id, this._browserViewService); - this._models.set(id, model); + await this.workspaceTrustManagementService.workspaceTrustInitialized; - // Initialize the model with current state - try { - await model.initialize(create); - } catch (e) { - this._models.delete(id); - throw e; + const isWorkspaceUntrusted = + this.workspaceContextService.getWorkbenchState() !== WorkbenchState.EMPTY && + !this.workspaceTrustManagementService.isWorkspaceTrusted(); + + return isWorkspaceUntrusted ? BrowserViewStorageScope.Ephemeral : dataStorageSetting; + } + + /** + * Fetch all views owned by this window from the main service and create + * models for them so they are available synchronously. + */ + private async _initializeExistingViews(): Promise { + const views = await this._browserViewService.getBrowserViews(this._mainWindowId); + for (const info of views) { + if (!this._known.has(info.id)) { + this._createModel(info.id, info.owner, info.state); + } } + } - // Clean up model when disposed - Event.once(model.onWillDispose)(() => { - this._models.delete(id); - }); + private _createModel(id: string, owner: IBrowserViewOwner, state: IBrowserViewState): IBrowserViewModel { + // Don't double-create + const existing = this._known.get(id)?.model; + if (existing) { + return existing; + } + + const model = this.instantiationService.createInstance(BrowserViewModel, id, owner, state, this._browserViewService); + + // Sanity: both pass and assign the model to be sure. It will no-op if already set. + this.getOrCreateLazy(id, {}, model).model = model; return model; } + /** + * Open an editor tab for a newly created browser view. + */ + private _openEditorForCreatedView(view: BrowserEditorInput, openOptions: IBrowserViewOpenOptions): void { + const opts = openOptions; + + // Resolve target group: auxiliary window, parent's group, or default + let targetGroup: number | typeof AUX_WINDOW_GROUP | undefined; + if (opts.auxiliaryWindow) { + targetGroup = AUX_WINDOW_GROUP; + } else if (opts.parentViewId) { + targetGroup = this._findEditorGroupForView(opts.parentViewId); + } + + void this.editorService.openEditor(view, { + inactive: opts.background, + preserveFocus: opts.preserveFocus, + pinned: opts.pinned, + auxiliary: opts.auxiliaryWindow + ? { bounds: opts.auxiliaryWindow, compact: true } + : undefined, + }, targetGroup); + } + + /** + * Find the editor group that currently contains a browser view with the + * given ID, or undefined if not open in any group. + */ + private _findEditorGroupForView(viewId: string): number | undefined { + for (const group of this.editorGroupsService.groups) { + for (const editor of group.editors) { + if (editor instanceof BrowserEditorInput && editor.id === viewId) { + return group.id; + } + } + } + return undefined; + } + private sendKeybindings(): void { const keybindings: { [commandId: string]: string } = Object.create(null); for (const commandId of browserViewContextMenuCommands) { diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserTabManagementFeatures.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserTabManagementFeatures.ts index 2f0c1db0f6c0c..e51217a53b5a6 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/features/browserTabManagementFeatures.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserTabManagementFeatures.ts @@ -25,6 +25,7 @@ import { ITelemetryService } from '../../../../../platform/telemetry/common/tele import { ContextKeyExpr, IContextKeyService, RawContextKey } from '../../../../../platform/contextkey/common/contextkey.js'; import { BrowserViewCommandId } from '../../../../../platform/browserView/common/browserView.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../common/contributions.js'; +import { IBrowserViewWorkbenchService } from '../../common/browserView.js'; import { IConfigurationRegistry, Extensions as ConfigurationExtensions } from '../../../../../platform/configuration/common/configurationRegistry.js'; import { workbenchConfigurationNodeBase } from '../../../../common/configuration.js'; import { IExternalOpener, IOpenerService } from '../../../../../platform/opener/common/opener.js'; @@ -279,6 +280,7 @@ class OpenIntegratedBrowserAction extends Action2 { async run(accessor: ServicesAccessor, urlOrOptions?: string | IOpenBrowserOptions): Promise { const editorService = accessor.get(IEditorService); const telemetryService = accessor.get(ITelemetryService); + const browserViewService = accessor.get(IBrowserViewWorkbenchService); // Parse arguments const options = typeof urlOrOptions === 'string' ? { url: urlOrOptions } : (urlOrOptions ?? {}); @@ -287,11 +289,7 @@ class OpenIntegratedBrowserAction extends Action2 { if (options.reuseUrlFilter) { const filterUri = URI.parse(options.reuseUrlFilter); - const matchingEditor = editorService.editors.find((e): e is BrowserEditorInput => { - if (!(e instanceof BrowserEditorInput)) { - return false; - } - + const matchingEditor = [...browserViewService.getKnownBrowserViews().values()].find((e) => { const editorUri = URI.parse(e.url || ''); // URIs default to putting "file" scheme. Check that the scheme is really in the filter. if (filterUri.scheme && options.reuseUrlFilter!.startsWith(`${filterUri.scheme}:`) && filterUri.scheme !== editorUri.scheme) { @@ -428,10 +426,10 @@ class OpenBrowserFromViewMenuAction extends Action2 { } async run(accessor: ServicesAccessor): Promise { - const editorService = accessor.get(IEditorService); + const browserViewService = accessor.get(IBrowserViewWorkbenchService); const commandService = accessor.get(ICommandService); - const hasOpenBrowserEditor = editorService.editors.some(editor => editor instanceof BrowserEditorInput); + const hasOpenBrowserEditor = browserViewService.getKnownBrowserViews().size > 0; if (hasOpenBrowserEditor) { await commandService.executeCommand(BrowserViewCommandId.QuickOpen); @@ -477,25 +475,15 @@ class BrowserEditorOpenContextKeyContribution extends Disposable implements IWor constructor( @IContextKeyService contextKeyService: IContextKeyService, - @IEditorService editorService: IEditorService, + @IBrowserViewWorkbenchService browserViewService: IBrowserViewWorkbenchService, ) { super(); const contextKey = CONTEXT_BROWSER_EDITOR_OPEN.bindTo(contextKeyService); - const update = () => contextKey.set(editorService.editors.some(e => e instanceof BrowserEditorInput)); + const update = () => contextKey.set(browserViewService.getKnownBrowserViews().size > 0); update(); - - this._register(editorService.onWillOpenEditor(e => { - if (e.editor instanceof BrowserEditorInput) { - contextKey.set(true); - } - })); - this._register(editorService.onDidCloseEditor(e => { - if (e.editor instanceof BrowserEditorInput) { - update(); - } - })); + this._register(browserViewService.onDidChangeBrowserViews(() => update())); } } diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/browserToolHelpers.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/browserToolHelpers.ts index 44a84be163943..641cd8bb0dc37 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/browserToolHelpers.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/browserToolHelpers.ts @@ -12,6 +12,7 @@ import { IAgentNetworkFilterService } from '../../../../../platform/networkFilte import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { IToolResult } from '../../../chat/common/tools/languageModelToolsService.js'; import { BrowserEditorInput } from '../../common/browserEditorInput.js'; +import { IBrowserViewWorkbenchService } from '../../common/browserView.js'; // eslint-disable-next-line local/code-import-patterns import type { Page } from 'playwright-core'; @@ -145,10 +146,10 @@ export function errorResult(message: string): IToolResult { * exists. When {@link playwrightService} is provided, only pages tracked by Playwright * (i.e. shared with the agent) are considered. * - * @returns The first matching {@link BrowserEditorInput}, or `undefined` if none was found. + * @returns All matching {@link BrowserEditorInput}s. */ async function findExistingPagesByHost( - editorService: IEditorService, + browserViewService: IBrowserViewWorkbenchService, playwrightService: IPlaywrightService | undefined, url: string, ): Promise { @@ -162,7 +163,7 @@ async function findExistingPagesByHost( : undefined; const results: BrowserEditorInput[] = []; - for (const editor of editorService.editors) { + for (const editor of browserViewService.getKnownBrowserViews().values()) { if (!(editor instanceof BrowserEditorInput)) { continue; } @@ -197,11 +198,12 @@ async function findExistingPagesByHost( */ export async function getExistingPagesResult( editorService: IEditorService, + browserViewService: IBrowserViewWorkbenchService, playwrightService: IPlaywrightService | undefined, url: string, formatOptions?: FormatBrowserEditorLinesOptions ): Promise { - const existing = await findExistingPagesByHost(editorService, playwrightService, url); + const existing = await findExistingPagesByHost(browserViewService, playwrightService, url); if (existing.length === 0) { return undefined; } diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/browserTools.contribution.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/browserTools.contribution.ts index b1097a0d1f725..4dad58cb16fff 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/browserTools.contribution.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/browserTools.contribution.ts @@ -14,7 +14,7 @@ import { registerWorkbenchContribution2, WorkbenchPhase, type IWorkbenchContribu import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { IChatContextService } from '../../../chat/browser/contextContrib/chatContextService.js'; import { ILanguageModelToolsService, ToolDataSource, ToolSet } from '../../../chat/common/tools/languageModelToolsService.js'; -import { BrowserEditorInput } from '../../common/browserEditorInput.js'; +import { IBrowserViewWorkbenchService } from '../../common/browserView.js'; import { formatBrowserEditorList } from './browserToolHelpers.js'; import { ClickBrowserTool, ClickBrowserToolData } from './clickBrowserTool.js'; import { DragElementTool, DragElementToolData } from './dragElementTool.js'; @@ -45,6 +45,7 @@ class BrowserChatAgentToolsContribution extends Disposable implements IWorkbench @IPlaywrightService private readonly playwrightService: IPlaywrightService, @IChatContextService private readonly chatContextService: IChatContextService, @IEditorService private readonly editorService: IEditorService, + @IBrowserViewWorkbenchService private readonly browserViewService: IBrowserViewWorkbenchService, @IAgentNetworkFilterService private readonly agentNetworkFilterService: IAgentNetworkFilterService, ) { super(); @@ -111,24 +112,20 @@ class BrowserChatAgentToolsContribution extends Disposable implements IWorkbench this._trackedIds = new Set(ids); this._updateBrowserContext(); })); - this._toolsStore.add(this.editorService.onDidEditorsChange(() => this._updateBrowserContext())); + this._toolsStore.add(this.browserViewService.onDidChangeBrowserViews(() => this._updateBrowserContext())); this._toolsStore.add(this.agentNetworkFilterService.onDidChange(() => this._updateBrowserContext())); } private _updateBrowserContext(): void { - const trackedEditors: BrowserEditorInput[] = []; - for (const editor of this.editorService.editors) { - if (editor instanceof BrowserEditorInput && this._trackedIds.has(editor.id)) { - trackedEditors.push(editor); - } - } + const trackedBrowsers = [...this.browserViewService.getKnownBrowserViews().values()] + .filter(entry => this._trackedIds.has(entry.id)); - if (trackedEditors.length === 0) { + if (trackedBrowsers.length === 0) { this.chatContextService.updateWorkspaceContextItems(BrowserChatAgentToolsContribution.CONTEXT_ID, []); return; } - const list = formatBrowserEditorList(this.editorService, trackedEditors, { agentNetworkFilterService: this.agentNetworkFilterService }); + const list = formatBrowserEditorList(this.editorService, trackedBrowsers, { agentNetworkFilterService: this.agentNetworkFilterService }); this.chatContextService.updateWorkspaceContextItems(BrowserChatAgentToolsContribution.CONTEXT_ID, [{ handle: 0, label: localize('browserContext.label', "Browser Pages"), diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserTool.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserTool.ts index 00fd405d9df6f..89b5d2159b023 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserTool.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserTool.ts @@ -13,6 +13,7 @@ import { IEditorService } from '../../../../services/editor/common/editorService import { ToolDataSource, type CountTokensCallback, type IPreparedToolInvocation, type IToolData, type IToolImpl, type IToolInvocation, type IToolInvocationPreparationContext, type IToolResult, type ToolProgress } from '../../../chat/common/tools/languageModelToolsService.js'; import { IAgentNetworkFilterService } from '../../../../../platform/networkFilter/common/networkFilterService.js'; import { createBrowserPageLink, getExistingPagesResult } from './browserToolHelpers.js'; +import { IBrowserViewWorkbenchService } from '../../common/browserView.js'; export const OpenPageToolId = 'open_browser_page'; @@ -49,6 +50,7 @@ export class OpenBrowserTool implements IToolImpl { constructor( @IPlaywrightService private readonly playwrightService: IPlaywrightService, @IEditorService private readonly editorService: IEditorService, + @IBrowserViewWorkbenchService private readonly browserViewService: IBrowserViewWorkbenchService, @IAgentNetworkFilterService private readonly agentNetworkFilterService: IAgentNetworkFilterService, ) { } @@ -84,7 +86,7 @@ export class OpenBrowserTool implements IToolImpl { const params = invocation.parameters as IOpenBrowserToolParams; if (!params.forceNew) { - const existingResult = await getExistingPagesResult(this.editorService, this.playwrightService, params.url, { agentNetworkFilterService: this.agentNetworkFilterService }); + const existingResult = await getExistingPagesResult(this.editorService, this.browserViewService, this.playwrightService, params.url, { agentNetworkFilterService: this.agentNetworkFilterService }); if (existingResult) { return existingResult; } diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserToolNonAgentic.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserToolNonAgentic.ts index 12ca0b468f71e..149fdd44c796a 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserToolNonAgentic.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserToolNonAgentic.ts @@ -14,6 +14,7 @@ import { type CountTokensCallback, type IPreparedToolInvocation, type IToolData, import { IOpenBrowserToolParams, OpenBrowserToolData } from './openBrowserTool.js'; import { MarkdownString } from '../../../../../base/common/htmlContent.js'; import { createBrowserPageLink, getExistingPagesResult } from './browserToolHelpers.js'; +import { IBrowserViewWorkbenchService } from '../../common/browserView.js'; export const OpenBrowserToolNonAgenticData: IToolData = { ...OpenBrowserToolData, @@ -24,6 +25,7 @@ export class OpenBrowserToolNonAgentic implements IToolImpl { constructor( @ITelemetryService private readonly telemetryService: ITelemetryService, @IEditorService private readonly editorService: IEditorService, + @IBrowserViewWorkbenchService private readonly browserViewService: IBrowserViewWorkbenchService, ) { } async prepareToolInvocation(context: IToolInvocationPreparationContext, _token: CancellationToken): Promise { @@ -52,7 +54,7 @@ export class OpenBrowserToolNonAgentic implements IToolImpl { const params = invocation.parameters as IOpenBrowserToolParams; if (!params.forceNew) { - const existingResult = await getExistingPagesResult(this.editorService, undefined, params.url, { excludeIds: true }); + const existingResult = await getExistingPagesResult(this.editorService, this.browserViewService, undefined, params.url, { excludeIds: true }); if (existingResult) { return existingResult; } diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/screenshotBrowserTool.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/screenshotBrowserTool.ts index 11a1b91b22cdb..a0f6fc6925773 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/screenshotBrowserTool.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/screenshotBrowserTool.ts @@ -96,7 +96,11 @@ export class ScreenshotBrowserTool implements IToolImpl { // Note that we don't use Playwright's screenshot methods because they cause brief flashing on the page, // and also doesn't handle zooming well. - const browserViewModel = await this.browserViewWorkbenchService.getBrowserViewModel(params.pageId); // Throws if the given pageId doesn't exist + const browserViewModel = await this.browserViewWorkbenchService.getKnownBrowserViews().get(params.pageId)?.resolve(); + if (!browserViewModel) { + return errorResult(`No browser page found with ID ${params.pageId}`); + } + const bounds = selector && await playwrightInvokeRaw(this.playwrightService, params.pageId, async (page, selector, scrollIntoViewIfNeeded) => { const locator = page.locator(selector); if (scrollIntoViewIfNeeded) { From 17813fd47a88636f12c45273f61781198256b39b Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Thu, 23 Apr 2026 17:42:37 -0400 Subject: [PATCH 65/70] don't truncate question (#312220) --- .../chatContentParts/media/chatQuestionCarousel.css | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css index e941918d96dc7..66535a8f2927d 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css @@ -62,12 +62,11 @@ display: flex; flex-direction: column; flex-shrink: 0; - overflow: hidden; .chat-question-title-row { display: flex; justify-content: space-between; - align-items: center; + align-items: flex-start; gap: 8px; min-width: 0; padding: 8px 8px 8px 16px; @@ -77,8 +76,9 @@ .chat-question-title { flex: 1; min-width: 0; - word-wrap: break-word; - overflow-wrap: break-word; + word-break: break-word; + overflow-wrap: anywhere; + white-space: normal; font-weight: 500; font-size: var(--vscode-chat-font-size-body-s); margin: 0; From f67a9935784e2eed98093bb364f317094a89e5ed Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Thu, 23 Apr 2026 14:45:42 -0700 Subject: [PATCH 66/70] Update snapshots --- .../agentPrompts-claude-opus-4.5/all_non_edit_tools.spec.snap | 1 + .../agentPrompts-claude-opus-4.5/all_tools.spec.snap | 1 + .../agentPrompts-claude-opus-4.6/all_non_edit_tools.spec.snap | 1 + .../agentPrompts-claude-opus-4.6/all_tools.spec.snap | 1 + .../agentPrompts-claude-sonnet-4.5/all_non_edit_tools.spec.snap | 1 + .../agentPrompts-claude-sonnet-4.5/all_tools.spec.snap | 1 + .../agentPrompts-claude-sonnet-4.6/all_non_edit_tools.spec.snap | 1 + .../agentPrompts-claude-sonnet-4.6/all_tools.spec.snap | 1 + 8 files changed, 8 insertions(+) diff --git a/extensions/copilot/src/extension/prompts/node/agent/test/__snapshots__/agentPrompts-claude-opus-4.5/all_non_edit_tools.spec.snap b/extensions/copilot/src/extension/prompts/node/agent/test/__snapshots__/agentPrompts-claude-opus-4.5/all_non_edit_tools.spec.snap index bcb792fd42c92..afbfef22b3053 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/test/__snapshots__/agentPrompts-claude-opus-4.5/all_non_edit_tools.spec.snap +++ b/extensions/copilot/src/extension/prompts/node/agent/test/__snapshots__/agentPrompts-claude-opus-4.5/all_non_edit_tools.spec.snap @@ -91,6 +91,7 @@ get_project_setup_info get_search_view_results get_vscode_api github_repo +github_text_search install_extension read_notebook_cell_output read_project_structure diff --git a/extensions/copilot/src/extension/prompts/node/agent/test/__snapshots__/agentPrompts-claude-opus-4.5/all_tools.spec.snap b/extensions/copilot/src/extension/prompts/node/agent/test/__snapshots__/agentPrompts-claude-opus-4.5/all_tools.spec.snap index 85814394368b2..4743542bdf0ff 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/test/__snapshots__/agentPrompts-claude-opus-4.5/all_tools.spec.snap +++ b/extensions/copilot/src/extension/prompts/node/agent/test/__snapshots__/agentPrompts-claude-opus-4.5/all_tools.spec.snap @@ -90,6 +90,7 @@ get_project_setup_info get_search_view_results get_vscode_api github_repo +github_text_search install_extension read_notebook_cell_output read_project_structure diff --git a/extensions/copilot/src/extension/prompts/node/agent/test/__snapshots__/agentPrompts-claude-opus-4.6/all_non_edit_tools.spec.snap b/extensions/copilot/src/extension/prompts/node/agent/test/__snapshots__/agentPrompts-claude-opus-4.6/all_non_edit_tools.spec.snap index a22d84d1ebe64..3ba1c3a97d206 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/test/__snapshots__/agentPrompts-claude-opus-4.6/all_non_edit_tools.spec.snap +++ b/extensions/copilot/src/extension/prompts/node/agent/test/__snapshots__/agentPrompts-claude-opus-4.6/all_non_edit_tools.spec.snap @@ -73,6 +73,7 @@ get_project_setup_info get_search_view_results get_vscode_api github_repo +github_text_search install_extension read_notebook_cell_output read_project_structure diff --git a/extensions/copilot/src/extension/prompts/node/agent/test/__snapshots__/agentPrompts-claude-opus-4.6/all_tools.spec.snap b/extensions/copilot/src/extension/prompts/node/agent/test/__snapshots__/agentPrompts-claude-opus-4.6/all_tools.spec.snap index 0aa2cd1657c72..6c10dbbdb81b9 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/test/__snapshots__/agentPrompts-claude-opus-4.6/all_tools.spec.snap +++ b/extensions/copilot/src/extension/prompts/node/agent/test/__snapshots__/agentPrompts-claude-opus-4.6/all_tools.spec.snap @@ -73,6 +73,7 @@ get_project_setup_info get_search_view_results get_vscode_api github_repo +github_text_search install_extension read_notebook_cell_output read_project_structure diff --git a/extensions/copilot/src/extension/prompts/node/agent/test/__snapshots__/agentPrompts-claude-sonnet-4.5/all_non_edit_tools.spec.snap b/extensions/copilot/src/extension/prompts/node/agent/test/__snapshots__/agentPrompts-claude-sonnet-4.5/all_non_edit_tools.spec.snap index bcb792fd42c92..afbfef22b3053 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/test/__snapshots__/agentPrompts-claude-sonnet-4.5/all_non_edit_tools.spec.snap +++ b/extensions/copilot/src/extension/prompts/node/agent/test/__snapshots__/agentPrompts-claude-sonnet-4.5/all_non_edit_tools.spec.snap @@ -91,6 +91,7 @@ get_project_setup_info get_search_view_results get_vscode_api github_repo +github_text_search install_extension read_notebook_cell_output read_project_structure diff --git a/extensions/copilot/src/extension/prompts/node/agent/test/__snapshots__/agentPrompts-claude-sonnet-4.5/all_tools.spec.snap b/extensions/copilot/src/extension/prompts/node/agent/test/__snapshots__/agentPrompts-claude-sonnet-4.5/all_tools.spec.snap index 85814394368b2..4743542bdf0ff 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/test/__snapshots__/agentPrompts-claude-sonnet-4.5/all_tools.spec.snap +++ b/extensions/copilot/src/extension/prompts/node/agent/test/__snapshots__/agentPrompts-claude-sonnet-4.5/all_tools.spec.snap @@ -90,6 +90,7 @@ get_project_setup_info get_search_view_results get_vscode_api github_repo +github_text_search install_extension read_notebook_cell_output read_project_structure diff --git a/extensions/copilot/src/extension/prompts/node/agent/test/__snapshots__/agentPrompts-claude-sonnet-4.6/all_non_edit_tools.spec.snap b/extensions/copilot/src/extension/prompts/node/agent/test/__snapshots__/agentPrompts-claude-sonnet-4.6/all_non_edit_tools.spec.snap index ef8381871d947..513c41537c9f8 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/test/__snapshots__/agentPrompts-claude-sonnet-4.6/all_non_edit_tools.spec.snap +++ b/extensions/copilot/src/extension/prompts/node/agent/test/__snapshots__/agentPrompts-claude-sonnet-4.6/all_non_edit_tools.spec.snap @@ -73,6 +73,7 @@ get_project_setup_info get_search_view_results get_vscode_api github_repo +github_text_search install_extension read_notebook_cell_output read_project_structure diff --git a/extensions/copilot/src/extension/prompts/node/agent/test/__snapshots__/agentPrompts-claude-sonnet-4.6/all_tools.spec.snap b/extensions/copilot/src/extension/prompts/node/agent/test/__snapshots__/agentPrompts-claude-sonnet-4.6/all_tools.spec.snap index 8cc8f00a5689a..8f118188a401c 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/test/__snapshots__/agentPrompts-claude-sonnet-4.6/all_tools.spec.snap +++ b/extensions/copilot/src/extension/prompts/node/agent/test/__snapshots__/agentPrompts-claude-sonnet-4.6/all_tools.spec.snap @@ -73,6 +73,7 @@ get_project_setup_info get_search_view_results get_vscode_api github_repo +github_text_search install_extension read_notebook_cell_output read_project_structure From 8e8f5d8c69aa2655e1eab1cc05483fc163852a83 Mon Sep 17 00:00:00 2001 From: Tyler James Leonhardt <2644648+TylerLeonhardt@users.noreply.github.com> Date: Thu, 23 Apr 2026 15:05:16 -0700 Subject: [PATCH 67/70] When in the Agents app, return all sessions (#312229) and make sure that git stuff is gated behind workspace trust. Co-authored-by: Copilot --- .../extension/chatSessions/claude/AGENTS.md | 4 +- .../claude/CLAUDE_SESSION_USER_GUIDE.md | 2 + .../claude/node/claudeCodeSdkService.ts | 4 +- .../sessionParser/claudeCodeSessionService.ts | 12 ++++ .../test/claudeCodeSessionService.spec.ts | 56 ++++++++++++++++++- .../node/test/mockClaudeCodeSdkService.ts | 2 +- .../claudeChatSessionContentProvider.ts | 32 ++++++++--- .../vscode-node/sessionOptionGroupBuilder.ts | 2 +- .../test/node/notebookPromptRendering.spec.ts | 3 + .../ignore/node/test/mockWorkspaceService.ts | 6 +- .../test/node/simulationWorkspaceServices.ts | 4 ++ .../workspace/common/workspaceService.ts | 6 ++ .../workspace/vscode/workspaceServiceImpl.ts | 4 ++ 13 files changed, 121 insertions(+), 16 deletions(-) diff --git a/extensions/copilot/src/extension/chatSessions/claude/AGENTS.md b/extensions/copilot/src/extension/chatSessions/claude/AGENTS.md index c439aa682639e..4adc4bf2bc269 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/AGENTS.md +++ b/extensions/copilot/src/extension/chatSessions/claude/AGENTS.md @@ -240,7 +240,9 @@ In multi-root and empty workspaces, a folder picker option appears in the chat s ### Session Metadata Enrichment -Each Claude session item carries metadata that drives the Sessions view UI (button visibility, status indicators). The `ClaudeChatSessionItemController._buildSessionMetadata()` method enriches session items with git repository state: +Each Claude session item carries metadata that drives the Sessions view UI (button visibility, status indicators). The `ClaudeChatSessionItemController._buildSessionMetadata()` method enriches session items with git repository state. + +**Workspace Trust:** Session metadata and git change detection are gated on workspace trust via `IWorkspaceService.isResourceTrusted()`. For untrusted working directories, `_buildSessionMetadata()` returns only the `workingDirectoryPath` (no git data), and `getWorkspaceChanges()` is skipped entirely. The trust check is resolved once in `_createClaudeChatSessionItem` and passed into `_buildSessionMetadata` to avoid redundant calls. When trusted, the metadata fetch and workspace changes fetch run concurrently via `Promise.all`. | Field | Type | Description | |-------|------|-------------| 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 be8d9f281e568..e938b54ff61db 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 @@ -230,6 +230,8 @@ Each session in the list displays: Sessions are sorted by recency — the most recent session appears at the top. In the dedicated sidebar, they're also grouped by time period. +> **Note:** Git metadata (branch name, change stats, action buttons) and workspace change detection require the session's working directory to be in a **trusted workspace**. If the folder is untrusted, sessions still appear in the list but without git-related information or actions. + #### Git Action Buttons When a session has a git repository, action buttons appear in the Changes view based on the repository state: diff --git a/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeSdkService.ts b/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeSdkService.ts index f4b5f0515c071..5d17bcafe68d8 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeSdkService.ts +++ b/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeSdkService.ts @@ -24,7 +24,7 @@ export interface IClaudeCodeSdkService { * @param dir Workspace/project directory path (the SDK resolves this to the session storage location internally) * @returns Array of session info objects */ - listSessions(dir: string): Promise; + listSessions(dir?: string): Promise; /** * Gets detailed information for a specific session @@ -98,7 +98,7 @@ export class ClaudeCodeSdkService implements IClaudeCodeSdkService { return query(options); } - public async listSessions(dir: string): Promise { + public async listSessions(dir?: string): Promise { const { listSessions } = await this._loadSdk(); return listSessions({ dir }); } diff --git a/extensions/copilot/src/extension/chatSessions/claude/node/sessionParser/claudeCodeSessionService.ts b/extensions/copilot/src/extension/chatSessions/claude/node/sessionParser/claudeCodeSessionService.ts index 91f21d7ea340a..337a1fe647952 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/node/sessionParser/claudeCodeSessionService.ts +++ b/extensions/copilot/src/extension/chatSessions/claude/node/sessionParser/claudeCodeSessionService.ts @@ -27,6 +27,7 @@ import { IWorkspaceService } from '../../../../../platform/workspace/common/work import { createServiceIdentifier } from '../../../../../util/common/services'; import { basename } from '../../../../../util/vs/base/common/resources'; import { URI } from '../../../../../util/vs/base/common/uri'; +import { IAgentSessionsWorkspace } from '../../../../chatSessions/common/agentSessionsWorkspace'; import { IFolderRepositoryManager } from '../../../../chatSessions/common/folderRepositoryManager'; import { ClaudeSessionUri } from '../../common/claudeSessionUri'; import { IClaudeCodeSdkService } from '../claudeCodeSdkService'; @@ -76,6 +77,7 @@ export class ClaudeCodeSessionService implements IClaudeCodeSessionService { @ILogService private readonly _logService: ILogService, @IWorkspaceService private readonly _workspace: IWorkspaceService, @IFolderRepositoryManager private readonly _folderRepositoryManager: IFolderRepositoryManager, + @IAgentSessionsWorkspace private readonly _agentSessionsWorkspace: IAgentSessionsWorkspace, ) { } /** @@ -83,6 +85,16 @@ export class ClaudeCodeSessionService implements IClaudeCodeSessionService { * Delegates to the SDK's `listSessions()` and converts results. */ async getAllSessions(token: CancellationToken): Promise { + if (this._agentSessionsWorkspace.isAgentSessionsWorkspace) { + try { + const sdkSessions = await this._sdkService.listSessions(); + return sdkSessions.map(sdkInfo => sdkSessionInfoToSessionInfo(sdkInfo)); + } catch (e) { + this._logService.debug(`[ClaudeCodeSessionService] Failed to list all sessions: ${e}`); + return []; + } + } + const items: IClaudeCodeSessionInfo[] = []; const projectFolders = await this._getProjectFolders(); diff --git a/extensions/copilot/src/extension/chatSessions/claude/node/sessionParser/test/claudeCodeSessionService.spec.ts b/extensions/copilot/src/extension/chatSessions/claude/node/sessionParser/test/claudeCodeSessionService.spec.ts index fbaa27a8c2f03..d2c22710935c6 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/node/sessionParser/test/claudeCodeSessionService.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/claude/node/sessionParser/test/claudeCodeSessionService.spec.ts @@ -14,7 +14,8 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../util/ import { CancellationToken, CancellationTokenSource } from '../../../../../../util/vs/base/common/cancellation'; import { URI } from '../../../../../../util/vs/base/common/uri'; import { IInstantiationService } from '../../../../../../util/vs/platform/instantiation/common/instantiation'; -import { FolderRepositoryMRUEntry, IFolderRepositoryManager } from '../../../../../chatSessions/common/folderRepositoryManager'; +import { IFolderRepositoryManager, FolderRepositoryMRUEntry } from '../../../../../chatSessions/common/folderRepositoryManager'; +import { IAgentSessionsWorkspace } from '../../../../../chatSessions/common/agentSessionsWorkspace'; import { createExtensionUnitTestingServices } from '../../../../../test/node/services'; import { IClaudeCodeSdkService } from '../../claudeCodeSdkService'; import { computeFolderSlug } from '../../claudeProjectFolders'; @@ -103,6 +104,7 @@ describe('ClaudeCodeSessionService', () => { const workspaceService = store.add(new TestWorkspaceService([folderUri])); testingServiceCollection.set(IWorkspaceService, workspaceService); testingServiceCollection.define(IFolderRepositoryManager, new MockFolderRepositoryManager()); + testingServiceCollection.set(IAgentSessionsWorkspace, { _serviceBrand: undefined, isAgentSessionsWorkspace: false }); const accessor = testingServiceCollection.createTestingAccessor(); mockFs = accessor.get(IFileSystemService) as MockFileSystemService; @@ -273,6 +275,54 @@ describe('ClaudeCodeSessionService', () => { expect(sessions).toHaveLength(0); }); + + describe('when in agent sessions workspace', () => { + let agentSessionsService: ClaudeCodeSessionService; + let agentSessionsSdkService: MockClaudeCodeSdkService; + + beforeEach(() => { + agentSessionsSdkService = new MockClaudeCodeSdkService(); + const sc = store.add(createExtensionUnitTestingServices(store)); + sc.set(IFileSystemService, new MockFileSystemService()); + sc.set(IClaudeCodeSdkService, agentSessionsSdkService); + sc.set(IWorkspaceService, store.add(new TestWorkspaceService([]))); + sc.define(IFolderRepositoryManager, new MockFolderRepositoryManager()); + sc.set(IAgentSessionsWorkspace, { _serviceBrand: undefined, isAgentSessionsWorkspace: true }); + + agentSessionsService = sc.createTestingAccessor().get(IInstantiationService).createInstance(ClaudeCodeSessionService); + }); + + it('lists all sessions without a dir argument', async () => { + agentSessionsSdkService.mockSessions = [ + createSdkSessionInfo({ sessionId: 'global-1', summary: 'Global session' }), + createSdkSessionInfo({ sessionId: 'global-2', summary: 'Another session' }), + ]; + + const sessions = await agentSessionsService.getAllSessions(CancellationToken.None); + + expect(sessions).toHaveLength(2); + expect(sessions[0].id).toBe('global-1'); + expect(sessions[1].id).toBe('global-2'); + }); + + it('returns empty array when SDK throws', async () => { + agentSessionsSdkService.listSessions = async () => { throw new Error('SDK failure'); }; + + const sessions = await agentSessionsService.getAllSessions(CancellationToken.None); + + expect(sessions).toHaveLength(0); + }); + + it('does not set folderName on sessions', async () => { + agentSessionsSdkService.mockSessions = [ + createSdkSessionInfo({ sessionId: 'no-folder' }), + ]; + + const sessions = await agentSessionsService.getAllSessions(CancellationToken.None); + + expect(sessions[0].folderName).toBeUndefined(); + }); + }); }); // #endregion @@ -471,6 +521,7 @@ describe('ClaudeCodeSessionService', () => { const emptyWorkspaceService = store.add(new TestWorkspaceService([])); noWorkspaceTestingServiceCollection.set(IWorkspaceService, emptyWorkspaceService); noWorkspaceTestingServiceCollection.define(IFolderRepositoryManager, noWorkspaceFolderManager); + noWorkspaceTestingServiceCollection.set(IAgentSessionsWorkspace, { _serviceBrand: undefined, isAgentSessionsWorkspace: false }); noWorkspaceFolderManager.setMRUEntries([ { folder: mruFolder, repository: undefined, lastAccessed: Date.now() }, @@ -500,6 +551,7 @@ describe('ClaudeCodeSessionService', () => { noMruServiceCollection.set(IClaudeCodeSdkService, new MockClaudeCodeSdkService()); noMruServiceCollection.set(IWorkspaceService, store.add(new TestWorkspaceService([]))); noMruServiceCollection.define(IFolderRepositoryManager, noWorkspaceFolderManager); + noMruServiceCollection.set(IAgentSessionsWorkspace, { _serviceBrand: undefined, isAgentSessionsWorkspace: false }); const accessor = noMruServiceCollection.createTestingAccessor(); const noMruService = accessor.get(IInstantiationService).createInstance(ClaudeCodeSessionService); @@ -526,6 +578,7 @@ describe('ClaudeCodeSessionService', () => { multiMruServiceCollection.set(IClaudeCodeSdkService, multiSdkService); multiMruServiceCollection.set(IWorkspaceService, store.add(new TestWorkspaceService([]))); multiMruServiceCollection.define(IFolderRepositoryManager, noWorkspaceFolderManager); + multiMruServiceCollection.set(IAgentSessionsWorkspace, { _serviceBrand: undefined, isAgentSessionsWorkspace: false }); const accessor = multiMruServiceCollection.createTestingAccessor(); const multiMruService = accessor.get(IInstantiationService).createInstance(ClaudeCodeSessionService); @@ -553,6 +606,7 @@ describe('ClaudeCodeSessionService', () => { const multiRootWorkspaceService = store.add(new TestWorkspaceService([folder1, folder2])); multiRootTestingServiceCollection.set(IWorkspaceService, multiRootWorkspaceService); multiRootTestingServiceCollection.define(IFolderRepositoryManager, new MockFolderRepositoryManager()); + multiRootTestingServiceCollection.set(IAgentSessionsWorkspace, { _serviceBrand: undefined, isAgentSessionsWorkspace: false }); const accessor = multiRootTestingServiceCollection.createTestingAccessor(); const instaService = accessor.get(IInstantiationService); diff --git a/extensions/copilot/src/extension/chatSessions/claude/node/test/mockClaudeCodeSdkService.ts b/extensions/copilot/src/extension/chatSessions/claude/node/test/mockClaudeCodeSdkService.ts index 96122ca5902a0..d30cbf4799905 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/node/test/mockClaudeCodeSdkService.ts +++ b/extensions/copilot/src/extension/chatSessions/claude/node/test/mockClaudeCodeSdkService.ts @@ -33,7 +33,7 @@ export class MockClaudeCodeSdkService implements IClaudeCodeSdkService { return this.createMockQuery(options.prompt); } - public async listSessions(dir: string): Promise { + public async listSessions(dir?: string): Promise { return this.mockSessions; } diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts index d73ca8676c824..efcd971492a9e 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts @@ -688,7 +688,7 @@ export class ClaudeChatSessionItemController extends Disposable { item.timing = { ...item.timing, lastRequestEnded: Date.now() }; } const session = await this._claudeCodeSessionService.getSession(resource, CancellationToken.None); - if (session?.cwd) { + if (session?.cwd && await this._workspaceService.isResourceTrusted(URI.file(session.cwd))) { item.changes = await this._claudeWorkspaceFolderService.getWorkspaceChanges( session.cwd, session.gitBranch, @@ -734,12 +734,21 @@ export class ClaudeChatSessionItemController extends Disposable { }; item.iconPath = new vscode.ThemeIcon('claude'); if (session.cwd) { - item.metadata = await this._buildSessionMetadata(session.cwd); - item.changes = await this._claudeWorkspaceFolderService.getWorkspaceChanges( - session.cwd, - session.gitBranch, - undefined, - ); + const isTrusted = await this._workspaceService.isResourceTrusted(URI.file(session.cwd)); + if (isTrusted) { + const [metadata, changes] = await Promise.all([ + this._buildSessionMetadata(session.cwd, isTrusted), + this._claudeWorkspaceFolderService.getWorkspaceChanges( + session.cwd, + session.gitBranch, + undefined, + ), + ]); + item.metadata = metadata; + item.changes = changes; + } else { + item.metadata = await this._buildSessionMetadata(session.cwd, isTrusted); + } } return item; } @@ -759,8 +768,13 @@ export class ClaudeChatSessionItemController extends Disposable { return repositories.length > 1; } - private async _buildSessionMetadata(cwd: string): Promise { - const repoContext = await this._gitService.getRepository(URI.file(cwd)); + private async _buildSessionMetadata(cwd: string, isTrusted?: boolean): Promise { + const cwdUri = URI.file(cwd); + if (!(isTrusted ?? await this._workspaceService.isResourceTrusted(cwdUri))) { + return { workingDirectoryPath: cwd }; + } + + const repoContext = await this._gitService.getRepository(cwdUri); if (!repoContext) { return { workingDirectoryPath: cwd }; } diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/sessionOptionGroupBuilder.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/sessionOptionGroupBuilder.ts index 95af5fe2bbb2a..1cc36660b2747 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/sessionOptionGroupBuilder.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/sessionOptionGroupBuilder.ts @@ -361,7 +361,7 @@ export class SessionOptionGroupBuilder implements ISessionOptionGroupBuilder { if (!uri) { return undefined; } - const isTrusted = await vscode.workspace.isResourceTrusted(uri); + const isTrusted = await this.workspaceService.isResourceTrusted(uri); if (!isTrusted) { return undefined; } diff --git a/extensions/copilot/src/extension/test/node/notebookPromptRendering.spec.ts b/extensions/copilot/src/extension/test/node/notebookPromptRendering.spec.ts index c14772c9de948..a8bff7ab9d57a 100644 --- a/extensions/copilot/src/extension/test/node/notebookPromptRendering.spec.ts +++ b/extensions/copilot/src/extension/test/node/notebookPromptRendering.spec.ts @@ -135,6 +135,9 @@ describe('Notebook Prompt Rendering', function () { override applyEdit(edit: vscode.WorkspaceEdit): Thenable { throw new Error('Method not implemented.'); } + override isResourceTrusted(_resource: vscode.Uri): Thenable { + return Promise.resolve(true); + } override requestResourceTrust(_options: vscode.ResourceTrustRequestOptions): Thenable { return Promise.resolve(true); } diff --git a/extensions/copilot/src/platform/ignore/node/test/mockWorkspaceService.ts b/extensions/copilot/src/platform/ignore/node/test/mockWorkspaceService.ts index 3bee106f3c8aa..3db4e3b321f79 100644 --- a/extensions/copilot/src/platform/ignore/node/test/mockWorkspaceService.ts +++ b/extensions/copilot/src/platform/ignore/node/test/mockWorkspaceService.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type { FileSystem, NotebookData, NotebookDocument, NotebookDocumentChangeEvent, ResourceTrustRequestOptions, TextDocument, TextDocumentChangeEvent, TextEditorSelectionChangeEvent, WorkspaceEdit, WorkspaceFolder, WorkspaceFoldersChangeEvent, WorkspaceTrustRequestOptions } from 'vscode'; +import type { FileSystem, NotebookData, NotebookDocument, NotebookDocumentChangeEvent, ResourceTrustRequestOptions, TextDocument, TextDocumentChangeEvent, TextEditorSelectionChangeEvent, Uri, WorkspaceEdit, WorkspaceFolder, WorkspaceFoldersChangeEvent, WorkspaceTrustRequestOptions } from 'vscode'; import { Event } from '../../../../util/vs/base/common/event'; import { URI } from '../../../../util/vs/base/common/uri'; import { NotebookDocumentSnapshot } from '../../../editing/common/notebookDocumentSnapshot'; @@ -87,6 +87,10 @@ export class MockWorkspaceService implements IWorkspaceService { return Promise.resolve(); } + isResourceTrusted(_resource: Uri): Thenable { + return Promise.resolve(true); + } + requestResourceTrust(_options: ResourceTrustRequestOptions): Thenable { return Promise.resolve(true); } diff --git a/extensions/copilot/src/platform/test/node/simulationWorkspaceServices.ts b/extensions/copilot/src/platform/test/node/simulationWorkspaceServices.ts index 691a158cb985e..f09ba0ae0f3be 100644 --- a/extensions/copilot/src/platform/test/node/simulationWorkspaceServices.ts +++ b/extensions/copilot/src/platform/test/node/simulationWorkspaceServices.ts @@ -124,6 +124,10 @@ export class SimulationWorkspaceService extends AbstractWorkspaceService { return Promise.resolve(true); } + override isResourceTrusted(_resource: vscode.Uri): Thenable { + return Promise.resolve(true); + } + override requestResourceTrust(options: vscode.ResourceTrustRequestOptions): Thenable { return Promise.resolve(true); } diff --git a/extensions/copilot/src/platform/workspace/common/workspaceService.ts b/extensions/copilot/src/platform/workspace/common/workspaceService.ts index 20cdfedcb0476..3e4885626614c 100644 --- a/extensions/copilot/src/platform/workspace/common/workspaceService.ts +++ b/extensions/copilot/src/platform/workspace/common/workspaceService.ts @@ -49,6 +49,7 @@ export interface IWorkspaceService { * has been downloaded before we can use them. */ ensureWorkspaceIsFullyLoaded(): Promise; + isResourceTrusted(resource: Uri): Thenable; requestResourceTrust(options: ResourceTrustRequestOptions): Thenable; requestWorkspaceTrust(options?: WorkspaceTrustRequestOptions): Thenable; } @@ -75,6 +76,7 @@ export abstract class AbstractWorkspaceService implements IWorkspaceService { abstract showWorkspaceFolderPicker(): Promise; abstract getWorkspaceFolderName(workspaceFolderUri: URI): string; abstract applyEdit(edit: WorkspaceEdit): Thenable; + abstract isResourceTrusted(resource: Uri): Thenable; abstract requestResourceTrust(options: ResourceTrustRequestOptions): Thenable; abstract requestWorkspaceTrust(options?: WorkspaceTrustRequestOptions): Thenable; @@ -229,6 +231,10 @@ export class NullWorkspaceService extends AbstractWorkspaceService implements ID this.disposables.dispose(); } + override isResourceTrusted(_resource: Uri): Thenable { + return Promise.resolve(true); + } + override requestResourceTrust(options: ResourceTrustRequestOptions): Thenable { return Promise.resolve(true); } diff --git a/extensions/copilot/src/platform/workspace/vscode/workspaceServiceImpl.ts b/extensions/copilot/src/platform/workspace/vscode/workspaceServiceImpl.ts index f24d5e8ad854c..95e9b9096a184 100644 --- a/extensions/copilot/src/platform/workspace/vscode/workspaceServiceImpl.ts +++ b/extensions/copilot/src/platform/workspace/vscode/workspaceServiceImpl.ts @@ -111,6 +111,10 @@ export class ExtensionTextDocumentManager extends AbstractWorkspaceService { } + override isResourceTrusted(resource: Uri): Thenable { + return workspace.isResourceTrusted(resource); + } + override requestResourceTrust(options: ResourceTrustRequestOptions): Thenable { return workspace.requestResourceTrust(options); } From f9f1cdca7ce1a1ad9f136157a9dc386127f8d451 Mon Sep 17 00:00:00 2001 From: Tyler James Leonhardt <2644648+TylerLeonhardt@users.noreply.github.com> Date: Thu, 23 Apr 2026 15:15:22 -0700 Subject: [PATCH 68/70] Get task buttons working for Claude (#312232) * Get task buttons working for Claude By hard coding it along with CLI... * feedback --- .../browser/sessionsTerminalContribution.ts | 16 ++++---- .../sessionsTerminalContribution.test.ts | 41 ++++++++++++++++++- .../browser/sessionsManagementService.ts | 8 ++-- .../services/sessions/common/session.ts | 10 +++++ 4 files changed, 62 insertions(+), 13 deletions(-) diff --git a/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts b/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts index b25fa07f82d64..87032a4efda17 100644 --- a/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts +++ b/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts @@ -21,7 +21,7 @@ import { Menus } from '../../../browser/menus.js'; import { isAgentHostProvider, LOCAL_AGENT_HOST_PROVIDER_ID } from '../../../common/agentHostSessionsProvider.js'; import { SessionsWelcomeVisibleContext, IsPhoneLayoutContext } from '../../../common/contextkeys.js'; import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; -import { CopilotCLISessionType, ISession } from '../../../services/sessions/common/session.js'; +import { isWorkspaceAgentSessionType, ISession } from '../../../services/sessions/common/session.js'; import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; import { IsAuxiliaryWindowContext } from '../../../../workbench/common/contextkeys.js'; import { ContextKeyExpr, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; @@ -42,11 +42,11 @@ interface ISessionTerminalInfo { /** * Returns terminal info for the given session: worktree or repository path for - * background sessions only. Returns `undefined` for non-background sessions - * (Cloud, Local, etc.) which have no local worktree, or when no path is available. + * workspace-backed agent sessions. Returns `undefined` for sessions without a + * workspace (e.g. Cloud), or when no path is available. */ function getSessionTerminalInfo(session: ISession | undefined): ISessionTerminalInfo | undefined { - if (session?.sessionType !== CopilotCLISessionType.id) { + if (!session || !isWorkspaceAgentSessionType(session.sessionType)) { return undefined; } const repo = session.workspace.get()?.repositories[0]; @@ -152,12 +152,12 @@ export class SessionsTerminalContribution extends Disposable implements IWorkben } })); - // When a session is archived or removed, close all terminals for its worktree + // When a session is archived or removed, close all terminals for its cwd this._register(this._sessionsManagementService.onDidChangeSessions(e => { for (const session of [...e.removed, ...e.changed.filter(s => s.isArchived.get())]) { - const worktreeUri = session.workspace.get()?.repositories[0]?.workingDirectory; - if (worktreeUri) { - this._closeTerminalsForPath(worktreeUri.fsPath); + const info = getSessionTerminalInfo(session); + if (info) { + this._closeTerminalsForPath(info.cwd.fsPath); } } })); diff --git a/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts b/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts index b188df4dee4b6..ed396d300ee11 100644 --- a/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts +++ b/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts @@ -328,6 +328,28 @@ suite('SessionsTerminalContribution', () => { assert.strictEqual(createdTerminals[0].cwd.fsPath, repoUri.fsPath); }); + // --- Claude provider: also uses worktree/repository path --- + + test('creates a terminal at the worktree for a Claude session', async () => { + const worktreeUri = URI.file('/worktree'); + const session = makeAgentSession({ worktree: worktreeUri, repository: URI.file('/repo'), providerType: AgentSessionProviders.Claude }); + activeSessionObs.set(session, undefined); + await tick(); + + assert.strictEqual(createdTerminals.length, 1); + assert.strictEqual(createdTerminals[0].cwd.fsPath, worktreeUri.fsPath); + }); + + test('falls back to repository when worktree is undefined for a Claude session', async () => { + const repoUri = URI.file('/repo'); + const session = makeAgentSession({ repository: repoUri, providerType: AgentSessionProviders.Claude }); + activeSessionObs.set(session, undefined); + await tick(); + + assert.strictEqual(createdTerminals.length, 1); + assert.strictEqual(createdTerminals[0].cwd.fsPath, repoUri.fsPath); + }); + // --- Non-background providers: use home directory --- test('uses home directory for a cloud agent session', async () => { @@ -471,6 +493,7 @@ suite('SessionsTerminalContribution', () => { const session = makeAgentSession({ isArchived: true, worktree: worktreeUri, + providerType: AgentSessionProviders.Background, }); onDidChangeSessions.fire({ added: [], removed: [], changed: [session] }); await tick(); @@ -503,13 +526,29 @@ suite('SessionsTerminalContribution', () => { assert.strictEqual(disposedInstances.length, 0); }); + test('closes terminals when archived session has only a repository (no worktree)', async () => { + const repoUri = URI.file('/repo'); + const session = makeAgentSession({ repository: repoUri, providerType: AgentSessionProviders.Background, isArchived: false }); + activeSessionObs.set(session, undefined); + await tick(); + + assert.strictEqual(createdTerminals.length, 1); + assert.strictEqual(createdTerminals[0].cwd.fsPath, repoUri.fsPath); + + const archivedSession = makeAgentSession({ repository: repoUri, providerType: AgentSessionProviders.Background, isArchived: true }); + onDidChangeSessions.fire({ added: [], removed: [], changed: [archivedSession] }); + await tick(); + + assert.strictEqual(disposedInstances.length, 1); + }); + test('closes terminals when session is removed', async () => { const worktreeUri = URI.file('/worktree'); await contribution.ensureTerminal(worktreeUri, false); assert.strictEqual(createdTerminals.length, 1); - const session = makeAgentSession({ worktree: worktreeUri }); + const session = makeAgentSession({ worktree: worktreeUri, providerType: AgentSessionProviders.Background }); onDidChangeSessions.fire({ added: [], removed: [session], changed: [] }); await tick(); diff --git a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts index 0d2f936d85a70..b2b5193c2257d 100644 --- a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts +++ b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts @@ -18,7 +18,7 @@ import { ActiveSessionProviderIdContext, ActiveSessionTypeContext, IsActiveSessi import { ActiveSessionSupportsMultiChatContext, IActiveSession, ISessionsChangeEvent, ISessionsManagementService } from '../common/sessionsManagement.js'; import { ISessionsProvidersChangeEvent, ISessionsProvidersService } from './sessionsProvidersService.js'; import { ISendRequestOptions, ISessionChangeEvent, ISessionsProvider } from '../common/sessionsProvider.js'; -import { COPILOT_CLI_SESSION_TYPE, IChat, ISession, SessionStatus, ISessionType } from '../common/session.js'; +import { IChat, ISession, isWorkspaceAgentSessionType, SessionStatus, ISessionType } from '../common/session.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; const ACTIVE_SESSION_STATES_KEY = 'agentSessions.activeSessionStates'; @@ -61,7 +61,7 @@ class SessionsManagementService extends Disposable implements ISessionsManagemen private readonly _isNewChatInSessionContext: IContextKey; private readonly _activeSessionProviderId: IContextKey; private readonly _activeSessionType: IContextKey; - private readonly _isBackgroundProvider: IContextKey; + private readonly _isWorkspaceAgent: IContextKey; private readonly _isActiveSessionArchived: IContextKey; private readonly _supportsMultiChat: IContextKey; private _activeChatObservable: ISettableObservable | undefined; @@ -86,7 +86,7 @@ class SessionsManagementService extends Disposable implements ISessionsManagemen this._isNewChatInSessionContext = IsNewChatInSessionContext.bindTo(contextKeyService); this._activeSessionProviderId = ActiveSessionProviderIdContext.bindTo(contextKeyService); this._activeSessionType = ActiveSessionTypeContext.bindTo(contextKeyService); - this._isBackgroundProvider = IsActiveSessionBackgroundProviderContext.bindTo(contextKeyService); + this._isWorkspaceAgent = IsActiveSessionBackgroundProviderContext.bindTo(contextKeyService); this._isActiveSessionArchived = IsActiveSessionArchivedContext.bindTo(contextKeyService); this._supportsMultiChat = ActiveSessionSupportsMultiChatContext.bindTo(contextKeyService); @@ -407,7 +407,7 @@ class SessionsManagementService extends Disposable implements ISessionsManagemen // Update context keys from session data this._activeSessionProviderId.set(session?.providerId ?? ''); this._activeSessionType.set(session?.sessionType ?? ''); - this._isBackgroundProvider.set(session?.sessionType === COPILOT_CLI_SESSION_TYPE); + this._isWorkspaceAgent.set(isWorkspaceAgentSessionType(session?.sessionType)); this._isActiveSessionArchived.set(session?.isArchived.get() ?? false); this._supportsMultiChat.set(session?.capabilities.supportsMultipleChats ?? false); diff --git a/src/vs/sessions/services/sessions/common/session.ts b/src/vs/sessions/services/sessions/common/session.ts index 152e4f0274d95..101fda93509de 100644 --- a/src/vs/sessions/services/sessions/common/session.ts +++ b/src/vs/sessions/services/sessions/common/session.ts @@ -50,6 +50,16 @@ export const ClaudeCodeSessionType: ISessionType = { icon: Codicon.claude, }; +/** + * Returns whether the given session type represents a workspace-backed + * agent (e.g. Copilot CLI, Claude Code) that operates on a worktree or + * repository — regardless of whether the agent runs locally or remotely. + * TODO: Somehow make this contributable so we don't have to hardcode session types here. + */ +export function isWorkspaceAgentSessionType(sessionType: string | undefined): boolean { + return sessionType === COPILOT_CLI_SESSION_TYPE || sessionType === CLAUDE_CODE_SESSION_TYPE; +} + export const GITHUB_REMOTE_FILE_SCHEME = 'github-remote-file'; /** From 20cf0c727688e6e05e8acec03c8f27ec28ebc30f Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Thu, 23 Apr 2026 15:18:40 -0700 Subject: [PATCH 69/70] mcp: add .mcp.json workspace discovery and server collision handling (#312234) * mcp: add .mcp.json workspace discovery and server collision handling Adds support for discovering MCP servers from .mcp.json files at workspace folder roots using the Claude-style format, and introduces collision-aware enablement logic for servers with duplicate names. - Adds WorkspaceDotMcpDiscovery to discover servers from .mcp.json files in workspace folder roots, with file watching and throttled updates. - Adds McpCollisionEnablementModel that disables lower-priority servers when multiple servers share the same label, controlled by a new chat.mcp.collisionBehavior setting (disable or suffix). - Promotes collection 'order' from presentation.order to a required top-level field on McpCollectionDefinition for clearer priority semantics. - Adds ITransaction support to EnablementModel.setEnabled for atomic multi-server enablement changes. - Adds an 'Enable Server' action in the server options quick pick and a 'Disabled' codelens for disabled servers in config files. - Adds language features (codelens, diagnostics, inlay hints) for .mcp.json files. - Adds comprehensive tests for the collision enablement model. (Commit message generated by Copilot) * address copilot review feedback * restore suffix collision behavior option --- src/vs/workbench/api/browser/mainThreadMcp.ts | 3 +- .../browser/actions/createPluginAction.ts | 2 +- .../contrib/chat/browser/chat.contribution.ts | 17 +- .../contrib/chat/common/enablement.ts | 26 +- .../actions/createPluginAction.test.ts | 2 +- .../contrib/mcp/browser/mcp.contribution.ts | 2 + .../contrib/mcp/browser/mcpCommands.ts | 34 +-- .../mcp/browser/mcpLanguageFeatures.ts | 37 ++- .../common/discovery/extensionMcpDiscovery.ts | 3 +- .../discovery/installedMcpServersDiscovery.ts | 4 +- .../discovery/nativeMcpDiscoveryAbstract.ts | 2 +- .../common/discovery/pluginMcpDiscovery.ts | 2 +- .../discovery/workspaceDotMcpDiscovery.ts | 107 +++++++ .../discovery/workspaceMcpDiscoveryAdapter.ts | 2 +- .../contrib/mcp/common/mcpConfiguration.ts | 6 + .../contrib/mcp/common/mcpRegistry.ts | 2 +- .../contrib/mcp/common/mcpService.ts | 128 +++++++- .../workbench/contrib/mcp/common/mcpTypes.ts | 6 +- .../mcpGatewayToolBrokerChannel.test.ts | 4 +- .../mcp/test/common/mcpRegistry.test.ts | 276 ++++++++++++++++++ .../mcp/test/common/mcpRegistryTypes.ts | 1 + .../test/common/mcpServerConnection.test.ts | 1 + 22 files changed, 611 insertions(+), 56 deletions(-) create mode 100644 src/vs/workbench/contrib/mcp/common/discovery/workspaceDotMcpDiscovery.ts diff --git a/src/vs/workbench/api/browser/mainThreadMcp.ts b/src/vs/workbench/api/browser/mainThreadMcp.ts index a254d1c97be74..ae3f56cec6644 100644 --- a/src/vs/workbench/api/browser/mainThreadMcp.ts +++ b/src/vs/workbench/api/browser/mainThreadMcp.ts @@ -20,7 +20,7 @@ import { LogLevel } from '../../../platform/log/common/log.js'; import { ITelemetryService } from '../../../platform/telemetry/common/telemetry.js'; import { IWorkbenchMcpGatewayService } from '../../contrib/mcp/common/mcpGatewayService.js'; import { IMcpMessageTransport, IMcpRegistry } from '../../contrib/mcp/common/mcpRegistryTypes.js'; -import { extensionPrefixedIdentifier, McpCollectionDefinition, McpConnectionState, McpServerDefinition, McpServerLaunch, McpServerTransportType, McpServerTrust, UserInteractionRequiredError } from '../../contrib/mcp/common/mcpTypes.js'; +import { extensionPrefixedIdentifier, McpCollectionDefinition, McpCollectionSortOrder, McpConnectionState, McpServerDefinition, McpServerLaunch, McpServerTransportType, McpServerTrust, UserInteractionRequiredError } from '../../contrib/mcp/common/mcpTypes.js'; import { MCP } from '../../contrib/mcp/common/modelContextProtocol.js'; import { IAuthenticationMcpAccessService } from '../../services/authentication/browser/authenticationMcpAccessService.js'; import { IAuthenticationMcpService } from '../../services/authentication/browser/authenticationMcpService.js'; @@ -148,6 +148,7 @@ export class MainThreadMcp extends Disposable implements MainThreadMcpShape { handle.value ??= this._mcpRegistry.registerCollection({ ...collection, source: extensionId, + order: McpCollectionSortOrder.Extension, resolveServerLanch: collection.canResolveLaunch ? (async def => { const r = await this._proxy.$resolveMcpLaunch(collection.id, def.label); return r ? McpServerLaunch.fromSerialized(r) : undefined; diff --git a/src/vs/workbench/contrib/chat/browser/actions/createPluginAction.ts b/src/vs/workbench/contrib/chat/browser/actions/createPluginAction.ts index 03e96021ee1a4..e7d0cbaf2e9ef 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/createPluginAction.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/createPluginAction.ts @@ -66,7 +66,7 @@ function isUserDefined(storage: PromptsStorage): boolean { } function isUserDefinedMcpCollection(collection: McpCollectionDefinition): boolean { - const order = collection.presentation?.order; + const order = collection.order; return order === McpCollectionSortOrder.User || order === McpCollectionSortOrder.WorkspaceFolder || order === McpCollectionSortOrder.Workspace; diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index af8c8ce3c7c56..25d20c456ffcb 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -36,7 +36,7 @@ import { IEditorResolverService, RegisteredEditorPriority } from '../../../servi import { IPathService } from '../../../services/path/common/pathService.js'; import { IViewsService } from '../../../services/views/common/viewsService.js'; import { AddConfigurationType, AssistedTypes } from '../../mcp/browser/mcpCommandsAddConfiguration.js'; -import { allDiscoverySources, discoverySourceSettingsLabel, mcpDiscoverySection, mcpServerSamplingSection } from '../../mcp/common/mcpConfiguration.js'; +import { allDiscoverySources, discoverySourceSettingsLabel, McpCollisionBehavior, mcpDiscoverySection, mcpServerCollisionBehaviorSection, mcpServerSamplingSection } from '../../mcp/common/mcpConfiguration.js'; import { ChatAgentNameService, ChatAgentService, IChatAgentNameService, IChatAgentService } from '../common/participants/chatAgents.js'; import { CodeMapperService, ICodeMapperService } from '../common/editing/chatCodeMapperService.js'; import '../common/widget/chatColors.js'; @@ -736,6 +736,19 @@ configurationRegistry.registerConfiguration({ default: true, tags: ['experimental'], }, + [mcpServerCollisionBehaviorSection]: { + type: 'string', + description: nls.localize('chat.mcp.collisionBehavior', "Controls behavior when multiple MCP servers are discovered with the same name. 'disable' disables lower-priority duplicates. 'suffix' appends numeric suffixes to disambiguate."), + enum: [ + McpCollisionBehavior.Disable, + McpCollisionBehavior.Suffix, + ], + enumDescriptions: [ + nls.localize('chat.mcp.collisionBehavior.disable', "Disable lower-priority servers with duplicate names."), + nls.localize('chat.mcp.collisionBehavior.suffix', "Append numeric suffixes to servers with duplicate names."), + ], + default: McpCollisionBehavior.Disable, + }, [mcpServerSamplingSection]: { type: 'object', description: nls.localize('chat.mcp.serverSampling', "Configures which models are exposed to MCP servers for sampling (making model requests in the background). This setting can be edited in a graphical way under the `{0}` command.", 'MCP: ' + nls.localize('mcp.list', 'List Servers')), @@ -745,7 +758,7 @@ configurationRegistry.registerConfiguration({ properties: { allowedDuringChat: { type: 'boolean', - description: nls.localize('chat.mcp.serverSampling.allowedDuringChat', "Whether this server is make sampling requests during its tool calls in a chat session."), + description: nls.localize('chat.mcp.serverSampling.allowedDuringChat', "Whether this server is allowed to make sampling requests during its tool calls in a chat session."), default: true, }, allowedOutsideChat: { diff --git a/src/vs/workbench/contrib/chat/common/enablement.ts b/src/vs/workbench/contrib/chat/common/enablement.ts index a6e3b90ff7541..799e663b2a6a5 100644 --- a/src/vs/workbench/contrib/chat/common/enablement.ts +++ b/src/vs/workbench/contrib/chat/common/enablement.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Disposable } from '../../../../base/common/lifecycle.js'; -import { IReader } from '../../../../base/common/observable.js'; +import { IReader, ITransaction } from '../../../../base/common/observable.js'; import { ObservableMemento, observableMemento } from '../../../../platform/observable/common/observableMemento.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; @@ -25,7 +25,7 @@ export function isContributionDisabled(state: ContributionEnablementState): bool export interface IEnablementModel { readEnabled(key: string, reader?: IReader): ContributionEnablementState; - setEnabled(key: string, state: ContributionEnablementState): void; + setEnabled(key: string, state: ContributionEnablementState, tx?: ITransaction): void; remove(key: string): void; } @@ -93,29 +93,29 @@ export class EnablementModel extends Disposable implements IEnablementModel { return ContributionEnablementState.EnabledProfile; } - setEnabled(key: string, state: ContributionEnablementState): void { + setEnabled(key: string, state: ContributionEnablementState, tx?: ITransaction): void { switch (state) { case ContributionEnablementState.EnabledProfile: { // Enabled-profile is the default: remove key from profile state, // and also remove any workspace override. - this._deleteFromMap(this._profileState, key); - this._deleteFromMap(this._workspaceState, key); + this._deleteFromMap(this._profileState, key, tx); + this._deleteFromMap(this._workspaceState, key, tx); break; } case ContributionEnablementState.DisabledProfile: { // Store disabled in profile, remove workspace override. - this._setInMap(this._profileState, key, false); - this._deleteFromMap(this._workspaceState, key); + this._setInMap(this._profileState, key, false, tx); + this._deleteFromMap(this._workspaceState, key, tx); break; } case ContributionEnablementState.EnabledWorkspace: { // Workspace override: always store explicitly. - this._setInMap(this._workspaceState, key, true); + this._setInMap(this._workspaceState, key, true, tx); break; } case ContributionEnablementState.DisabledWorkspace: { // Workspace override: always store explicitly. - this._setInMap(this._workspaceState, key, false); + this._setInMap(this._workspaceState, key, false, tx); break; } } @@ -126,23 +126,23 @@ export class EnablementModel extends Disposable implements IEnablementModel { this._deleteFromMap(this._workspaceState, key); } - private _setInMap(memento: ObservableMemento, key: string, value: boolean): void { + private _setInMap(memento: ObservableMemento, key: string, value: boolean, tx?: ITransaction): void { const current = memento.get(); if (current.get(key) === value) { return; } const next = new Map(current); next.set(key, value); - memento.set(next, undefined); + memento.set(next, tx); } - private _deleteFromMap(memento: ObservableMemento, key: string): void { + private _deleteFromMap(memento: ObservableMemento, key: string, tx?: ITransaction): void { const current = memento.get(); if (!current.has(key)) { return; } const next = new Map(current); next.delete(key); - memento.set(next, undefined); + memento.set(next, tx); } } diff --git a/src/vs/workbench/contrib/chat/test/browser/actions/createPluginAction.test.ts b/src/vs/workbench/contrib/chat/test/browser/actions/createPluginAction.test.ts index 8bdbac583fbb6..df1f780d155c1 100644 --- a/src/vs/workbench/contrib/chat/test/browser/actions/createPluginAction.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/actions/createPluginAction.test.ts @@ -473,7 +473,7 @@ suite('writePluginToDisk', () => { collection: { id: 'col1', label: 'Test Collection', - presentation: { order: McpCollectionSortOrder.User }, + order: McpCollectionSortOrder.User, } as IResourceTreeItem['mcpServer'] extends undefined ? never : NonNullable['collection'], definition: { id: 'def1', diff --git a/src/vs/workbench/contrib/mcp/browser/mcp.contribution.ts b/src/vs/workbench/contrib/mcp/browser/mcp.contribution.ts index ca5f2458fb0b6..06e56c89f9b12 100644 --- a/src/vs/workbench/contrib/mcp/browser/mcp.contribution.ts +++ b/src/vs/workbench/contrib/mcp/browser/mcp.contribution.ts @@ -23,6 +23,7 @@ import { mcpDiscoveryRegistry } from '../common/discovery/mcpDiscovery.js'; import { RemoteNativeMpcDiscovery } from '../common/discovery/nativeMcpRemoteDiscovery.js'; import { PluginMcpDiscovery } from '../common/discovery/pluginMcpDiscovery.js'; import { CursorWorkspaceMcpDiscoveryAdapter } from '../common/discovery/workspaceMcpDiscoveryAdapter.js'; +import { WorkspaceDotMcpDiscovery } from '../common/discovery/workspaceDotMcpDiscovery.js'; import { McpCommandIds } from '../common/mcpCommandIds.js'; import { mcpServerSchema } from '../common/mcpConfiguration.js'; import { McpContextKeysController } from '../common/mcpContextKeys.js'; @@ -62,6 +63,7 @@ mcpDiscoveryRegistry.register(new SyncDescriptor(RemoteNativeMpcDiscovery)); mcpDiscoveryRegistry.register(new SyncDescriptor(InstalledMcpServersDiscovery)); mcpDiscoveryRegistry.register(new SyncDescriptor(ExtensionMcpDiscovery)); mcpDiscoveryRegistry.register(new SyncDescriptor(CursorWorkspaceMcpDiscoveryAdapter)); +mcpDiscoveryRegistry.register(new SyncDescriptor(WorkspaceDotMcpDiscovery)); mcpDiscoveryRegistry.register(new SyncDescriptor(PluginMcpDiscovery)); registerWorkbenchContribution2('mcpDiscovery', McpDiscovery, WorkbenchPhase.AfterRestored); diff --git a/src/vs/workbench/contrib/mcp/browser/mcpCommands.ts b/src/vs/workbench/contrib/mcp/browser/mcpCommands.ts index cd1acdfbb555f..cd430359345fc 100644 --- a/src/vs/workbench/contrib/mcp/browser/mcpCommands.ts +++ b/src/vs/workbench/contrib/mcp/browser/mcpCommands.ts @@ -53,6 +53,7 @@ import { ChatViewId, IChatWidgetService } from '../../chat/browser/chat.js'; import { ChatContextKeys } from '../../chat/common/actions/chatContextKeys.js'; import { IChatElicitationRequest, IChatToolInvocation } from '../../chat/common/chatService/chatService.js'; import { ChatAgentLocation, ChatModeKind } from '../../chat/common/constants.js'; +import { ContributionEnablementState, isContributionDisabled } from '../../chat/common/enablement.js'; import { ILanguageModelsService } from '../../chat/common/languageModels.js'; import { ILanguageModelToolsService } from '../../chat/common/tools/languageModelToolsService.js'; import { extensionsFilterSubMenu, IExtensionsWorkbenchService, VIEWLET_ID } from '../../extensions/common/extensions.js'; @@ -60,11 +61,10 @@ import { TEXT_FILE_EDITOR_ID } from '../../files/common/files.js'; import { McpCommandIds } from '../common/mcpCommandIds.js'; import { McpContextKeys } from '../common/mcpContextKeys.js'; import { IMcpRegistry } from '../common/mcpRegistryTypes.js'; -import { HasInstalledMcpServersContext, IMcpSamplingService, IMcpServer, IMcpServerStartOpts, IMcpService, IMcpWorkbenchService, InstalledMcpServersViewId, LazyCollectionState, McpCapability, McpCollectionDefinition, McpConnectionState, McpDefinitionReference, mcpPromptPrefix, McpServerCacheState, McpStartServerInteraction } from '../common/mcpTypes.js'; +import { HasInstalledMcpServersContext, IMcpSamplingService, IMcpServer, IMcpServerStartOpts, IMcpService, InstalledMcpServersViewId, LazyCollectionState, McpCapability, McpCollectionDefinition, McpConnectionState, McpDefinitionReference, mcpPromptPrefix, McpServerCacheState, McpStartServerInteraction } from '../common/mcpTypes.js'; +import { startServerAndWaitForLiveTools } from '../common/mcpTypesUtils.js'; import { McpAddConfigurationCommand, McpInstallFromManifestCommand } from './mcpCommandsAddConfiguration.js'; import { McpResourceQuickAccess, McpResourceQuickPick } from './mcpResourceQuickAccess.js'; -import { startServerAndWaitForLiveTools } from '../common/mcpTypesUtils.js'; -import { isContributionDisabled } from '../../chat/common/enablement.js'; import './media/mcpServerAction.css'; import { openPanelChatAndGetWidget } from './openPanelChatAndGetWidget.js'; @@ -104,7 +104,6 @@ export class ListMcpServerCommand extends Action2 { const mcpService = accessor.get(IMcpService); const commandService = accessor.get(ICommandService); const quickInput = accessor.get(IQuickInputService); - const mcpWorkbenchService = accessor.get(IMcpWorkbenchService); type ItemType = { id: string } & IQuickPickItem; @@ -117,7 +116,7 @@ export class ListMcpServerCommand extends Action2 { store.add(pick); store.add(autorun(reader => { - const servers = groupBy(mcpService.servers.read(reader).slice().sort((a, b) => (a.collection.presentation?.order || 0) - (b.collection.presentation?.order || 0)), s => s.collection.id); + const servers = groupBy(mcpService.servers.read(reader).slice().sort((a, b) => a.collection.order - b.collection.order), s => s.collection.id); const firstRun = pick.items.length === 0; pick.items = [ { id: '$add', label: localize('mcp.addServer', 'Add Server'), description: localize('mcp.addServer.description', 'Add a new server configuration'), alwaysShow: true, iconClass: ThemeIcon.asClassName(Codicon.add) }, @@ -159,21 +158,13 @@ export class ListMcpServerCommand extends Action2 { } else if (picked.id === '$add') { commandService.executeCommand(McpCommandIds.AddConfiguration); } else { - const server = mcpService.servers.get().find(s => s.definition.id === picked.id); - if (server && isContributionDisabled(server.enablement.get())) { - const workbenchServer = mcpWorkbenchService.local.find(s => s.id === picked.id); - if (workbenchServer) { - mcpWorkbenchService.open(workbenchServer); - } - } else { - commandService.executeCommand(McpCommandIds.ServerOptions, picked.id); - } + commandService.executeCommand(McpCommandIds.ServerOptions, picked.id); } } } interface ActionItem extends IQuickPickItem { - action: 'start' | 'stop' | 'restart' | 'showOutput' | 'config' | 'configSampling' | 'samplingLog' | 'resources'; + action: 'start' | 'stop' | 'restart' | 'showOutput' | 'config' | 'configSampling' | 'samplingLog' | 'resources' | 'enable'; } interface AuthActionItem extends IQuickPickItem { @@ -249,11 +240,17 @@ export class McpServerOptionsCommand extends Action2 { const items: (ActionItem | AuthActionItem | IQuickPickSeparator)[] = []; const serverState = server.connectionState.get(); + const disabled = isContributionDisabled(server.enablement.get()); items.push({ type: 'separator', label: localize('mcp.actions.status', 'Status') }); - // Only show start when server is stopped or in error state - if (McpConnectionState.canBeStarted(serverState.state)) { + if (disabled) { + items.push({ + label: localize('mcp.enableWorkspace', 'Enable Server (Workspace)'), + action: 'enable' + }); + } else if (McpConnectionState.canBeStarted(serverState.state)) { + // Only show start when server is stopped or in error state items.push({ label: localize('mcp.start', 'Start Server'), action: 'start' @@ -320,6 +317,9 @@ export class McpServerOptionsCommand extends Action2 { } switch (pick.action) { + case 'enable': + mcpService.enablementModel.setEnabled(server.definition.id, ContributionEnablementState.EnabledWorkspace); + break; case 'start': await server.start({ promptType: 'all-untrusted' }); server.showOutput(); diff --git a/src/vs/workbench/contrib/mcp/browser/mcpLanguageFeatures.ts b/src/vs/workbench/contrib/mcp/browser/mcpLanguageFeatures.ts index 285e27f037057..9848b1720ceee 100644 --- a/src/vs/workbench/contrib/mcp/browser/mcpLanguageFeatures.ts +++ b/src/vs/workbench/contrib/mcp/browser/mcpLanguageFeatures.ts @@ -15,19 +15,26 @@ import { CodeLens, CodeLensList, CodeLensProvider, InlayHint, InlayHintList } fr import { ITextModel } from '../../../../editor/common/model.js'; import { ILanguageFeaturesService } from '../../../../editor/common/services/languageFeatures.js'; import { localize } from '../../../../nls.js'; +import { ConfigurationTarget } from '../../../../platform/configuration/common/configuration.js'; import { IMarkerData, IMarkerService, MarkerSeverity } from '../../../../platform/markers/common/markers.js'; +import { StorageScope } from '../../../../platform/storage/common/storage.js'; import { IWorkbenchContribution } from '../../../common/contributions.js'; import { IConfigurationResolverService } from '../../../services/configurationResolver/common/configurationResolver.js'; import { ConfigurationResolverExpression, IResolvedValue } from '../../../services/configurationResolver/common/configurationResolverExpression.js'; import { McpCommandIds } from '../common/mcpCommandIds.js'; import { mcpConfigurationSection } from '../common/mcpConfiguration.js'; import { IMcpRegistry } from '../common/mcpRegistryTypes.js'; +import { isContributionDisabled } from '../../chat/common/enablement.js'; import { IMcpConfigPath, IMcpServerStartOpts, IMcpService, IMcpWorkbenchService, McpConnectionState } from '../common/mcpTypes.js'; const diagnosticOwner = 'vscode.mcp'; +type ConfigDescriptor = Pick & { + serversKey?: string; +}; + export class McpLanguageFeatures extends Disposable implements IWorkbenchContribution { - private readonly _cachedMcpSection = this._register(new MutableDisposable<{ model: ITextModel; inConfig: IMcpConfigPath; tree: Node } & IDisposable>()); + private readonly _cachedMcpSection = this._register(new MutableDisposable<{ model: ITextModel; inConfig: ConfigDescriptor; tree: Node } & IDisposable>()); constructor( @ILanguageFeaturesService languageFeaturesService: ILanguageFeaturesService, @@ -41,6 +48,7 @@ export class McpLanguageFeatures extends Disposable implements IWorkbenchContrib const patterns = [ { pattern: '**/mcp.json' }, + { pattern: '**/.mcp.json' }, { pattern: '**/workspace.json' }, ]; @@ -64,7 +72,9 @@ export class McpLanguageFeatures extends Disposable implements IWorkbenchContrib } const uri = model.uri; - const inConfig = await this._mcpWorkbenchService.getMcpConfigPath(model.uri); + const inConfig: ConfigDescriptor | undefined = uri.path.endsWith('/.mcp.json') + ? { scope: StorageScope.WORKSPACE, target: ConfigurationTarget.WORKSPACE_FOLDER, serversKey: 'mcpServers' } + : await this._mcpWorkbenchService.getMcpConfigPath(model.uri); if (!inConfig) { return undefined; } @@ -88,8 +98,9 @@ export class McpLanguageFeatures extends Disposable implements IWorkbenchContrib }; } - private _addDiagnostics(tm: ITextModel, value: string, tree: Node, inConfig: IMcpConfigPath) { - const serversNode = findNodeAtLocation(tree, inConfig.section ? [...inConfig.section, 'servers'] : ['servers']); + private _addDiagnostics(tm: ITextModel, value: string, tree: Node, inConfig: ConfigDescriptor) { + const serversKey = inConfig.serversKey ?? 'servers'; + const serversNode = findNodeAtLocation(tree, inConfig.section ? [...inConfig.section, serversKey] : [serversKey]); if (!serversNode) { return; } @@ -145,7 +156,8 @@ export class McpLanguageFeatures extends Disposable implements IWorkbenchContrib } const { tree, inConfig } = parsed; - const serversNode = findNodeAtLocation(tree, inConfig.section ? [...inConfig.section, 'servers'] : ['servers']); + const serversKey = inConfig.serversKey ?? 'servers'; + const serversNode = findNodeAtLocation(tree, inConfig.section ? [...inConfig.section, serversKey] : [serversKey]); if (!serversNode) { return undefined; } @@ -176,6 +188,19 @@ export class McpLanguageFeatures extends Disposable implements IWorkbenchContrib } const range = Range.fromPositions(model.getPositionAt(node.children[0].offset)); + + if (isContributionDisabled(read(server.enablement))) { + lenses.push({ + range, + command: { + id: McpCommandIds.ServerOptions, + title: '$(circle-slash) ' + localize('server.disabled', 'Disabled'), + arguments: [server.definition.id], + }, + }); + continue; + } + const canDebug = !!server.readDefinitions().get().server?.devMode?.debug; const state = read(server.connectionState).state; switch (state) { @@ -338,7 +363,7 @@ export class McpLanguageFeatures extends Disposable implements IWorkbenchContrib const inputs = await this._mcpRegistry.getSavedInputs(inConfig.scope); const hints: InlayHint[] = []; - const serversNode = findNodeAtLocation(mcpSection, ['servers']); + const serversNode = findNodeAtLocation(mcpSection, [inConfig.serversKey ?? 'servers']); if (serversNode) { annotateServers(serversNode); } diff --git a/src/vs/workbench/contrib/mcp/common/discovery/extensionMcpDiscovery.ts b/src/vs/workbench/contrib/mcp/common/discovery/extensionMcpDiscovery.ts index 004919f456fb2..925edaf562f78 100644 --- a/src/vs/workbench/contrib/mcp/common/discovery/extensionMcpDiscovery.ts +++ b/src/vs/workbench/contrib/mcp/common/discovery/extensionMcpDiscovery.ts @@ -15,7 +15,7 @@ import { IExtensionService } from '../../../../services/extensions/common/extens import * as extensionsRegistry from '../../../../services/extensions/common/extensionsRegistry.js'; import { mcpActivationEvent, mcpContributionPoint } from '../mcpConfiguration.js'; import { IMcpRegistry } from '../mcpRegistryTypes.js'; -import { extensionPrefixedIdentifier, McpServerDefinition, McpServerTrust } from '../mcpTypes.js'; +import { extensionPrefixedIdentifier, McpCollectionSortOrder, McpServerDefinition, McpServerTrust } from '../mcpTypes.js'; import { IMcpDiscovery } from './mcpDiscovery.js'; const cacheKey = 'mcp.extCachedServers'; @@ -122,6 +122,7 @@ export class ExtensionMcpDiscovery extends Disposable implements IMcpDiscovery { trustBehavior: McpServerTrust.Kind.Trusted, scope: StorageScope.WORKSPACE, configTarget: ConfigurationTarget.USER, + order: McpCollectionSortOrder.Extension, serverDefinitions: observableValue(this, serverDefs?.map(McpServerDefinition.fromSerialized) || []), lazy: { isCached: !!serverDefs, diff --git a/src/vs/workbench/contrib/mcp/common/discovery/installedMcpServersDiscovery.ts b/src/vs/workbench/contrib/mcp/common/discovery/installedMcpServersDiscovery.ts index 3208a2541f82e..5731e1e3b0ec3 100644 --- a/src/vs/workbench/contrib/mcp/common/discovery/installedMcpServersDiscovery.ts +++ b/src/vs/workbench/contrib/mcp/common/discovery/installedMcpServersDiscovery.ts @@ -18,7 +18,7 @@ import { IWorkbenchLocalMcpServer } from '../../../../services/mcp/common/mcpWor import { getMcpServerMapping } from '../mcpConfigFileUtils.js'; import { mcpConfigurationSection } from '../mcpConfiguration.js'; import { IMcpRegistry } from '../mcpRegistryTypes.js'; -import { IMcpConfigPath, IMcpWorkbenchService, McpCollectionDefinition, McpServerDefinition, McpServerLaunch, McpServerTransportType, McpServerTrust } from '../mcpTypes.js'; +import { IMcpConfigPath, IMcpWorkbenchService, McpCollectionDefinition, McpCollectionSortOrder, McpServerDefinition, McpServerLaunch, McpServerTransportType, McpServerTrust } from '../mcpTypes.js'; import { IMcpDiscovery } from './mcpDiscovery.js'; interface CollectionState extends IDisposable { @@ -131,8 +131,8 @@ export class InstalledMcpServersDiscovery extends Disposable implements IMcpDisc const newCollection: McpCollectionDefinition = { id, label: mcpConfigPath?.label ?? '', + order: mcpConfigPath?.order ?? McpCollectionSortOrder.User, presentation: { - order: serverDefinitions[0]?.presentation?.order, origin: mcpConfigPath?.uri, }, remoteAuthority: mcpConfigPath?.remoteAuthority ?? null, diff --git a/src/vs/workbench/contrib/mcp/common/discovery/nativeMcpDiscoveryAbstract.ts b/src/vs/workbench/contrib/mcp/common/discovery/nativeMcpDiscoveryAbstract.ts index 79f0563403bd5..d17fa5e9b22c6 100644 --- a/src/vs/workbench/contrib/mcp/common/discovery/nativeMcpDiscoveryAbstract.ts +++ b/src/vs/workbench/contrib/mcp/common/discovery/nativeMcpDiscoveryAbstract.ts @@ -151,9 +151,9 @@ export abstract class NativeFilesystemMcpDiscovery extends FilesystemMcpDiscover scope: StorageScope.PROFILE, trustBehavior: McpServerTrust.Kind.TrustedOnNonce, serverDefinitions: observableValue(this, []), + order: adapter.order + (adapter.remoteAuthority ? McpCollectionSortOrder.RemoteBoost : 0), presentation: { origin: file, - order: adapter.order + (adapter.remoteAuthority ? McpCollectionSortOrder.RemoteBoost : 0), }, }; diff --git a/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts b/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts index 522a1be3ec646..28ae133110ff7 100644 --- a/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts +++ b/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts @@ -77,9 +77,9 @@ export class PluginMcpDiscovery extends Disposable implements IMcpDiscovery { trustBehavior: McpServerTrust.Kind.Trusted, serverDefinitions: plugin.mcpServerDefinitions.map(defs => defs.map(d => this._toServerDefinition(collectionId, d)).filter(isDefined)), + order: McpCollectionSortOrder.Plugin, presentation: { origin: manifestURI, - order: McpCollectionSortOrder.Plugin, }, }); } diff --git a/src/vs/workbench/contrib/mcp/common/discovery/workspaceDotMcpDiscovery.ts b/src/vs/workbench/contrib/mcp/common/discovery/workspaceDotMcpDiscovery.ts new file mode 100644 index 0000000000000..d7e21890057c3 --- /dev/null +++ b/src/vs/workbench/contrib/mcp/common/discovery/workspaceDotMcpDiscovery.ts @@ -0,0 +1,107 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { RunOnceScheduler } from '../../../../../base/common/async.js'; +import { Disposable, DisposableMap, DisposableStore, IDisposable, MutableDisposable } from '../../../../../base/common/lifecycle.js'; +import { observableValue } from '../../../../../base/common/observable.js'; +import { joinPath } from '../../../../../base/common/resources.js'; +import { ConfigurationTarget } from '../../../../../platform/configuration/common/configuration.js'; +import { IFileService } from '../../../../../platform/files/common/files.js'; +import { StorageScope } from '../../../../../platform/storage/common/storage.js'; +import { IWorkspaceContextService, IWorkspaceFolder } from '../../../../../platform/workspace/common/workspace.js'; +import { IRemoteAgentService } from '../../../../services/remote/common/remoteAgentService.js'; +import { IMcpRegistry } from '../mcpRegistryTypes.js'; +import { McpCollectionSortOrder, McpServerDefinition, McpServerTrust } from '../mcpTypes.js'; +import { IMcpDiscovery } from './mcpDiscovery.js'; +import { claudeConfigToServerDefinition } from './nativeMcpDiscoveryAdapters.js'; + +/** + * Discovers MCP servers defined in `.mcp.json` files at workspace folder roots. + * Uses the Claude-style format: `{ "mcpServers": { ... } }`. + */ +export class WorkspaceDotMcpDiscovery extends Disposable implements IMcpDiscovery { + readonly fromGallery = false; + + private readonly _collections = this._register(new DisposableMap()); + + constructor( + @IFileService private readonly _fileService: IFileService, + @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, + @IMcpRegistry private readonly _mcpRegistry: IMcpRegistry, + @IRemoteAgentService private readonly _remoteAgentService: IRemoteAgentService, + ) { + super(); + } + + start(): void { + this._register(this._workspaceContextService.onDidChangeWorkspaceFolders(e => { + for (const removed of e.removed) { + this._collections.deleteAndDispose(removed.uri.toString()); + } + for (const added of e.added) { + this._watchFolder(added); + } + })); + + for (const folder of this._workspaceContextService.getWorkspace().folders) { + this._watchFolder(folder); + } + } + + private _watchFolder(folder: IWorkspaceFolder) { + const configFile = joinPath(folder.uri, '.mcp.json'); + const collectionId = `workspace-dot-mcp.${folder.index}`; + const serverDefinitions = observableValue(this, []); + + const collection = { + id: collectionId, + label: `${folder.name}/.mcp.json`, + remoteAuthority: this._remoteAgentService.getConnection()?.remoteAuthority || null, + scope: StorageScope.WORKSPACE, + trustBehavior: McpServerTrust.Kind.TrustedOnNonce as const, + serverDefinitions, + configTarget: ConfigurationTarget.WORKSPACE_FOLDER, + order: McpCollectionSortOrder.WorkspaceFolder + 1, + presentation: { + origin: configFile, + }, + }; + + const store = new DisposableStore(); + const collectionRegistration = store.add(new MutableDisposable()); + + const updateFile = async () => { + let definitions: McpServerDefinition[] = []; + try { + const contents = await this._fileService.readFile(configFile); + const defs = await claudeConfigToServerDefinition(collectionId, contents.value, folder.uri); + if (defs) { + for (const d of defs) { + d.roots = [folder.uri]; + } + definitions = defs; + } + } catch { + // file doesn't exist or is malformed + } + + if (!definitions.length) { + collectionRegistration.clear(); + } else { + serverDefinitions.set(definitions, undefined); + if (!collectionRegistration.value) { + collectionRegistration.value = this._mcpRegistry.registerCollection(collection); + } + } + }; + + const throttler = store.add(new RunOnceScheduler(updateFile, 500)); + const watcher = store.add(this._fileService.createWatcher(configFile, { recursive: false, excludes: [] })); + store.add(watcher.onDidChange(() => throttler.schedule())); + updateFile(); + + this._collections.set(folder.uri.toString(), store); + } +} diff --git a/src/vs/workbench/contrib/mcp/common/discovery/workspaceMcpDiscoveryAdapter.ts b/src/vs/workbench/contrib/mcp/common/discovery/workspaceMcpDiscoveryAdapter.ts index b1ade8f9a866f..b6f97353a5569 100644 --- a/src/vs/workbench/contrib/mcp/common/discovery/workspaceMcpDiscoveryAdapter.ts +++ b/src/vs/workbench/contrib/mcp/common/discovery/workspaceMcpDiscoveryAdapter.ts @@ -57,9 +57,9 @@ export class CursorWorkspaceMcpDiscoveryAdapter extends FilesystemMcpDiscovery i trustBehavior: McpServerTrust.Kind.TrustedOnNonce, serverDefinitions: observableValue(this, []), configTarget: ConfigurationTarget.WORKSPACE_FOLDER, + order: McpCollectionSortOrder.WorkspaceFolder + 1, presentation: { origin: configFile, - order: McpCollectionSortOrder.WorkspaceFolder + 1, }, }; diff --git a/src/vs/workbench/contrib/mcp/common/mcpConfiguration.ts b/src/vs/workbench/contrib/mcp/common/mcpConfiguration.ts index 8b1559c8f8454..2261258525ebc 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpConfiguration.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpConfiguration.ts @@ -54,6 +54,12 @@ export const discoverySourceSettingsLabel: Record = { export const mcpConfigurationSection = 'mcp'; export const mcpDiscoverySection = 'chat.mcp.discovery.enabled'; export const mcpServerSamplingSection = 'chat.mcp.serverSampling'; +export const mcpServerCollisionBehaviorSection = 'chat.mcp.collisionBehavior'; + +export const enum McpCollisionBehavior { + Disable = 'disable', + Suffix = 'suffix', +} export interface IMcpServerSamplingConfiguration { allowedDuringChat?: boolean; diff --git a/src/vs/workbench/contrib/mcp/common/mcpRegistry.ts b/src/vs/workbench/contrib/mcp/common/mcpRegistry.ts index d9ff9dad84aa0..634e86531b605 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpRegistry.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpRegistry.ts @@ -120,7 +120,7 @@ export class McpRegistry extends Disposable implements IMcpRegistry { this._collections.set(currentCollections.map(c => c === toReplace ? collection : c), undefined); } else { this._collections.set([...currentCollections, collection] - .sort((a, b) => (a.presentation?.order || 0) - (b.presentation?.order || 0)), undefined); + .sort((a, b) => a.order - b.order), undefined); } return { diff --git a/src/vs/workbench/contrib/mcp/common/mcpService.ts b/src/vs/workbench/contrib/mcp/common/mcpService.ts index 940704772ae02..4fa9f3430ab26 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpService.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpService.ts @@ -6,13 +6,15 @@ import { RunOnceScheduler } from '../../../../base/common/async.js'; import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; import { Disposable, DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; -import { autorun, IObservable, ISettableObservable, observableValue, transaction } from '../../../../base/common/observable.js'; +import { autorun, derived, IObservable, IReader, ISettableObservable, ITransaction, observableValue, transaction } from '../../../../base/common/observable.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 { mcpAutoStartConfig, McpAutoStartValue } from '../../../../platform/mcp/common/mcpManagement.js'; +import { observableConfigValue } from '../../../../platform/observable/common/platformObservableUtils.js'; import { IStorageService, StorageScope } from '../../../../platform/storage/common/storage.js'; -import { EnablementModel, isContributionEnabled } from '../../chat/common/enablement.js'; +import { ContributionEnablementState, EnablementModel, IEnablementModel, isContributionEnabled } from '../../chat/common/enablement.js'; +import { McpCollisionBehavior, mcpServerCollisionBehaviorSection } from './mcpConfiguration.js'; import { IMcpRegistry } from './mcpRegistryTypes.js'; import { McpServer, McpServerMetadataCache } from './mcpServer.js'; import { IAutostartResult, IMcpServer, IMcpService, McpCollectionDefinition, McpConnectionState, McpDefinitionReference, McpServerCacheState, McpServerDefinition, McpStartServerInteraction, McpToolName, UserInteractionRequiredError } from './mcpTypes.js'; @@ -30,7 +32,7 @@ export class McpService extends Disposable implements IMcpService { public get lazyCollectionState() { return this._mcpRegistry.lazyCollectionState; } - public readonly enablementModel: EnablementModel; + public readonly enablementModel: McpCollisionEnablementModel; protected readonly userCache: McpServerMetadataCache; protected readonly workspaceCache: McpServerMetadataCache; @@ -44,7 +46,9 @@ export class McpService extends Disposable implements IMcpService { ) { super(); - this.enablementModel = this._register(new EnablementModel('mcp.enablement', storageService)); + const baseEnablement = this._register(new EnablementModel('mcp.enablement', storageService)); + const collisionBehavior = observableConfigValue(mcpServerCollisionBehaviorSection, McpCollisionBehavior.Disable, configurationService); + this.enablementModel = new McpCollisionEnablementModel(baseEnablement, this._mcpRegistry, collisionBehavior); this.userCache = this._register(_instantiationService.createInstance(McpServerMetadataCache, StorageScope.PROFILE)); this.workspaceCache = this._register(_instantiationService.createInstance(McpServerMetadataCache, StorageScope.WORKSPACE)); @@ -247,3 +251,119 @@ class McpPrefixGenerator { return toolPrefix; } } + +/** + * Wraps an {@link EnablementModel} with collision-aware defaults and + * mutual-exclusion logic for MCP servers with the same label. + * + * When collision behavior is `disable`: + * - Servers whose label collides with a higher-priority server are disabled + * by default (unless the user has explicitly toggled them). + * - Enabling a colliding server disables all other servers with the same label. + * + * When collision behavior is `suffix`, delegates everything unchanged. + */ +export class McpCollisionEnablementModel implements IEnablementModel { + + /** + * For each server definition ID, the list of all definition IDs that share + * the same (case-insensitive) label, in priority order (lowest collection + * order first). Empty when collision behavior is `suffix`. + */ + private readonly _collisionGroups: IObservable>; + + constructor( + private readonly _base: EnablementModel, + registry: IMcpRegistry, + collisionBehavior: IObservable, + ) { + this._collisionGroups = derived(reader => { + if (collisionBehavior.read(reader) !== McpCollisionBehavior.Disable) { + return new Map(); + } + + const collections = registry.collections.read(reader); + // label → list of server definition IDs, in priority order + const labelToIds = new Map(); + for (const collection of collections) { + for (const server of collection.serverDefinitions.read(reader)) { + const key = server.label.toLowerCase(); + let ids = labelToIds.get(key); + if (!ids) { + ids = []; + labelToIds.set(key, ids); + } + ids.push(server.id); + } + } + + const groups = new Map(); + for (const ids of labelToIds.values()) { + if (ids.length < 2) { + continue; + } + for (const id of ids) { + groups.set(id, ids); + } + } + + return groups; + }); + } + + readEnabled(key: string, reader?: IReader): ContributionEnablementState { + const baseState = this._base.readEnabled(key, reader); + + if (!isContributionEnabled(baseState)) { + return baseState; + } + + const group = this._collisionGroups.read(reader).get(key); + if (!group) { + return baseState; + } + + // This server is enabled and in a collision group. Only allow it + // to stay enabled if no higher-priority server in the group is + // also enabled. + for (const otherId of group) { + if (otherId === key) { + return baseState; + } + if (isContributionEnabled(this._base.readEnabled(otherId, reader))) { + return ContributionEnablementState.DisabledProfile; + } + } + return baseState; + } + + setEnabled(key: string, state: ContributionEnablementState, tx?: ITransaction): void { + const isEnabling = state === ContributionEnablementState.EnabledProfile || state === ContributionEnablementState.EnabledWorkspace; + const group = isEnabling ? this._collisionGroups.get().get(key) : undefined; + + if (!group) { + this._base.setEnabled(key, state, tx); + return; + } + + // Enabling a colliding server: disable all others in the group atomically + const updateGroup = (innerTx: ITransaction) => { + this._base.setEnabled(key, state, innerTx); + for (const otherId of group) { + if (otherId !== key) { + this._base.setEnabled(otherId, ContributionEnablementState.DisabledWorkspace, innerTx); + } + } + }; + + if (tx) { + updateGroup(tx); + } else { + transaction(innerTx => updateGroup(innerTx)); + } + } + + remove(key: string): void { + this._base.remove(key); + } +} diff --git a/src/vs/workbench/contrib/mcp/common/mcpTypes.ts b/src/vs/workbench/contrib/mcp/common/mcpTypes.ts index 8868b1de415a9..f4fa7a32d9673 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpTypes.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpTypes.ts @@ -81,9 +81,10 @@ export interface McpCollectionDefinition { readonly source?: IWorkbenchMcpServer | ExtensionIdentifier; + /** Sort order of the collection. Lower values have higher priority. */ + readonly order: number; + readonly presentation?: { - /** Sort order of the collection. */ - readonly order?: number; /** Place where this collection is configured, used in workspace trust prompts and "show config" */ readonly origin?: URI; }; @@ -279,6 +280,7 @@ export const IMcpService = createDecorator('IMcpService'); export interface McpCollectionReference { id: string; label: string; + order: number; presentation?: McpCollectionDefinition['presentation']; } diff --git a/src/vs/workbench/contrib/mcp/test/common/mcpGatewayToolBrokerChannel.test.ts b/src/vs/workbench/contrib/mcp/test/common/mcpGatewayToolBrokerChannel.test.ts index 6a41246aa967d..a655ef1bf1483 100644 --- a/src/vs/workbench/contrib/mcp/test/common/mcpGatewayToolBrokerChannel.test.ts +++ b/src/vs/workbench/contrib/mcp/test/common/mcpGatewayToolBrokerChannel.test.ts @@ -367,7 +367,7 @@ function createServer( let startCalls = 0; return { - collection: { id: collectionId, label: collectionId }, + collection: { id: collectionId, label: collectionId, order: 0 }, definition: { id: definitionId, label: definitionId }, connection: observableValue(owner, undefined), connectionState, @@ -406,7 +406,7 @@ function createNeverStartingServer( let startBehavior: 'hang' | 'succeed' = 'hang'; const result: IMcpServer & { startCalls: number; startBehavior: 'hang' | 'succeed'; cacheStateValue: ReturnType> } = { - collection: { id: collectionId, label: collectionId }, + collection: { id: collectionId, label: collectionId, order: 0 }, definition: { id: definitionId, label: definitionId }, connection: observableValue(owner, undefined), connectionState, diff --git a/src/vs/workbench/contrib/mcp/test/common/mcpRegistry.test.ts b/src/vs/workbench/contrib/mcp/test/common/mcpRegistry.test.ts index 722a4ee2fe77f..1675a96a4668d 100644 --- a/src/vs/workbench/contrib/mcp/test/common/mcpRegistry.test.ts +++ b/src/vs/workbench/contrib/mcp/test/common/mcpRegistry.test.ts @@ -25,15 +25,19 @@ import { IProductService } from '../../../../../platform/product/common/productS import { ISecretStorageService } from '../../../../../platform/secrets/common/secrets.js'; import { TestSecretStorageService } from '../../../../../platform/secrets/test/common/testSecretStorageService.js'; import { IStorageService, StorageScope } from '../../../../../platform/storage/common/storage.js'; +import { observableConfigValue } from '../../../../../platform/observable/common/platformObservableUtils.js'; import { IWorkspaceFolderData } from '../../../../../platform/workspace/common/workspace.js'; import { IConfigurationResolverService } from '../../../../services/configurationResolver/common/configurationResolver.js'; import { ConfigurationResolverExpression, Replacement } from '../../../../services/configurationResolver/common/configurationResolverExpression.js'; import { IOutputService } from '../../../../services/output/common/output.js'; import { TestLoggerService, TestStorageService } from '../../../../test/common/workbenchTestServices.js'; +import { ContributionEnablementState, EnablementModel, isContributionEnabled } from '../../../chat/common/enablement.js'; +import { McpCollisionBehavior, mcpServerCollisionBehaviorSection } from '../../common/mcpConfiguration.js'; import { McpRegistry } from '../../common/mcpRegistry.js'; import { IMcpHostDelegate, IMcpMessageTransport } from '../../common/mcpRegistryTypes.js'; import { IMcpSandboxService } from '../../common/mcpSandboxService.js'; import { McpServerConnection } from '../../common/mcpServerConnection.js'; +import { McpCollisionEnablementModel } from '../../common/mcpService.js'; import { McpTaskManager } from '../../common/mcpTaskManager.js'; import { IMcpPotentialSandboxBlock, LazyCollectionState, McpCollectionDefinition, McpServerDefinition, McpServerLaunch, McpServerTransportStdio, McpServerTransportType, McpServerTrust, McpStartServerInteraction } from '../../common/mcpTypes.js'; import { TestMcpMessageTransport } from './mcpRegistryTypes.js'; @@ -225,6 +229,7 @@ suite('Workbench - MCP - Registry', () => { trustBehavior: McpServerTrust.Kind.Trusted, scope: StorageScope.APPLICATION, configTarget: ConfigurationTarget.USER, + order: 0, }; // Create base definition that can be reused @@ -698,6 +703,276 @@ suite('Workbench - MCP - Registry', () => { }); }); + suite('Server Label Collision Enablement', () => { + let enablementModel: McpCollisionEnablementModel; + let baseEnablement: EnablementModel; + + function createCollectionWithServers( + id: string, + order: number, + servers: { id: string; label: string }[], + ): McpCollectionDefinition & { serverDefinitions: ISettableObservable } { + return { + id, + label: `Collection ${id}`, + remoteAuthority: null, + order, + serverDefinitions: observableValue('serverDefs', servers.map(s => ({ + ...baseDefinition, + id: s.id, + label: s.label, + }))), + trustBehavior: McpServerTrust.Kind.Trusted, + scope: StorageScope.APPLICATION, + configTarget: ConfigurationTarget.USER, + }; + } + + function setupModel() { + baseEnablement = store.add(new EnablementModel('mcp.enablement.test', testStorageService)); + const collisionBehavior = observableConfigValue(mcpServerCollisionBehaviorSection, McpCollisionBehavior.Disable, configurationService); + enablementModel = new McpCollisionEnablementModel(baseEnablement, registry, collisionBehavior); + } + + test('disables lower-priority servers with same label', () => { + const col1 = createCollectionWithServers('col-1', 0, [{ id: 'col-1.srv-a', label: 'My Server' }]); + const col2 = createCollectionWithServers('col-2', 100, [{ id: 'col-2.srv-a', label: 'My Server' }]); + store.add(registry.registerCollection(col1)); + store.add(registry.registerCollection(col2)); + setupModel(); + + assert.ok(isContributionEnabled(enablementModel.readEnabled('col-1.srv-a'))); + assert.ok(!isContributionEnabled(enablementModel.readEnabled('col-2.srv-a'))); + }); + + test('does not disable servers with different labels', () => { + const col1 = createCollectionWithServers('col-1', 0, [{ id: 'col-1.srv-a', label: 'Server A' }]); + const col2 = createCollectionWithServers('col-2', 100, [{ id: 'col-2.srv-b', label: 'Server B' }]); + store.add(registry.registerCollection(col1)); + store.add(registry.registerCollection(col2)); + setupModel(); + + assert.ok(isContributionEnabled(enablementModel.readEnabled('col-1.srv-a'))); + assert.ok(isContributionEnabled(enablementModel.readEnabled('col-2.srv-b'))); + }); + + test('label collision is case-insensitive', () => { + const col1 = createCollectionWithServers('col-1', 0, [{ id: 'col-1.srv-a', label: 'My Server' }]); + const col2 = createCollectionWithServers('col-2', 100, [{ id: 'col-2.srv-a', label: 'my server' }]); + store.add(registry.registerCollection(col1)); + store.add(registry.registerCollection(col2)); + setupModel(); + + assert.ok(isContributionEnabled(enablementModel.readEnabled('col-1.srv-a'))); + assert.ok(!isContributionEnabled(enablementModel.readEnabled('col-2.srv-a'))); + }); + + test('respects collection order for priority', () => { + const col2 = createCollectionWithServers('col-2', 200, [{ id: 'col-2.srv-a', label: 'My Server' }]); + const col1 = createCollectionWithServers('col-1', 0, [{ id: 'col-1.srv-a', label: 'My Server' }]); + store.add(registry.registerCollection(col2)); + store.add(registry.registerCollection(col1)); + setupModel(); + + assert.ok(isContributionEnabled(enablementModel.readEnabled('col-1.srv-a'))); + assert.ok(!isContributionEnabled(enablementModel.readEnabled('col-2.srv-a'))); + }); + + test('enabling a colliding server disables others with same label', () => { + const col1 = createCollectionWithServers('col-1', 0, [{ id: 'col-1.srv-a', label: 'My Server' }]); + const col2 = createCollectionWithServers('col-2', 100, [{ id: 'col-2.srv-a', label: 'My Server' }]); + store.add(registry.registerCollection(col1)); + store.add(registry.registerCollection(col2)); + setupModel(); + + // Enable the lower-priority server explicitly + enablementModel.setEnabled('col-2.srv-a', ContributionEnablementState.EnabledWorkspace); + + // col-2 is now enabled, col-1 should be disabled (set to DisabledWorkspace) + assert.ok(isContributionEnabled(enablementModel.readEnabled('col-2.srv-a'))); + assert.ok(!isContributionEnabled(enablementModel.readEnabled('col-1.srv-a'))); + assert.strictEqual(enablementModel.readEnabled('col-1.srv-a'), ContributionEnablementState.DisabledWorkspace); + }); + + test('no collision effect when behavior is "suffix"', () => { + configurationService.setUserConfiguration('chat.mcp.collisionBehavior', McpCollisionBehavior.Suffix); + configurationService.onDidChangeConfigurationEmitter.fire({ + affectsConfiguration: (key: string) => key === 'chat.mcp.collisionBehavior', + } as unknown as IConfigurationChangeEvent); + + const col1 = createCollectionWithServers('col-1', 0, [{ id: 'col-1.srv-a', label: 'My Server' }]); + const col2 = createCollectionWithServers('col-2', 100, [{ id: 'col-2.srv-a', label: 'My Server' }]); + store.add(registry.registerCollection(col1)); + store.add(registry.registerCollection(col2)); + setupModel(); + + // Both should be enabled when collision behavior is "suffix" + assert.ok(isContributionEnabled(enablementModel.readEnabled('col-1.srv-a'))); + assert.ok(isContributionEnabled(enablementModel.readEnabled('col-2.srv-a'))); + }); + + test('non-winner becomes enabled when winner is explicitly disabled', () => { + const col1 = createCollectionWithServers('col-1', 0, [{ id: 'col-1.srv-a', label: 'My Server' }]); + const col2 = createCollectionWithServers('col-2', 100, [{ id: 'col-2.srv-a', label: 'My Server' }]); + store.add(registry.registerCollection(col1)); + store.add(registry.registerCollection(col2)); + setupModel(); + + // Explicitly disable the winner + enablementModel.setEnabled('col-1.srv-a', ContributionEnablementState.DisabledProfile); + + // col-1 is disabled, col-2 becomes the first enabled server in the group + assert.ok(!isContributionEnabled(enablementModel.readEnabled('col-1.srv-a'))); + assert.ok(isContributionEnabled(enablementModel.readEnabled('col-2.srv-a'))); + }); + + test('updates when server definitions change', () => { + const col1 = createCollectionWithServers('col-1', 0, [{ id: 'col-1.srv-a', label: 'Server A' }]); + const col2: McpCollectionDefinition & { serverDefinitions: ISettableObservable } = { + ...createCollectionWithServers('col-2', 100, []), + }; + store.add(registry.registerCollection(col1)); + store.add(registry.registerCollection(col2)); + setupModel(); + + // Initially no collision — both enabled + assert.ok(isContributionEnabled(enablementModel.readEnabled('col-1.srv-a'))); + + // Add a conflicting server to col2 + col2.serverDefinitions.set([{ ...baseDefinition, id: 'col-2.srv-a', label: 'Server A' }], undefined); + assert.ok(isContributionEnabled(enablementModel.readEnabled('col-1.srv-a'))); + assert.ok(!isContributionEnabled(enablementModel.readEnabled('col-2.srv-a'))); + }); + + test('three-way collision: only highest priority is enabled', () => { + const col1 = createCollectionWithServers('col-1', 0, [{ id: 'col-1.srv', label: 'My Server' }]); + const col2 = createCollectionWithServers('col-2', 100, [{ id: 'col-2.srv', label: 'My Server' }]); + const col3 = createCollectionWithServers('col-3', 200, [{ id: 'col-3.srv', label: 'My Server' }]); + store.add(registry.registerCollection(col1)); + store.add(registry.registerCollection(col2)); + store.add(registry.registerCollection(col3)); + setupModel(); + + assert.ok(isContributionEnabled(enablementModel.readEnabled('col-1.srv'))); + assert.ok(!isContributionEnabled(enablementModel.readEnabled('col-2.srv'))); + assert.ok(!isContributionEnabled(enablementModel.readEnabled('col-3.srv'))); + }); + + test('three-way collision: enabling lowest disables both others', () => { + const col1 = createCollectionWithServers('col-1', 0, [{ id: 'col-1.srv', label: 'My Server' }]); + const col2 = createCollectionWithServers('col-2', 100, [{ id: 'col-2.srv', label: 'My Server' }]); + const col3 = createCollectionWithServers('col-3', 200, [{ id: 'col-3.srv', label: 'My Server' }]); + store.add(registry.registerCollection(col1)); + store.add(registry.registerCollection(col2)); + store.add(registry.registerCollection(col3)); + setupModel(); + + enablementModel.setEnabled('col-3.srv', ContributionEnablementState.EnabledWorkspace); + + assert.ok(!isContributionEnabled(enablementModel.readEnabled('col-1.srv'))); + assert.ok(!isContributionEnabled(enablementModel.readEnabled('col-2.srv'))); + assert.ok(isContributionEnabled(enablementModel.readEnabled('col-3.srv'))); + }); + + test('disabling winner cascades to next in priority', () => { + const col1 = createCollectionWithServers('col-1', 0, [{ id: 'col-1.srv', label: 'My Server' }]); + const col2 = createCollectionWithServers('col-2', 100, [{ id: 'col-2.srv', label: 'My Server' }]); + const col3 = createCollectionWithServers('col-3', 200, [{ id: 'col-3.srv', label: 'My Server' }]); + store.add(registry.registerCollection(col1)); + store.add(registry.registerCollection(col2)); + store.add(registry.registerCollection(col3)); + setupModel(); + + // Disable the winner — col-2 (next priority) becomes the active one + enablementModel.setEnabled('col-1.srv', ContributionEnablementState.DisabledProfile); + + assert.ok(!isContributionEnabled(enablementModel.readEnabled('col-1.srv'))); + assert.ok(isContributionEnabled(enablementModel.readEnabled('col-2.srv'))); + assert.ok(!isContributionEnabled(enablementModel.readEnabled('col-3.srv'))); + }); + + test('both servers in same collection with same label: only first enabled', () => { + const col = createCollectionWithServers('col-1', 0, [ + { id: 'col-1.srv-a', label: 'My Server' }, + { id: 'col-1.srv-b', label: 'My Server' }, + ]); + store.add(registry.registerCollection(col)); + setupModel(); + + assert.ok(isContributionEnabled(enablementModel.readEnabled('col-1.srv-a'))); + assert.ok(!isContributionEnabled(enablementModel.readEnabled('col-1.srv-b'))); + }); + + test('EnabledWorkspace non-winner still suppressed if winner also enabled', () => { + const col1 = createCollectionWithServers('col-1', 0, [{ id: 'col-1.srv', label: 'My Server' }]); + const col2 = createCollectionWithServers('col-2', 100, [{ id: 'col-2.srv', label: 'My Server' }]); + store.add(registry.registerCollection(col1)); + store.add(registry.registerCollection(col2)); + setupModel(); + + // Manually set both to EnabledWorkspace in the base model + baseEnablement.setEnabled('col-1.srv', ContributionEnablementState.EnabledWorkspace); + baseEnablement.setEnabled('col-2.srv', ContributionEnablementState.EnabledWorkspace); + + // Even though both are explicitly enabled, only the higher-priority one wins + assert.ok(isContributionEnabled(enablementModel.readEnabled('col-1.srv'))); + assert.ok(!isContributionEnabled(enablementModel.readEnabled('col-2.srv'))); + }); + + test('remove clears collision override and restores default behavior', () => { + const col1 = createCollectionWithServers('col-1', 0, [{ id: 'col-1.srv', label: 'My Server' }]); + const col2 = createCollectionWithServers('col-2', 100, [{ id: 'col-2.srv', label: 'My Server' }]); + store.add(registry.registerCollection(col1)); + store.add(registry.registerCollection(col2)); + setupModel(); + + // Enable col-2, which disables col-1 via DisabledWorkspace + enablementModel.setEnabled('col-2.srv', ContributionEnablementState.EnabledWorkspace); + assert.ok(!isContributionEnabled(enablementModel.readEnabled('col-1.srv'))); + + // Remove both overrides — restores default collision behavior + enablementModel.remove('col-1.srv'); + enablementModel.remove('col-2.srv'); + assert.ok(isContributionEnabled(enablementModel.readEnabled('col-1.srv'))); + assert.ok(!isContributionEnabled(enablementModel.readEnabled('col-2.srv'))); + }); + + test('non-colliding servers in same collection as colliding ones are unaffected', () => { + const col1 = createCollectionWithServers('col-1', 0, [ + { id: 'col-1.srv-a', label: 'My Server' }, + { id: 'col-1.srv-b', label: 'Unique Server' }, + ]); + const col2 = createCollectionWithServers('col-2', 100, [ + { id: 'col-2.srv-a', label: 'My Server' }, + { id: 'col-2.srv-c', label: 'Another Unique' }, + ]); + store.add(registry.registerCollection(col1)); + store.add(registry.registerCollection(col2)); + setupModel(); + + // Colliding servers: only col-1's wins + assert.ok(isContributionEnabled(enablementModel.readEnabled('col-1.srv-a'))); + assert.ok(!isContributionEnabled(enablementModel.readEnabled('col-2.srv-a'))); + // Non-colliding servers: both enabled + assert.ok(isContributionEnabled(enablementModel.readEnabled('col-1.srv-b'))); + assert.ok(isContributionEnabled(enablementModel.readEnabled('col-2.srv-c'))); + }); + + test('setEnabled with non-colliding server does not affect others', () => { + const col1 = createCollectionWithServers('col-1', 0, [{ id: 'col-1.srv-a', label: 'Server A' }]); + const col2 = createCollectionWithServers('col-2', 100, [{ id: 'col-2.srv-b', label: 'Server B' }]); + store.add(registry.registerCollection(col1)); + store.add(registry.registerCollection(col2)); + setupModel(); + + enablementModel.setEnabled('col-2.srv-b', ContributionEnablementState.EnabledWorkspace); + + // No collision group — col-1 should be unaffected + assert.ok(isContributionEnabled(enablementModel.readEnabled('col-1.srv-a'))); + assert.ok(isContributionEnabled(enablementModel.readEnabled('col-2.srv-b'))); + }); + }); + suite('Trust Flow', () => { /** * Helper to create a test MCP collection with a specific trust behavior @@ -711,6 +986,7 @@ suite('Workbench - MCP - Registry', () => { trustBehavior, scope: StorageScope.APPLICATION, configTarget: ConfigurationTarget.USER, + order: 0, }; } diff --git a/src/vs/workbench/contrib/mcp/test/common/mcpRegistryTypes.ts b/src/vs/workbench/contrib/mcp/test/common/mcpRegistryTypes.ts index 69b07fdf55f6f..4360cbdf62d8c 100644 --- a/src/vs/workbench/contrib/mcp/test/common/mcpRegistryTypes.ts +++ b/src/vs/workbench/contrib/mcp/test/common/mcpRegistryTypes.ts @@ -168,6 +168,7 @@ export class TestMcpRegistry implements IMcpRegistry { remoteAuthority: null, label: 'Test Collection', configTarget: ConfigurationTarget.USER, + order: 0, serverDefinitions: observableValue(this, [{ id: 'test-server', label: 'Test Server', diff --git a/src/vs/workbench/contrib/mcp/test/common/mcpServerConnection.test.ts b/src/vs/workbench/contrib/mcp/test/common/mcpServerConnection.test.ts index 518481fb12320..8e1c2c2d8c615 100644 --- a/src/vs/workbench/contrib/mcp/test/common/mcpServerConnection.test.ts +++ b/src/vs/workbench/contrib/mcp/test/common/mcpServerConnection.test.ts @@ -95,6 +95,7 @@ suite('Workbench - MCP - ServerConnection', () => { trustBehavior: McpServerTrust.Kind.Trusted, scope: StorageScope.APPLICATION, configTarget: ConfigurationTarget.USER, + order: 0, }; // Create server definition From f46e6eb98cde3f7c05bc339d9dcbde7df93e2f0d Mon Sep 17 00:00:00 2001 From: Hawk Ticehurst <39639992+hawkticehurst@users.noreply.github.com> Date: Thu, 23 Apr 2026 18:20:49 -0400 Subject: [PATCH 70/70] sessions: fix spinner animation restart and respect reduced motion (#312219) * sessions: fix spinner animation restart and respect reduced motion Fix the session list spinner (loading icon) animation restarting on every tree re-render. The tree calls renderElement on all visible rows whenever the list is spliced, which clears elementDisposables and recreates the icon autorun. The icon CSS selector tracking was stored in a local variable that reset to undefined each time, causing the spinner DOM to be rebuilt and the CSS animation to restart. Move the icon selector tracking onto the template object so it persists across renderSession calls for the same row. The spinner element now stays alive and its animation continues smoothly. Also add reduced motion support: when the user has reduced motion enabled (via workbench.reduceMotion or system preference), show the static session-in-progress codicon instead of the animated loading spinner. The icon reactively updates when the preference changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * address PR feedback: hoist motion signal, fix stale icon color - Hoist observableSignalFromEvent to a renderer-level field so it's shared across all rows instead of recreated on every renderSession. - Always update iconSpan.style.color even when the selector hasn't changed, fixing stale color when the same codicon is used with different theme colors (e.g. circleFilled for both NeedsInput and unread states). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../sessions/browser/views/sessionsList.ts | 48 +++++++++++++++---- 1 file changed, 40 insertions(+), 8 deletions(-) diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts index a30cab968356b..43646737f04b7 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts @@ -15,7 +15,7 @@ import { HighlightedLabel } from '../../../../../base/browser/ui/highlightedlabe import { createMatches, FuzzyScore, IMatch } from '../../../../../base/common/filters.js'; import { Disposable, DisposableStore, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; import { MarkdownString } from '../../../../../base/common/htmlContent.js'; -import { IReader, autorun } from '../../../../../base/common/observable.js'; +import { IReader, autorun, observableSignalFromEvent } from '../../../../../base/common/observable.js'; import { ThemeIcon, themeColorFromId } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; import { fromNow } from '../../../../../base/common/date.js'; @@ -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 { IAgentSessionsService } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; +import { IAccessibilityService } from '../../../../../platform/accessibility/common/accessibility.js'; import { ISessionsListModelService } from './sessionsListModelService.js'; import { IAgentHostFilterService } from '../../../remoteAgentHost/common/agentHostFilter.js'; @@ -149,6 +150,8 @@ interface ISessionItemTemplate { readonly contextKeyService: IContextKeyService; readonly disposables: DisposableStore; readonly elementDisposables: DisposableStore; + /** Tracks the current icon CSS selector to avoid rebuilding the DOM (and restarting CSS animations) when the icon hasn't changed. */ + currentIconSelector: string | undefined; } class SessionItemRenderer implements ITreeRenderer { @@ -167,6 +170,8 @@ class SessionItemRenderer implements ITreeRenderer(); readonly onDidChangeItemHeight: Event = this._onDidChangeItemHeight.event; + private readonly _motionReducedSignal; + constructor( private readonly options: { grouping: () => SessionsGrouping; sorting: () => SessionsSorting; isPinned: (session: ISession) => boolean; isRead: (session: ISession) => boolean }, private readonly approvalModel: AgentSessionApprovalModel | undefined, @@ -175,7 +180,10 @@ class SessionItemRenderer implements ITreeRenderer, _index: number, template: ISessionItemTemplate): void { @@ -238,16 +246,34 @@ class SessionItemRenderer implements ITreeRenderer { const sessionStatus = element.status.read(reader); const isRead = this.options.isRead(element); const isArchived = element.isArchived.read(reader); const gitHubInfo = element.gitHubInfo.read(reader); - DOM.clearNode(template.iconContainer); + this._motionReducedSignal.read(reader); const icon = this.getStatusIcon(sessionStatus, isRead, isArchived, gitHubInfo?.pullRequest?.icon); - const iconSpan = DOM.append(template.iconContainer, $(`span${ThemeIcon.asCSSSelector(icon)}`)); - iconSpan.style.color = icon.color ? asCssVariable(icon.color.id) : ''; + const iconSelector = ThemeIcon.asCSSSelector(icon); + const iconColor = icon.color ? asCssVariable(icon.color.id) : ''; + + if (iconSelector !== template.currentIconSelector) { + template.currentIconSelector = iconSelector; + DOM.clearNode(template.iconContainer); + const iconSpan = DOM.append(template.iconContainer, $(`span${iconSelector}`)); + iconSpan.style.color = iconColor; + } else { + const iconSpan = template.iconContainer.firstElementChild as HTMLElement | null; + if (iconSpan) { + iconSpan.style.color = iconColor; + } + } template.iconContainer.classList.toggle('session-icon-pulse', sessionStatus === SessionStatus.NeedsInput); template.container.classList.toggle('in-progress', sessionStatus === SessionStatus.InProgress); })); @@ -466,7 +492,11 @@ class SessionItemRenderer implements ITreeRenderer accessor.get(IMarkdownRendererService)); const hoverService = instantiationService.invokeFunction(accessor => accessor.get(IHoverService)); const agentSessionsService = instantiationService.invokeFunction(accessor => accessor.get(IAgentSessionsService)); + const accessibilityService = instantiationService.invokeFunction(accessor => accessor.get(IAccessibilityService)); const sessionRenderer = new SessionItemRenderer( { grouping: this.options.grouping, sorting: this.options.sorting, isPinned: s => this.isSessionPinned(s), isRead: s => this.isSessionRead(s) }, approvalModel, @@ -739,6 +770,7 @@ export class SessionsList extends Disposable implements ISessionsList { markdownRendererService, hoverService, agentSessionsService, + accessibilityService, ); const showMoreRenderer = new SessionShowMoreRenderer();