From 4c187e8e838bb638752bc9ab6c44d7d77e227a93 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 23 Oct 2026 00:04:55 -0500 Subject: [PATCH] =?UTF-8?q?Reconstruct=20batch=20036/124:=202026-09-01=20?= =?UTF-8?q?=E2=86=92=202026-09-04=20(chat,=20sessions,=20agenthost,=20agen?= =?UTF-8?q?t=20host)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Squashed reconstruction of 50 upstream commits (2082c5b594d..c772f67cd2f). 324 files changed, 19602 insertions(+), 2792 deletions(-) chat: - chat: Mark consolidated remote workspaces experimental (#334342) - Implement subagent default setting to Auto without overriding models (#333663) - chat: Select primary Luna model for dictation cleanup (#334363) - Experimental model picker redesign (#334332) - chat: Virtualize AI customization section lists (#334366) - Fix chat response jump by reserving space for visible chat pets (#334336) - chat: Add setting for subagent credit usage (#334384) - chat: fix picker focus and keyboard navigation (#334416) - Prevent duplicate implicit PR attachments (#334215) - Update file icon mask properties for better alignment and sizing (#334450) sessions: - Fix remote host empty state fixture width - sessions: align new chat picker typography (#334307) - sessions: Add no-workspace option to new session picker (#334356) - sessions: rename focused chat with F2 (#334163) - Keep sandbox toggle editable when managed policy allows bypass (#334370) - sessions: add Agents window layout telemetry (#334255) - Agents - fix vertical alignment of the badge number (#334430) - sessions: polish: add new session button treatments (#334269) agenthost: - Prevent auth replay from blocking provider shutdown (#332824) - agentHost: remove redundant picker tab stop (#334306) - Agents - enable switching branches for folder sessions (#334354) - agentHost: scale E2E protocol timeouts for CI contention (#334331) - agentHost: classify inline chat telemetry (#334378) - Implement queueing for busy target chats in send_message tool (#334260) - Convert workspace-less sessions to workspace sessions (#334250) agent host: - agent host: stop chat queues from wedging on a non-settling SDK RPC (#334338) - Agent Host: stop the reconnecting banner nagging on a flapping transport (#334348) - Agent Host: Open Dev Container workspaces in Agents (#334249) extensions/copilot: - Avoid changing model picker for subagent Auto retries (#334357) - Add experimental Opus thinking effort setting (#334426) editor: - Unify multi-diff editor presentation variants - Fix multi-diff widget field initialization extensions/markdown-language-features: - Update Markdown editor to 0.0.2-88 - [cherry-pick] Fix markdown inline comment hover background configuration: - Enhance ConfigurationDefaultOverridesContribution to retain auto experiment overrides and apply schema defaults - Address review feedback: extract test fixture helper and condense comments launch: - launch: Encode agent session titles for command safety (#334304) test: - test: avoid restoring archived session during E2E cleanup (#334346) policy: - policy: disable custom terminal tool for managed accounts (#334297) extensions/bat: - Fix CMD/bat comment spacing (#334205) scripts: - remove example from mock-policy-server (#334361) browser: - Browser: skip unused screenshot after load failure (#333737) plan widget: - plan widget: show implementation dropdown when plan is open (#334381) assignment: - Stop ABExp.queriedFeature telemetry from being redacted as a file path (#334379) github: - Fixes github pr and issue link presentation data fetching (#334374) terminalcontrib: - Align Agent Host sandbox network and credential defaults (#334390) browserview: - Support opening MHTML files in integrated browser (#333307) build: - Make extensions.gallery.authProvider policy-controlled (#333837) Co-Authored-By: Claude Opus 5.5 --- .agents/skills/launch/scripts/launch.ps1 | 3 +- .agents/skills/launch/scripts/launch.sh | 3 +- build/lib/policies/policyData.jsonc | 46 +- extensions/bat/language-configuration.json | 2 +- extensions/copilot/package.json | 16 + extensions/copilot/package.nls.json | 1 + .../common/languageModelAccess.ts | 7 +- .../vscode-node/chatParticipants.ts | 11 +- .../vscode-node/languageModelAccess.ts | 30 +- .../test/languageModelAccess.test.ts | 19 +- .../common/configurationService.ts | 2 + .../telemetry/common/baseTelemetryService.ts | 6 + .../markdown-editor-src/markdownEditor.css | 13 + .../package-lock.json | 8 +- .../markdown-language-features/package.json | 2 +- package.json | 2 +- scripts/mock-policy-server/endpoints.ts | 11 - src/vs/base/browser/domSanitize.ts | 57 +- src/vs/base/browser/ui/radio/radio.css | 46 + src/vs/base/browser/ui/radio/radio.ts | 90 +- src/vs/base/browser/ui/toggle/switch.css | 60 + src/vs/base/browser/ui/toggle/switch.ts | 99 ++ src/vs/base/common/defaultAccount.ts | 4 +- src/vs/base/test/browser/domSanitize.test.ts | 53 +- .../features/hideUnchangedRegionsFeature.ts | 2 +- .../browser/widget/diffEditor/style.css | 4 + .../multiDiffEditor/diffEditorItemTemplate.ts | 20 +- .../multiDiffEditor/multiDiffEditorOptions.ts | 45 + .../multiDiffEditor/multiDiffEditorWidget.ts | 34 +- .../multiDiffEditorWidgetImpl.ts | 9 +- .../browser/widget/multiDiffEditor/style.css | 373 ++++- .../workbenchUIElementFactory.ts | 17 +- .../standalone/browser/standaloneEditor.ts | 3 +- .../widget/multiDiffEditorWidget.test.ts | 33 +- .../actionWidget/browser/actionList.ts | 203 ++- .../actionWidget/browser/actionWidget.css | 52 - .../browser/tabbedActionListWidget.css | 34 + .../browser/tabbedActionListWidget.ts | 188 ++- .../test/browser/actionList.test.ts | 99 +- .../browser/tabbedActionListWidget.test.ts | 41 + .../browser/agentHostEnablementService.ts | 6 +- .../browser/agentHostProtocolClient.ts | 45 +- src/vs/platform/agentHost/common/agent.ts | 26 + .../agentHostChangesetOperationService.ts | 1 + .../common/agentHostEnablementService.ts | 1 + .../common/agentHostExtensionProtocol.ts | 13 + .../agentHost/common/agentHostGitService.ts | 2 + .../common/meta/agentCheckoutOperationMeta.ts | 25 + .../agentHost/common/serverToolNames.ts | 1 + .../agentHost/common/sessionDataService.ts | 5 + .../agentHost/common/state/sessionActions.ts | 13 +- .../agentHost/common/state/sessionState.ts | 22 +- .../agentHost/node/agentHostBootstrap.ts | 1 + .../agentHostChangesetOperationService.ts | 73 +- .../node/agentHostCheckoutOperationHandler.ts | 83 ++ .../agentHostCheckoutOperationProvider.ts | 58 + .../node/agentHostClientConnectionService.ts | 12 + .../agentHost/node/agentHostContributions.ts | 2 + .../agentHost/node/agentHostGitService.ts | 4 + .../node/agentHostProviderService.ts | 22 +- .../agentHost/node/agentHostServices.ts | 2 + .../agentHost/node/agentHostStateManager.ts | 40 +- .../node/agentHostTelemetryReporter.ts | 39 +- .../agentHost/node/agentHostTurnService.ts | 166 +++ .../agentHost/node/agentHostTurnStarter.ts | 6 +- .../agentHost/node/agentHostTurnTracker.ts | 6 + .../platform/agentHost/node/agentService.ts | 31 +- .../agentHost/node/agentServiceComposition.ts | 27 +- .../agentHost/node/agentServiceFoundation.ts | 5 +- .../agentHost/node/agentSideEffects.ts | 48 +- .../builtInChatContributions.ts | 2 + .../queueDrain/queueDrainContribution.ts | 5 + .../sessionWorkspaceConversionContribution.ts | 45 + .../sessionWorkspaceConversionService.ts | 531 +++++++ .../agentHost/node/claude/claudeAgent.ts | 5 + .../agentHost/node/claude/claudeCanUseTool.ts | 8 +- .../agentHost/node/codex/codexAgent.ts | 10 +- .../node/copilot/agentHostSandboxEngine.ts | 59 +- .../agentHost/node/copilot/copilotAgent.ts | 356 ++++- .../node/copilot/copilotAgentSession.ts | 186 ++- .../node/copilot/copilotShellTools.ts | 98 +- .../node/copilot/copilotToolDisplay.ts | 7 +- .../node/copilot/prompts/systemMessage.ts | 7 +- .../node/copilot/sandboxConfigForSdk.ts | 6 +- .../agentHost/node/protocolServerHandler.ts | 11 +- .../agentHost/node/sessionDatabase.ts | 11 + .../node/shared/agentFeedbackServerTools.ts | 3 + .../node/shared/agentMergeServerTools.ts | 1 + .../node/shared/agentServerToolHost.ts | 27 +- .../node/shared/artifactServerTools.ts | 3 + .../node/shared/sessionServerTools.ts | 141 +- .../node/shared/worktreeIsolation.ts | 31 +- .../agentHostEnablementService.test.ts | 27 +- .../test/common/sessionTestHelpers.ts | 7 + .../agentHostProtocolClient.test.ts | 175 ++- ...agentHostChangesetOperationService.test.ts | 44 + .../agentHostCheckoutOperationHandler.test.ts | 107 ++ ...agentHostCheckoutOperationProvider.test.ts | 73 + .../agentHostCommitOperationHandler.test.ts | 1 + ...HostDiscardChangesOperationHandler.test.ts | 1 + .../agentHostGitService.integrationTest.ts | 9 + .../test/node/agentHostPromptRegistry.test.ts | 12 + .../node/agentHostProviderService.test.ts | 50 +- ...entHostPullRequestOperationHandler.test.ts | 1 + .../test/node/agentHostStateManager.test.ts | 30 +- .../node/agentHostTelemetryReporter.test.ts | 23 +- .../node/agentHostToolCallTelemetry.test.ts | 10 + .../node/agentHostTurnHangTelemetry.test.ts | 23 +- .../test/node/agentHostTurnService.test.ts | 131 ++ .../test/node/agentHostTurnTelemetry.test.ts | 25 +- .../agentHost/test/node/agentService.test.ts | 25 +- .../test/node/agentServiceTestUtils.ts | 1 + .../test/node/agentSideEffects.test.ts | 10 + .../test/node/chatContributions.test.ts | 48 +- .../agentHost/test/node/claudeAgent.test.ts | 51 +- .../test/node/codex/codexCreateChat.test.ts | 17 +- .../agentHost/test/node/copilotAgent.test.ts | 1319 ++++++++++++++++- .../test/node/copilotAgentSession.test.ts | 507 ++++++- .../test/node/copilotGitProject.test.ts | 1 + .../test/node/copilotShellTools.test.ts | 134 +- .../test/node/copilotToolDisplay.test.ts | 21 + .../e2e/harness/agentHostE2ETestHarness.ts | 11 +- ...Copilot_prompts_claude-haiku-4_5.prompt.md | 2 +- ..._Copilot_prompts_claude-opus-4_5.prompt.md | 2 +- ..._Copilot_prompts_claude-opus-4_6.prompt.md | 2 +- ..._Copilot_prompts_claude-opus-4_7.prompt.md | 2 +- ..._Copilot_prompts_claude-opus-4_8.prompt.md | 2 +- ...___Copilot_prompts_claude-opus-5.prompt.md | 2 +- ...opilot_prompts_claude-sonnet-4_5.prompt.md | 2 +- ...opilot_prompts_claude-sonnet-4_6.prompt.md | 2 +- ..._Copilot_prompts_claude-sonnet-5.prompt.md | 2 +- ...Copilot_prompts_gemini-2_0-flash.prompt.md | 2 +- ...2E___Copilot_prompts_gpt-5-codex.prompt.md | 2 +- ...E2E___Copilot_prompts_gpt-5-mini.prompt.md | 2 +- ...Host_E2E___Copilot_prompts_gpt-5.prompt.md | 2 +- ...pilot_prompts_gpt-5_1-codex-mini.prompt.md | 2 +- ...___Copilot_prompts_gpt-5_1-codex.prompt.md | 2 +- ...st_E2E___Copilot_prompts_gpt-5_1.prompt.md | 2 +- ...E___Copilot_prompts_gpt-5_6-luna.prompt.md | 2 +- ...2E___Copilot_prompts_gpt-5_6-sol.prompt.md | 2 +- ...___Copilot_prompts_gpt-5_6-terra.prompt.md | 2 +- .../test/node/e2e/suites/changesetSuite.ts | 11 +- .../e2e/suites/sessionPersistenceSuite.ts | 8 +- .../platform/agentHost/test/node/mockAgent.ts | 19 +- .../test/node/protocolServerHandler.test.ts | 67 + .../test/node/sandboxConfigForSdk.test.ts | 18 +- .../test/node/serverIntegrationTestHelpers.ts | 14 +- .../test/node/serverToolGroups.test.ts | 27 + .../test/node/sessionDatabase.test.ts | 21 + .../test/node/sessionServerTools.test.ts | 276 +++- .../node/sessionWorkspaceConversion.test.ts | 974 ++++++++++++ .../node/shared/worktreeIsolation.test.ts | 38 +- src/vs/platform/environment/common/argv.ts | 2 +- src/vs/platform/environment/node/argv.ts | 2 +- .../platform/github/common/githubTransport.ts | 3 - .../github/test/node/githubTransport.test.ts | 8 +- .../policy/common/copilotManagedSettings.ts | 18 +- .../common/copilotManagedSettings.test.ts | 16 +- .../node/nativeManagedSettingsService.test.ts | 4 +- .../test/common/terminalSandboxEngine.test.ts | 1 + .../sessions/browser/parts/chatGroupView.ts | 28 +- .../sessions/browser/parts/chatGroupsView.ts | 21 +- src/vs/sessions/browser/parts/chatView.ts | 7 +- .../browser/parts/sessionRemoteConnection.ts | 23 +- src/vs/sessions/browser/parts/sessionView.ts | 15 +- src/vs/sessions/browser/parts/sessionsPart.ts | 6 +- .../sessions/browser/parts/sessionsParts.ts | 4 + src/vs/sessions/browser/workbench.ts | 18 + .../common/agentHostSessionsProvider.ts | 4 + src/vs/sessions/common/contextkeys.ts | 1 + src/vs/sessions/common/sessionCommands.ts | 3 + .../changes/browser/changes.contribution.ts | 1 - .../contrib/changes/browser/changesActions.ts | 3 +- .../changes/browser/changesViewService.ts | 5 +- .../browser/media/multiFileDiffEditor.css | 273 ---- .../browser/media/sessionChangesEditor.css | 48 + .../media/sessionChangesEditorInput.css | 1 - .../changes/browser/sessionChangesEditor.ts | 35 +- .../test/browser/agentsDiffEditor.fixture.ts | 40 +- .../test/browser/changesActions.test.ts | 9 +- .../test/browser/changesViewService.test.ts | 21 + .../chat/browser/agentsWindowOpenIntent.ts | 37 + .../sessions/contrib/chat/browser/chatView.ts | 14 +- .../contrib/chat/browser/media/chatView.css | 13 + .../contrib/chat/browser/media/chatWidget.css | 12 +- .../contrib/chat/browser/newChatWidget.ts | 113 +- .../contrib/chat/browser/sessionTypePicker.ts | 2 +- .../chat/browser/sessionWorkspacePicker.ts | 78 +- .../browser/sessionsChatAccessibilityHelp.ts | 7 +- .../electron-browser/chat.contribution.ts | 31 +- .../browser/agentsWindowOpenIntent.test.ts | 51 + .../test/browser/newChatWidget.fixture.ts | 2 +- .../chat/test/browser/newChatWidget.test.ts | 234 ++- .../browser/sessionWorkspacePicker.test.ts | 59 + .../browser/agentHostSessionChangesets.ts | 7 +- .../browser/agentHostSessionConfigPicker.ts | 145 +- .../browser/baseAgentHostSessionsProvider.ts | 202 ++- .../browser/localAgentHostSessionsProvider.ts | 22 + .../agentHostPermissionPickerDelegate.test.ts | 1 + .../agentHostSessionConfigPicker.test.ts | 363 ++++- .../agentHostSessionChangesets.test.ts | 20 + .../localAgentHostSessionsProvider.test.ts | 310 +++- .../test/browser/openSubagentChat.test.ts | 6 +- .../browser/mobilePermissionPicker.ts | 4 +- .../browser/permissionPicker.ts | 33 +- .../test/browser/permissionPicker.test.ts | 130 ++ .../browser/remoteAgentHostActions.ts | 4 +- .../media/newSessionActionViewItem.css | 31 + .../sessions/browser/sessions.contribution.ts | 15 +- .../sessions/browser/sessionsActions.ts | 179 ++- .../sessions/browser/views/sessionsList.ts | 13 + .../browser/views/sessionsViewActions.ts | 2 +- .../test/browser/sessionsList.test.ts | 63 +- .../test/browser/sessionsListTestUtils.ts | 4 + .../test/browser/sessionsRename.test.ts | 199 ++- .../sessionsSessionManagementActions.test.ts | 113 +- .../chatView/browser/chatViewFactory.ts | 6 +- .../sessions/browser/sessionsPartService.ts | 5 + .../sessions/browser/sessionsService.ts | 13 +- .../services/sessions/common/session.ts | 7 +- .../browser/sessionsManagementService.test.ts | 51 + .../test/common/sessionContextKeys.test.ts | 1 + .../test/browser/chatGroupsView.test.ts | 142 +- .../browser/permissionPickerList.fixture.ts | 2 +- ...remoteHostUnavailableEmptyState.fixture.ts | 4 +- .../sessions/test/browser/workbench.test.ts | 28 + .../api/browser/mainThreadDataChannels.ts | 3 + .../browser/mainThreadDataChannels.test.ts | 3 + .../browserView.contribution.ts | 2 +- .../features/browserTabManagementFeatures.ts | 12 +- .../webContentsViewRendererFeature.ts | 14 +- .../agentHost/agentHostChatInputPicker.ts | 32 +- .../agentHost/agentHostSessionHandler.ts | 5 +- .../agentHost/agentHostSessionListStore.ts | 7 +- .../agentHost/stateToProgressAdapter.ts | 9 +- .../aiCustomizationListWidget.ts | 292 +++- .../aiCustomizationManagementEditor.ts | 485 ++++-- .../aiCustomization/customizationCardList.ts | 159 ++ .../browser/aiCustomization/mcpListWidget.ts | 471 +++++- .../media/aiCustomizationManagement.css | 234 +-- .../aiCustomization/pluginListWidget.ts | 573 +++++-- .../aiCustomization/toolsListWidget.ts | 954 +++++++----- .../attachments/implicitContextAttachment.ts | 30 +- .../chat/browser/chat.shared.contribution.ts | 40 +- .../speechToText/chatSpeechToTextService.ts | 24 +- .../chatContentParts/chatPlanReviewPart.ts | 49 +- .../chatContentParts/chatSubagentOpenChat.ts | 25 +- .../media/chatCodeBlockPill.css | 3 + .../chat/browser/widget/chatPetWidget.ts | 4 +- .../contrib/chat/browser/widget/chatWidget.ts | 16 +- .../browser/widget/input/chatInputPart.ts | 19 +- .../widget/input/chatInputPickerActionItem.ts | 21 + .../input/chatModelConfigurationStore.ts | 2 +- .../input/modelPicker/media/modelPicker.css | 679 ++++++++- .../modelPicker/modelPickerActionItem.ts | 21 +- .../input/modelPicker/modelPickerAutoRow.ts | 113 ++ .../input/modelPicker/modelPickerBadges.ts | 63 + .../input/modelPicker/modelPickerCard.ts | 320 ++++ .../modelPicker/modelPickerConfiguration.ts | 76 +- .../input/modelPicker/modelPickerDetails.ts | 54 + .../input/modelPicker/modelPickerHover.ts | 63 +- .../modelPicker/modelPickerItemPrimitives.ts | 2 +- .../input/modelPicker/modelPickerItems.ts | 4 + .../input/modelPicker/modelPickerLineage.ts | 78 + .../modelPicker/modelPickerModelConfig.ts | 104 ++ .../modelPicker/modelPickerPresentation.ts | 22 + .../modelPicker/modelPickerTabbedWidget.ts | 453 ++++++ .../input/modelPicker/modelPickerTabs.ts | 309 ++++ .../input/modelPicker/modelPickerTelemetry.ts | 73 + .../input/modelPicker/modelPickerVariants.ts | 73 + .../input/modelPicker/modelPickerWelcome.ts | 39 + .../input/modelPicker/modelPickerWidget.ts | 136 +- .../input/modelPicker/modelProviderIcons.ts | 46 +- .../contrib/chat/common/constants.ts | 2 + .../contrib/chat/common/languageModels.ts | 10 +- .../tools/builtinTools/runSubagentTool.ts | 122 +- .../agentSessions/agentSessionsActions.ts | 6 +- .../agentHostChatInputPicker.test.ts | 167 ++- .../stateToProgressAdapter.test.ts | 47 +- .../aiCustomizationListWidget.test.ts | 378 ++++- .../aiCustomizationManagementEditor.test.ts | 53 + .../aiCustomization/mcpListWidget.test.ts | 8 + .../implicitContextAttachment.test.ts | 36 + .../browser/chatSpeechToTextService.test.ts | 12 +- .../chatPlanReviewPart.test.ts | 100 +- .../chatSubagentContentPart.test.ts | 82 +- .../test/browser/widget/chatPetWidget.test.ts | 2 +- .../input/chatInputPickerActionItem.test.ts | 68 + .../modelPicker/modelPickerAutoRow.test.ts | 110 ++ .../modelPickerConfiguration.test.ts | 2 +- .../input/modelPicker/modelPickerTabs.test.ts | 660 +++++++++ .../sessionTargetPickerActionItem.test.ts | 5 +- .../editor/chatEditorInput.test.ts | 14 +- .../chat/test/common/constants.test.ts | 4 +- .../builtinTools/runSubagentTool.test.ts | 818 +++++++--- .../agentSessionsActions.test.ts | 66 + .../browser/extensions.contribution.ts | 21 + .../githubLinkPresentation.contribution.ts | 83 +- .../browser/githubLinkPresentation.test.ts | 123 +- .../browser/multiDiffEditor.ts | 11 +- .../browser/diff/notebookMultiDiffEditor.ts | 3 +- .../terminalChatAgentToolsConfiguration.ts | 2 +- .../browser/terminalSandboxService.test.ts | 3 +- .../test/common/sandboxSettingsReader.test.ts | 14 + ...erminalChatAgentToolsConfiguration.test.ts | 17 +- .../accounts/browser/defaultAccount.ts | 2 +- .../accounts/browser/managedSettings.ts | 11 +- .../test/browser/managedSettings.test.ts | 12 +- .../electron-browser/agentHostService.test.ts | 1 + .../assignment/common/assignmentService.ts | 20 +- .../test/common/assignmentService.test.ts | 18 +- .../browser/configurationService.ts | 16 +- .../test/browser/configurationService.test.ts | 182 ++- .../electron-browser/environmentService.ts | 7 +- .../services/github/browser/githubService.ts | 25 +- .../github/test/browser/githubService.test.ts | 55 + .../policies/common/accountPolicyService.ts | 30 +- .../test/browser/accountPolicyService.test.ts | 107 +- .../chat/tabbedModelPicker.fixture.ts | 503 +++++++ .../editor/multiDiffEditorFixtureUtils.ts | 3 +- ...aiCustomizationManagementEditor.fixture.ts | 10 +- .../sessions/sessionsList.fixture.ts | 78 +- .../blocks-ci-screenshots.md | 102 +- test/smoke/src/areas/chat/chatSandbox.test.ts | 58 +- 324 files changed, 19602 insertions(+), 2792 deletions(-) create mode 100644 src/vs/base/browser/ui/toggle/switch.css create mode 100644 src/vs/base/browser/ui/toggle/switch.ts create mode 100644 src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorOptions.ts create mode 100644 src/vs/platform/agentHost/common/meta/agentCheckoutOperationMeta.ts create mode 100644 src/vs/platform/agentHost/node/agentHostCheckoutOperationHandler.ts create mode 100644 src/vs/platform/agentHost/node/agentHostCheckoutOperationProvider.ts create mode 100644 src/vs/platform/agentHost/node/agentHostTurnService.ts create mode 100644 src/vs/platform/agentHost/node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionContribution.ts create mode 100644 src/vs/platform/agentHost/node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionService.ts create mode 100644 src/vs/platform/agentHost/test/node/agentHostCheckoutOperationHandler.test.ts create mode 100644 src/vs/platform/agentHost/test/node/agentHostCheckoutOperationProvider.test.ts create mode 100644 src/vs/platform/agentHost/test/node/agentHostTurnService.test.ts create mode 100644 src/vs/platform/agentHost/test/node/sessionWorkspaceConversion.test.ts delete mode 100644 src/vs/sessions/contrib/changes/browser/media/multiFileDiffEditor.css create mode 100644 src/vs/sessions/contrib/chat/browser/agentsWindowOpenIntent.ts create mode 100644 src/vs/sessions/contrib/chat/test/browser/agentsWindowOpenIntent.test.ts create mode 100644 src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerAutoRow.ts create mode 100644 src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerBadges.ts create mode 100644 src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerCard.ts create mode 100644 src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerDetails.ts create mode 100644 src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerLineage.ts create mode 100644 src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerModelConfig.ts create mode 100644 src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerTabbedWidget.ts create mode 100644 src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerTabs.ts create mode 100644 src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerTelemetry.ts create mode 100644 src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerVariants.ts create mode 100644 src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerWelcome.ts create mode 100644 src/vs/workbench/contrib/chat/test/browser/attachments/implicitContextAttachment.test.ts create mode 100644 src/vs/workbench/contrib/chat/test/browser/widget/input/chatInputPickerActionItem.test.ts create mode 100644 src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelPickerAutoRow.test.ts create mode 100644 src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelPickerTabs.test.ts create mode 100644 src/vs/workbench/contrib/chat/test/electron-browser/agentSessionsActions.test.ts create mode 100644 src/vs/workbench/services/github/test/browser/githubService.test.ts create mode 100644 src/vs/workbench/test/browser/componentFixtures/chat/tabbedModelPicker.fixture.ts diff --git a/.agents/skills/launch/scripts/launch.ps1 b/.agents/skills/launch/scripts/launch.ps1 index 2ee956c7139c7..3ed2b72c30e23 100644 --- a/.agents/skills/launch/scripts/launch.ps1 +++ b/.agents/skills/launch/scripts/launch.ps1 @@ -506,7 +506,8 @@ try { if ($agents) { $launchArgs.Add('--agents') if (-not [string]::IsNullOrWhiteSpace($sessionTitle)) { - $launchArgs.Add("--session-title=$sessionTitle") + $sessionTitleBase64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($sessionTitle)).TrimEnd('=').Replace('+', '-').Replace('/', '_') + $launchArgs.Add("--session-title-base64=$sessionTitleBase64") } } $launchArgs.Add("--user-data-dir=$destinationUdd") diff --git a/.agents/skills/launch/scripts/launch.sh b/.agents/skills/launch/scripts/launch.sh index ad7a1f4be96f2..8e6ce2f4aa832 100755 --- a/.agents/skills/launch/scripts/launch.sh +++ b/.agents/skills/launch/scripts/launch.sh @@ -221,7 +221,8 @@ fi if [[ "$AGENTS" == "1" ]]; then ARGS=("--agents" "${ARGS[@]}") if [[ -n "$SESSION_TITLE" ]]; then - ARGS+=("--session-title=$SESSION_TITLE") + SESSION_TITLE_BASE64=$(node -e 'process.stdout.write(Buffer.from(process.argv[1], "utf8").toString("base64url"))' -- "$SESSION_TITLE") + ARGS+=("--session-title-base64=$SESSION_TITLE_BASE64") fi fi if (( ${#EXTRA_ARGS[@]} )); then diff --git a/build/lib/policies/policyData.jsonc b/build/lib/policies/policyData.jsonc index a61a6525255bb..7f691798a66fa 100644 --- a/build/lib/policies/policyData.jsonc +++ b/build/lib/policies/policyData.jsonc @@ -140,7 +140,7 @@ } }, "type": "boolean", - "default": false, + "default": true, "included": true }, { @@ -220,6 +220,21 @@ "default": false, "included": true }, + { + "key": "chat.agentHost.customTerminalTool.enabled", + "name": "ChatAgentHostCustomTerminalTool", + "category": "InteractiveSession", + "minimumVersion": "1.137", + "localization": { + "description": { + "key": "chat.agentHost.customTerminalTool.enabled.policy", + "value": "Enable the Agent Host custom terminal tool override for Copilot SDK sessions." + } + }, + "type": "boolean", + "default": false, + "included": true + }, { "key": "chat.agentHost.otel.captureContent", "name": "CopilotOtelCaptureContent", @@ -852,6 +867,35 @@ "default": 12, "included": true }, + { + "key": "extensions.gallery.authProvider", + "name": "ExtensionGalleryAuthProvider", + "category": "Extensions", + "minimumVersion": "1.137", + "localization": { + "description": { + "key": "extensions.gallery.authProvider", + "value": "Configure the authentication provider for the Extensions Marketplace" + }, + "enumDescriptions": [ + { + "key": "extensions.gallery.authProvider.github", + "value": "Authenticate to the Extensions Marketplace using GitHub." + }, + { + "key": "extensions.gallery.authProvider.microsoft", + "value": "Authenticate to the Extensions Marketplace using a Microsoft (Entra ID) account." + } + ] + }, + "type": "string", + "default": "github", + "enum": [ + "github", + "microsoft" + ], + "included": false + }, { "key": "extensions.gallery.serviceUrl", "name": "ExtensionGalleryServiceUrl", diff --git a/extensions/bat/language-configuration.json b/extensions/bat/language-configuration.json index 1a09da07381c3..1bf5de59ce72c 100644 --- a/extensions/bat/language-configuration.json +++ b/extensions/bat/language-configuration.json @@ -1,6 +1,6 @@ { "comments": { - "lineComment": "@REM" + "lineComment": "@REM " }, "brackets": [ ["{", "}"], diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index 529db684406e1..ef1c3d8c91d01 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -4144,6 +4144,22 @@ "onExp" ] }, + "github.copilot.chat.claudeOpusDefaultReasoningEffort": { + "type": "string", + "default": "", + "enum": [ + "", + "low", + "medium", + "high", + "max" + ], + "markdownDescription": "%github.copilot.config.claudeOpusDefaultReasoningEffort%", + "tags": [ + "experimental", + "onExp" + ] + }, "github.copilot.chat.gpt55ReadFileTool.enabled": { "type": "boolean", "default": true, diff --git a/extensions/copilot/package.nls.json b/extensions/copilot/package.nls.json index fa3928b65a197..2c49c81c48781 100644 --- a/extensions/copilot/package.nls.json +++ b/extensions/copilot/package.nls.json @@ -362,6 +362,7 @@ "github.copilot.config.gpt56Verbosity.enabled": "Sets the response verbosity to low for gpt-5.6 models.", "github.copilot.config.gemini3GetChangedFilesTool.enabled": "Enables the Get Changed Files tool for gemini-3 models.", "github.copilot.config.gemini3LowReasoningEffort.enabled": "Sets the reasoning effort to low for gemini-3 models.", + "github.copilot.config.claudeOpusDefaultReasoningEffort": "Overrides the default thinking effort shown in the model picker for Claude Opus models. Leave empty to use the built-in default. Ignored if the model does not support the chosen level.", "github.copilot.config.gpt55ReadFileTool.enabled": "Enables the Read File tool for gpt-5.5 models.", "github.copilot.config.anthropic.tools.websearch.enabled": "Enable Anthropic's native web search tool for BYOK Claude models. When enabled, allows Claude to search the web for current information. \n\n**Note**: This is an experimental feature only available for BYOK Anthropic Claude models.", "github.copilot.config.anthropic.tools.websearch.maxUses": "Maximum number of web searches allowed per request. Valid range is 1 to 20. Prevents excessive API calls within a single interaction. If Claude exceeds this limit, the response returns an error.", diff --git a/extensions/copilot/src/extension/conversation/common/languageModelAccess.ts b/extensions/copilot/src/extension/conversation/common/languageModelAccess.ts index 1c55c18653e4c..973d2e331d410 100644 --- a/extensions/copilot/src/extension/conversation/common/languageModelAccess.ts +++ b/extensions/copilot/src/extension/conversation/common/languageModelAccess.ts @@ -70,16 +70,17 @@ export function getReasoningEffortLabel(level: string): string { * Builds the `reasoningEffort` property descriptor for a model's * {@link LanguageModelConfigurationSchema}. Centralises the default-selection * and localized descriptions so the picker stays consistent across the - * Copilot and BYOK code paths. + * Copilot and BYOK code paths. `defaultOverride` wins over the family default + * when it is one of the advertised levels. */ -export function buildReasoningEffortSchemaProperty(effortLevels: readonly string[], family: string): NonNullable[string] { +export function buildReasoningEffortSchemaProperty(effortLevels: readonly string[], family: string, defaultOverride?: string): NonNullable[string] { return { type: 'string', title: l10n.t('Thinking Effort'), enum: effortLevels, enumItemLabels: effortLevels.map(getReasoningEffortLabel), enumDescriptions: effortLevels.map(getReasoningEffortDescription), - default: pickDefaultReasoningEffort(effortLevels, family), + default: defaultOverride && effortLevels.includes(defaultOverride) ? defaultOverride : pickDefaultReasoningEffort(effortLevels, family), group: 'navigation', }; } diff --git a/extensions/copilot/src/extension/conversation/vscode-node/chatParticipants.ts b/extensions/copilot/src/extension/conversation/vscode-node/chatParticipants.ts index 36228f7fa0846..1b90e0ccbecb5 100644 --- a/extensions/copilot/src/extension/conversation/vscode-node/chatParticipants.ts +++ b/extensions/copilot/src/extension/conversation/vscode-node/chatParticipants.ts @@ -298,9 +298,12 @@ Learn more about [GitHub Copilot](https://docs.github.com/copilot/using-github-c if (!baseLmModel) { return request; } - await vscode.commands.executeCommand('workbench.action.chat.changeModel', { vendor: baseLmModel.vendor, id: baseLmModel.id, family: baseLmModel.family }); - // Switch to the base model and show a warning request = { ...request, model: baseLmModel }; + if (request.subAgentInvocationId === undefined) { + // A subagent runs inside the main request; changing the picker there would flip every widget mid-turn. + await vscode.commands.executeCommand('workbench.action.chat.changeModel', { vendor: baseLmModel.vendor, id: baseLmModel.id, family: baseLmModel.family }); + } + // Switch to the base model and show a warning let messageString: vscode.MarkdownString; if (this.authenticationService.copilotToken?.isIndividual) { messageString = new vscode.MarkdownString(vscode.l10n.t({ @@ -322,8 +325,10 @@ Learn more about [GitHub Copilot](https://docs.github.com/copilot/using-github-c if (!autoModel) { return request; } - await vscode.commands.executeCommand('workbench.action.chat.changeModel', { vendor: autoModel.vendor, id: autoModel.id, family: autoModel.family }); request = { ...request, model: autoModel }; + if (request.subAgentInvocationId === undefined) { + await vscode.commands.executeCommand('workbench.action.chat.changeModel', { vendor: autoModel.vendor, id: autoModel.id, family: autoModel.family }); + } if (alwaysSwitchToAuto) { await vscode.workspace.getConfiguration('github.copilot').update('chat.rateLimitAutoSwitchToAuto', true, vscode.ConfigurationTarget.Global); } diff --git a/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts b/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts index fb90f002fb8a3..3427a4644e37a 100644 --- a/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts +++ b/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts @@ -10,6 +10,7 @@ import { IAuthenticationService } from '../../../platform/authentication/common/ import { CopilotToken } from '../../../platform/authentication/common/copilotToken'; import { IBlockedExtensionService } from '../../../platform/chat/common/blockedExtensionService'; import { ChatFetchResponseType, ChatLocation, getErrorDetailsFromChatFetchError } from '../../../platform/chat/common/commonTypes'; +import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService'; import { getTextPart } from '../../../platform/chat/common/globalStringUtils'; import { EmbeddingType, getWellKnownEmbeddingTypeInfo, IEmbeddingsComputer } from '../../../platform/embeddings/common/embeddingsComputer'; import { AUTO_MODE_TIER_PROPERTY, defaultAutoModeTier, selectableAutoModeTiers } from '../../../platform/endpoint/common/autoModeTiers'; @@ -119,7 +120,7 @@ function buildAutoRoutingContext( // Auto model delegates to different backends, so the only picker it exposes is // the routing tier; per-model options belong to the model it routes to. -function buildConfigurationSchema(endpoint: IChatEndpoint, autoTiersEnabled: boolean): { configurationSchema?: vscode.LanguageModelConfigurationSchema } { +function buildConfigurationSchema(endpoint: IChatEndpoint, autoTiersEnabled: boolean, opusDefaultEffort: string | undefined): { configurationSchema?: vscode.LanguageModelConfigurationSchema } { if (endpoint instanceof AutoChatEndpoint) { return autoTiersEnabled ? { configurationSchema: { properties: { [AUTO_MODE_TIER_PROPERTY]: buildAutoModeTierSchemaProperty(selectableAutoModeTiers, defaultAutoModeTier) } } } @@ -131,7 +132,9 @@ function buildConfigurationSchema(endpoint: IChatEndpoint, autoTiersEnabled: boo // Reasoning effort config const effortLevels = endpoint.supportsReasoningEffort; if (effortLevels && effortLevels.length > 1) { - properties.reasoningEffort = buildReasoningEffortSchemaProperty(effortLevels, endpoint.family.toLowerCase()); + const family = endpoint.family.toLowerCase(); + const defaultOverride = family.includes('opus') ? opusDefaultEffort : undefined; + properties.reasoningEffort = buildReasoningEffortSchemaProperty(effortLevels, family, defaultOverride); } // Context size config @@ -158,6 +161,7 @@ function buildConfigurationSchema(endpoint: IChatEndpoint, autoTiersEnabled: boo const DICTATION_CLEANUP_NANO_ALIAS = 'copilot-dictation-cleanup-nano'; const DICTATION_CLEANUP_LUNA_ALIAS = 'copilot-dictation-cleanup-luna'; +const DICTATION_CLEANUP_LUNA_MODEL_ID = 'gpt-5.6-luna'; const dictationCleanupAliases: ReadonlySet = new Set([DICTATION_CLEANUP_NANO_ALIAS, DICTATION_CLEANUP_LUNA_ALIAS]); const utilityAliasFamilies: readonly ChatEndpointFamily[] = ['copilot-utility-small', 'copilot-utility', DICTATION_CLEANUP_NANO_ALIAS, DICTATION_CLEANUP_LUNA_ALIAS]; @@ -175,7 +179,7 @@ const utilityAliasFamilies: readonly ChatEndpointFamily[] = ['copilot-utility-sm * normal copilot model entry. */ export function buildUtilityAliasModelInfo( - family: ChatEndpointFamily, + family: string, endpoint: IChatEndpoint, models: readonly vscode.LanguageModelChatInformation[], baseCount: number, @@ -245,6 +249,7 @@ export class LanguageModelAccess extends Disposable implements IExtensionContrib @IVSCodeExtensionContext private readonly _vsCodeExtensionContext: IVSCodeExtensionContext, @IAutomodeService private readonly _automodeService: IAutomodeService, @IExperimentationService private readonly _expService: IExperimentationService, + @IConfigurationService private readonly _configurationService: IConfigurationService, ) { super(); @@ -298,6 +303,11 @@ export class LanguageModelAccess extends Disposable implements IExtensionContrib // honored while routing goes through `POST /auto`. this._onDidChange.fire(); })); + this._register(this._configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(ConfigKey.ClaudeOpusDefaultReasoningEffort.fullyQualifiedId)) { + this._onDidChange.fire(); + } + })); void this._refreshUtilityOverrides().catch(err => { this._logService.warn(`[LanguageModelAccess] Failed to pre-resolve internal model aliases: ${err}`); }); @@ -331,6 +341,7 @@ export class LanguageModelAccess extends Disposable implements IExtensionContrib const seenFamilies = new Set(); const autoTiersEnabled = this._automodeService.areAutoModeTiersSupported(); + const opusDefaultEffort = this._configurationService.getExperimentBasedConfig(ConfigKey.ClaudeOpusDefaultReasoningEffort, this._expService) || undefined; for (const endpoint of chatEndpoints) { if (seenFamilies.has(endpoint.family) && !endpoint.showInModelPicker) { @@ -409,7 +420,7 @@ export class LanguageModelAccess extends Disposable implements IExtensionContrib imageInput: endpoint instanceof AutoChatEndpoint ? true : endpoint.supportsVision, toolCalling: endpoint.supportsToolCalls, }, - ...buildConfigurationSchema(endpoint, autoTiersEnabled), + ...buildConfigurationSchema(endpoint, autoTiersEnabled, opusDefaultEffort), }; models.push(model); @@ -453,6 +464,14 @@ export class LanguageModelAccess extends Disposable implements IExtensionContrib } } + const luna = this._resolvedUtilityEndpoints.get(DICTATION_CLEANUP_LUNA_ALIAS); + if (luna && !models.some(model => model.id === DICTATION_CLEANUP_LUNA_MODEL_ID)) { + this._utilityAliasEndpoints.set(DICTATION_CLEANUP_LUNA_MODEL_ID, luna.endpoint); + const modelInfo = buildUtilityAliasModelInfo(DICTATION_CLEANUP_LUNA_MODEL_ID, luna.endpoint, models, luna.baseCount, requiresAuthorization); + this._logService.trace(`[LanguageModelAccess] Publishing core-only model '${DICTATION_CLEANUP_LUNA_MODEL_ID}' -> ${luna.endpoint.model}.`); + models.push(modelInfo.info); + } + // Resolution may hang (override lookups, base-count tokenization), so keep it off // the model-info request path. Newly resolved endpoints are published on the next // request once `_refreshUtilityOverrides` fires `_onDidChange`. @@ -546,7 +565,8 @@ export class LanguageModelAccess extends Disposable implements IExtensionContrib progress: vscode.Progress, token: vscode.CancellationToken ): Promise { - if (dictationCleanupAliases.has(model.id) && options.requestInitiator !== 'core') { + const isCoreOnlyModel = dictationCleanupAliases.has(model.id) || (model.id === DICTATION_CLEANUP_LUNA_MODEL_ID && this._utilityAliasEndpoints.has(model.id)); + if (isCoreOnlyModel && options.requestInitiator !== 'core') { throw new Error(`Model ${model.id} is only available to VS Code core.`); } let endpoint = await this._getEndpointForModel(model, buildAutoRoutingContext(messages, options)); diff --git a/extensions/copilot/src/extension/conversation/vscode-node/test/languageModelAccess.test.ts b/extensions/copilot/src/extension/conversation/vscode-node/test/languageModelAccess.test.ts index d658ae9b73f37..6f2acea117640 100644 --- a/extensions/copilot/src/extension/conversation/vscode-node/test/languageModelAccess.test.ts +++ b/extensions/copilot/src/extension/conversation/vscode-node/test/languageModelAccess.test.ts @@ -437,7 +437,7 @@ suite('LanguageModelAccess model info', () => { } }); - test('publishes core-only aliases for dictation cleanup without publishing hidden models directly', async () => { + test('publishes core-only dictation cleanup models without exposing them in the picker', async () => { const makeHiddenEndpoint = (model: string): IChatEndpoint => ({ model, name: model, @@ -510,13 +510,15 @@ suite('LanguageModelAccess model info', () => { assert.ok(modelInfo, 'provideLanguageModelChatInfo did not resolve'); const nanoAlias = modelInfo.find(m => m.id === 'copilot-dictation-cleanup-nano'); const lunaAlias = modelInfo.find(m => m.id === 'copilot-dictation-cleanup-luna'); + const lunaModel = modelInfo.find(m => m.id === 'gpt-5.6-luna'); assert.deepStrictEqual({ nanoAliasPublished: Boolean(nanoAlias), nanoAliasUserSelectable: nanoAlias?.isUserSelectable, lunaAliasPublished: Boolean(lunaAlias), lunaAliasUserSelectable: lunaAlias?.isUserSelectable, nanoPublishedDirectly: modelInfo.some(m => m.id === 'gpt-5.4-nano'), - lunaPublishedDirectly: modelInfo.some(m => m.id === 'gpt-5.6-luna'), + lunaPublishedDirectly: Boolean(lunaModel), + lunaDirectlyUserSelectable: lunaModel?.isUserSelectable, otherPublished: modelInfo.some(m => m.id === 'some-hidden-model'), }, { nanoAliasPublished: true, @@ -524,13 +526,14 @@ suite('LanguageModelAccess model info', () => { lunaAliasPublished: true, lunaAliasUserSelectable: false, nanoPublishedDirectly: false, - lunaPublishedDirectly: false, + lunaPublishedDirectly: true, + lunaDirectlyUserSelectable: false, otherPublished: false, }); - for (const alias of [nanoAlias!, lunaAlias!]) { + for (const model of [nanoAlias!, lunaAlias!, lunaModel!]) { await assert.rejects( testAccess._provideLanguageModelChatResponse( - alias, + model, [], { requestInitiator: 'publisher.extension' } as vscode.ProvideLanguageModelChatResponseOptions, { report: () => { } }, @@ -688,6 +691,12 @@ suite('reasoning effort schema', () => { assert.deepStrictEqual(prop.enum, ['low', 'high']); assert.strictEqual(prop.group, 'navigation'); }); + + test('buildReasoningEffortSchemaProperty honors a default override only when advertised', () => { + assert.strictEqual(buildReasoningEffortSchemaProperty(['low', 'medium', 'high'], 'claude-opus-4.5', 'medium').default, 'medium'); + assert.strictEqual(buildReasoningEffortSchemaProperty(['low', 'high'], 'claude-opus-4.5', 'medium').default, 'high'); + assert.strictEqual(buildReasoningEffortSchemaProperty(['low', 'medium', 'high'], 'claude-opus-4.5', undefined).default, 'high'); + }); }); suite('auto mode tier schema', () => { diff --git a/extensions/copilot/src/platform/configuration/common/configurationService.ts b/extensions/copilot/src/platform/configuration/common/configurationService.ts index 5176b2311e03c..4b578b629ab3d 100644 --- a/extensions/copilot/src/platform/configuration/common/configurationService.ts +++ b/extensions/copilot/src/platform/configuration/common/configurationService.ts @@ -1123,6 +1123,8 @@ export namespace ConfigKey { export const EnableGemini3GetChangedFilesTool = defineSetting('chat.gemini3GetChangedFilesTool.enabled', ConfigType.ExperimentBased, false); /** When enabled, sends `reasoning_effort: 'low'` to Gemini 3 models. */ export const EnableGemini3LowReasoningEffort = defineSetting('chat.gemini3LowReasoningEffort.enabled', ConfigType.ExperimentBased, false); + /** Default thinking effort for Claude Opus models in the model picker. Empty keeps the built-in default ('high'). */ + export const ClaudeOpusDefaultReasoningEffort = defineSetting('chat.claudeOpusDefaultReasoningEffort', ConfigType.ExperimentBased, ''); /** Enable read_file tool for GPT-5.5 models */ export const EnableGpt55ReadFileTool = defineSetting('chat.gpt55ReadFileTool.enabled', ConfigType.ExperimentBased, true); export const EnableChatImageUpload = defineSetting('chat.imageUpload.enabled', ConfigType.Simple, true); diff --git a/extensions/copilot/src/platform/telemetry/common/baseTelemetryService.ts b/extensions/copilot/src/platform/telemetry/common/baseTelemetryService.ts index e5823d0ebfbd8..22f3dde0c7c70 100644 --- a/extensions/copilot/src/platform/telemetry/common/baseTelemetryService.ts +++ b/extensions/copilot/src/platform/telemetry/common/baseTelemetryService.ts @@ -196,6 +196,12 @@ export class BaseTelemetryService implements ITelemetryService { ...Object.fromEntries(props), ...this._sharedProperties }; + // Mark the queried-feature name trusted so the telemetry cleaner does not redact the + // `/vscode/`-scoped key as a `user-file-path`. + const queriedFeature = properties['ABExp.queriedFeature']; + if (typeof queriedFeature === 'string') { + properties['ABExp.queriedFeature'] = new TelemetryTrustedValue(queriedFeature); + } this._microsoftTelemetrySender.sendInternalTelemetryEvent(eventName, properties); this._microsoftTelemetrySender.sendTelemetryEvent(eventName, properties); } diff --git a/extensions/markdown-language-features/markdown-editor-src/markdownEditor.css b/extensions/markdown-language-features/markdown-editor-src/markdownEditor.css index 3771a8cd9de0c..b5ea146cdf09e 100644 --- a/extensions/markdown-language-features/markdown-editor-src/markdownEditor.css +++ b/extensions/markdown-language-features/markdown-editor-src/markdownEditor.css @@ -20,3 +20,16 @@ body { width: 100%; overflow: scroll; } + +.md-comment-widget { + background: + linear-gradient(var(--vscode-menu-background), var(--vscode-menu-background)), + var(--vscode-editor-background); +} + +.md-comment-widget:hover, +.md-comment-widget--hover { + background: + linear-gradient(var(--vscode-list-hoverBackground), var(--vscode-list-hoverBackground)), + var(--vscode-editor-background); +} diff --git a/extensions/markdown-language-features/package-lock.json b/extensions/markdown-language-features/package-lock.json index bca87cd3d18dd..5a14bcc4bada2 100644 --- a/extensions/markdown-language-features/package-lock.json +++ b/extensions/markdown-language-features/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "@vscode/extension-telemetry": "^0.9.8", - "@vscode/markdown-editor": "^0.0.2-87", + "@vscode/markdown-editor": "^0.0.2-88", "@vscode/observables": "^0.1.1-0", "dompurify": "^3.4.10", "highlight.js": "^11.8.0", @@ -633,9 +633,9 @@ "integrity": "sha512-ukOMWnCg1tCvT7WnDfsUKQOFDQGsyR5tNgRpwmqi+5/vzU3ghdDXzvIM4IOPdSb3OeSsBNvmSL8nxIVOqi2WXA==" }, "node_modules/@vscode/markdown-editor": { - "version": "0.0.2-87", - "resolved": "https://registry.npmjs.org/@vscode/markdown-editor/-/markdown-editor-0.0.2-87.tgz", - "integrity": "sha512-c1T5c2p2btf8NGVARkDN/qGezyZ25xCL/2KXqwsQeh9NmcH4bPZi2CtaeaLmQU2EyHS7WpdYKcIdwGMrJstLEg==", + "version": "0.0.2-88", + "resolved": "https://registry.npmjs.org/@vscode/markdown-editor/-/markdown-editor-0.0.2-88.tgz", + "integrity": "sha512-vN4vtf1QRF79BKZBA3ufuluFgqeb3uASOnsNcmm2bDHxZjgeRLiji+fgTkP//I+Qg/A68lelk/8K6SMQ933bTA==", "license": "MIT", "dependencies": { "@vscode/codicons": "0.0.46-36", diff --git a/extensions/markdown-language-features/package.json b/extensions/markdown-language-features/package.json index 998abf8528692..c51f87715c44e 100644 --- a/extensions/markdown-language-features/package.json +++ b/extensions/markdown-language-features/package.json @@ -1791,7 +1791,7 @@ }, "dependencies": { "@vscode/extension-telemetry": "^0.9.8", - "@vscode/markdown-editor": "^0.0.2-87", + "@vscode/markdown-editor": "^0.0.2-88", "@vscode/observables": "^0.1.1-0", "dompurify": "^3.4.10", "highlight.js": "^11.8.0", diff --git a/package.json b/package.json index a55c68603dc80..8e44d8818f12f 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,7 @@ "tsec-compile-check": "node --max-old-space-size=8192 node_modules/tsec/bin/tsec -p src/tsconfig.tsec.json", "vscode-dts-compile-check": "tsc --project src/tsconfig.vscode-dts.json && tsc --project src/tsconfig.vscode-proposed-dts.json", "valid-layers-check": "node build/checker/layersChecker.ts && node build/checker/layersTypeCheck.ts", - "define-class-fields-check": "node build/lib/propertyInitOrderChecker.ts && tsc --project src/tsconfig.defineClassFields.json", + "define-class-fields-check": "node --max-old-space-size=8192 build/lib/propertyInitOrderChecker.ts && tsc --project src/tsconfig.defineClassFields.json", "update-distro": "node build/npm/update-distro.ts", "export-policy-data": "node build/lib/policies/exportPolicyData.ts", "web": "echo 'npm run web' is replaced by './scripts/code-server' or './scripts/code-web'", diff --git a/scripts/mock-policy-server/endpoints.ts b/scripts/mock-policy-server/endpoints.ts index 03c4bfbf94c1d..88a9048fa6f82 100644 --- a/scripts/mock-policy-server/endpoints.ts +++ b/scripts/mock-policy-server/endpoints.ts @@ -95,17 +95,6 @@ declare var MOCK_POLICY_ENDPOINTS: EndpointDef[]; } } }, - { - id: 'allow-auto-only', - label: 'Allow auto-approval only', - description: 'Blocks full allow-all bypass but still permits advisory auto-approval (LLM safety recommendations with normal prompt paths).', - status: 200, - body: { - permissions: { - disableBypassPermissionsMode: 'allow-auto-only' - } - } - }, { id: 'deny-dangerous-commands', label: 'Deny dangerous shell/file operations', diff --git a/src/vs/base/browser/domSanitize.ts b/src/vs/base/browser/domSanitize.ts index 942bf6e8315e3..027fdee5c73d1 100644 --- a/src/vs/base/browser/domSanitize.ts +++ b/src/vs/base/browser/domSanitize.ts @@ -5,6 +5,7 @@ import { Schemas } from '../common/network.js'; import { reset } from './dom.js'; +import { createTrustedTypesPolicy } from './trustedTypes.js'; // eslint-disable-next-line no-restricted-imports import dompurify, * as DomPurifyTypes from './dompurify/dompurify.js'; @@ -322,21 +323,61 @@ function doSanitizeHtml(untrusted: string, config: DomSanitizerConfig | undefine } if (outputType === 'dom') { - return dompurify.sanitize(untrusted, { - ...resolvedConfig, - RETURN_DOM_FRAGMENT: true - }); + return sanitizeSurvivingStalePolicy(untrusted, { ...resolvedConfig, RETURN_DOM_FRAGMENT: true }) as DocumentFragment; } else { - return dompurify.sanitize(untrusted, { - ...resolvedConfig, - RETURN_TRUSTED_TYPE: true - }) as unknown as TrustedHTML; // Cast from lib TrustedHTML to global TrustedHTML + return sanitizeSurvivingStalePolicy(untrusted, { ...resolvedConfig, RETURN_TRUSTED_TYPE: true }) as unknown as TrustedHTML; // Cast from lib TrustedHTML to global TrustedHTML } } finally { dompurify.removeAllHooks(); } } +/** Names a replacement policy; Trusted Types rejects a name that is already taken. */ +let stalePolicyReplacementCount = 0; + +/** The sanitizer call this module recovers around. */ +type SanitizeCall = (untrusted: string, config: DomPurifyTypes.Config) => ReturnType; + +/** + * Sanitizes HTML, replacing the sanitizer's Trusted Types policy first when the policy's + * creating realm is gone and every call would otherwise throw. The replacement is kept + * for later calls, so this recovers once rather than on every call. + * + * Exported, with `sanitize` injectable, only so a test can drive the recovery: dompurify + * caches one policy for the lifetime of the module, so once anything has sanitized, no + * later stand-in policy is ever consulted. Prefer {@link sanitizeHtml}. + */ +export function sanitizeSurvivingStalePolicy( + untrusted: string, + config: DomPurifyTypes.Config, + sanitize: SanitizeCall = (html, cfg) => dompurify.sanitize(html, cfg), +): string | DocumentFragment | TrustedHTML { + try { + return sanitize(untrusted, config); + } catch (error) { + if (!isStaleTrustedTypesPolicy(error)) { + throw error; + } + const replacement = createTrustedTypesPolicy(`domSanitize${stalePolicyReplacementCount++}`, { + createHTML: (value: string) => value, + createScriptURL: (value: string) => value, + }); + if (!replacement) { + throw error; + } + // Named through dompurify's own config rather than the global `TrustedTypePolicy`. + // The two spell the same type, but the editor build resolves `trusted-types` twice + // and the branded declarations then do not unify. + const policy = replacement as unknown as DomPurifyTypes.Config['TRUSTED_TYPES_POLICY']; + return sanitize(untrusted, { ...config, TRUSTED_TYPES_POLICY: policy }); + } +} + +/** Whether the failure is a Trusted Types policy whose realm is gone, rather than bad markup. */ +function isStaleTrustedTypesPolicy(error: unknown): boolean { + return error instanceof Error && /no longer runnable/i.test(error.message); +} + const selfClosingTags = ['area', 'base', 'br', 'col', 'command', 'embed', 'hr', 'img', 'input', 'keygen', 'link', 'meta', 'param', 'source', 'track', 'wbr']; const replaceWithPlainTextHook: DomPurifyTypes.UponSanitizeElementHook = (node, data, _config) => { diff --git a/src/vs/base/browser/ui/radio/radio.css b/src/vs/base/browser/ui/radio/radio.css index 259c157011f36..9f89d689bd75e 100644 --- a/src/vs/base/browser/ui/radio/radio.css +++ b/src/vs/base/browser/ui/radio/radio.css @@ -67,3 +67,49 @@ .monaco-custom-radio > .monaco-button:hover:not(.active) { background-color: var(--vscode-radio-inactiveHoverBackground); } + +/* optional segmented appearance - opt in via IRadioOptions.className */ + +.monaco-custom-radio.segmented { + align-items: center; + gap: var(--vscode-spacing-size20); + padding: var(--vscode-spacing-size20); + border-radius: var(--vscode-cornerRadius-circle); + background: color-mix(in srgb, var(--vscode-foreground) 10%, transparent); +} + +.monaco-custom-radio.segmented > .monaco-button { + flex: 1 1 0; + justify-content: center; + height: var(--vscode-spacing-size240); + padding: var(--vscode-spacing-sizeNone) var(--vscode-spacing-size60); + border: none; + border-radius: var(--vscode-cornerRadius-circle); + color: var(--vscode-descriptionForeground); + background: transparent; + font-size: var(--vscode-fontSize-label2); + white-space: nowrap; +} + +.monaco-custom-radio.segmented > .monaco-button:hover:not(.active) { + color: var(--vscode-foreground); + background: transparent; +} + +.monaco-custom-radio.segmented > .monaco-button.active, +.monaco-custom-radio.segmented > .monaco-button.active:hover { + color: var(--vscode-foreground); + background: color-mix(in srgb, var(--vscode-button-background) 18%, var(--vscode-menu-background)); + box-shadow: var(--vscode-shadow-sm); + font-weight: var(--vscode-fontWeight-semiBold); +} + +.monaco-custom-radio.segmented > .monaco-button:focus-visible { + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: calc(-1 * var(--vscode-strokeThickness)); +} + +.hc-black .monaco-custom-radio.segmented > .monaco-button.active, +.hc-light .monaco-custom-radio.segmented > .monaco-button.active { + border: var(--vscode-strokeThickness) solid var(--vscode-radio-activeBorder, var(--vscode-contrastActiveBorder)); +} diff --git a/src/vs/base/browser/ui/radio/radio.ts b/src/vs/base/browser/ui/radio/radio.ts index 163a5869ca4c6..8fb75fffbdbdf 100644 --- a/src/vs/base/browser/ui/radio/radio.ts +++ b/src/vs/base/browser/ui/radio/radio.ts @@ -7,7 +7,9 @@ import { Widget } from '../widget.js'; import { ThemeIcon } from '../../../common/themables.js'; import { Emitter } from '../../../common/event.js'; import './radio.css'; -import { $ } from '../../dom.js'; +import { $, addDisposableListener, EventHelper, EventType } from '../../dom.js'; +import { StandardKeyboardEvent } from '../../keyboardEvent.js'; +import { KeyCode } from '../../../common/keyCodes.js'; import { IHoverDelegate } from '../hover/hoverDelegate.js'; import { Button } from '../button/button.js'; import { DisposableMap, DisposableStore } from '../../../common/lifecycle.js'; @@ -26,6 +28,8 @@ export interface IRadioStyles { export interface IRadioOptionItem { readonly text: string; readonly tooltip?: string; + /** Accessible name. Defaults to {@link tooltip} ?? {@link text}. Set it when {@link text} is icon-only. */ + readonly ariaLabel?: string; readonly isActive?: boolean; readonly disabled?: boolean; } @@ -34,6 +38,16 @@ export interface IRadioOptions { readonly items: ReadonlyArray; readonly activeIcon?: ThemeIcon; readonly hoverDelegate?: IHoverDelegate; + /** Accessible name of the radio group. */ + readonly ariaLabel?: string; + /** Extra class added to {@link Radio.domNode}, e.g. `segmented` for the pill appearance. */ + readonly className?: string; + /** + * How arrow keys behave. `select` (default) moves focus and selects, matching the + * ARIA radiogroup pattern. `focus` only moves focus, and Enter or Space selects — + * use it when selecting has a side effect the user should be able to travel past. + */ + readonly arrowKeyBehavior?: 'select' | 'focus'; } export class Radio extends Widget { @@ -44,9 +58,11 @@ export class Radio extends Widget { readonly domNode: HTMLElement; private readonly hoverDelegate: IHoverDelegate; + private readonly arrowKeyBehavior: 'select' | 'focus'; private items: ReadonlyArray = []; private activeItem: IRadioOptionItem | undefined; + private orderedButtons: Button[] = []; private readonly buttons = this._register(new DisposableMap()); @@ -54,15 +70,23 @@ export class Radio extends Widget { super(); this.hoverDelegate = opts.hoverDelegate ?? this._register(createInstantHoverDelegate()); + this.arrowKeyBehavior = opts.arrowKeyBehavior ?? 'select'; this.domNode = $('.monaco-custom-radio'); - this.domNode.setAttribute('role', 'radio'); + if (opts.className) { + this.domNode.classList.add(opts.className); + } + this.domNode.setAttribute('role', 'radiogroup'); + if (opts.ariaLabel) { + this.domNode.setAttribute('aria-label', opts.ariaLabel); + } this.setItems(opts.items); } setItems(items: ReadonlyArray): void { this.buttons.clearAndDisposeAll(); + this.orderedButtons = []; this.items = items; this.activeItem = this.items.find(item => item.isActive) ?? this.items[0]; for (let index = 0; index < this.items.length; index++) { @@ -71,16 +95,24 @@ export class Radio extends Widget { const button = disposables.add(new Button(this.domNode, { hoverDelegate: this.hoverDelegate, title: item.tooltip, + ariaLabel: item.ariaLabel, supportIcons: true, })); + button.element.setAttribute('role', 'radio'); button.enabled = !item.disabled; - disposables.add(button.onDidClick(() => { - if (this.activeItem !== item) { - this.activeItem = item; - this.updateButtons(); - this._onDidSelect.fire(index); + // Button turns Enter and Space into a click, which is how `focus` mode selects. + disposables.add(button.onDidClick(() => this.selectItem(index))); + disposables.add(addDisposableListener(button.element, EventType.KEY_DOWN, e => { + const event = new StandardKeyboardEvent(e); + const delta = event.equals(KeyCode.RightArrow) || event.equals(KeyCode.DownArrow) ? 1 + : event.equals(KeyCode.LeftArrow) || event.equals(KeyCode.UpArrow) ? -1 : 0; + if (delta === 0) { + return; } + EventHelper.stop(e, true); + this.navigate(index, delta); })); + this.orderedButtons.push(button); this.buttons.set(button, { item, dispose: () => disposables.dispose() }); } this.updateButtons(); @@ -98,6 +130,48 @@ export class Radio extends Widget { for (const [button] of this.buttons) { button.enabled = enabled; } + this.updateButtons(); + } + + /** Moves focus to the active item, for callers that rebuild the row after a selection. */ + focusActiveItem(): void { + const index = this.activeItem ? this.items.indexOf(this.activeItem) : -1; + if (index !== -1) { + this.orderedButtons[index]?.focus(); + } + } + + private selectItem(index: number): void { + const item = this.items[index]; + if (!item || this.activeItem === item) { + return; + } + this.activeItem = item; + this.updateButtons(); + this._onDidSelect.fire(index); + } + + /** Moves to the next enabled item in `delta` direction, wrapping around the ends. */ + private navigate(from: number, delta: number): void { + const count = this.items.length; + for (let offset = 1; offset <= count; offset++) { + const index = (((from + delta * offset) % count) + count) % count; + if (this.items[index].disabled) { + continue; + } + if (this.arrowKeyBehavior === 'select') { + this.selectItem(index); + } + this.focusItem(index); + return; + } + } + + private focusItem(index: number): void { + for (let candidate = 0; candidate < this.orderedButtons.length; candidate++) { + this.orderedButtons[candidate].element.tabIndex = candidate === index ? 0 : -1; + } + this.orderedButtons[index]?.focus(); } private updateButtons(): void { @@ -107,6 +181,8 @@ export class Radio extends Widget { isActive = item === this.activeItem; button.element.classList.toggle('active', isActive); button.element.classList.toggle('previous-active', isPreviousActive); + button.element.setAttribute('aria-checked', String(isActive)); + button.element.tabIndex = isActive ? 0 : -1; button.label = item.text; } } diff --git a/src/vs/base/browser/ui/toggle/switch.css b/src/vs/base/browser/ui/toggle/switch.css new file mode 100644 index 0000000000000..9dea7703a3cd4 --- /dev/null +++ b/src/vs/base/browser/ui/toggle/switch.css @@ -0,0 +1,60 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.monaco-switch { + position: relative; + flex: 0 0 auto; + width: var(--vscode-spacing-size280); + height: var(--vscode-spacing-size160); + padding: 0; + border: var(--vscode-strokeThickness) solid transparent; + border-radius: var(--vscode-cornerRadius-circle); + background: color-mix(in srgb, var(--vscode-descriptionForeground) 36%, transparent); + cursor: pointer; + transition: background-color 120ms ease, border-color 120ms ease; +} + +.monaco-switch.checked { + background: var(--vscode-button-background); + border-color: var(--vscode-button-background); +} + +.monaco-switch:disabled { + cursor: default; + opacity: 0.5; +} + +.monaco-switch:focus-visible { + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: var(--vscode-spacing-size20); +} + +.monaco-switch-thumb { + position: absolute; + top: var(--vscode-strokeThickness); + left: var(--vscode-strokeThickness); + width: var(--vscode-spacing-size120); + height: var(--vscode-spacing-size120); + border-radius: var(--vscode-cornerRadius-circle); + background: var(--vscode-button-foreground); + transition: transform 120ms ease; +} + +.monaco-switch.checked .monaco-switch-thumb { + transform: translateX(var(--vscode-spacing-size120)); +} + +/* The workbench marks high contrast with `.hc-black` / `.hc-light`; `.vscode-high-contrast` + * is the webview equivalent, and the switch is rendered in both. */ +.hc-black .monaco-switch, +.hc-light .monaco-switch, +.vscode-high-contrast .monaco-switch { + border-color: var(--vscode-contrastBorder); +} + +.monaco-reduce-motion .monaco-switch, +.monaco-reduce-motion .monaco-switch-thumb { + transition: none; +} diff --git a/src/vs/base/browser/ui/toggle/switch.ts b/src/vs/base/browser/ui/toggle/switch.ts new file mode 100644 index 0000000000000..88302c71ab739 --- /dev/null +++ b/src/vs/base/browser/ui/toggle/switch.ts @@ -0,0 +1,99 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../dom.js'; +import { Emitter, Event } from '../../../common/event.js'; +import { Disposable } from '../../../common/lifecycle.js'; +import { HoverStyle } from '../hover/hover.js'; +import { getBaseLayerHoverDelegate } from '../hover/hoverDelegate2.js'; +import './switch.css'; + +export interface ISwitchOptions { + readonly checked?: boolean; + /** Accessible name. Also used as the hover title unless {@link title} is given. */ + readonly ariaLabel: string; + /** Hover title. Defaults to {@link ariaLabel}. */ + readonly title?: string; + readonly disabled?: boolean; +} + +/** + * A pill switch for a setting that takes effect as soon as it is flipped, e.g. enabling + * a plugin or letting a model be chosen automatically. + * + * Use this rather than a `Checkbox` when the control commits immediately; a checkbox + * reads as part of a form that is submitted later. + */ +export class Switch extends Disposable { + + private readonly _onChange = this._register(new Emitter()); + /** Fires with the new state when the user flips the switch, not when it is set in code. */ + readonly onChange: Event = this._onChange.event; + + readonly domNode: HTMLButtonElement; + + private _checked: boolean; + private _title: string; + + constructor(options: ISwitchOptions) { + super(); + this._checked = !!options.checked; + this._title = options.title ?? options.ariaLabel; + + this.domNode = dom.$('button.monaco-switch'); + this.domNode.type = 'button'; + this.domNode.setAttribute('role', 'switch'); + dom.append(this.domNode, dom.$('.monaco-switch-thumb')); + + this._register(getBaseLayerHoverDelegate().setupDelayedHover(this.domNode, () => ({ + content: this._title, + style: HoverStyle.Pointer, + }))); + + this.setAriaLabel(options.ariaLabel, options.title); + this.disabled = !!options.disabled; + this._applyState(); + + this._register(dom.addDisposableListener(this.domNode, dom.EventType.CLICK, e => { + dom.EventHelper.stop(e, true); + if (this.domNode.disabled) { + return; + } + this._checked = !this._checked; + this._applyState(); + this._onChange.fire(this._checked); + })); + } + + get checked(): boolean { + return this._checked; + } + + /** Sets the state without firing {@link onChange}. */ + set checked(checked: boolean) { + if (this._checked !== checked) { + this._checked = checked; + this._applyState(); + } + } + + get disabled(): boolean { + return this.domNode.disabled; + } + + set disabled(disabled: boolean) { + this.domNode.disabled = disabled; + } + + setAriaLabel(ariaLabel: string, title = ariaLabel): void { + this.domNode.setAttribute('aria-label', ariaLabel); + this._title = title; + } + + private _applyState(): void { + this.domNode.setAttribute('aria-checked', String(this._checked)); + this.domNode.classList.toggle('checked', this._checked); + } +} diff --git a/src/vs/base/common/defaultAccount.ts b/src/vs/base/common/defaultAccount.ts index bd01fbb300543..17dcc8bf9a14c 100644 --- a/src/vs/base/common/defaultAccount.ts +++ b/src/vs/base/common/defaultAccount.ts @@ -76,8 +76,8 @@ export interface IPolicyData { * the user is governed by GitHub Copilot managed settings at all, independent of which keys * were set. * - * Unlike {@link managedSettings}, this is not projected onto the keys VS Code declares, so it - * also reflects runtime-owned keys VS Code never reads. + * Unlike {@link managedSettings}, this also reflects structured runtime-owned keys that the + * policy projection does not retain. */ readonly managedSettingsActive?: boolean; } diff --git a/src/vs/base/test/browser/domSanitize.test.ts b/src/vs/base/test/browser/domSanitize.test.ts index 0bcf386848f1e..eb234ecd525a6 100644 --- a/src/vs/base/test/browser/domSanitize.test.ts +++ b/src/vs/base/test/browser/domSanitize.test.ts @@ -4,10 +4,13 @@ *--------------------------------------------------------------------------------------------*/ import * as assert from 'assert'; -import { sanitizeHtml } from '../../browser/domSanitize.js'; +import { sanitizeHtml, sanitizeSurvivingStalePolicy } from '../../browser/domSanitize.js'; import { Schemas } from '../../common/network.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../common/utils.js'; +/** Derived from the function under test, since dompurify may not be imported directly. */ +type SanitizeConfig = Parameters[1]; + suite('DomSanitize', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -257,4 +260,52 @@ suite('DomSanitize', () => { assert.strictEqual(result.toString(), '
allowed<forbidden>not allowed</forbidden>
'); }); }); + + suite('stale trusted types policy', () => { + + const STALE_POLICY_ERROR = `Failed to execute 'createHTML' on 'TrustedTypePolicy': The provided callback is no longer runnable.`; + + /** + * Stands in for dompurify, failing the first call the way a policy whose creating + * realm is gone fails. The sanitizer keeps one policy for the lifetime of the + * module, so a stand-in policy installed after anything has sanitized is never + * consulted; driving the call itself is what makes this independent of test order. + */ + function failingOnce(message: string) { + const configs: (SanitizeConfig | undefined)[] = []; + let failed = false; + const sanitize = (untrusted: string, config: SanitizeConfig) => { + configs.push(config); + if (!failed) { + failed = true; + throw new Error(message); + } + return `sanitized:${untrusted}`; + }; + return { sanitize, configs }; + } + + test('retries with a replacement policy when the sanitizer policy stops working', () => { + const { sanitize, configs } = failingOnce(STALE_POLICY_ERROR); + + const result = sanitizeSurvivingStalePolicy('
safe
', {}, sanitize); + + assert.deepStrictEqual( + { + result, + attempts: configs.length, + firstHadPolicy: configs[0]?.TRUSTED_TYPES_POLICY !== undefined, + retriedWithPolicy: typeof configs[1]?.TRUSTED_TYPES_POLICY?.createHTML === 'function', + }, + { result: 'sanitized:
safe
', attempts: 2, firstHadPolicy: false, retriedWithPolicy: true }, + ); + }); + + test('a failure that is not a stale policy is not retried', () => { + const { sanitize, configs } = failingOnce('Some other sanitizer failure'); + + assert.throws(() => sanitizeSurvivingStalePolicy('
safe
', {}, sanitize), /Some other sanitizer failure/); + assert.strictEqual(configs.length, 1); + }); + }); }); diff --git a/src/vs/editor/browser/widget/diffEditor/features/hideUnchangedRegionsFeature.ts b/src/vs/editor/browser/widget/diffEditor/features/hideUnchangedRegionsFeature.ts index f5a7a9b95fb57..cb8eca88df45d 100644 --- a/src/vs/editor/browser/widget/diffEditor/features/hideUnchangedRegionsFeature.ts +++ b/src/vs/editor/browser/widget/diffEditor/features/hideUnchangedRegionsFeature.ts @@ -308,7 +308,7 @@ class CollapsedCodeOverlayWidget extends ViewZoneOverlayWidget { private readonly _nodes = h('div.diff-hidden-lines', [ h('div.top@top', { title: localize('diff.hiddenLines.top', 'Click or drag to show more above') }), h('div.center@content', { style: { display: 'flex' } }, [ - h('div@first', { style: { display: 'flex', justifyContent: 'center', alignItems: 'center', flexShrink: '0' } }, + h('div.first@first', { style: { display: 'flex', alignItems: 'center', flexShrink: '0' } }, [$('a', { title: localize('showUnchangedRegion', 'Show Unchanged Region'), role: 'button', onclick: () => { this._unchangedRegion.showAll(undefined); } }, ...renderLabelWithIcons('$(unfold)'))] ), diff --git a/src/vs/editor/browser/widget/diffEditor/style.css b/src/vs/editor/browser/widget/diffEditor/style.css index 6e27efbe75aee..7ef8d1d64ea2b 100644 --- a/src/vs/editor/browser/widget/diffEditor/style.css +++ b/src/vs/editor/browser/widget/diffEditor/style.css @@ -84,6 +84,10 @@ box-shadow: inset 0 -5px 5px -7px var(--vscode-diffEditor-unchangedRegionShadow), inset 0 5px 5px -7px var(--vscode-diffEditor-unchangedRegionShadow); } +.monaco-editor .diff-hidden-lines .first { + justify-content: center; +} + .monaco-editor .diff-hidden-lines .center span.codicon { vertical-align: middle; } diff --git a/src/vs/editor/browser/widget/multiDiffEditor/diffEditorItemTemplate.ts b/src/vs/editor/browser/widget/multiDiffEditor/diffEditorItemTemplate.ts index fb08a7df208f0..f9a5cb8df3b65 100644 --- a/src/vs/editor/browser/widget/multiDiffEditor/diffEditorItemTemplate.ts +++ b/src/vs/editor/browser/widget/multiDiffEditor/diffEditorItemTemplate.ts @@ -22,6 +22,7 @@ import { OffsetRange } from '../../../common/core/ranges/offsetRange.js'; import { observableCodeEditor } from '../../observableCodeEditor.js'; import { DiffEditorWidget } from '../diffEditor/diffEditorWidget.js'; import { DocumentDiffItemViewModel } from './multiDiffEditorViewModel.js'; +import { IMultiDiffEditorVariantConfiguration } from './multiDiffEditorOptions.js'; import { ActionRunnerWithContext } from './utils.js'; import { IVirtualizedItemBindingContext, VirtualizedItemBinding, VirtualizedItemTemplate } from './virtualizedItemManager.js'; import { IWorkbenchUIElementFactory, MultiDiffEditorItemLabelKind } from './workbenchUIElementFactory.js'; @@ -69,6 +70,7 @@ export class DiffEditorItemTemplate extends VirtualizedItemTemplate | undefined, @IInstantiationService private readonly _instantiationService: IInstantiationService, @IContextKeyService _parentContextKeyService: IContextKeyService, @@ -77,7 +79,7 @@ export class DiffEditorItemTemplate extends VirtualizedItemTemplate(this, undefined); this._collapsed = derived(this, reader => this._viewModel.read(reader)?.collapsed.read(reader)); this._editorContentHeight = observableValue(this, 500); - this._itemHorizontalInsets = this._workbenchUIElementFactory.diffEditorItemHorizontalInsets ?? { left: 9, right: 9 }; + this._itemHorizontalInsets = this._variantConfiguration.horizontalInsets; this.size = derived(this, reader => { if (this._collapsed.read(reader)) { return this._headerHeight; @@ -160,14 +162,18 @@ export class DiffEditorItemTemplate extends VirtualizedItemTemplate this._viewModel.get()?.setActive(undefined); @@ -195,7 +201,7 @@ export class DiffEditorItemTemplate extends VirtualizedItemTemplate; + readonly headerHeight: number; + readonly contentBottomPadding: number; + readonly headerClickToCollapse: boolean; +} + +export function getMultiDiffEditorVariantConfiguration(variant: MultiDiffEditorVariant): IMultiDiffEditorVariantConfiguration { + switch (variant) { + case MultiDiffEditorVariant.Standard: + return { + className: 'multiDiffEditor-standard', + horizontalInsets: { left: 9, right: 9 }, + headerHeight: 40, + contentBottomPadding: 0, + headerClickToCollapse: false, + }; + case MultiDiffEditorVariant.Compact: + return { + className: 'multiDiffEditor-compact', + horizontalInsets: { left: 0, right: 0 }, + headerHeight: 32, + contentBottomPadding: 8, + headerClickToCollapse: true, + }; + } +} diff --git a/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.ts b/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.ts index 51779dfb0d028..24f11fe4ccd1d 100644 --- a/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.ts +++ b/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.ts @@ -20,6 +20,7 @@ import { DiffEditorWidget } from '../diffEditor/diffEditorWidget.js'; import './colors.js'; import { DiffEditorItemTemplate } from './diffEditorItemTemplate.js'; import { IDocumentDiffItem, IMultiDiffEditorModel } from './model.js'; +import { getMultiDiffEditorVariantConfiguration, IMultiDiffEditorVariantConfiguration, IMultiDiffEditorWidgetOptions } from './multiDiffEditorOptions.js'; import { MultiDiffEditorViewModel } from './multiDiffEditorViewModel.js'; import { IMultiDiffEditorLayoutDebugState, IMultiDiffEditorViewState, MultiDiffEditorWidgetImpl } from './multiDiffEditorWidgetImpl.js'; import { IWorkbenchUIElementFactory } from './workbenchUIElementFactory.js'; @@ -29,29 +30,32 @@ export class MultiDiffEditorWidget extends Disposable { private readonly _viewModel = observableValue(this, undefined); private readonly _diffLayoutOptions = observableValue(this, undefined); private readonly _paddingBottomPx = observableValue(this, 0); - - private readonly _widgetImpl = derived(this, (reader) => { - readHotReloadableExport(DiffEditorItemTemplate, reader); - return reader.store.add(this._instantiationService.createInstance(( - readHotReloadableExport(MultiDiffEditorWidgetImpl, reader)), - this._element, - this._dimension, - this._viewModel, - this._workbenchUIElementFactory, - this._diffLayoutOptions, - this._diffEditorOptions, - this._paddingBottomPx, - )); - }); + private readonly _variantConfiguration: IMultiDiffEditorVariantConfiguration; + private readonly _widgetImpl: IObservable; constructor( private readonly _element: HTMLElement, private readonly _workbenchUIElementFactory: IWorkbenchUIElementFactory, - private readonly _diffEditorOptions: IDiffEditorOptions | undefined, + private readonly _options: IMultiDiffEditorWidgetOptions, @IInstantiationService private readonly _instantiationService: IInstantiationService, ) { super(); + this._variantConfiguration = getMultiDiffEditorVariantConfiguration(this._options.variant); + this._widgetImpl = derived(this, reader => { + readHotReloadableExport(DiffEditorItemTemplate, reader); + return reader.store.add(this._instantiationService.createInstance(( + readHotReloadableExport(MultiDiffEditorWidgetImpl, reader)), + this._element, + this._dimension, + this._viewModel, + this._workbenchUIElementFactory, + this._variantConfiguration, + this._diffLayoutOptions, + this._options.diffEditorOptions, + this._paddingBottomPx, + )); + }); this._register(recomputeInitiallyAndOnChange(this._widgetImpl)); } diff --git a/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.ts b/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.ts index 3da1bfe43a339..707977dd82b4f 100644 --- a/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.ts +++ b/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.ts @@ -26,6 +26,7 @@ import { ICompressedVirtualizedScrollLayout } from './compressedVirtualizedScrol import { binaryFilePlaceholderContentHeight, DiffEditorItemBinding, DiffEditorItemTemplate } from './diffEditorItemTemplate.js'; import { IDocumentDiffItem } from './model.js'; import { formatDiffItemKey, formatUri, ILoggedDiffItem, MultiDiffEditorLogger } from './multiDiffEditorLogging.js'; +import { IMultiDiffEditorVariantConfiguration } from './multiDiffEditorOptions.js'; import { DocumentDiffItemViewModel, MultiDiffEditorViewModel } from './multiDiffEditorViewModel.js'; import { RevealOptions } from './multiDiffEditorWidget.js'; import './style.css'; @@ -69,6 +70,7 @@ export class MultiDiffEditorWidgetImpl extends Disposable { private readonly _dimension: IObservable, private readonly _viewModel: IObservable, private readonly _workbenchUIElementFactory: IWorkbenchUIElementFactory, + private readonly _variantConfiguration: IMultiDiffEditorVariantConfiguration, private readonly _diffLayoutOptions: IObservable, private readonly _diffEditorOptions: IDiffEditorOptions | undefined, private readonly _paddingBottomPx: IObservable, @@ -101,13 +103,13 @@ export class MultiDiffEditorWidgetImpl extends Disposable { getId: item => item, getTemplateId: () => 'diffEditor', getUnboundSize: item => derived(item, reader => { - const headerHeight = this._workbenchUIElementFactory.diffEditorItemHeaderHeight ?? 40; + const headerHeight = this._variantConfiguration.headerHeight; if (item.collapsed.read(reader)) { return headerHeight; } if (item.isBinary) { return headerHeight - + (this._workbenchUIElementFactory.diffEditorItemContentBottomPadding ?? 0) + + this._variantConfiguration.contentBottomPadding + binaryFilePlaceholderContentHeight; } return item.lastTemplateData.read(reader).expandedContentHeight; @@ -117,6 +119,7 @@ export class MultiDiffEditorWidgetImpl extends Disposable { context.contentDomNode, context.overflowWidgetsDomNode, this._workbenchUIElementFactory, + this._variantConfiguration, this._optionsOverride, ), onDidBind: binding => { @@ -197,7 +200,7 @@ export class MultiDiffEditorWidgetImpl extends Disposable { items: items.map((item, index) => item.getLayoutDebugState(reader, layout.items[index])), }; }); - this._elements = h('div.monaco-component.multiDiffEditor', {}, [ + this._elements = h(`div.monaco-component.multiDiffEditor.${this._variantConfiguration.className}`, {}, [ this._scrollView.domNode, h('div.placeholder@placeholder', {}, [h('div')]), ]); diff --git a/src/vs/editor/browser/widget/multiDiffEditor/style.css b/src/vs/editor/browser/widget/multiDiffEditor/style.css index 138aa773b0b21..a1b9a0e4f6337 100644 --- a/src/vs/editor/browser/widget/multiDiffEditor/style.css +++ b/src/vs/editor/browser/widget/multiDiffEditor/style.css @@ -4,42 +4,34 @@ *--------------------------------------------------------------------------------------------*/ .monaco-component.multiDiffEditor { - background: var(--vscode-multiDiffEditor-background); - position: relative; - - height: 100%; width: 100%; - + height: 100%; overflow-y: hidden; + background: var(--vscode-multiDiffEditor-background); > div { position: absolute; - top: 0px; - left: 0px; - - height: 100%; + top: 0; + left: 0; width: 100%; + height: 100%; &.placeholder { + display: grid; + place-items: center; + place-content: center; visibility: hidden; &.visible { visibility: visible; } - - display: grid; - place-items: center; - place-content: center; } } > .multi-diff-root-floating-menu { position: absolute; - top: auto; - right: 28px; - bottom: 24px; - left: auto; + inset: auto var(--vscode-spacing-size280) var(--vscode-spacing-size240) auto; width: auto; } @@ -49,42 +41,24 @@ .multiDiffEntry { display: flex; - flex-direction: column; flex: 1; + flex-direction: column; overflow: hidden; - .collapse-button { - margin: 0 5px; cursor: pointer; - - a { - display: block; - } } .header { z-index: 1000; - background: var(--vscode-editor-background); - - &:not(.collapsed) .header-content { - border-bottom: 1px solid var(--vscode-sideBarSectionHeader-border); - } .header-content { - margin: 8px 0px 0px 0px; - padding: 4px 5px; - - border-top: 1px solid var(--vscode-multiDiffEditor-border); - display: flex; align-items: center; - color: var(--vscode-foreground); - background: var(--vscode-multiDiffEditor-headerBackground); &.shadow { - box-shadow: var(--vscode-scrollbar-shadow) 0px 6px 6px -6px; + box-shadow: var(--vscode-scrollbar-shadow) 0 6px 6px -6px; } .file-path { @@ -94,60 +68,46 @@ overflow: hidden; .title { - font-size: 14px; - line-height: 22px; flex: 0 1 auto; min-width: 0; overflow: hidden; + line-height: 22px; text-overflow: ellipsis; - &.original { - flex: 1 1 auto; + &.modified { + display: flex; + align-items: center; } - } - .status { - font-weight: 600; - opacity: 0.75; - margin: 0px 10px; - line-height: 22px; - - /* - TODO@hediet: move colors from git extension to core! - &.renamed { - color: v ar(--vscode-gitDecoration-renamedResourceForeground); + &.modified > .monaco-icon-label { + flex: 0 1 auto; + min-width: 0; } - &.deleted { - color: v ar(--vscode-gitDecoration-deletedResourceForeground); + &.original { + flex: 1 1 auto; } + } - &.added { - color: v ar(--vscode-gitDecoration-addedResourceForeground); - } - */ + .multi-diff-resource-label-accessory:empty { + display: none; + } - &:not(.added):not(.deleted):not(.renamed) { - display: none; - } + .status:not(.added):not(.deleted):not(.renamed) { + display: none; } } .actions { flex: 0 0 auto; - padding: 0 8px; } } - - } .editorParent { - flex: 1; display: flex; + flex: 1; flex-direction: column; - - border-bottom: 1px solid var(--vscode-multiDiffEditor-border); overflow: hidden; } @@ -174,4 +134,283 @@ } } } + + &.multiDiffEditor-standard { + .multiDiffEntry { + .collapse-button { + margin: 0 5px; + + a { + display: block; + } + } + + .header { + background: var(--vscode-editor-background); + + &:not(.collapsed) .header-content { + border-bottom: var(--vscode-strokeThickness) solid var(--vscode-sideBarSectionHeader-border); + } + + .header-content { + margin: var(--vscode-spacing-size80) 0 0; + padding: var(--vscode-spacing-size40) 5px; + border-top: var(--vscode-strokeThickness) solid var(--vscode-multiDiffEditor-border); + background: var(--vscode-multiDiffEditor-headerBackground); + + .file-path { + .title { + font-size: 14px; + } + + .status { + margin: 0 var(--vscode-spacing-size100); + font-weight: var(--vscode-fontWeight-semiBold); + line-height: 22px; + opacity: 0.75; + } + } + + .actions { + padding: 0 var(--vscode-spacing-size80); + } + } + } + + .editorParent { + border-bottom: var(--vscode-strokeThickness) solid var(--vscode-multiDiffEditor-border); + } + } + } + + &.multiDiffEditor-compact { + .multiDiffEntry { + box-sizing: border-box; + + &::after { + position: absolute; + right: var(--vscode-spacing-size100); + bottom: 0; + left: var(--vscode-spacing-size100); + z-index: 1001; + height: var(--vscode-strokeThickness); + pointer-events: none; + content: ''; + background: var(--vscode-panel-border); + } + + &.header-hovered, + &.header-focused { + z-index: 1002; + overflow: visible; + } + + &.header-hovered::after, + &.header-focused::after { + right: 0; + left: 0; + z-index: 1004; + } + + &.header-hovered::before, + &.header-focused::before { + position: absolute; + top: calc(-1 * var(--vscode-strokeThickness)); + right: 0; + left: 0; + z-index: 1004; + height: var(--vscode-strokeThickness); + pointer-events: none; + content: ''; + background: var(--vscode-panel-border); + } + + &.first-diff-entry.header-hovered::before, + &.first-diff-entry.header-focused::before { + top: 0; + } + + .collapse-button { + margin: 0 var(--vscode-spacing-size60); + + a { + display: flex; + align-items: center; + justify-content: center; + padding: var(--vscode-spacing-size40); + border-radius: var(--vscode-cornerRadius-medium); + + &:hover { + background: var(--vscode-toolbar-hoverBackground); + } + + &:active { + background: var(--vscode-toolbar-activeBackground); + } + + &:focus-visible { + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: calc(-1 * var(--vscode-strokeThickness)); + } + + &:focus:not(:focus-visible) { + outline: none; + } + } + } + + .header { + position: relative; + background: var(--vscode-multiDiffEditor-background); + + &[role='button'] { + cursor: pointer; + } + + .header-content { + box-sizing: border-box; + height: var(--vscode-spacing-size320); + margin: 0; + padding: 0; + border: none; + background: transparent; + + .file-path { + .title { + font-size: var(--vscode-fontSize-body1); + } + + .status { + display: inline-flex; + align-items: center; + justify-content: center; + width: var(--vscode-spacing-size160); + min-width: var(--vscode-spacing-size160); + margin: 0 var(--vscode-spacing-size100); + font-size: var(--vscode-fontSize-body2); + font-weight: var(--vscode-fontWeight-semiBold); + line-height: 22px; + opacity: 0.9; + + &.added { + color: var(--vscode-gitDecoration-addedResourceForeground); + } + + &.deleted { + color: var(--vscode-gitDecoration-deletedResourceForeground); + } + + &.renamed { + color: var(--vscode-gitDecoration-modifiedResourceForeground); + } + } + } + + .actions { + padding: 0 var(--vscode-spacing-size80); + + .actions-container { + gap: var(--vscode-spacing-size40); + + .action-item:not(.multi-diff-action-always-visible) { + visibility: hidden; + } + + .action-item:not(.checkbox-action-item) .action-label { + padding: var(--vscode-spacing-size40); + } + } + } + } + + &:hover .header-content { + background: var(--vscode-list-hoverBackground); + } + + &:active .header-content { + background: var(--vscode-toolbar-activeBackground); + } + + &[aria-expanded='false'] + .editorParent { + border: none; + } + + &:focus { + outline: none; + } + + &:focus-visible .header-content { + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: calc(-1 * var(--vscode-strokeThickness)); + } + } + + &:hover .header .header-content .actions .actions-container .action-item, + &:focus-within .header .header-content .actions .actions-container .action-item { + visibility: visible; + } + + .editorParent { + border: none; + } + } + + &:focus-within .multiDiffEntry.active { + z-index: 1002; + overflow: visible; + } + + &:focus-within .multiDiffEntry.active::after { + right: 0; + left: 0; + z-index: 1004; + } + + &:focus-within .multiDiffEntry.active::before { + position: absolute; + top: calc(-1 * var(--vscode-strokeThickness)); + right: 0; + left: 0; + z-index: 1004; + height: var(--vscode-strokeThickness); + pointer-events: none; + content: ''; + background: var(--vscode-panel-border); + } + + &:focus-within .multiDiffEntry.first-diff-entry.active::before { + top: 0; + } + + &:focus-within .multiDiffEntry.active .header .header-content, + &:focus-within .multiDiffEntry.active .header:hover .header-content, + &:focus-within .multiDiffEntry.active .header:active .header-content { + background: var(--vscode-list-inactiveSelectionBackground); + } + + .diff-hidden-lines .center { + overflow: hidden; + box-shadow: none; + } + + .diff-hidden-lines .first { + box-sizing: border-box; + justify-content: flex-start; + padding-left: var(--vscode-spacing-size60); + } + + .fold-unchanged { + box-sizing: border-box; + } + + .editor.original .diff-hidden-lines .center { + border-top-left-radius: var(--vscode-cornerRadius-medium); + border-bottom-left-radius: var(--vscode-cornerRadius-medium); + } + + .editor.modified .diff-hidden-lines .center { + border-top-right-radius: var(--vscode-cornerRadius-medium); + border-bottom-right-radius: var(--vscode-cornerRadius-medium); + } + } } diff --git a/src/vs/editor/browser/widget/multiDiffEditor/workbenchUIElementFactory.ts b/src/vs/editor/browser/widget/multiDiffEditor/workbenchUIElementFactory.ts index a5f61ea899d1c..ae08f66039138 100644 --- a/src/vs/editor/browser/widget/multiDiffEditor/workbenchUIElementFactory.ts +++ b/src/vs/editor/browser/widget/multiDiffEditor/workbenchUIElementFactory.ts @@ -26,22 +26,7 @@ export const enum MultiDiffEditorItemLabelKind { * This would make monaco-editor consumption much more difficult though. */ export interface IWorkbenchUIElementFactory { - createResourceLabel?(element: HTMLElement, kind: MultiDiffEditorItemLabelKind): IResourceLabel; - - /** Horizontal insets reserved around each embedded diff editor. */ - readonly diffEditorItemHorizontalInsets?: Readonly<{ left: number; right: number }>; - - /** Height of each entry's file header, in px. Defaults to 40. */ - readonly diffEditorItemHeaderHeight?: number; - - /** Padding reserved below each embedded diff editor, in px. Defaults to 0. */ - readonly diffEditorItemContentBottomPadding?: number; - - /** - * When true, the entire header area is clickable to toggle collapse/expand - * and receives keyboard activation (Enter/Space) and ARIA button semantics. - */ - readonly headerClickToCollapse?: boolean; + createResourceLabel?(element: HTMLElement, kind: MultiDiffEditorItemLabelKind, accessoryContainer: HTMLElement): IResourceLabel; /** Handles a middle-click on an entry header. Returns whether the event was handled. */ handleHeaderMiddleClick?(resource: URI): boolean; diff --git a/src/vs/editor/standalone/browser/standaloneEditor.ts b/src/vs/editor/standalone/browser/standaloneEditor.ts index a454f5b111873..36f1092b3c880 100644 --- a/src/vs/editor/standalone/browser/standaloneEditor.ts +++ b/src/vs/editor/standalone/browser/standaloneEditor.ts @@ -39,6 +39,7 @@ import { IKeybindingService } from '../../../platform/keybinding/common/keybindi import { IMarker, IMarkerData, IMarkerService } from '../../../platform/markers/common/markers.js'; import { IOpenerService } from '../../../platform/opener/common/opener.js'; import { MultiDiffEditorWidget } from '../../browser/widget/multiDiffEditor/multiDiffEditorWidget.js'; +import { MultiDiffEditorVariant } from '../../browser/widget/multiDiffEditor/multiDiffEditorOptions.js'; import { IWebWorkerService } from '../../../platform/webWorker/browser/webWorkerService.js'; /** @@ -102,7 +103,7 @@ export function createDiffEditor(domElement: HTMLElement, options?: IStandaloneD export function createMultiFileDiffEditor(domElement: HTMLElement, override?: IEditorOverrideServices) { const instantiationService = StandaloneServices.initialize(override || {}); - return new MultiDiffEditorWidget(domElement, {}, undefined, instantiationService); + return new MultiDiffEditorWidget(domElement, {}, { variant: MultiDiffEditorVariant.Standard }, instantiationService); } /** diff --git a/src/vs/editor/test/browser/widget/multiDiffEditorWidget.test.ts b/src/vs/editor/test/browser/widget/multiDiffEditorWidget.test.ts index e988b9cb967f5..96f7eff3c3010 100644 --- a/src/vs/editor/test/browser/widget/multiDiffEditorWidget.test.ts +++ b/src/vs/editor/test/browser/widget/multiDiffEditorWidget.test.ts @@ -23,6 +23,7 @@ import { IDiffProviderFactoryService } from '../../../browser/widget/diffEditor/ import { DiffEditorWidget } from '../../../browser/widget/diffEditor/diffEditorWidget.js'; import { RefCounted } from '../../../browser/widget/diffEditor/utils.js'; import { DiffItemSource, IDocumentDiffItem, IMultiDiffEditorModel } from '../../../browser/widget/multiDiffEditor/model.js'; +import { getMultiDiffEditorVariantConfiguration, MultiDiffEditorVariant } from '../../../browser/widget/multiDiffEditor/multiDiffEditorOptions.js'; import { MultiDiffEditorWidget } from '../../../browser/widget/multiDiffEditor/multiDiffEditorWidget.js'; import { IWorkbenchUIElementFactory } from '../../../browser/widget/multiDiffEditor/workbenchUIElementFactory.js'; import { EditorOption } from '../../../common/config/editorOptions.js'; @@ -39,6 +40,28 @@ suite('MultiDiffEditorWidget', () => { sinon.restore(); }); + test('uses closed variant configurations', () => { + assert.deepStrictEqual({ + standard: getMultiDiffEditorVariantConfiguration(MultiDiffEditorVariant.Standard), + compact: getMultiDiffEditorVariantConfiguration(MultiDiffEditorVariant.Compact), + }, { + standard: { + className: 'multiDiffEditor-standard', + horizontalInsets: { left: 9, right: 9 }, + headerHeight: 40, + contentBottomPadding: 0, + headerClickToCollapse: false, + }, + compact: { + className: 'multiDiffEditor-compact', + horizontalInsets: { left: 0, right: 0 }, + headerHeight: 32, + contentBottomPadding: 8, + headerClickToCollapse: true, + }, + }); + }); + test('models bottom padding as trailing scroll content', () => { const services = new ServiceCollection(); services.set(IAccessibilitySignalService, new class extends mock() { }()); @@ -61,7 +84,7 @@ suite('MultiDiffEditorWidget', () => { MultiDiffEditorWidget, container, {} satisfies IWorkbenchUIElementFactory, - undefined, + { variant: MultiDiffEditorVariant.Standard }, ); widget.layout(new Dimension(800, 200)); const initialState = widget.getLayoutDebugState().get(); @@ -117,7 +140,7 @@ suite('MultiDiffEditorWidget', () => { { openDiffEditor: (original, modified) => openedDiff = { original, modified }, } satisfies IWorkbenchUIElementFactory, - undefined, + { variant: MultiDiffEditorVariant.Standard }, ); widget.layout(new Dimension(800, 600)); const viewModel = widget.createViewModel(model); @@ -213,7 +236,7 @@ suite('MultiDiffEditorWidget', () => { MultiDiffEditorWidget, container, {} satisfies IWorkbenchUIElementFactory, - undefined, + { variant: MultiDiffEditorVariant.Standard }, ); widget.setRenderSideBySide(true, { useInlineViewWhenSpaceIsLimited: true }); widget.layout(new Dimension(800, 600)); @@ -286,7 +309,7 @@ suite('MultiDiffEditorWidget', () => { MultiDiffEditorWidget, container, {} satisfies IWorkbenchUIElementFactory, - undefined, + { variant: MultiDiffEditorVariant.Standard }, ); widget.layout(new Dimension(800, 600)); const viewModel = widget.createViewModel(model); @@ -366,7 +389,7 @@ suite('MultiDiffEditorWidget', () => { MultiDiffEditorWidget, container, {} satisfies IWorkbenchUIElementFactory, - undefined, + { variant: MultiDiffEditorVariant.Standard }, ); widget.layout(new Dimension(800, 200)); const viewModel = widget.createViewModel(model); diff --git a/src/vs/platform/actionWidget/browser/actionList.ts b/src/vs/platform/actionWidget/browser/actionList.ts index cc08b1c8f8552..bab38bcfb7a5a 100644 --- a/src/vs/platform/actionWidget/browser/actionList.ts +++ b/src/vs/platform/actionWidget/browser/actionList.ts @@ -8,7 +8,7 @@ import { renderMarkdown } from '../../../base/browser/markdownRenderer.js'; import { ActionBar } from '../../../base/browser/ui/actionbar/actionbar.js'; import { getAnchorRect, IAnchor } from '../../../base/browser/ui/contextview/contextview.js'; import { KeybindingLabel } from '../../../base/browser/ui/keybindingLabel/keybindingLabel.js'; -import { Toggle } from '../../../base/browser/ui/toggle/toggle.js'; +import { Switch } from '../../../base/browser/ui/toggle/switch.js'; import { IListEvent, IListMouseEvent, IListRenderer, IListVirtualDelegate } from '../../../base/browser/ui/list/list.js'; import { IListAccessibilityProvider, List } from '../../../base/browser/ui/list/listWidget.js'; import { IAction, SubmenuAction, toAction } from '../../../base/common/actions.js'; @@ -49,13 +49,24 @@ export interface IActionListDelegate { */ export interface IActionListItemHover { /** - * Content to display in the hover. Can be a markdown string or an HTMLElement for full DOM control. + * Content to display in the hover. Pass a function to build the content the first + * time the panel opens, for content that is expensive to construct. */ - readonly content?: string | IMarkdownString | HTMLElement; + readonly content?: string | IMarkdownString | HTMLElement | (() => HTMLElement); /** * Optional disposable associated with the hover content (e.g. from rendered markdown). */ readonly disposable?: IDisposable; + /** + * When true, the row shows a chevron that opens the hover panel on click, and + * ArrowRight opens it from the keyboard. The panel still auto-shows on hover. + */ + readonly expandable?: boolean; + /** + * CSS class set on the hover panel while this item's hover is showing, so a + * consumer can style the panel without reaching for the content inside it. + */ + readonly panelClassName?: string; } /** @@ -136,7 +147,7 @@ export interface IActionListItem { */ readonly isSectionToggle?: boolean; /** - * Optional CSS class name to add to the row container. + * Optional CSS class names to add to the row container, separated by spaces. */ readonly className?: string; /** @@ -167,7 +178,7 @@ interface IActionMenuTemplateData { readonly submenuIndicator: HTMLElement; readonly inlineToggleContainer: HTMLElement; readonly elementDisposables: DisposableStore; - previousClassName?: string; + previousClassNames?: readonly string[]; } export const enum ActionListItemKind { @@ -222,6 +233,7 @@ class SeparatorRenderer implements IListRenderer, ISeparat } renderElement(element: IActionListItem, _index: number, templateData: ISeparatorTemplateData): void { + templateData.container.classList.toggle('has-label', !!element.label); templateData.text.textContent = element.label ?? ''; } @@ -230,6 +242,14 @@ class SeparatorRenderer implements IListRenderer, ISeparat } } +/** + * Whether the item shows a chevron that opens its panel. Items whose panel only + * auto-shows on hover get no chevron. + */ +function hasSubmenuIndicator(item: IActionListItem): boolean { + return (!!item.submenuActions?.length && !item.hover?.content) || !!item.hover?.expandable; +} + class ActionItemRenderer implements IListRenderer, IActionMenuTemplateData> { get templateId(): string { return ActionListItemKind.Action; } @@ -238,11 +258,11 @@ class ActionItemRenderer implements IListRenderer, IAction private readonly _supportsPreview: boolean, private readonly _onRemoveItem: ((item: IActionListItem) => void) | undefined, private readonly _onShowSubmenu: ((item: IActionListItem) => void) | undefined, - private readonly _hasAnySubmenuActions: boolean, + private readonly _reservesSubmenuSpace: () => boolean, private readonly _groupTitleByIndex: ReadonlyMap, private readonly _linkHandler: ((uri: URI, item: IActionListItem) => void) | undefined, private readonly _hideDefaultKeybindingTooltip: boolean, - private readonly _registerStandaloneToggle: (item: IActionListItem, toggle: Toggle) => IDisposable, + private readonly _registerStandaloneToggle: (item: IActionListItem, toggle: Switch) => IDisposable, @IKeybindingService private readonly _keybindingService: IKeybindingService, @IOpenerService private readonly _openerService: IOpenerService, ) { } @@ -326,14 +346,15 @@ class ActionItemRenderer implements IListRenderer, IAction // Apply optional className - clean up previous to avoid stale classes // from virtualized row reuse - if (data.previousClassName) { - data.container.classList.remove(data.previousClassName); + if (data.previousClassNames?.length) { + data.container.classList.remove(...data.previousClassNames); } - data.container.classList.toggle('action-list-custom', !!element.className); - if (element.className) { - data.container.classList.add(element.className); + const classNames = element.className?.split(/\s+/).filter(name => name.length > 0) ?? []; + data.container.classList.toggle('action-list-custom', classNames.length > 0); + if (classNames.length) { + data.container.classList.add(...classNames); } - data.previousClassName = element.className; + data.previousClassNames = classNames; data.text.textContent = stripNewlines(element.label); @@ -407,18 +428,13 @@ class ActionItemRenderer implements IListRenderer, IAction data.inlineToggleContainer.style.display = ''; data.container.classList.toggle('has-inline-toggle', !!element.inlineToggle); data.container.classList.toggle('has-standalone-toggle', !!element.standaloneToggle); - const toggle = data.elementDisposables.add(new Toggle({ - title: toggleConfig.title ?? toggleConfig.label, - isChecked: toggleConfig.checked, - actionClassName: 'action-list-inline-switch', - notFocusable: false, - inputActiveOptionBorder: undefined, - inputActiveOptionForeground: undefined, - inputActiveOptionBackground: undefined, + const toggle = data.elementDisposables.add(new Switch({ + // Callers use `title` to say why a switch is unavailable, so it names the + // control for a screen reader too rather than only appearing on hover. + ariaLabel: toggleConfig.title ?? toggleConfig.label, + checked: toggleConfig.checked, + disabled: toggleConfig.disabled, })); - if (toggleConfig.disabled) { - toggle.disable(); - } data.inlineToggleContainer.append(toggle.domNode); if (element.standaloneToggle) { data.elementDisposables.add(this._registerStandaloneToggle(element, toggle)); @@ -477,24 +493,33 @@ class ActionItemRenderer implements IListRenderer, IAction actionBar.push(toolbarActions, { icon: true, label: false }); } - // Show submenu indicator only for items with submenu actions - // but not when the item also has hover content (panel auto-shows on hover) - if (element.submenuActions?.length && !element.hover?.content) { + // Show submenu indicator for items with submenu actions, or for items that + // opt into an expandable hover panel. + if (hasSubmenuIndicator(element)) { data.submenuIndicator.className = 'action-list-submenu-indicator has-submenu ' + ThemeIcon.asClassName(Codicon.chevronRight); data.submenuIndicator.style.display = ''; data.submenuIndicator.style.visibility = ''; + // Names what the row opens, so a screen reader can tell that there is more + // here than the row itself. Rows are recycled, so the state is always set + // rather than only added. + data.container.setAttribute('aria-haspopup', element.hover?.expandable ? 'dialog' : 'menu'); + data.container.setAttribute('aria-expanded', 'false'); data.elementDisposables.add(dom.addDisposableListener(data.submenuIndicator, dom.EventType.CLICK, (e) => { e.stopPropagation(); this._onShowSubmenu?.(element); })); - } else if (this._hasAnySubmenuActions) { + } else if (this._reservesSubmenuSpace()) { // Reserve space for alignment when other items have submenus data.submenuIndicator.className = 'action-list-submenu-indicator'; data.submenuIndicator.style.display = ''; data.submenuIndicator.style.visibility = 'hidden'; + data.container.removeAttribute('aria-haspopup'); + data.container.removeAttribute('aria-expanded'); } else { data.submenuIndicator.className = 'action-list-submenu-indicator'; data.submenuIndicator.style.display = 'none'; + data.container.removeAttribute('aria-haspopup'); + data.container.removeAttribute('aria-expanded'); } } @@ -615,10 +640,13 @@ export interface IActionListOptions { readonly initialFocusItemId?: string; /** - * When false, non-submenu items do not reserve space for the submenu chevron. - * Defaults to true for alignment consistency. + * Controls the gutter kept for the submenu chevron on items that have none. + * - `true` (default): kept while some item shows a chevron. + * - `'always'`: kept regardless, for lists whose items gain and lose their chevron + * while the popup stays open, where a collapsing gutter would shift every row. + * - `false`: never kept. */ - readonly reserveSubmenuSpace?: boolean; + readonly reserveSubmenuSpace?: boolean | 'always'; /** * When true, items without an explicit `tooltip` or `hover` do not get a @@ -702,6 +730,9 @@ export class ActionListWidget extends Disposable { private _submenuShowTimeout: ReturnType | undefined; private _currentSubmenuWidget: ActionListWidget | undefined; private _currentSubmenuElement: IActionListItem | undefined; + private _submenuPanelClassName: string | undefined; + /** The row currently reporting `aria-expanded`, reset when its panel closes. */ + private _expandedTrigger: HTMLElement | undefined; private readonly _collapsedSections = new Set(); private _filterText = ''; @@ -714,7 +745,7 @@ export class ActionListWidget extends Disposable { private _headerContainer: HTMLElement | undefined; private readonly _filterCts = this._register(new MutableDisposable()); private readonly _groupTitleByIndex = new Map(); - private readonly _standaloneToggles = new Map, Toggle>(); + private readonly _standaloneToggles = new Map, Switch>(); private _visibleMenuItems: readonly IActionListItem[]; private readonly _onDidRequestLayout = this._register(new Emitter()); @@ -762,6 +793,18 @@ export class ActionListWidget extends Disposable { this._submenuContainer.tabIndex = -1; this.domNode.append(this._submenuContainer); + // A panel showing only hover content has no inner list to own the keyboard, so + // the way back to the row it belongs to lives here. A panel that does have a + // submenu list stops these keys before they reach this handler. + this._register(dom.addDisposableListener(this._submenuContainer, 'keydown', (e: KeyboardEvent) => { + if (e.key !== 'ArrowLeft' && e.key !== 'Escape') { + return; + } + dom.EventHelper.stop(e, true); + this._hideSubmenu(); + this._list.domFocus(); + })); + this._register(dom.addDisposableListener(this._submenuContainer, 'mouseenter', () => { this._cancelSubmenuHide(); })); @@ -792,11 +835,15 @@ export class ActionListWidget extends Disposable { }; - const reserveSubmenuSpace = this._options?.reserveSubmenuSpace ?? true; - const hasAnySubmenuActions = reserveSubmenuSpace && items.some(item => !!item.submenuActions?.length && !item.hover?.content); + // Read on every render: whether any item opens a panel can change when the items + // are rebuilt in place, and a stale answer shifts every row by a chevron's width. + const reservesSubmenuSpace = () => { + const reserve = this._options?.reserveSubmenuSpace ?? true; + return reserve === 'always' || (reserve && this._allMenuItems.some(hasSubmenuIndicator)); + }; this._list = this._register(new List(user, this.domNode, virtualDelegate, [ - new ActionItemRenderer(this._supportsPreview, (item) => this._removeItem(item), (item) => this._showSubmenuForItem(item), hasAnySubmenuActions, this._groupTitleByIndex, this._options?.linkHandler, this._options?.hideDefaultKeybindingTooltip ?? false, (item, toggle) => { + new ActionItemRenderer(this._supportsPreview, (item) => this._removeItem(item), (item) => this._showSubmenuForItem(item), reservesSubmenuSpace, this._groupTitleByIndex, this._options?.linkHandler, this._options?.hideDefaultKeybindingTooltip ?? false, (item, toggle) => { this._standaloneToggles.set(item, toggle); return toDisposable(() => { if (this._standaloneToggles.get(item) === toggle) { @@ -825,8 +872,7 @@ export class ActionListWidget extends Disposable { } if (element.hover?.content && !element.ariaDescription && !element.description) { const hoverContent = element.hover.content; - const hoverText = typeof hoverContent === 'string' ? hoverContent : isMarkdownString(hoverContent) ? hoverContent.value : dom.isHTMLElement(hoverContent) ? hoverContent.textContent ?? undefined : undefined; - if (hoverText && (!element.detail || stripNewlines(element.detail) !== stripNewlines(hoverText))) { + const hoverText = typeof hoverContent === 'string' ? hoverContent : isMarkdownString(hoverContent) ? hoverContent.value : dom.isHTMLElement(hoverContent) ? hoverContent.textContent ?? undefined : undefined; if (hoverText && (!element.detail || stripNewlines(element.detail) !== stripNewlines(hoverText))) { label = label + ', ' + stripNewlines(hoverText); } } @@ -1011,12 +1057,16 @@ export class ActionListWidget extends Disposable { const focused = this._list.getFocus(); if (focused.length > 0) { const element = this._list.element(focused[0]); - if (element?.submenuActions?.length) { + if (element?.submenuActions?.length || element?.hover?.expandable) { dom.EventHelper.stop(e, true); const rowElement = this._getRowElement(focused[0]); if (rowElement) { this._showSubmenuForElement(element, rowElement); - this._currentSubmenuWidget?.focus(); + if (this._currentSubmenuWidget) { + this._currentSubmenuWidget.focus(); + } else { + this._submenuContainer.focus(); + } } } } @@ -1206,6 +1256,9 @@ export class ActionListWidget extends Disposable { // Capture whether the filter input currently has focus before splice // which may cause DOM changes that shift focus. const filterInputHasFocus = this._filterInput && dom.isActiveElement(this._filterInput); + // Focus is only ours to restore if the list had it. Something outside the list, + // like a footer control, can rebuild the items while keeping focus itself. + const listHasFocus = dom.isAncestorOfActiveElement(this._list.getHTMLElement()); this._visibleMenuItems = visible; this._list.splice(0, this._list.length, visible); @@ -1232,9 +1285,12 @@ export class ActionListWidget extends Disposable { if ((el.item as { id?: string })?.id === focusedItemId) { this._list.setFocus([i]); this._list.reveal(i); - // Move DOM focus back to the list: the splice above destroyed - // the previously focused row, leaving DOM focus on the body. - this._list.domFocus(); + // Move DOM focus back to the list when the list had it: the splice + // above destroyed the previously focused row, leaving DOM focus on + // the body. + if (listHasFocus) { + this._list.domFocus(); + } break; } } @@ -1296,16 +1352,21 @@ export class ActionListWidget extends Disposable { /** * Replaces the items in the list in place, preserving the current filter, - * without closing or repositioning the widget. When {@link focusItemId} is - * provided, that item ({@link IActionListItem.item}'s `id`) is focused; - * otherwise the previously focused item is preserved (matched by id). + * without closing the widget. When {@link focusItemId} is provided, that item + * ({@link IActionListItem.item}'s `id`) is focused; otherwise the previously + * focused item is preserved (matched by id). The widget only re-measures when + * the number of visible rows changed. */ updateItems(items: readonly IActionListItem[], focusItemId?: string): void { this._allMenuItems = [...items]; - // Don't fire a layout request: the item set keeps the same shape, so the - // widget size is unchanged and repositioning could mis-anchor if the - // anchor element was re-rendered by the action that triggered this update. + const previousVisibleCount = this._visibleMenuItems.length; + // Re-layout only when the number of rows changed. Holding the widget still + // otherwise keeps it from re-anchoring against a trigger that the same action + // just re-rendered. this._applyFilter(false, false); + if (this._visibleMenuItems.length !== previousVisibleCount) { + this._onDidRequestLayout.fire(); + } if (focusItemId !== undefined) { this.focusItemById(focusItemId); } @@ -1467,9 +1528,11 @@ export class ActionListWidget extends Disposable { this._list.layout(height, width); this.domNode.style.height = `${height}px`; - // Place filter container on the preferred side. - if (this._filterContainer && this._filterContainer.parentElement) { - this._filterContainer.parentElement.insertBefore(this._filterContainer, this.domNode); + // Keep the filter above the list. Skipped when the caller mounted the filter + // somewhere else entirely (e.g. inside a tab bar), where it has no list to sit above. + const listParent = this.domNode.parentElement; + if (listParent && this._filterContainer?.parentElement === listParent) { + listParent.insertBefore(this._filterContainer, this.domNode); } } @@ -1655,7 +1718,7 @@ export class ActionListWidget extends Disposable { if (element.standaloneToggle) { this._list.setSelection([]); const toggle = this._standaloneToggles.get(element); - if (toggle?.enabled) { + if (toggle && !toggle.disabled) { toggle.checked = !toggle.checked; element.standaloneToggle.onChange(toggle.checked); } @@ -1797,9 +1860,20 @@ export class ActionListWidget extends Disposable { this._currentSubmenuElement = element; this._clearSubmenuContainer(); + // Marks the row the panel belongs to as open, so a screen reader can tell what + // ArrowRight opened. Reset in `_clearSubmenuContainer`. + anchor.setAttribute('aria-expanded', 'true'); + this._expandedTrigger = anchor; + + // Set after clearing, which is what removes the previous item's class. + this._submenuPanelClassName = element.hover?.panelClassName; + if (this._submenuPanelClassName) { + this._submenuContainer.classList.add(this._submenuPanelClassName); + } + // When the item has hover content, render it as a header let hoverHeader: HTMLElement | undefined; - const hoverContent = element.hover?.content; + const hoverContent = typeof element.hover?.content === 'function' ? element.hover.content() : element.hover?.content; if (hoverContent) { if (dom.isHTMLElement(hoverContent)) { hoverHeader = hoverContent; @@ -1840,7 +1914,16 @@ export class ActionListWidget extends Disposable { // Show container before creating widget so List can measure during construction this._submenuContainer.style.display = ''; this._submenuContainer.style.position = 'absolute'; - this._submenuContainer.removeAttribute('role'); + // An expandable hover panel is a named region the user travels into, so it says + // what it is. A panel carrying a submenu list leaves the semantics to that list. + if (element.hover?.expandable) { + this._submenuContainer.setAttribute('role', 'dialog'); + if (element.label) { + this._submenuContainer.setAttribute('aria-label', element.label); + } + } else { + this._submenuContainer.removeAttribute('role'); + } const anchorRect = anchor.getBoundingClientRect(); const parentRect = this.domNode.getBoundingClientRect(); @@ -2018,6 +2101,18 @@ export class ActionListWidget extends Disposable { if (this._submenuContainer.contains(dom.getActiveElement())) { this._list.domFocus(); } + if (this._submenuPanelClassName) { + this._submenuContainer.classList.remove(this._submenuPanelClassName); + this._submenuPanelClassName = undefined; + } + this._submenuContainer.removeAttribute('role'); + this._submenuContainer.removeAttribute('aria-label'); + // The row that opened the panel is no longer expanded. Skipped when the row has + // since been recycled onto an item with no panel, which drops the attribute. + if (this._expandedTrigger?.hasAttribute('aria-expanded')) { + this._expandedTrigger.setAttribute('aria-expanded', 'false'); + } + this._expandedTrigger = undefined; dom.clearNode(this._submenuContainer); } diff --git a/src/vs/platform/actionWidget/browser/actionWidget.css b/src/vs/platform/actionWidget/browser/actionWidget.css index eed7de9a04636..d692df6098b18 100644 --- a/src/vs/platform/actionWidget/browser/actionWidget.css +++ b/src/vs/platform/actionWidget/browser/actionWidget.css @@ -371,58 +371,6 @@ color: var(--vscode-foreground); } -/* Inline pill switch (restyles the base Toggle as an iOS-style switch) */ -.action-widget .action-list-item-inline-toggle .monaco-custom-toggle.action-list-inline-switch { - position: relative; - flex-shrink: 0; - float: none; - margin: 0; - padding: 0; - width: 26px; - height: 16px; - border-radius: var(--vscode-cornerRadius-circle); - border: var(--vscode-strokeThickness) solid var(--vscode-checkbox-border); - background-color: var(--vscode-checkbox-background); - overflow: visible; - transition: background-color 0.1s ease, border-color 0.1s ease; -} - -.action-widget .action-list-item-inline-toggle .monaco-custom-toggle.action-list-inline-switch:hover { - background-color: var(--vscode-checkbox-background); -} - -.action-widget .action-list-item-inline-toggle .monaco-custom-toggle.action-list-inline-switch::before { - content: ""; - position: absolute; - top: 1px; - left: 1px; - width: 12px; - height: 12px; - border-radius: 50%; - background-color: var(--vscode-checkbox-foreground); - transition: left 0.1s ease, background-color 0.1s ease; -} - -.action-widget .action-list-item-inline-toggle .monaco-custom-toggle.action-list-inline-switch.checked { - background-color: var(--vscode-button-background); - border-color: var(--vscode-button-background); -} - -.action-widget .action-list-item-inline-toggle .monaco-custom-toggle.action-list-inline-switch.checked:hover { - background-color: var(--vscode-button-hoverBackground, var(--vscode-button-background)); -} - -.action-widget .action-list-item-inline-toggle .monaco-custom-toggle.action-list-inline-switch.checked::before { - left: 11px; - background-color: var(--vscode-button-foreground); -} - -.hc-black .action-widget .action-list-item-inline-toggle .monaco-custom-toggle.action-list-inline-switch, -.hc-light .action-widget .action-list-item-inline-toggle .monaco-custom-toggle.action-list-inline-switch { - border-color: var(--vscode-contrastBorder); -} - - /* 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 */ diff --git a/src/vs/platform/actionWidget/browser/tabbedActionListWidget.css b/src/vs/platform/actionWidget/browser/tabbedActionListWidget.css index 2c0141dd6394c..0e9454a42f8e1 100644 --- a/src/vs/platform/actionWidget/browser/tabbedActionListWidget.css +++ b/src/vs/platform/actionWidget/browser/tabbedActionListWidget.css @@ -26,3 +26,37 @@ .action-widget .tabbed-action-list-tabbar .monaco-custom-radio > .monaco-button:not(.active) { color: var(--vscode-descriptionForeground); } + +.action-widget .tabbed-action-list-tabbar .tabbed-action-list-tabbar-action { + display: flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + padding: 0; + margin: 0; + border: none; + border-radius: var(--vscode-cornerRadius-small); + background: transparent; + color: var(--vscode-descriptionForeground); + cursor: pointer; +} + +.action-widget .tabbed-action-list-tabbar .tabbed-action-list-tabbar-action.align-end { + margin-left: auto; +} + +.action-widget .tabbed-action-list-tabbar .tabbed-action-list-tabbar-action:hover, +.action-widget .tabbed-action-list-tabbar .tabbed-action-list-tabbar-action.checked { + background-color: var(--vscode-toolbar-hoverBackground); + color: var(--vscode-foreground); +} + +.action-widget .tabbed-action-list-tabbar .tabbed-action-list-tabbar-action:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} + +.action-widget .tabbed-action-list-footer, +.action-widget .tabbed-action-list-empty { + flex: 0 0 auto; +} diff --git a/src/vs/platform/actionWidget/browser/tabbedActionListWidget.ts b/src/vs/platform/actionWidget/browser/tabbedActionListWidget.ts index c118da4c0deaf..735bce4d37466 100644 --- a/src/vs/platform/actionWidget/browser/tabbedActionListWidget.ts +++ b/src/vs/platform/actionWidget/browser/tabbedActionListWidget.ts @@ -8,7 +8,7 @@ import { IListAccessibilityProvider } from '../../../base/browser/ui/list/listWi import { Radio } from '../../../base/browser/ui/radio/radio.js'; import { KeyCode } from '../../../base/common/keyCodes.js'; import { Emitter } from '../../../base/common/event.js'; -import { Disposable, DisposableStore, MutableDisposable, toDisposable } from '../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../base/common/lifecycle.js'; import { ThemeIcon } from '../../../base/common/themables.js'; import { IContextViewService } from '../../contextview/browser/contextView.js'; import { IInstantiationService } from '../../instantiation/common/instantiation.js'; @@ -42,6 +42,22 @@ export interface ITabDescriptor { readonly icon?: ThemeIcon; } +/** + * An icon button rendered at the trailing edge of the tab bar. Unlike a tab, + * running it does not change the active tab. + */ +export interface ITabBarAction { + /** Stable identifier, used as the button's `data-id` for tests. */ + readonly id: string; + readonly icon: ThemeIcon; + readonly tooltip: string; + /** When true, the button is pushed to the far end of the tab bar. */ + readonly alignEnd?: boolean; + /** When true, the button renders in its pressed state. */ + readonly checked?: boolean; + run(): void; +} + /** * Options for {@link TabbedActionListWidget.show}. The widget renders a * tab bar above an `ActionList` inside a single popup. Consumers describe @@ -67,6 +83,31 @@ export interface ITabbedActionListShowOptions { readonly width?: number; /** Optional class name to add to the tab bar element (in addition to `.tabbed-action-list-tabbar`). Must be a single class. */ readonly tabBarClassName?: string; + /** + * Computes the class names on the popup's `.action-widget` element. Re-evaluated + * whenever the popup re-renders or its list is refreshed, so state that changes + * while the popup stays open is not replayed stale on a tab switch. + */ + readonly widgetClassNames?: (activeTab: string) => readonly string[]; + /** Optional icon buttons rendered after the tabs. */ + readonly tabBarActions?: readonly ITabBarAction[]; + /** + * When tabs show their label beside their icon. `active` labels only the active tab, + * and a tab with no icon always shows its label. Defaults to `always`. + */ + readonly tabLabels?: 'always' | 'active' | 'never'; + /** + * When true, the list's filter row is rendered inside the tab bar in place of the + * tabs, rather than as its own row below them. + */ + readonly filterInTabBar?: boolean; + /** Renders content pinned below the list, e.g. a persistent option row. */ + renderFooter?(container: HTMLElement, activeTab: string): IDisposable; + /** + * Renders the body when the active tab has no items, e.g. a sign-in prompt. + * When it returns `undefined` the empty list is shown instead. + */ + renderEmpty?(container: HTMLElement, activeTab: string): IDisposable | undefined; } /** @@ -82,6 +123,7 @@ export class TabbedActionListWidget extends Disposable { private readonly _activePopup = this._register(new MutableDisposable()); private _swappingTab = false; + private _refreshActiveList: (() => void) | undefined; get isVisible(): boolean { return !!this._activePopup.value; @@ -130,19 +172,38 @@ export class TabbedActionListWidget extends Disposable { const renderDisposables = new DisposableStore(); const widget = dom.append(container, dom.$('.action-widget')); + let widgetClassNames: readonly string[] = []; + const applyWidgetClassNames = () => { + const next = options.widgetClassNames?.(activeTab) ?? []; + const removed = widgetClassNames.filter(name => !next.includes(name)); + const added = next.filter(name => !widgetClassNames.includes(name)); + if (removed.length) { + widget.classList.remove(...removed); + } + if (added.length) { + widget.classList.add(...added); + } + widgetClassNames = next; + }; + applyWidgetClassNames(); + + // Invisible layers that swallow the mouse events which follow the one that + // opened the popup. Without them a trigger that opens on mouse down is + // dismissed by its own mouse up. + const block = dom.append(container, dom.$('.context-view-block')); + renderDisposables.add(dom.addDisposableGenericMouseDownListener(block, e => e.stopPropagation())); + const pointerBlock = dom.append(container, dom.$('.context-view-pointerBlock')); + renderDisposables.add(dom.addDisposableListener(pointerBlock, dom.EventType.POINTER_MOVE, () => pointerBlock.remove())); + renderDisposables.add(dom.addDisposableGenericMouseDownListener(pointerBlock, () => pointerBlock.remove())); const tabBar = dom.append(widget, dom.$('.tabbed-action-list-tabbar')); if (options.tabBarClassName) { tabBar.classList.add(options.tabBarClassName); } - const radio = renderDisposables.add(new Radio({ - items: options.tabs.map(tab => { - const label = tab.label ?? tab.id; - const text = tab.icon ? `$(${tab.icon.id}) ${label}` : label; - return { text, tooltip: tab.tooltip ?? label, isActive: tab.id === activeTab }; - }), - })); - tabBar.appendChild(radio.domNode); + // A consumer showing a filter hides the strip and takes its place, so the + // trailing actions never move. + const tabStrip = dom.append(tabBar, dom.$('.tabbed-action-list-tabstrip')); + const filterSlot = dom.append(tabBar, dom.$('.tabbed-action-list-filter-slot')); const activateTab = (next: string) => { if (next === activeTab) { @@ -153,6 +214,17 @@ export class TabbedActionListWidget extends Disposable { this.show({ ...options, initialTab: next }); }; + const radio = renderDisposables.add(new Radio({ + items: options.tabs.map(tab => { + const label = tab.label ?? tab.id; + const iconPrefix = tab.icon ? `$(${tab.icon.id})` : ''; + const labelMode = options.tabLabels ?? 'always'; + const showsLabel = !iconPrefix || labelMode === 'always' || (labelMode === 'active' && tab.id === activeTab); + const text = showsLabel ? (iconPrefix ? `${iconPrefix} ${label}` : label) : iconPrefix; + return { text, tooltip: tab.tooltip ?? label, ariaLabel: label, isActive: tab.id === activeTab }; + }), + })); + tabStrip.appendChild(radio.domNode); renderDisposables.add(radio.onDidSelect(index => { const next = options.tabs[index]; if (next) { @@ -160,7 +232,28 @@ export class TabbedActionListWidget extends Disposable { } })); + for (const tabAction of options.tabBarActions ?? []) { + const container = tabAction.alignEnd ? tabBar : tabStrip; + const button = dom.append(container, dom.$('button.tabbed-action-list-tabbar-action')); + button.classList.toggle('align-end', !!tabAction.alignEnd); + button.classList.toggle('checked', !!tabAction.checked); + button.dataset.id = tabAction.id; + button.title = tabAction.tooltip; + button.ariaLabel = tabAction.tooltip; + // Only an action with a real on/off state announces as a toggle. A + // momentary one would otherwise read as a toggle that is switched off. + if (tabAction.checked !== undefined) { + button.setAttribute('aria-pressed', String(tabAction.checked)); + } + dom.append(button, dom.$(`span${ThemeIcon.asCSSSelector(tabAction.icon)}`)); + renderDisposables.add(dom.addDisposableListener(button, dom.EventType.CLICK, e => { + dom.EventHelper.stop(e, true); + tabAction.run(); + })); + } + const { items, listOptions } = options.createActionList(activeTab); + const emptyBody = items.length === 0 ? this._renderEmptyBody(widget, options, activeTab, renderDisposables) : undefined; const list = renderDisposables.add(this._instantiationService.createInstance( ActionList, options.user, @@ -172,39 +265,76 @@ export class TabbedActionListWidget extends Disposable { options.anchor, )); listRef = list; + // Rebuilding has to ask the consumer again, since what the popup shows can + // depend on state that changed while it stayed open. + this._refreshActiveList = () => { + applyWidgetClassNames(); + list.updateItems(options.createActionList(activeTab).items); + }; + renderDisposables.add(toDisposable(() => { + this._refreshActiveList = undefined; + })); - if (list.filterContainer) { - widget.appendChild(list.filterContainer); + if (!emptyBody) { + if (list.headerContainer) { + widget.appendChild(list.headerContainer); + } + if (list.filterContainer) { + // The filter takes the tabs' place inside the bar, so the trailing + // actions stay put and no extra row appears. + (options.filterInTabBar ? filterSlot : widget).appendChild(list.filterContainer); + } + widget.appendChild(list.domNode); + if (list.footerContainer) { + widget.appendChild(list.footerContainer); + } + } + + if (options.renderFooter) { + const footer = dom.append(widget, dom.$('.tabbed-action-list-footer')); + renderDisposables.add(options.renderFooter(footer, activeTab)); } - widget.appendChild(list.domNode); const width = list.layout(0); widget.style.width = `${options.width ?? width}px`; - list.focus(); + if (emptyBody) { + // The list is not in the DOM at all, so focusing it would drop focus + // out of the popup. The active tab is the nearest thing to act on, and + // it leads to the empty body's own action. + radio.focusActiveItem(); + } else { + list.focus(); + } // Keyboard nav. Bound to the popup widget so we don't // observe unrelated document-wide keypresses. renderDisposables.add(dom.addStandardDisposableListener(widget, 'keydown', e => { const target = e.target as HTMLElement | null; const onTabBar = !!target?.closest('.tabbed-action-list-tabbar'); + const onFooter = !!target?.closest('.tabbed-action-list-footer'); const onEditable = !!target?.closest('input, textarea, [contenteditable="true"]'); + // The empty body and the hover panel carry controls of their own, e.g. a + // sign-in button or the detail card's pin. Keys pressed there belong to + // those controls rather than to the list sitting behind them. + const onOwnControls = !!target?.closest('.tabbed-action-list-empty, .action-list-submenu-panel'); + const listNavigation = !onTabBar && !onFooter && !onOwnControls; if (e.keyCode === KeyCode.Escape) { dom.EventHelper.stop(e, true); hide(); return; } - if (e.keyCode === KeyCode.Enter && !onTabBar) { + if (e.keyCode === KeyCode.Enter && listNavigation) { dom.EventHelper.stop(e, true); list.acceptSelected(); return; } - if (e.keyCode === KeyCode.UpArrow && !onTabBar) { + if (e.keyCode === KeyCode.UpArrow && listNavigation) { dom.EventHelper.stop(e, true); list.focusPrevious(); return; } - if (e.keyCode === KeyCode.DownArrow && !onTabBar) { + if (e.keyCode === KeyCode.DownArrow && listNavigation) { dom.EventHelper.stop(e, true); list.focusNext(); return; @@ -212,7 +342,7 @@ export class TabbedActionListWidget extends Disposable { if (e.keyCode !== KeyCode.LeftArrow && e.keyCode !== KeyCode.RightArrow) { return; } - if (onEditable && !onTabBar) { + if (onFooter || onOwnControls || (onEditable && !onTabBar)) { return; } const currentIndex = options.tabs.findIndex(t => t.id === activeTab); @@ -272,6 +402,30 @@ export class TabbedActionListWidget extends Disposable { this._activePopup.value = undefined; } + /** + * Rebuilds the active tab's items and the popup's class names in place, keeping its + * position and whatever currently has focus. Use when an action inside the popup + * changes what it shows but should not dismiss it. + */ + refreshActiveList(): void { + this._refreshActiveList?.(); + } + + /** Renders the caller's empty body, or nothing when it declines to handle the empty tab. */ + private _renderEmptyBody(widget: HTMLElement, options: ITabbedActionListShowOptions, activeTab: string, disposables: DisposableStore): HTMLElement | undefined { + if (!options.renderEmpty) { + return undefined; + } + const body = dom.append(widget, dom.$('.tabbed-action-list-empty')); + const rendered = options.renderEmpty(body, activeTab); + if (!rendered) { + body.remove(); + return undefined; + } + disposables.add(rendered); + return body; + } + override dispose(): void { this._activePopup.value = undefined; super.dispose(); diff --git a/src/vs/platform/actionWidget/test/browser/actionList.test.ts b/src/vs/platform/actionWidget/test/browser/actionList.test.ts index afb61a7f195ec..83e8dc61fb599 100644 --- a/src/vs/platform/actionWidget/test/browser/actionList.test.ts +++ b/src/vs/platform/actionWidget/test/browser/actionList.test.ts @@ -187,7 +187,7 @@ suite('ActionListWidget', () => { standaloneClass: row?.classList.contains('has-standalone-toggle'), label: row?.querySelector('.title')?.textContent, toggleLabelCount: row?.querySelectorAll('.action-list-item-inline-toggle-label').length, - switchChecked: row?.querySelector('.action-list-inline-switch')?.classList.contains('checked'), + switchChecked: row?.querySelector('.monaco-switch')?.classList.contains('checked'), title: row?.title, }, { checked: true, @@ -216,17 +216,17 @@ suite('ActionListWidget', () => { widget.focus(); widget.acceptSelected(); - const toggle = widget.domNode.querySelector('.action-list-inline-switch'); + const toggle = widget.domNode.querySelector('.monaco-switch'); assert.deepStrictEqual({ changeCount, checked: toggle?.classList.contains('checked'), - disabled: toggle?.getAttribute('aria-disabled'), + disabled: (toggle as HTMLButtonElement | null)?.disabled, title: toggle?.getAttribute('aria-label'), }, { changeCount: 0, checked: true, - disabled: 'true', + disabled: true, title: 'Managed by your organization', }); }); @@ -557,6 +557,97 @@ suite('ActionListWidget', () => { ); }); + test('an expandable row names the panel it opens, and stops when it closes', () => { + const widget = createActionListWidget(disposables, { + items: [{ ...action('auto'), hover: { content: 'panel', expandable: true } }, action('plain')], + listOptions: { reserveSubmenuSpace: 'always' }, + }); + const rows = () => Array.from(widget.domNode.querySelectorAll('.monaco-list-row.action')); + const state = () => rows().map(row => ({ + haspopup: row.getAttribute('aria-haspopup'), + expanded: row.getAttribute('aria-expanded'), + })); + + const initial = state(); + // The chevron is what opens the panel; ArrowRight does the same from the keyboard. + rows()[0].querySelector('.action-list-submenu-indicator.has-submenu')?.click(); + const opened = state(); + const panel = widget.domNode.querySelector('.action-list-submenu-panel'); + const panelRole = panel?.getAttribute('role'); + const panelLabel = panel?.getAttribute('aria-label'); + // Escape inside the panel is the way back to the row. + panel?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + const closed = state(); + + assert.deepStrictEqual( + { initial, opened, closed, panelRole, panelLabel }, + { + // The plain row opens nothing, so it says nothing. + initial: [{ haspopup: 'dialog', expanded: 'false' }, { haspopup: null, expanded: null }], + opened: [{ haspopup: 'dialog', expanded: 'true' }, { haspopup: null, expanded: null }], + closed: [{ haspopup: 'dialog', expanded: 'false' }, { haspopup: null, expanded: null }], + panelRole: 'dialog', + panelLabel: 'auto', + }, + ); + }); + + test('the submenu gutter follows the items the list currently holds', () => { + const expandable = (id: string): IActionListItem => ({ ...action(id), hover: { content: 'panel', expandable: true } }); + const gutters = (widget: ActionListWidget) => + Array.from(widget.domNode.querySelectorAll('.monaco-list-row .action-list-submenu-indicator')) + .map(el => el.style.display === 'none' ? 'none' : (el.style.visibility || 'shown')); + + const widget = createActionListWidget(disposables, { items: [expandable('one'), action('two')] }); + const always = createActionListWidget(disposables, { + items: [expandable('one'), action('two')], + listOptions: { reserveSubmenuSpace: 'always' }, + }); + + const before = { byDefault: gutters(widget), always: gutters(always) }; + // The chevrons go away, so by default the gutter goes with them. + widget.updateItems([action('one'), action('two')]); + always.updateItems([action('one'), action('two')]); + + assert.deepStrictEqual( + { before, afterLosingChevrons: { byDefault: gutters(widget), always: gutters(always) } }, + { + before: { byDefault: ['shown', 'hidden'], always: ['shown', 'hidden'] }, + afterLosingChevrons: { byDefault: ['none', 'none'], always: ['hidden', 'hidden'] }, + }, + ); + }); + + test('rebuilding the items in place re-measures only when the row count changed', () => { + const widget = createActionListWidget(disposables, { items: [action('one'), action('two')] }); + const layouts: string[] = []; + disposables.add(widget.onDidRequestLayout(() => { layouts.push(getVisibleRowText(widget).join(',')); })); + + widget.updateItems([action('one'), action('two-renamed')]); + const afterSameCount = layouts.length; + widget.updateItems([action('one'), action('two-renamed'), action('three')]); + + assert.deepStrictEqual( + { afterSameCount, afterGrowing: layouts, rows: getVisibleRowText(widget) }, + { afterSameCount: 0, afterGrowing: ['one,two-renamed,three'], rows: ['one', 'two-renamed', 'three'] }, + ); + }); + + test('rebuilding the items in place leaves focus alone when the list does not have it', () => { + const widget = createActionListWidget(disposables, { items: [action('one'), action('two')] }); + const outside = document.createElement('button'); + document.body.appendChild(outside); + disposables.add({ dispose: () => outside.remove() }); + outside.focus(); + + widget.updateItems([action('one'), action('two'), action('three')]); + + assert.deepStrictEqual( + { focusStayedOutside: document.activeElement === outside, rows: getVisibleRowText(widget) }, + { focusStayedOutside: true, rows: ['one', 'two', 'three'] }, + ); + }); + test('shows a row hover panel once the hover delay elapses', () => runWithFakedTimers({ useFakeTimers: true }, async () => { const widget = createActionListWidget(disposables, { items: [{ ...action('auto'), hover: { content: 'Auto routes based on your task' } }, action('other')], diff --git a/src/vs/platform/actionWidget/test/browser/tabbedActionListWidget.test.ts b/src/vs/platform/actionWidget/test/browser/tabbedActionListWidget.test.ts index 59c616cc0916d..59dba6c39fefe 100644 --- a/src/vs/platform/actionWidget/test/browser/tabbedActionListWidget.test.ts +++ b/src/vs/platform/actionWidget/test/browser/tabbedActionListWidget.test.ts @@ -142,6 +142,47 @@ suite('TabbedActionListWidget', () => { assert.deepStrictEqual(calls, ['Remote']); }); + test('popup class names are re-read on tab switch, not replayed from show()', () => { + const { widget } = createWidget(disposables); + const anchor = document.createElement('div'); + document.body.appendChild(anchor); + disposables.add({ dispose: () => anchor.remove() }); + + let dimmed = false; + widget.show({ + user: 'test', + anchor, + tabs: [{ id: 'Local' }, { id: 'Remote' }], + initialTab: 'Local', + widgetClassNames: tab => ['picker', `tab-${tab}`, ...(dimmed ? ['dimmed'] : [])], + createActionList: () => ({ items: [action('a')] }), + delegate: { onSelect: () => { }, onHide: () => { } }, + }); + + const classes = () => { + const popup = document.querySelector('.action-widget:not(.action-list-submenu-panel)'); + return [...(popup?.classList ?? [])].filter(name => name !== 'action-widget').sort(); + }; + + const onShow = classes(); + // State the popup reports changes while it stays open. + dimmed = true; + widget.refreshActiveList(); + const afterRefresh = classes(); + // Switching tabs re-renders the popup, which must not bring back the old state. + document.querySelectorAll('.tabbed-action-list-tabstrip .monaco-button')[1].click(); + const afterTabSwitch = classes(); + + assert.deepStrictEqual( + { onShow, afterRefresh, afterTabSwitch }, + { + onShow: ['picker', 'tab-Local'], + afterRefresh: ['dimmed', 'picker', 'tab-Local'], + afterTabSwitch: ['dimmed', 'picker', 'tab-Remote'], + }, + ); + }); + test('hide() then show() resets visibility cleanly', () => { const { widget } = createWidget(disposables); const anchor = document.createElement('div'); diff --git a/src/vs/platform/agentHost/browser/agentHostEnablementService.ts b/src/vs/platform/agentHost/browser/agentHostEnablementService.ts index 23a6aa0342b05..7f52f4a3c9e5d 100644 --- a/src/vs/platform/agentHost/browser/agentHostEnablementService.ts +++ b/src/vs/platform/agentHost/browser/agentHostEnablementService.ts @@ -11,7 +11,7 @@ import { ChatAIDisabledSettingId } from '../../chat/common/chatSettings.js'; import { IContextKeyService } from '../../contextkey/common/contextkey.js'; import { InstantiationType, registerSingleton } from '../../instantiation/common/extensions.js'; import { bindContextKey, observableConfigValue } from '../../observable/common/platformObservableUtils.js'; -import { COPILOT_SANDBOX_ENABLED_KEY, IManagedSettingsService } from '../../policy/common/copilotManagedSettings.js'; +import { COPILOT_SANDBOX_ALLOW_BYPASS_KEY, COPILOT_SANDBOX_ENABLED_KEY, IManagedSettingsService } from '../../policy/common/copilotManagedSettings.js'; import { AGENT_HOST_ENABLED_CONTEXT_KEY, IAgentHostEnablementService } from '../common/agentHostEnablementService.js'; export class AgentHostEnablementService extends Disposable implements IAgentHostEnablementService { @@ -20,6 +20,7 @@ export class AgentHostEnablementService extends Disposable implements IAgentHost readonly enabled: IObservable; readonly managedSandboxEnforced: IObservable; + readonly managedSandboxAllowsBypass: IObservable; constructor( private readonly _isAgentHostRuntimeAvailable: boolean, @@ -35,6 +36,9 @@ export class AgentHostEnablementService extends Disposable implements IAgentHost this.managedSandboxEnforced = observableFromEvent(this, managedSettingsService.onDidChangeManagedSettings, () => managedSettingsService.getManagedSettingValue(COPILOT_SANDBOX_ENABLED_KEY) === true); + this.managedSandboxAllowsBypass = observableFromEvent(this, + managedSettingsService.onDidChangeManagedSettings, + () => managedSettingsService.getManagedSettingValue(COPILOT_SANDBOX_ALLOW_BYPASS_KEY) === true); } } diff --git a/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts index 70634e21c2b48..35f1b47a29df1 100644 --- a/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts @@ -13,12 +13,13 @@ import { Schemas } from '../../../base/common/network.js'; import { hasKey } from '../../../base/common/types.js'; import { URI } from '../../../base/common/uri.js'; import { generateUuid } from '../../../base/common/uuid.js'; +import { localize } from '../../../nls.js'; import { ILogService } from '../../log/common/log.js'; import { FileSystemProviderErrorCode, toFileSystemProviderErrorCode } from '../../files/common/files.js'; import { ConfigurationTarget, ConfigurationTargetToString, IConfigurationService } from '../../configuration/common/configuration.js'; import { AgentSession, IAgentCreateChatRequestOptions, IAgentCreateSessionConfig, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult, IMcpNotification } from '../common/agent.js'; import { AGENT_HOST_DEBUG_LOGS_CHUNK_BYTES, AGENT_HOST_DEBUG_LOGS_MAX_ENTRIES, IAgentConnection, IAgentHostManagedSettingsDiagnostics, IAgentHostNetworkDiagnosticsInfo, IAgentHostNetworkFetchResult, type AgentHostDebugLogsArtifactKind, type IAgentHostDebugLogsArtifact, type IAgentHostDebugLogsChunk } from '../common/agentService.js'; -import { ClaimAgentHostDetachedWorktreeExtensionMethod, CollectAgentHostDebugLogsExtensionMethod, CreateAgentHostDetachedWorktreeExtensionMethod, DeleteAgentHostDetachedWorktreeExtensionMethod, GetAgentHostSessionStateFileExtensionMethod, ReadAgentHostDebugLogsChunkExtensionMethod, ReconcileAgentHostDetachedWorktreesExtensionMethod, SetAgentHostDetachedWorktreeArchivedExtensionMethod, supportsAgentHostChatStateFile, type IAgentHostExtensionCommandMap, type IAgentHostExtensionInitializeResult } from '../common/agentHostExtensionProtocol.js'; +import { ClaimAgentHostDetachedWorktreeExtensionMethod, CollectAgentHostDebugLogsExtensionMethod, CreateAgentHostDetachedWorktreeExtensionMethod, DeleteAgentHostDetachedWorktreeExtensionMethod, GetAgentHostSessionStateFileExtensionMethod, ReadAgentHostDebugLogsChunkExtensionMethod, ReconcileAgentHostDetachedWorktreesExtensionMethod, RequestAgentHostWorkspaceTrustExtensionMethod, SetAgentHostDetachedWorktreeArchivedExtensionMethod, supportsAgentHostChatStateFile, type IAgentHostExtensionCommandMap, type IAgentHostExtensionInitializeResult, type IAgentHostExtensionServerCommandMap } from '../common/agentHostExtensionProtocol.js'; import { AMBIENT_AGENT_HOST_AUTHORITY } from '../common/agentHostConnectionsService.js'; import { createRemoteWatchHandle, type IRemoteWatchHandle } from '../common/agentHostFileSystemProvider.js'; import { AgentSubscriptionManager, type IActiveSubscriptionInfo, type IAgentSubscription } from '../common/state/agentSubscription.js'; @@ -52,6 +53,8 @@ import { isFileResourceRead } from '../common/resourceReadLogging.js'; import { ResourceSet } from '../../../base/common/map.js'; import { computeReconnectDelay, DEFAULT_RECONNECT_POLICY, hasExhaustedReconnectAttempts, type IRemoteAgentHostReconnectPolicy } from '../common/reconnectPolicy.js'; import type { IRemoteAgentHostProtocolClient } from '../common/remoteAgentHostService.js'; +import { IWorkspaceTrustManagementService, IWorkspaceTrustRequestService } from '../../workspace/common/workspaceTrust.js'; +import { isWorktreeUnderRepository } from '../common/worktreePaths.js'; const AHP_CLIENT_CONNECTION_CLOSED = -32000; // AHP 0.9 changed the automation catalog wire shape, so VS Code cannot safely negotiate 0.8. @@ -400,6 +403,8 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect @IAgentHostResourceService private readonly _resourceService: IAgentHostResourceService, @IConfigurationService private readonly _configurationService: IConfigurationService, @ITelemetryService private readonly _telemetryService: ITelemetryService, + @IWorkspaceTrustManagementService private readonly _workspaceTrustManagementService: IWorkspaceTrustManagementService, + @IWorkspaceTrustRequestService private readonly _workspaceTrustRequestService: IWorkspaceTrustRequestService, ) { super(); this._resourceIdentity = identity; @@ -1951,6 +1956,44 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect void (async () => { try { switch (method) { + case RequestAgentHostWorkspaceTrustExtensionMethod: { + if (typeof p.workspace !== 'string') { + throw new Error('Missing workspace'); + } + const hostWorkspace = URI.parse(p.workspace, true); + if (hostWorkspace.scheme !== Schemas.file || !hostWorkspace.path.startsWith('/')) { + throw new Error('Workspace must be an absolute file URI'); + } + const workspace = this.resourceUris.fromAgentHost(hostWorkspace); + if (p.trustedParent !== undefined) { + if (typeof p.trustedParent !== 'string') { + throw new Error('Invalid trustedParent'); + } + const hostParent = URI.parse(p.trustedParent, true); + if (hostParent.scheme !== Schemas.file || !hostParent.path.startsWith('/')) { + throw new Error('Trusted parent must be an absolute file URI'); + } + if (!isWorktreeUnderRepository(hostWorkspace, hostParent)) { + throw new Error('Workspace is not a managed worktree under the trusted parent'); + } + const parent = this.resourceUris.fromAgentHost(hostParent); + const parentTrust = await this._workspaceTrustManagementService.getUriTrustInfo(parent); + if (parentTrust.trusted) { + const workspaceTrust = await this._workspaceTrustManagementService.getUriTrustInfo(workspace); + if (!workspaceTrust.trusted) { + await this._workspaceTrustManagementService.setUrisTrust([workspace], true); + } + sendResult({ trusted: true } satisfies IAgentHostExtensionServerCommandMap[typeof RequestAgentHostWorkspaceTrustExtensionMethod]['result']); + return; + } + } + const trusted = await this._workspaceTrustRequestService.requestResourcesTrust({ + uri: workspace, + message: localize('agentHost.trustWorkspaceMessage', "An agent session will be able to read files, run commands, and make changes in this folder."), + }); + sendResult({ trusted: trusted === true } satisfies IAgentHostExtensionServerCommandMap[typeof RequestAgentHostWorkspaceTrustExtensionMethod]['result']); + return; + } case 'resourceList': { if (!p.uri) { throw new Error('Missing uri'); } const result = await this._resourceService.list(identity, URI.parse(p.uri as string)); diff --git a/src/vs/platform/agentHost/common/agent.ts b/src/vs/platform/agentHost/common/agent.ts index f4b28c2034c80..e5214007bf01b 100644 --- a/src/vs/platform/agentHost/common/agent.ts +++ b/src/vs/platform/agentHost/common/agent.ts @@ -27,6 +27,16 @@ export class AgentHostStartError extends Error { } } +/** Reports a provider CWD error after the new directory became irreversible and authoritative. */ +export class AgentWorkingDirectoryChangedError extends Error { + constructor( + readonly workingDirectory: URI, + message: string, + ) { + super(message); + } +} + export function isInvalidUtilityProcessConfigurationMessage(message: string): boolean { return /^Invalid value for (?:args|env|execArgv)$/.test(message); } @@ -249,6 +259,11 @@ export const CODEX_AGENT_PROVIDER_ID = 'codex' as const; */ export type IAgentCapabilities = AgentCapabilities; +/** Agent Host-only capabilities that are not serialized to protocol clients. */ +export interface IAgentHostCapabilities { + readonly workspaceConversion: boolean; +} + /** Metadata describing an agent backend, discovered over IPC. */ export interface IAgentDescriptor { readonly provider: AgentProvider; @@ -1108,6 +1123,9 @@ export interface IAgent { /** Unique provider identifier. */ readonly id: AgentProvider; + /** Capabilities consumed only inside the Agent Host process. */ + readonly agentHostCapabilities: IAgentHostCapabilities; + /** Provider descriptor and capabilities. */ getDescriptor(): IAgentDescriptor; @@ -1143,6 +1161,14 @@ export interface IAgent { /** Optional history mutation for providers with a native truncation operation. */ truncateChat?(chat: URI, turnId: string | undefined, context?: URI | IAgentChatContext): Promise; + /** + * Changes the working directory of an exact chat's existing provider-native + * backing. Callers MUST gate this operation on + * {@link IAgentHostCapabilities.workspaceConversion}; implementations that do + * not advertise the capability MUST reject the call. + */ + setWorkingDirectory(chat: URI, context: URI | IAgentChatContext, workingDirectory: URI): Promise; + /** Return bounded diagnostics for an in-flight turn when supported. */ getTurnDiagnosticSnapshot?(chat: URI, turnId: string): IAgentTurnDiagnosticSnapshot | undefined; diff --git a/src/vs/platform/agentHost/common/agentHostChangesetOperationService.ts b/src/vs/platform/agentHost/common/agentHostChangesetOperationService.ts index ae9a30f2bc560..5b0096f795069 100644 --- a/src/vs/platform/agentHost/common/agentHostChangesetOperationService.ts +++ b/src/vs/platform/agentHost/common/agentHostChangesetOperationService.ts @@ -13,6 +13,7 @@ import type { ChangesetOperation, ISessionGitHubState, ISessionGitState, URI } f export const IAgentHostChangesetOperationService = createDecorator('agentHostChangesetOperationService'); export const AGENT_HOST_MERGE_CHANGESET_OPERATION_ID = 'merge'; +export const AGENT_HOST_CHECKOUT_CHANGESET_OPERATION_ID = 'checkout'; export const AGENT_HOST_COMMIT_CHANGESET_OPERATION_ID = 'commit'; export const AGENT_HOST_SYNC_CHANGESET_OPERATION_ID = 'sync'; diff --git a/src/vs/platform/agentHost/common/agentHostEnablementService.ts b/src/vs/platform/agentHost/common/agentHostEnablementService.ts index 96f8f8c8cb09a..e1b4951139fb8 100644 --- a/src/vs/platform/agentHost/common/agentHostEnablementService.ts +++ b/src/vs/platform/agentHost/common/agentHostEnablementService.ts @@ -33,6 +33,7 @@ export interface IAgentHostEnablementService { * affected, and virtual workspaces are exempt. */ readonly managedSandboxEnforced: IObservable; + readonly managedSandboxAllowsBypass: IObservable; } const configurationRegistry = Registry.as(ConfigurationExtensions.Configuration); diff --git a/src/vs/platform/agentHost/common/agentHostExtensionProtocol.ts b/src/vs/platform/agentHost/common/agentHostExtensionProtocol.ts index 5279b0c1ac859..4eb14fb4d706a 100644 --- a/src/vs/platform/agentHost/common/agentHostExtensionProtocol.ts +++ b/src/vs/platform/agentHost/common/agentHostExtensionProtocol.ts @@ -15,6 +15,7 @@ export const DeleteAgentHostDetachedWorktreeExtensionMethod = 'vscode/deleteAgen export const ReconcileAgentHostDetachedWorktreesExtensionMethod = 'vscode/reconcileAgentHostDetachedWorktrees'; export const ReadAgentHostDebugLogsChunkExtensionMethod = 'vscode/readAgentHostDebugLogsChunk'; export const SetAgentHostDetachedWorktreeArchivedExtensionMethod = 'vscode/setAgentHostDetachedWorktreeArchived'; +export const RequestAgentHostWorkspaceTrustExtensionMethod = 'vscode/requestWorkspaceTrust'; const AgentHostChatStateFileCapabilityMetaKey = 'vscode.getAgentHostSessionStateFile.chat'; const AgentHostDetachedWorktreeCapabilityMetaKey = 'vscode.detachedWorktrees'; @@ -92,3 +93,15 @@ export interface IAgentHostExtensionCommandMap { result: { data: string; eof: boolean }; }; } + +export interface IAgentHostWorkspaceTrustRequest { + readonly workspace: string; + readonly trustedParent?: string; +} + +export interface IAgentHostExtensionServerCommandMap { + [RequestAgentHostWorkspaceTrustExtensionMethod]: { + params: IAgentHostWorkspaceTrustRequest; + result: { trusted: boolean }; + }; +} diff --git a/src/vs/platform/agentHost/common/agentHostGitService.ts b/src/vs/platform/agentHost/common/agentHostGitService.ts index 14694fc1755b9..3f2c4848aaec9 100644 --- a/src/vs/platform/agentHost/common/agentHostGitService.ts +++ b/src/vs/platform/agentHost/common/agentHostGitService.ts @@ -256,6 +256,8 @@ export interface IAgentHostGitService { branchExists(repositoryRoot: URI, branchName: string): Promise; /** Creates a new branch and optionally checks it out while preserving the working tree. */ createBranch(workingDirectory: URI, branchName: string, options?: { readonly checkout?: boolean }): Promise; + /** Checks out an existing local branch. */ + checkout(workingDirectory: URI, treeish: string): Promise; /** * Returns true when the working tree has any tracked, staged, or * untracked changes. Used by archive cleanup to skip removing a diff --git a/src/vs/platform/agentHost/common/meta/agentCheckoutOperationMeta.ts b/src/vs/platform/agentHost/common/meta/agentCheckoutOperationMeta.ts new file mode 100644 index 0000000000000..2a6dfd36d4663 --- /dev/null +++ b/src/vs/platform/agentHost/common/meta/agentCheckoutOperationMeta.ts @@ -0,0 +1,25 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +interface IHasCheckoutOperationMeta { + readonly _meta?: Record; +} + +const TREEISH_META_KEY = 'treeish'; + +/** Creates request metadata for a Checkout changeset operation. */ +export function checkoutOperationMeta(treeish: string): Record { + return { [TREEISH_META_KEY]: treeish }; +} + +/** Reads and validates the treeish requested by a Checkout changeset operation. */ +export function readCheckoutOperationTreeish(source: IHasCheckoutOperationMeta): string | undefined { + const meta = source._meta; + if (!meta) { + return undefined; + } + const treeish = meta[TREEISH_META_KEY]; + return typeof treeish === 'string' && treeish.length > 0 ? treeish : undefined; +} diff --git a/src/vs/platform/agentHost/common/serverToolNames.ts b/src/vs/platform/agentHost/common/serverToolNames.ts index 9d0113ae1b15f..6669a7203be14 100644 --- a/src/vs/platform/agentHost/common/serverToolNames.ts +++ b/src/vs/platform/agentHost/common/serverToolNames.ts @@ -19,6 +19,7 @@ export const enum SessionServerToolName { ListSessions = 'list_sessions', GetCurrentSession = 'get_current_session', + SetWorkspace = 'set_workspace', CreateSession = 'create_session', CreateChat = 'create_chat', RenameChat = 'rename_chat', diff --git a/src/vs/platform/agentHost/common/sessionDataService.ts b/src/vs/platform/agentHost/common/sessionDataService.ts index a2737973c1bcd..42bca40ae0e64 100644 --- a/src/vs/platform/agentHost/common/sessionDataService.ts +++ b/src/vs/platform/agentHost/common/sessionDataService.ts @@ -302,6 +302,11 @@ export interface ISessionDatabase extends IDisposable { */ setMetadataValues(values: Readonly>): Promise; + /** + * Atomically delete metadata keys. + */ + deleteMetadata(keys: readonly string[]): Promise; + /** * Atomically stores metadata values only when `key` is absent. Values named * by `copies` are read from their source keys and copied when present. diff --git a/src/vs/platform/agentHost/common/state/sessionActions.ts b/src/vs/platform/agentHost/common/state/sessionActions.ts index 563712337ba61..96297212274d8 100644 --- a/src/vs/platform/agentHost/common/state/sessionActions.ts +++ b/src/vs/platform/agentHost/common/state/sessionActions.ts @@ -88,7 +88,6 @@ export { AuthRequiredReason, type SessionAddedParams, type SessionRemovedParams, - type SessionSummaryChangedParams, type ProgressParams, type AuthRequiredParams, } from './protocol/notifications.js'; @@ -145,9 +144,19 @@ import { type RootConfigChangedAction, } from './protocol/actions.js'; -import type { SessionAddedParams, SessionRemovedParams, SessionSummaryChangedParams, ProgressParams, AuthRequiredParams } from './protocol/notifications.js'; +import type { SessionSummary } from './protocol/state.js'; +import type { SessionAddedParams, SessionRemovedParams, SessionSummaryChangedParams as ProtocolSessionSummaryChangedParams, ProgressParams, AuthRequiredParams } from './protocol/notifications.js'; import type { RootAction as IRootAction_, SessionAction as ISessionAction_, ChatAction as IChatAction_, ClientSessionAction as IClientSessionAction_, ServerSessionAction as IServerSessionAction_, ClientChatAction as IClientChatAction_, ServerChatAction as IServerChatAction_, TerminalAction as ITerminalAction_, ClientTerminalAction as IClientTerminalAction_, ChangesetAction as IChangesetAction_, ClientChangesetAction as IClientChangesetAction_, AnnotationsAction as IAnnotationsAction_, ClientAnnotationsAction as IClientAnnotationsAction_, AutomationAction as IAutomationAction_, ClientAutomationAction as IClientAutomationAction_, AutomationRunAction as IAutomationRunAction_, ClientAutomationRunAction as IClientAutomationRunAction_ } from './protocol/action-origin.generated.js'; +export type SessionSummaryChanges = Omit, 'activity'> & { + /** `null` explicitly clears activity; omission leaves it unchanged. */ + activity?: string | null; +}; + +export type SessionSummaryChangedParams = Omit & { + readonly changes: SessionSummaryChanges; +}; + /** * Discriminated union of all server→client protocol notifications other than * the action envelope. Each variant carries its protocol `method` so callers diff --git a/src/vs/platform/agentHost/common/state/sessionState.ts b/src/vs/platform/agentHost/common/state/sessionState.ts index ea20300c048ac..50a0a13312726 100644 --- a/src/vs/platform/agentHost/common/state/sessionState.ts +++ b/src/vs/platform/agentHost/common/state/sessionState.ts @@ -282,15 +282,18 @@ const MESSAGE_HIDDEN_FROM_TRANSCRIPT_META_KEY = 'vscode.chat.hiddenFromTranscrip const MESSAGE_HIDDEN_FROM_TRANSCRIPT_PREFIX = '\n'; const MESSAGE_REQUEST_HIDDEN_FROM_TRANSCRIPT_META_KEY = 'vscode.chat.requestHiddenFromTranscript'; const MESSAGE_REQUEST_HIDDEN_FROM_TRANSCRIPT_PREFIX = '\n'; +const MESSAGE_SYSTEM_INITIATED_LABEL_META_KEY = 'vscode.chat.systemInitiatedLabel'; -function readMessageMeta(message: Message): { readonly hiddenFromTranscript: boolean; readonly requestHiddenFromTranscript: boolean } { +function readMessageMeta(message: Message): { readonly hiddenFromTranscript: boolean; readonly requestHiddenFromTranscript: boolean; readonly systemInitiatedLabel: string | undefined } { const meta = message._meta; + const systemInitiatedLabel = meta?.[MESSAGE_SYSTEM_INITIATED_LABEL_META_KEY]; const hiddenFromTranscript = meta?.[MESSAGE_HIDDEN_FROM_TRANSCRIPT_META_KEY] === true || message.text.startsWith(MESSAGE_HIDDEN_FROM_TRANSCRIPT_PREFIX); return { hiddenFromTranscript, requestHiddenFromTranscript: meta?.[MESSAGE_REQUEST_HIDDEN_FROM_TRANSCRIPT_META_KEY] === true || message.text.startsWith(MESSAGE_REQUEST_HIDDEN_FROM_TRANSCRIPT_PREFIX), + systemInitiatedLabel: typeof systemInitiatedLabel === 'string' ? systemInitiatedLabel : undefined, }; } @@ -303,6 +306,10 @@ export function isMessageRequestHiddenFromTranscript(message: Message): boolean return readMessageMeta(message).requestHiddenFromTranscript; } +export function readMessageSystemInitiatedLabel(message: Message): string | undefined { + return readMessageMeta(message).systemInitiatedLabel; +} + export function withMessageHiddenFromTranscript(message: Message, hidden: boolean | undefined): Message { if (!hidden) { return message; @@ -332,6 +339,16 @@ export function withMessageRequestHiddenFromTranscript(message: Message, hidden: }; } +export function withMessageSystemInitiatedLabel(message: Message, label: string): Message { + return { + ...message, + _meta: { + ...message._meta, + [MESSAGE_SYSTEM_INITIATED_LABEL_META_KEY]: label, + }, + }; +} + /** * Whether `turn` is a hidden system notification the host appended purely to * carry a message (e.g. an Agent Merge status change). It never reaches the @@ -1995,6 +2012,9 @@ export const SESSION_META_WORKSPACELESS_KEY = 'workspaceless'; */ export const AH_META_WORKSPACELESS_DB_KEY = 'agentHost.workspaceless'; +/** Blocks turns for a session whose provider could not be detached from an untrusted working directory. */ +export const AH_META_WORKSPACE_CONVERSION_QUARANTINED_DB_KEY = 'agentHost.workspaceConversionQuarantined'; + /** * Session-database metadata key recording whether a session is archived. Written by * the AH orchestrator (`AgentSideEffects` on `SessionIsArchivedChanged`) and read by diff --git a/src/vs/platform/agentHost/node/agentHostBootstrap.ts b/src/vs/platform/agentHost/node/agentHostBootstrap.ts index c6ff31388aba0..ef5d005eeb698 100644 --- a/src/vs/platform/agentHost/node/agentHostBootstrap.ts +++ b/src/vs/platform/agentHost/node/agentHostBootstrap.ts @@ -186,6 +186,7 @@ export async function createAgentHostRuntime(options: ICreateAgentHostRuntimeOpt agentServiceOptions, accessor, instantiationService!, + services, logService, sessionDataService, foundation, diff --git a/src/vs/platform/agentHost/node/agentHostChangesetOperationService.ts b/src/vs/platform/agentHost/node/agentHostChangesetOperationService.ts index 6ff134ce86a72..2ad72f13b7d39 100644 --- a/src/vs/platform/agentHost/node/agentHostChangesetOperationService.ts +++ b/src/vs/platform/agentHost/node/agentHostChangesetOperationService.ts @@ -6,6 +6,7 @@ import { CancellationToken } from '../../../base/common/cancellation.js'; import { toErrorMessage } from '../../../base/common/errorMessage.js'; import { Disposable, DisposableMap, DisposableStore, toDisposable, type IDisposable } from '../../../base/common/lifecycle.js'; +import { stableStringify } from '../../../base/common/objects.js'; import { ChangesetKind, parseChangesetUri } from '../common/changesetUri.js'; import { isMultiRootSession } from '../common/agentHostWorkingDirectories.js'; import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from '../common/state/protocol/channels-changeset/commands.js'; @@ -25,6 +26,7 @@ export class AgentHostChangesetOperationService extends Disposable implements IA private readonly _handlerRegistrations = this._register(new DisposableMap()); private readonly _changesetOperationHandlers = new Map(); private readonly _inFlightOperations = new Map>(); + private readonly _operationLanes = new Map>(); constructor( @IAgentHostStateManager private readonly _stateManager: AgentHostStateManager, @@ -253,47 +255,62 @@ export class AgentHostChangesetOperationService extends Disposable implements IA handler: IChangesetOperationHandler, params: InvokeChangesetOperationParams, ): Promise { - const operationKey = `${params.channel}\x00${params.operationId}\x00${JSON.stringify(params.target ?? null)}`; + const operationLane = `${params.channel}\x00${params.operationId}`; + const operationKey = `${operationLane}\x00${stableStringify({ target: params.target ?? null, _meta: params._meta ?? null })}`; const inFlightOperationResult = this._inFlightOperations.get(operationKey); - if (inFlightOperationResult) { + if (inFlightOperationResult && this._operationLanes.get(operationLane) === inFlightOperationResult) { return inFlightOperationResult; } + const runningOperation = this._operationLanes.get(operationLane); + const operationPromise = runningOperation + ? runningOperation.then( + () => this._executeChangesetOperation(handler, params), + () => this._executeChangesetOperation(handler, params), + ) + : this._executeChangesetOperation(handler, params); + this._operationLanes.set(operationLane, operationPromise); + this._inFlightOperations.set(operationKey, operationPromise); + + const clearOperation = () => { + if (this._inFlightOperations.get(operationKey) === operationPromise) { + this._inFlightOperations.delete(operationKey); + } + if (this._operationLanes.get(operationLane) === operationPromise) { + this._operationLanes.delete(operationLane); + } + }; + void operationPromise.then(clearOperation, clearOperation); + + return operationPromise; + } + + private async _executeChangesetOperation(handler: IChangesetOperationHandler, params: InvokeChangesetOperationParams): Promise { this._stateManager.dispatchServerAction(params.channel, { type: ActionType.ChangesetOperationStatusChanged, operationId: params.operationId, status: ChangesetOperationStatus.Running, }); - const operationPromise = handler.invoke(params, CancellationToken.None) - .then(result => { - this._stateManager.dispatchServerAction(params.channel, { - type: ActionType.ChangesetOperationStatusChanged, - operationId: params.operationId, - status: ChangesetOperationStatus.Idle, - }); - - return result; - }) - .catch((error) => { - this._stateManager.dispatchServerAction(params.channel, { - type: ActionType.ChangesetOperationStatusChanged, - operationId: params.operationId, - status: ChangesetOperationStatus.Error, - error: toChangesetOperationError(error), - }); - - throw error; - }) - .finally(() => { - if (this._inFlightOperations.get(operationKey) === operationPromise) { - this._inFlightOperations.delete(operationKey); - } + try { + const result = await handler.invoke(params, CancellationToken.None); + this._stateManager.dispatchServerAction(params.channel, { + type: ActionType.ChangesetOperationStatusChanged, + operationId: params.operationId, + status: ChangesetOperationStatus.Idle, }); - this._inFlightOperations.set(operationKey, operationPromise); + return result; + } catch (error) { + this._stateManager.dispatchServerAction(params.channel, { + type: ActionType.ChangesetOperationStatusChanged, + operationId: params.operationId, + status: ChangesetOperationStatus.Error, + error: toChangesetOperationError(error), + }); - return operationPromise; + throw error; + } } private _registerChangesetOperationHandler(operationId: string, handler: IChangesetOperationHandler): IDisposable { diff --git a/src/vs/platform/agentHost/node/agentHostCheckoutOperationHandler.ts b/src/vs/platform/agentHost/node/agentHostCheckoutOperationHandler.ts new file mode 100644 index 0000000000000..7c69a95398015 --- /dev/null +++ b/src/vs/platform/agentHost/node/agentHostCheckoutOperationHandler.ts @@ -0,0 +1,83 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../base/common/cancellation.js'; +import { URI } from '../../../base/common/uri.js'; +import { localize } from '../../../nls.js'; +import { ILogService } from '../../log/common/log.js'; +import { AGENT_HOST_CHECKOUT_CHANGESET_OPERATION_ID, type IChangesetOperationHandler } from '../common/agentHostChangesetOperationService.js'; +import { IAgentHostGitService } from '../common/agentHostGitService.js'; +import { ChangesetKind, parseChangesetUri } from '../common/changesetUri.js'; +import { readCheckoutOperationTreeish } from '../common/meta/agentCheckoutOperationMeta.js'; +import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from '../common/state/protocol/channels-changeset/commands.js'; +import { AHP_SESSION_NOT_FOUND, JsonRpcErrorCodes, ProtocolError } from '../common/state/sessionProtocol.js'; +import type { SessionState } from '../common/state/sessionState.js'; + +export class AgentHostCheckoutOperationHandler implements IChangesetOperationHandler { + + public static readonly OPERATION_CHECKOUT = AGENT_HOST_CHECKOUT_CHANGESET_OPERATION_ID; + + constructor( + private readonly _getSessionState: (sessionKey: string) => SessionState | undefined, + private readonly _onCheckedOut: (sessionKey: string) => void, + @IAgentHostGitService private readonly _gitService: IAgentHostGitService, + @ILogService private readonly _logService: ILogService, + ) { } + + async invoke(params: InvokeChangesetOperationParams, token: CancellationToken): Promise { + const parsed = parseChangesetUri(params.channel); + if (!parsed || parsed.kind !== ChangesetKind.Uncommitted) { + throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, `Not an uncommitted changeset URI: ${params.channel}`); + } + this._throwIfCancelled(token); + + const sessionUri = parsed.sessionUri; + const sessionState = this._getSessionState(sessionUri); + if (!sessionState) { + throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Session not found: ${sessionUri}`); + } + + const workingDirectoryValue = sessionState.workingDirectories?.[0]; + if (!workingDirectoryValue) { + throw new ProtocolError(JsonRpcErrorCodes.InternalError, `Session has no working directory: ${sessionUri}`); + } + const treeish = readCheckoutOperationTreeish(params); + if (!treeish) { + throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, localize('agentHost.changeset.checkout.branchMissing', "Select a branch to check out.")); + } + + const workingDirectory = URI.parse(workingDirectoryValue); + if (treeish.startsWith('-') || !await this._gitService.branchExists(workingDirectory, treeish)) { + throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, localize('agentHost.changeset.checkout.branchInvalid', "Branch '{0}' is not an existing local branch.", treeish)); + } + this._throwIfCancelled(token); + if (await this._gitService.hasUncommittedChanges(workingDirectory)) { + throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, localize('agentHost.changeset.checkout.dirty', "Commit or stash the current changes before checking out '{0}'.", treeish)); + } + this._throwIfCancelled(token); + + this._logService.info(`[AgentHostCheckoutOperationHandler] Checking out ${treeish} for session ${sessionUri}`); + try { + await this._gitService.checkout(workingDirectory, treeish); + } catch (error) { + this._throwIfCancelled(token); + throw new ProtocolError(JsonRpcErrorCodes.InternalError, localize('agentHost.changeset.checkout.failed', "Failed to check out '{0}': {1}", treeish, error instanceof Error ? error.message : String(error))); + } + + try { + await this._onCheckedOut(sessionUri); + } catch (error) { + this._logService.warn(`[AgentHostCheckoutOperationHandler] Post-checkout refresh failed for session ${sessionUri}: ${error instanceof Error ? error.message : String(error)}`); + } + + return { message: { markdown: localize('agentHost.changeset.checkout.checkedOut', "Checked out branch `{0}`.", treeish) } }; + } + + private _throwIfCancelled(token: CancellationToken): void { + if (token.isCancellationRequested) { + throw new ProtocolError(JsonRpcErrorCodes.InternalError, localize('agentHost.changeset.checkout.cancelled', "Checkout operation was cancelled.")); + } + } +} diff --git a/src/vs/platform/agentHost/node/agentHostCheckoutOperationProvider.ts b/src/vs/platform/agentHost/node/agentHostCheckoutOperationProvider.ts new file mode 100644 index 0000000000000..748104f6b3706 --- /dev/null +++ b/src/vs/platform/agentHost/node/agentHostCheckoutOperationProvider.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 { Disposable, DisposableStore, IDisposable } from '../../../base/common/lifecycle.js'; +import { localize } from '../../../nls.js'; +import { IInstantiationService } from '../../instantiation/common/instantiation.js'; +import type { IChangesetOperationContribution, IChangesetOperationContext, IChangesetOperationRegistry } from '../common/agentHostChangesetOperationService.js'; +import { ChangesetKind } from '../common/changesetUri.js'; +import { ChangesetOperationScope, ChangesetOperationStatus, SessionLifecycle, type ChangesetOperation } from '../common/state/sessionState.js'; +import { AgentHostCheckoutOperationHandler } from './agentHostCheckoutOperationHandler.js'; +import { AgentHostStateManager, IAgentHostStateManager } from './agentHostStateManager.js'; + +export class AgentHostCheckoutOperationContribution extends Disposable implements IChangesetOperationContribution { + + private _registry: IChangesetOperationRegistry | undefined; + + constructor( + @IAgentHostStateManager private readonly _stateManager: AgentHostStateManager, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + ) { + super(); + } + + registerHandlers(registry: IChangesetOperationRegistry): IDisposable { + this._registry = registry; + const store = new DisposableStore(); + const getSessionState = (sessionKey: string) => this._stateManager.getSessionState(sessionKey); + const handler = this._instantiationService.createInstance(AgentHostCheckoutOperationHandler, getSessionState, (sessionKey: string) => this._onCheckedOut(sessionKey)); + store.add(registry.registerChangesetOperationHandler(AgentHostCheckoutOperationHandler.OPERATION_CHECKOUT, handler)); + store.add({ dispose: () => { this._registry = undefined; } }); + return store; + } + + getOperations({ sessionKey, changesetKind }: IChangesetOperationContext): ChangesetOperation[] | undefined { + const state = this._stateManager.getSessionState(sessionKey); + if ( + state?.lifecycle !== SessionLifecycle.Creating || + this._stateManager.isUnusedDraft(sessionKey) !== true || + changesetKind !== ChangesetKind.Uncommitted + ) { + return undefined; + } + + return [{ + id: AgentHostCheckoutOperationHandler.OPERATION_CHECKOUT, + label: localize('agentHost.changeset.checkout', "Checkout"), + group: 'checkout', + scopes: [ChangesetOperationScope.Changeset], + status: ChangesetOperationStatus.Idle, + } satisfies ChangesetOperation]; + } + + private _onCheckedOut(sessionKey: string): void { + void this._registry?.refreshSessionGitState(sessionKey); + } +} diff --git a/src/vs/platform/agentHost/node/agentHostClientConnectionService.ts b/src/vs/platform/agentHost/node/agentHostClientConnectionService.ts index 973cfed7b3990..0022261152bc7 100644 --- a/src/vs/platform/agentHost/node/agentHostClientConnectionService.ts +++ b/src/vs/platform/agentHost/node/agentHostClientConnectionService.ts @@ -5,6 +5,7 @@ import { Disposable, toDisposable, type IDisposable } from '../../../base/common/lifecycle.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; +import type { IAgentHostWorkspaceTrustRequest } from '../common/agentHostExtensionProtocol.js'; export const AGENT_HOST_CLIENT_CONNECTION_HISTORY_RETENTION = 30_000 * 10; @@ -18,6 +19,7 @@ export interface IAgentHostClientConnectionSource { hasSeenClient(clientId: string): boolean; isClientConnected(clientId: string): boolean; getConnectedClientTransportCounts(): ReadonlyMap; + requestWorkspaceTrust(clientId: string, request: IAgentHostWorkspaceTrustRequest): Promise; } export const IAgentHostClientConnectionService = createDecorator('agentHostClientConnectionService'); @@ -28,6 +30,7 @@ export interface IAgentHostClientConnectionService { hasSeenClient(clientId: string): boolean; isClientConnected(clientId: string): boolean; getConnectionCounts(clientId: string): IAgentHostClientConnectionCounts; + requestWorkspaceTrust(clientId: string, request: IAgentHostWorkspaceTrustRequest): Promise; } export class AgentHostClientConnectionService extends Disposable implements IAgentHostClientConnectionService { @@ -84,4 +87,13 @@ export class AgentHostClientConnectionService extends Disposable implements IAge clientTransportCount, }; } + + requestWorkspaceTrust(clientId: string, request: IAgentHostWorkspaceTrustRequest): Promise { + for (const source of this._sources) { + if (source.isClientConnected(clientId)) { + return source.requestWorkspaceTrust(clientId, request); + } + } + return Promise.reject(new Error(`Cannot request workspace trust because client ${clientId} is not connected.`)); + } } diff --git a/src/vs/platform/agentHost/node/agentHostContributions.ts b/src/vs/platform/agentHost/node/agentHostContributions.ts index 03ec7a8b76d58..ac65d83300d90 100644 --- a/src/vs/platform/agentHost/node/agentHostContributions.ts +++ b/src/vs/platform/agentHost/node/agentHostContributions.ts @@ -7,6 +7,7 @@ import { DisposableStore } from '../../../base/common/lifecycle.js'; import { IInstantiationService, ServicesAccessor } from '../../instantiation/common/instantiation.js'; import { ILogService } from '../../log/common/log.js'; import { IAgentHostChangesetOperationService } from '../common/agentHostChangesetOperationService.js'; +import { AgentHostCheckoutOperationContribution } from './agentHostCheckoutOperationProvider.js'; import { IAgentHostStateManager } from './agentHostStateManager.js'; import { AgentHostCommitOperationContribution } from './agentHostCommitOperationProvider.js'; import { IAgentHostCompletions } from './agentHostCompletions.js'; @@ -31,6 +32,7 @@ export function activateAgentHostContributions(accessor: ServicesAccessor, insta store.add(changesetOperationService.registerContribution(instantiationService.createInstance(AgentHostMergeOperationContribution))); store.add(changesetOperationService.registerContribution(instantiationService.createInstance(AgentHostSyncOperationContribution))); store.add(changesetOperationService.registerContribution(instantiationService.createInstance(AgentHostDiscardChangesOperationContribution))); + store.add(changesetOperationService.registerContribution(instantiationService.createInstance(AgentHostCheckoutOperationContribution))); const completions = accessor.get(IAgentHostCompletions); const stateManager = accessor.get(IAgentHostStateManager); diff --git a/src/vs/platform/agentHost/node/agentHostGitService.ts b/src/vs/platform/agentHost/node/agentHostGitService.ts index d143d37623539..cb8c9ea3e49ad 100644 --- a/src/vs/platform/agentHost/node/agentHostGitService.ts +++ b/src/vs/platform/agentHost/node/agentHostGitService.ts @@ -367,6 +367,10 @@ export class AgentHostGitService implements IAgentHostGitService { await this._runGit(workingDirectory, args, { throwOnError: true }); } + async checkout(workingDirectory: URI, treeish: string): Promise { + await this._runGit(workingDirectory, ['checkout', '-q', treeish], { throwOnError: true }); + } + async hasUncommittedChanges(workingDirectory: URI): Promise { const output = await this._runGitStatus(workingDirectory, ['--porcelain']); return !!output && output.trim().length > 0; diff --git a/src/vs/platform/agentHost/node/agentHostProviderService.ts b/src/vs/platform/agentHost/node/agentHostProviderService.ts index 711e007834328..ed43a3dbc99ba 100644 --- a/src/vs/platform/agentHost/node/agentHostProviderService.ts +++ b/src/vs/platform/agentHost/node/agentHostProviderService.ts @@ -58,6 +58,7 @@ export class AgentHostProviderService extends Disposable implements IAgentHostPr private readonly _providerInitializers = new Set<(provider: IAgent) => IDisposable>(); private readonly _authenticationReplays = new Map>(); private _defaultProvider: AgentProvider | undefined; + private _shutdownStarted = false; private _shutdownPromise: Promise | undefined; constructor( @@ -77,7 +78,7 @@ export class AgentHostProviderService extends Disposable implements IAgentHostPr } registerProvider(provider: IAgent): void { - if (this._shutdownPromise) { + if (this._shutdownStarted) { throw new Error('Cannot register an agent provider after shutdown has started'); } if (this._providers.has(provider.id)) { @@ -219,14 +220,27 @@ export class AgentHostProviderService extends Disposable implements IAgentHostPr } shutdown(): Promise { - return this._shutdownPromise ??= this._shutdown(); + if (!this._shutdownPromise) { + this._shutdownStarted = true; + this._shutdownPromise = this._shutdown(); + } + return this._shutdownPromise; } private async _shutdown(): Promise { + const providers = [...this._providers.values()]; try { - await Promises.settled([...this._authenticationReplays.values()]); - await Promises.settled([...this._providers.values()].map(provider => provider.shutdown())); + await Promises.settled([ + Promises.settled([...this._authenticationReplays.values()]), + Promises.settled(providers.map(async provider => provider.shutdown())), + ]); } finally { + for (const provider of providers) { + this._providerRegistrations.deleteAndDispose(provider.id); + this._providers.deleteAndDispose(provider.id); + } + this._agents.set([], undefined); + this._defaultProvider = undefined; this._sessionToProvider.clear(); } } diff --git a/src/vs/platform/agentHost/node/agentHostServices.ts b/src/vs/platform/agentHost/node/agentHostServices.ts index 6cee16d28a531..da336c112e05a 100644 --- a/src/vs/platform/agentHost/node/agentHostServices.ts +++ b/src/vs/platform/agentHost/node/agentHostServices.ts @@ -64,6 +64,7 @@ import { EditArcReporterService, IEditArcReporterService } from './shared/editAr import { EditSurvivalReporterFactory, IEditSurvivalReporterFactory } from './shared/editSurvivalReporter.js'; import { IAgentHostWorktreeIsolation, WorktreeIsolation } from './shared/worktreeIsolation.js'; import { AgentBranchNameGenerator, IAgentBranchNameGenerator } from './shared/agentBranchNameGenerator.js'; +import { AgentHostTurnService, IAgentHostTurnService } from './agentHostTurnService.js'; export interface IAgentHostCoreServiceInputs { readonly storageResource: URI | undefined; @@ -99,6 +100,7 @@ export function registerAgentHostCoreServices(services: ServiceCollection, input services.set(IAgentHostCompletions, new SyncDescriptor(AgentHostCompletions)); services.set(IAgentHostTerminalManager, new SyncDescriptor(AgentHostTerminalManager)); services.set(IAgentHostChatContributions, new SyncDescriptor(AgentHostChatContributions)); + services.set(IAgentHostTurnService, new SyncDescriptor(AgentHostTurnService)); services.set(IAgentHostTelemetryReporter, new SyncDescriptor(AgentHostTelemetryReporter)); services.set(IAgentHostTurnTracker, new SyncDescriptor(AgentHostTurnTracker)); services.set(IAgentHostToolCallTracker, new SyncDescriptor(AgentHostToolCallTracker)); diff --git a/src/vs/platform/agentHost/node/agentHostStateManager.ts b/src/vs/platform/agentHost/node/agentHostStateManager.ts index a000fabbf8c5a..df619f6e88ac2 100644 --- a/src/vs/platform/agentHost/node/agentHostStateManager.ts +++ b/src/vs/platform/agentHost/node/agentHostStateManager.ts @@ -10,7 +10,7 @@ import { equals } from '../../../base/common/objects.js'; import { ILogService } from '../../log/common/log.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; import { TelemetryLevel } from '../../telemetry/common/telemetry.js'; -import { ActionType, ActionEnvelope, ActionOrigin, INotification, IRootConfigChangedAction, SessionAction, ChatAction, RootAction, StateAction, TerminalAction, ChangesetAction, ClientChangesetAction, AnnotationsAction, ClientAnnotationsAction, isRootAction, isSessionAction, isChatAction, isChangesetAction, isAnnotationsAction, isAutomationAction, isAutomationRunAction, isPassiveSessionMetadataAction, type AuthRequiredParams, type ClientAutomationAction, type ClientAutomationRunAction, type ProgressParams, type SessionSummaryChangedParams } from '../common/state/sessionActions.js'; +import { ActionType, ActionEnvelope, ActionOrigin, INotification, IRootConfigChangedAction, SessionAction, ChatAction, RootAction, StateAction, TerminalAction, ChangesetAction, ClientChangesetAction, AnnotationsAction, ClientAnnotationsAction, isRootAction, isSessionAction, isChatAction, isChangesetAction, isAnnotationsAction, isAutomationAction, isAutomationRunAction, isPassiveSessionMetadataAction, type AuthRequiredParams, type ClientAutomationAction, type ClientAutomationRunAction, type ProgressParams, type SessionSummaryChangedParams, type SessionSummaryChanges } from '../common/state/sessionActions.js'; import type { IStateSnapshot } from '../common/state/sessionProtocol.js'; import { rootReducer, sessionReducer, chatReducer, changesetReducer, annotationsReducer, automationReducer, automationRunReducer } from '../common/state/sessionReducers.js'; import { createRootState, createSessionState, createChatState, createDefaultChatSummary, chatSummaryFromState, buildDefaultChatUri, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseSubagentSessionUri, isAhpChatChannel, isAhpAutomationCatalogChannel, isAhpAutomationRunChannel, isDefaultChatUri, mergeSessionWithDefaultChat, isAhpRootChannel, readSessionExternal, SessionLifecycle, withHostBuildInfo, withSessionStatusFlag, type AutomationState, type AutomationRunState, type Changeset, type ChangesetState, type AnnotationsState, type ChatState, type ChatSummary, type Customization, type ISessionWithDefaultChat, type Message, type RootState, type SessionConfigState, type SessionMeta, type SessionState, type SessionSummary, type Turn, type URI, ROOT_STATE_URI, ChangesetStatus, IHostBuildInfo, SessionStatus } from '../common/state/sessionState.js'; @@ -64,6 +64,8 @@ interface ISessionEntry { readonly createdAt: string; /** Last modification timestamp (ISO 8601). Catalog-only; derived from chat aggregation. */ modifiedAt: string; + /** Host-resolved project metadata that is not part of synchronized session state. */ + project?: SessionSummary['project']; /** Aggregate file-change counts for the session-wide changeset. Catalog-only. */ changes?: ChangesSummary; /** Whether this session is still an unused draft. Latches to `Used`. */ @@ -118,7 +120,7 @@ class SessionSummaryNotifier extends Disposable { constructor( private readonly _getSummary: (session: string) => SessionSummary | undefined, - private readonly _emit: (session: string, changes: Partial) => void, + private readonly _emit: (session: string, changes: SessionSummaryChanges) => void, ) { super(); } @@ -197,10 +199,10 @@ class SessionSummaryNotifier extends Disposable { return; } - const changes: Partial = {}; + const changes: SessionSummaryChanges = {}; if (current.title !== lastNotified.title) { changes.title = current.title; } if (current.status !== lastNotified.status) { changes.status = current.status; } - if (current.activity !== lastNotified.activity) { changes.activity = current.activity; } + if (current.activity !== lastNotified.activity) { changes.activity = current.activity ?? null; } if (current.modifiedAt !== lastNotified.modifiedAt) { changes.modifiedAt = current.modifiedAt; } if (current.project !== lastNotified.project) { changes.project = current.project; } if (current.changes !== lastNotified.changes) { changes.changes = current.changes; } @@ -471,7 +473,7 @@ export class AgentHostStateManager extends Disposable { modifiedAt: entry.modifiedAt, }; if (state.activity !== undefined) { summary.activity = state.activity; } - if (state.project !== undefined) { summary.project = state.project; } + if (entry.project !== undefined) { summary.project = entry.project; } if (state.workingDirectories !== undefined) { summary.workingDirectories = state.workingDirectories; } if (state.annotations !== undefined) { summary.annotations = state.annotations; } if (entry.changes !== undefined) { summary.changes = entry.changes; } @@ -830,7 +832,7 @@ export class AgentHostStateManager extends Disposable { /** Builds the authoritative {@link ISessionEntry} for a freshly seeded state. */ private _newEntry(state: SessionState, summary: SessionSummary, use: SessionUse): ISessionEntry { - return { state, createdAt: summary.createdAt, modifiedAt: summary.modifiedAt, changes: summary.changes, use }; + return { state, createdAt: summary.createdAt, modifiedAt: summary.modifiedAt, project: summary.project, changes: summary.changes, use }; } /** @@ -841,8 +843,9 @@ export class AgentHostStateManager extends Disposable { * `workingDirectory`, `modifiedAt`, `changes`) from the supplied summary * onto the session entry so subscribers see them. The reducer-owned metadata * (`title`, `status`, `activity`) is intentionally NOT copied back — the live - * state is authoritative for those. No-ops for sessions that were already - * announced (idempotent). + * state is authoritative for those. Project remains catalog-only while the + * resolved working directories are synchronized session state. No-ops for + * sessions that were already announced (idempotent). */ markSessionPersisted(session: URI, summary: SessionSummary, force = false): void { const key = session.toString(); @@ -855,11 +858,12 @@ export class AgentHostStateManager extends Disposable { return; } // Propagate the materialization-resolved fields so subscribers calling - // `getSessionState` / `getSessionSummary` see the resolved working - // directory / project. We don't need to schedule a + // `getSessionSummary` sees the resolved project and both state and + // summary see the resolved working directory. We don't need to schedule a // `SessionSummaryChanged` flush because the upcoming `SessionAdded` // notification carries the complete summary already. - entry.state = { ...entry.state, project: summary.project, workingDirectories: summary.workingDirectories }; + entry.state = { ...entry.state, workingDirectories: summary.workingDirectories }; + entry.project = summary.project; entry.modifiedAt = summary.modifiedAt; entry.changes = summary.changes; const full = this._toSummary(key, entry); @@ -1386,6 +1390,20 @@ export class AgentHostStateManager extends Disposable { this.dispatchServerAction(session, { type: ActionType.SessionMetaChanged, _meta: meta }); } + /** Updates catalog-only host-resolved project metadata. */ + setSessionProject(session: URI, project: SessionSummary['project']): void { + const entry = this._sessionStates.get(session); + if (!entry) { + this._logService.warn(`[AgentHostStateManager] setSessionProject: unknown session ${session}`); + return; + } + if (equals(entry.project, project)) { + return; + } + entry.project = project; + this._summaryNotifier.markDirty(session); + } + /** * Seeds or replaces a session's resolved {@link SessionConfigState} on the * live session state. Unlike mid-session {@link ActionType.SessionConfigChanged} diff --git a/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts b/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts index f8d91f3aaea88..708d7481967c8 100644 --- a/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts +++ b/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts @@ -24,16 +24,18 @@ import { AgentHostClientConnectionKind, AgentHostLaunchKind, AgentHostTransportK export type AgentHostUserMessageSentSource = 'direct' | 'queued'; /** - * Who produced the message that started a turn. Extends the protocol's - * {@link MessageKind} with `agentMerge`: Agent Merge drives its repair turns - * with a host-generated message that carries the `systemNotification` origin, - * and reporting those under their own value keeps automated merge work - * separable from turns a person or an agent asked for. + * The message origin used for telemetry. Extends the protocol's + * {@link MessageKind} with host-owned classifications for Agent Merge repair + * turns and VS Code sessions marked ephemeral, which telemetry reports as + * `inline`. */ -export type AgentHostMessageOriginTelemetryKind = MessageKind | 'agentMerge'; +export type AgentHostMessageOriginTelemetryKind = MessageKind | 'agentMerge' | 'inline'; -/** Classifies the actor that produced a turn's message for telemetry. */ -export function getMessageOriginTelemetryKind(message: Message): AgentHostMessageOriginTelemetryKind { +/** Classifies a turn's message origin, including host-owned session classifications. */ +export function getMessageOriginTelemetryKind(message: Message, isEphemeralSession: boolean): AgentHostMessageOriginTelemetryKind { + if (isEphemeralSession) { + return 'inline'; + } // The marker only counts on the origin the host stamps it with, so a client // cannot dress a user message up as automated merge work. if (message.origin.kind === MessageKind.SystemNotification && isAgentMergeMessage(message)) { @@ -123,7 +125,7 @@ export type IAgentHostUserMessageSentClassification = IAgentHostCopilotSkuClassi initiatorDevDeviceId?: { classification: 'EndUserPseudonymizedInformation'; purpose: 'BusinessInsight'; endpoint: 'SqmMachineId'; comment: 'The initiating VS Code client development device identifier.' }; agentSessionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The agent host session identifier.' }; source: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the message was sent directly or from the queued-message flow.' }; - messageOriginKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The kind of actor that produced the message: a user, an agent (session orchestration tools such as create_session/send_message), Agent Merge, a tool, an automation, or a system notification.' }; + messageOriginKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The message origin: a user, an agent (session orchestration tools such as create_session/send_message), Agent Merge, an inline session (derived from the VS Code ephemeral-session marker), a tool, an automation, or a system notification.' }; isSubagentSession: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether the message was sent to a subagent session.' }; turnCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The number of completed turns in the session when the message was sent.' }; activeClientId?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The identifier of the first active client for the session, if any.' }; @@ -249,7 +251,7 @@ export type IAgentHostTurnCompletedClassification = IAgentHostEventClassificatio isBYOK: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether the selected model is a bring-your-own-key model, when model context is available.' }; permissionLevel: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The tool auto-approval level configured for the session at turn start (e.g. default, autoApprove, autopilot).' }; interactionMode: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The agent host interaction mode configured at turn start.' }; - messageOriginKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The kind of actor that started the turn: a user, an agent (session orchestration tools such as create_session/create_chat/send_message), Agent Merge, a tool, an automation, or a system notification.' }; + messageOriginKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The message origin that started the turn: a user, an agent (session orchestration tools such as create_session/create_chat/send_message), Agent Merge, an inline session (derived from the VS Code ephemeral-session marker), a tool, an automation, or a system notification.' }; errorType: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The structured agent host or provider error type when the turn fails.' }; failureStage: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The bounded stage at which the agent host turn failed.' }; isMultiRoot: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether the session spans more than one working directory.' }; @@ -270,6 +272,7 @@ export interface IAgentHostTurnFailedEvent extends IAgentHostEventTelemetry { chatSessionId: string; isSubagentSession: boolean; turnId: string; + messageOriginKind: AgentHostMessageOriginTelemetryKind | undefined; failureStage: AgentHostTurnFailureStage; errorType: string; errorName: string | undefined; @@ -286,6 +289,7 @@ export type IAgentHostTurnFailedClassification = IAgentHostEventClassification & chatSessionId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The chat identifier within the agent host session.' }; isSubagentSession: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Whether the failed turn belongs to a subagent session.' }; turnId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The identifier of the failed turn within the agent host session.' }; + messageOriginKind: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The kind of actor or host-owned session classification that started the failed turn.' }; failureStage: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The bounded stage at which the agent host turn failed.' }; errorType: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The structured agent host or provider error type.' }; errorName: { classification: 'CallstackOrException'; purpose: 'PerformanceAndHealth'; comment: 'The name of the exception, when available.' }; @@ -406,6 +410,7 @@ export interface IAgentHostTurnHungEvent extends IAgentHostEventTelemetry { chatSessionId: string; isSubagentSession: boolean; turnId: string; + messageOriginKind: AgentHostMessageOriginTelemetryKind | undefined; hangReason: AgentHostTurnHangReason; isExpected: boolean; hadAnyProgress: boolean; @@ -433,6 +438,7 @@ export type IAgentHostTurnHungClassification = IAgentHostEventClassification & { chatSessionId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The chat identifier within the agent host session.' }; isSubagentSession: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Whether the hung turn belongs to a subagent session.' }; turnId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The identifier of the hung turn within the agent host session.' }; + messageOriginKind: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The kind of actor or host-owned session classification that started the hung turn.' }; hangReason: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The bounded state the turn was quiet in: noProgress, stalledAfterProgress, waitingOnUser, or runningTool.' }; isExpected: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Whether the quiet period is explained by a legitimate wait (blocked on the user or running a tool) rather than an unexplained hang.' }; hadAnyProgress: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Whether any turn activity at all was observed before the watchdog fired.' }; @@ -460,6 +466,7 @@ export interface IAgentHostTurnHungReport extends IAgentHostTurnAttributedReport provider: string; session: string; turnId: string; + messageOriginKind: AgentHostMessageOriginTelemetryKind | undefined; hangReason: AgentHostTurnHangReason; hadAnyProgress: boolean; lastActivityKind: string; @@ -485,6 +492,7 @@ export interface IAgentHostHungTurnCompletedEvent extends IAgentHostEventTelemet chatSessionId: string; isSubagentSession: boolean; turnId: string; + messageOriginKind: AgentHostMessageOriginTelemetryKind | undefined; hangReason: AgentHostTurnHangReason; result: AgentHostTurnResult; hangReportCount: number; @@ -498,6 +506,7 @@ export type IAgentHostHungTurnCompletedClassification = IAgentHostEventClassific chatSessionId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The chat identifier within the agent host session.' }; isSubagentSession: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Whether the recovered turn belongs to a subagent session.' }; turnId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The identifier of the recovered turn within the agent host session.' }; + messageOriginKind: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The kind of actor or host-owned session classification that started the recovered turn.' }; hangReason: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The most recently reported hang reason for the turn before it completed.' }; result: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Whether the previously hung turn eventually completed successfully, with an error, or was cancelled.' }; hangReportCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Number of hang reports emitted for the turn before it completed.' }; @@ -511,6 +520,7 @@ export interface IAgentHostHungTurnCompletedReport extends IAgentHostTurnAttribu provider: string; session: string; turnId: string; + messageOriginKind: AgentHostMessageOriginTelemetryKind | undefined; hangReason: AgentHostTurnHangReason; result: AgentHostTurnResult; hangReportCount: number; @@ -886,7 +896,7 @@ export class AgentHostTelemetryReporter { }); } - userMessageSent(provider: string, clientId: string | undefined, clientContext: IAgentHostClientTelemetryContext, session: string, turnId: string, sessionState: ISessionWithDefaultChat | undefined, source: AgentHostUserMessageSentSource, message: Message): void { + userMessageSent(provider: string, clientId: string | undefined, clientContext: IAgentHostClientTelemetryContext, session: string, turnId: string, sessionState: ISessionWithDefaultChat | undefined, source: AgentHostUserMessageSentSource, message: Message, isEphemeralSession: boolean): void { const attachmentCount = message.attachments?.length ?? 0; const activeClients = sessionState?.activeClients ?? []; const sessionUri = isAhpChatChannel(session) ? parseRequiredSessionUriFromChatUri(session) : session; @@ -901,7 +911,7 @@ export class AgentHostTelemetryReporter { ...(clientContext.devDeviceId ? { initiatorDevDeviceId: clientContext.devDeviceId } : {}), agentSessionId: AgentSession.id(sessionUri), source, - messageOriginKind: getMessageOriginTelemetryKind(message), + messageOriginKind: getMessageOriginTelemetryKind(message, isEphemeralSession), isSubagentSession: isSubagentSession(sessionUri), turnCount: sessionState?.turns.length ?? 0, ...(activeClients.length > 0 ? { @@ -916,7 +926,7 @@ export class AgentHostTelemetryReporter { initiatorClientType: clientContext.clientType, conversationId: AgentSession.id(sessionUri), turnId, - messageOriginKind: getMessageOriginTelemetryKind(message), + messageOriginKind: getMessageOriginTelemetryKind(message, isEphemeralSession), }); } @@ -1270,6 +1280,7 @@ export class AgentHostTelemetryReporter { chatSessionId, isSubagentSession: isSubagent, turnId: report.turnId, + messageOriginKind: report.messageOriginKind, failureStage: report.failure.stage, errorType: report.failure.error.errorType, errorName: report.failure.errorName, @@ -1296,6 +1307,7 @@ export class AgentHostTelemetryReporter { chatSessionId: getTelemetryChatSessionId(report.session), isSubagentSession: isSubagentChatUri(report.session) || isSubagentSession(session), turnId: report.turnId, + messageOriginKind: report.messageOriginKind, hangReason: report.hangReason, isExpected: report.hangReason === 'waitingOnUser' || report.hangReason === 'runningTool', hadAnyProgress: report.hadAnyProgress, @@ -1330,6 +1342,7 @@ export class AgentHostTelemetryReporter { chatSessionId: getTelemetryChatSessionId(report.session), isSubagentSession: isSubagentChatUri(report.session) || isSubagentSession(session), turnId: report.turnId, + messageOriginKind: report.messageOriginKind, hangReason: report.hangReason, result: report.result, hangReportCount: report.hangReportCount, diff --git a/src/vs/platform/agentHost/node/agentHostTurnService.ts b/src/vs/platform/agentHost/node/agentHostTurnService.ts new file mode 100644 index 0000000000000..6042be45b381a --- /dev/null +++ b/src/vs/platform/agentHost/node/agentHostTurnService.ts @@ -0,0 +1,166 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { StopWatch } from '../../../base/common/stopwatch.js'; +import { URI } from '../../../base/common/uri.js'; +import { generateUuid } from '../../../base/common/uuid.js'; +import { createDecorator, IInstantiationService } from '../../instantiation/common/instantiation.js'; +import { AgentHostClientType } from '../common/agentHostClientInfo.js'; +import { createUnknownAgentHostClientTelemetryContext, type IAgentHostClientTelemetryContext } from '../common/agentHostTelemetry.js'; +import { IAgentHostChatContributions } from '../common/agentHostChatContributionsService.js'; +import { ActionType } from '../common/state/sessionActions.js'; +import type { ChatTurnStartedAction } from '../common/state/protocol/actions.js'; +import { createErrorResponsePart, parseRequiredSessionUriFromChatUri, type ErrorInfo, type Message, type URI as ProtocolURI } from '../common/state/sessionState.js'; +import { AgentHostStateManager, IAgentHostStateManager } from './agentHostStateManager.js'; +import { startTurn } from './agentHostTurnStarter.js'; + +export const IAgentHostTurnService = createDecorator('agentHostTurnService'); + +/** An active host-authored turn whose provider execution has not started yet. */ +export interface IDeferredAgentHostTurn { + readonly turnId: string; +} + +/** Starts host-authored turns through the standard admission and provider-send path. */ +export interface IAgentHostTurnService { + readonly _serviceBrand: undefined; + startTurnMessage(chat: URI, message: Message): void; + beginDeferredTurnMessage(chat: URI, message: Message): IDeferredAgentHostTurn; + continueDeferredTurnMessage(chat: URI, turn: IDeferredAgentHostTurn, message: Message): boolean; + failDeferredTurnMessage(chat: URI, turn: IDeferredAgentHostTurn, error: ErrorInfo): boolean; + handleTurnStarted(channel: ProtocolURI, action: ChatTurnStartedAction, clientId?: string, clientContextOrType?: IAgentHostClientTelemetryContext | AgentHostClientType): void; +} + +/** Standard turn admission and provider routing shared by client- and host-authored turns. */ +export class AgentHostTurnService implements IAgentHostTurnService { + + declare readonly _serviceBrand: undefined; + + private readonly _deferredTurns = new Map(); + + constructor( + @IAgentHostStateManager private readonly _stateManager: AgentHostStateManager, + @IAgentHostChatContributions private readonly _chatContributions: IAgentHostChatContributions, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + ) { } + + startTurnMessage(chat: URI, message: Message): void { + const channel = chat.toString(); + const action = this._dispatchTurnStarted(channel, message); + this.handleTurnStarted(channel, action); + } + + beginDeferredTurnMessage(chat: URI, message: Message): IDeferredAgentHostTurn { + const channel = chat.toString(); + if (this._deferredTurns.has(channel) || this._stateManager.getActiveTurnId(channel) !== undefined) { + throw new Error(`Cannot defer another turn while a turn is active: ${channel}`); + } + const action = this._dispatchTurnStarted(channel, message); + this._deferredTurns.set(channel, action); + return { turnId: action.turnId }; + } + + continueDeferredTurnMessage(chat: URI, turn: IDeferredAgentHostTurn, message: Message): boolean { + const channel = chat.toString(); + const action = this._getDeferredTurn(channel, turn); + if (!action) { + return false; + } + if (this._stateManager.getActiveTurnId(channel) !== action.turnId) { + this._deferredTurns.delete(channel); + return false; + } + this.handleTurnStarted(channel, { ...action, message }); + this._deferredTurns.delete(channel); + return true; + } + + failDeferredTurnMessage(chat: URI, turn: IDeferredAgentHostTurn, error: ErrorInfo): boolean { + const channel = chat.toString(); + const action = this._getDeferredTurn(channel, turn); + if (!action) { + return false; + } + if (this._stateManager.getActiveTurnId(channel) !== action.turnId) { + this._deferredTurns.delete(channel); + return false; + } + this._stateManager.dispatchServerAction(channel, { + type: ActionType.ChatError, + turnId: action.turnId, + duration: Math.max(0, Date.now() - Date.parse(action.startedAt)), + part: createErrorResponsePart(error), + }); + this._chatContributions.turnEnd({ + session: parseRequiredSessionUriFromChatUri(channel), + channel, + turnId: action.turnId, + reason: { kind: 'error', error, resumable: false }, + }); + this._deferredTurns.delete(channel); + return true; + } + + handleTurnStarted(channel: ProtocolURI, action: ChatTurnStartedAction, clientId?: string, clientContextOrType?: IAgentHostClientTelemetryContext | AgentHostClientType): void { + const host = this._chatContributions.getHost(); + if (!host) { + throw new Error('Agent Host turn routing is unavailable.'); + } + const sessionChannel = parseRequiredSessionUriFromChatUri(channel); + const turnStopWatch = StopWatch.create(false); + const clientContext = clientContextOrType === undefined + ? { + ...createUnknownAgentHostClientTelemetryContext(AgentHostClientType.Unknown), + hostLaunchKind: host.hostLaunchKind, + } + : typeof clientContextOrType === 'string' + ? createUnknownAgentHostClientTelemetryContext(clientContextOrType) + : clientContextOrType; + const started = this._instantiationService.invokeFunction(startTurn, { + session: sessionChannel, + chat: channel, + turnChannel: channel, + turnId: action.turnId, + message: action.message, + source: 'direct', + clientId, + clientContext, + turnStopWatch, + }); + if (!started) { + return; + } + host.sendTurnMessage({ + agent: started.agent, + sessionChannel, + turnChannel: channel, + chat: channel, + message: action.message, + turnId: action.turnId, + senderClientId: clientId, + clientContext, + turnStopWatch, + }); + } + + private _dispatchTurnStarted(channel: ProtocolURI, message: Message): ChatTurnStartedAction { + const action: ChatTurnStartedAction = { + type: ActionType.ChatTurnStarted, + turnId: generateUuid(), + startedAt: new Date().toISOString(), + message, + }; + this._stateManager.dispatchServerAction(channel, action); + return action; + } + + private _getDeferredTurn(channel: ProtocolURI, turn: IDeferredAgentHostTurn): ChatTurnStartedAction | undefined { + const action = this._deferredTurns.get(channel); + if (action?.turnId !== turn.turnId) { + return undefined; + } + return action; + } +} diff --git a/src/vs/platform/agentHost/node/agentHostTurnStarter.ts b/src/vs/platform/agentHost/node/agentHostTurnStarter.ts index 8c0987167dc7b..0f1af0d02a480 100644 --- a/src/vs/platform/agentHost/node/agentHostTurnStarter.ts +++ b/src/vs/platform/agentHost/node/agentHostTurnStarter.ts @@ -116,8 +116,10 @@ export function startTurn(accessor: ServicesAccessor, request: ITurnStartRequest return undefined; } - telemetryReporter.userMessageSent(agent.id, request.clientId, request.clientContext, request.chat, request.turnId, state, request.source, request.message); + const isEphemeralSession = stateManager.isEphemeralSession(request.session); + const messageOriginKind = getMessageOriginTelemetryKind(request.message, isEphemeralSession); + telemetryReporter.userMessageSent(agent.id, request.clientId, request.clientContext, request.chat, request.turnId, state, request.source, request.message, isEphemeralSession); const { model, modelTelemetryKind, modelSelectionKind, permissionLevel, interactionMode } = getTurnTelemetryContext(agent, request.chat, createAgentChatContext(stateManager, request.session, request.chat), state, request.message.model?.id); - turnTracker.turnStarted(agent, request.chat, request.turnId, model, modelTelemetryKind, modelSelectionKind, permissionLevel, interactionMode, request.clientContext, request.clientId, undefined, undefined, getMessageOriginTelemetryKind(request.message)); + turnTracker.turnStarted(agent, request.chat, request.turnId, model, modelTelemetryKind, modelSelectionKind, permissionLevel, interactionMode, request.clientContext, request.clientId, undefined, undefined, messageOriginKind); return { agent }; } diff --git a/src/vs/platform/agentHost/node/agentHostTurnTracker.ts b/src/vs/platform/agentHost/node/agentHostTurnTracker.ts index 81caac77519b0..aa28ae421a9e4 100644 --- a/src/vs/platform/agentHost/node/agentHostTurnTracker.ts +++ b/src/vs/platform/agentHost/node/agentHostTurnTracker.ts @@ -370,6 +370,10 @@ export class AgentHostTurnTracker extends Disposable { return this._turnTimings.get(this._key(session, turnId))?.clientContext; } + getMessageOriginKind(session: string, turnId: string): AgentHostMessageOriginTelemetryKind | undefined { + return this._turnTimings.get(this._key(session, turnId))?.messageOriginKind; + } + getInitiatorClientId(session: string, turnId: string): string | undefined { return this._turnTimings.get(this._key(session, turnId))?.initiatorClientId; } @@ -424,6 +428,7 @@ export class AgentHostTurnTracker extends Disposable { provider: timing.agent.id, session: timing.session, turnId, + messageOriginKind: timing.messageOriginKind, hangReason: timing.lastHangReason, result, hangReportCount: timing.hangReportCount, @@ -505,6 +510,7 @@ export class AgentHostTurnTracker extends Disposable { provider: timing.agent.id, session: timing.session, turnId: timing.turnId, + messageOriginKind: timing.messageOriginKind, hangReason, hadAnyProgress: timing.lastActivityKind !== TURN_ACTIVITY_NONE, lastActivityKind: timing.lastActivityKind, diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 9f571b2644672..c337fa86c6203 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -37,7 +37,7 @@ import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } f import { AhpErrorCodes, AHP_SESSION_NOT_FOUND, ContentEncoding, JSON_RPC_INTERNAL_ERROR, ProtocolError, ResourceChangeType, ResourceType, ResourceWriteMode, type CreateResourceWatchParams, type CreateResourceWatchResult, type DirectoryEntry, type ResourceCopyParams, type ResourceCopyResult, type ResourceDeleteParams, type ResourceDeleteResult, type ResourceListResult, type ResourceMkdirParams, type ResourceMkdirResult, type ResourceMoveParams, type ResourceMoveResult, type ResourceReadResult, type ResourceResolveParams, type ResourceResolveResult, type ResourceWatchState, type ResourceWriteParams, type ResourceWriteResult, type IStateSnapshot } from '../common/state/sessionProtocol.js'; import { ChangesSummary, ChatInteractivity, ChatOriginKind, MessageAttachmentKind, type Annotation, type AnnotationEntry, type AnnotationOrigin, type AnnotationsState, type ChatOrigin, type Customization, type Message, type MessageAttachment, type MessageResourceAttachment, type TextRange } from '../common/state/protocol/state.js'; import type { ChatPendingMessageSetAction, ChatTurnStartedAction, SessionConfigChangedAction } from '../common/state/protocol/actions.js'; -import { isAhpAutomationCatalogChannel, isAhpAutomationRunChannel, ISessionGitHubState, ISessionGitState, MessageKind, ResponsePartKind, SESSION_META_GITHUB_KEY, SESSION_META_GIT_KEY, SESSION_META_MULTI_ROOT_KEY, SESSION_META_SOURCE_CONTROL_KEY, AH_META_CREATED_BY_SESSION_DB_KEY, readSessionCreationReference, readSessionSpawnDepth, withSessionSpawnDepth, withSessionCreationReference, parseSessionCreationReference, SessionLifecycle, SessionStatus, ToolCallStatus, ToolResultContentType, TurnState, AH_META_WORKSPACELESS_DB_KEY, AH_META_EHCLI_ADOPTED_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_DONE_DB_KEY, AH_META_IS_READ_DB_KEY, buildChatUri, buildDefaultChatUri, buildResourceWatchChannelUri, buildSubagentChatUri, buildSubagentSessionUriPrefix, getErrorResponsePart, isAhpChatChannel, isChatReadOnly, isDefaultChatUri, isSubagentChatUri, isSubagentSession, needsSessionGitStateRefresh, parseChatUri, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseResourceWatchChannelUri, parseSessionMultiRootMetadata, parseSubagentSessionUri, readSessionExternal, readSessionGitHubState, readSessionGitState, readSessionMultiRootMetadata, readSessionSourceControlState, readSessionWorkspaceless, withMessageRequestHiddenFromTranscript, withSessionExternal, withSessionGitHubState, withSessionGitState, withSessionMultiRootMetadata, withSessionSourceControlState, withSessionStatusFlag, withSessionWorkspaceless, withSessionEhcliAdopted, withSessionEhcliLastMigratedTurn, AH_META_EHCLI_LAST_TURN_DB_KEY, withSessionFolderPickerDecision, readSessionFolderPickerDecision, parseSessionFolderPickerDecision, SESSION_META_FOLDER_PICKER_KEY, readSessionEhcliAdoptable, type ISessionSourceControlState, type SessionConfigState, type SessionSummary, type ToolResultSubagentContent, type Turn } from '../common/state/sessionState.js'; +import { isAhpAutomationCatalogChannel, isAhpAutomationRunChannel, ISessionGitHubState, ISessionGitState, MessageKind, ResponsePartKind, SESSION_META_GITHUB_KEY, SESSION_META_GIT_KEY, SESSION_META_MULTI_ROOT_KEY, SESSION_META_SOURCE_CONTROL_KEY, AH_META_CREATED_BY_SESSION_DB_KEY, readSessionCreationReference, readSessionSpawnDepth, withSessionSpawnDepth, withSessionCreationReference, parseSessionCreationReference, SessionLifecycle, SessionStatus, ToolCallStatus, ToolResultContentType, TurnState, AH_META_WORKSPACE_CONVERSION_QUARANTINED_DB_KEY, AH_META_WORKSPACELESS_DB_KEY, AH_META_EHCLI_ADOPTED_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_DONE_DB_KEY, AH_META_IS_READ_DB_KEY, buildChatUri, buildDefaultChatUri, buildResourceWatchChannelUri, buildSubagentChatUri, buildSubagentSessionUriPrefix, getErrorResponsePart, isAhpChatChannel, isChatReadOnly, isDefaultChatUri, isSubagentChatUri, isSubagentSession, needsSessionGitStateRefresh, parseChatUri, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseResourceWatchChannelUri, parseSessionMultiRootMetadata, parseSubagentSessionUri, readSessionExternal, readSessionGitHubState, readSessionGitState, readSessionMultiRootMetadata, readSessionSourceControlState, readSessionWorkspaceless, withMessageRequestHiddenFromTranscript, withSessionExternal, withSessionGitHubState, withSessionGitState, withSessionMultiRootMetadata, withSessionSourceControlState, withSessionStatusFlag, withSessionWorkspaceless, withSessionEhcliAdopted, withSessionEhcliLastMigratedTurn, AH_META_EHCLI_LAST_TURN_DB_KEY, withSessionFolderPickerDecision, readSessionFolderPickerDecision, parseSessionFolderPickerDecision, SESSION_META_FOLDER_PICKER_KEY, readSessionEhcliAdoptable, type ISessionSourceControlState, type SessionConfigState, type SessionSummary, type ToolResultSubagentContent, type Turn } from '../common/state/sessionState.js'; import { readToolCallMeta } from '../common/meta/agentToolCallMeta.js'; import { isHostSnapshotAttachment, toHostSnapshotAttachmentMeta } from '../common/meta/agentSnapshotAttachmentMeta.js'; import { readEphemeralSessionMeta, withEphemeralSessionMeta } from '../common/meta/agentEphemeralSessionMeta.js'; @@ -64,7 +64,7 @@ import { AgentHostLocalTurns } from './agentHostLocalTurns.js'; import { AgentSessionResidency } from './agentSessionResidency.js'; import { IAgentHostSessionOpenTelemetry, type IAgentHostSessionOpenTelemetryScope } from './agentHostSessionOpenTelemetry.js'; import { AgentServerToolHost } from './shared/agentServerToolHost.js'; -import { type IChatContextSnapshot, type IRenameTitleResult, type ISessionCreationDefaults, type ISessionServerToolAccessor, validateRenameTitle } from './shared/sessionServerTools.js'; +import { type IAgentServiceSessionServerToolAccessor, type IChatContextSnapshot, type IRenameTitleResult, type ISessionCreationDefaults, validateRenameTitle } from './shared/sessionServerTools.js'; import { AGENT_HOST_TITLE_SOURCE_AGENT, customChatTitleMetadataKey, customChatTitleSourceMetadataKey, persistSessionMetadata, persistSessionMetadataValues, SESSION_ARTIFACTS_KEY, SESSION_CUSTOM_TITLE_KEY, SESSION_CUSTOM_TITLE_SOURCE_KEY } from './shared/persistSessionMetadata.js'; import { type IArtifactServerToolAccessor } from './shared/artifactServerTools.js'; import { parseSessionArtifacts, stringifySessionArtifacts, withSessionArtifacts, type ISessionArtifact } from '../common/sessionArtifacts.js'; @@ -96,6 +96,7 @@ import { IAgentHostChangesetService, CHANGESET_DB_METADATA_KEYS, META_CHANGES_SU import { GIT_DB_METADATA_KEYS, IAgentHostGitStateService, META_GIT_STATE, META_GITHUB_STATE, META_SOURCE_CONTROL_STATE } from '../common/agentHostGitStateService.js'; import { IAgentHostChangesetOperationService } from '../common/agentHostChangesetOperationService.js'; import { IAgentHostChatContributions } from '../common/agentHostChatContributionsService.js'; +import { IAgentHostTurnService } from './agentHostTurnService.js'; import { IAgentHostStorageService } from './agentHostStorageService.js'; /** @@ -400,7 +401,7 @@ export interface IAgentServiceCallbacks { readonly resolveChatAttachmentTurns: NonNullable; readonly getSessionMetadata: (session: URI) => Promise; readonly restoreSession: (session: URI) => Promise; - readonly sessionServerToolAccessor: ISessionServerToolAccessor; + readonly sessionServerToolAccessor: IAgentServiceSessionServerToolAccessor; readonly artifactServerToolAccessor: IArtifactServerToolAccessor; } @@ -621,6 +622,7 @@ export class AgentService extends Disposable implements IAgentService { @IInstantiationService instantiationService: IInstantiationService, @IAgentHostWorktreeIsolation private readonly _worktree: IAgentHostWorktreeIsolation, @IAgentHostProviderService private readonly _providerService: IAgentHostProviderService, + @IAgentHostTurnService private readonly _turnService: IAgentHostTurnService, @IAgentHostStorageService private readonly _storageService: IAgentHostStorageService, ) { super(); @@ -1169,9 +1171,11 @@ export class AgentService extends Disposable implements IAgentService { * Builds the dependency surface the session server-tool group needs, bound * to this service so the group stays decoupled from the concrete host. */ - private _createSessionServerToolAccessor(): ISessionServerToolAccessor { + private _createSessionServerToolAccessor(): IAgentServiceSessionServerToolAccessor { return { isActiveAgentTitleGenerationEnabled: () => this._isActiveAgentTitleGenerationEnabled(), + canConvertWorkspace: session => this._providerService.getProviderForSession(session)?.agentHostCapabilities.workspaceConversion === true + && readSessionWorkspaceless(this._stateManager.getSessionState(session.toString())?._meta), listSessions: () => this.listSessions(), getSession: session => this._getSessionMetadata(session), createSession: config => this.createSession(config), @@ -1276,9 +1280,7 @@ export class AgentService extends Disposable implements IAgentService { } private async _startSessionMessage(chat: URI, message: Message): Promise { - const action = { type: ActionType.ChatTurnStarted, turnId: generateUuid(), startedAt: new Date().toISOString(), message } as const; - this._stateManager.dispatchServerAction(chat.toString(), action); - this._sideEffects.handleAction(chat.toString(), action); + this._turnService.startTurnMessage(chat, message); } private async _cancelAutomationSession(session: URI): Promise { @@ -5183,6 +5185,9 @@ export class AgentService extends Disposable implements IAgentService { if (await this._sessionRegistry.isTombstoned(session)) { throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Session was explicitly deleted: ${sessionStr}`); } + if (await this._isWorkspaceConversionQuarantined(session)) { + throw new ProtocolError(JSON_RPC_INTERNAL_ERROR, `Session is unavailable because its provider could not be detached from an untrusted working directory: ${sessionStr}`); + } let registeredSession = await this._sessionRegistry.get(session, entry => this._migrateRegisteredSession(entry)); if (registeredSession) { this._providerService.associateSession(session, registeredSession.provider); @@ -6068,6 +6073,18 @@ export class AgentService extends Disposable implements IAgentService { } } + private async _isWorkspaceConversionQuarantined(session: URI): Promise { + const ref = await this._sessionDataService.tryOpenDatabase?.(session); + if (!ref) { + return false; + } + try { + return await ref.object.getMetadata(AH_META_WORKSPACE_CONVERSION_QUARANTINED_DB_KEY) === 'true'; + } finally { + ref.dispose(); + } + } + /** * Reads the orchestrator's persisted peer-chat catalog for a session. * Returns `undefined` when the session has no catalog yet (a legacy session diff --git a/src/vs/platform/agentHost/node/agentServiceComposition.ts b/src/vs/platform/agentHost/node/agentServiceComposition.ts index 61317402fe8fe..2d152857ced59 100644 --- a/src/vs/platform/agentHost/node/agentServiceComposition.ts +++ b/src/vs/platform/agentHost/node/agentServiceComposition.ts @@ -8,6 +8,7 @@ import { DisposableStore, type IDisposable, MutableDisposable } from '../../../b import type { IObservable } from '../../../base/common/observable.js'; import { dirname, joinPath } from '../../../base/common/resources.js'; import { IInstantiationService, ServicesAccessor } from '../../instantiation/common/instantiation.js'; +import { ServiceCollection } from '../../instantiation/common/serviceCollection.js'; import { ILogService } from '../../log/common/log.js'; import { IAgentHostChangesetOperationService } from '../common/agentHostChangesetOperationService.js'; import { IAgentHostChangesetService } from '../common/agentHostChangesetService.js'; @@ -33,10 +34,13 @@ import { AgentMergeTools } from './agentMergeTools.js'; import { AgentService, type IAgentServiceCollaborators, type IAgentServiceCore, type IAgentServiceOptions } from './agentService.js'; import { AgentSessionRegistry } from './agentSessionRegistry.js'; import { AgentSideEffects } from './agentSideEffects.js'; -import { AgentServerToolHost } from './shared/agentServerToolHost.js'; +import { AgentServerToolHost, IAgentHostServerToolService } from './shared/agentServerToolHost.js'; import { buildServerToolGroups } from './shared/serverToolGroups.js'; +import type { ISessionServerToolAccessor } from './shared/sessionServerTools.js'; import { type IAgentServiceFoundation } from './agentServiceFoundation.js'; import { IAgentHostProviderService } from './agentHostProviderService.js'; +import { ISessionWorkspaceConversionService, SessionWorkspaceConversionService } from './chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionService.js'; +import { IAgentHostTurnTracker } from './agentHostTurnTracker.js'; export interface IAgentServiceComposition { readonly agentService: AgentService; @@ -66,6 +70,7 @@ export function createAgentServiceComposition( options: IAgentServiceOptions, accessor: ServicesAccessor, instantiationService: IInstantiationService, + services: ServiceCollection, logService: ILogService, sessionDataService: ISessionDataService, foundation: IAgentServiceFoundation, @@ -138,10 +143,28 @@ export function createAgentServiceComposition( () => agentMergeController.isEnabled(), session => agentMergeController.getTurnContext(session), ); + const turnTracker = accessor.get(IAgentHostTurnTracker); + const workspaceConversionService: { value: ISessionWorkspaceConversionService | undefined } = { value: undefined }; + const sessionServerToolAccessor: ISessionServerToolAccessor = { + ...callbackAdapter.sessionServerToolAccessor, + requestSessionWorkspaceUpdate: (chat, turnId, workspaceFolder, isolation) => { + const initiatingClientId = turnTracker.getInitiatorClientId(chat.toString(), turnId); + if (!initiatingClientId) { + throw new Error('Session workspace conversion requires a turn initiated by a connected VS Code client.'); + } + if (!workspaceConversionService.value) { + throw new Error('Session workspace conversion is unavailable.'); + } + workspaceConversionService.value.requestSessionWorkspaceUpdate(chat, turnId, workspaceFolder, isolation, initiatingClientId); + }, + }; const serverToolHost = new AgentServerToolHost( stateManager, - buildServerToolGroups(callbackAdapter.sessionServerToolAccessor, agentMergeTools, callbackAdapter.artifactServerToolAccessor), + buildServerToolGroups(sessionServerToolAccessor, agentMergeTools, callbackAdapter.artifactServerToolAccessor), ); + services.set(IAgentHostServerToolService, serverToolHost); + workspaceConversionService.value = owned.add(instantiationService.createInstance(SessionWorkspaceConversionService)); + services.set(ISessionWorkspaceConversionService, workspaceConversionService.value); const automationService = owned.add(instantiationService.createInstance(AgentHostAutomationService, callbackAdapter.automationExecution)); const collaborators: IAgentServiceCollaborators = { diff --git a/src/vs/platform/agentHost/node/agentServiceFoundation.ts b/src/vs/platform/agentHost/node/agentServiceFoundation.ts index 2a4f02b6989b6..8506160d570ad 100644 --- a/src/vs/platform/agentHost/node/agentServiceFoundation.ts +++ b/src/vs/platform/agentHost/node/agentServiceFoundation.ts @@ -21,7 +21,7 @@ import { AgentHostProxyResolver, IAgentHostProxyResolver } from './agentHostProx import { AgentHostRequestService } from './agentHostRequestService.js'; import { AgentHostStateManager, IAgentHostStateManager } from './agentHostStateManager.js'; import type { IArtifactServerToolAccessor } from './shared/artifactServerTools.js'; -import type { ISessionServerToolAccessor } from './shared/sessionServerTools.js'; +import type { IAgentServiceSessionServerToolAccessor } from './shared/sessionServerTools.js'; import { hostBuildInfoFromProduct } from '../common/state/sessionState.js'; export class AgentServiceCallbackAdapter implements IAgentServiceCallbackBinder { @@ -34,8 +34,9 @@ export class AgentServiceCallbackAdapter implements IAgentServiceCallbackBinder cancelSession: session => this.value.automationExecution.cancelSession(session), }; - readonly sessionServerToolAccessor: ISessionServerToolAccessor = { + readonly sessionServerToolAccessor: IAgentServiceSessionServerToolAccessor = { isActiveAgentTitleGenerationEnabled: () => this.value.sessionServerToolAccessor.isActiveAgentTitleGenerationEnabled(), + canConvertWorkspace: session => this.value.sessionServerToolAccessor.canConvertWorkspace(session), listSessions: () => this.value.sessionServerToolAccessor.listSessions(), getSession: session => this.value.sessionServerToolAccessor.getSession(session), createSession: config => this.value.sessionServerToolAccessor.createSession(config), diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index 969e653613441..4766171dbf730 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -72,13 +72,13 @@ import { IAgentHostSessionTitleController } from './agentHostSessionTitleControl import { AgentHostStateManager, resolveChatStateForUri } from './agentHostStateManager.js'; import { IAgentConfigurationService } from './agentConfigurationService.js'; import { createAgentChatContext, getSessionChatsForFanOut } from './agentChatContext.js'; -import { AgentHostTelemetryReporter, IAgentHostTelemetryReporter, type AgentHostTurnFailureStage, type AgentHostTurnResult, type IAgentHostTurnFailure } from './agentHostTelemetryReporter.js'; +import { AgentHostTelemetryReporter, getMessageOriginTelemetryKind, IAgentHostTelemetryReporter, type AgentHostMessageOriginTelemetryKind, type AgentHostTurnFailureStage, type AgentHostTurnResult, type IAgentHostTurnFailure } from './agentHostTelemetryReporter.js'; import { AgentHostToolCallTracker, IAgentHostToolCallTracker } from './agentHostToolCallTracker.js'; import { updateAgentHostTelemetryLevelFromConfig } from './agentHostTelemetryService.js'; import { getConfiguredSessionMode, getModelTelemetryContext, getTurnTelemetryContext } from './agentHostTurnTelemetryContext.js'; import { AgentHostTurnTracker, IAgentHostTurnTracker } from './agentHostTurnTracker.js'; +import { IAgentHostTurnService } from './agentHostTurnService.js'; import type { IAgentHostCustomizationEnablementService } from './agentHostCustomizationEnablementService.js'; -import { startTurn } from './agentHostTurnStarter.js'; import './localCommands/localChatCommands.contribution.js'; import { SessionPermissionManager } from './sessionPermissions.js'; import { stripProxyErrorMarker, toChatErrorMeta, tryParseForwardedChatError } from './shared/proxyChatError.js'; @@ -131,6 +131,7 @@ interface ISubagentSessionRef { interface ISubagentParentTurnTelemetryContext { readonly parentTurnId: string | undefined; readonly parentClientContext: IAgentHostClientTelemetryContext | undefined; + readonly messageOriginKind: AgentHostMessageOriginTelemetryKind; /** Hierarchy edge; set only when the immediate parent chat has an active turn, else omitted. */ readonly correlatedParentTurnId: string | undefined; readonly initiatorClientId: string | undefined; @@ -237,6 +238,7 @@ export class AgentSideEffects extends Disposable { @IAgentHostTurnTracker private readonly _turnTracker: AgentHostTurnTracker, @IAgentHostToolCallTracker private readonly _toolCallTracker: AgentHostToolCallTracker, @IAgentHostWorktreeIsolation private readonly _worktree: IAgentHostWorktreeIsolation, + @IAgentHostTurnService private readonly _turnService: IAgentHostTurnService, ) { super(); this.onDidStartTurn = this._turnTracker.onDidStartTurn; @@ -987,7 +989,7 @@ export class AgentSideEffects extends Disposable { // supplied by the provider on the `subagent_started` signal. const turnId = generateUuid(); const parentTurnId = this._stateManager.getActiveTurnId(contentChatUri); - const { parentClientContext, correlatedParentTurnId, initiatorClientId } = this._getSubagentParentTurnTelemetryContext(immediateParentChatUri, contentChatUri); + const { parentClientContext, correlatedParentTurnId, initiatorClientId, messageOriginKind } = this._getSubagentParentTurnTelemetryContext(immediateParentChatUri, contentChatUri); this._stateManager.dispatchServerAction(subagentChatUri, { type: ActionType.ChatTurnStarted, turnId, @@ -997,7 +999,7 @@ export class AgentSideEffects extends Disposable { const agent = this._options.getAgent(parentSessionUri); if (agent) { const interactionMode = getConfiguredSessionMode(this._stateManager.getSessionState(parentSessionUri)?.config); - this._turnTracker.turnStarted(agent, subagentChatUri, turnId, undefined, undefined, 'default', undefined, interactionMode, parentClientContext, initiatorClientId, correlatedParentTurnId, toolCallId, MessageKind.Tool); + this._turnTracker.turnStarted(agent, subagentChatUri, turnId, undefined, undefined, 'default', undefined, interactionMode, parentClientContext, initiatorClientId, correlatedParentTurnId, toolCallId, messageOriginKind); this._turnTracker.setCurrentStage(subagentChatUri, turnId, 'provider'); } @@ -1062,7 +1064,7 @@ export class AgentSideEffects extends Disposable { const turnId = generateUuid(); const correlatedParentChatUri = immediateParentChatURI ?? subagent.immediateParentChatUri; const parentChatUri = correlatedParentChatUri ?? parentChatURI; - const { parentClientContext, correlatedParentTurnId, initiatorClientId } = this._getSubagentParentTurnTelemetryContext(correlatedParentChatUri, parentChatUri); + const { parentClientContext, correlatedParentTurnId, initiatorClientId, messageOriginKind } = this._getSubagentParentTurnTelemetryContext(correlatedParentChatUri, parentChatUri); this._logService.info(`[AgentSideEffects] Resuming subagent turn: ${subagent.chatUri} (parent=${parentChatURI}, toolCallId=${toolCallId})`); this._stateManager.dispatchServerAction(subagent.chatUri, { type: ActionType.ChatTurnStarted, @@ -1073,7 +1075,7 @@ export class AgentSideEffects extends Disposable { const agent = this._options.getAgent(subagent.sessionUri); if (agent) { const interactionMode = getConfiguredSessionMode(this._stateManager.getSessionState(subagent.sessionUri)?.config); - this._turnTracker.turnStarted(agent, subagent.chatUri, turnId, undefined, undefined, 'default', undefined, interactionMode, parentClientContext, initiatorClientId, correlatedParentTurnId, toolCallId, MessageKind.Tool); + this._turnTracker.turnStarted(agent, subagent.chatUri, turnId, undefined, undefined, 'default', undefined, interactionMode, parentClientContext, initiatorClientId, correlatedParentTurnId, toolCallId, messageOriginKind); this._turnTracker.setCurrentStage(subagent.chatUri, turnId, 'provider'); } this._subagentChats.set({ ...subagent, immediateParentChatUri: correlatedParentChatUri, turnStopWatch: StopWatch.create(false) }, parentChatURI, toolCallId); @@ -1082,9 +1084,14 @@ export class AgentSideEffects extends Disposable { private _getSubagentParentTurnTelemetryContext(immediateParentChatUri: ProtocolURI | undefined, fallbackParentChatUri: ProtocolURI): ISubagentParentTurnTelemetryContext { const parentChatUri = immediateParentChatUri ?? fallbackParentChatUri; const parentTurnId = this._stateManager.getActiveTurnId(parentChatUri); + const parentSessionUri = parseRequiredSessionUriFromChatUri(parentChatUri); + const parentMessageOriginKind = parentTurnId ? this._turnTracker.getMessageOriginKind(parentChatUri, parentTurnId) : undefined; return { parentTurnId, parentClientContext: parentTurnId ? this._turnTracker.getClientTelemetryContext(parentChatUri, parentTurnId) : undefined, + messageOriginKind: parentMessageOriginKind === 'inline' || (!parentMessageOriginKind && this._stateManager.isEphemeralSession(parentSessionUri)) + ? 'inline' + : MessageKind.Tool, correlatedParentTurnId: immediateParentChatUri ? parentTurnId : undefined, initiatorClientId: parentTurnId ? this._turnTracker.getInitiatorClientId(parentChatUri, parentTurnId) : undefined, }; @@ -1344,32 +1351,7 @@ export class AgentSideEffects extends Disposable { if (!chatChannel) { throw new Error(`ChatTurnStarted must be handled on an AHP chat channel: ${channel}`); } - const turnStopWatch = StopWatch.create(false); - const started = this._instantiationService.invokeFunction(startTurn, { - session: sessionChannel, - chat: channel, - turnChannel: channel, - turnId: action.turnId, - message: action.message, - source: 'direct', - clientId, - clientContext, - turnStopWatch, - }); - if (!started) { - break; - } - void this._sendTurnMessage({ - agent: started.agent, - sessionChannel, - turnChannel: channel, - chat: channel, - message: action.message, - turnId: action.turnId, - senderClientId: clientId, - clientContext, - turnStopWatch, - }); + this._turnService.handleTurnStarted(channel, action, clientId, clientContext); break; } case ActionType.ChatTurnResume: { @@ -1382,7 +1364,7 @@ export class AgentSideEffects extends Disposable { } const state = this._stateManager.getSessionState(channel); const { model, modelTelemetryKind, modelSelectionKind, permissionLevel, interactionMode } = getTurnTelemetryContext(agent, channel, this._chatContext(sessionChannel, channel), state, resumedTurn.message.model?.id); - this._turnTracker.turnStarted(agent, channel, action.turnId, model, modelTelemetryKind, modelSelectionKind, permissionLevel, interactionMode, clientContext, clientId); + this._turnTracker.turnStarted(agent, channel, action.turnId, model, modelTelemetryKind, modelSelectionKind, permissionLevel, interactionMode, clientContext, clientId, undefined, undefined, getMessageOriginTelemetryKind(resumedTurn.message, this._stateManager.isEphemeralSession(sessionChannel))); this._turnTracker.setCurrentStage(channel, action.turnId, 'provider'); const key = this._resumedTurnExecutionKey(channel, action.turnId); const execution: IResumedTurnExecution = { diff --git a/src/vs/platform/agentHost/node/chatContributions/builtInChatContributions.ts b/src/vs/platform/agentHost/node/chatContributions/builtInChatContributions.ts index 61cd96cc1b834..928bd79d829cd 100644 --- a/src/vs/platform/agentHost/node/chatContributions/builtInChatContributions.ts +++ b/src/vs/platform/agentHost/node/chatContributions/builtInChatContributions.ts @@ -14,6 +14,7 @@ import { LocalCommandContribution } from './localCommand/localCommandContributio import { MarkdownPlanRichLinksContribution } from './markdownPlanRichLinks/markdownPlanRichLinksContribution.js'; import { MarkUnreadContribution } from './markUnread/markUnreadContribution.js'; import { PersistedTurnUsageContribution } from './persistedTurnUsage/persistedTurnUsageContribution.js'; +import { SessionWorkspaceConversionContribution } from './sessionWorkspaceConversion/sessionWorkspaceConversionContribution.js'; import { QueueDrainContribution } from './queueDrain/queueDrainContribution.js'; import { SessionFlagsContribution } from './sessionFlags/sessionFlagsContribution.js'; import { SessionInputNeededContribution } from './sessionInputNeeded/sessionInputNeededContribution.js'; @@ -34,6 +35,7 @@ export function registerBuiltInChatContributions( registrations.add(contributions.registerContribution(PersistedTurnUsageContribution)); registrations.add(contributions.registerContribution(WorktreeAnnouncementContribution)); registrations.add(contributions.registerContribution(CheckpointAndChangesetContribution)); + registrations.add(contributions.registerContribution(SessionWorkspaceConversionContribution)); registrations.add(contributions.registerContribution(QueueDrainContribution)); registrations.add(contributions.registerContribution(SessionInputNeededContribution)); registrations.add(contributions.registerContribution(GitHubReferencesContribution)); diff --git a/src/vs/platform/agentHost/node/chatContributions/queueDrain/queueDrainContribution.ts b/src/vs/platform/agentHost/node/chatContributions/queueDrain/queueDrainContribution.ts index 0761febd2102a..769cc4bec90ee 100644 --- a/src/vs/platform/agentHost/node/chatContributions/queueDrain/queueDrainContribution.ts +++ b/src/vs/platform/agentHost/node/chatContributions/queueDrain/queueDrainContribution.ts @@ -17,6 +17,7 @@ import { getErrorResponsePart, isAhpChatChannel, parseRequiredSessionUriFromChat import { AgentHostStateManager, IAgentHostStateManager } from '../../agentHostStateManager.js'; import { IAgentHostProviderService } from '../../agentHostProviderService.js'; import { startTurn } from '../../agentHostTurnStarter.js'; +import { ISessionWorkspaceConversionService } from '../sessionWorkspaceConversion/sessionWorkspaceConversionService.js'; const QueuedSender = createChatMementoKey('queueDrain.sender', () => undefined); @@ -33,6 +34,7 @@ export class QueueDrainContribution extends Disposable implements IAgentHostChat @IAgentHostStateManager private readonly _stateManager: AgentHostStateManager, @IAgentHostProviderService private readonly _providerService: IAgentHostProviderService, @IInstantiationService private readonly _instantiationService: IInstantiationService, + @ISessionWorkspaceConversionService private readonly _conversionService: ISessionWorkspaceConversionService, ) { super(); } @@ -89,6 +91,9 @@ export class QueueDrainContribution extends Disposable implements IAgentHostChat } private _tryConsumeNextQueuedMessage(channel: ProtocolURI): void { + if (this._conversionService.isPending(channel)) { + return; + } if (this._stateManager.getActiveTurnId(channel)) { return; } diff --git a/src/vs/platform/agentHost/node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionContribution.ts b/src/vs/platform/agentHost/node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionContribution.ts new file mode 100644 index 0000000000000..4e23de05aa600 --- /dev/null +++ b/src/vs/platform/agentHost/node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionContribution.ts @@ -0,0 +1,45 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { localize } from '../../../../../nls.js'; +import type { IAgentHostChatContribution, IAgentHostChatContributionContext, IIncomingRequest, IncomingRequestDisposition, ITurnEnd } from '../../../common/agentHostChatContributionsService.js'; +import { ISessionWorkspaceConversionService } from './sessionWorkspaceConversionService.js'; + +/** Finalizes requested workspace conversions after a turn and blocks new turns while conversion is pending. */ +export class SessionWorkspaceConversionContribution extends Disposable implements IAgentHostChatContribution { + + static readonly id = 'sessionWorkspaceConversion'; + readonly order = 150; + + constructor( + protected readonly _context: IAgentHostChatContributionContext, + @ISessionWorkspaceConversionService private readonly _conversionService: ISessionWorkspaceConversionService, + ) { + super(); + } + + onTurnEnd(turn: ITurnEnd): void { + if (turn.reason.kind === 'success') { + void this._conversionService.updateSessionWorkspace(turn.channel, turn.turnId); + } else { + this._conversionService.cancel(turn.channel, turn.turnId); + } + } + + onIncomingRequest(request: IIncomingRequest): IncomingRequestDisposition | undefined { + if (!this._conversionService.isPending(request.chat)) { + return undefined; + } + return { + kind: 'reject', + error: { + errorType: 'workspaceConversionPending', + message: localize('agentHost.workspaceConversionPending', "Wait for workspace setup to finish before sending another message."), + }, + stage: 'validation', + }; + } +} diff --git a/src/vs/platform/agentHost/node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionService.ts b/src/vs/platform/agentHost/node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionService.ts new file mode 100644 index 0000000000000..604699116fd22 --- /dev/null +++ b/src/vs/platform/agentHost/node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionService.ts @@ -0,0 +1,531 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { toErrorMessage } from '../../../../../base/common/errorMessage.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { Schemas } from '../../../../../base/common/network.js'; +import { equals } from '../../../../../base/common/objects.js'; +import { isEqual } from '../../../../../base/common/resources.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { localize } from '../../../../../nls.js'; +import { createDecorator } from '../../../../instantiation/common/instantiation.js'; +import { ILogService } from '../../../../log/common/log.js'; +import { AgentSession, AgentWorkingDirectoryChangedError, type IAgent, type IAgentSessionProjectInfo } from '../../../common/agent.js'; +import { ISessionDataService } from '../../../common/sessionDataService.js'; +import { SessionConfigKey } from '../../../common/sessionConfigKeys.js'; +import { ActionType } from '../../../common/state/sessionActions.js'; +import { AH_META_WORKSPACE_CONVERSION_QUARANTINED_DB_KEY, AH_META_WORKSPACELESS_DB_KEY, buildDefaultChatUri, isDefaultChatUri, MessageKind, parseChatUri, readSessionWorkspaceless, ResponsePartKind, SessionStatus, withMessageSystemInitiatedLabel, withSessionWorkspaceless, type ISessionWithDefaultChat, type SessionConfigState, type URI as ProtocolURI } from '../../../common/state/sessionState.js'; +import { AgentHostStateManager, IAgentHostStateManager } from '../../agentHostStateManager.js'; +import { IAgentHostClientConnectionService } from '../../agentHostClientConnectionService.js'; +import { IAgentHostProviderService } from '../../agentHostProviderService.js'; +import { IAgentHostTurnService, type IDeferredAgentHostTurn } from '../../agentHostTurnService.js'; +import { IAgentHostServerToolService } from '../../shared/agentServerToolHost.js'; +import { IAgentHostWorktreeIsolation, type IIsolationConfigContribution } from '../../shared/worktreeIsolation.js'; + +interface IPendingSessionWorkspaceConversion { + readonly chat: URI; + readonly turnId: string; + readonly workspaceFolder: URI; + readonly isolation: boolean; + readonly initiatingClientId: string; + readonly prompt: string | undefined; + phase: 'requested' | 'converting'; + resolvedWorkingDirectory?: URI; +} + +interface IResolvedWorkspace { + readonly workingDirectory: URI; + readonly configValues: Record; + readonly isolationConfig: IIsolationConfigContribution | undefined; + readonly isolated: boolean; + readonly project: IAgentSessionProjectInfo | undefined; +} + +class UnsafeProviderWorkingDirectoryError extends Error { +} + +export const ISessionWorkspaceConversionService = createDecorator('sessionWorkspaceConversionService'); + +/** Coordinates requested workspace changes after the requesting turn has finished. */ +export interface ISessionWorkspaceConversionService { + readonly _serviceBrand: undefined; + requestSessionWorkspaceUpdate(chat: URI, turnId: string, workspaceFolder: URI, isolation: boolean, initiatingClientId: string): void; + isPending(chat: ProtocolURI): boolean; + cancel(chat: ProtocolURI, turnId: string | undefined): void; + updateSessionWorkspace(chat: ProtocolURI, turnId: string | undefined): Promise; +} + +/** Converts workspace-less sessions in place while preserving their session and chat identities. */ +export class SessionWorkspaceConversionService extends Disposable implements ISessionWorkspaceConversionService { + + declare readonly _serviceBrand: undefined; + + private readonly _pending = new Map(); + private readonly _quarantined = new Set(); + + constructor( + @IAgentHostStateManager private readonly _stateManager: AgentHostStateManager, + @IAgentHostProviderService private readonly _providerService: IAgentHostProviderService, + @ISessionDataService private readonly _sessionDataService: ISessionDataService, + @IAgentHostWorktreeIsolation private readonly _worktreeIsolation: IAgentHostWorktreeIsolation, + @IAgentHostClientConnectionService private readonly _clientConnections: IAgentHostClientConnectionService, + @IAgentHostTurnService private readonly _turnService: IAgentHostTurnService, + @IAgentHostServerToolService private readonly _serverToolHost: IAgentHostServerToolService, + @ILogService private readonly _logService: ILogService, + ) { + super(); + this._register(this._stateManager.onDidRemoveSession(session => { + const chat = buildDefaultChatUri(session); + this._pending.delete(chat); + this._quarantined.delete(chat); + })); + } + + requestSessionWorkspaceUpdate(chat: URI, turnId: string, workspaceFolder: URI, isolation: boolean, initiatingClientId: string): void { + if (!initiatingClientId) { + throw new Error('Session workspace conversion requires an initiating client.'); + } + this._validateConversion(chat, workspaceFolder); + const activeTurnId = this._stateManager.getActiveTurnId(chat.toString()); + if (activeTurnId !== turnId) { + throw new Error('Session workspace conversion must be requested from the active turn.'); + } + const prompt = this._stateManager.getChatState(chat.toString())?.activeTurn?.message.text; + const key = chat.toString(); + if (this.isPending(key)) { + throw new Error('A workspace conversion is already pending for this session.'); + } + this._pending.set(key, { chat, turnId, workspaceFolder, isolation, initiatingClientId, prompt, phase: 'requested' }); + } + + isPending(chat: ProtocolURI): boolean { + return this._pending.has(chat) || this._quarantined.has(chat); + } + + cancel(chat: ProtocolURI, turnId: string | undefined): void { + const pending = this._pending.get(chat); + if (pending && pending.turnId === turnId && pending.phase === 'requested') { + this._pending.delete(chat); + } + } + + async updateSessionWorkspace(chat: ProtocolURI, turnId: string | undefined): Promise { + const pending = this._pending.get(chat); + if (!pending || pending.turnId !== turnId || pending.phase !== 'requested') { + return; + } + + pending.phase = 'converting'; + let continuation: IDeferredAgentHostTurn | undefined; + try { + continuation = this._beginContinuation(pending); + pending.resolvedWorkingDirectory = await this._convert(pending.chat, pending.workspaceFolder, pending.isolation, pending.initiatingClientId, pending.prompt); + this._pending.delete(chat); + this._continueConversion(continuation, pending, true); + } catch (error) { + this._logService.error(`[SessionWorkspaceConversionService] Failed to convert ${pending.chat.toString()}: ${toErrorMessage(error)}`); + if (error instanceof UnsafeProviderWorkingDirectoryError) { + this._pending.delete(chat); + this._quarantined.add(chat); + this._failConversion(continuation, pending, error); + } else { + this._pending.delete(chat); + this._continueConversion(continuation, pending, false, error); + } + } + } + + private async _convert(chat: URI, workspaceFolder: URI, isolation: boolean, initiatingClientId: string, prompt?: string): Promise { + const { session, state, previousWorkingDirectory } = this._validateConversion(chat, workspaceFolder); + const provider = this._providerService.getProviderForSession(session); + if (!provider?.agentHostCapabilities.workspaceConversion) { + throw new Error(`Provider does not support changing the working directory: ${AgentSession.provider(session) ?? '(unknown)'}`); + } + await this._requireWorkspaceTrust(initiatingClientId, workspaceFolder); + const resolvedWorkspace = await this._resolveWorkspace(session, chat, workspaceFolder, isolation, initiatingClientId, prompt, state.config?.values); + let authoritativeWorkingDirectory = resolvedWorkspace.workingDirectory; + let providerAlignmentError: AgentWorkingDirectoryChangedError | undefined; + try { + await provider.setWorkingDirectory(chat, session, resolvedWorkspace.workingDirectory); + } catch (error) { + if (!(error instanceof AgentWorkingDirectoryChangedError)) { + const cleanupError = resolvedWorkspace.isolated ? await this._removeWorktree(session) : undefined; + if (cleanupError) { + throw new Error(`${toErrorMessage(error)}; failed to clean up the isolated worktree: ${toErrorMessage(cleanupError)}`); + } + throw error; + } + authoritativeWorkingDirectory = error.workingDirectory; + providerAlignmentError = error; + } + if (!isEqual(authoritativeWorkingDirectory, resolvedWorkspace.workingDirectory)) { + try { + await this._requireWorkspaceTrust(initiatingClientId, authoritativeWorkingDirectory); + } catch (error) { + const finalizationErrors = [error]; + const disposal = await this._disposeUnsafeProviderChat(provider, chat, session); + finalizationErrors.push(...disposal.errors); + if (resolvedWorkspace.isolated) { + const cleanupError = await this._removeWorktree(session); + if (cleanupError) { + finalizationErrors.push(cleanupError); + } + } + throw new UnsafeProviderWorkingDirectoryError(`The provider changed to an untrusted working directory and was disposed: ${finalizationErrors.map(error => toErrorMessage(error)).join('; ')}`); + } + } + + const convertedState = this._getUnchangedConversionState(session, chat, previousWorkingDirectory); + if (!convertedState) { + const disposal = await this._disposeUnsafeProviderChat(provider, chat, session); + const finalizationErrors = [...disposal.errors]; + if (resolvedWorkspace.isolated) { + const cleanupError = await this._removeWorktree(session); + if (cleanupError) { + finalizationErrors.push(cleanupError); + } + } + throw new UnsafeProviderWorkingDirectoryError(`The workspace-less session state changed after the provider working directory changed, so the provider was disposed${finalizationErrors.length > 0 ? `: ${finalizationErrors.map(error => toErrorMessage(error)).join('; ')}` : ''}`); + } + const worktreeApplied = resolvedWorkspace.isolated && isEqual(authoritativeWorkingDirectory, resolvedWorkspace.workingDirectory); + const worktreeCleanupError = resolvedWorkspace.isolated && !worktreeApplied ? await this._removeWorktree(session) : undefined; + const configPatch: Record = worktreeApplied + ? { + [SessionConfigKey.Isolation]: 'worktree', + [SessionConfigKey.Branch]: resolvedWorkspace.configValues[SessionConfigKey.Branch], + } + : { [SessionConfigKey.Isolation]: 'folder' }; + const configValues = convertedState.config || worktreeApplied + ? { ...convertedState.config?.values, ...configPatch } + : undefined; + let persistenceError: unknown; + const database = this._sessionDataService.openDatabase(session); + try { + const metadata = { [AH_META_WORKSPACELESS_DB_KEY]: 'false' }; + if (configValues) { + Object.assign(metadata, { configValues: JSON.stringify(configValues) }); + } + await database.object.setMetadataValues(metadata); + } catch (error) { + persistenceError = error; + } finally { + database.dispose(); + } + if (persistenceError) { + const finalizationErrors = [persistenceError]; + const quarantineError = await this._persistQuarantine(session); + if (quarantineError) { + finalizationErrors.push(quarantineError); + } + throw new UnsafeProviderWorkingDirectoryError(`The provider working directory changed, but the converted session metadata could not be committed atomically: ${finalizationErrors.map(error => toErrorMessage(error)).join('; ')}`); + } + + const finalState = this._getUnchangedConversionState(session, chat, previousWorkingDirectory, convertedState); + if (!finalState) { + const disposal = await this._disposeUnsafeProviderChat(provider, chat, session); + const finalizationErrors = [...disposal.errors]; + if (resolvedWorkspace.isolated) { + const cleanupError = await this._removeWorktree(session); + if (cleanupError) { + finalizationErrors.push(cleanupError); + } + } + throw new UnsafeProviderWorkingDirectoryError(`The workspace-less session state changed while converted metadata was being persisted, so the provider was disposed and the session was quarantined${finalizationErrors.length > 0 ? `: ${finalizationErrors.map(error => toErrorMessage(error)).join('; ')}` : ''}`); + } + + if (worktreeApplied && resolvedWorkspace.project) { + this._stateManager.setSessionProject(session.toString(), { + uri: resolvedWorkspace.project.uri.toString(), + displayName: resolvedWorkspace.project.displayName, + }); + } + this._stateManager.setSessionMeta(session.toString(), withSessionWorkspaceless(finalState._meta, false)); + this._stateManager.dispatchServerAction(session.toString(), { + type: ActionType.SessionWorkingDirectoryReplaced, + directory: previousWorkingDirectory, + replacement: authoritativeWorkingDirectory.toString(), + }); + this._updateIsolationConfig(session, finalState.config, configPatch, resolvedWorkspace.isolationConfig, worktreeApplied); + this._serverToolHost.advertise(session.toString()); + try { + const customizations = await provider.getChatCustomizations(chat, session); + this._stateManager.dispatchServerAction(session.toString(), { + type: ActionType.SessionCustomizationsChanged, + customizations: [...customizations], + }); + } catch (error) { + this._logService.error(`[SessionWorkspaceConversionService] Failed to refresh customizations for ${session.toString()}: ${toErrorMessage(error)}`); + } + const finalizationErrors: unknown[] = []; + if (providerAlignmentError) { + finalizationErrors.push(providerAlignmentError); + } + if (worktreeCleanupError) { + finalizationErrors.push(worktreeCleanupError); + } + if (finalizationErrors.length > 0) { + throw new Error(`The workspace changed to '${authoritativeWorkingDirectory.fsPath}', but conversion did not complete cleanly: ${finalizationErrors.map(error => toErrorMessage(error)).join('; ')}`); + } + return authoritativeWorkingDirectory; + } + + private async _requireWorkspaceTrust(clientId: string, workspace: URI, trustedParent?: URI): Promise { + const trusted = await this._clientConnections.requestWorkspaceTrust(clientId, { + workspace: workspace.toString(), + ...(trustedParent ? { trustedParent: trustedParent.toString() } : {}), + }); + if (!trusted) { + throw new Error(`Workspace trust was not granted for '${workspace.fsPath}'`); + } + } + + private async _persistQuarantine(session: URI): Promise { + const database = this._sessionDataService.openDatabase(session); + try { + await database.object.setMetadata(AH_META_WORKSPACE_CONVERSION_QUARANTINED_DB_KEY, 'true'); + return undefined; + } catch (error) { + return error; + } finally { + database.dispose(); + } + } + + private async _disposeUnsafeProviderChat(provider: IAgent, chat: URI, session: URI): Promise<{ readonly errors: readonly unknown[] }> { + const errors: unknown[] = []; + const quarantineError = await this._persistQuarantine(session); + if (quarantineError) { + errors.push(quarantineError); + } + try { + await provider.chats.releaseChat(chat, session); + } catch (error) { + errors.push(error); + } + try { + await provider.chats.disposeChat(chat, session); + } catch (error) { + errors.push(error); + } + return { errors }; + } + + private async _resolveWorkspace( + session: URI, + chat: URI, + workspaceFolder: URI, + isolation: boolean, + initiatingClientId: string, + prompt: string | undefined, + currentConfig: Record | undefined, + ): Promise { + if (!isolation) { + return { + workingDirectory: workspaceFolder, + configValues: { ...currentConfig, [SessionConfigKey.Isolation]: 'folder' }, + isolationConfig: undefined, + isolated: false, + project: undefined, + }; + } + if (!this._worktreeIsolation.supported) { + throw new Error('Isolated worktrees are not supported by this Agent Host.'); + } + + const requestedConfig: Record = { ...currentConfig, [SessionConfigKey.Isolation]: 'worktree' }; + delete requestedConfig[SessionConfigKey.Branch]; + const isolationConfig = await this._worktreeIsolation.resolveIsolationConfig({ + workingDirectory: workspaceFolder, + config: requestedConfig, + }); + if (!isolationConfig || isolationConfig.isolationValue !== 'worktree' || !isolationConfig.branchValue) { + throw new Error('An isolated worktree requires a local Git repository with at least one commit.'); + } + const configValues = { + ...requestedConfig, + [SessionConfigKey.Branch]: isolationConfig.branchValue, + }; + const workingDirectory = await this._worktreeIsolation.resolveOnFirstSend({ + sessionUri: session, + sessionId: AgentSession.id(session), + workingDirectory: workspaceFolder, + config: configValues, + prompt, + onWillCreate: async metadata => { + if (!isEqual(metadata.repositoryRoot, workspaceFolder)) { + await this._requireWorkspaceTrust(initiatingClientId, metadata.repositoryRoot); + } + await this._requireWorkspaceTrust(initiatingClientId, metadata.worktreePath, metadata.repositoryRoot); + }, + }); + if (!workingDirectory || isEqual(workingDirectory, workspaceFolder)) { + throw new Error('The isolated worktree could not be created.'); + } + this._worktreeIsolation.takePendingAnnouncement(AgentSession.id(session)); + const project = this._worktreeIsolation.sessionWorktreeProject(AgentSession.id(session)); + if (!project) { + const cleanupError = await this._removeWorktree(session); + throw new Error(cleanupError + ? `The isolated worktree project could not be resolved, and cleanup failed: ${toErrorMessage(cleanupError)}` + : 'The isolated worktree project could not be resolved.'); + } + return { workingDirectory, configValues, isolationConfig, isolated: true, project }; + } + + private async _removeWorktree(session: URI): Promise { + const sessionId = AgentSession.id(session); + try { + const worktree = await this._worktreeIsolation.prepareSessionDeletion(session, sessionId); + await this._worktreeIsolation.discardSessionWorktree(session, sessionId, worktree); + return undefined; + } catch (error) { + return error; + } + } + + private _updateIsolationConfig( + session: URI, + currentConfig: SessionConfigState | undefined, + configPatch: Record, + isolationConfig: IIsolationConfigContribution | undefined, + worktreeApplied: boolean, + ): void { + if (worktreeApplied && isolationConfig) { + const properties = { + ...currentConfig?.schema.properties, + [SessionConfigKey.Isolation]: isolationConfig.isolationProperty.protocol, + ...(isolationConfig.branchProperty ? { [SessionConfigKey.Branch]: isolationConfig.branchProperty.protocol } : {}), + ...(isolationConfig.worktreeBranchPrefixProperty ? { [SessionConfigKey.WorktreeBranchPrefix]: isolationConfig.worktreeBranchPrefixProperty.protocol } : {}), + ...(isolationConfig.worktreeBranchTrackProperty ? { [SessionConfigKey.WorktreeBranchTrack]: isolationConfig.worktreeBranchTrackProperty.protocol } : {}), + ...(isolationConfig.worktreeCreateNewBranchProperty ? { [SessionConfigKey.WorktreeCreateNewBranch]: isolationConfig.worktreeCreateNewBranchProperty.protocol } : {}), + ...(isolationConfig.worktreeIncludeFilesProperty ? { [SessionConfigKey.WorktreeIncludeFiles]: isolationConfig.worktreeIncludeFilesProperty.protocol } : {}), + }; + this._stateManager.setSessionConfig(session.toString(), { + schema: { type: 'object', properties }, + values: { ...currentConfig?.values }, + }); + } + if (this._stateManager.getSessionState(session.toString())?.config) { + this._stateManager.dispatchServerAction(session.toString(), { + type: ActionType.SessionConfigChanged, + config: configPatch, + }); + } + } + + private _getUnchangedConversionState(session: URI, chat: URI, previousWorkingDirectory: ProtocolURI, expectedState?: ISessionWithDefaultChat): ISessionWithDefaultChat | undefined { + const state = this._stateManager.getSessionState(session.toString()); + if (!state + || this._pending.get(chat.toString())?.phase !== 'converting' + || !readSessionWorkspaceless(state._meta) + || (state.status & SessionStatus.IsArchived) === SessionStatus.IsArchived + || state.defaultChat !== chat.toString() + || state.workingDirectories?.length !== 1 + || state.workingDirectories[0] !== previousWorkingDirectory + || (expectedState && (!equals(state._meta, expectedState._meta) || !equals(state.config, expectedState.config) || !equals(state.project, expectedState.project))) + ) { + return undefined; + } + return state; + } + + private _validateConversion(chat: URI, workspaceFolder: URI) { + const parsedChat = parseChatUri(chat); + if (!parsedChat) { + throw new Error(`Cannot change the working directory for invalid chat resource: ${chat.toString()}`); + } + if (workspaceFolder.scheme !== Schemas.file || !workspaceFolder.path.startsWith('/')) { + throw new Error('The workspace folder must be an absolute local path or file URI.'); + } + const session = URI.parse(parsedChat.session, true); + const state = this._stateManager.getSessionState(session.toString()); + if (!state) { + throw new Error(`Cannot change the working directory for unknown session: ${session.toString()}`); + } + if (!readSessionWorkspaceless(state._meta)) { + throw new Error('Only a workspace-less session can be converted to a workspace session.'); + } + if ((state.status & SessionStatus.IsArchived) === SessionStatus.IsArchived) { + throw new Error('An archived session cannot be converted to a workspace session.'); + } + if (!isDefaultChatUri(chat) || state.defaultChat !== chat.toString()) { + throw new Error('Only the owning default chat can convert the session to a workspace session.'); + } + if (state.workingDirectories?.length !== 1) { + throw new Error('A workspace-less session must have exactly one working directory before conversion.'); + } + return { session, state, previousWorkingDirectory: state.workingDirectories[0] }; + } + + private _beginContinuation(pending: IPendingSessionWorkspaceConversion): IDeferredAgentHostTurn { + const continuation = this._turnService.beginDeferredTurnMessage(pending.chat, withMessageSystemInitiatedLabel({ + text: localize('agentHost.continueInWorkspaceMessage', "Continue in the requested workspace."), + origin: { kind: MessageKind.SystemNotification }, + }, localize('agentHost.continueInWorkspaceLabel', "Continue in Requested Workspace"))); + return continuation; + } + + private _continueConversion(continuation: IDeferredAgentHostTurn | undefined, pending: IPendingSessionWorkspaceConversion, converted: boolean, error?: unknown): void { + if (!continuation) { + this._logService.error(`[SessionWorkspaceConversionService] Cannot continue workspace conversion for ${pending.chat.toString()} because its deferred turn did not start.`); + return; + } + const errorMessage = error === undefined ? undefined : toErrorMessage(error).replace(/\.+$/, ''); + const text = converted + ? `The current session is now attached to ${(pending.resolvedWorkingDirectory ?? pending.workspaceFolder).fsPath}${pending.isolation ? ' in an isolated worktree' : ''}. Continue the user's original task in this workspace. Do not request another session or workspace conversion.` + : `The requested workspace setup did not complete successfully: ${errorMessage}. Do not run the user's task. Tell the user that workspace setup failed and include this error.`; + const label = converted + ? localize('agentHost.workspaceSetLabel', "Workspace Set") + : localize('agentHost.workspaceSetupFailedLabel', "Workspace Setup Failed"); + this._publishConversionOutcome(pending.chat, continuation, label); + try { + if (!this._turnService.continueDeferredTurnMessage(pending.chat, continuation, withMessageSystemInitiatedLabel({ + text, + origin: { kind: MessageKind.SystemNotification }, + }, label))) { + this._logService.info(`[SessionWorkspaceConversionService] The deferred workspace conversion turn for ${pending.chat.toString()} ended before it could continue.`); + } + } catch (continuationError) { + this._logService.error(`[SessionWorkspaceConversionService] Failed to start the conversion continuation for ${pending.chat.toString()}: ${toErrorMessage(continuationError)}`); + this._failConversion(continuation, pending, continuationError instanceof Error ? continuationError : new Error(toErrorMessage(continuationError)), false); + } + } + + private _failConversion(continuation: IDeferredAgentHostTurn | undefined, pending: IPendingSessionWorkspaceConversion, error: Error, publishOutcome = true): void { + if (!continuation) { + this._logService.error(`[SessionWorkspaceConversionService] Cannot report workspace conversion failure for ${pending.chat.toString()} because its deferred turn did not start.`); + return; + } + if (publishOutcome) { + this._publishConversionOutcome(pending.chat, continuation, localize('agentHost.workspaceSetupFailedLabel', "Workspace Setup Failed")); + } + if (!this._turnService.failDeferredTurnMessage(pending.chat, continuation, { + errorType: 'workspaceConversionFailed', + message: toErrorMessage(error), + })) { + this._logService.info(`[SessionWorkspaceConversionService] The deferred workspace conversion turn for ${pending.chat.toString()} ended before its failure could be reported.`); + } + } + + private _publishConversionOutcome(chat: URI, continuation: IDeferredAgentHostTurn, label: string): void { + if (this._stateManager.getActiveTurnId(chat.toString()) !== continuation.turnId) { + return; + } + this._stateManager.dispatchServerAction(chat.toString(), { + type: ActionType.ChatResponsePart, + turnId: continuation.turnId, + part: { + kind: ResponsePartKind.SystemNotification, + content: label, + }, + }); + } + + override dispose(): void { + this._pending.clear(); + this._quarantined.clear(); + super.dispose(); + } +} diff --git a/src/vs/platform/agentHost/node/claude/claudeAgent.ts b/src/vs/platform/agentHost/node/claude/claudeAgent.ts index dbb1d79a5b592..a8baa4bcf574a 100644 --- a/src/vs/platform/agentHost/node/claude/claudeAgent.ts +++ b/src/vs/platform/agentHost/node/claude/claudeAgent.ts @@ -351,6 +351,7 @@ class ClaudeActiveClientHandle implements IActiveClient { */ export class ClaudeAgent extends Disposable implements IAgent { readonly id: AgentProvider = CLAUDE_AGENT_PROVIDER_ID; + readonly agentHostCapabilities = { workspaceConversion: false } as const; private readonly _onDidChatProgress = this._register(new Emitter()); readonly onDidChatProgress = this._onDidChatProgress.event; @@ -715,6 +716,10 @@ export class ClaudeAgent extends Disposable implements IAgent { return this._configurationService.getRootValue(platformRootSchema, AgentHostClaudeMultiRootEnabledConfigKey) === true; } + async setWorkingDirectory(_chat: URI, _context: URI | IAgentChatContext, _workingDirectory: URI): Promise { + throw new Error('Claude does not support changing the working directory of an existing session.'); + } + getProtectedResources(): ProtectedResourceMetadata[] { // Always listed, always optional. Listing it is what lets the host forward a // token to an already-signed-in user (matching ignores `required`); the diff --git a/src/vs/platform/agentHost/node/claude/claudeCanUseTool.ts b/src/vs/platform/agentHost/node/claude/claudeCanUseTool.ts index 12c33cbf54623..09e0d710bc654 100644 --- a/src/vs/platform/agentHost/node/claude/claudeCanUseTool.ts +++ b/src/vs/platform/agentHost/node/claude/claudeCanUseTool.ts @@ -10,6 +10,7 @@ import { ClaudePermissionMode, ClaudeSessionConfigKey } from '../../common/claud import { ChatInputRequestPurpose, withChatInputRequestPurpose } from '../../common/meta/agentChatInputRequestMeta.js'; import { ChatInputResponseKind, ToolCallPendingConfirmationState, ToolCallStatus } from '../../common/state/protocol/state.js'; import { IAgentConfigurationService } from '../agentConfigurationService.js'; +import { getServerToolDisplay } from '../shared/serverToolGroups.js'; import { ClaudeAgentSession } from './claudeAgentSession.js'; import { extractServerToolName } from './claudeServerToolMcpServer.js'; import { buildAskUserSessionInputQuestions, buildExitPlanModeConfirmationState, flattenAskUserAnswers, parseAskUserQuestionInput } from './claudeInteractiveTools.js'; @@ -149,16 +150,17 @@ async function dispatchCanUseTool( const permissionKind = getClaudePermissionKind(toolName); const displayName = getClaudeToolDisplayName(toolName); const permissionPath = options.blockedPath ?? getClaudeToolPath(toolName, input); - const toolInputString = getClaudeToolInputString(toolName, input); + const serverDisplay = serverToolName ? getServerToolDisplay(serverToolName, input) : undefined; + const toolInputString = serverDisplay?.hideConfirmationInput ? undefined : getClaudeToolInputString(toolName, input); const meta = buildClaudeToolMeta(toolName); const state: ToolCallPendingConfirmationState = { status: ToolCallStatus.PendingConfirmation, toolCallId: options.toolUseID, toolName, displayName, - invocationMessage: getClaudeInvocationMessage(toolName, displayName, input), + invocationMessage: serverDisplay?.confirmationMessage ?? getClaudeInvocationMessage(toolName, displayName, input), toolInput: toolInputString, - confirmationTitle: getClaudeConfirmationTitle(toolName), + confirmationTitle: serverDisplay?.confirmationTitle ?? getClaudeConfirmationTitle(toolName), ...(meta ? { _meta: meta } : {}), }; diff --git a/src/vs/platform/agentHost/node/codex/codexAgent.ts b/src/vs/platform/agentHost/node/codex/codexAgent.ts index 2143c0f4240a3..aa72e8a1cea3b 100644 --- a/src/vs/platform/agentHost/node/codex/codexAgent.ts +++ b/src/vs/platform/agentHost/node/codex/codexAgent.ts @@ -1004,6 +1004,7 @@ function narrowFileChangeDecision(decision: CommandExecutionApprovalDecision): F export class CodexAgent extends Disposable implements IAgent { readonly id: AgentProvider = CODEX_AGENT_PROVIDER_ID; + readonly agentHostCapabilities = { workspaceConversion: false } as const; private readonly _onDidChatProgress = this._register(new Emitter()); readonly onDidChatProgress = this._onDidChatProgress.event; @@ -2710,14 +2711,15 @@ export class CodexAgent extends Disposable implements IAgent { if (!entry) { return { result: this._toolFailure(`No pending server tool call for ${params.tool} (callId ${params.callId})`) }; } - const invocationMessage = getServerToolDisplay(params.tool, params.arguments)?.invocationMessage ?? `Calling ${params.tool}`; + const display = getServerToolDisplay(params.tool, params.arguments); + const invocationMessage = display?.confirmationMessage ?? display?.invocationMessage ?? `Calling ${params.tool}`; const decision = await session.pendingCommandApprovals.registerAndFire(entry.toolCallId, () => { this._fire(session.sessionUri, { type: ActionType.ChatToolCallReady, turnId: entry.turnId, toolCallId: entry.toolCallId, invocationMessage, - confirmationTitle: localize('codex.serverToolConfirmation.title', "Allow tool call?"), + confirmationTitle: display?.confirmationTitle ?? localize('codex.serverToolConfirmation.title', "Allow tool call?"), }); }); if (decision !== 'accept' && decision !== 'acceptForSession') { @@ -3983,6 +3985,10 @@ export class CodexAgent extends Disposable implements IAgent { return this._configurationService.getRootValue(platformRootSchema, AgentHostCodexMultiRootEnabledConfigKey) === true; } + async setWorkingDirectory(_chat: URI, _context: URI | IAgentChatContext, _workingDirectory: URI): Promise { + throw new Error('Codex does not support changing the working directory of an existing session.'); + } + /** * Hides the multi-root Folder picker unless several working directories carry * a Codex `.codex/hooks.json` hook manifest (see diff --git a/src/vs/platform/agentHost/node/copilot/agentHostSandboxEngine.ts b/src/vs/platform/agentHost/node/copilot/agentHostSandboxEngine.ts index cad3dd863a2aa..898c7553ebcdb 100644 --- a/src/vs/platform/agentHost/node/copilot/agentHostSandboxEngine.ts +++ b/src/vs/platform/agentHost/node/copilot/agentHostSandboxEngine.ts @@ -3,7 +3,8 @@ * 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 } from '../../../../base/common/lifecycle.js'; import { FileAccess } from '../../../../base/common/network.js'; import { dirname } from '../../../../base/common/path.js'; import { OS, OperatingSystem } from '../../../../base/common/platform.js'; @@ -24,25 +25,32 @@ const SANDBOX_TEMP_DIR_NAME = 'tmp'; /** * Host adapter that bridges agent-host environment data into the shared * {@link TerminalSandboxEngine}. One instance per session, wired up via - * {@link createAgentHostSandboxEngine}. + * {@link AgentHostSandboxEngine}. */ -class AgentHostTerminalSandboxHost implements ITerminalSandboxEngineHost { - readonly onDidChangeRoots = Event.None; +class AgentHostTerminalSandboxHost extends Disposable implements ITerminalSandboxEngineHost { + private readonly _onDidChangeRoots = this._register(new Emitter()); + readonly onDidChangeRoots = this._onDidChangeRoots.event; readonly onDidChangeSandboxSettings: Event; private readonly _sandboxHelper: ISandboxHelperService; constructor( private readonly _sessionId: string, - private readonly _workingDirectory: URI | undefined, + private _workingDirectory: URI | undefined, private readonly _environmentService: INativeEnvironmentService, private readonly _productService: IProductService, private readonly _agentConfigurationService: IAgentConfigurationService, sandboxHelper: ISandboxHelperService, ) { + super(); this._sandboxHelper = sandboxHelper; this.onDidChangeSandboxSettings = this._agentConfigurationService.onDidRootConfigChange; } + setWorkingDirectory(workingDirectory: URI): void { + this._workingDirectory = workingDirectory; + this._onDidChangeRoots.fire(); + } + async getOS(): Promise { return OS; } @@ -119,21 +127,28 @@ class AgentHostTerminalSandboxHost implements ITerminalSandboxEngineHost { } } -/** - * Construct a per-session {@link TerminalSandboxEngine} for the agent host. - * The returned engine is registered with the caller's instantiation service - * but the caller is responsible for disposing it (typically by registering it - * alongside the per-session {@link ShellManager}). - */ -export function createAgentHostSandboxEngine( - instantiationService: IInstantiationService, - environmentService: IEnvironmentService, - productService: IProductService, - agentConfigurationService: IAgentConfigurationService, - sandboxHelper: ISandboxHelperService, - sessionId: string, - workingDirectory: URI | undefined, -): TerminalSandboxEngine { - const host = new AgentHostTerminalSandboxHost(sessionId, workingDirectory, environmentService as INativeEnvironmentService, productService, agentConfigurationService, sandboxHelper); - return instantiationService.createInstance(TerminalSandboxEngine, host); +/** Owns the terminal sandbox engine and its mutable Agent Host adapter. */ +export class AgentHostSandboxEngine extends Disposable { + readonly engine: TerminalSandboxEngine; + private readonly _host: AgentHostTerminalSandboxHost; + + constructor( + sessionId: string, + workingDirectory: URI | undefined, + @IInstantiationService instantiationService: IInstantiationService, + @IEnvironmentService environmentService: IEnvironmentService, + @IProductService productService: IProductService, + @IAgentConfigurationService agentConfigurationService: IAgentConfigurationService, + @ISandboxHelperService sandboxHelper: ISandboxHelperService, + ) { + super(); + this._host = new AgentHostTerminalSandboxHost(sessionId, workingDirectory, environmentService as INativeEnvironmentService, productService, agentConfigurationService, sandboxHelper); + this.engine = instantiationService.createInstance(TerminalSandboxEngine, this._host); + this._register(this.engine); + this._register(this._host); + } + + setWorkingDirectory(workingDirectory: URI): void { + this._host.setWorkingDirectory(workingDirectory); + } } diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index bc81eb16dabb4..0f8144b6b3bf5 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -18,7 +18,7 @@ import { FileAccess, Schemas } from '../../../../base/common/network.js'; import { formatTokenCount } from '../../../../base/common/numbers.js'; import { equals } from '../../../../base/common/objects.js'; import { autorun, observableValue, observableValueOpts, type IObservable, type ISettableObservable } from '../../../../base/common/observable.js'; -import { delimiter, dirname, join } from '../../../../base/common/path.js'; +import { delimiter, dirname, isAbsolute, join } from '../../../../base/common/path.js'; import { basename as resourceBasename, isEqual, isEqualOrParent, joinPath as resourceJoinPath, relativePath } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; import { generateUuid } from '../../../../base/common/uuid.js'; @@ -77,7 +77,7 @@ import { IAgentHostSessionTitleSignal } from '../agentHostSessionTitleSignal.js' import { IByokLmBridgeRegistry } from '../byokLmBridgeRegistry.js'; import { IAgentHostWorktreeIsolation, type IAgentHostWorktreeResumeService, SessionWorkingDirectoryMissingError } from '../shared/worktreeIsolation.js'; import { buildSessionEventLogFromTurns } from './buildSessionEvents.js'; -import { CopilotAgentSession } from './copilotAgentSession.js'; +import { CopilotAgentSession, type ICopilotWorkingDirectoryChangeTransaction } from './copilotAgentSession.js'; import { createCopilotCliEnvironment } from './copilotCliEnvironment.js'; import { ICopilotSessionContext, projectFromCopilotContext } from './copilotGitProject.js'; import { parsedPluginsEqual, toChildCustomizations } from './copilotPluginConverters.js'; @@ -343,8 +343,25 @@ interface ICopilotAgentSessionIdentity { readonly resource: URI; } +interface IWorkingDirectoryMetadataSnapshot { + readonly workingDirectory: string | undefined; + readonly workingDirectories: string | undefined; + readonly customizationDirectory: string | undefined; +} + +interface IWorkingDirectoryChangeTransactionOptions { + readonly resource: URI; + readonly activeClient: ActiveClient; + readonly workingDirectory: URI; + readonly previousWorkingDirectory: URI; + readonly previousCustomizationDirectory: URI; + readonly previousCustomizationAdditionalDirectories: readonly URI[]; + readonly previousMetadata: IWorkingDirectoryMetadataSnapshot; +} + /** Stable empty host-customization snapshot used before the host publishes one. */ const NO_HOST_CUSTOMIZATIONS: readonly Customization[] = Object.freeze([]); +const CHAT_QUEUE_STALL_WARNING_MS = 60_000; /** Coordinates all per-session work, resumption, and teardown. */ class CopilotSessionLifetime { @@ -712,6 +729,7 @@ const NANO_AIU_PER_CREDIT = 1_000_000_000; */ export class CopilotAgent extends Disposable implements IAgent { readonly id = 'copilotcli' as const; + readonly agentHostCapabilities = { workspaceConversion: true } as const; protected readonly _now = Date.now; private readonly _onDidChatProgress = this._register(new Emitter()); @@ -842,6 +860,7 @@ export class CopilotAgent extends Disposable implements IAgent { private readonly _chatEntriesBySdkId = this._register(new DisposableMap()); /** Exact host chat URI -> persisted provider backing; live SDK sessions are tracked separately. */ private readonly _chatBackings = new Map(); + private readonly _workingDirectoryMutations = new ResourceMap(); /** Exact chat -> recorded configuration scope, used for fork/restore paths that only know the chat URI. */ private readonly _chatScopes = new Map(); @@ -888,6 +907,15 @@ export class CopilotAgent extends Disposable implements IAgent { private readonly _onDidChangeChatData = this._register(new Emitter()); readonly onDidChangeChatData: Event = this._onDidChangeChatData.event; private readonly _sessionLifetimes = new Map(); + /** Outstanding queued chat operations, keyed by chat sequencer key. */ + private readonly _chatQueueDepth = new Map(); + /** + * Chats whose abort arrived before a live session existed. Aborting no longer + * waits on the chat queue (a wedged queue must not swallow a cancel), so an + * abort can land while a queued send is still materializing its session. The + * send honours and clears this before dispatching. + */ + private readonly _pendingChatAborts = new Set(); /** Provisional chats that defer SDK/session creation until the first send. */ private readonly _provisionalSessions = new Map(); private _shutdownPromise: Promise | undefined; @@ -1433,6 +1461,156 @@ export class CopilotAgent extends Disposable implements IAgent { return applyMcpServerEnablement(customizations, this._retainedHostCustomizations(session)); } + async setWorkingDirectory(chat: URI, context: URI | IAgentChatContext, workingDirectory: URI): Promise { + const initial = this._resolveLiveWorkingDirectoryContext(chat, context); + if (!isDefaultChatUri(chat)) { + throw new Error(`Cannot change the working directory for peer chat '${chat.toString()}': live working-directory changes are only supported for the owning default chat`); + } + const existingMutation = this._workingDirectoryMutations.get(initial.configurationResource); + if (existingMutation) { + throw new Error(`Cannot change the working directory for chat '${chat.toString()}' while another working-directory change is active for its configuration`); + } + this._throwIfRecordedChatSharesConfiguration(chat, initial.configurationResource); + this._workingDirectoryMutations.set(initial.configurationResource, initial.entry); + try { + await this._queueChat(initial.configurationId, initial.sdkSessionId, 'setWorkingDirectory', async () => { + const current = this._resolveLiveWorkingDirectoryContext(chat, context); + if (current.entry !== initial.entry || current.sdkSessionId !== initial.sdkSessionId) { + throw new Error(`Cannot change the working directory: chat '${chat.toString()}' is no longer backed by the same live session`); + } + this._throwIfRecordedChatSharesConfiguration(chat, current.configurationResource); + for (const candidate of this._chatEntriesBySdkId.values()) { + const sibling = candidate.chatSession; + if (sibling !== current.entry && isEqual(sibling.ownerSessionUri ?? sibling.sessionUri, current.configurationResource)) { + throw new Error(`Cannot change the working directory for chat '${chat.toString()}' while another live chat shares its configuration`); + } + } + if (workingDirectory.scheme !== Schemas.file || !isAbsolute(workingDirectory.fsPath)) { + throw new Error(`Cannot change the working directory to non-local or relative resource '${workingDirectory.toString()}'`); + } + if (!await this._isExistingDirectory(workingDirectory.fsPath)) { + throw new Error(`Cannot change the working directory because '${workingDirectory.fsPath}' is not an existing directory`); + } + + const { entry, configurationResource, resource } = current; + const activeClient = this._activeClients.get(configurationResource); + if (!activeClient) { + throw new Error(`Cannot change the working directory: chat '${chat.toString()}' has no active client`); + } + + const storedMetadata = await this._readWorkingDirectoryMetadata(resource); + if (entry.appliedAdditionalDirectories.length > 0 || activeClient.pluginController.additionalDirectories.length > 0 || (storedMetadata.workingDirectories?.length ?? 0) > 1) { + throw new Error(`Cannot change the working directory for multi-root chat '${chat.toString()}'`); + } + + const previousWorkingDirectory = entry.workingDirectory; + if (!previousWorkingDirectory) { + throw new Error(`Cannot change the working directory: live chat '${chat.toString()}' has no working directory`); + } + if (isEqual(previousWorkingDirectory, workingDirectory)) { + return; + } + + const previousCustomizationDirectory = activeClient.pluginController.directory ?? previousWorkingDirectory; + const previousCustomizationAdditionalDirectories = [...activeClient.pluginController.additionalDirectories]; + const transaction = this._createWorkingDirectoryChangeTransaction({ + resource, + activeClient, + workingDirectory, + previousWorkingDirectory, + previousCustomizationDirectory, + previousCustomizationAdditionalDirectories, + previousMetadata: storedMetadata.snapshot, + }); + await entry.setWorkingDirectory(workingDirectory, transaction); + }); + } finally { + if (this._workingDirectoryMutations.get(initial.configurationResource) === initial.entry) { + this._workingDirectoryMutations.delete(initial.configurationResource); + } + } + } + + private _createWorkingDirectoryChangeTransaction(options: IWorkingDirectoryChangeTransactionOptions): ICopilotWorkingDirectoryChangeTransaction { + const { + resource, + activeClient, + workingDirectory, + previousWorkingDirectory, + previousCustomizationDirectory, + previousCustomizationAdditionalDirectories, + previousMetadata, + } = options; + const metadataFor = (directory: URI): IWorkingDirectoryMetadataSnapshot => ({ + workingDirectory: directory.toString(), + workingDirectories: JSON.stringify([directory.toString()]), + customizationDirectory: directory.toString(), + }); + const applyProviderState = async (directory: URI, additionalDirectories: readonly URI[], metadata: IWorkingDirectoryMetadataSnapshot): Promise => { + const errors: string[] = []; + try { + activeClient.pluginController.reanchor(directory); + } catch (error) { + errors.push(`customization anchor: ${getErrorMessage(error)}`); + } + try { + activeClient.pluginController.setAdditionalDirectories(additionalDirectories); + } catch (error) { + errors.push(`customization additional roots: ${getErrorMessage(error)}`); + } + try { + await this._storeWorkingDirectoryMetadataSnapshot(resource, metadata); + } catch (error) { + errors.push(`provider metadata: ${getErrorMessage(error)}`); + } + if (errors.length > 0) { + throw new Error(errors.join('; ')); + } + }; + + return { + prepare: () => applyProviderState(workingDirectory, [], metadataFor(workingDirectory)), + rollback: () => applyProviderState(previousCustomizationDirectory, previousCustomizationAdditionalDirectories, previousMetadata), + reconcile: authoritativeWorkingDirectory => isEqual(authoritativeWorkingDirectory, previousWorkingDirectory) + ? applyProviderState(previousCustomizationDirectory, previousCustomizationAdditionalDirectories, previousMetadata) + : applyProviderState(authoritativeWorkingDirectory, [], metadataFor(authoritativeWorkingDirectory)), + }; + } + + private _resolveLiveWorkingDirectoryContext(chat: URI, context: URI | IAgentChatContext): { + readonly configurationResource: URI; + readonly configurationId: string; + readonly resource: URI; + readonly sdkSessionId: string; + readonly entry: CopilotAgentSession; + } { + const resolved = URI.isUri(context) + ? { configurationResource: context, resource: context } + : resolveAgentChatContext(context, chat); + const backing = this._chatBackings.get(chat.toString()); + const entry = backing ? this._findSessionBySdkId(backing.sdkSessionId) : undefined; + const configurationResource = entry?.ownerSessionUri ?? entry?.sessionUri; + if ( + !backing + || !entry + || backing.sdkSessionId !== entry.sessionId + || !isEqual(chat, entry.chatChannelUri) + || !configurationResource + || !isEqual(resolved.configurationResource, configurationResource) + || !isEqual(resolved.resource, entry.resourceUri) + || (isDefaultChatUri(chat) && (!isEqual(entry.resourceUri, configurationResource) || !isEqual(chat, URI.parse(buildDefaultChatUri(configurationResource))))) + ) { + throw new Error(`Cannot change the working directory: chat '${chat.toString()}' is unknown, not live, or does not match the supplied context`); + } + return { + configurationResource, + configurationId: AgentSession.id(configurationResource), + resource: entry.resourceUri, + sdkSessionId: entry.sessionId, + entry, + }; + } + /** * Copilot applies hooks from the primary working directory only (see * `_hookWorkingDirectories` in sessionCustomizationDiscovery), so in a @@ -3090,6 +3268,7 @@ export class CopilotAgent extends Disposable implements IAgent { private async _createChat(chat: URI, context: IAgentChatContext, options: IAgentCreateChatOptions = {}): Promise { const scope = context.configurationResource; const chatKey = chat.toString(); + this._throwIfWorkingDirectoryMutationBlocksChat(scope, chat); // A duplicate/reconnect create call for a chat the agent already binds — // live (a real running session), provisional/reserved, or restored via // `materializeChat` — must never roll back that preexisting binding just @@ -3272,7 +3451,7 @@ export class CopilotAgent extends Disposable implements IAgent { private async _resumeTurnOnce(chat: URI, turnId: string, operationContext: URI | IAgentChatContext, senderClientId?: string, clientType = AgentHostClientType.Unknown): Promise { const context = this._resolveChatContext(chat, operationContext); const clientTelemetryContext = URI.isUri(operationContext) ? undefined : operationContext.clientTelemetryContext; - await this._queueChat(context.configurationId, context.sequencerKey, async () => { + await this._queueChat(context.configurationId, context.sequencerKey, 'resumeTurn', async () => { const current = this._resolveChatContext(chat, operationContext); let entry = current.target ?? await this._ensureResolvedChatSession(current); if (!entry) { @@ -3280,7 +3459,7 @@ export class CopilotAgent extends Disposable implements IAgent { } const activeClient = this._activeClients.get(current.configurationResource); const currentSnapshot = activeClient ? await activeClient.snapshot(current.chatKey) : undefined; - if (activeClient && currentSnapshot && await activeClient.requiresRestart(entry.appliedSnapshot, current.chatKey, currentSnapshot)) { + if (entry.requiresRestartAfterWorkingDirectoryChange || (activeClient && currentSnapshot && await activeClient.requiresRestart(entry.appliedSnapshot, current.chatKey, currentSnapshot))) { await this._destroyLiveSession(entry, true); entry = entry.sessionId === current.configurationId ? await this._resumeSession(current.configurationId, current.chat) @@ -3974,7 +4153,7 @@ export class CopilotAgent extends Disposable implements IAgent { private async _sendMessageOnce(chat: URI, prompt: string, attachments?: readonly MessageAttachment[], turnId?: string, senderClientId?: string, clientType = AgentHostClientType.Unknown, workingDirectories?: readonly URI[], operationContext?: URI | IAgentChatContext, clientTelemetryContext?: IAgentHostClientTelemetryContext): Promise { const context = this._resolveSendChatContext(chat, operationContext); - await this._queueChat(context.configurationId, context.sequencerKey, async () => { + await this._queueChat(context.configurationId, context.sequencerKey, 'sendMessage', async enterUnboundedPhase => { const current = this._resolveSendChatContext(chat, operationContext); await this._activeClients.get(current.configurationResource)?.pluginController.retryFailedClientSyncIfNeeded(); @@ -3998,7 +4177,7 @@ export class CopilotAgent extends Disposable implements IAgent { [...new Set(entry.appliedDisabledRootMcpServers)].sort(), [...new Set(currentDisabledRootMcpServers)].sort(), ); - if (entry && (rootsChanged || structuralConfigChanged || disabledRootMcpServersChanged || entry.requiresMcpLaunchConfigurationRefresh)) { + if (entry && (entry.requiresRestartAfterWorkingDirectoryChange || rootsChanged || structuralConfigChanged || disabledRootMcpServersChanged || entry.requiresMcpLaunchConfigurationRefresh || entry.requiresControlPlaneResync)) { this._logService.info(`[Copilot:${current.configurationId}] Session configuration changed, refreshing session. clients=[${activeClient ? [...activeClient.toolSet.clientIds()].join(', ') || '(none)' : '(none)'}]`); // Finish disconnecting before resuming the SAME SDK session id with // the updated config. Routing is preserved so the session identity @@ -4032,6 +4211,16 @@ export class CopilotAgent extends Disposable implements IAgent { try { const sdkMode = this._resolveSdkMode(current.configurationResource); + // An abort that arrived while this send was still materializing its + // session had nothing to cancel at the time; honour it now rather + // than dispatching a turn the user already cancelled. + if (this._pendingChatAborts.delete(current.sequencerKey)) { + this._logService.info(`[Copilot:${current.configurationId}] Dropping send for a chat cancelled before its session materialized`); + entry.discardActiveTurn(); + return; + } + // The provider call runs the whole turn and is unbounded by design. + enterUnboundedPhase(); await entry.send(prompt, attachments, turnId, sdkMode, senderClientId, clientType, resolveAgentHostInstructions(operationContext), clientTelemetryContext, !!operationContext && !URI.isUri(operationContext) && operationContext.agentMergeTurn === true); } catch (err) { const errCode = (err as { code?: number })?.code; @@ -4118,7 +4307,7 @@ export class CopilotAgent extends Disposable implements IAgent { if (this._provisionalSessions.get(context.configurationId)?.sdkSessionId === context.sdkSessionId) { return []; } - const entry = await this._queueChat(context.configurationId, context.sequencerKey, async () => { + const entry = await this._queueChat(context.configurationId, context.sequencerKey, 'getChatMessages', async () => { return this._ensureResolvedChatSession(this._resolveChatContext(chat, sessionOrContext)).catch(err => { if (err instanceof SessionWorkingDirectoryMissingError) { throw err; @@ -4186,9 +4375,17 @@ export class CopilotAgent extends Disposable implements IAgent { private async _abortSessionOnce(chat: URI, operationContext: URI | IAgentChatContext): Promise { const context = this._resolveChatContext(chat, operationContext); - await this._queueChat(context.configurationId, context.sequencerKey, async () => { - await this._resolveChatContext(chat, operationContext).target?.abort(); - }); + if (!context.target) { + // No live session to abort. If work is still queued for this chat it + // will materialize a session and dispatch after this point, so record + // the intent instead of silently dropping the cancel. + if (this._chatQueueDepth.has(context.sequencerKey)) { + this._logService.info(`[Copilot:${context.configurationId}] Abort with no live session; recording pending abort: chat=${context.sequencerKey}`); + this._pendingChatAborts.add(context.sequencerKey); + } + return; + } + await context.target.abort(); } /** Creates a concrete chat backing immediately, optionally by importing history from another chat. */ @@ -4214,7 +4411,7 @@ export class CopilotAgent extends Disposable implements IAgent { // A fork reads the source's state, so it serializes against the source's // session unless it explicitly requests an independent queue. const queue = (task: () => Promise) => fork?.independentQueue - ? this._queueChat(sessionId, chatKey, task) + ? this._queueChat(sessionId, chatKey, 'createChat', task) : this._queueSession(forkSourceSessionId ?? sessionId, task); await queue(async () => { const existing = this._chatBackings.get(chatKey); @@ -4372,6 +4569,7 @@ export class CopilotAgent extends Disposable implements IAgent { /** Resolves the live session for an addressed chat from exact recorded backings. */ private async _ensureResolvedChatSession(context: IResolvedCopilotChatContext, workingDirectories?: readonly URI[]): Promise { + this._throwIfWorkingDirectoryMutationBlocksChat(context.configurationResource, context.chat); const provisional = this._provisionalSessions.get(context.configurationId); if (provisional && provisional.sdkSessionId === context.sdkSessionId) { return this._materializeProvisional(context.configurationId, workingDirectories); @@ -4467,7 +4665,7 @@ export class CopilotAgent extends Disposable implements IAgent { const chatKey = chat.toString(); const initial = this._resolveChatContext(chat, operationContext); const configurationId = initial.configurationId; - return this._queueChat(configurationId, initial.sequencerKey, async () => { + return this._queueChat(configurationId, initial.sequencerKey, 'disposeChat', async () => { const current = this._resolveChatContext(chat, operationContext); const target = current.target; const backing = this._chatBackings.get(chatKey); @@ -4644,14 +4842,40 @@ export class CopilotAgent extends Disposable implements IAgent { return lifetime ? lifetime.queueSession(task) : Promise.reject(new CancellationError()); } - private _queueChat(sessionId: string, chatKey: string, task: () => Promise): Promise { + private _queueChat(sessionId: string, chatKey: string, operation: string, task: (enterUnboundedPhase: () => void) => Promise): Promise { const lifetime = this._getOrCreateSessionLifetime(sessionId); - return lifetime ? lifetime.queueChat(chatKey, task) : Promise.reject(new CancellationError()); + if (!lifetime) { + return Promise.reject(new CancellationError()); + } + this._chatQueueDepth.set(chatKey, (this._chatQueueDepth.get(chatKey) ?? 0) + 1); + return lifetime.queueChat(chatKey, async () => { + const stallWarning = disposableTimeout( + () => this._logService.warn(`[Copilot:${sessionId}] Chat queue task stalled: chat=${chatKey}, operation=${operation}`), + CHAT_QUEUE_STALL_WARNING_MS, + ); + try { + // A task that reaches a legitimately unbounded phase (an agent turn + // can run for many minutes) cancels the warning itself, so only + // bounded setup and control-plane work can be reported as stalled. + return await task(() => stallWarning.dispose()); + } finally { + stallWarning.dispose(); + } + }).finally(() => { + const remaining = (this._chatQueueDepth.get(chatKey) ?? 1) - 1; + if (remaining > 0) { + this._chatQueueDepth.set(chatKey, remaining); + } else { + this._chatQueueDepth.delete(chatKey); + this._pendingChatAborts.delete(chatKey); + } + }); } /** Returns the live session for an exact chat, resuming it if necessary. */ private async _resolveOrResumeChatSession(context: IResolvedCopilotChatContext, workingDirectories?: readonly URI[]): Promise { const { configurationResource, configurationId, chat, chatKey } = context; + this._throwIfWorkingDirectoryMutationBlocksChat(configurationResource, chat); const existing = this._findChatByUri(chat); if (existing) { return existing; @@ -4667,6 +4891,7 @@ export class CopilotAgent extends Disposable implements IAgent { } let agentSession: CopilotAgentSession | undefined; try { + this._throwIfWorkingDirectoryMutationBlocksChat(configurationResource, chat); const again = this._findChatByUri(chat); if (again) { return again; @@ -4675,19 +4900,22 @@ export class CopilotAgent extends Disposable implements IAgent { if (!info) { return undefined; } + const storedMetadata = await this._readSessionMetadata(configurationResource); + const resumeWorkingDirectories = this._workingDirectoriesForResume(storedMetadata, workingDirectories); const parentEntry = this._findSessionBySdkId(configurationId); - const persistedWorkingDirectory = workingDirectories?.[0] ?? parentEntry?.workingDirectory + const persistedWorkingDirectory = resumeWorkingDirectories?.[0] ?? parentEntry?.workingDirectory ?? this._provisionalSessions.get(configurationId)?.workingDirectory - ?? (await this._readSessionMetadata(configurationResource)).workingDirectory; + ?? storedMetadata.workingDirectory; if (!persistedWorkingDirectory) { this._logService.warn(`[Copilot] Cannot resume chat ${chatKey}: missing working directory`); return undefined; } const workingDirectory = await this._worktree.resolveWorkingDirectoryForResume(configurationResource, AgentSession.id(configurationResource), persistedWorkingDirectory); - const launchWorkingDirectories = workingDirectories - ? [workingDirectory, ...workingDirectories.slice(1)] + const launchWorkingDirectories = resumeWorkingDirectories + ? [workingDirectory, ...resumeWorkingDirectories.slice(1)] : undefined; const client = await this._ensureClient(); + this._throwIfWorkingDirectoryMutationBlocksChat(configurationResource, chat); const activeClient = this._getOrCreateActiveClient(configurationResource, workingDirectory); activeClient.pluginController.reanchor(workingDirectory); const snapshot = await activeClient.snapshot(chatKey); @@ -4709,6 +4937,7 @@ export class CopilotAgent extends Disposable implements IAgent { agentSession = this._createAgentSession(launchPlan, workingDirectory, activeClient, { sessionUri: configurationResource, chatChannelUri: chat, resource: context.resource }); await agentSession.initializeSession(); this._throwIfClientReplaced(client, agentSession); + this._throwIfWorkingDirectoryMutationBlocksChat(configurationResource, chat); this._registerLiveChat(chat, agentSession, activeClient); if (launchWorkingDirectories) { await this._storeSessionMetadata(context.resource, info.model, workingDirectory, launchWorkingDirectories, undefined, undefined); @@ -4725,13 +4954,28 @@ export class CopilotAgent extends Disposable implements IAgent { }); } + private _throwIfWorkingDirectoryMutationBlocksChat(configurationResource: URI, chat: URI): void { + const mutationOwner = this._workingDirectoryMutations.get(configurationResource); + if (mutationOwner && this._findChatByUri(chat) !== mutationOwner) { + throw new Error(`Cannot create or resume chat '${chat.toString()}' while its configuration working directory is changing`); + } + } + + private _throwIfRecordedChatSharesConfiguration(chat: URI, configurationResource: URI): void { + for (const [recordedChat, recordedConfigurationResource] of this._chatScopes) { + if (recordedChat !== chat.toString() && isEqual(recordedConfigurationResource, configurationResource)) { + throw new Error(`Cannot change the working directory for chat '${chat.toString()}' while another recorded chat shares its configuration`); + } + } + } + async truncateChat(chat: URI, turnId: string | undefined, context?: URI | IAgentChatContext): Promise { const resolved = this._resolveTruncateChatContext(chat, context); const sessionId = resolved.configurationId; if (this._provisionalSessions.get(sessionId)?.chat.toString() === chat.toString()) { return; } - await this._queueChat(resolved.configurationId, resolved.sequencerKey, async () => { + await this._queueChat(resolved.configurationId, resolved.sequencerKey, 'truncateChat', async () => { const current = this._resolveTruncateChatContext(chat, context); this._logService.info(`[Copilot:${sessionId}] Truncating chat ${chat.toString()}${turnId !== undefined ? ` at turnId=${turnId}` : ' (all turns)'}`); @@ -4776,7 +5020,7 @@ export class CopilotAgent extends Disposable implements IAgent { private async _changeModelOnce(chat: URI, model: ModelSelection, operationContext: URI | IAgentChatContext): Promise { const context = this._resolveChatContext(chat, operationContext); - await this._queueChat(context.configurationId, context.sequencerKey, async () => { + await this._queueChat(context.configurationId, context.sequencerKey, 'changeModel', async () => { const current = this._resolveChatContext(chat, operationContext); const longContextWindow = this._longContextWindowFor(model.id); const freeLongContext = this._isFreeLongContext(model.id); @@ -4850,7 +5094,7 @@ export class CopilotAgent extends Disposable implements IAgent { private async _changeAgentOnce(chat: URI, agent: AgentSelection | undefined, operationContext: URI | IAgentChatContext): Promise { const context = this._resolveChatContext(chat, operationContext); - await this._queueChat(context.configurationId, context.sequencerKey, async () => { + await this._queueChat(context.configurationId, context.sequencerKey, 'changeAgent', async () => { const current = this._resolveChatContext(chat, operationContext); const provisional = this._provisionalSessions.get(current.configurationId); if (provisional) { @@ -5231,7 +5475,7 @@ export class CopilotAgent extends Disposable implements IAgent { // Multi-root: re-attach the non-primary roots so discovery spans every // root on resume. Empty when single-root / gated off. A send-time // snapshot supersedes the persisted restoration seed. - const launchWorkingDirectories = workingDirectories ?? storedMetadata.workingDirectories; + const launchWorkingDirectories = this._workingDirectoriesForResume(storedMetadata, workingDirectories) ?? storedMetadata.workingDirectories; activeClient.pluginController.setAdditionalDirectories(this._additionalCustomizationDirectories(launchWorkingDirectories)); // Prefer chat-scoped membership when this SDK session is already bound to a chat. const snapshot = await activeClient.snapshot(this._findBoundSessionChatUri(sessionId)?.toString()); @@ -5275,6 +5519,22 @@ export class CopilotAgent extends Disposable implements IAgent { return agentSession; } + private _workingDirectoriesForResume( + storedMetadata: { readonly workingDirectory?: URI; readonly workingDirectories?: readonly URI[]; readonly workspaceless?: boolean }, + workingDirectories: readonly URI[] | undefined, + ): readonly URI[] | undefined { + // A live Quick Chat CWD change updates provider metadata before the AHP + // session model is converted. Preserve that provider-owned directory + // across refreshes instead of accepting the model's stale scratch root. + if (storedMetadata.workspaceless && storedMetadata.workingDirectory) { + return [ + storedMetadata.workingDirectory, + ...(storedMetadata.workingDirectories?.slice(1) ?? []), + ]; + } + return workingDirectories; + } + // ---- session metadata persistence -------------------------------------- private static readonly _META_MODEL = 'copilot.model'; @@ -5392,6 +5652,58 @@ export class CopilotAgent extends Disposable implements IAgent { } } + private async _readWorkingDirectoryMetadata(session: URI): Promise<{ + workingDirectory?: URI; + workingDirectories?: readonly URI[]; + customizationDirectory?: URI; + snapshot: IWorkingDirectoryMetadataSnapshot; + }> { + const dbRef = await this._sessionDataService.tryOpenDatabase(session); + if (!dbRef) { + return { + snapshot: { + workingDirectory: undefined, + workingDirectories: undefined, + customizationDirectory: undefined, + }, + }; + } + try { + const metadata = await dbRef.object.getMetadataObject({ + [CopilotAgent._META_CWD]: true, + [CopilotAgent._META_CWDS]: true, + [CopilotAgent._META_CUSTOMIZATION_DIRECTORY]: true, + }); + const workingDirectory = metadata[CopilotAgent._META_CWD]; + const customizationDirectory = metadata[CopilotAgent._META_CUSTOMIZATION_DIRECTORY]; + return { + workingDirectory: workingDirectory ? URI.parse(workingDirectory) : undefined, + workingDirectories: this._parseWorkingDirectories(metadata[CopilotAgent._META_CWDS], undefined), + customizationDirectory: customizationDirectory ? URI.parse(customizationDirectory) : undefined, + snapshot: { + workingDirectory, + workingDirectories: metadata[CopilotAgent._META_CWDS], + customizationDirectory, + }, + }; + } finally { + dbRef.dispose(); + } + } + + private async _storeWorkingDirectoryMetadataSnapshot(session: URI, metadata: IWorkingDirectoryMetadataSnapshot): Promise { + const dbRef = this._sessionDataService.openDatabase(session); + try { + await dbRef.object.setMetadataValues({ + [CopilotAgent._META_CWD]: metadata.workingDirectory ?? '', + [CopilotAgent._META_CWDS]: metadata.workingDirectories ?? '', + [CopilotAgent._META_CUSTOMIZATION_DIRECTORY]: metadata.customizationDirectory ?? '', + }); + } finally { + dbRef.dispose(); + } + } + /** * Parses the persisted ordered working-directory set. Prefers the JSON * `_META_CWDS` array when present and valid, otherwise falls back to the diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index f68f078f57c7c..f0ee8e65822f6 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -7,7 +7,7 @@ import type { CopilotSession, CurrentToolMetadata, ElicitationContext, Elicitati import { realpath as fsRealpath } from 'fs'; import { cp, rm } from 'fs/promises'; import { promisify } from 'util'; -import { DeferredPromise, firstParallel, raceCancellation, RunOnceScheduler, Sequencer, SequencerByKey, Throttler, timeout } from '../../../../base/common/async.js'; +import { DeferredPromise, firstParallel, raceCancellation, raceTimeout, RunOnceScheduler, Sequencer, SequencerByKey, Throttler, timeout } from '../../../../base/common/async.js'; import { encodeBase64, VSBuffer } from '../../../../base/common/buffer.js'; import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; import { Emitter } from '../../../../base/common/event.js'; @@ -40,7 +40,7 @@ import { gitHubMcpServerUrl } from '../../common/githubEndpoints.js'; import { AgentHostSandboxConfigKey, sandboxConfigSchema } from '../../common/sandboxConfigSchema.js'; import { AgentHostGlobalAutoApproveEnabledConfigKey, AgentHostAutoReplyAnswer, AgentHostAutoReplyEnabledConfigKey, AgentHostDisableRepoInfoTelemetryConfigKey, platformRootSchema, platformSessionSchema } from '../../common/agentHostSchema.js'; import { createUnknownAgentHostClientTelemetryContext, type IAgentHostClientTelemetryContext } from '../../common/agentHostTelemetry.js'; -import { AgentSession, AgentSignal, AuthenticateParams, IMcpNotification, type AgentTurnProviderCallState, type IAgentToolPendingConfirmationSignal, type IAgentTurnDiagnosticSnapshot } from '../../common/agent.js'; +import { AgentSession, AgentSignal, AgentWorkingDirectoryChangedError, AuthenticateParams, IMcpNotification, type AgentTurnProviderCallState, type IAgentToolPendingConfirmationSignal, type IAgentTurnDiagnosticSnapshot } from '../../common/agent.js'; import { META_DIFF_BASE_BRANCH } from '../../common/agentHostGitService.js'; import { stripRedundantCdPrefix } from '../../common/commandLineHelpers.js'; import { toToolCallMeta, type IToolCallMeta, type IToolCallUiMeta, type IToolSearchCandidate } from '../../common/meta/agentToolCallMeta.js'; @@ -398,6 +398,8 @@ function isCopilotSdkToolOutputTempFile(filePath: string, tmpDir: string): boole } const realpath = promisify(fsRealpath); +// A non-settling control RPC must not permanently block the per-chat sequencer. +const CONTROL_PLANE_RPC_TIMEOUT_MS = 30_000; function hasParentPathSegment(filePath: string): boolean { return filePath.split(/[\\/]/).includes('..'); @@ -486,6 +488,15 @@ export interface ICopilotAgentSessionOptions { readonly platform?: NodeJS.Platform; /** Resolves symlinks for plugin resource permission checks. */ readonly realpath?: (path: string) => Promise; + /** Overrides the control-plane RPC timeout for deterministic tests. */ + readonly controlPlaneRpcTimeoutMs?: number; +} + +/** Keeps provider-owned state consistent with a live SDK working-directory mutation. */ +export interface ICopilotWorkingDirectoryChangeTransaction { + prepare(): Promise; + rollback(): Promise; + reconcile(authoritativeWorkingDirectory: URI): Promise; } /** @@ -758,6 +769,8 @@ export class CopilotAgentSession extends Disposable { readonly sessionId: string; readonly resourceUri: URI; private readonly _ownerSessionUri: URI; + private readonly _controlPlaneRpcTimeoutMs: number; + private _controlPlaneDesynchronized = false; get ownerSessionUri(): URI { return this._ownerSessionUri; } /** @deprecated Compatibility alias for SDK callbacks; this is the exact persistence resource. */ get sessionUri(): URI { return this.resourceUri; } @@ -868,6 +881,7 @@ export class CopilotAgentSession extends Disposable { */ private readonly _currentTurn = this._register(new MutableDisposable()); private _resumingTurnAwaitingProviderStart: CopilotTurn | undefined; + private _abortingTurn: CopilotTurn | undefined; private _developmentRecoverableError: { readonly turnId: string; remainingFailures: number; readonly totalFailures: number } | undefined; private readonly _developmentErrorInjectionEnabled: boolean; private _dropLateRootTurnEvents = false; @@ -975,6 +989,8 @@ export class CopilotAgentSession extends Disposable { private readonly _sessionUsageMetricsRefreshThrottler = this._register(new Throttler()); /** SDK session wrapper, set by {@link initializeSession}. */ private _wrapper!: CopilotSessionWrapper; + private _workingDirectoryMutationInProgress = false; + private _requiresRestartAfterWorkingDirectoryChange = false; private readonly _slashCommandProvider: CopilotSlashCommandProvider; /** Last agent mode pushed to the SDK via {@link applyMode}, to elide redundant `rpc.mode.set` calls. */ private _lastAppliedMode: CopilotSdkMode | undefined; @@ -1052,8 +1068,8 @@ export class CopilotAgentSession extends Disposable { private readonly _shellManager: ShellManager | undefined; /** Streams runtime-executed shell output into output-only (non-pty) terminal channels. */ private readonly _nonPtyShellTerminals: NonPtyShellTerminalStreams; - private readonly _workingDirectory: URI | undefined; - private readonly _customizationDirectory: URI | undefined; + private _workingDirectory: URI | undefined; + private _customizationDirectory: URI | undefined; private readonly _serverToolHost: IAgentServerToolHost | undefined; /** Bridges SDK-reported MCP server state into AHP customization actions. */ private readonly _mcpCustomizations: McpCustomizationController; @@ -1125,6 +1141,7 @@ export class CopilotAgentSession extends Disposable { this._developmentErrorInjectionEnabled = options.enableDevelopmentErrorInjection ?? !product.commit; this.sessionId = options.rawSessionId; this._ownerSessionUri = options.sessionUri; + this._controlPlaneRpcTimeoutMs = options.controlPlaneRpcTimeoutMs ?? CONTROL_PLANE_RPC_TIMEOUT_MS; this.resourceUri = options.resource ?? options.sessionUri; this._slashCommandProvider = new CopilotSlashCommandProvider(() => this._wrapper.session.rpc.commands.list({ includeBuiltins: true, includeSkills: true, includeClientCommands: true }).then(c => c.commands), this._logService); this._chatChannelUri = options.chatChannelUri; @@ -1853,11 +1870,26 @@ export class CopilotAgentSession extends Disposable { return this._appliedSnapshot; } + get requiresRestartAfterWorkingDirectoryChange(): boolean { + return this._requiresRestartAfterWorkingDirectoryChange; + } + get requiresMcpLaunchConfigurationRefresh(): boolean { this._markMcpLaunchConfigurationDirty(); return this._mcpLaunchConfigurationDirty; } + /** + * Set when a control-plane RPC timed out. Timing out abandons the await but + * cannot cancel the in-flight SDK request, so the model/agent/history state + * this session believes it applied may not match the SDK's. The next send + * discards and resumes the SDK session so a late-settling request lands on a + * session nothing is using rather than mutating live state. + */ + get requiresControlPlaneResync(): boolean { + return this._controlPlaneDesynchronized; + } + get appliedDisabledRootMcpServers(): readonly string[] { return this._launchPlan.disabledRootMcpServers ?? []; } @@ -2399,7 +2431,115 @@ export class CopilotAgentSession extends Disposable { // ---- session operations ------------------------------------------------- + async setWorkingDirectory(workingDirectory: URI, transaction: ICopilotWorkingDirectoryChangeTransaction): Promise { + if (!this._wrapper) { + throw new Error('Cannot change the working directory before the session is initialized'); + } + if (this.hasActiveTurn) { + throw new Error('Cannot change the working directory while a turn is active'); + } + if (this._workingDirectoryMutationInProgress) { + throw new Error('Cannot change the working directory while another working directory change is in progress'); + } + const previousWorkingDirectory = this._workingDirectory; + if (!previousWorkingDirectory) { + throw new Error('Cannot change the working directory for a session without an existing working directory'); + } + this._shellManager?.assertCanSetWorkingDirectory(); + + this._workingDirectoryMutationInProgress = true; + try { + try { + await transaction.prepare(); + } catch (prepareError) { + try { + await transaction.rollback(); + } catch (rollbackError) { + throw new Error(`Working directory preparation failed: ${getErrorMessage(prepareError)}; failed to roll back the prepared working directory: ${getErrorMessage(rollbackError)}`); + } + throw prepareError; + } + + if (this.hasActiveTurn) { + const activeTurnError = new Error('Cannot change the working directory while a turn is active'); + try { + await transaction.rollback(); + } catch (rollbackError) { + throw new Error(`${activeTurnError.message}; failed to roll back the prepared working directory: ${getErrorMessage(rollbackError)}`); + } + throw activeTurnError; + } + + let result: Awaited>; + try { + result = await this._wrapper.session.rpc.metadata.setWorkingDirectory({ workingDirectory: workingDirectory.fsPath }); + } catch (sdkError) { + try { + await transaction.rollback(); + } catch (rollbackError) { + throw new Error(`Failed to change the SDK working directory: ${getErrorMessage(sdkError)}; failed to roll back the prepared working directory: ${getErrorMessage(rollbackError)}`); + } + throw sdkError; + } + + const requestedUri = normalizePath(URI.file(workingDirectory.fsPath)); + const actualUri = normalizePath(URI.file(result.workingDirectory)); + let runtimeAlignmentError: string | undefined; + try { + const updateResult = await this._wrapper.session.rpc.options.update({ workingDirectory: actualUri.fsPath }); + if (!updateResult.success) { + runtimeAlignmentError = 'the SDK rejected the runtime working directory update'; + } + } catch (error) { + runtimeAlignmentError = `failed to update the SDK runtime working directory: ${getErrorMessage(error)}`; + } + if (extUriBiasedIgnorePathCase.isEqual(actualUri, requestedUri)) { + let shellAlignmentError: string | undefined; + try { + this._shellManager?.setWorkingDirectory(workingDirectory); + } catch (error) { + shellAlignmentError = `failed to align the local shell working directory: ${getErrorMessage(error)}`; + } finally { + this._workingDirectory = workingDirectory; + this._customizationDirectory = workingDirectory; + this._requiresRestartAfterWorkingDirectoryChange = true; + } + const alignmentErrors = [runtimeAlignmentError, shellAlignmentError].filter(isDefined); + if (alignmentErrors.length > 0) { + throw new AgentWorkingDirectoryChangedError(workingDirectory, `The SDK working directory changed to '${workingDirectory.fsPath}', but runtime alignment failed: ${alignmentErrors.join('; ')}`); + } + return; + } + + const mismatchError = new Error(`The SDK returned working directory '${result.workingDirectory}' instead of '${workingDirectory.fsPath}'`); + const alignmentErrors = runtimeAlignmentError ? [runtimeAlignmentError] : []; + try { + this._shellManager?.setWorkingDirectory(actualUri); + } catch (shellError) { + alignmentErrors.push(`failed to align the local shell working directory: ${getErrorMessage(shellError)}`); + } finally { + this._workingDirectory = actualUri; + this._customizationDirectory = actualUri; + this._requiresRestartAfterWorkingDirectoryChange = true; + } + try { + await transaction.reconcile(actualUri); + } catch (reconcileError) { + alignmentErrors.push(`failed to reconcile the authoritative working directory: ${getErrorMessage(reconcileError)}`); + } + if (alignmentErrors.length > 0) { + throw new AgentWorkingDirectoryChangedError(actualUri, `${mismatchError.message}; ${alignmentErrors.join('; ')}`); + } + throw new AgentWorkingDirectoryChangedError(actualUri, mismatchError.message); + } finally { + this._workingDirectoryMutationInProgress = false; + } + } + async send(prompt: string, attachments?: readonly MessageAttachment[], turnId?: string, mode?: CopilotSdkMode, senderClientId?: string, clientType = AgentHostClientType.Unknown, hostInstructions?: readonly string[], clientContext = createUnknownAgentHostClientTelemetryContext(clientType), agentMergeTurn = false): Promise { + if (this._workingDirectoryMutationInProgress) { + throw new Error('Cannot start a turn while the working directory is changing'); + } this._resetAbortToken(); this._agentMergeTurn = agentMergeTurn; if (turnId && this._currentTurn.value?.id !== turnId) { @@ -3104,6 +3244,8 @@ export class CopilotAgentSession extends Disposable { this._logService.info(`[Copilot:${this.sessionId}] Aborting session...`); const abortingTurn = this._currentTurn.value; const resumingTurn = this._resumingTurnAwaitingProviderStart; + const abortTarget = abortingTurn ?? resumingTurn; + this._abortingTurn = abortTarget; if (abortingTurn) { this._dropLateRootTurnEvents = true; } @@ -3112,6 +3254,9 @@ export class CopilotAgentSession extends Disposable { try { await this._wrapper.session.abort(); } catch (error) { + if (this._abortingTurn === abortTarget) { + this._abortingTurn = undefined; + } this._resetAbortToken(); throw error; } @@ -3177,7 +3322,7 @@ export class CopilotAgentSession extends Disposable { async setModel(model: string, reasoningEffort?: SessionConfig['reasoningEffort'], contextTier?: SessionConfig['contextTier']): Promise { this._logService.info(`[Copilot:${this.sessionId}] Changing model to: ${model}`); this._lastSeenModelId = model; - await this._wrapper.session.setModel(model, { reasoningEffort, contextTier }); + await this._awaitControlPlaneRpc('session.setModel', this._wrapper.session.setModel(model, { reasoningEffort, contextTier })); } /** @@ -3415,6 +3560,20 @@ export class CopilotAgentSession extends Disposable { } } + /** Bounds a short SDK control-plane RPC so it cannot wedge the owning chat queue. */ + private async _awaitControlPlaneRpc(operation: string, rpc: Promise): Promise { + const result = await raceTimeout(rpc.then(value => ({ value })), this._controlPlaneRpcTimeoutMs); + if (!result) { + // The request is still in flight and may still mutate SDK state, so + // mark the session for resync rather than continuing to use it. + this._controlPlaneDesynchronized = true; + const error = new Error(`[Copilot:${this.sessionId}] ${operation} timed out after ${this._controlPlaneRpcTimeoutMs}ms`); + this._logService.error(error, `[Copilot:${this.sessionId}] Control-plane RPC timed out: ${operation}`); + throw error; + } + return result.value; + } + /** * Selects (or clears) a custom agent on the live SDK session. * Mirrors the SDK's `rpc.agent.select` / `rpc.agent.deselect` pair. @@ -3424,7 +3583,7 @@ export class CopilotAgentSession extends Disposable { const name = agentName; this._logService.info(`[Copilot:${this.sessionId}] Selecting custom agent: ${name}`); try { - await this._wrapper.session.rpc.agent.select({ name }); + await this._awaitControlPlaneRpc('rpc.agent.select', this._wrapper.session.rpc.agent.select({ name })); } catch (err) { this._logService.error(err, `[Copilot:${this.sessionId}] rpc.agent.select failed: name=${name}`); throw err; @@ -3432,7 +3591,7 @@ export class CopilotAgentSession extends Disposable { } else { this._logService.info(`[Copilot:${this.sessionId}] Clearing custom agent selection`); try { - await this._wrapper.session.rpc.agent.deselect(); + await this._awaitControlPlaneRpc('rpc.agent.deselect', this._wrapper.session.rpc.agent.deselect()); } catch (err) { this._logService.error(err, `[Copilot:${this.sessionId}] rpc.agent.deselect failed`); throw err; @@ -5157,6 +5316,8 @@ export class CopilotAgentSession extends Disposable { this._register(wrapper.onIdle(e => { this._logService.info(`[Copilot:${sessionId}] Session idle`); + const abortingTurn = this._abortingTurn; + this._abortingTurn = undefined; if (e.data.aborted) { this._resetAbortToken(); } @@ -5180,7 +5341,7 @@ export class CopilotAgentSession extends Disposable { // drop it before the provider starts. // - any other pending turn is a queued message started after the // abort; leave it open for its own non-abort idle. - if (e.data.aborted) { + if (e.data.aborted && (!abortingTurn || turn === abortingTurn)) { this._cancelActiveRepoInfoTelemetry(); if (turn.isRunning || turn === this._resumingTurnAwaitingProviderStart) { this._logService.trace(`[Copilot:${sessionId}] Idle from abort; tearing down cancelled turn ${turn.id}`); @@ -5195,6 +5356,13 @@ export class CopilotAgentSession extends Disposable { } return; } + if (e.data.aborted && !turn.isRunning) { + this._logService.trace(`[Copilot:${sessionId}] Idle from abort; leaving ${turn.state} replacement turn ${turn.id} open`); + return; + } + if (e.data.aborted) { + this._logService.trace(`[Copilot:${sessionId}] Idle from abort reached running replacement turn ${turn.id}; completing replacement`); + } if (turn === this._resumingTurnAwaitingProviderStart && !turn.providerTurnStarted) { this._logService.trace(`[Copilot:${sessionId}] Ignoring idle from the failed execution while resumed turn ${turn.id} awaits provider start`); return; @@ -6470,7 +6638,7 @@ export class CopilotAgentSession extends Disposable { */ async truncateAtEventId(eventId: string, keepTurnId?: string): Promise { this._logService.info(`[Copilot:${this.sessionId}] Truncating via SDK RPC at eventId=${eventId}`); - const result = await this._wrapper.session.rpc.history.truncate({ eventId }); + const result = await this._awaitControlPlaneRpc('rpc.history.truncate', this._wrapper.session.rpc.history.truncate({ eventId })); this._logService.info(`[Copilot:${this.sessionId}] SDK truncation removed ${result.eventsRemoved} events`); // Clean up stale turns from our DB so getNextTurnEventId doesn't diff --git a/src/vs/platform/agentHost/node/copilot/copilotShellTools.ts b/src/vs/platform/agentHost/node/copilot/copilotShellTools.ts index 9c7e3e75b5f7f..34868c1885006 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotShellTools.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotShellTools.ts @@ -8,19 +8,15 @@ import { generateUuid } from '../../../../base/common/uuid.js'; import { URI } from '../../../../base/common/uri.js'; import { Disposable, DisposableStore, type IReference, toDisposable } from '../../../../base/common/lifecycle.js'; import { Emitter, Event } from '../../../../base/common/event.js'; -import { IEnvironmentService } from '../../../environment/common/environment.js'; import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; import { ILogService } from '../../../log/common/log.js'; -import { IProductService } from '../../../product/common/productService.js'; -import { ISandboxHelperService } from '../../../sandbox/common/sandboxHelperService.js'; import type { ITerminalSandboxResolvedNetworkDomains } from '../../../sandbox/common/terminalSandboxService.js'; import { TerminalSandboxEngine } from '../../../sandbox/common/terminalSandboxEngine.js'; import { TerminalClaimKind, TerminalLifecycleStatus, type TerminalSessionClaim } from '../../common/state/protocol/state.js'; import { parseRequiredSessionUriFromChatUri } from '../../common/state/sessionState.js'; import { isZsh } from '../agentHostShellUtils.js'; import { IAgentHostTerminalManager } from '../agentHostTerminalManager.js'; -import { createAgentHostSandboxEngine } from './agentHostSandboxEngine.js'; -import { IAgentConfigurationService } from '../agentConfigurationService.js'; +import { AgentHostSandboxEngine } from './agentHostSandboxEngine.js'; import { DEFAULT_SHELL_COMMAND_TIMEOUT_MS, executeShellCommand, isMultilineCommand, prefixForHistorySuppression, prepareOutputForModel, shellTypeForExecutable, type IShellCommandResult, type ShellType } from '../shared/shellCommandExecution.js'; // Re-exported for consumers (and tests) that historically imported these @@ -61,7 +57,9 @@ export class ShellManager extends Disposable { private readonly _shells = new Map(); private readonly _toolCallShells = new Map(); private _resolvedExecutable: Promise | undefined; - private _sandboxEngine: TerminalSandboxEngine | undefined; + private _sandboxEngine: AgentHostSandboxEngine | undefined; + private _workingDirectory: URI | undefined; + private _pendingShellCreations = 0; /** Set of shell ids currently executing a command and unsafe to share. */ private readonly _busyShellIds = new Set(); /** Release listeners for shells held after a tool returns while the command is still running. */ @@ -72,16 +70,13 @@ export class ShellManager extends Disposable { constructor( private readonly _sessionUri: URI, - public readonly workingDirectory: URI | undefined, + workingDirectory: URI | undefined, @IAgentHostTerminalManager private readonly _terminalManager: IAgentHostTerminalManager, @ILogService private readonly _logService: ILogService, @IInstantiationService private readonly _instantiationService: IInstantiationService, - @IEnvironmentService private readonly _environmentService: IEnvironmentService, - @IProductService private readonly _productService: IProductService, - @IAgentConfigurationService private readonly _agentConfigurationService: IAgentConfigurationService, - @ISandboxHelperService private readonly _sandboxHelper: ISandboxHelperService, ) { super(); + this._workingDirectory = workingDirectory; this._register(toDisposable(() => { for (const store of this._heldShellReleaseListeners.values()) { @@ -99,6 +94,32 @@ export class ShellManager extends Disposable { })); } + get workingDirectory(): URI | undefined { + return this._workingDirectory; + } + + /** Throws if shell activity prevents changing the working directory safely. */ + assertCanSetWorkingDirectory(): void { + if (this._busyShellIds.size > 0 || this._heldShellReleaseListeners.size > 0 || this._pendingShellCreations > 0) { + throw new Error('Cannot change the working directory while a shell is busy'); + } + } + + /** Re-anchors future shells and sandbox roots after safely discarding idle shell state. */ + setWorkingDirectory(workingDirectory: URI): void { + this.assertCanSetWorkingDirectory(); + + for (const shell of this._shells.values()) { + if (this._terminalManager.hasTerminal(shell.terminalUri)) { + this._terminalManager.disposeTerminal(shell.terminalUri); + } + } + this._shells.clear(); + this._toolCallShells.clear(); + this._workingDirectory = workingDirectory; + this._sandboxEngine?.setWorkingDirectory(workingDirectory); + } + /** * Resolves the session's shell executable via {@link IAgentHostTerminalManager.getDefaultShell} * and caches it so every tool call in the session uses the same binary @@ -119,22 +140,18 @@ export class ShellManager extends Disposable { getOrCreateSandboxEngine(): TerminalSandboxEngine { if (!this._sandboxEngine) { const sessionId = this._sessionUri.path.split('/').pop() ?? generateUuid(); - const engine = createAgentHostSandboxEngine( - this._instantiationService, - this._environmentService, - this._productService, - this._agentConfigurationService, - this._sandboxHelper, + const sandboxEngine = this._instantiationService.createInstance( + AgentHostSandboxEngine, sessionId, - this.workingDirectory, + this._workingDirectory, ); - this._register(engine); + this._register(sandboxEngine); this._register(toDisposable(() => { - void engine.cleanupTempDir().catch(err => this._logService.warn('[ShellManager] Sandbox temp dir cleanup failed', err)); + void sandboxEngine.engine.cleanupTempDir().catch(err => this._logService.warn('[ShellManager] Sandbox temp dir cleanup failed', err)); })); - this._sandboxEngine = engine; + this._sandboxEngine = sandboxEngine; } - return this._sandboxEngine; + return this._sandboxEngine.engine; } /** @@ -184,22 +201,27 @@ export class ShellManager extends Disposable { }; const shellDisplayName = shellType === 'bash' ? 'Bash' : 'PowerShell'; - const executable = await this.getResolvedExecutable(); - - await this._terminalManager.createTerminal({ - channel: terminalUri, - claim, - name: shellDisplayName, - cwd: cwd ?? this.workingDirectory?.fsPath, - }, { shell: executable, preventShellHistory: true, nonInteractive: true }); - - const shell: IManagedShell = { id, terminalUri, shellType, executable }; - this._shells.set(id, shell); - this._busyShellIds.add(id); - this._trackToolCall(toolCallId, id); - - this._logService.info(`[ShellManager] Created ${shellType} shell ${id} (terminal=${terminalUri}, executable=${executable})`); - return this._makeReference(shell); + this._pendingShellCreations++; + try { + const executable = await this.getResolvedExecutable(); + + await this._terminalManager.createTerminal({ + channel: terminalUri, + claim, + name: shellDisplayName, + cwd: cwd ?? this.workingDirectory?.fsPath, + }, { shell: executable, preventShellHistory: true, nonInteractive: true }); + + const shell: IManagedShell = { id, terminalUri, shellType, executable }; + this._shells.set(id, shell); + this._busyShellIds.add(id); + this._trackToolCall(toolCallId, id); + + this._logService.info(`[ShellManager] Created ${shellType} shell ${id} (terminal=${terminalUri}, executable=${executable})`); + return this._makeReference(shell); + } finally { + this._pendingShellCreations--; + } } private _makeReference(shell: IManagedShell): IReference { diff --git a/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts b/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts index c08a3cb4475b3..c2d6762d73b93 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts @@ -1196,10 +1196,11 @@ export function getPermissionDisplay(request: PermissionRequest, workingDirector permissionPath: path, }; } + const serverDisplay = sdkToolName ? getServerToolDisplay(sdkToolName, args) : undefined; return { - confirmationTitle: localize('copilot.permission.default.title', "Allow tool call?"), - invocationMessage: md(localize('copilot.permission.default.message', "Allow the model to call {0}?", appendEscapedMarkdownInlineCode(toolName ?? request.kind))), - toolInput: args ? tryStringify(args) : tryStringify(request), + confirmationTitle: serverDisplay?.confirmationTitle ?? localize('copilot.permission.default.title', "Allow tool call?"), + invocationMessage: serverDisplay?.confirmationMessage ?? md(localize('copilot.permission.default.message', "Allow the model to call {0}?", appendEscapedMarkdownInlineCode(toolName ?? request.kind))), + toolInput: serverDisplay?.hideConfirmationInput ? undefined : args ? tryStringify(args) : tryStringify(request), permissionKind: request.kind, permissionPath: path, }; diff --git a/src/vs/platform/agentHost/node/copilot/prompts/systemMessage.ts b/src/vs/platform/agentHost/node/copilot/prompts/systemMessage.ts index 2f153cecf73b8..e8957e3ccb478 100644 --- a/src/vs/platform/agentHost/node/copilot/prompts/systemMessage.ts +++ b/src/vs/platform/agentHost/node/copilot/prompts/systemMessage.ts @@ -47,8 +47,8 @@ export const COPILOT_AGENT_HOST_SYSTEM_MESSAGE = { * Scratch/repoless guidance appended to a workspace-less chat's system message. * A workspace-less chat's working directory is a throwaway SCRATCH dir, not a * code repository — so this tells the agent not to treat it like a project, to - * stay read-only on real repos, and to delegate code changes to a dedicated - * session. Modeled on the GitHub app's `build_general_chat_system_message`. + * stay read-only on real repos, and to attach a workspace before project work. + * Modeled on the GitHub app's `build_general_chat_system_message`. */ export const COPILOT_AGENT_HOST_WORKSPACELESS_INSTRUCTIONS = [ '', @@ -56,7 +56,8 @@ export const COPILOT_AGENT_HOST_WORKSPACELESS_INSTRUCTIONS = [ '', '- Your working directory is a SCRATCH directory for running commands and saving throwaway artifacts — it is NOT a code repository. Do not treat it as a project to build, test, or commit.', '- If the user points you at a real repository, prefer read-only operations: read files, search code, and inspect git metadata (branch, log, diff, status) to answer questions. Avoid modifying files or running builds, tests, linters, or installs in their working copies.', - '- When the user wants code changes, test runs, or any work that modifies or executes against a real project, delegate it to a dedicated session rather than doing it here.', + '- When the task should continue in a real workspace and `set_workspace` is available, prefer attaching that workspace and continuing this same conversation. Do not create another session solely to move the work. Use `list_sessions` to discover a known workspace when needed, and never guess a path.', + '- Immediately before every `set_workspace` call, always use `ask_user` to confirm both the workspace and whether the work should be isolated, even if the user previously mentioned or requested those choices. Tool approval is separate and does not replace this confirmation.', '', ].join('\n'); diff --git a/src/vs/platform/agentHost/node/copilot/sandboxConfigForSdk.ts b/src/vs/platform/agentHost/node/copilot/sandboxConfigForSdk.ts index 0af78e827b494..f29a2d345729a 100644 --- a/src/vs/platform/agentHost/node/copilot/sandboxConfigForSdk.ts +++ b/src/vs/platform/agentHost/node/copilot/sandboxConfigForSdk.ts @@ -186,8 +186,8 @@ export function buildSandboxConfigForSdk( addCurrentWorkingDirectory: true, allowDevToolAccess: true, auth: { - git: false, - gh: false, + git: true, + gh: true, }, userPolicy: { filesystem: { @@ -198,7 +198,7 @@ export function buildSandboxConfigForSdk( }, network: { allowOutbound: typeof allowNetwork === 'boolean' ? allowNetwork : false, - allowLocalNetwork: true, + allowLocalNetwork: false, }, }, }; diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts index dd8d5e53736ec..a047e83ad2163 100644 --- a/src/vs/platform/agentHost/node/protocolServerHandler.ts +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -20,7 +20,7 @@ import { AgentHostClientConnectionKind, AgentHostLaunchKind, AgentHostTransportK import { AgentSession, type IAgentCreateChatRequestOptions, type IMcpNotification } from '../common/agent.js'; import { isManagedSettingsPermissions } from '../common/agentHostManagedSettings.js'; import { type IAgentService } from '../common/agentService.js'; -import { ClaimAgentHostDetachedWorktreeExtensionMethod, collectAgentHostDebugLogsParamsValidator, CollectAgentHostDebugLogsExtensionMethod, CreateAgentHostDetachedWorktreeExtensionMethod, DeleteAgentHostDetachedWorktreeExtensionMethod, getAgentHostExtensionInitializeResultMeta, GetAgentHostSessionStateFileExtensionMethod, ReadAgentHostDebugLogsChunkExtensionMethod, ReconcileAgentHostDetachedWorktreesExtensionMethod, SetAgentHostDetachedWorktreeArchivedExtensionMethod, type IAgentHostExtensionInitializeResult } from '../common/agentHostExtensionProtocol.js'; +import { ClaimAgentHostDetachedWorktreeExtensionMethod, collectAgentHostDebugLogsParamsValidator, CollectAgentHostDebugLogsExtensionMethod, CreateAgentHostDetachedWorktreeExtensionMethod, DeleteAgentHostDetachedWorktreeExtensionMethod, getAgentHostExtensionInitializeResultMeta, GetAgentHostSessionStateFileExtensionMethod, ReadAgentHostDebugLogsChunkExtensionMethod, ReconcileAgentHostDetachedWorktreesExtensionMethod, RequestAgentHostWorkspaceTrustExtensionMethod, SetAgentHostDetachedWorktreeArchivedExtensionMethod, type IAgentHostExtensionInitializeResult, type IAgentHostExtensionServerCommandMap, type IAgentHostWorkspaceTrustRequest } from '../common/agentHostExtensionProtocol.js'; import { isAgentDevContainerWorktreeHandle } from '../common/meta/agentDevContainerWorktreeMeta.js'; import { isActionEnvelopeRelevantToSubscriptionUris } from '../common/state/agentSubscription.js'; import { ChatSourceKind } from '../common/state/protocol/channels-chat/commands.js'; @@ -1332,6 +1332,15 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien return result; } + async requestWorkspaceTrust(clientId: string, request: IAgentHostWorkspaceTrustRequest): Promise { + const result = await this._sendReverseRequest( + clientId, + RequestAgentHostWorkspaceTrustExtensionMethod, + request, + ); + return result.trusted === true; + } + /** Number of clients that currently have a live connection. */ private get _connectedClientCount(): number { let count = 0; diff --git a/src/vs/platform/agentHost/node/sessionDatabase.ts b/src/vs/platform/agentHost/node/sessionDatabase.ts index 9d038c7aad43c..a7e62bddae8c2 100644 --- a/src/vs/platform/agentHost/node/sessionDatabase.ts +++ b/src/vs/platform/agentHost/node/sessionDatabase.ts @@ -736,6 +736,17 @@ export class SessionDatabase implements ISessionDatabase { })); } + deleteMetadata(keys: readonly string[]): Promise { + return this._track(() => this._metadataSequencer.queue(async () => { + if (keys.length === 0) { + return; + } + const db = await this._ensureDb(); + const placeholders = keys.map(() => '?').join(','); + await dbRun(db, `DELETE FROM session_metadata WHERE key IN (${placeholders})`, [...keys]); + })); + } + setMetadataValuesIfAbsent(key: string, values: Readonly>, copies: Readonly> = {}): Promise { return this._track(() => this._metadataSequencer.queue(async () => { const db = await this._ensureDb(); diff --git a/src/vs/platform/agentHost/node/shared/agentFeedbackServerTools.ts b/src/vs/platform/agentHost/node/shared/agentFeedbackServerTools.ts index 37c591e843f1a..885bd4dfaa716 100644 --- a/src/vs/platform/agentHost/node/shared/agentFeedbackServerTools.ts +++ b/src/vs/platform/agentHost/node/shared/agentFeedbackServerTools.ts @@ -651,6 +651,9 @@ export const feedbackServerToolGroup: IServerToolGroup = { isEnabled(): boolean { return true; }, + isEnabledForSession(): boolean { + return true; + }, canRequireConfirmation(toolName): boolean { return feedbackToolRequiresConfirmation(toolName); }, diff --git a/src/vs/platform/agentHost/node/shared/agentMergeServerTools.ts b/src/vs/platform/agentHost/node/shared/agentMergeServerTools.ts index 8c47629fbb860..9150093cafa8c 100644 --- a/src/vs/platform/agentHost/node/shared/agentMergeServerTools.ts +++ b/src/vs/platform/agentHost/node/shared/agentMergeServerTools.ts @@ -62,6 +62,7 @@ export function createAgentMergeServerToolGroup(accessor?: IAgentMergeToolAccess return { definitions, isEnabled: toolName => accessor?.isEnabled() === true && definitions.some(definition => definition.name === toolName), + isEnabledForSession: () => true, execute: (_stateManager: AgentHostStateManager, context, toolName: string, rawArgs: unknown) => { if (!accessor) { throw new Error('Agent Merge tools are not available without an Agent Merge controller.'); diff --git a/src/vs/platform/agentHost/node/shared/agentServerToolHost.ts b/src/vs/platform/agentHost/node/shared/agentServerToolHost.ts index 4eda016fdecf9..b264a47d9deb4 100644 --- a/src/vs/platform/agentHost/node/shared/agentServerToolHost.ts +++ b/src/vs/platform/agentHost/node/shared/agentServerToolHost.ts @@ -6,8 +6,16 @@ import type { IAgentServerToolDefinition, IAgentServerToolHost } from '../../common/agentServerTools.js'; import { ActionType } from '../../common/state/protocol/common/actions.js'; import { parseRequiredSessionUriFromChatUri, type StringOrMarkdown, type ToolDefinition, type URI } from '../../common/state/sessionState.js'; +import { createDecorator } from '../../../instantiation/common/instantiation.js'; import type { AgentHostStateManager } from '../agentHostStateManager.js'; +export const IAgentHostServerToolService = createDecorator('agentHostServerToolService'); + +/** Injectable Agent Host server-tool registry and advertiser. */ +export interface IAgentHostServerToolService extends IAgentServerToolHost { + readonly _serviceBrand: undefined; +} + /** * Result of a server tool, passed to {@link IServerToolGroup.getDisplay} so the * owning group can tailor its past-tense message to what the tool returned. @@ -34,6 +42,12 @@ export interface IServerToolDisplay { readonly invocationMessage?: StringOrMarkdown; /** Past-tense message shown once the tool completes. When omitted, the provider reuses `invocationMessage`. */ readonly pastTenseMessage?: StringOrMarkdown; + /** Short title shown when the tool requires confirmation. */ + readonly confirmationTitle?: string; + /** Plain-language description of the decision shown when the tool requires confirmation. */ + readonly confirmationMessage?: StringOrMarkdown; + /** Whether the generic raw-input preview should be omitted from the confirmation. */ + readonly hideConfirmationInput?: boolean; } export interface IServerToolExecutionContext { @@ -72,6 +86,8 @@ export interface IServerToolGroup { readonly materializeDefinitions?: boolean; /** Whether a contributed tool is currently enabled for advertisement and execution. */ isEnabled(toolName: string): boolean; + /** Whether a contributed tool is supported by a specific session. */ + isEnabledForSession(toolName: string, sessionUri: URI): boolean; /** * Whether {@link toolName} (one of this group's {@link definitions}) can * ever prompt for confirmation. Providers exclude such tools from their @@ -126,7 +142,9 @@ export interface IServerToolGroup { * tool on a session's {@link SessionState.serverTools} so clients see them as * server-provided. */ -export class AgentServerToolHost implements IAgentServerToolHost { +export class AgentServerToolHost implements IAgentHostServerToolService { + + declare readonly _serviceBrand: undefined; /** Every name the host answers to — current and legacy — and its owning group. */ private readonly _groupByToolName = new Map(); @@ -181,9 +199,10 @@ export class AgentServerToolHost implements IAgentServerToolHost { const currentByName = new Map(group.definitions.map(definition => [definition.name, definition])); return materializedDefinitions .filter(definition => this._groupByToolName.get(definition.name) === group) + .filter(definition => !currentByName.has(definition.name) || group.isEnabledForSession(definition.name, sessionUri)) .map(definition => currentByName.get(definition.name) ?? definition); } - const definitions = group.definitions.filter(definition => group.isEnabled(definition.name)); + const definitions = group.definitions.filter(definition => group.isEnabled(definition.name) && group.isEnabledForSession(definition.name, sessionUri)); return isEphemeral ? definitions.filter(definition => definition.enabledForEphemeralSessions) : definitions; }); } @@ -250,6 +269,10 @@ export class AgentServerToolHost implements IAgentServerToolHost { } private _isEnabledForSession(group: IServerToolGroup, chatUri: URI, toolName: string, requestedToolName = toolName): boolean { + const sessionUri = parseRequiredSessionUriFromChatUri(chatUri); + if (!group.isEnabledForSession(toolName, sessionUri)) { + return false; + } const advertisedTools = this._stateManager.getSessionState(chatUri)?.serverTools; return advertisedTools ? advertisedTools.some(tool => tool.name === toolName) || group.legacyToolNames?.has(requestedToolName) === true diff --git a/src/vs/platform/agentHost/node/shared/artifactServerTools.ts b/src/vs/platform/agentHost/node/shared/artifactServerTools.ts index aee5845e1b168..0402f70c0dd8f 100644 --- a/src/vs/platform/agentHost/node/shared/artifactServerTools.ts +++ b/src/vs/platform/agentHost/node/shared/artifactServerTools.ts @@ -125,6 +125,9 @@ export function createArtifactServerToolGroup(accessor?: IArtifactServerToolAcce isEnabled(): boolean { return accessor?.isEnabled() === true; }, + isEnabledForSession(): boolean { + return true; + }, getDisplay(toolName, args, result): IServerToolDisplay | undefined { switch (toolName) { case ArtifactServerToolName.AddArtifactOrReference: { diff --git a/src/vs/platform/agentHost/node/shared/sessionServerTools.ts b/src/vs/platform/agentHost/node/shared/sessionServerTools.ts index 2cddea6e377c7..dad63d53cdf6d 100644 --- a/src/vs/platform/agentHost/node/shared/sessionServerTools.ts +++ b/src/vs/platform/agentHost/node/shared/sessionServerTools.ts @@ -5,13 +5,15 @@ import type { Mutable } from '../../../../base/common/types.js'; import { URI } from '../../../../base/common/uri.js'; -import { isEqual } from '../../../../base/common/resources.js'; -import type { IAgentMessageDelegationMeta } from '../../common/meta/agentMessageDelegationMeta.js'; +import { basename, isEqual } from '../../../../base/common/resources.js'; +import { Schemas } from '../../../../base/common/network.js'; +import { toAgentMessageDelegationMeta, type IAgentMessageDelegationMeta } from '../../common/meta/agentMessageDelegationMeta.js'; import { localize } from '../../../../nls.js'; import { AgentSession, type AgentProvider, type IAgentCreateSessionConfig, type IAgentModelInfo, type IAgentSessionMetadata } from '../../common/agent.js'; import { SessionStatus } from '../../common/state/protocol/channels-session/state.js'; +import { ActionType } from '../../common/state/sessionActions.js'; import type { IAgentServerToolDefinition } from '../../common/agentServerTools.js'; -import { buildChatUri, buildDefaultChatUri, getInlineToolInput, getSessionRelatedPullRequestUrls, isDefaultChatUri, isSessionStatusArchived, isSessionStatusRead, parseChatUri, readSessionGitState, readSessionGitHubState, ResponsePartKind, ToolCallStatus, TurnState, withSessionCreationReference, type Message, type ModelSelection, type ResponsePart, type ToolCallState, type ToolDefinition, type Turn, type URI as ProtocolURI } from '../../common/state/sessionState.js'; +import { buildChatUri, buildDefaultChatUri, getInlineToolInput, getSessionRelatedPullRequestUrls, isDefaultChatUri, isSessionStatusArchived, isSessionStatusRead, MessageKind, parseChatUri, PendingMessageKind, readSessionGitState, readSessionGitHubState, ResponsePartKind, ToolCallStatus, TurnState, withSessionCreationReference, type Message, type ModelSelection, type ResponsePart, type ToolCallState, type ToolDefinition, type Turn, type URI as ProtocolURI } from '../../common/state/sessionState.js'; import { buildOpenSessionLinkUri, parseOpenSessionLinkChatId, parseOpenSessionLinkUri } from '../../common/openSessionLink.js'; import { SessionServerToolName } from '../../common/serverToolNames.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; @@ -35,7 +37,7 @@ const maxCreatedChats = 25; /** Process-wide backstop against runaway `send_message` fan-out. */ const maxSentMessages = 50; -const sessionConfirmationToolNames: ReadonlySet = new Set([SessionServerToolName.CreateSession, SessionServerToolName.CreateChat, SessionServerToolName.SendMessage, SessionServerToolName.DeleteSession]); +const sessionConfirmationToolNames: ReadonlySet = new Set([SessionServerToolName.SetWorkspace, SessionServerToolName.CreateSession, SessionServerToolName.CreateChat, SessionServerToolName.SendMessage, SessionServerToolName.DeleteSession]); const createSessionRelationshipValues = ['currentSession', 'independent'] as const; export type CreateSessionRelationship = typeof createSessionRelationshipValues[number]; @@ -86,6 +88,21 @@ const getCurrentSessionInputSchema: ToolDefinition['inputSchema'] = { properties: {}, }; +const setWorkspaceInputSchema: ToolDefinition['inputSchema'] = { + type: 'object', + properties: { + workspaceFolder: { + type: 'string', + description: 'Absolute local folder path or file URI to set as the current session\'s workspace. Use an exact path from the user or `list_sessions`; do not guess.', + }, + isolation: { + type: 'boolean', + description: 'Whether to create an isolated Git worktree and use it as the workspace. Include this choice in the required user confirmation immediately before calling this tool.', + }, + }, + required: ['workspaceFolder', 'isolation'], +}; + const renameChatInputSchema: ToolDefinition['inputSchema'] = { type: 'object', properties: { @@ -146,6 +163,13 @@ export const sessionServerToolDefinitions: IAgentServerToolDefinition[] = [ inputSchema: getCurrentSessionInputSchema, annotations: { readOnlyHint: true }, }, + { + name: SessionServerToolName.SetWorkspace, + title: 'Set Workspace', + description: 'Set the current session\'s workspace when the task should continue in a workspace not yet attached to this session. This preserves the session, chat, and conversation history. Immediately before every call to this tool, always use the available user-input tool to ask the user to confirm both the workspace and whether the work should be isolated, even if the user previously mentioned or requested those choices. Tool approval is separate and does not replace this confirmation. Set `isolation` to true to create a managed Git worktree, or false to work directly in the folder. The workspace change is deferred until the current turn ends, then the host automatically continues the original task in the selected workspace. Make this the final tool call of the turn.', + inputSchema: setWorkspaceInputSchema, + annotations: { readOnlyHint: false }, + }, { name: SessionServerToolName.CreateSession, title: 'Create Session', @@ -163,7 +187,7 @@ export const sessionServerToolDefinitions: IAgentServerToolDefinition[] = [ { name: SessionServerToolName.SendMessage, title: 'Send Message', - description: 'Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link; a link carrying a chat id targets that specific chat. The message is delivered asynchronously — this tool does not wait for or return the reply.', + description: 'Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link; a link carrying a chat id targets that specific chat. If the target chat is busy, the message is queued and starts after the active turn completes successfully. Delivery is asynchronous — this tool does not wait for or return the reply.', inputSchema: sendMessageInputSchema, annotations: { readOnlyHint: false }, }, @@ -210,9 +234,10 @@ export type IResolvedCreateSessionArgs = { readonly model?: IAgentModelInfo; }; -/** Minimal dependency surface needed by the session server-tool group. */ -export interface ISessionServerToolAccessor { +/** AgentService-owned operations used by the session server-tool group. */ +export interface IAgentServiceSessionServerToolAccessor { readonly isActiveAgentTitleGenerationEnabled: () => boolean; + readonly canConvertWorkspace: (session: URI) => boolean; readonly listSessions: () => Promise; readonly getSession: (session: URI) => Promise; readonly createSession: (config: IAgentCreateSessionConfig) => Promise; @@ -231,6 +256,11 @@ export interface ISessionServerToolAccessor { readonly setSessionSpawnDepth: (session: URI, depth: number) => void; } +/** Complete dependency surface needed by the session server-tool group. */ +export interface ISessionServerToolAccessor extends IAgentServiceSessionServerToolAccessor { + readonly requestSessionWorkspaceUpdate: (chat: URI, turnId: string, workspaceFolder: URI, isolation: boolean) => void; +} + export interface IRenameTitleResult { readonly title: string; } @@ -308,6 +338,13 @@ function getRequiredString(value: unknown, field: string, toolName: string): str return value; } +function getRequiredBoolean(value: unknown, field: string, toolName: string): boolean { + if (typeof value !== 'boolean') { + throw new Error(`Invalid ${toolName} input: ${field} must be a boolean.`); + } + return value; +} + function getOptionalString(value: unknown, field: string, toolName: string): string | undefined { if (value === undefined) { return undefined; @@ -378,6 +415,20 @@ function parseWorkspaceUri(workspace: string): URI | undefined { } } +/** Validates and resolves the workspace requested by `set_workspace`. */ +export function getSetWorkspaceArgs(rawArgs: unknown): { readonly workspaceFolder: URI; readonly isolation: boolean } { + const args = (rawArgs ?? {}) as { readonly workspaceFolder?: unknown; readonly isolation?: unknown }; + const input = getRequiredString(args.workspaceFolder, 'workspaceFolder', SessionServerToolName.SetWorkspace); + const workspaceFolder = parseWorkspaceUri(input); + if (!workspaceFolder || workspaceFolder.scheme !== Schemas.file || !workspaceFolder.path.startsWith('/') || workspaceFolder.query || workspaceFolder.fragment) { + throw new Error(`Invalid ${SessionServerToolName.SetWorkspace} input: workspaceFolder must be an absolute local path or file URI.`); + } + return { + workspaceFolder, + isolation: getRequiredBoolean(args.isolation, 'isolation', SessionServerToolName.SetWorkspace), + }; +} + function resolveWorkspace(workspace: string, sessions: readonly IAgentSessionMetadata[]): URI { const parsed = parseWorkspaceUri(workspace); for (const session of sessions) { @@ -1065,11 +1116,12 @@ export function getSendMessageArgs(rawArgs: unknown, sessions: readonly IAgentSe } /** - * Sends a message to an existing session/chat, starting a new turn there. + * Sends a message to an existing session/chat, starting a new turn there or + * queuing it behind the target chat's active or pending messages. * Refuses to target {@link currentChannel} (the chat channel the tool runs on) * to avoid a session trivially messaging itself in a loop. */ -export async function applySendMessageTool(accessor: ISessionServerToolAccessor, rawArgs: unknown, currentChannel?: ProtocolURI, sourceTurnId?: string): Promise { +export async function applySendMessageTool(accessor: ISessionServerToolAccessor, rawArgs: unknown, currentChannel?: ProtocolURI, sourceTurnId?: string, stateManager?: AgentHostStateManager): Promise { const sessions = await accessor.listSessions(); const { session, chat, chatId, message } = getSendMessageArgs(rawArgs, sessions); if (currentChannel && chat.toString() === URI.parse(currentChannel).toString()) { @@ -1077,17 +1129,33 @@ export async function applySendMessageTool(accessor: ISessionServerToolAccessor, } const sourceChat = currentChannel ? URI.parse(currentChannel) : undefined; const sourceSession = sourceChat ? currentSessionUri(sourceChat.toString()) : undefined; - await accessor.startPrompt(session, chat, message, sourceSession ? { + const delegation: IAgentMessageDelegationMeta | undefined = sourceSession ? { sourceSession: sourceSession.toString(), sourceChat: sourceChat?.toString(), ...(sourceTurnId !== undefined ? { sourceTurnId } : {}), - } : undefined); - return formatSendMessageResult(buildOpenSessionLinkUri(session, chatId)); + } : undefined; + const targetState = stateManager?.getChatState(chat.toString()); + if (stateManager && (targetState?.activeTurn || targetState?.steeringMessage || targetState?.queuedMessages?.length)) { + const queuedMessage: Message = { + text: message, + origin: { kind: MessageKind.Agent }, + ...(delegation ? { _meta: toAgentMessageDelegationMeta(delegation) } : {}), + }; + stateManager.dispatchServerAction(chat.toString(), { + type: ActionType.ChatPendingMessageSet, + kind: PendingMessageKind.Queued, + id: generateUuid(), + message: queuedMessage, + }); + return formatSendMessageResult(buildOpenSessionLinkUri(session, chatId), true); + } + await accessor.startPrompt(session, chat, message, delegation); + return formatSendMessageResult(buildOpenSessionLinkUri(session, chatId), false); } /** Builds the model-facing `send_message` result. */ -export function formatSendMessageResult(openLink: string): string { - return `Message sent (${openLink}).`; +export function formatSendMessageResult(openLink: string, queued: boolean): string { + return `Message ${queued ? 'queued' : 'sent'} (${openLink}).`; } // --- get_session_context ----------------------------------------------------- @@ -1311,6 +1379,18 @@ export async function applyDeleteSessionTool(accessor: ISessionServerToolAccesso return `Deleted session ${session.toString()}. Reply with one short sentence confirming the session was deleted.`; } +/** Requests setting the workspace in place after the tool's active turn completes. */ +export function applySetWorkspaceTool(accessor: ISessionServerToolAccessor, rawArgs: unknown, chat: URI, turnId: string | undefined): string { + if (!turnId) { + throw new Error(`${SessionServerToolName.SetWorkspace} must run from an active chat turn.`); + } + const { workspaceFolder, isolation } = getSetWorkspaceArgs(rawArgs); + accessor.requestSessionWorkspaceUpdate(chat, turnId, workspaceFolder, isolation); + return isolation + ? `An isolated worktree will be created from ${workspaceFolder.toString()} and set as the workspace after this turn ends. End this turn now without calling more tools or replying; the host will continue the original task automatically in the isolated workspace.` + : `Workspace will be set to ${workspaceFolder.toString()} after this turn ends. End this turn now without calling more tools or replying; the host will continue the original task automatically in the selected workspace.`; +} + function getSessionToolDisplay(toolName: string, args: unknown, _result?: IServerToolDisplayResult): IServerToolDisplay | undefined { switch (toolName) { case SessionServerToolName.ListSessions: @@ -1364,6 +1444,29 @@ function getSessionToolDisplay(toolName: string, args: unknown, _result?: IServe displayName: localize('toolName.getCurrentSession', "Get Current Session"), invocationMessage: localize('toolInvoke.getCurrentSession', "Get current session"), }; + case SessionServerToolName.SetWorkspace: + { + const input = args as { readonly workspaceFolder?: unknown; readonly isolation?: unknown } | undefined; + const workspaceFolder = typeof input?.workspaceFolder === 'string' + ? input.workspaceFolder + : localize('toolConfirm.setWorkspace.selectedWorkspace', "the selected workspace"); + const workspaceName = typeof input?.workspaceFolder === 'string' + ? basename(parseWorkspaceUri(input.workspaceFolder) ?? URI.file(input.workspaceFolder)) || workspaceFolder + : workspaceFolder; + const confirmationMessage = input?.isolation === true + ? localize('toolConfirm.setWorkspace.isolated', "Continue this session in {0} with changes isolated from the existing folder?", workspaceFolder) + : input?.isolation === false + ? localize('toolConfirm.setWorkspace.direct', "Continue this session in {0} and make changes directly in that folder?", workspaceFolder) + : localize('toolConfirm.setWorkspace.generic', "Continue this session in {0}?", workspaceFolder); + return { + displayName: localize('toolName.setWorkspace', "Set Workspace"), + invocationMessage: localize('toolInvoke.setWorkspace', "Setting workspace"), + pastTenseMessage: localize('toolComplete.setWorkspace', "Scheduled workspace change"), + confirmationTitle: localize('toolConfirm.setWorkspace.title', "Continue in {0}?", workspaceName), + confirmationMessage, + hideConfirmationInput: true, + }; + } case SessionServerToolName.DeleteSession: return { displayName: localize('toolName.deleteSession', "Delete Session"), @@ -1396,13 +1499,16 @@ export function createSessionServerToolGroup(accessor?: ISessionServerToolAccess isEnabled(toolName: string): boolean { return toolName !== SessionServerToolName.RenameChat || accessor?.isActiveAgentTitleGenerationEnabled() !== false; }, + isEnabledForSession(toolName: string, sessionUri: ProtocolURI): boolean { + return toolName !== SessionServerToolName.SetWorkspace || accessor?.canConvertWorkspace(URI.parse(sessionUri)) === true; + }, canRequireConfirmation(toolName: string): boolean { return sessionToolRequiresConfirmation(toolName); }, getDisplay(toolName: string, args: unknown, result?: IServerToolDisplayResult): IServerToolDisplay | undefined { return getSessionToolDisplay(toolName, args, result); }, - async execute(_stateManager: AgentHostStateManager, context, toolName: string, rawArgs: unknown): Promise { + async execute(stateManager: AgentHostStateManager, context, toolName: string, rawArgs: unknown): Promise { if (!accessor) { throw new Error(`Session server tool "${toolName}" cannot run: the group was built without a session accessor.`); } @@ -1418,6 +1524,9 @@ export function createSessionServerToolGroup(accessor?: ISessionServerToolAccess const metadata = await accessor.getSession(currentSession); return serializeCurrentSession(currentSession, metadata ? [metadata] : []); } + case SessionServerToolName.SetWorkspace: { + return applySetWorkspaceTool(accessor, rawArgs, URI.parse(currentChannel), context.turnId); + } case SessionServerToolName.CreateSession: { const relationship = getCreateSessionRelationship(rawArgs); if (relationship === 'currentSession' && createdChatCount >= maxCreatedChats) { @@ -1448,7 +1557,7 @@ export function createSessionServerToolGroup(accessor?: ISessionServerToolAccess if (sentMessageCount >= maxSentMessages) { throw new Error(`Refusing to send more than ${maxSentMessages} messages from server tools in this process.`); } - const result = await applySendMessageTool(accessor, rawArgs, currentChannel, context.turnId); + const result = await applySendMessageTool(accessor, rawArgs, currentChannel, context.turnId, stateManager); sentMessageCount++; return result; } diff --git a/src/vs/platform/agentHost/node/shared/worktreeIsolation.ts b/src/vs/platform/agentHost/node/shared/worktreeIsolation.ts index d630fe630746d..3f4b2d02ae59f 100644 --- a/src/vs/platform/agentHost/node/shared/worktreeIsolation.ts +++ b/src/vs/platform/agentHost/node/shared/worktreeIsolation.ts @@ -63,6 +63,7 @@ export interface IAgentHostWorktreeIsolation extends IAgentHostWorktreePendingSt applyRestoreAnnouncement(sessionUri: URI, turns: readonly Turn[]): Promise; prepareSessionDeletion(sessionUri: URI, sessionId: string): Promise; removeSessionWorktree(sessionId: string, worktree: ISessionWorktree | undefined): Promise; + discardSessionWorktree(sessionUri: URI, sessionId: string, worktree: ISessionWorktree | undefined): Promise; cleanupWorktreeOnArchive(sessionUri: URI, sessionId: string): Promise; recreateWorktreeOnUnarchive(sessionUri: URI, sessionId: string): Promise; readWorktreeMetadata(sessionUri: URI): Promise; @@ -723,8 +724,7 @@ export class WorktreeIsolation extends Disposable implements IAgentHostWorktreeI sessionMutable: false, }); - // Resolve isolation first — downstream schema shapes (branch's - // read-only mode + enum restriction) depend on the effective value. + // Resolve isolation first because the branch default depends on the effective value. const isolationDefault: 'folder' | 'worktree' = gitInfo ? 'worktree' : 'folder'; const isolationValue = isolationProperty.validate(request.config?.[SessionConfigKey.Isolation]) ? request.config![SessionConfigKey.Isolation] as 'folder' | 'worktree' @@ -738,9 +738,8 @@ export class WorktreeIsolation extends Disposable implements IAgentHostWorktreeI let worktreeBranchTrackProperty: ISchemaProperty | undefined; let worktreeCreateNewBranchProperty: ISchemaProperty | undefined; if (gitInfo) { - const branchReadOnly = isolationValue === 'folder'; branchDefault = isolationValue === 'worktree' ? gitInfo.defaultBranch.name : gitInfo.currentBranch; - branchValue = isolationValue === 'worktree' && typeof request.config?.[SessionConfigKey.Branch] === 'string' + branchValue = typeof request.config?.[SessionConfigKey.Branch] === 'string' ? request.config[SessionConfigKey.Branch] as string : branchDefault; branchProperty = schemaProperty({ @@ -750,8 +749,8 @@ export class WorktreeIsolation extends Disposable implements IAgentHostWorktreeI enum: [branchDefault], enumLabels: [branchDefault], default: branchDefault, - enumDynamic: !branchReadOnly, - readOnly: branchReadOnly, + enumDynamic: true, + readOnly: false, sessionMutable: false, }); @@ -907,9 +906,9 @@ export class WorktreeIsolation extends Disposable implements IAgentHostWorktreeI // first percentage arrives. onProgress?.(buildWorktreeProgressText(WorktreeCreationPhase.CheckingOut)); - await fs.mkdir(worktreesRoot.fsPath, { recursive: true }); const worktreePath = URI.joinPath(worktreesRoot, getWorktreeName(newBranchName ?? selectedBranch, worktreeBranchPrefix)); await request.onWillCreate?.({ repositoryRoot, worktreePath, baseBranch, branchName: newBranchName ?? selectedBranch }); + await fs.mkdir(worktreesRoot.fsPath, { recursive: true }); await withPercentProgress(WorktreeCreationPhase.CheckingOut, onProgress, progress => this._gitService.addWorktree(repositoryRoot, { @@ -1074,6 +1073,23 @@ export class WorktreeIsolation extends Disposable implements IAgentHostWorktreeI return this._sequencer.queue(sessionId, () => this._removeSessionWorktree(sessionId, worktree)); } + async discardSessionWorktree(sessionUri: URI, sessionId: string, worktree: ISessionWorktree | undefined): Promise { + await this.removeSessionWorktree(sessionId, worktree); + const dbRef = this._sessionDataService.openDatabase(sessionUri); + try { + await dbRef.object.deleteMetadata([ + WORKTREE_META_BRANCH, + WORKTREE_META_PATH, + WORKTREE_META_REPOSITORY_ROOT, + WORKTREE_META_CREATION_FAILURE, + LEGACY_WORKTREE_META_WORKING_DIRECTORY, + META_DIFF_BASE_BRANCH, + ]); + } finally { + dbRef.dispose(); + } + } + private async _removeSessionWorktree(sessionId: string, worktree: ISessionWorktree | undefined): Promise { this.clearPending(sessionId); if (!worktree) { @@ -1470,6 +1486,7 @@ export class NullAgentHostWorktreeIsolation implements IAgentHostWorktreeIsolati async applyRestoreAnnouncement(_sessionUri: URI, turns: readonly Turn[]): Promise { return turns; } async prepareSessionDeletion(_sessionUri: URI, _sessionId: string): Promise { return undefined; } async removeSessionWorktree(_sessionId: string, _worktree: ISessionWorktree | undefined): Promise { } + async discardSessionWorktree(_sessionUri: URI, _sessionId: string, _worktree: ISessionWorktree | undefined): Promise { } async cleanupWorktreeOnArchive(_sessionUri: URI, _sessionId: string): Promise { } async recreateWorktreeOnUnarchive(_sessionUri: URI, _sessionId: string): Promise { } async readWorktreeMetadata(_sessionUri: URI): Promise { return undefined; } diff --git a/src/vs/platform/agentHost/test/browser/agentHostEnablementService.test.ts b/src/vs/platform/agentHost/test/browser/agentHostEnablementService.test.ts index 2ae3b0ef95c0f..f4ad17bdd489d 100644 --- a/src/vs/platform/agentHost/test/browser/agentHostEnablementService.test.ts +++ b/src/vs/platform/agentHost/test/browser/agentHostEnablementService.test.ts @@ -12,7 +12,7 @@ import { AGENT_HOST_ENABLED_CONTEXT_KEY } from '../../common/agentHostEnablement import { ConfigurationTarget, IConfigurationChangeEvent, IConfigurationOverrides } from '../../../configuration/common/configuration.js'; import { ChatAIDisabledSettingId } from '../../../chat/common/chatSettings.js'; import { TestConfigurationService } from '../../../configuration/test/common/testConfigurationService.js'; -import { COPILOT_SANDBOX_ENABLED_KEY, IManagedSettingsService, NullManagedSettingsService } from '../../../policy/common/copilotManagedSettings.js'; +import { COPILOT_SANDBOX_ALLOW_BYPASS_KEY, COPILOT_SANDBOX_ENABLED_KEY, IManagedSettingsService, NullManagedSettingsService } from '../../../policy/common/copilotManagedSettings.js'; import { MockContextKeyService } from '../../../keybinding/test/common/mockKeybindingService.js'; class AgentHostTestConfigurationService extends TestConfigurationService { @@ -112,6 +112,31 @@ suite('AgentHostEnablementService', () => { }); }); + test('tracks bypass-only policy changes without changing the managed sandbox floor', () => { + let allowBypass: boolean | undefined; + const managedSettingsEmitter = disposables.add(new Emitter()); + const managedSettingsService: IManagedSettingsService = { + _serviceBrand: undefined, + onDidChangeManagedSettings: managedSettingsEmitter.event, + getManagedSettingValue: key => key === COPILOT_SANDBOX_ENABLED_KEY ? true : key === COPILOT_SANDBOX_ALLOW_BYPASS_KEY ? allowBypass : undefined, + }; + const { service } = createService(false, true, managedSettingsService); + const enforcedChanges: boolean[] = []; + const bypassChanges: boolean[] = []; + disposables.add(autorun(reader => enforcedChanges.push(service.managedSandboxEnforced.read(reader)))); + disposables.add(autorun(reader => bypassChanges.push(service.managedSandboxAllowsBypass.read(reader)))); + + for (const value of [false, true, true, false, undefined]) { + allowBypass = value; + managedSettingsEmitter.fire(); + } + + assert.deepStrictEqual({ enforcedChanges, bypassChanges }, { + enforcedChanges: [true], + bypassChanges: [false, true, false], + }); + }); + test('tracks the effective managed sandbox floor', () => { let sandboxEnabled = false; const managedSettingsEmitter = disposables.add(new Emitter()); diff --git a/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts b/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts index 5e043dc1af549..8d75939af585e 100644 --- a/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts +++ b/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts @@ -94,6 +94,12 @@ export class TestSessionDatabase implements ISessionDatabase { } } + async deleteMetadata(keys: readonly string[]): Promise { + for (const key of keys) { + this._metadata.delete(key); + } + } + async setMetadataValuesIfAbsent(key: string, values: Readonly>, copies: Readonly> = {}): Promise { if (this._metadata.has(key)) { return false; @@ -351,6 +357,7 @@ export function createNoopGitService(): import('../../common/agentHostGitService removeWorktree: async () => { }, branchExists: async () => false, createBranch: async () => { }, + checkout: async () => { }, hasUncommittedChanges: async () => false, commitAll: async () => { }, mergeBranch: async () => '', diff --git a/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts b/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts index 3906d696de5f0..19fa407d81800 100644 --- a/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts @@ -14,10 +14,11 @@ import { observableValue } from '../../../../base/common/observable.js'; import { extUriBiasedIgnorePathCase } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; import { runWithFakedTimers } from '../../../../base/test/common/timeTravelScheduler.js'; +import { mock } from '../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { ILogService, NullLogService } from '../../../log/common/log.js'; import { AgentHostClientState, AgentHostProtocolClient } from '../../browser/agentHostProtocolClient.js'; -import { getAgentHostExtensionInitializeResultMeta } from '../../common/agentHostExtensionProtocol.js'; +import { getAgentHostExtensionInitializeResultMeta, RequestAgentHostWorkspaceTrustExtensionMethod } from '../../common/agentHostExtensionProtocol.js'; import { agentHostAuthority, toAgentHostUri } from '../../common/agentHostUri.js'; import { AgentHostPermissionMode, AgentHostResourceIdentity, AgentHostResourcePermissionError, IAgentHostResourceService, LOCAL_AGENT_HOST_RESOURCE_IDENTITY } from '../../common/agentHostResourceService.js'; import { buildAnnotationsUri } from '../../common/annotationsUri.js'; @@ -84,6 +85,7 @@ import type { Implementation } from '../../common/state/protocol/common/commands import { agentsWindowAgentHostClientInfo, editorWindowAgentHostClientInfo } from '../../common/agentHostClientInfo.js'; import { AgentHostClientConnectionKind } from '../../common/agentHostTelemetry.js'; import type { IRemoteAgentHostReconnectPolicy } from '../../common/reconnectPolicy.js'; +import { IWorkspaceTrustManagementService, IWorkspaceTrustRequestService, type ResourceTrustRequestOptions } from '../../../workspace/common/workspaceTrust.js'; type ProtocolTransportMessage = ProtocolMessage | AhpServerNotification | JsonRpcNotification | JsonRpcResponse | JsonRpcRequest; type RootConfigValue = boolean | string | AgentHostTerminalAutoApproveRules | undefined; @@ -189,6 +191,11 @@ class TestProtocolTransport extends Disposable implements IProtocolTransport { this._onMessage.fire(message); } + fireExtensionRequest(id: number, method: string, params: Record): void { + // VS Code-private reverse requests intentionally are not part of the public AHP ProtocolMessage union. + this._onMessage.fire({ jsonrpc: '2.0', id, method, params } as unknown as ProtocolMessage); + } + fireClose(): void { this._onClose.fire(); } @@ -334,11 +341,40 @@ suite('AgentHostProtocolClient', () => { }; } - function createClientForIdentity(identity: AgentHostResourceIdentity, transport = disposables.add(new TestProtocolTransport()), permissionService = createPermissionService(), loadEstimator?: { hasHighLoad(): boolean }, logService: ILogService = new NullLogService(), configurationService = new TestConfigurationService(), clientId?: string, clientInfo?: Implementation, telemetryService: ITelemetryService = NullTelemetryService, reconnectPolicy?: IRemoteAgentHostReconnectPolicy): { client: AgentHostProtocolClient; transport: TestProtocolTransport; configurationService: TestConfigurationService } { + function createWorkspaceTrustServices(config?: { readonly trusted?: readonly URI[]; readonly requestResult?: boolean }) { + const trusted = new Set((config?.trusted ?? []).map(uri => uri.toString())); + const requests: URI[] = []; + const grants: URI[] = []; + const management = new class extends mock() { + override async getUriTrustInfo(uri: URI) { + return { uri, trusted: trusted.has(uri.toString()) }; + } + + override async setUrisTrust(uris: URI[], isTrusted: boolean): Promise { + for (const uri of uris) { + if (isTrusted) { + trusted.add(uri.toString()); + grants.push(uri); + } else { + trusted.delete(uri.toString()); + } + } + } + }(); + const request = new class extends mock() { + override async requestResourcesTrust(options: ResourceTrustRequestOptions): Promise { + requests.push(options.uri); + return config?.requestResult ?? true; + } + }(); + return { management, request, requests, grants }; + } + + function createClientForIdentity(identity: AgentHostResourceIdentity, transport = disposables.add(new TestProtocolTransport()), permissionService = createPermissionService(), loadEstimator?: { hasHighLoad(): boolean }, logService: ILogService = new NullLogService(), configurationService = new TestConfigurationService(), clientId?: string, clientInfo?: Implementation, telemetryService: ITelemetryService = NullTelemetryService, reconnectPolicy?: IRemoteAgentHostReconnectPolicy, workspaceTrust = createWorkspaceTrustServices()): { client: AgentHostProtocolClient; transport: TestProtocolTransport; configurationService: TestConfigurationService } { const options = loadEstimator !== undefined || clientId !== undefined || clientInfo !== undefined || reconnectPolicy !== undefined ? { loadEstimator, clientId, clientInfo, reconnectPolicy } : undefined; - const client = disposables.add(new AgentHostProtocolClient(identity, transport, options, logService, permissionService, configurationService, telemetryService)); + const client = disposables.add(new AgentHostProtocolClient(identity, transport, options, logService, permissionService, configurationService, telemetryService, workspaceTrust.management, workspaceTrust.request)); return { client, transport, configurationService }; } @@ -1183,6 +1219,7 @@ suite('AgentHostProtocolClient', () => { test('forwards the actual telemetry service restriction during initialization and config sync', async () => { const transport = disposables.add(new TestProtocolTransport(AgentHostClientConnectionKind.RemoteExtensionHost)); const configurationService = new TestConfigurationService(); + const workspaceTrust = createWorkspaceTrustServices(); const client = disposables.add(new AgentHostProtocolClient( 'test.example:1234', transport, @@ -1191,6 +1228,8 @@ suite('AgentHostProtocolClient', () => { createPermissionService(), configurationService, NullTelemetryService, + workspaceTrust.management, + workspaceTrust.request, )); const connectPromise = client.connect(); @@ -1728,6 +1767,133 @@ suite('AgentHostProtocolClient', () => { await rejected; }); + suite('reverse workspace trust', () => { + + test('uses the standard workspace trust request', async () => { + const workspaceTrust = createWorkspaceTrustServices({ requestResult: false }); + const { transport } = createClientForIdentity( + LOCAL_AGENT_HOST_RESOURCE_IDENTITY, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + workspaceTrust, + ); + const workspace = URI.file('/workspace/project'); + + transport.fireExtensionRequest(51, RequestAgentHostWorkspaceTrustExtensionMethod, { + workspace: workspace.toString(), + }); + await timeout(0); + + assert.deepStrictEqual({ + requests: workspaceTrust.requests.map(uri => uri.toString()), + response: transport.sentMessages.pop(), + }, { + requests: [workspace.toString()], + response: { + jsonrpc: '2.0', + id: 51, + result: { trusted: false }, + }, + }); + }); + + test('inherits trust for a validated managed worktree', async () => { + const parent = URI.file('/workspace/project'); + const worktree = URI.file('/workspace/project.worktrees/feature'); + const workspaceTrust = createWorkspaceTrustServices({ trusted: [parent] }); + const { transport } = createClientForIdentity( + LOCAL_AGENT_HOST_RESOURCE_IDENTITY, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + workspaceTrust, + ); + + transport.fireExtensionRequest(52, RequestAgentHostWorkspaceTrustExtensionMethod, { + workspace: worktree.toString(), + trustedParent: parent.toString(), + }); + await timeout(0); + + assert.deepStrictEqual({ + requests: workspaceTrust.requests, + grants: workspaceTrust.grants.map(uri => uri.toString()), + response: transport.sentMessages.pop(), + }, { + requests: [], + grants: [worktree.toString()], + response: { + jsonrpc: '2.0', + id: 52, + result: { trusted: true }, + }, + }); + }); + + test('rejects invalid workspace trust resources', async () => { + const workspaceTrust = createWorkspaceTrustServices({ trusted: [URI.file('/workspace/project')] }); + const { transport } = createClientForIdentity( + LOCAL_AGENT_HOST_RESOURCE_IDENTITY, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + workspaceTrust, + ); + + transport.fireExtensionRequest(53, RequestAgentHostWorkspaceTrustExtensionMethod, { + workspace: 'https://example.com/project', + }); + transport.fireExtensionRequest(54, RequestAgentHostWorkspaceTrustExtensionMethod, { + workspace: URI.file('/workspace/unrelated').toString(), + trustedParent: URI.file('/workspace/project').toString(), + }); + await timeout(0); + + assert.deepStrictEqual({ + requests: workspaceTrust.requests, + grants: workspaceTrust.grants, + responses: transport.sentMessages.splice(-2), + }, { + requests: [], + grants: [], + responses: [{ + jsonrpc: '2.0', + id: 53, + error: { + code: -32000, + message: 'Workspace must be an absolute file URI', + }, + }, { + jsonrpc: '2.0', + id: 54, + error: { + code: -32000, + message: 'Workspace is not a managed worktree under the trusted parent', + }, + }], + }); + }); + }); + suite('reverse permission gating', () => { test('remote local address does not receive trusted local access', async () => { @@ -2317,8 +2483,9 @@ suite('AgentHostProtocolClient', () => { transports.push(t); return t; }; + const workspaceTrust = createWorkspaceTrustServices(); const client = disposables.add(new AgentHostProtocolClient( - 'test.example:1234', factory, clientInfo !== undefined || reconnectPolicy !== undefined || loadEstimator !== undefined ? { clientInfo, reconnectPolicy, loadEstimator } : undefined, new NullLogService(), permissionService, new TestConfigurationService(), telemetryService, + 'test.example:1234', factory, clientInfo !== undefined || reconnectPolicy !== undefined || loadEstimator !== undefined ? { clientInfo, reconnectPolicy, loadEstimator } : undefined, new NullLogService(), permissionService, new TestConfigurationService(), telemetryService, workspaceTrust.management, workspaceTrust.request, )); return { client, transports }; } diff --git a/src/vs/platform/agentHost/test/node/agentHostChangesetOperationService.test.ts b/src/vs/platform/agentHost/test/node/agentHostChangesetOperationService.test.ts index c84f0de91db0f..b6e5e17b2e398 100644 --- a/src/vs/platform/agentHost/test/node/agentHostChangesetOperationService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostChangesetOperationService.test.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { DeferredPromise } from '../../../../base/common/async.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { Disposable, DisposableStore, type IDisposable } from '../../../../base/common/lifecycle.js'; import { Event } from '../../../../base/common/event.js'; @@ -454,6 +455,49 @@ suite('AgentHostChangesetOperationService', () => { }); }); + test('serializes in-flight invocations with different metadata', async () => { + const stateManager = disposables.add(new AgentHostStateManager(new NullLogService())); + const sessionKey = 'agent:/session'; + const changesetUri = buildUncommittedChangesetUri(sessionKey); + stateManager.registerChangeset(changesetUri); + stateManager.dispatchServerAction(changesetUri, { + type: ActionType.ChangesetOperationsChanged, + operations: [{ id: testOperationId, label: 'Checkout', scopes: [ChangesetOperationScope.Changeset], status: ChangesetOperationStatus.Idle }], + }); + + const pending: DeferredPromise[] = []; + const metadata: (Record | undefined)[] = []; + const handler: IChangesetOperationHandler = { + invoke: params => { + metadata.push(params._meta); + const result = new DeferredPromise(); + pending.push(result); + return result.p; + }, + }; + const service = createService(stateManager); + disposables.add(service.registerContribution(new TestContribution(handler))); + + const first = service.invokeChangesetOperation({ channel: changesetUri, operationId: testOperationId, _meta: { treeish: 'main' } }); + const second = service.invokeChangesetOperation({ channel: changesetUri, operationId: testOperationId, _meta: { treeish: 'featureA' } }); + const metadataWhileFirstRunning = [...metadata]; + pending[0].complete({}); + await first; + const metadataAfterFirstCompleted = [...metadata]; + pending[1].complete({}); + await second; + + assert.deepStrictEqual({ + metadataWhileFirstRunning, + metadataAfterFirstCompleted, + metadata, + }, { + metadataWhileFirstRunning: [{ treeish: 'main' }], + metadataAfterFirstCompleted: [{ treeish: 'main' }, { treeish: 'featureA' }], + metadata: [{ treeish: 'main' }, { treeish: 'featureA' }], + }); + }); + test('publishes running and idle state around a successful changeset operation', async () => { const stateManager = disposables.add(new AgentHostStateManager(new NullLogService())); const sessionKey = 'agent:/session'; diff --git a/src/vs/platform/agentHost/test/node/agentHostCheckoutOperationHandler.test.ts b/src/vs/platform/agentHost/test/node/agentHostCheckoutOperationHandler.test.ts new file mode 100644 index 0000000000000..2f288fcea8129 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentHostCheckoutOperationHandler.test.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 assert from 'assert'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { URI } from '../../../../base/common/uri.js'; +import { mock } from '../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { NullLogService } from '../../../log/common/log.js'; +import { IAgentHostGitService } from '../../common/agentHostGitService.js'; +import { buildUncommittedChangesetUri } from '../../common/changesetUri.js'; +import { checkoutOperationMeta } from '../../common/meta/agentCheckoutOperationMeta.js'; +import { JsonRpcErrorCodes, ProtocolError } from '../../common/state/sessionProtocol.js'; +import { SessionStatus } from '../../common/state/sessionState.js'; +import { AgentHostCheckoutOperationHandler } from '../../node/agentHostCheckoutOperationHandler.js'; +import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; + +suite('AgentHostCheckoutOperationHandler', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + test('checks out the selected branch from a clean working directory', async () => { + const session = URI.parse('agent:/session'); + const workingDirectory = URI.file('/repo'); + const stateManager = disposables.add(new AgentHostStateManager(new NullLogService())); + stateManager.createSession({ + resource: session.toString(), + provider: 'copilot', + title: 'Session', + status: SessionStatus.Idle, + createdAt: new Date(1).toISOString(), + modifiedAt: new Date(1).toISOString(), + workingDirectories: [workingDirectory.toString()], + }); + const gitCalls: string[] = []; + const gitService = new class extends mock() { + declare readonly _serviceBrand: undefined; + + override async hasUncommittedChanges(resource: URI): Promise { + gitCalls.push(`hasUncommittedChanges:${resource.toString()}`); + return false; + } + + override async branchExists(resource: URI, branchName: string): Promise { + gitCalls.push(`branchExists:${resource.toString()}:${branchName}`); + return branchName === 'dev'; + } + + override async checkout(resource: URI, treeish: string): Promise { + gitCalls.push(`checkout:${resource.toString()}:${treeish}`); + } + }(); + const refreshedSessions: string[] = []; + const handler = new AgentHostCheckoutOperationHandler( + sessionKey => stateManager.getSessionState(sessionKey), + async sessionKey => { refreshedSessions.push(sessionKey); }, + gitService, + new NullLogService(), + ); + + const result = await handler.invoke({ + channel: buildUncommittedChangesetUri(session.toString()), + operationId: AgentHostCheckoutOperationHandler.OPERATION_CHECKOUT, + _meta: checkoutOperationMeta('dev'), + }, CancellationToken.None); + let optionError: ProtocolError | undefined; + try { + await handler.invoke({ + channel: buildUncommittedChangesetUri(session.toString()), + operationId: AgentHostCheckoutOperationHandler.OPERATION_CHECKOUT, + _meta: checkoutOperationMeta('-Bmain'), + }, CancellationToken.None); + } catch (error) { + optionError = error as ProtocolError; + } + let missingBranchError: ProtocolError | undefined; + try { + await handler.invoke({ + channel: buildUncommittedChangesetUri(session.toString()), + operationId: AgentHostCheckoutOperationHandler.OPERATION_CHECKOUT, + _meta: checkoutOperationMeta('missing'), + }, CancellationToken.None); + } catch (error) { + missingBranchError = error as ProtocolError; + } + + assert.deepStrictEqual({ + gitCalls, + refreshedSessions, + message: result.message, + optionErrorCode: optionError?.code, + missingBranchErrorCode: missingBranchError?.code, + }, { + gitCalls: [ + `branchExists:${workingDirectory.toString()}:dev`, + `hasUncommittedChanges:${workingDirectory.toString()}`, + `checkout:${workingDirectory.toString()}:dev`, + `branchExists:${workingDirectory.toString()}:missing`, + ], + refreshedSessions: [session.toString()], + message: { markdown: 'Checked out branch `dev`.' }, + optionErrorCode: JsonRpcErrorCodes.InvalidParams, + missingBranchErrorCode: JsonRpcErrorCodes.InvalidParams, + }); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/agentHostCheckoutOperationProvider.test.ts b/src/vs/platform/agentHost/test/node/agentHostCheckoutOperationProvider.test.ts new file mode 100644 index 0000000000000..f5d09e20346f8 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentHostCheckoutOperationProvider.test.ts @@ -0,0 +1,73 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { InstantiationService } from '../../../instantiation/common/instantiationService.js'; +import { NullLogService } from '../../../log/common/log.js'; +import { buildBranchChangesetUri, buildUncommittedChangesetUri, ChangesetKind } from '../../common/changesetUri.js'; +import { ActionType } from '../../common/state/sessionActions.js'; +import { ChangesetOperationScope, ChangesetOperationStatus, SessionStatus } from '../../common/state/sessionState.js'; +import { AgentHostCheckoutOperationContribution } from '../../node/agentHostCheckoutOperationProvider.js'; +import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; + +const sessionKey = 'agent:/session'; +const uncommittedChangesetUri = buildUncommittedChangesetUri(sessionKey); +const branchChangesetUri = buildBranchChangesetUri(sessionKey); + +suite('AgentHostCheckoutOperationContribution', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + function createContribution(): { contribution: AgentHostCheckoutOperationContribution; stateManager: AgentHostStateManager } { + const stateManager = disposables.add(new AgentHostStateManager(new NullLogService())); + stateManager.createSession({ + resource: sessionKey, + provider: 'copilot', + title: 'Session', + status: SessionStatus.Idle, + createdAt: new Date(1).toISOString(), + modifiedAt: new Date(1).toISOString(), + }); + const contribution = disposables.add(new AgentHostCheckoutOperationContribution( + stateManager, + disposables.add(new InstantiationService()), + )); + return { contribution, stateManager }; + } + + test('advertises Checkout only for the uncommitted changeset on a new session', () => { + const { contribution, stateManager } = createContribution(); + + const checkoutOperation = contribution.getOperations({ + sessionKey, + changesetUri: uncommittedChangesetUri, + changesetKind: ChangesetKind.Uncommitted, + })?.[0]; + const branchOperations = contribution.getOperations({ + sessionKey, + changesetUri: branchChangesetUri, + changesetKind: ChangesetKind.Branch, + }); + + stateManager.dispatchServerAction(sessionKey, { type: ActionType.SessionReady }); + const readySessionOperations = contribution.getOperations({ + sessionKey, + changesetUri: uncommittedChangesetUri, + changesetKind: ChangesetKind.Uncommitted, + }); + + assert.deepStrictEqual({ checkoutOperation, branchOperations, readySessionOperations }, { + checkoutOperation: { + id: 'checkout', + label: 'Checkout', + group: 'checkout', + scopes: [ChangesetOperationScope.Changeset], + status: ChangesetOperationStatus.Idle, + }, + branchOperations: undefined, + readySessionOperations: undefined, + }); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/agentHostCommitOperationHandler.test.ts b/src/vs/platform/agentHost/test/node/agentHostCommitOperationHandler.test.ts index 3e2af826d5de9..7f48a5a965579 100644 --- a/src/vs/platform/agentHost/test/node/agentHostCommitOperationHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostCommitOperationHandler.test.ts @@ -48,6 +48,7 @@ class TestGitService implements IAgentHostGitService { async removeWorktree(): Promise { } async branchExists(): Promise { return false; } async createBranch(): Promise { } + async checkout(): Promise { } async hasUncommittedChanges(): Promise { this.calls.push('hasUncommittedChanges'); return this.uncommitted; diff --git a/src/vs/platform/agentHost/test/node/agentHostDiscardChangesOperationHandler.test.ts b/src/vs/platform/agentHost/test/node/agentHostDiscardChangesOperationHandler.test.ts index 3c68788aa2f0c..604faab4b7c31 100644 --- a/src/vs/platform/agentHost/test/node/agentHostDiscardChangesOperationHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostDiscardChangesOperationHandler.test.ts @@ -36,6 +36,7 @@ class TestGitService implements IAgentHostGitService { async removeWorktree(): Promise { } async branchExists(): Promise { return false; } async createBranch(): Promise { } + async checkout(): Promise { } async hasUncommittedChanges(): Promise { return true; } async commitAll(): Promise { } async mergeBranch(): Promise { return ''; } diff --git a/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts b/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts index 2ce0736749515..86693e21fa590 100644 --- a/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts @@ -600,6 +600,15 @@ suite('AgentHostGitService - worktree helpers (real git)', () => { }); }); + (hasGit ? test : test.skip)('checkout switches a clean working directory to an existing branch', async () => { + const dir = initRepo(); + cp.execFileSync('git', ['branch', 'dev'], { cwd: dir, env, stdio: 'pipe' }); + + await svc!.checkout(URI.file(dir), 'dev'); + + assert.strictEqual(cp.execFileSync('git', ['branch', '--show-current'], { cwd: dir, env, encoding: 'utf8' }).trim(), 'dev'); + }); + (hasGit ? test : test.skip)('hasUncommittedChanges flips with untracked and committed work', async () => { const dir = initRepo(); assert.strictEqual(await svc!.hasUncommittedChanges(URI.file(dir)), false); diff --git a/src/vs/platform/agentHost/test/node/agentHostPromptRegistry.test.ts b/src/vs/platform/agentHost/test/node/agentHostPromptRegistry.test.ts index 3af4932ff97e8..3b297e5914c41 100644 --- a/src/vs/platform/agentHost/test/node/agentHostPromptRegistry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostPromptRegistry.test.ts @@ -213,6 +213,18 @@ suite('AgentHostPromptRegistry', () => { }); suite('workspace-less scratch/repoless wiring', () => { + test('prefers attaching a workspace over creating a replacement session', () => { + assert.deepStrictEqual({ + usesSetWorkspace: COPILOT_AGENT_HOST_WORKSPACELESS_INSTRUCTIONS.includes('`set_workspace` is available, prefer attaching that workspace and continuing this same conversation'), + avoidsReplacementSession: COPILOT_AGENT_HOST_WORKSPACELESS_INSTRUCTIONS.includes('Do not create another session solely to move the work'), + requiresConfirmation: COPILOT_AGENT_HOST_WORKSPACELESS_INSTRUCTIONS.includes('Immediately before every `set_workspace` call, always use `ask_user` to confirm both the workspace and whether the work should be isolated'), + }, { + usesSetWorkspace: true, + avoidsReplacementSession: true, + requiresConfirmation: true, + }); + }); + test('appends the scratch instructions to the default config for a workspace-less chat', () => { const registry = new AgentHostPromptRegistry(); assert.deepStrictEqual( diff --git a/src/vs/platform/agentHost/test/node/agentHostProviderService.test.ts b/src/vs/platform/agentHost/test/node/agentHostProviderService.test.ts index 61ccff933906f..5b4b02b9cd15c 100644 --- a/src/vs/platform/agentHost/test/node/agentHostProviderService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostProviderService.test.ts @@ -21,10 +21,12 @@ class TestAuthenticationService extends AgentHostAuthenticationService { readonly replayedProviders: IAgent[] = []; readonly authenticateCalls: { params: AuthenticateParams; providers: readonly IAgent[] }[] = []; replayGate: DeferredPromise | undefined; + afterReplay: (() => void) | undefined; override async replay(provider: IAgent): Promise { this.replayedProviders.push(provider); await this.replayGate?.p; + this.afterReplay?.(); } override async authenticate(params: AuthenticateParams, providers: Iterable) { @@ -39,6 +41,8 @@ class TestProvider extends MockAgent { disposeCount = 0; shutdownCount = 0; shutdownError: Error | undefined; + onShutdown: (() => void) | undefined; + resourceActive = false; mcpRequests: { chat: URI; serverName: string; method: string; params: Record | undefined }[] = []; async handleMcpRequest(chat: URI, serverName: string, method: string, params: Record | undefined): Promise { @@ -52,6 +56,7 @@ class TestProvider extends MockAgent { override async shutdown(): Promise { this.shutdownCount++; + this.onShutdown?.(); if (this.shutdownError) { throw this.shutdownError; } @@ -59,6 +64,7 @@ class TestProvider extends MockAgent { override dispose(): void { this.disposeCount++; + this.resourceActive = false; this._onMcpNotification.dispose(); super.dispose(); } @@ -290,33 +296,69 @@ suite('AgentHostProviderService', () => { assert.deepStrictEqual({ authenticateProviders: authentication.authenticateCalls[0].providers.map(provider => provider.id), shutdownCounts: [first.shutdownCount, second.shutdownCount], + disposeCounts: [first.disposeCount, second.disposeCount], }, { authenticateProviders: ['first', 'second'], shutdownCounts: [1, 1], + disposeCounts: [1, 1], }); }); - test('waits for authentication replay before shutdown', async () => { + test('starts provider shutdown while authentication replay is in flight', async () => { const { service, authentication } = createService(); const provider = new TestProvider('copilot'); + provider.shutdownError = new Error('shutdown failed'); authentication.replayGate = new DeferredPromise(); + authentication.afterReplay = () => provider.resourceActive = true; service.registerProvider(provider); const shutdown = service.shutdown(); + const shutdownRejected = assert.rejects(shutdown, /shutdown failed/); await Promise.resolve(); assert.deepStrictEqual({ replayedProviders: authentication.replayedProviders.map(provider => provider.id), shutdownCount: provider.shutdownCount, + disposeCount: provider.disposeCount, }, { replayedProviders: ['copilot'], - shutdownCount: 0, + shutdownCount: 1, + disposeCount: 0, }); const lateProvider = new TestProvider('late'); assert.throws(() => service.registerProvider(lateProvider), /shutdown has started/); lateProvider.dispose(); authentication.replayGate.complete(); - await shutdown; - assert.strictEqual(provider.shutdownCount, 1); + await shutdownRejected; + assert.deepStrictEqual({ + shutdownCount: provider.shutdownCount, + disposeCount: provider.disposeCount, + resourceActive: provider.resourceActive, + agents: service.agents.get(), + }, { + shutdownCount: 1, + disposeCount: 1, + resourceActive: false, + agents: [], + }); + }); + + test('rejects provider registration from synchronous shutdown callbacks', async () => { + const { service } = createService(); + const provider = new TestProvider('copilot'); + const lateProvider = new TestProvider('late'); + provider.onShutdown = () => assert.throws(() => service.registerProvider(lateProvider), /shutdown has started/); + service.registerProvider(provider); + + await service.shutdown(); + + assert.deepStrictEqual({ + providerDisposeCount: provider.disposeCount, + lateProviderDisposeCount: lateProvider.disposeCount, + }, { + providerDisposeCount: 1, + lateProviderDisposeCount: 0, + }); + lateProvider.dispose(); }); }); diff --git a/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts b/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts index 6f800c856eefd..44ae250540c6b 100644 --- a/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts @@ -98,6 +98,7 @@ class TestGitService implements IAgentHostGitService { this.calls.push(`createBranch:${branchName}`); this.createdBranch = branchName; } + async checkout(): Promise { } async hasUncommittedChanges(): Promise { this.calls.push('hasUncommittedChanges'); return this.uncommitted; diff --git a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts index fc376767808d6..53fe7a1251c14 100644 --- a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts @@ -357,7 +357,7 @@ suite('AgentHostStateManager', () => { addedWorkingDirectories: added?.type === NotificationType.SessionAdded ? added.summary.workingDirectories : undefined, }, { status: SessionStatus.InProgress, - project: persisted.project, + project: provisional.project, workingDirectories: persisted.workingDirectories, addedStatus: SessionStatus.InProgress, addedProject: persisted.project, @@ -1988,6 +1988,34 @@ suite('AgentHostStateManager', () => { ); }); }); + + test('SessionSummaryNotifier serializes activity clearing as null', () => { + return runWithFakedTimers({ useFakeTimers: true }, async () => { + manager.createSession(makeSessionSummary()); + + const notifications: INotification[] = []; + disposables.add(manager.onDidEmitNotification(notification => notifications.push(notification))); + + manager.dispatchServerAction(sessionUri, { + type: ActionType.SessionActivityChanged, + activity: 'Setting up workspace', + }); + await new Promise(resolve => setTimeout(resolve, 150)); + manager.dispatchServerAction(sessionUri, { + type: ActionType.SessionActivityChanged, + activity: undefined, + }); + await new Promise(resolve => setTimeout(resolve, 150)); + + const summaryChanges = notifications + .filter(notification => notification.type === NotificationType.SessionSummaryChanged) + .map(notification => JSON.parse(JSON.stringify(notification)) as SessionSummaryChangedParams); + assert.deepStrictEqual(summaryChanges.map(notification => notification.changes.activity), [ + 'Setting up workspace', + null, + ]); + }); + }); }); // Exercises the opaque, agent-owned `providerData` blob supplied to restored diff --git a/src/vs/platform/agentHost/test/node/agentHostTelemetryReporter.test.ts b/src/vs/platform/agentHost/test/node/agentHostTelemetryReporter.test.ts index c5a2297abd9aa..0165df52ff0ad 100644 --- a/src/vs/platform/agentHost/test/node/agentHostTelemetryReporter.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostTelemetryReporter.test.ts @@ -84,7 +84,7 @@ suite('AgentHostTelemetryReporter', () => { const reporter = new AgentHostTelemetryReporter(service); const chat = buildSubagentChatUri(session, 'tool-call-1'); - reporter.userMessageSent('copilot', 'client-1', createUnknownAgentHostClientTelemetryContext(AgentHostClientType.AgentsWindow), chat, 'turn-1', undefined, 'direct', userMessage); + reporter.userMessageSent('copilot', 'client-1', createUnknownAgentHostClientTelemetryContext(AgentHostClientType.AgentsWindow), chat, 'turn-1', undefined, 'direct', userMessage, false); assert.deepStrictEqual(service.githubStandardEvents, [{ eventName: 'agentHost.userMessageSent', @@ -106,8 +106,8 @@ suite('AgentHostTelemetryReporter', () => { ...createUnknownAgentHostClientTelemetryContext(AgentHostClientType.AgentsWindow), machineId: 'client-machine-id', devDeviceId: 'client-dev-device-id', - }, session, 'turn-1', undefined, 'direct', userMessage); - reporter.userMessageSent('copilot', 'client-2', createUnknownAgentHostClientTelemetryContext(AgentHostClientType.EditorWindow), session, 'turn-2', undefined, 'direct', userMessage); + }, session, 'turn-1', undefined, 'direct', userMessage, false); + reporter.userMessageSent('copilot', 'client-2', createUnknownAgentHostClientTelemetryContext(AgentHostClientType.EditorWindow), session, 'turn-2', undefined, 'direct', userMessage, false); assert.deepStrictEqual(service.standardEvents.map(event => ({ initiatorMachineId: event.data?.initiatorMachineId, @@ -128,17 +128,18 @@ suite('AgentHostTelemetryReporter', () => { const agentMergeMessage: Message = { text: 'fix the failing checks', origin: { kind: MessageKind.SystemNotification }, _meta: toAgentMergeMessageMeta() }; const spoofedMergeMessage: Message = { text: 'hello', origin: { kind: MessageKind.User }, _meta: toAgentMergeMessageMeta() }; - reporter.userMessageSent('copilot', 'client-1', createUnknownAgentHostClientTelemetryContext(AgentHostClientType.AgentsWindow), session, 'turn-1', undefined, 'direct', agentMessage); - reporter.userMessageSent('copilot', undefined, createUnknownAgentHostClientTelemetryContext(AgentHostClientType.Unknown), session, 'turn-2', undefined, 'direct', agentMergeMessage); - reporter.userMessageSent('copilot', 'client-1', createUnknownAgentHostClientTelemetryContext(AgentHostClientType.AgentsWindow), session, 'turn-3', undefined, 'queued', userMessage); - reporter.userMessageSent('copilot', 'client-1', createUnknownAgentHostClientTelemetryContext(AgentHostClientType.AgentsWindow), session, 'turn-4', undefined, 'direct', spoofedMergeMessage); + reporter.userMessageSent('copilot', 'client-1', createUnknownAgentHostClientTelemetryContext(AgentHostClientType.AgentsWindow), session, 'turn-1', undefined, 'direct', agentMessage, false); + reporter.userMessageSent('copilot', undefined, createUnknownAgentHostClientTelemetryContext(AgentHostClientType.Unknown), session, 'turn-2', undefined, 'direct', agentMergeMessage, false); + reporter.userMessageSent('copilot', 'client-1', createUnknownAgentHostClientTelemetryContext(AgentHostClientType.AgentsWindow), session, 'turn-3', undefined, 'queued', userMessage, false); + reporter.userMessageSent('copilot', 'client-1', createUnknownAgentHostClientTelemetryContext(AgentHostClientType.AgentsWindow), session, 'turn-4', undefined, 'direct', spoofedMergeMessage, false); + reporter.userMessageSent('copilot', 'client-1', createUnknownAgentHostClientTelemetryContext(AgentHostClientType.AgentsWindow), session, 'turn-5', undefined, 'direct', userMessage, true); assert.deepStrictEqual({ standard: service.standardEvents.map(event => event.data?.messageOriginKind), github: service.githubStandardEvents.map(event => event.properties?.messageOriginKind), }, { - standard: ['agent', 'agentMerge', 'user', 'user'], - github: ['agent', 'agentMerge', 'user', 'user'], + standard: ['agent', 'agentMerge', 'user', 'user', 'inline'], + github: ['agent', 'agentMerge', 'user', 'user', 'inline'], }); }); @@ -425,6 +426,7 @@ suite('AgentHostTelemetryReporter', () => { provider: 'copilot', session, turnId: 'turn-1', + messageOriginKind: undefined, hangReason: 'stalledAfterProgress', hadAnyProgress: true, lastActivityKind: ActionType.ChatToolCallDelta, @@ -452,6 +454,7 @@ suite('AgentHostTelemetryReporter', () => { provider: 'copilot', session, turnId: 'turn-2', + messageOriginKind: undefined, hangReason: 'stalledAfterProgress', hadAnyProgress: true, lastActivityKind: 'custom/path/value', @@ -479,6 +482,7 @@ suite('AgentHostTelemetryReporter', () => { chatSessionId: getTelemetryChatSessionId(session), isSubagentSession: false, turnId: 'turn-1', + messageOriginKind: undefined, hangReason: 'stalledAfterProgress', isExpected: false, hadAnyProgress: true, @@ -507,6 +511,7 @@ suite('AgentHostTelemetryReporter', () => { chatSessionId: getTelemetryChatSessionId(session), isSubagentSession: false, turnId: 'turn-2', + messageOriginKind: undefined, hangReason: 'stalledAfterProgress', isExpected: false, hadAnyProgress: true, diff --git a/src/vs/platform/agentHost/test/node/agentHostToolCallTelemetry.test.ts b/src/vs/platform/agentHost/test/node/agentHostToolCallTelemetry.test.ts index b6117fcdd934c..52b9cb5b3d92c 100644 --- a/src/vs/platform/agentHost/test/node/agentHostToolCallTelemetry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostToolCallTelemetry.test.ts @@ -30,6 +30,7 @@ import { AgentHostLocalTurns, IAgentHostLocalTurns } from '../../node/agentHostL import { AgentHostLocalCommands, IAgentHostLocalCommands } from '../../node/localCommands/localChatCommand.js'; import { AgentHostChatContributions } from '../../node/agentHostChatContributionsService.js'; import { registerBuiltInChatContributions } from '../../node/chatContributions/builtInChatContributions.js'; +import { ISessionWorkspaceConversionService } from '../../node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionService.js'; import { IAgentHostProviderService } from '../../node/agentHostProviderService.js'; import { createTestAgentHostProviderService } from './testAgentHostProviderService.js'; import { AgentHostSessionTitleController, IAgentHostSessionTitleController } from '../../node/agentHostSessionTitleController.js'; @@ -37,6 +38,7 @@ import { AgentHostTelemetryReporter, IAgentHostTelemetryReporter } from '../../n import { AgentHostTelemetryService } from '../../node/agentHostTelemetryService.js'; import { AgentHostToolCallTracker, IAgentHostToolCallTracker } from '../../node/agentHostToolCallTracker.js'; import { AgentHostTurnTracker, IAgentHostTurnTracker } from '../../node/agentHostTurnTracker.js'; +import { AgentHostTurnService, IAgentHostTurnService } from '../../node/agentHostTurnService.js'; import { AgentHostClientConnectionService, IAgentHostClientConnectionService } from '../../node/agentHostClientConnectionService.js'; import { AgentConfigurationService, IAgentConfigurationService } from '../../node/agentConfigurationService.js'; import { IAgentHostChangesetService } from '../../common/agentHostChangesetService.js'; @@ -256,10 +258,18 @@ suite('AgentSideEffects — tool call telemetry', () => { [ISessionDataService, sessionDataService], [IAgentHostWorktreeIsolation, createNoopWorktreeIsolation()], [IAgentHostClientConnectionService, disposables.add(new AgentHostClientConnectionService())], + [ISessionWorkspaceConversionService, { + _serviceBrand: undefined, + requestSessionWorkspaceUpdate: () => { }, + isPending: () => false, + cancel: () => { }, + updateSessionWorkspace: async () => { }, + }], ); const instantiationService = disposables.add(new InstantiationService(services, /*strict*/ true)); const chatContributions = disposables.add(new AgentHostChatContributions(logService, instantiationService)); services.set(IAgentHostChatContributions, chatContributions); + services.set(IAgentHostTurnService, new AgentHostTurnService(stateManager, chatContributions, instantiationService)); services.set(IAgentHostSessionTitleController, disposables.add(new AgentHostSessionTitleController(stateManager, { sessionDataService }, logService))); services.set(IAgentHostProviderService, createTestAgentHostProviderService(() => agent)); const telemetryReporter = new AgentHostTelemetryReporter(telemetryService); diff --git a/src/vs/platform/agentHost/test/node/agentHostTurnHangTelemetry.test.ts b/src/vs/platform/agentHost/test/node/agentHostTurnHangTelemetry.test.ts index e135d5c73d9cb..9ff069c8a828c 100644 --- a/src/vs/platform/agentHost/test/node/agentHostTurnHangTelemetry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostTurnHangTelemetry.test.ts @@ -19,6 +19,7 @@ import { getTelemetryChatSessionId } from '../../common/agentTelemetryCorrelatio import { AgentSession, IAgent } from '../../common/agent.js'; import { AgentHostClientType } from '../../common/agentHostClientInfo.js'; import { createUnknownAgentHostClientTelemetryContext } from '../../common/agentHostTelemetry.js'; +import { withEphemeralSessionMeta } from '../../common/meta/agentEphemeralSessionMeta.js'; import { SessionInputRequestKind } from '../../common/state/protocol/state.js'; import { ActionType, type ChatAction } from '../../common/state/sessionActions.js'; import { buildDefaultChatUri, buildSubagentChatUri, ChatInputQuestionKind, MessageKind, ResponsePartKind, SessionStatus, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallContributorKind } from '../../common/state/sessionState.js'; @@ -29,6 +30,7 @@ import { AgentHostLocalTurns, IAgentHostLocalTurns } from '../../node/agentHostL import { AgentHostLocalCommands, IAgentHostLocalCommands } from '../../node/localCommands/localChatCommand.js'; import { AgentHostChatContributions } from '../../node/agentHostChatContributionsService.js'; import { registerBuiltInChatContributions } from '../../node/chatContributions/builtInChatContributions.js'; +import { ISessionWorkspaceConversionService } from '../../node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionService.js'; import { IAgentHostProviderService } from '../../node/agentHostProviderService.js'; import { createTestAgentHostProviderService } from './testAgentHostProviderService.js'; import { AgentHostSessionTitleController, IAgentHostSessionTitleController } from '../../node/agentHostSessionTitleController.js'; @@ -41,6 +43,7 @@ import { AgentSideEffects } from '../../node/agentSideEffects.js'; import { AgentHostStateManager, IAgentHostStateManager } from '../../node/agentHostStateManager.js'; import { AgentHostToolCallTracker, IAgentHostToolCallTracker } from '../../node/agentHostToolCallTracker.js'; import { AgentHostTurnTracker, IAgentHostTurnTracker, TURN_ACTIVITY_NONE, TURN_HANG_THRESHOLD_MS } from '../../node/agentHostTurnTracker.js'; +import { AgentHostTurnService, IAgentHostTurnService } from '../../node/agentHostTurnService.js'; import { AgentHostTelemetryReporter, IAgentHostTelemetryReporter } from '../../node/agentHostTelemetryReporter.js'; import { IAgentHostWorktreeIsolation } from '../../node/shared/worktreeIsolation.js'; import { createNoopGitStateService, createNullSessionDataService } from '../common/sessionTestHelpers.js'; @@ -115,7 +118,7 @@ suite('AgentSideEffects — turn hang telemetry', () => { const sessionKey = sessionUri.toString(); const defaultChatUri = buildDefaultChatUri(sessionUri); - function setupSession(): void { + function setupSession(isEphemeral = false): void { stateManager.createSession({ resource: sessionKey, provider: 'mock', @@ -123,6 +126,7 @@ suite('AgentSideEffects — turn hang telemetry', () => { status: SessionStatus.Idle, createdAt: new Date().toISOString(), modifiedAt: new Date().toISOString(), + ...(isEphemeral ? { _meta: withEphemeralSessionMeta(undefined, true) } : {}), }); stateManager.dispatchServerAction(sessionKey, { type: ActionType.SessionReady }); } @@ -208,6 +212,7 @@ suite('AgentSideEffects — turn hang telemetry', () => { hasSeenClient: clientId => clientId === 'test', isClientConnected: clientId => clientId === 'test', getConnectedClientTransportCounts: () => new Map([['test', 1]]), + requestWorkspaceTrust: async () => true, })); const sharedLocalTurns = new AgentHostLocalTurns(sessionDataService, logService); const services = new ServiceCollection( @@ -223,10 +228,18 @@ suite('AgentSideEffects — turn hang telemetry', () => { [ISessionDataService, sessionDataService], [IAgentHostWorktreeIsolation, createNoopWorktreeIsolation()], [IAgentHostClientConnectionService, clientConnections], + [ISessionWorkspaceConversionService, { + _serviceBrand: undefined, + requestSessionWorkspaceUpdate: () => { }, + isPending: () => false, + cancel: () => { }, + updateSessionWorkspace: async () => { }, + }], ); const instantiationService = disposables.add(new InstantiationService(services, /*strict*/ true)); const chatContributions = disposables.add(new AgentHostChatContributions(logService, instantiationService)); services.set(IAgentHostChatContributions, chatContributions); + services.set(IAgentHostTurnService, new AgentHostTurnService(stateManager, chatContributions, instantiationService)); services.set(IAgentHostSessionTitleController, disposables.add(new AgentHostSessionTitleController(stateManager, { sessionDataService }, logService))); services.set(IAgentHostProviderService, createTestAgentHostProviderService(() => agent)); const telemetryReporter = new AgentHostTelemetryReporter(telemetryService); @@ -257,7 +270,7 @@ suite('AgentSideEffects — turn hang telemetry', () => { test('reports noProgress for a turn that starts and is never heard from again', async () => { await runWithFakedTimers({}, async () => { - setupSession(); + setupSession(true); startTurn('turn-lost'); await timeout(TURN_HANG_THRESHOLD_MS); }); @@ -270,6 +283,7 @@ suite('AgentSideEffects — turn hang telemetry', () => { chatSessionId: getTelemetryChatSessionId(defaultChatUri), isSubagentSession: false, turnId: 'turn-lost', + messageOriginKind: 'inline', hangReason: 'noProgress', isExpected: false, hadAnyProgress: false, @@ -418,7 +432,7 @@ suite('AgentSideEffects — turn hang telemetry', () => { test('reports the paired recovery event when a hung turn later completes', async () => { await runWithFakedTimers({}, async () => { - setupSession(); + setupSession(true); startTurn('turn-recovered'); await timeout(TURN_HANG_THRESHOLD_MS); fire({ type: ActionType.ChatTurnComplete, turnId: 'turn-recovered', duration: 1000 }); @@ -432,6 +446,7 @@ suite('AgentSideEffects — turn hang telemetry', () => { chatSessionId: getTelemetryChatSessionId(defaultChatUri), isSubagentSession: false, turnId: 'turn-recovered', + messageOriginKind: 'inline', hangReason: 'noProgress', result: 'success', hangReportCount: 1, @@ -502,6 +517,7 @@ suite('AgentSideEffects — turn hang telemetry', () => { hasSeenClient: clientId => clientId === 'connected-client', isClientConnected: clientId => clientId === 'connected-client', getConnectedClientTransportCounts: () => new Map([['connected-client', 1]]), + requestWorkspaceTrust: async () => true, })); const diagnosticAgent = disposables.add(new MockAgent('copilotcli')); diagnosticAgent.getTurnDiagnosticSnapshot = () => ({ @@ -540,6 +556,7 @@ suite('AgentSideEffects — turn hang telemetry', () => { chatSessionId: getTelemetryChatSessionId(session), isSubagentSession: false, turnId: 'turn', + messageOriginKind: undefined, hangReason: 'noProgress', isExpected: false, hadAnyProgress: false, diff --git a/src/vs/platform/agentHost/test/node/agentHostTurnService.test.ts b/src/vs/platform/agentHost/test/node/agentHostTurnService.test.ts new file mode 100644 index 0000000000000..ce02eb9346ac8 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentHostTurnService.test.ts @@ -0,0 +1,131 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { URI } from '../../../../base/common/uri.js'; +import { mock } from '../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import type { IInstantiationService } from '../../../instantiation/common/instantiation.js'; +import { NullLogService } from '../../../log/common/log.js'; +import type { IAgentHostChatContributions, ITurnEnd } from '../../common/agentHostChatContributionsService.js'; +import { ActionType } from '../../common/state/sessionActions.js'; +import { buildDefaultChatUri, MessageKind, SessionStatus, TurnState } from '../../common/state/sessionState.js'; +import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; +import { AgentHostTurnService } from '../../node/agentHostTurnService.js'; + +suite('AgentHostTurnService', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + function createHarness() { + const stateManager = disposables.add(new AgentHostStateManager(new NullLogService())); + const turnEnds: ITurnEnd[] = []; + const contributions = new class extends mock() { + override turnEnd(turn: ITurnEnd): void { + turnEnds.push(turn); + } + }(); + const instantiationService = new class extends mock() { }(); + const service = new AgentHostTurnService(stateManager, contributions, instantiationService); + const session = URI.parse('copilot:/deferred-turn'); + const chat = URI.parse(buildDefaultChatUri(session)); + stateManager.createSession({ + resource: session.toString(), + provider: 'copilot', + title: 'Deferred turn', + status: SessionStatus.Idle, + createdAt: new Date(0).toISOString(), + modifiedAt: new Date(0).toISOString(), + }); + return { service, stateManager, session, chat, turnEnds }; + } + + test('keeps a deferred turn active until it is failed', () => { + const harness = createHarness(); + const message = { text: 'Setting up workspace', origin: { kind: MessageKind.SystemNotification } } as const; + + const deferred = harness.service.beginDeferredTurnMessage(harness.chat, message); + const active = harness.stateManager.getChatState(harness.chat.toString()); + const failed = harness.service.failDeferredTurnMessage(harness.chat, deferred, { + errorType: 'workspaceConversionFailed', + message: 'Workspace setup failed', + }); + const ended = harness.stateManager.getChatState(harness.chat.toString()); + const endedTurn = ended?.turns.at(-1); + + assert.deepStrictEqual({ + activeTurnId: active?.activeTurn?.id, + activeMessage: active?.activeTurn?.message, + activeStatus: active?.status, + failed, + endedTurn: endedTurn && { + id: endedTurn.id, + message: endedTurn.message, + responseParts: endedTurn.responseParts, + state: endedTurn.state, + }, + turnEnds: harness.turnEnds, + }, { + activeTurnId: deferred.turnId, + activeMessage: message, + activeStatus: SessionStatus.InProgress, + failed: true, + endedTurn: { + id: deferred.turnId, + message, + responseParts: [{ + kind: 'error', + error: { + errorType: 'workspaceConversionFailed', + message: 'Workspace setup failed', + }, + }], + state: TurnState.Error, + }, + turnEnds: [{ + session: harness.session.toString(), + channel: harness.chat.toString(), + turnId: deferred.turnId, + reason: { + kind: 'error', + error: { + errorType: 'workspaceConversionFailed', + message: 'Workspace setup failed', + }, + resumable: false, + }, + }], + }); + }); + + test('does not continue a deferred turn after cancellation', () => { + const harness = createHarness(); + const deferred = harness.service.beginDeferredTurnMessage(harness.chat, { + text: 'Setting up workspace', + origin: { kind: MessageKind.SystemNotification }, + }); + harness.stateManager.dispatchServerAction(harness.chat.toString(), { + type: ActionType.ChatTurnCancelled, + turnId: deferred.turnId, + duration: 1, + }); + + const continued = harness.service.continueDeferredTurnMessage(harness.chat, deferred, { + text: 'Continue the task', + origin: { kind: MessageKind.SystemNotification }, + }); + const replacement = harness.service.beginDeferredTurnMessage(harness.chat, { + text: 'Replacement turn', + origin: { kind: MessageKind.SystemNotification }, + }); + + assert.deepStrictEqual({ + continued, + activeTurnId: harness.stateManager.getActiveTurnId(harness.chat.toString()), + }, { + continued: false, + activeTurnId: replacement.turnId, + }); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts b/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts index 006a2871ef241..7869a5b101f12 100644 --- a/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts @@ -22,6 +22,7 @@ import { AgentHostClientConnectionKind, AgentHostLaunchKind, AgentHostTransportK import type { SessionMode } from '../../common/agentHostSchema.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { ActionType, type ChatAction, type ChatUsageAction } from '../../common/state/sessionActions.js'; +import { withEphemeralSessionMeta } from '../../common/meta/agentEphemeralSessionMeta.js'; import { toAgentMergeMessageMeta } from '../../common/meta/agentMergeMessageMeta.js'; import { buildDefaultChatUri, buildSubagentChatUri, createErrorResponsePart, type Message, MessageKind, PendingMessageKind, ResponsePartKind, SessionStatus } from '../../common/state/sessionState.js'; import { IAgentHostCheckpointService, NULL_CHECKPOINT_SERVICE } from '../../common/agentHostCheckpointService.js'; @@ -31,6 +32,7 @@ import { AgentHostLocalTurns, IAgentHostLocalTurns } from '../../node/agentHostL import { AgentHostLocalCommands, IAgentHostLocalCommands } from '../../node/localCommands/localChatCommand.js'; import { AgentHostChatContributions } from '../../node/agentHostChatContributionsService.js'; import { registerBuiltInChatContributions } from '../../node/chatContributions/builtInChatContributions.js'; +import { ISessionWorkspaceConversionService } from '../../node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionService.js'; import { IAgentHostProviderService } from '../../node/agentHostProviderService.js'; import { createTestAgentHostProviderService } from './testAgentHostProviderService.js'; import { AgentHostSessionTitleController, IAgentHostSessionTitleController } from '../../node/agentHostSessionTitleController.js'; @@ -38,6 +40,7 @@ import { AgentHostTelemetryReporter, IAgentHostTelemetryReporter } from '../../n import { AgentHostTelemetryService } from '../../node/agentHostTelemetryService.js'; import { AgentHostToolCallTracker, IAgentHostToolCallTracker } from '../../node/agentHostToolCallTracker.js'; import { AgentHostTurnTracker, IAgentHostTurnTracker } from '../../node/agentHostTurnTracker.js'; +import { AgentHostTurnService, IAgentHostTurnService } from '../../node/agentHostTurnService.js'; import { AgentHostClientConnectionService, IAgentHostClientConnectionService } from '../../node/agentHostClientConnectionService.js'; import { AgentConfigurationService, IAgentConfigurationService } from '../../node/agentConfigurationService.js'; import { IAgentHostChangesetService } from '../../common/agentHostChangesetService.js'; @@ -118,7 +121,7 @@ suite('AgentSideEffects — turn tracker telemetry', () => { const sessionKey = sessionUri.toString(); const defaultChatUri = buildDefaultChatUri(sessionUri); - function setupSession(ready = true, workingDirectories?: string[]): void { + function setupSession(ready = true, workingDirectories?: string[], isEphemeral = false): void { stateManager.createSession({ resource: sessionKey, provider: 'mock', @@ -127,6 +130,7 @@ suite('AgentSideEffects — turn tracker telemetry', () => { createdAt: new Date().toISOString(), modifiedAt: new Date().toISOString(), ...(workingDirectories ? { workingDirectories } : {}), + ...(isEphemeral ? { _meta: withEphemeralSessionMeta(undefined, true) } : {}), }); if (ready) { stateManager.dispatchServerAction(sessionKey, { type: ActionType.SessionReady }); @@ -234,10 +238,18 @@ suite('AgentSideEffects — turn tracker telemetry', () => { [ISessionDataService, sessionDataService], [IAgentHostWorktreeIsolation, createNoopWorktreeIsolation()], [IAgentHostClientConnectionService, disposables.add(new AgentHostClientConnectionService())], + [ISessionWorkspaceConversionService, { + _serviceBrand: undefined, + requestSessionWorkspaceUpdate: () => { }, + isPending: () => false, + cancel: () => { }, + updateSessionWorkspace: async () => { }, + }], ); const instantiationService = disposables.add(new InstantiationService(services, /*strict*/ true)); const chatContributions = disposables.add(new AgentHostChatContributions(logService, instantiationService)); services.set(IAgentHostChatContributions, chatContributions); + services.set(IAgentHostTurnService, new AgentHostTurnService(stateManager, chatContributions, instantiationService)); services.set(IAgentHostSessionTitleController, disposables.add(new AgentHostSessionTitleController(stateManager, { sessionDataService }, logService))); services.set(IAgentHostProviderService, createTestAgentHostProviderService(() => agent)); const telemetryReporter = new AgentHostTelemetryReporter(telemetryService); @@ -267,7 +279,7 @@ suite('AgentSideEffects — turn tracker telemetry', () => { ensureNoDisposablesAreLeakedInTestSuite(); test('emits turnCompleted with timing and turn-start context on success', () => { - setupSession(); + setupSession(true, undefined, true); agent.setModels([{ provider: 'mock', id: 'gpt-5.5', name: 'GPT 5.5', supportsVision: false }]); setSessionConfig({ autoApprove: 'autopilot', mode: 'interactive' }); startTurn('turn-1', 'hello', 'gpt-5.5'); @@ -290,10 +302,12 @@ suite('AgentSideEffects — turn tracker telemetry', () => { assert.strictEqual(data.isSubagentSession, false); assert.strictEqual(data.isBYOK, false); assert.strictEqual(data.interactionMode, 'interactive'); + assert.strictEqual(data.messageOriginKind, 'inline'); assert.strictEqual(typeof data.totalTime, 'number'); assert.strictEqual(typeof data.timeToFirstProgress, 'number'); assert.strictEqual(data.isMultiRoot, false); assert.strictEqual(data.folderCount, 0); + assert.strictEqual((telemetry.events.find(event => event.eventName === 'agentHost.userMessageSent')?.data as Record).messageOriginKind, 'inline'); }); test('attributes completed and failed turns to the initiating client identity', () => { @@ -317,6 +331,7 @@ suite('AgentSideEffects — turn tracker telemetry', () => { initiatorConnectionKind: data.initiatorConnectionKind, initiatorTransportKind: data.initiatorTransportKind, hostLaunchKind: data.hostLaunchKind, + messageOriginKind: data.messageOriginKind, initiatorMachineId: data.initiatorMachineId, initiatorDevDeviceId: data.initiatorDevDeviceId, }; @@ -326,6 +341,7 @@ suite('AgentSideEffects — turn tracker telemetry', () => { initiatorConnectionKind: 'remote_extension_host', initiatorTransportKind: 'message_port', hostLaunchKind: 'vscode_main_process', + messageOriginKind: 'user', initiatorMachineId: 'client-machine-id', initiatorDevDeviceId: 'client-dev-device-id', }, { @@ -334,6 +350,7 @@ suite('AgentSideEffects — turn tracker telemetry', () => { initiatorConnectionKind: 'remote_extension_host', initiatorTransportKind: 'message_port', hostLaunchKind: 'vscode_main_process', + messageOriginKind: 'user', initiatorMachineId: 'client-machine-id', initiatorDevDeviceId: 'client-dev-device-id', }]); @@ -1009,7 +1026,7 @@ suite('AgentSideEffects — turn tracker telemetry', () => { }); test('emits result=error when a queued sendMessage rejects', async () => { - setupSession(); + setupSession(true, undefined, true); agent.sendMessage = async () => { throw new Error('boom'); }; const setAction: ChatAction = { @@ -1026,6 +1043,8 @@ suite('AgentSideEffects — turn tracker telemetry', () => { const events = completedEvents(); assert.strictEqual(events.length, 1); assert.strictEqual((events[0].data as Record).result, 'error'); + assert.strictEqual((events[0].data as Record).messageOriginKind, 'inline'); + assert.strictEqual((telemetry.events.find(event => event.eventName === 'agentHost.userMessageSent')?.data as Record).messageOriginKind, 'inline'); }); test('captures interactionMode for queued turns', () => { diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index ff886d015d829..32a44e14179dc 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -41,7 +41,7 @@ import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { AgentMergeConfigKey, readAgentMergeSessionState } from '../../common/agentMerge.js'; import { SessionDatabase } from '../../node/sessionDatabase.js'; import { ActionType, ActionEnvelope, NotificationType, type INotification } from '../../common/state/sessionActions.js'; -import { AH_META_CREATED_BY_SESSION_DB_KEY, AH_META_IS_READ_DB_KEY, AH_META_EHCLI_ADOPTED_DB_KEY, readSessionEhcliAdopted, AH_META_IS_ARCHIVED_DB_KEY, AH_META_WORKSPACELESS_DB_KEY, ChangesetStatus, CustomizationType, MessageAttachmentKind, MessageKind, SessionActiveClient, ResponsePartKind, ROOT_STATE_URI, SESSION_META_FOLDER_PICKER_KEY, SESSION_META_MULTI_ROOT_KEY, SessionLifecycle, SessionSourceControlOutcome, SessionStatus, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildChatUri, buildDefaultChatUri, buildSubagentChatUri, buildSubagentSessionUri, createErrorResponsePart, customizationId, isDefaultChatUri, isMessageHiddenFromTranscript, isMessageRequestHiddenFromTranscript, isSubagentSession, parseChatUri, parseSubagentSessionUri, readSessionCreationReference, readSessionExternal, readSessionGitHubState, readSessionMultiRootMetadata, readSessionFolderPickerDecision, readSessionSourceControlState, withSessionEhcliAdoptable, withSessionExternal, withSessionMultiRootMetadata, ChatOriginKind, type ChangesetState, type ISessionFolderPickerDecision, type ISessionWithDefaultChat, type MarkdownResponsePart, type SessionState, type SessionSummary, type ToolCallCompletedState, type ToolCallResponsePart, type Turn } from '../../common/state/sessionState.js'; +import { AH_META_CREATED_BY_SESSION_DB_KEY, AH_META_IS_READ_DB_KEY, AH_META_EHCLI_ADOPTED_DB_KEY, readSessionEhcliAdopted, AH_META_IS_ARCHIVED_DB_KEY, AH_META_WORKSPACE_CONVERSION_QUARANTINED_DB_KEY, AH_META_WORKSPACELESS_DB_KEY, ChangesetStatus, CustomizationType, MessageAttachmentKind, MessageKind, SessionActiveClient, ResponsePartKind, ROOT_STATE_URI, SESSION_META_FOLDER_PICKER_KEY, SESSION_META_MULTI_ROOT_KEY, SessionLifecycle, SessionSourceControlOutcome, SessionStatus, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildChatUri, buildDefaultChatUri, buildSubagentChatUri, buildSubagentSessionUri, createErrorResponsePart, customizationId, isDefaultChatUri, isMessageHiddenFromTranscript, isMessageRequestHiddenFromTranscript, isSubagentSession, parseChatUri, parseSubagentSessionUri, readSessionCreationReference, readSessionExternal, readSessionGitHubState, readSessionMultiRootMetadata, readSessionFolderPickerDecision, readSessionSourceControlState, withSessionEhcliAdoptable, withSessionExternal, withSessionMultiRootMetadata, ChatOriginKind, type ChangesetState, type ISessionFolderPickerDecision, type ISessionWithDefaultChat, type MarkdownResponsePart, type SessionState, type SessionSummary, type ToolCallCompletedState, type ToolCallResponsePart, type Turn } from '../../common/state/sessionState.js'; import { ChatInteractivity, type MessageAttachment } from '../../common/state/protocol/state.js'; import { isHostSnapshotAttachment, toHostSnapshotAttachmentMeta } from '../../common/meta/agentSnapshotAttachmentMeta.js'; import { readAgentMessageDelegationMeta } from '../../common/meta/agentMessageDelegationMeta.js'; @@ -1168,7 +1168,7 @@ suite('AgentService (node dispatcher)', () => { providerSetting: 'initial', }, selected: { isolation: 'worktree', branch: 'feature/config', branchPrefix: 'users/test/', includeFiles: ['.env'], branchTrack: false, createNewBranch: false, providerSetting: 'selected' }, - folder: { isolation: 'folder', branch: 'feature', providerSetting: 'folder' }, + folder: { isolation: 'folder', branch: 'feature/config', providerSetting: 'folder' }, }); }); @@ -2803,6 +2803,7 @@ suite('AgentService (node dispatcher)', () => { unsupported: undefined, }); }); + }); suite('createSession', () => { @@ -6652,6 +6653,7 @@ suite('AgentService (node dispatcher)', () => { removeWorktree: async () => { }, branchExists: async () => false, createBranch: async () => { }, + checkout: async () => { }, hasUncommittedChanges: async () => false, commitAll: async () => { }, mergeBranch: async () => '', @@ -6760,6 +6762,7 @@ suite('AgentService (node dispatcher)', () => { removeWorktree: async () => { }, branchExists: async () => false, createBranch: async () => { }, + checkout: async () => { }, hasUncommittedChanges: async () => false, commitAll: async () => { }, mergeBranch: async () => '', @@ -7606,6 +7609,22 @@ suite('AgentService (node dispatcher)', () => { assert.deepStrictEqual(await db.getChatDraft(chat), expected); } + test('refuses to restore a workspace-conversion quarantine before materializing the provider', async () => { + const database = new TestSessionDatabase(); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(database), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const agent = disposables.add(new MockAgent('copilot')); + registerTestAgentProvider(svc, agent); + const session = await svc.createSession({ provider: agent.id }); + await database.setMetadata(AH_META_WORKSPACE_CONVERSION_QUARANTINED_DB_KEY, 'true'); + getStateManager(svc).deleteSession(session.toString()); + + await assert.rejects( + svc.restoreSession(session), + /could not be detached from an untrusted working directory/, + ); + assert.strictEqual(getStateManager(svc).getSessionState(session.toString()), undefined); + }); + test('marks only an explicit restore as an activating metadata read', async () => { class LazyMetadataAgent extends MockAgent { ambientReads = 0; @@ -14622,7 +14641,7 @@ suite('AgentService (node dispatcher)', () => { gitStateCalls: [{ resource: sourceDir.toString(), baseBranch: undefined }], diffCalls: [sourceDir.toString()], uncommittedFiles: [sourceFile], - uncommittedOperations: ['commit', 'discard-changes'], + uncommittedOperations: ['checkout', 'commit', 'discard-changes'], }, afterMaterialization: { workingDirectory: worktreeDir.toString(), diff --git a/src/vs/platform/agentHost/test/node/agentServiceTestUtils.ts b/src/vs/platform/agentHost/test/node/agentServiceTestUtils.ts index 95bfea811c8ef..8e3247bdb60e1 100644 --- a/src/vs/platform/agentHost/test/node/agentServiceTestUtils.ts +++ b/src/vs/platform/agentHost/test/node/agentServiceTestUtils.ts @@ -216,6 +216,7 @@ export function createTestAgentService( options, accessor, instantiationService, + services, logService, sessionDataService, foundation, diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index 03fac7f948404..c63233f6b41e1 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -51,9 +51,11 @@ import { IAgentHostProviderService } from '../../node/agentHostProviderService.j import { createTestAgentHostProviderService } from './testAgentHostProviderService.js'; import { AgentHostSessionTitleController, IAgentHostSessionTitleController } from '../../node/agentHostSessionTitleController.js'; import { registerBuiltInChatContributions } from '../../node/chatContributions/builtInChatContributions.js'; +import { ISessionWorkspaceConversionService } from '../../node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionService.js'; import { AgentHostTelemetryReporter, IAgentHostTelemetryReporter, type IAgentHostAskQuestionsToolInvokedEvent } from '../../node/agentHostTelemetryReporter.js'; import { AgentHostToolCallTracker, IAgentHostToolCallTracker } from '../../node/agentHostToolCallTracker.js'; import { AgentHostTurnTracker, IAgentHostTurnTracker } from '../../node/agentHostTurnTracker.js'; +import { AgentHostTurnService, IAgentHostTurnService } from '../../node/agentHostTurnService.js'; import { AgentHostLocalCommands, IAgentHostLocalCommands } from '../../node/localCommands/localChatCommand.js'; import { IAgentHostTerminalManager } from '../../node/agentHostTerminalManager.js'; import { SessionDatabase } from '../../node/sessionDatabase.js'; @@ -178,6 +180,13 @@ function createTestSideEffects( [IAgentHostWorktreeIsolation, new NoopWorktreeIsolation()], [IAgentHostClientConnectionService, disposables.add(new AgentHostClientConnectionService())], ); + services.set(ISessionWorkspaceConversionService, { + _serviceBrand: undefined, + requestSessionWorkspaceUpdate: () => { }, + isPending: () => false, + cancel: () => { }, + updateSessionWorkspace: async () => { }, + }); const titleController = disposables.add(new AgentHostSessionTitleController(stateManager, { sessionDataService: options.sessionDataService, isActiveAgentTitleGenerationEnabled: () => configService.getRootValue(platformRootSchema, AgentHostActiveAgentTitleGenerationConfigKey) === true, @@ -187,6 +196,7 @@ function createTestSideEffects( const instantiationService = disposables.add(new InstantiationService(services, /*strict*/ true)); const chatContributions: IAgentHostChatContributions = disposables.add(new AgentHostChatContributions(logService, instantiationService)); services.set(IAgentHostChatContributions, chatContributions); + services.set(IAgentHostTurnService, new AgentHostTurnService(stateManager, chatContributions, instantiationService)); const telemetryReporter = new AgentHostTelemetryReporter(telemetryService); services.set(IAgentHostTelemetryReporter, telemetryReporter); const turnTracker = disposables.add(instantiationService.createInstance(AgentHostTurnTracker)); diff --git a/src/vs/platform/agentHost/test/node/chatContributions.test.ts b/src/vs/platform/agentHost/test/node/chatContributions.test.ts index 13c33567e8a61..71ae61375b300 100644 --- a/src/vs/platform/agentHost/test/node/chatContributions.test.ts +++ b/src/vs/platform/agentHost/test/node/chatContributions.test.ts @@ -43,6 +43,8 @@ import { AgentHostLocalCommands, IAgentHostLocalCommands } from '../../node/loca import { registerBuiltInChatContributions } from '../../node/chatContributions/builtInChatContributions.js'; import { LocalCommandContribution } from '../../node/chatContributions/localCommand/localCommandContribution.js'; import { QueueDrainContribution } from '../../node/chatContributions/queueDrain/queueDrainContribution.js'; +import { ISessionWorkspaceConversionService } from '../../node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionService.js'; +import { SessionWorkspaceConversionContribution } from '../../node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionContribution.js'; import { SessionTitleContribution } from '../../node/chatContributions/sessionTitle/sessionTitleContribution.js'; import { SideChatContribution } from '../../node/chatContributions/sideChat/sideChatContribution.js'; import { TurnDelegationContribution } from '../../node/chatContributions/turnDelegation/turnDelegationContribution.js'; @@ -832,6 +834,13 @@ function createBuiltInContributions(disposables: ReturnType { }, + isPending: () => false, + cancel: () => { }, + updateSessionWorkspace: async () => { observed?.push('sessionWorkspaceConversion'); }, + }); services.set(IAgentHostSessionTitleController, new RecordingTitleController(observed, enableSendInstructions ? 'rename instruction' : undefined)); const queueAgent = new MockAgent(); services.set(IAgentHostProviderService, createTestAgentHostProviderService(() => queueAgent)); @@ -875,6 +884,14 @@ function createQueueDrainContributions(disposables: ReturnType { }, + isPending: () => conversionPending, + cancel: () => { }, + updateSessionWorkspace: async () => { }, + }); const mockAgent = new MockAgent(); let agent: MockAgent | undefined = mockAgent; const pendingMessages: (PendingMessage | undefined)[] = []; @@ -900,8 +917,9 @@ function createQueueDrainContributions(disposables: ReturnType admitted.push({ channel: options.turnChannel, message: options.message, clientId: options.senderClientId, hostLaunchKind: options.clientContext.hostLaunchKind }), })); disposables.add(service.registerContribution(LocalCommandContribution as unknown as IConstructorSignature & { readonly id: string })); + disposables.add(service.registerContribution(SessionWorkspaceConversionContribution as unknown as IConstructorSignature & { readonly id: string })); disposables.add(service.registerContribution(QueueDrainContribution as unknown as IConstructorSignature & { readonly id: string })); - return { service, stateManager, session, chat, pendingMessages, admitted, titleController, telemetryService, clearAgent: () => agent = undefined }; + return { service, stateManager, session, chat, pendingMessages, admitted, titleController, telemetryService, clearAgent: () => agent = undefined, setConversionPending: (pending: boolean) => conversionPending = pending }; } function appliedClientAction(channel: string, session: string, action: IAppliedClientAction['action'], clientId = 'client'): IAppliedClientAction { @@ -1098,6 +1116,32 @@ suite('AgentHostChatContributions', () => { assert.deepStrictEqual([active.admitted, steering.admitted, empty.admitted], [[], [], []]); }); + test('queue drain waits while session workspace conversion is pending', () => { + const queue = createQueueDrainContributions(disposables); + queue.stateManager.dispatchServerAction(queue.chat, queuedMessage('queued', 'queued')); + queue.setConversionPending(true); + + queue.service.turnEnd({ session: queue.session, channel: queue.chat, turnId: 'conversion-turn', reason: { kind: 'success' } }); + const admission = queue.service.incomingRequest(incomingRequest(queue.session, queue.chat)); + + assert.deepStrictEqual({ + admitted: queue.admitted, + queuedMessages: queue.stateManager.getSessionState(queue.chat)?.queuedMessages?.map(message => message.message.text), + admission, + }, { + admitted: [], + queuedMessages: ['queued'], + admission: { + kind: 'reject', + error: { + errorType: 'workspaceConversionPending', + message: 'Wait for workspace setup to finish before sending another message.', + }, + stage: 'validation', + }, + }); + }); + test('queue drain captures senders, handles pending actions, and honors reordering', () => { const queue = createQueueDrainContributions(disposables); queue.stateManager.dispatchServerAction(queue.chat, { @@ -1263,7 +1307,7 @@ suite('AgentHostChatContributions', () => { const contributions = createBuiltInContributions(disposables, observed); contributions.service.turnEnd(turnEnd('built-in-order')); - assert.deepStrictEqual(observed, ['checkpointAndChangeset', 'queueDrain', 'githubReferences', 'sessionTitle', 'markUnread']); + assert.deepStrictEqual(observed, ['checkpointAndChangeset', 'sessionWorkspaceConversion', 'queueDrain', 'githubReferences', 'sessionTitle', 'markUnread']); }); test('resumable errors defer checkpoint capture until the logical turn ends', () => { diff --git a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts index 20c3ddb68d7b9..42012b007a6a2 100644 --- a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts @@ -1305,8 +1305,17 @@ suite('ClaudeAgent', () => { const { agent } = createTestContext(disposables); const desc = agent.getDescriptor(); assert.deepStrictEqual( - { provider: desc.provider, displayName: desc.displayName, hasDescription: desc.description.length > 0 }, - { provider: 'claude', displayName: 'Claude', hasDescription: true }, + { provider: desc.provider, displayName: desc.displayName, hasDescription: desc.description.length > 0, agentHostCapabilities: agent.agentHostCapabilities }, + { provider: 'claude', displayName: 'Claude', hasDescription: true, agentHostCapabilities: { workspaceConversion: false } }, + ); + }); + + test('setWorkingDirectory rejects because Claude does not advertise workspace conversion', async () => { + const { agent } = createTestContext(disposables); + + await assert.rejects( + () => agent.setWorkingDirectory(URI.parse('claude:/chat'), URI.parse('claude:/session'), URI.file('/workspace')), + /Claude does not support changing the working directory/, ); }); @@ -6880,6 +6889,10 @@ suite('ClaudeAgent (Phase 7 §3.4 — _handleCanUseTool)', () => { name: 'viewUnreviewedComments', description: 'View unreviewed comments', inputSchema: { type: 'object', properties: {} }, + }, { + name: 'set_workspace', + description: 'Set workspace', + inputSchema: { type: 'object', properties: {} }, }]; readonly toolNames = this.definitions.map(definition => definition.name); confirmationRequiredForSession = false; @@ -6996,6 +7009,40 @@ suite('ClaudeAgent (Phase 7 §3.4 — _handleCanUseTool)', () => { }); }); + test('set_workspace uses a plain-language confirmation without raw input', async () => { + const host = new FakeServerToolHost(); + host.confirmationRequiredForSession = true; + const { ctx, canUseTool } = await materialize(undefined, host); + const signals: AgentSignal[] = []; + disposables.add(ctx.agent.onDidChatProgress(signal => signals.push(signal))); + + const input = { workspaceFolder: '/workspace/app', isolation: true }; + const resultPromise = canUseTool('mcp__host__set_workspace', input, makeOptions('tu_set_workspace')); + await tick(); + ctx.agent.respondToPermissionRequest('tu_set_workspace', true); + const confirmation = signals.find(signal => signal.kind === 'pending_confirmation'); + + assert.deepStrictEqual({ + result: await resultPromise, + confirmation: confirmation?.kind === 'pending_confirmation' ? { + displayName: confirmation.state.displayName, + invocationMessage: confirmation.state.invocationMessage, + toolInput: confirmation.state.toolInput, + confirmationTitle: confirmation.state.confirmationTitle, + permissionKind: confirmation.permissionKind, + } : undefined, + }, { + result: { behavior: 'allow', updatedInput: input }, + confirmation: { + displayName: 'Set Workspace', + invocationMessage: 'Continue this session in /workspace/app with changes isolated from the existing folder?', + toolInput: undefined, + confirmationTitle: 'Continue in app?', + permissionKind: 'mcp', + }, + }); + }); + // Tests 3 and 4 (bypassPermissions / acceptEdits auto-allow) intentionally // omitted: the SDK auto-approves under those modes BEFORE invoking // `canUseTool`, so there is no host-side branch to exercise. See diff --git a/src/vs/platform/agentHost/test/node/codex/codexCreateChat.test.ts b/src/vs/platform/agentHost/test/node/codex/codexCreateChat.test.ts index dc9f5abc7d256..61fd1575cb4b3 100644 --- a/src/vs/platform/agentHost/test/node/codex/codexCreateChat.test.ts +++ b/src/vs/platform/agentHost/test/node/codex/codexCreateChat.test.ts @@ -332,7 +332,22 @@ suite('CodexAgent createChat', () => { test('advertises chat fork and side-chat support', async () => { const agent = await createAgent(disposables); - assert.deepStrictEqual(agent.getDescriptor().capabilities?.multipleChats, { fork: true, sideChat: true }); + assert.deepStrictEqual({ + multipleChats: agent.getDescriptor().capabilities?.multipleChats, + agentHostCapabilities: agent.agentHostCapabilities, + }, { + multipleChats: { fork: true, sideChat: true }, + agentHostCapabilities: { workspaceConversion: false }, + }); + }); + + test('setWorkingDirectory rejects because Codex does not advertise workspace conversion', async () => { + const agent = await createAgent(disposables); + + await assert.rejects( + () => agent.setWorkingDirectory(URI.parse('codex:/chat'), URI.parse('codex:/session'), URI.file('/workspace')), + /Codex does not support changing the working directory/, + ); }); test('fresh: binds the exact target chat during creation, never leaving the runtime unbound', async () => { diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index a431135c28cd6..bb6887b953d88 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -11,7 +11,7 @@ import { isCustomizationEnabled } from '../../common/customizationEnablement.js' import * as fs from 'fs/promises'; import * as os from 'os'; import { VSBuffer } from '../../../../base/common/buffer.js'; -import { DeferredPromise, timeout } from '../../../../base/common/async.js'; +import { DeferredPromise, raceTimeout, timeout } from '../../../../base/common/async.js'; import { isCancellationError } from '../../../../base/common/errors.js'; import { Disposable, toDisposable, type DisposableStore, type IDisposable, type IReference } from '../../../../base/common/lifecycle.js'; import { Emitter, Event } from '../../../../base/common/event.js'; @@ -23,6 +23,7 @@ import { INativeEnvironmentService } from '../../../environment/common/environme import { FileService } from '../../../files/common/fileService.js'; import { IFileService, type IStat } from '../../../files/common/files.js'; import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js'; +import { DiskFileSystemProvider } from '../../../files/node/diskFileSystemProvider.js'; import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; import { InstantiationService } from '../../../instantiation/common/instantiationService.js'; import { ServiceCollection } from '../../../instantiation/common/serviceCollection.js'; @@ -290,6 +291,7 @@ class TestAgentHostGitService implements IAgentHostGitService { async createBranch(_workingDirectory: URI, branchName: string): Promise { this.existingBranches.add(branchName); } + async checkout(): Promise { } async hasUncommittedChanges(workingDirectory: URI): Promise { return this.dirtyWorkingDirectories.has(workingDirectory.fsPath); } @@ -387,10 +389,51 @@ class TestCopilotApiService implements ICopilotApiService { } } +class TestSessionDatabase extends SessionDatabase { + private _metadataWriteFailure: { readonly key: string; readonly error: Error } | undefined; + private _metadataWriteGate: { readonly key: string; readonly wait: Promise; readonly entered: DeferredPromise } | undefined; + readonly metadataWrites: { readonly key: string; readonly value: string }[] = []; + + failNextMetadataWrite(key: string, error: Error): void { + this._metadataWriteFailure = { key, error }; + } + + gateNextMetadataWrite(key: string, wait: Promise, entered: DeferredPromise): void { + this._metadataWriteGate = { key, wait, entered }; + } + + override async setMetadata(key: string, value: string): Promise { + this.metadataWrites.push({ key, value }); + await this._beforeMetadataWrite([key]); + await super.setMetadata(key, value); + } + + override async setMetadataValues(values: Readonly>): Promise { + const entries = Object.entries(values); + this.metadataWrites.push(...entries.map(([key, value]) => ({ key, value }))); + await this._beforeMetadataWrite(entries.map(([key]) => key)); + await super.setMetadataValues(values); + } + + private async _beforeMetadataWrite(keys: readonly string[]): Promise { + if (this._metadataWriteGate && keys.includes(this._metadataWriteGate.key)) { + const gate = this._metadataWriteGate; + this._metadataWriteGate = undefined; + gate.entered.complete(); + await gate.wait; + } + if (this._metadataWriteFailure && keys.includes(this._metadataWriteFailure.key)) { + const error = this._metadataWriteFailure.error; + this._metadataWriteFailure = undefined; + throw error; + } + } +} + class TestSessionDataService extends Disposable implements ISessionDataService { declare readonly _serviceBrand: undefined; - private readonly _databases = new Map(); + private readonly _databases = new Map(); readonly openedSessions: string[] = []; getSessionDataDir(session: URI): URI { return URI.from({ scheme: 'test', path: `/session-data/${AgentSession.id(session)}` }); } @@ -401,7 +444,7 @@ class TestSessionDataService extends Disposable implements ISessionDataService { this.openedSessions.push(sessionId); let db = this._databases.get(sessionId); if (!db) { - db = this._register(new SessionDatabase(':memory:')); + db = this._register(new TestSessionDatabase(':memory:')); this._databases.set(sessionId, db); } return { object: db, dispose: () => { } }; @@ -412,6 +455,24 @@ class TestSessionDataService extends Disposable implements ISessionDataService { return db ? { object: db, dispose: () => { } } : undefined; } + failNextMetadataWrite(session: URI, key: string, error: Error): void { + const db = this._databases.get(AgentSession.id(session)); + assert.ok(db, `No database exists for ${session.toString()}`); + db.failNextMetadataWrite(key, error); + } + + gateNextMetadataWrite(session: URI, key: string, wait: Promise, entered: DeferredPromise): void { + const db = this._databases.get(AgentSession.id(session)); + assert.ok(db, `No database exists for ${session.toString()}`); + db.gateNextMetadataWrite(key, wait, entered); + } + + metadataWrites(session: URI): readonly { readonly key: string; readonly value: string }[] { + const db = this._databases.get(AgentSession.id(session)); + assert.ok(db, `No database exists for ${session.toString()}`); + return db.metadataWrites; + } + deleteSessionData(): Promise { return Promise.resolve(); } readonly onWillDeleteSessionData = Event.None; cleanupOrphanedData(): Promise { return Promise.resolve(); } @@ -611,6 +672,9 @@ interface ICredentialUpdateSession { class MockCopilotSession { readonly sessionId = 'test-session-1'; + readonly workingDirectoryCalls: string[] = []; + readonly workingDirectoryErrors: Array = []; + readonly workingDirectoryResults: string[] = []; readonly rpc = { eventLog: { registerInterest: async () => ({ handle: 'sampling-interest' }), @@ -625,6 +689,16 @@ class MockCopilotSession { permissions: { setMode: async ({ mode }: { mode: PermissionMode }) => ({ success: true, mode }), }, + metadata: { + setWorkingDirectory: async ({ workingDirectory }: { workingDirectory: string }) => { + this.workingDirectoryCalls.push(workingDirectory); + const error = this.workingDirectoryErrors.shift(); + if (error) { + throw error; + } + return { workingDirectory: this.workingDirectoryResults.shift() ?? workingDirectory }; + }, + }, }; private readonly _handlers = new Set(); private readonly _typedHandlers = new Map) => void>>(); @@ -769,6 +843,12 @@ class TestProxyResolver implements IAgentHostProxyResolver { readonly fetch: typeof globalThis.fetch = (input, init) => globalThis.fetch(input, init); } +class TestDiskFileSystemProvider extends DiskFileSystemProvider { + override watch(): IDisposable { + return Disposable.None; + } +} + class ResumePathCopilotAgent extends CopilotAgent { constructor( private readonly _copilotClient: ITestCopilotClient, @@ -973,16 +1053,16 @@ function createTestAgent(disposables: Pick, options?: { type CopilotCreateSessionOptions = Parameters[0]; -function createAgentSessionThroughAgent(agent: CopilotAgent, instantiationService: IInstantiationService, options?: { readonly mockSession?: MockCopilotSession; readonly activeClientToolSet?: ActiveClientToolSet; readonly snapshot?: IActiveClientSnapshot }): { readonly session: CopilotAgentSession; readonly activeClient: unknown; readonly createOptions: () => CopilotCreateSessionOptions | undefined } { +function createAgentSessionThroughAgent(agent: CopilotAgent, instantiationService: IInstantiationService, options?: { readonly mockSession?: MockCopilotSession; readonly activeClientToolSet?: ActiveClientToolSet; readonly snapshot?: IActiveClientSnapshot; readonly workingDirectory?: URI; readonly additionalDirectories?: readonly URI[] }): { readonly session: CopilotAgentSession; readonly activeClient: unknown; readonly createOptions: () => CopilotCreateSessionOptions | undefined } { const sessionUri = AgentSession.uri('copilotcli', 'test-session-1'); - const shellManager = instantiationService.createInstance(ShellManager, sessionUri, undefined); + const shellManager = instantiationService.createInstance(ShellManager, sessionUri, options?.workingDirectory); let createOptions: CopilotCreateSessionOptions | undefined; const mockSession = options?.mockSession ?? new MockCopilotSession(); const agentInternals = (agent as unknown as { _getOrCreateActiveClient: (session: URI, directory: URI | undefined) => { readonly toolSet: ActiveClientToolSet }; _createAgentSession: (launchPlan: CopilotSessionLaunchPlan, customizationDirectory: URI | undefined, activeClient: unknown) => CopilotAgentSession; }); - const activeClient = agentInternals._getOrCreateActiveClient(sessionUri, undefined); + const activeClient = agentInternals._getOrCreateActiveClient(sessionUri, options?.workingDirectory); const launchPlan: CopilotSessionLaunchPlan = { kind: 'create', client: { @@ -997,14 +1077,15 @@ function createAgentSessionThroughAgent(agent: CopilotAgent, instantiationServic // needs an isolated registry passes its own. activeClientToolSet: options?.activeClientToolSet ?? activeClient.toolSet, sessionId: 'test-session-1', - workingDirectory: undefined, + workingDirectory: options?.workingDirectory, + additionalDirectories: options?.additionalDirectories, resolvedAgentName: undefined, snapshot: options?.snapshot ?? { tools: [], plugins: [], mcpServers: {} }, shellManager, githubToken: 'token', model: undefined, }; - return { session: agentInternals._createAgentSession(launchPlan, undefined, activeClient), activeClient, createOptions: () => createOptions }; + return { session: agentInternals._createAgentSession(launchPlan, options?.workingDirectory, activeClient), activeClient, createOptions: () => createOptions }; } function withoutUndefinedProperties(metadata: IAgentChatMetadata): Record { @@ -1480,11 +1561,17 @@ suite('CopilotAgent', () => { test('advertises Copilot as its display name', async () => { const agent = createTestAgent(disposables); try { - assert.deepStrictEqual(agent.getDescriptor(), { - provider: 'copilotcli', - displayName: 'Copilot', - description: 'Copilot SDK agent running in the local agent host process', - capabilities: { multipleChats: { fork: true, sideChat: true } }, + assert.deepStrictEqual({ + descriptor: agent.getDescriptor(), + agentHostCapabilities: agent.agentHostCapabilities, + }, { + descriptor: { + provider: 'copilotcli', + displayName: 'Copilot', + description: 'Copilot SDK agent running in the local agent host process', + capabilities: { multipleChats: { fork: true, sideChat: true } }, + }, + agentHostCapabilities: { workspaceConversion: true }, }); } finally { await disposeAgent(agent); @@ -3366,6 +3453,1062 @@ suite('CopilotAgent', () => { meta: [repoA.toString()], }); }); + + test('changes an exact live default chat from a bare owning session URI and persists provider-owned working-directory state', async () => { + const root = await fs.mkdtemp(`${os.tmpdir()}/agent-set-cwd-`); + const previous = URI.file(join(root, 'previous')); + const next = URI.file(join(root, 'next')); + await Promise.all([fs.mkdir(previous.fsPath), fs.mkdir(next.fsPath)]); + const session = AgentSession.uri('copilotcli', 'test-session-1'); + const chat = defaultChatUri(session); + const sessionDataService = disposables.add(new TestSessionDataService()); + const db = sessionDataService.openDatabase(session); + await db.object.setMetadata('copilot.workingDirectory', previous.toString()); + await db.object.setMetadata('copilot.workingDirectories', JSON.stringify([previous.toString()])); + await db.object.setMetadata('copilot.customizationDirectory', previous.toString()); + await db.object.setMetadata('agentHost.workspaceless', 'true'); + db.dispose(); + const { agent, instantiationService } = createTestAgentContext(disposables, { sessionDataService }); + const mockSession = new MockCopilotSession(); + const created = createAgentSessionThroughAgent(agent, instantiationService, { mockSession, workingDirectory: previous }); + const activeClient = created.activeClient as { + readonly pluginController: { + readonly directory: URI | undefined; + readonly additionalDirectories: readonly URI[]; + }; + }; + try { + await created.session.initializeSession(); + (agent as unknown as { + _registerLiveChat(chat: URI, session: CopilotAgentSession, activeClient: unknown): void; + })._registerLiveChat(chat, created.session, created.activeClient); + const originalSession = getPeerChatStub(agent, chat); + const originalSdkSessionId = chatBackings(agent).get(chat.toString())?.sdkSessionId; + + await agent.setWorkingDirectory(chat, session, next); + + const stored = sessionDataService.openDatabase(session); + const metadata = await stored.object.getMetadataObject({ + 'copilot.workingDirectory': true, + 'copilot.workingDirectories': true, + 'copilot.customizationDirectory': true, + 'agentHost.workspaceless': true, + }); + stored.dispose(); + assert.deepStrictEqual({ + sameSession: getPeerChatStub(agent, chat) === originalSession, + sdkSessionId: chatBackings(agent).get(chat.toString())?.sdkSessionId, + originalSdkSessionId, + liveWorkingDirectory: created.session.workingDirectory?.toString(), + sdkCalls: mockSession.workingDirectoryCalls, + pluginDirectory: activeClient.pluginController.directory?.toString(), + pluginAdditionalDirectories: activeClient.pluginController.additionalDirectories.map(directory => directory.toString()), + metadata, + }, { + sameSession: true, + sdkSessionId: 'test-session-1', + originalSdkSessionId: 'test-session-1', + liveWorkingDirectory: next.toString(), + sdkCalls: [next.fsPath], + pluginDirectory: next.toString(), + pluginAdditionalDirectories: [], + metadata: { + 'copilot.workingDirectory': next.toString(), + 'copilot.workingDirectories': JSON.stringify([next.toString()]), + 'copilot.customizationDirectory': next.toString(), + 'agentHost.workspaceless': 'true', + }, + }); + } finally { + await fs.rm(root, { recursive: true, force: true }); + await disposeAgent(agent); + } + }); + + test('resumes the SDK session with the new workspace customizations and existing client tools before the next turn', async () => { + const root = await fs.mkdtemp(`${os.tmpdir()}/agent-set-cwd-resume-`); + const previous = URI.file(join(root, 'previous')); + const next = URI.file(join(root, 'next')); + await Promise.all([fs.mkdir(previous.fsPath), fs.mkdir(next.fsPath)]); + const session = AgentSession.uri('copilotcli', 'test-session-1'); + const chat = defaultChatUri(session); + const sessionDataService = disposables.add(new TestSessionDataService()); + const database = sessionDataService.openDatabase(session); + await database.object.setMetadata('copilot.workingDirectory', previous.toString()); + await database.object.setMetadata('copilot.workingDirectories', JSON.stringify([previous.toString()])); + await database.object.setMetadata('copilot.customizationDirectory', previous.toString()); + await database.object.setMetadata('copilot.project.resolved', 'true'); + database.dispose(); + + const fileService = disposables.add(new FileService(new NullLogService())); + disposables.add(fileService.registerProvider(Schemas.file, disposables.add(new TestDiskFileSystemProvider(new NullLogService())))); + await fileService.createFolder(URI.joinPath(previous, '.github', 'skills', 'previous-skill')); + await fileService.createFolder(URI.joinPath(next, '.github', 'skills', 'workspace-skill')); + await fileService.createFolder(URI.joinPath(next, '.github', 'instructions')); + await fileService.writeFile( + URI.joinPath(previous, '.github', 'skills', 'previous-skill', 'SKILL.md'), + VSBuffer.fromString('---\nname: previous-skill\ndescription: Previous workspace skill\n---\nbody'), + ); + await fileService.writeFile( + URI.joinPath(previous, '.mcp.json'), + VSBuffer.fromString('{"mcpServers":{"previous-server":{"command":"previous-server"}}}'), + ); + await fileService.writeFile( + URI.joinPath(next, '.github', 'skills', 'workspace-skill', 'SKILL.md'), + VSBuffer.fromString('---\nname: workspace-skill\ndescription: Converted workspace skill\n---\nbody'), + ); + await fileService.writeFile( + URI.joinPath(next, '.github', 'instructions', 'workspace.instructions.md'), + VSBuffer.fromString('---\napplyTo: "**/*.ts"\ndescription: Converted workspace instruction\n---\nbody'), + ); + await fileService.writeFile( + URI.joinPath(next, '.mcp.json'), + VSBuffer.fromString('{"mcpServers":{"workspace-server":{"command":"workspace-server"}}}'), + ); + const client = new TestCopilotClient([sdkSession('test-session-1', previous.fsPath)]); + const resumedConfigs: Array<{ + workingDirectory: string | undefined; + skillDirectories: readonly string[] | undefined; + instructionDirectories: readonly string[] | undefined; + mcpServerNames: readonly string[]; + hasClientTool: boolean; + }> = []; + client.resumeSession = async (_sessionId, options) => { + resumedConfigs.push({ + workingDirectory: options?.workingDirectory, + skillDirectories: options?.skillDirectories, + instructionDirectories: options?.instructionDirectories, + mcpServerNames: Object.keys(options?.mcpServers ?? {}), + hasClientTool: options?.tools?.some(tool => tool.name === 'workspace_client_tool') ?? false, + }); + return new MockCopilotSession() as unknown as CopilotSession; + }; + const { agent, instantiationService } = createTestAgentContext(disposables, { + sessionDataService, + copilotClient: client, + useRealResumePath: true, + fileService, + }); + const created = createAgentSessionThroughAgent(agent, instantiationService, { + mockSession: new MockCopilotSession(), + workingDirectory: previous, + }); + try { + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'token'); + await created.session.initializeSession(); + (agent as unknown as { + _registerLiveChat(chat: URI, session: CopilotAgentSession, activeClient: unknown): void; + })._registerLiveChat(chat, created.session, created.activeClient); + agent.getOrCreateActiveClient(chat, exactChatContext(session, chat, session), { clientId: 'client' }).tools = [{ + name: 'workspace_client_tool', + description: 'Existing client tool', + inputSchema: { type: 'object', properties: {} }, + }]; + + await agent.setWorkingDirectory(chat, exactChatContext(session, chat, session), next); + await agent.chats.sendMessage(chat, 'Continue', next, undefined, 'turn-2', undefined, undefined, exactChatContext(session, chat, session)); + + assert.deepStrictEqual(resumedConfigs, [{ + workingDirectory: next.fsPath, + skillDirectories: [URI.joinPath(next, '.github', 'skills', 'workspace-skill').fsPath], + instructionDirectories: [URI.joinPath(next, '.github', 'instructions').fsPath], + mcpServerNames: ['workspace-server'], + hasClientTool: true, + }]); + } finally { + await fs.rm(root, { recursive: true, force: true }); + await disposeAgent(agent); + } + }); + + test('holds the session turn reservation until provider metadata persistence completes', async () => { + const root = await fs.mkdtemp(`${os.tmpdir()}/agent-set-cwd-reservation-`); + const previous = URI.file(join(root, 'previous')); + const next = URI.file(join(root, 'next')); + await Promise.all([fs.mkdir(previous.fsPath), fs.mkdir(next.fsPath)]); + const session = AgentSession.uri('copilotcli', 'test-session-1'); + const chat = defaultChatUri(session); + const sessionDataService = disposables.add(new TestSessionDataService()); + const db = sessionDataService.openDatabase(session); + await db.object.setMetadata('copilot.workingDirectory', previous.toString()); + await db.object.setMetadata('copilot.workingDirectories', JSON.stringify([previous.toString()])); + await db.object.setMetadata('copilot.customizationDirectory', previous.toString()); + db.dispose(); + const { agent, instantiationService } = createTestAgentContext(disposables, { sessionDataService }); + const mockSession = new MockCopilotSession(); + const created = createAgentSessionThroughAgent(agent, instantiationService, { mockSession, workingDirectory: previous }); + const writeGate = new DeferredPromise(); + const writeEntered = new DeferredPromise(); + let change: Promise | undefined; + try { + await created.session.initializeSession(); + (agent as unknown as { + _registerLiveChat(chat: URI, session: CopilotAgentSession, activeClient: unknown): void; + })._registerLiveChat(chat, created.session, created.activeClient); + sessionDataService.gateNextMetadataWrite(session, 'copilot.workingDirectory', writeGate.p, writeEntered); + + change = agent.setWorkingDirectory(chat, exactChatContext(session, chat, session), next); + await writeEntered.p; + let sendFailure: string | undefined; + try { + await created.session.send('blocked'); + } catch (error) { + sendFailure = error instanceof Error ? error.message : String(error); + } + writeGate.complete(); + await change; + + assert.deepStrictEqual({ + sendFailure, + sdkCalls: mockSession.workingDirectoryCalls, + liveWorkingDirectory: created.session.workingDirectory?.toString(), + }, { + sendFailure: 'Cannot start a turn while the working directory is changing', + sdkCalls: [next.fsPath], + liveWorkingDirectory: next.toString(), + }); + } finally { + writeGate.complete(); + await change?.catch(() => undefined); + await fs.rm(root, { recursive: true, force: true }); + await disposeAgent(agent); + } + }); + + test('rejects invalid and multi-root targets before mutating the live SDK session', async () => { + const root = await fs.mkdtemp(`${os.tmpdir()}/agent-set-cwd-reject-`); + const previous = URI.file(join(root, 'previous')); + const secondary = URI.file(join(root, 'secondary')); + const notDirectory = URI.file(join(root, 'file.txt')); + await Promise.all([fs.mkdir(previous.fsPath), fs.mkdir(secondary.fsPath)]); + await fs.writeFile(notDirectory.fsPath, 'not a directory'); + const session = AgentSession.uri('copilotcli', 'test-session-1'); + const chat = defaultChatUri(session); + const sessionDataService = disposables.add(new TestSessionDataService()); + const db = sessionDataService.openDatabase(session); + await db.object.setMetadata('copilot.workingDirectory', previous.toString()); + await db.object.setMetadata('copilot.workingDirectories', JSON.stringify([previous.toString(), secondary.toString()])); + db.dispose(); + const { agent, instantiationService } = createTestAgentContext(disposables, { sessionDataService }); + const mockSession = new MockCopilotSession(); + const created = createAgentSessionThroughAgent(agent, instantiationService, { mockSession, workingDirectory: previous, additionalDirectories: [secondary] }); + try { + await created.session.initializeSession(); + (agent as unknown as { + _registerLiveChat(chat: URI, session: CopilotAgentSession, activeClient: unknown): void; + })._registerLiveChat(chat, created.session, created.activeClient); + + const failures: string[] = []; + for (const target of [ + URI.from({ scheme: Schemas.inMemory, path: '/not-local' }), + notDirectory, + secondary, + ]) { + try { + await agent.setWorkingDirectory(chat, exactChatContext(session, chat, session), target); + } catch (error) { + failures.push(error instanceof Error ? error.message : String(error)); + } + } + + assert.deepStrictEqual({ + failures, + sdkCalls: mockSession.workingDirectoryCalls, + liveWorkingDirectory: created.session.workingDirectory?.toString(), + }, { + failures: [ + 'Cannot change the working directory to non-local or relative resource \'inmemory:/not-local\'', + `Cannot change the working directory because '${notDirectory.fsPath}' is not an existing directory`, + `Cannot change the working directory for multi-root chat '${chat.toString()}'`, + ], + sdkCalls: [], + liveWorkingDirectory: previous.toString(), + }); + } finally { + await fs.rm(root, { recursive: true, force: true }); + await disposeAgent(agent); + } + }); + + test('rejects an exact chat without a live backing instead of resuming it', async () => { + const root = URI.file(await fs.mkdtemp(`${os.tmpdir()}/agent-set-cwd-not-live-`)); + const session = AgentSession.uri('copilotcli', 'not-live'); + const chat = defaultChatUri(session); + const agent = createTestAgent(disposables); + try { + await assert.rejects( + () => agent.setWorkingDirectory(chat, exactChatContext(session, chat, session), root), + error => error instanceof Error && error.message === `Cannot change the working directory: chat '${chat.toString()}' is unknown, not live, or does not match the supplied context`, + ); + } finally { + await fs.rm(root.fsPath, { recursive: true, force: true }); + await disposeAgent(agent); + } + }); + + test('rejects mismatched live configuration and resource contexts without mutation', async () => { + const root = await fs.mkdtemp(`${os.tmpdir()}/agent-set-cwd-context-`); + const previous = URI.file(join(root, 'previous')); + const next = URI.file(join(root, 'next')); + await Promise.all([fs.mkdir(previous.fsPath), fs.mkdir(next.fsPath)]); + const session = AgentSession.uri('copilotcli', 'test-session-1'); + const otherSession = AgentSession.uri('copilotcli', 'other-session'); + const chat = defaultChatUri(session); + const sessionDataService = disposables.add(new TestSessionDataService()); + const db = sessionDataService.openDatabase(session); + await db.object.setMetadata('copilot.workingDirectory', previous.toString()); + await db.object.setMetadata('copilot.workingDirectories', JSON.stringify([previous.toString()])); + await db.object.setMetadata('copilot.customizationDirectory', previous.toString()); + db.dispose(); + const { agent, instantiationService } = createTestAgentContext(disposables, { sessionDataService }); + const mockSession = new MockCopilotSession(); + const created = createAgentSessionThroughAgent(agent, instantiationService, { mockSession, workingDirectory: previous }); + const activeClient = created.activeClient as { + readonly pluginController: { + readonly directory: URI | undefined; + readonly additionalDirectories: readonly URI[]; + }; + }; + try { + await created.session.initializeSession(); + (agent as unknown as { + _registerLiveChat(chat: URI, session: CopilotAgentSession, activeClient: unknown): void; + })._registerLiveChat(chat, created.session, created.activeClient); + const writeStart = sessionDataService.metadataWrites(session).length; + const failures: string[] = []; + + for (const mismatchedContext of [ + exactChatContext(otherSession, chat, otherSession), + exactChatContext(session, chat, chat), + ]) { + try { + await agent.setWorkingDirectory(chat, mismatchedContext, next); + } catch (error) { + failures.push(error instanceof Error ? error.message : String(error)); + } + } + + assert.deepStrictEqual({ + failures, + liveWorkingDirectory: created.session.workingDirectory?.toString(), + sdkCalls: mockSession.workingDirectoryCalls, + pluginDirectory: activeClient.pluginController.directory?.toString(), + pluginAdditionalDirectories: activeClient.pluginController.additionalDirectories.map(directory => directory.toString()), + metadataWrites: sessionDataService.metadataWrites(session).slice(writeStart), + }, { + failures: [ + `Cannot change the working directory: chat '${chat.toString()}' is unknown, not live, or does not match the supplied context`, + `Cannot change the working directory: chat '${chat.toString()}' is unknown, not live, or does not match the supplied context`, + ], + liveWorkingDirectory: previous.toString(), + sdkCalls: [], + pluginDirectory: previous.toString(), + pluginAdditionalDirectories: [], + metadataWrites: [], + }); + } finally { + await fs.rm(root, { recursive: true, force: true }); + await disposeAgent(agent); + } + }); + + test('rejects an exact live peer chat without mutation', async () => { + const root = await fs.mkdtemp(`${os.tmpdir()}/agent-set-cwd-peer-`); + const previous = URI.file(join(root, 'previous')); + const next = URI.file(join(root, 'next')); + await Promise.all([fs.mkdir(previous.fsPath), fs.mkdir(next.fsPath)]); + const session = AgentSession.uri('copilotcli', 'test-session-1'); + const peerChat = URI.parse(buildChatUri(session, 'peer')); + const sessionDataService = disposables.add(new TestSessionDataService()); + const agent = createTestAgent(disposables, { sessionDataService }); + const activeClient = (agent as unknown as { + _getOrCreateActiveClient(session: URI, directory: URI): { + readonly pluginController: { + readonly directory: URI | undefined; + readonly additionalDirectories: readonly URI[]; + }; + }; + })._getOrCreateActiveClient(session, previous) as { + readonly pluginController: { + readonly directory: URI | undefined; + readonly additionalDirectories: readonly URI[]; + }; + }; + const peerSdkCalls: string[] = []; + try { + setPeerChatStub(agent, peerChat, { + workingDirectory: previous, + appliedAdditionalDirectories: [], + setWorkingDirectory: async (directory: URI) => { peerSdkCalls.push(directory.toString()); }, + }, 'peer-sdk-session'); + + let failure: string | undefined; + try { + await agent.setWorkingDirectory(peerChat, exactChatContext(session, peerChat, peerChat), next); + } catch (error) { + failure = error instanceof Error ? error.message : String(error); + } + + assert.deepStrictEqual({ + failure, + peerSdkCalls, + pluginDirectory: activeClient.pluginController.directory?.toString(), + pluginAdditionalDirectories: activeClient.pluginController.additionalDirectories.map(directory => directory.toString()), + openedSessions: sessionDataService.openedSessions, + }, { + failure: `Cannot change the working directory for peer chat '${peerChat.toString()}': live working-directory changes are only supported for the owning default chat`, + peerSdkCalls: [], + pluginDirectory: previous.toString(), + pluginAdditionalDirectories: [], + openedSessions: [], + }); + } finally { + await fs.rm(root, { recursive: true, force: true }); + await disposeAgent(agent); + } + }); + + test('rejects a default chat while another live chat shares its configuration', async () => { + const root = await fs.mkdtemp(`${os.tmpdir()}/agent-set-cwd-sibling-`); + const previous = URI.file(join(root, 'previous')); + const next = URI.file(join(root, 'next')); + await Promise.all([fs.mkdir(previous.fsPath), fs.mkdir(next.fsPath)]); + const session = AgentSession.uri('copilotcli', 'test-session-1'); + const chat = defaultChatUri(session); + const peerChat = URI.parse(buildChatUri(session, 'peer')); + const sessionDataService = disposables.add(new TestSessionDataService()); + const db = sessionDataService.openDatabase(session); + await db.object.setMetadata('copilot.workingDirectory', previous.toString()); + await db.object.setMetadata('copilot.workingDirectories', JSON.stringify([previous.toString()])); + await db.object.setMetadata('copilot.customizationDirectory', previous.toString()); + db.dispose(); + const { agent, instantiationService } = createTestAgentContext(disposables, { sessionDataService }); + const mockSession = new MockCopilotSession(); + const created = createAgentSessionThroughAgent(agent, instantiationService, { mockSession, workingDirectory: previous }); + const activeClient = created.activeClient as { + readonly pluginController: { + readonly directory: URI | undefined; + readonly additionalDirectories: readonly URI[]; + }; + }; + try { + await created.session.initializeSession(); + (agent as unknown as { + _registerLiveChat(chat: URI, session: CopilotAgentSession, activeClient: unknown): void; + })._registerLiveChat(chat, created.session, created.activeClient); + setLiveChatStub(agent, 'peer-sdk-session', { + sessionId: 'peer-sdk-session', + ownerSessionUri: session, + sessionUri: peerChat, + resourceUri: peerChat, + chatChannelUri: peerChat, + dispose: () => { }, + }, peerChat); + const writeStart = sessionDataService.metadataWrites(session).length; + + let failure: string | undefined; + try { + await agent.setWorkingDirectory(chat, exactChatContext(session, chat, session), next); + } catch (error) { + failure = error instanceof Error ? error.message : String(error); + } + + assert.deepStrictEqual({ + failure, + sdkCalls: mockSession.workingDirectoryCalls, + liveWorkingDirectory: created.session.workingDirectory?.toString(), + pluginDirectory: activeClient.pluginController.directory?.toString(), + pluginAdditionalDirectories: activeClient.pluginController.additionalDirectories.map(directory => directory.toString()), + metadataWrites: sessionDataService.metadataWrites(session).slice(writeStart), + }, { + failure: `Cannot change the working directory for chat '${chat.toString()}' while another live chat shares its configuration`, + sdkCalls: [], + liveWorkingDirectory: previous.toString(), + pluginDirectory: previous.toString(), + pluginAdditionalDirectories: [], + metadataWrites: [], + }); + } finally { + await fs.rm(root, { recursive: true, force: true }); + await disposeAgent(agent); + } + }); + + test('rejects shared customization additional roots absent from session and metadata state', async () => { + const root = await fs.mkdtemp(`${os.tmpdir()}/agent-set-cwd-controller-roots-`); + const previous = URI.file(join(root, 'previous')); + const secondary = URI.file(join(root, 'secondary')); + const next = URI.file(join(root, 'next')); + await Promise.all([fs.mkdir(previous.fsPath), fs.mkdir(secondary.fsPath), fs.mkdir(next.fsPath)]); + const session = AgentSession.uri('copilotcli', 'test-session-1'); + const chat = defaultChatUri(session); + const sessionDataService = disposables.add(new TestSessionDataService()); + const db = sessionDataService.openDatabase(session); + await db.object.setMetadata('copilot.workingDirectory', previous.toString()); + await db.object.setMetadata('copilot.workingDirectories', JSON.stringify([previous.toString()])); + await db.object.setMetadata('copilot.customizationDirectory', previous.toString()); + db.dispose(); + const { agent, instantiationService } = createTestAgentContext(disposables, { sessionDataService }); + const mockSession = new MockCopilotSession(); + const created = createAgentSessionThroughAgent(agent, instantiationService, { mockSession, workingDirectory: previous }); + const activeClient = created.activeClient as { + readonly pluginController: { + readonly directory: URI | undefined; + readonly additionalDirectories: readonly URI[]; + setAdditionalDirectories(directories: readonly URI[]): void; + }; + }; + try { + await created.session.initializeSession(); + (agent as unknown as { + _registerLiveChat(chat: URI, session: CopilotAgentSession, activeClient: unknown): void; + })._registerLiveChat(chat, created.session, created.activeClient); + activeClient.pluginController.setAdditionalDirectories([secondary]); + const writeStart = sessionDataService.metadataWrites(session).length; + + let failure: string | undefined; + try { + await agent.setWorkingDirectory(chat, exactChatContext(session, chat, session), next); + } catch (error) { + failure = error instanceof Error ? error.message : String(error); + } + + assert.deepStrictEqual({ + failure, + sdkCalls: mockSession.workingDirectoryCalls, + liveWorkingDirectory: created.session.workingDirectory?.toString(), + pluginDirectory: activeClient.pluginController.directory?.toString(), + pluginAdditionalDirectories: activeClient.pluginController.additionalDirectories.map(directory => directory.toString()), + metadataWrites: sessionDataService.metadataWrites(session).slice(writeStart), + }, { + failure: `Cannot change the working directory for multi-root chat '${chat.toString()}'`, + sdkCalls: [], + liveWorkingDirectory: previous.toString(), + pluginDirectory: previous.toString(), + pluginAdditionalDirectories: [secondary.toString()], + metadataWrites: [], + }); + } finally { + await fs.rm(root, { recursive: true, force: true }); + await disposeAgent(agent); + } + }); + + test('rejects peer creation before recording while a configuration working-directory change is prepared', async () => { + const root = await fs.mkdtemp(`${os.tmpdir()}/agent-set-cwd-create-barrier-`); + const previous = URI.file(join(root, 'previous')); + const next = URI.file(join(root, 'next')); + await Promise.all([fs.mkdir(previous.fsPath), fs.mkdir(next.fsPath)]); + const session = AgentSession.uri('copilotcli', 'test-session-1'); + const chat = defaultChatUri(session); + const peerChat = URI.parse(buildChatUri(session, 'peer')); + const sessionDataService = disposables.add(new TestSessionDataService()); + const db = sessionDataService.openDatabase(session); + await db.object.setMetadata('copilot.workingDirectory', previous.toString()); + await db.object.setMetadata('copilot.workingDirectories', JSON.stringify([previous.toString()])); + await db.object.setMetadata('copilot.customizationDirectory', previous.toString()); + db.dispose(); + const { agent, instantiationService } = createTestAgentContext(disposables, { sessionDataService }); + const mockSession = new MockCopilotSession(); + const created = createAgentSessionThroughAgent(agent, instantiationService, { mockSession, workingDirectory: previous }); + const writeGate = new DeferredPromise(); + const writeEntered = new DeferredPromise(); + let change: Promise | undefined; + try { + await created.session.initializeSession(); + (agent as unknown as { + _registerLiveChat(chat: URI, session: CopilotAgentSession, activeClient: unknown): void; + })._registerLiveChat(chat, created.session, created.activeClient); + sessionDataService.gateNextMetadataWrite(session, 'copilot.workingDirectory', writeGate.p, writeEntered); + + change = agent.setWorkingDirectory(chat, exactChatContext(session, chat, session), next); + await writeEntered.p; + let failure: string | undefined; + try { + await agent.chats.createChat(peerChat, exactChatContext(session, peerChat, peerChat), { workingDirectories: [previous] }); + } catch (error) { + failure = error instanceof Error ? error.message : String(error); + } + const recordedBeforeRelease = chatScopes(agent).has(peerChat.toString()); + const backingBeforeRelease = chatBackings(agent).has(peerChat.toString()); + writeGate.complete(); + await change; + + assert.deepStrictEqual({ + failure, + recordedBeforeRelease, + backingBeforeRelease, + sdkCalls: mockSession.workingDirectoryCalls, + liveWorkingDirectory: created.session.workingDirectory?.toString(), + }, { + failure: `Cannot create or resume chat '${peerChat.toString()}' while its configuration working directory is changing`, + recordedBeforeRelease: false, + backingBeforeRelease: false, + sdkCalls: [next.fsPath], + liveWorkingDirectory: next.toString(), + }); + } finally { + writeGate.complete(); + await change?.catch(() => undefined); + await fs.rm(root, { recursive: true, force: true }); + await disposeAgent(agent); + } + }); + + test('rejects cold peer resume without live registration while a configuration working-directory change is prepared', async () => { + const root = await fs.mkdtemp(`${os.tmpdir()}/agent-set-cwd-resume-barrier-`); + const previous = URI.file(join(root, 'previous')); + const next = URI.file(join(root, 'next')); + await Promise.all([fs.mkdir(previous.fsPath), fs.mkdir(next.fsPath)]); + const session = AgentSession.uri('copilotcli', 'test-session-1'); + const chat = defaultChatUri(session); + const peerChat = URI.parse(buildChatUri(session, 'peer')); + const sessionDataService = disposables.add(new TestSessionDataService()); + const db = sessionDataService.openDatabase(session); + await db.object.setMetadata('copilot.workingDirectory', previous.toString()); + await db.object.setMetadata('copilot.workingDirectories', JSON.stringify([previous.toString()])); + await db.object.setMetadata('copilot.customizationDirectory', previous.toString()); + db.dispose(); + const { agent, instantiationService } = createTestAgentContext(disposables, { sessionDataService }); + const mockSession = new MockCopilotSession(); + const created = createAgentSessionThroughAgent(agent, instantiationService, { mockSession, workingDirectory: previous }); + const writeGate = new DeferredPromise(); + const writeEntered = new DeferredPromise(); + let change: Promise | undefined; + try { + await created.session.initializeSession(); + (agent as unknown as { + _registerLiveChat(chat: URI, session: CopilotAgentSession, activeClient: unknown): void; + })._registerLiveChat(chat, created.session, created.activeClient); + sessionDataService.gateNextMetadataWrite(session, 'copilot.workingDirectory', writeGate.p, writeEntered); + + change = agent.setWorkingDirectory(chat, exactChatContext(session, chat, session), next); + await writeEntered.p; + await agent.materializeChat(peerChat, exactChatContext(session, peerChat, peerChat), JSON.stringify({ sdkSessionId: 'peer-sdk-session' })); + let failure: string | undefined; + try { + await agent.chats.getMessages(peerChat, exactChatContext(session, peerChat, peerChat)); + } catch (error) { + failure = error instanceof Error ? error.message : String(error); + } + const liveBeforeRelease = hasLiveChat(agent, peerChat); + writeGate.complete(); + await change; + + assert.deepStrictEqual({ + failure, + liveBeforeRelease, + liveAfterRelease: hasLiveChat(agent, peerChat), + sdkCalls: mockSession.workingDirectoryCalls, + liveWorkingDirectory: created.session.workingDirectory?.toString(), + }, { + failure: `Cannot create or resume chat '${peerChat.toString()}' while its configuration working directory is changing`, + liveBeforeRelease: false, + liveAfterRelease: false, + sdkCalls: [next.fsPath], + liveWorkingDirectory: next.toString(), + }); + } finally { + writeGate.complete(); + await change?.catch(() => undefined); + await fs.rm(root, { recursive: true, force: true }); + await disposeAgent(agent); + } + }); + + test('rechecks recorded peer scopes after waiting behind the chat sequencer', async () => { + const root = await fs.mkdtemp(`${os.tmpdir()}/agent-set-cwd-recorded-peer-`); + const previous = URI.file(join(root, 'previous')); + const next = URI.file(join(root, 'next')); + await Promise.all([fs.mkdir(previous.fsPath), fs.mkdir(next.fsPath)]); + const session = AgentSession.uri('copilotcli', 'test-session-1'); + const chat = defaultChatUri(session); + const peerChat = URI.parse(buildChatUri(session, 'peer')); + const sessionDataService = disposables.add(new TestSessionDataService()); + const db = sessionDataService.openDatabase(session); + await db.object.setMetadata('copilot.workingDirectory', previous.toString()); + await db.object.setMetadata('copilot.workingDirectories', JSON.stringify([previous.toString()])); + await db.object.setMetadata('copilot.customizationDirectory', previous.toString()); + db.dispose(); + const { agent, instantiationService } = createTestAgentContext(disposables, { sessionDataService }); + const mockSession = new MockCopilotSession(); + const created = createAgentSessionThroughAgent(agent, instantiationService, { mockSession, workingDirectory: previous }); + const activeClient = created.activeClient as { + readonly pluginController: { + readonly directory: URI | undefined; + readonly additionalDirectories: readonly URI[]; + }; + }; + const blockerGate = new DeferredPromise(); + const blockerEntered = new DeferredPromise(); + let blocker: Promise | undefined; + let change: Promise | undefined; + try { + await created.session.initializeSession(); + (agent as unknown as { + _registerLiveChat(chat: URI, session: CopilotAgentSession, activeClient: unknown): void; + })._registerLiveChat(chat, created.session, created.activeClient); + const writeStart = sessionDataService.metadataWrites(session).length; + blocker = (agent as unknown as { + _queueChat(sessionId: string, chatKey: string, operation: string, task: () => Promise): Promise; + })._queueChat(AgentSession.id(session), created.session.sessionId, 'testBlocker', async () => { + blockerEntered.complete(); + await blockerGate.p; + }); + await blockerEntered.p; + + change = agent.setWorkingDirectory(chat, exactChatContext(session, chat, session), next); + await timeout(0); + await agent.materializeChat(peerChat, exactChatContext(session, peerChat, peerChat), JSON.stringify({ sdkSessionId: 'peer-sdk-session' })); + blockerGate.complete(); + await blocker; + + let failure: string | undefined; + try { + await change; + } catch (error) { + failure = error instanceof Error ? error.message : String(error); + } + + assert.deepStrictEqual({ + failure, + sdkCalls: mockSession.workingDirectoryCalls, + liveWorkingDirectory: created.session.workingDirectory?.toString(), + pluginDirectory: activeClient.pluginController.directory?.toString(), + pluginAdditionalDirectories: activeClient.pluginController.additionalDirectories.map(directory => directory.toString()), + peerRecorded: chatScopes(agent).get(peerChat.toString())?.toString(), + metadataWrites: sessionDataService.metadataWrites(session).slice(writeStart), + }, { + failure: `Cannot change the working directory for chat '${chat.toString()}' while another recorded chat shares its configuration`, + sdkCalls: [], + liveWorkingDirectory: previous.toString(), + pluginDirectory: previous.toString(), + pluginAdditionalDirectories: [], + peerRecorded: session.toString(), + metadataWrites: [], + }); + } finally { + blockerGate.complete(); + await blocker?.catch(() => undefined); + await change?.catch(() => undefined); + await fs.rm(root, { recursive: true, force: true }); + await disposeAgent(agent); + } + }); + + test('waits behind the chat sequencer and rejects a replaced backing without mutation', async () => { + const root = await fs.mkdtemp(`${os.tmpdir()}/agent-set-cwd-sequencer-`); + const previous = URI.file(join(root, 'previous')); + const next = URI.file(join(root, 'next')); + await Promise.all([fs.mkdir(previous.fsPath), fs.mkdir(next.fsPath)]); + const session = AgentSession.uri('copilotcli', 'test-session-1'); + const chat = defaultChatUri(session); + const sessionDataService = disposables.add(new TestSessionDataService()); + const db = sessionDataService.openDatabase(session); + await db.object.setMetadata('copilot.workingDirectory', previous.toString()); + await db.object.setMetadata('copilot.workingDirectories', JSON.stringify([previous.toString()])); + await db.object.setMetadata('copilot.customizationDirectory', previous.toString()); + db.dispose(); + const { agent, instantiationService } = createTestAgentContext(disposables, { sessionDataService }); + const mockSession = new MockCopilotSession(); + const created = createAgentSessionThroughAgent(agent, instantiationService, { mockSession, workingDirectory: previous }); + const activeClient = created.activeClient as { + readonly pluginController: { + readonly directory: URI | undefined; + readonly additionalDirectories: readonly URI[]; + }; + }; + const blockerGate = new DeferredPromise(); + const blockerEntered = new DeferredPromise(); + let blocker: Promise | undefined; + let change: Promise | undefined; + try { + await created.session.initializeSession(); + (agent as unknown as { + _registerLiveChat(chat: URI, session: CopilotAgentSession, activeClient: unknown): void; + })._registerLiveChat(chat, created.session, created.activeClient); + const writeStart = sessionDataService.metadataWrites(session).length; + blocker = (agent as unknown as { + _queueChat(sessionId: string, chatKey: string, operation: string, task: () => Promise): Promise; + })._queueChat(AgentSession.id(session), created.session.sessionId, 'testBlocker', async () => { + blockerEntered.complete(); + await blockerGate.p; + }); + await blockerEntered.p; + + change = agent.setWorkingDirectory(chat, exactChatContext(session, chat, session), next); + await timeout(0); + chatBackings(agent).set(chat.toString(), { sdkSessionId: 'replacement-sdk-session' }); + blockerGate.complete(); + await blocker; + let failure: string | undefined; + try { + await change; + } catch (error) { + failure = error instanceof Error ? error.message : String(error); + } + + assert.deepStrictEqual({ + failure, + liveWorkingDirectory: created.session.workingDirectory?.toString(), + sdkCalls: mockSession.workingDirectoryCalls, + pluginDirectory: activeClient.pluginController.directory?.toString(), + pluginAdditionalDirectories: activeClient.pluginController.additionalDirectories.map(directory => directory.toString()), + metadataWrites: sessionDataService.metadataWrites(session).slice(writeStart), + }, { + failure: `Cannot change the working directory: chat '${chat.toString()}' is unknown, not live, or does not match the supplied context`, + liveWorkingDirectory: previous.toString(), + sdkCalls: [], + pluginDirectory: previous.toString(), + pluginAdditionalDirectories: [], + metadataWrites: [], + }); + } finally { + blockerGate.complete(); + await blocker?.catch(() => undefined); + await change?.catch(() => undefined); + await fs.rm(root, { recursive: true, force: true }); + await disposeAgent(agent); + } + }); + + test('rolls live, customization, and provider metadata state back after persistence fails', async () => { + const root = await fs.mkdtemp(`${os.tmpdir()}/agent-set-cwd-rollback-`); + const previous = URI.file(join(root, 'previous')); + const next = URI.file(join(root, 'next')); + await Promise.all([fs.mkdir(previous.fsPath), fs.mkdir(next.fsPath)]); + const session = AgentSession.uri('copilotcli', 'test-session-1'); + const chat = defaultChatUri(session); + const sessionDataService = disposables.add(new TestSessionDataService()); + const db = sessionDataService.openDatabase(session); + await db.object.setMetadata('copilot.workingDirectory', previous.toString()); + await db.object.setMetadata('copilot.workingDirectories', JSON.stringify([previous.toString()])); + await db.object.setMetadata('copilot.customizationDirectory', previous.toString()); + await db.object.setMetadata('agentHost.workspaceless', 'true'); + db.dispose(); + const { agent, instantiationService } = createTestAgentContext(disposables, { sessionDataService }); + const mockSession = new MockCopilotSession(); + const created = createAgentSessionThroughAgent(agent, instantiationService, { mockSession, workingDirectory: previous }); + const activeClient = created.activeClient as { + readonly pluginController: { + readonly directory: URI | undefined; + readonly additionalDirectories: readonly URI[]; + }; + }; + try { + await created.session.initializeSession(); + (agent as unknown as { + _registerLiveChat(chat: URI, session: CopilotAgentSession, activeClient: unknown): void; + })._registerLiveChat(chat, created.session, created.activeClient); + const writeStart = sessionDataService.metadataWrites(session).length; + sessionDataService.failNextMetadataWrite(session, 'copilot.workingDirectories', new Error('metadata write failed')); + + let failure: string | undefined; + try { + await agent.setWorkingDirectory(chat, exactChatContext(session, chat, session), next); + } catch (error) { + failure = error instanceof Error ? error.message : String(error); + } + + const stored = sessionDataService.openDatabase(session); + const metadata = await stored.object.getMetadataObject({ + 'copilot.workingDirectory': true, + 'copilot.workingDirectories': true, + 'copilot.customizationDirectory': true, + 'agentHost.workspaceless': true, + }); + stored.dispose(); + assert.deepStrictEqual({ + failure, + liveWorkingDirectory: created.session.workingDirectory?.toString(), + sdkCalls: mockSession.workingDirectoryCalls, + pluginDirectory: activeClient.pluginController.directory?.toString(), + pluginAdditionalDirectories: activeClient.pluginController.additionalDirectories.map(directory => directory.toString()), + metadataWrites: sessionDataService.metadataWrites(session).slice(writeStart), + metadata, + }, { + failure: 'provider metadata: metadata write failed', + liveWorkingDirectory: previous.toString(), + sdkCalls: [], + pluginDirectory: previous.toString(), + pluginAdditionalDirectories: [], + metadataWrites: [ + { key: 'copilot.workingDirectory', value: next.toString() }, + { key: 'copilot.workingDirectories', value: JSON.stringify([next.toString()]) }, + { key: 'copilot.customizationDirectory', value: next.toString() }, + { key: 'copilot.workingDirectory', value: previous.toString() }, + { key: 'copilot.workingDirectories', value: JSON.stringify([previous.toString()]) }, + { key: 'copilot.customizationDirectory', value: previous.toString() }, + ], + metadata: { + 'copilot.workingDirectory': previous.toString(), + 'copilot.workingDirectories': JSON.stringify([previous.toString()]), + 'copilot.customizationDirectory': previous.toString(), + 'agentHost.workspaceless': 'true', + }, + }); + } finally { + await fs.rm(root, { recursive: true, force: true }); + await disposeAgent(agent); + } + }); + + test('restores customization and provider metadata state when the SDK rejects the target', async () => { + const root = await fs.mkdtemp(`${os.tmpdir()}/agent-set-cwd-sdk-reject-`); + const previous = URI.file(join(root, 'previous')); + const next = URI.file(join(root, 'next')); + await Promise.all([fs.mkdir(previous.fsPath), fs.mkdir(next.fsPath)]); + const session = AgentSession.uri('copilotcli', 'test-session-1'); + const chat = defaultChatUri(session); + const sessionDataService = disposables.add(new TestSessionDataService()); + const db = sessionDataService.openDatabase(session); + await db.object.setMetadata('copilot.workingDirectory', previous.toString()); + await db.object.setMetadata('copilot.workingDirectories', JSON.stringify([previous.toString()])); + await db.object.setMetadata('copilot.customizationDirectory', previous.toString()); + db.dispose(); + const { agent, instantiationService } = createTestAgentContext(disposables, { sessionDataService }); + const mockSession = new MockCopilotSession(); + mockSession.workingDirectoryErrors.push(new Error('SDK rejected target')); + const created = createAgentSessionThroughAgent(agent, instantiationService, { mockSession, workingDirectory: previous }); + const activeClient = created.activeClient as { + readonly pluginController: { + readonly directory: URI | undefined; + readonly additionalDirectories: readonly URI[]; + }; + }; + try { + await created.session.initializeSession(); + (agent as unknown as { + _registerLiveChat(chat: URI, session: CopilotAgentSession, activeClient: unknown): void; + })._registerLiveChat(chat, created.session, created.activeClient); + const writeStart = sessionDataService.metadataWrites(session).length; + + let failure: string | undefined; + try { + await agent.setWorkingDirectory(chat, exactChatContext(session, chat, session), next); + } catch (error) { + failure = error instanceof Error ? error.message : String(error); + } + + const stored = sessionDataService.openDatabase(session); + const metadata = await stored.object.getMetadataObject({ + 'copilot.workingDirectory': true, + 'copilot.workingDirectories': true, + 'copilot.customizationDirectory': true, + }); + stored.dispose(); + assert.deepStrictEqual({ + failure, + liveWorkingDirectory: created.session.workingDirectory?.toString(), + sdkCalls: mockSession.workingDirectoryCalls, + pluginDirectory: activeClient.pluginController.directory?.toString(), + pluginAdditionalDirectories: activeClient.pluginController.additionalDirectories.map(directory => directory.toString()), + metadataWrites: sessionDataService.metadataWrites(session).slice(writeStart), + metadata, + }, { + failure: 'SDK rejected target', + liveWorkingDirectory: previous.toString(), + sdkCalls: [next.fsPath], + pluginDirectory: previous.toString(), + pluginAdditionalDirectories: [], + metadataWrites: [ + { key: 'copilot.workingDirectory', value: next.toString() }, + { key: 'copilot.workingDirectories', value: JSON.stringify([next.toString()]) }, + { key: 'copilot.customizationDirectory', value: next.toString() }, + { key: 'copilot.workingDirectory', value: previous.toString() }, + { key: 'copilot.workingDirectories', value: JSON.stringify([previous.toString()]) }, + { key: 'copilot.customizationDirectory', value: previous.toString() }, + ], + metadata: { + 'copilot.workingDirectory': previous.toString(), + 'copilot.workingDirectories': JSON.stringify([previous.toString()]), + 'copilot.customizationDirectory': previous.toString(), + }, + }); + } finally { + await fs.rm(root, { recursive: true, force: true }); + await disposeAgent(agent); + } + }); + + test('adopts an SDK-returned working directory mismatch and reconciles provider state', async () => { + const root = await fs.mkdtemp(`${os.tmpdir()}/agent-set-cwd-sdk-mismatch-`); + const previous = URI.file(join(root, 'previous')); + const next = URI.file(join(root, 'next')); + const actual = URI.file(join(root, 'actual')); + await Promise.all([fs.mkdir(previous.fsPath), fs.mkdir(next.fsPath), fs.mkdir(actual.fsPath)]); + const session = AgentSession.uri('copilotcli', 'test-session-1'); + const chat = defaultChatUri(session); + const sessionDataService = disposables.add(new TestSessionDataService()); + const db = sessionDataService.openDatabase(session); + await db.object.setMetadata('copilot.workingDirectory', previous.toString()); + await db.object.setMetadata('copilot.workingDirectories', JSON.stringify([previous.toString()])); + await db.object.setMetadata('copilot.customizationDirectory', previous.toString()); + db.dispose(); + const { agent, instantiationService } = createTestAgentContext(disposables, { sessionDataService }); + const mockSession = new MockCopilotSession(); + mockSession.workingDirectoryResults.push(actual.fsPath); + const created = createAgentSessionThroughAgent(agent, instantiationService, { mockSession, workingDirectory: previous }); + const activeClient = created.activeClient as { + readonly pluginController: { + readonly directory: URI | undefined; + readonly additionalDirectories: readonly URI[]; + }; + }; + try { + await created.session.initializeSession(); + (agent as unknown as { + _registerLiveChat(chat: URI, session: CopilotAgentSession, activeClient: unknown): void; + })._registerLiveChat(chat, created.session, created.activeClient); + const writeStart = sessionDataService.metadataWrites(session).length; + + let failure: string | undefined; + try { + await agent.setWorkingDirectory(chat, exactChatContext(session, chat, session), next); + } catch (error) { + failure = error instanceof Error ? error.message : String(error); + } + + const stored = sessionDataService.openDatabase(session); + const metadata = await stored.object.getMetadataObject({ + 'copilot.workingDirectory': true, + 'copilot.workingDirectories': true, + 'copilot.customizationDirectory': true, + }); + stored.dispose(); + assert.deepStrictEqual({ + failure, + liveWorkingDirectory: created.session.workingDirectory?.toString(), + sdkCalls: mockSession.workingDirectoryCalls, + pluginDirectory: activeClient.pluginController.directory?.toString(), + pluginAdditionalDirectories: activeClient.pluginController.additionalDirectories.map(directory => directory.toString()), + metadataWrites: sessionDataService.metadataWrites(session).slice(writeStart), + metadata, + }, { + failure: `The SDK returned working directory '${actual.fsPath}' instead of '${next.fsPath}'`, + liveWorkingDirectory: actual.toString(), + sdkCalls: [next.fsPath], + pluginDirectory: actual.toString(), + pluginAdditionalDirectories: [], + metadataWrites: [ + { key: 'copilot.workingDirectory', value: next.toString() }, + { key: 'copilot.workingDirectories', value: JSON.stringify([next.toString()]) }, + { key: 'copilot.customizationDirectory', value: next.toString() }, + { key: 'copilot.workingDirectory', value: actual.toString() }, + { key: 'copilot.workingDirectories', value: JSON.stringify([actual.toString()]) }, + { key: 'copilot.customizationDirectory', value: actual.toString() }, + ], + metadata: { + 'copilot.workingDirectory': actual.toString(), + 'copilot.workingDirectories': JSON.stringify([actual.toString()]), + 'copilot.customizationDirectory': actual.toString(), + }, + }); + } finally { + await fs.rm(root, { recursive: true, force: true }); + await disposeAgent(agent); + } + }); }); suite('prewarmSessionMetadata cache', () => { @@ -9222,6 +10365,8 @@ suite('CopilotAgent', () => { readonly resets: { turnId: string; senderClientId: string | undefined }[]; readonly modelCalls: { id: string; effort: string | undefined; tier?: string | undefined }[]; readonly agentCalls: (string | undefined)[]; + aborted: number; + discardedTurns: number; readonly debugLogCalls: { outputDirectory: string; includeSessionLogs: boolean }[]; } @@ -9241,6 +10386,8 @@ suite('CopilotAgent', () => { resets: [], modelCalls: [], agentCalls: [], + aborted: 0, + discardedTurns: 0, debugLogCalls: [], }; const fake = { @@ -9259,6 +10406,8 @@ suite('CopilotAgent', () => { resetTurnState(turnId: string, senderClientId: string | undefined): void { rec.resets.push({ turnId, senderClientId }); }, async setModel(id: string, reasoningEffort?: string, contextTier?: string): Promise { rec.modelCalls.push({ id, effort: reasoningEffort, tier: contextTier }); }, async setAgent(name: string | undefined): Promise { rec.agentCalls.push(name); }, + async abort(): Promise { rec.aborted++; }, + discardActiveTurn(): void { rec.discardedTurns++; }, async collectDebugLogs(outputDirectory: URI, includeSessionLogs: boolean): Promise { rec.debugLogCalls.push({ outputDirectory: outputDirectory.toString(), includeSessionLogs }); return true; @@ -10150,6 +11299,73 @@ suite('CopilotAgent', () => { } }); + test('sendMessage preserves provider-owned workspace-less working directories when the caller still supplies the scratch directory', async () => { + const sessionDataService = disposables.add(new TestSessionDataService()); + const agent = createTestAgent(disposables, { sessionDataService, copilotClient: new TestCopilotClient([]), useRealResumePath: true }); + try { + await agent.authenticate('https://api.github.com', 'token'); + const session = AgentSession.uri('copilotcli', 'route-converted-quick-chat'); + const chatUri = defaultChatUri(session); + const sdkSessionId = 'converted-quick-chat-sdk'; + const scratchDirectory = URI.file('/quick-chat-scratch'); + const providerWorkingDirectory = URI.file('/selected-workspace'); + await agent.materializeChat(chatUri, exactChatContext(session, chatUri, session), JSON.stringify({ sdkSessionId })); + const dbRef = sessionDataService.openDatabase(session); + await dbRef.object.setMetadata('copilot.workingDirectory', providerWorkingDirectory.toString()); + await dbRef.object.setMetadata('copilot.workingDirectories', JSON.stringify([providerWorkingDirectory.toString()])); + await dbRef.object.setMetadata('copilot.customizationDirectory', providerWorkingDirectory.toString()); + await dbRef.object.setMetadata('agentHost.workspaceless', 'true'); + dbRef.dispose(); + + const internals = agent as unknown as ChatInternals; + const launches: { sessionId: string; workingDirectory: string | undefined; additionalDirectories: string[] | undefined }[] = []; + let recorder: IFakeChatRecorder | undefined; + internals._createAgentSession = (launchPlan, _customizationDirectory, _activeClient, identity) => { + launches.push({ + sessionId: launchPlan.sessionId, + workingDirectory: launchPlan.workingDirectory?.toString(), + additionalDirectories: launchPlan.additionalDirectories?.map(directory => directory.toString()), + }); + const built = makeFakeChatSession(session, launchPlan.sessionId, undefined, launchPlan.shellManager); + recorder = built.rec; + (built.fake as { chatChannelUri?: URI }).chatChannelUri = identity?.chatChannelUri; + (built.fake as { appliedAdditionalDirectories?: readonly URI[] }).appliedAdditionalDirectories = launchPlan.additionalDirectories; + return built.fake; + }; + + await agent.chats.sendMessage(chatUri, 'continue in the selected workspace', [scratchDirectory], undefined, 'turn-1', undefined, exactChatContext(session, chatUri, session)); + const storedRef = sessionDataService.openDatabase(session); + const metadata = await storedRef.object.getMetadataObject({ + 'copilot.workingDirectory': true, + 'copilot.workingDirectories': true, + 'copilot.customizationDirectory': true, + 'agentHost.workspaceless': true, + }); + storedRef.dispose(); + + assert.deepStrictEqual({ + launches, + sentPrompts: recorder?.sends.map(send => send.prompt), + metadata, + }, { + launches: [{ + sessionId: sdkSessionId, + workingDirectory: providerWorkingDirectory.toString(), + additionalDirectories: [], + }], + sentPrompts: ['continue in the selected workspace'], + metadata: { + 'copilot.workingDirectory': providerWorkingDirectory.toString(), + 'copilot.workingDirectories': JSON.stringify([providerWorkingDirectory.toString()]), + 'copilot.customizationDirectory': providerWorkingDirectory.toString(), + 'agentHost.workspaceless': 'true', + }, + }); + } finally { + await disposeAgent(agent); + } + }); + test('sendMessage throws for a chat with no backing chat', async () => { const agent = createTestAgent(disposables); try { @@ -10349,6 +11565,83 @@ suite('CopilotAgent', () => { } }); + test('continues queued sends after a non-settling control-plane RPC times out', async () => { + const agent = createTestAgent(disposables); + try { + const session = AgentSession.uri('copilotcli', 'control-rpc-timeout'); + const chat = URI.parse(buildChatUri(session, 'peer-a')); + const target = makeFakeChatSession(session, 'sdk-a'); + const neverSettles = new Promise(() => { }); + (target.fake as unknown as { setAgent(): Promise }).setAgent = async () => { + if (await raceTimeout(neverSettles, 1) === undefined) { + throw new Error('rpc.agent.deselect timed out'); + } + }; + setPeerChatStub(agent, chat, target.fake); + + const change = agent.chats.changeAgent(chat, undefined, exactChatContext(session, chat)); + const send = agent.chats.sendMessage(chat, 'follow-up', undefined, undefined, 'turn-1', undefined, exactChatContext(session, chat)); + await assert.rejects(change, /rpc\.agent\.deselect timed out/); + await send; + + assert.deepStrictEqual(target.rec.sends, [{ prompt: 'follow-up', turnId: 'turn-1', mode: undefined, senderClientId: undefined }]); + } finally { + await disposeAgent(agent); + } + }); + + test('aborts while a chat queue task is blocked', async () => { + const agent = createTestAgent(disposables); + try { + const session = AgentSession.uri('copilotcli', 'abort-queue-bypass'); + const chat = URI.parse(buildChatUri(session, 'peer-a')); + const target = makeFakeChatSession(session, 'sdk-a'); + const controlPlaneGate = new DeferredPromise(); + (target.fake as unknown as { setAgent(): Promise }).setAgent = async () => controlPlaneGate.p; + setPeerChatStub(agent, chat, target.fake); + + const change = agent.chats.changeAgent(chat, undefined, exactChatContext(session, chat)); + await timeout(0); + await agent.chats.abort(chat, exactChatContext(session, chat)); + controlPlaneGate.complete(); + await change; + + assert.deepStrictEqual({ aborted: target.rec.aborted, agentCalls: target.rec.agentCalls }, { aborted: 1, agentCalls: [] }); + } finally { + await disposeAgent(agent); + } + }); + test('drops a queued send when abort arrives before the session materializes', async () => { + const agent = createTestAgent(disposables); + try { + const session = AgentSession.uri('copilotcli', 'abort-before-materialize'); + const chat = URI.parse(buildChatUri(session, 'peer-a')); + const target = makeFakeChatSession(session, 'sdk-a'); + // Register the backing (which fixes the chat's sequencer key) but no + // live entry, modelling a chat whose session is still materializing. + chatBackings(agent).set(chat.toString(), { sdkSessionId: 'sdk-a' }); + chatScopes(agent).set(chat.toString(), session); + const materializeGate = new DeferredPromise(); + (agent as unknown as { _ensureResolvedChatSession(): Promise })._ensureResolvedChatSession = async () => { + await materializeGate.p; + return target.fake; + }; + + const send = agent.chats.sendMessage(chat, 'cancelled', undefined, undefined, 'turn-1', undefined, exactChatContext(session, chat)); + await timeout(0); + await agent.chats.abort(chat, exactChatContext(session, chat)); + materializeGate.complete(); + await send; + + assert.deepStrictEqual( + { sends: target.rec.sends, aborted: target.rec.aborted, discarded: target.rec.discardedTurns }, + { sends: [], aborted: 0, discarded: 1 }, + ); + } finally { + await disposeAgent(agent); + } + }); + test('round-trips addressed chats through providerData + materializeChat and resumes per-chat history after a restart', async () => { // A single session data service is shared across the two agent // instances to model the on-disk store surviving a process restart. diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index 2e2fc1381c23b..5b46ad04d277b 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -44,8 +44,9 @@ import { TerminalClaimKind } from '../../common/state/protocol/state.js'; import { toHostSnapshotAttachmentMeta } from '../../common/meta/agentSnapshotAttachmentMeta.js'; import { STREAMING_TOOL_DISPLAY_INTERVAL_MS } from '../../common/streamingToolCallDisplay.js'; import { CustomizationEnablementKind, CustomizationType, McpAuthRequiredReason, McpServerStatus, type Customization, type McpServerCustomization } from '../../common/state/protocol/channels-session/state.js'; -import { CopilotAgentSession } from '../../node/copilot/copilotAgentSession.js'; +import { CopilotAgentSession, type ICopilotWorkingDirectoryChangeTransaction } from '../../node/copilot/copilotAgentSession.js'; import { buildNonPtyShellTerminalUri } from '../../node/copilot/copilotNonPtyShellTerminals.js'; +import { ShellManager } from '../../node/copilot/copilotShellTools.js'; import { buildMcpChannel } from '../../node/shared/mcpCustomizationController.js'; import { buildSandboxConfigForSdk, type SandboxConfig } from '../../node/copilot/sandboxConfigForSdk.js'; import { ActiveClientToolSet } from '../../node/activeClientState.js'; @@ -76,6 +77,12 @@ import { IAgentHostGitHubEndpointService } from '../../node/agentHostGitHubEndpo import type { IAgentHostRestrictedTelemetry, IAgentHostRestrictedTelemetryContext, IAgentHostInternalTelemetryContext, TelemetryMeasurements, TelemetryProps } from '../../node/agentHostRestrictedTelemetry.js'; import { createTestGitHubEndpointService } from './testGitHubEndpointService.js'; +const noOpWorkingDirectoryChangeTransaction: ICopilotWorkingDirectoryChangeTransaction = { + prepare: async () => { }, + rollback: async () => { }, + reconcile: async () => { }, +}; + // ---- Mock CopilotSession (SDK level) ---------------------------------------- /** @@ -98,12 +105,24 @@ class MockCopilotSession { gitHubCredentialUpdateError: Error | undefined; readonly collectLogsCalls: Parameters[0][] = []; readonly collectLogsResults: Awaited>[] = []; + readonly workingDirectorySetCalls: Parameters[0][] = []; + readonly workingDirectorySetResults: Awaited>[] = []; + readonly workingDirectorySetErrors: Array = []; + workingDirectorySetGate: Promise | undefined; + onWorkingDirectorySet: (() => void) | undefined; + readonly workingDirectoryOptionUpdateCalls: string[] = []; + readonly workingDirectoryOptionUpdateErrors: Array = []; + workingDirectoryOptionUpdateSuccess = true; + onWorkingDirectoryOptionUpdate: (() => void) | undefined; readonly experimentalModeUpdates: boolean[] = []; experimentalModeUpdateSuccess = true; sandboxConfigUpdateSuccess = true; shellInitScriptUpdateSuccess = true; abortCalls = 0; abortGate: Promise | undefined; + modelGate: Promise | undefined; + agentSelectGate: Promise | undefined; + agentDeselectGate: Promise | undefined; readonly compactCalls: unknown[] = []; readonly commandListCalls: unknown[] = []; readonly commandInvokeCalls: Array<{ name: string; input?: string }> = []; @@ -262,7 +281,7 @@ class MockCopilotSession { this.abortCalls++; await this.abortGate; } - async setModel() { } + async setModel() { await this.modelGate; } async getEvents(): Promise { return this.messages; } async disconnect() { this.disconnectCalls++; @@ -274,6 +293,10 @@ class MockCopilotSession { } readonly rpc = { + agent: { + select: async () => { await this.agentSelectGate; }, + deselect: async () => { await this.agentDeselectGate; }, + }, sendMessages: async (request: unknown) => { this.sendMessagesRequests.push(request); if (this.sendMessagesError) { @@ -294,6 +317,19 @@ class MockCopilotSession { : { kind: 'archive' as const, path: destination.outputPath, entries: [] }; }, }, + metadata: { + setWorkingDirectory: async (params: Parameters[0]) => { + this.operationLog.push('metadata.setWorkingDirectory'); + this.workingDirectorySetCalls.push(params); + this.onWorkingDirectorySet?.(); + await this.workingDirectorySetGate; + const error = this.workingDirectorySetErrors.shift(); + if (error) { + throw error; + } + return this.workingDirectorySetResults.shift() ?? { workingDirectory: params.workingDirectory }; + }, + }, mode: { get: async () => ({ mode: 'interactive' as const }), set: async (params: { mode: 'interactive' | 'plan' | 'autopilot' }) => { @@ -436,7 +472,7 @@ class MockCopilotSession { cancelSamplingExecution: async () => { /* no-op */ }, }, options: { - update: async (params: { sandboxConfig?: unknown; isExperimentalMode?: boolean; shell?: { initScripts?: unknown } }) => { + update: async (params: Parameters[0]) => { if (params.sandboxConfig !== undefined) { this.operationLog.push('options.update:sandbox'); this.sandboxConfigUpdates.push(params.sandboxConfig); @@ -449,6 +485,16 @@ class MockCopilotSession { this.shellInitScriptUpdates.push(params.shell.initScripts); return { success: this.shellInitScriptUpdateSuccess }; } + if (params.workingDirectory !== undefined) { + this.operationLog.push('options.update:workingDirectory'); + this.workingDirectoryOptionUpdateCalls.push(params.workingDirectory); + this.onWorkingDirectoryOptionUpdate?.(); + const error = this.workingDirectoryOptionUpdateErrors.shift(); + if (error) { + throw error; + } + return { success: this.workingDirectoryOptionUpdateSuccess }; + } return { success: params.sandboxConfig !== undefined ? this.sandboxConfigUpdateSuccess : this.experimentalModeUpdateSuccess }; }, }, @@ -723,6 +769,8 @@ async function createAgentSession(disposables: DisposableStore, options?: { telemetryService?: ITelemetryService; captureRuntime?: { current?: ICopilotSessionRuntime }; workingDirectory?: URI; + customizationDirectory?: URI; + shellManager?: ShellManager; /** Per-key effective config values returned by the fake configuration service. */ configValues?: Record; /** Per-key root config values returned by the fake configuration service's `getRootValue`. */ @@ -738,6 +786,7 @@ async function createAgentSession(disposables: DisposableStore, options?: { sessionDatabase?: ISessionDatabase; /** Configure the mock session before {@link CopilotAgentSession.initializeSession} runs. */ configureMockSession?: (session: MockCopilotSession) => void; + controlPlaneRpcTimeoutMs?: number; sessionCustomizations?: () => readonly Customization[]; resolveCustomizationEnablement?: (target: ICustomizationEnablementTarget) => CustomizationEnablementResolution; initialSessionMeta?: Record; @@ -825,7 +874,7 @@ async function createAgentSession(disposables: DisposableStore, options?: { workingDirectory: options?.workingDirectory, resolvedAgentName: undefined, snapshot: options?.clientSnapshot ?? { tools: [], plugins: [], mcpServers: {} }, - shellManager: undefined, + shellManager: options?.shellManager, githubToken: options?.githubToken, isEphemeral: options?.isEphemeral, hasScopedEditSurface: options?.hasScopedEditSurface, @@ -1040,7 +1089,7 @@ async function createAgentSession(disposables: DisposableStore, options?: { onDidSessionProgress: progressEmitter, sessionLauncher, launchPlan, - shellManager: undefined, + shellManager: options?.shellManager, clientSnapshot: options?.clientSnapshot, activeClientToolSet: options?.activeClientToolSet, // The owning session's last host-published customization snapshot @@ -1048,12 +1097,14 @@ async function createAgentSession(disposables: DisposableStore, options?: { // through this accessor instead of shared host state. hostCustomizations: () => options?.sessionCustomizations?.() ?? [], workingDirectory: options?.workingDirectory, + customizationDirectory: options?.customizationDirectory, serverToolHost: options?.serverToolHost, platform: options?.platform ?? 'linux', isLaunchTokenCurrent: options?.isLaunchTokenCurrent, onTurnEnded: options?.onTurnEnded, enableDevelopmentErrorInjection: options?.enableDevelopmentErrorInjection ?? true, realpath: options?.realpath, + controlPlaneRpcTimeoutMs: options?.controlPlaneRpcTimeoutMs, }, )); @@ -1112,6 +1163,35 @@ function expectedSnapshotReadonlyNote(paths: string[]): string { const TEST_SHELL_INIT_DIRECTORY = URI.file('/mock-userdata/agentHost/shellInit/test-session-1'); const TEST_SHELL_INIT_DIR = TEST_SHELL_INIT_DIRECTORY.fsPath; +function createTestShellManager(disposables: DisposableStore, workingDirectory: URI, preflightError?: Error, setErrors: Array = []): { + readonly shellManager: ShellManager; + readonly preflightCalls: () => number; + readonly setCalls: readonly URI[]; +} { + const associationEmitter = disposables.add(new Emitter<{ toolCallId: string; terminalUri: string; displayName: string }>()); + const setCalls: URI[] = []; + let preflightCalls = 0; + const shellManager = { + onDidAssociateTerminal: associationEmitter.event, + assertCanSetWorkingDirectory: () => { + preflightCalls++; + if (preflightError) { + throw preflightError; + } + }, + setWorkingDirectory: (directory: URI) => { + const error = setErrors.shift(); + if (error) { + throw error; + } + setCalls.push(directory); + }, + get workingDirectory() { return setCalls.at(-1) ?? workingDirectory; }, + dispose: () => { }, + } as unknown as ShellManager; + return { shellManager, preflightCalls: () => preflightCalls, setCalls }; +} + suite('CopilotAgentSession', () => { const disposables = new DisposableStore(); @@ -1144,6 +1224,25 @@ suite('CopilotAgentSession', () => { }); }); + test('times out non-settling SDK control-plane RPCs', async () => { + const neverSettles = new Promise(() => { }); + const { session, mockSession } = await createAgentSession(disposables, { controlPlaneRpcTimeoutMs: 1 }); + mockSession.modelGate = neverSettles; + mockSession.agentSelectGate = neverSettles; + mockSession.agentDeselectGate = neverSettles; + + const results = await Promise.allSettled([ + session.setModel('test-model'), + session.setAgent('test-agent'), + session.setAgent(), + ]); + + assert.deepStrictEqual(results.map(result => result.status === 'rejected' ? (result.reason as Error).message : 'fulfilled'), [ + '[Copilot:test-session-1] session.setModel timed out after 1ms', + '[Copilot:test-session-1] rpc.agent.select timed out after 1ms', + '[Copilot:test-session-1] rpc.agent.deselect timed out after 1ms', + ]); + }); test('updates GitHub credentials through the SDK session RPC', async () => { const { session, mockSession } = await createAgentSession(disposables); await session.initializeSession(); @@ -1156,6 +1255,373 @@ suite('CopilotAgentSession', () => { }); }); + test('changes the SDK and shell working directory without replacing the session', async () => { + const originalWorkingDirectory = URI.file('/workspace/original'); + const nextWorkingDirectory = URI.file('/workspace/next/../next'); + const shell = createTestShellManager(disposables, originalWorkingDirectory); + const { session, mockSession } = await createAgentSession(disposables, { + workingDirectory: originalWorkingDirectory, + customizationDirectory: URI.file('/workspace/customizations'), + shellManager: shell.shellManager, + }); + const sessionId = session.sessionId; + mockSession.workingDirectorySetResults.push({ workingDirectory: '/workspace/next' }); + + await session.setWorkingDirectory(nextWorkingDirectory, noOpWorkingDirectoryChangeTransaction); + + assert.deepStrictEqual({ + sessionId: session.sessionId, + workingDirectory: session.workingDirectory?.toString(), + customizationDirectory: session.customizationDirectory?.toString(), + rpcCalls: mockSession.workingDirectorySetCalls, + runtimeOptionCalls: mockSession.workingDirectoryOptionUpdateCalls, + shellPreflightCalls: shell.preflightCalls(), + shellSetCalls: shell.setCalls.map(uri => uri.toString()), + }, { + sessionId, + workingDirectory: nextWorkingDirectory.toString(), + customizationDirectory: nextWorkingDirectory.toString(), + rpcCalls: [{ workingDirectory: nextWorkingDirectory.fsPath }], + runtimeOptionCalls: [URI.file('/workspace/next').fsPath], + shellPreflightCalls: 1, + shellSetCalls: [nextWorkingDirectory.toString()], + }); + }); + + test('leaves local working-directory state unchanged when the SDK rejects the change', async () => { + const originalWorkingDirectory = URI.file('/workspace/original'); + const customizationDirectory = URI.file('/workspace/customizations'); + const nextWorkingDirectory = URI.file('/workspace/next'); + const shell = createTestShellManager(disposables, originalWorkingDirectory); + const { session, mockSession } = await createAgentSession(disposables, { + workingDirectory: originalWorkingDirectory, + customizationDirectory, + shellManager: shell.shellManager, + }); + mockSession.workingDirectorySetErrors.push(new Error('SDK rejected cwd')); + + await assert.rejects(() => session.setWorkingDirectory(nextWorkingDirectory, noOpWorkingDirectoryChangeTransaction), /SDK rejected cwd/); + + assert.deepStrictEqual({ + workingDirectory: session.workingDirectory?.toString(), + customizationDirectory: session.customizationDirectory?.toString(), + rpcCalls: mockSession.workingDirectorySetCalls, + runtimeOptionCalls: mockSession.workingDirectoryOptionUpdateCalls, + shellSetCalls: shell.setCalls, + }, { + workingDirectory: originalWorkingDirectory.toString(), + customizationDirectory: customizationDirectory.toString(), + rpcCalls: [{ workingDirectory: nextWorkingDirectory.fsPath }], + runtimeOptionCalls: [], + shellSetCalls: [], + }); + }); + + test('rejects active-turn and shell-busy working-directory changes before the SDK RPC', async () => { + const workingDirectory = URI.file('/workspace/original'); + const nextWorkingDirectory = URI.file('/workspace/next'); + const workspaceless = await createAgentSession(disposables); + await assert.rejects(() => workspaceless.session.setWorkingDirectory(nextWorkingDirectory, noOpWorkingDirectoryChangeTransaction), /without an existing working directory/); + + const active = await createAgentSession(disposables, { workingDirectory }); + active.session.resetTurnState('turn-1'); + await assert.rejects(() => active.session.setWorkingDirectory(nextWorkingDirectory, noOpWorkingDirectoryChangeTransaction), /while a turn is active/); + + const busyShell = createTestShellManager(disposables, workingDirectory, new Error('shell is busy')); + const busy = await createAgentSession(disposables, { workingDirectory, shellManager: busyShell.shellManager }); + await assert.rejects(() => busy.session.setWorkingDirectory(nextWorkingDirectory, noOpWorkingDirectoryChangeTransaction), /shell is busy/); + + assert.deepStrictEqual({ + workspacelessRpcCalls: workspaceless.mockSession.workingDirectorySetCalls, + activeRpcCalls: active.mockSession.workingDirectorySetCalls, + busyRpcCalls: busy.mockSession.workingDirectorySetCalls, + busyPreflightCalls: busyShell.preflightCalls(), + }, { + workspacelessRpcCalls: [], + activeRpcCalls: [], + busyRpcCalls: [], + busyPreflightCalls: 1, + }); + }); + + test('reserves the idle session while a working-directory change is in flight', async () => { + const gate = new DeferredPromise(); + const workingDirectory = URI.file('/workspace/original'); + const nextWorkingDirectory = URI.file('/workspace/next'); + const { session, mockSession } = await createAgentSession(disposables, { workingDirectory }); + mockSession.workingDirectorySetGate = gate.p; + + const mutation = session.setWorkingDirectory(nextWorkingDirectory, noOpWorkingDirectoryChangeTransaction); + await assert.rejects(() => session.setWorkingDirectory(URI.file('/workspace/other'), noOpWorkingDirectoryChangeTransaction), /another working directory change is in progress/); + await assert.rejects(() => session.send('hello', undefined, 'turn-direct'), /working directory is changing/); + gate.complete(); + await mutation; + + assert.deepStrictEqual({ + rpcCalls: mockSession.workingDirectorySetCalls, + sendRequests: mockSession.sendRequests, + hasActiveTurn: session.hasActiveTurn, + }, { + rpcCalls: [{ workingDirectory: nextWorkingDirectory.fsPath }], + sendRequests: [], + hasActiveTurn: false, + }); + }); + + test('prepares before the single SDK mutation and keeps host sends reserved', async () => { + const prepareStarted = new DeferredPromise(); + const prepareGate = new DeferredPromise(); + const workingDirectory = URI.file('/workspace/original'); + const nextWorkingDirectory = URI.file('/workspace/next'); + const { session, mockSession } = await createAgentSession(disposables, { workingDirectory }); + const operationLog: string[] = []; + mockSession.onWorkingDirectorySet = () => operationLog.push('metadata.setWorkingDirectory'); + mockSession.onWorkingDirectoryOptionUpdate = () => operationLog.push('options.update:workingDirectory'); + + const mutation = session.setWorkingDirectory(nextWorkingDirectory, { + prepare: async () => { + operationLog.push('prepare'); + prepareStarted.complete(); + await prepareGate.p; + }, + rollback: async () => { operationLog.push('rollback'); }, + reconcile: async () => { operationLog.push('reconcile'); }, + }); + await prepareStarted.p; + await assert.rejects(() => session.send('hello', undefined, 'turn-direct'), /working directory is changing/); + assert.deepStrictEqual(mockSession.workingDirectorySetCalls, []); + prepareGate.complete(); + await mutation; + + assert.deepStrictEqual({ + workingDirectory: session.workingDirectory?.toString(), + operationLog, + rpcCalls: mockSession.workingDirectorySetCalls, + sendRequests: mockSession.sendRequests, + }, { + workingDirectory: nextWorkingDirectory.toString(), + operationLog: ['prepare', 'metadata.setWorkingDirectory', 'options.update:workingDirectory'], + rpcCalls: [{ workingDirectory: nextWorkingDirectory.fsPath }], + sendRequests: [], + }); + }); + + test('rolls back preparation without calling the SDK when preparation fails', async () => { + const workingDirectory = URI.file('/workspace/original'); + const nextWorkingDirectory = URI.file('/workspace/next'); + const { session, mockSession } = await createAgentSession(disposables, { workingDirectory }); + const calls: string[] = []; + + await assert.rejects(() => session.setWorkingDirectory(nextWorkingDirectory, { + prepare: async () => { calls.push('prepare'); throw new Error('prepare failed'); }, + rollback: async () => { calls.push('rollback'); }, + reconcile: async () => { calls.push('reconcile'); }, + }), /prepare failed/); + + assert.deepStrictEqual({ + calls, + rpcCalls: mockSession.workingDirectorySetCalls, + workingDirectory: session.workingDirectory?.toString(), + }, { + calls: ['prepare', 'rollback'], + rpcCalls: [], + workingDirectory: workingDirectory.toString(), + }); + }); + + test('preserves preparation and rollback failures without calling the SDK', async () => { + const workingDirectory = URI.file('/workspace/original'); + const { session, mockSession } = await createAgentSession(disposables, { workingDirectory }); + + await assert.rejects(() => session.setWorkingDirectory(URI.file('/workspace/next'), { + prepare: async () => { throw new Error('prepare failed'); }, + rollback: async () => { throw new Error('rollback failed'); }, + reconcile: async () => { }, + }), /preparation failed.*prepare failed.*failed to roll back.*rollback failed/); + + assert.deepStrictEqual(mockSession.workingDirectorySetCalls, []); + }); + + test('keeps an SDK-started turn visible and reverts before the SDK mutation', async () => { + const prepareStarted = new DeferredPromise(); + const prepareGate = new DeferredPromise(); + const workingDirectory = URI.file('/workspace/original'); + const nextWorkingDirectory = URI.file('/workspace/next'); + const { session, mockSession, signals } = await createAgentSession(disposables, { workingDirectory }); + const calls: string[] = []; + + const mutation = session.setWorkingDirectory(nextWorkingDirectory, { + prepare: async () => { + calls.push('prepare'); + prepareStarted.complete(); + await prepareGate.p; + }, + rollback: async () => { calls.push('rollback'); }, + reconcile: async () => { calls.push('reconcile'); }, + }); + await prepareStarted.p; + mockSession.fire('system.notification', { + content: '\nShell command completed\n', + kind: { type: 'shell_completed', shellId: 'shell-a', exitCode: 0, description: 'sleep 6' }, + } as SessionEventPayload<'system.notification'>['data']); + prepareGate.complete(); + await assert.rejects(mutation, /while a turn is active/); + + const turnStarted = getActions(signals).find(action => action.type === ActionType.ChatTurnStarted); + assert.ok(turnStarted); + assert.deepStrictEqual({ + calls, + currentTurnId: session.currentTurnId, + turnStartedId: turnStarted.turnId, + rpcCalls: mockSession.workingDirectorySetCalls, + workingDirectory: session.workingDirectory?.toString(), + }, { + calls: ['prepare', 'rollback'], + currentTurnId: turnStarted.turnId, + turnStartedId: turnStarted.turnId, + rpcCalls: [], + workingDirectory: workingDirectory.toString(), + }); + }); + + test('rolls back prepared provider state when the single SDK mutation rejects', async () => { + const workingDirectory = URI.file('/workspace/original'); + const nextWorkingDirectory = URI.file('/workspace/next'); + const { session, mockSession } = await createAgentSession(disposables, { workingDirectory }); + mockSession.workingDirectorySetErrors.push(new Error('SDK rejected cwd')); + const calls: string[] = []; + + await assert.rejects(() => session.setWorkingDirectory(nextWorkingDirectory, { + prepare: async () => { calls.push('prepare'); }, + rollback: async () => { calls.push('rollback'); }, + reconcile: async () => { calls.push('reconcile'); }, + }), /SDK rejected cwd/); + + assert.deepStrictEqual({ + calls, + rpcCalls: mockSession.workingDirectorySetCalls, + workingDirectory: session.workingDirectory?.toString(), + }, { + calls: ['prepare', 'rollback'], + rpcCalls: [{ workingDirectory: nextWorkingDirectory.fsPath }], + workingDirectory: workingDirectory.toString(), + }); + }); + + test('preserves SDK rejection and rollback failures', async () => { + const workingDirectory = URI.file('/workspace/original'); + const nextWorkingDirectory = URI.file('/workspace/next'); + const { session, mockSession } = await createAgentSession(disposables, { workingDirectory }); + mockSession.workingDirectorySetErrors.push(new Error('SDK rejected cwd')); + + await assert.rejects(() => session.setWorkingDirectory(nextWorkingDirectory, { + prepare: async () => { }, + rollback: async () => { throw new Error('rollback failed'); }, + reconcile: async () => { }, + }), /SDK working directory.*SDK rejected cwd.*failed to roll back.*rollback failed/); + + assert.deepStrictEqual(mockSession.workingDirectorySetCalls, [{ workingDirectory: nextWorkingDirectory.fsPath }]); + }); + + test('adopts and reconciles a mismatched authoritative SDK working directory', async () => { + const workingDirectory = URI.file('/workspace/original'); + const nextWorkingDirectory = URI.file('/workspace/next'); + const actualWorkingDirectory = URI.file('/workspace/actual'); + const shell = createTestShellManager(disposables, workingDirectory); + const { session, mockSession } = await createAgentSession(disposables, { workingDirectory, shellManager: shell.shellManager }); + mockSession.workingDirectorySetResults.push({ workingDirectory: '/workspace/actual/../actual' }); + const reconcileCalls: URI[] = []; + const reconcileStarted = new DeferredPromise(); + const reconcileGate = new DeferredPromise(); + + const mutation = session.setWorkingDirectory(nextWorkingDirectory, { + prepare: async () => { }, + rollback: async () => { }, + reconcile: async authoritativeWorkingDirectory => { + reconcileCalls.push(authoritativeWorkingDirectory); + reconcileStarted.complete(); + await reconcileGate.p; + }, + }); + await reconcileStarted.p; + await assert.rejects(() => session.send('hello', undefined, 'turn-direct'), /working directory is changing/); + reconcileGate.complete(); + await assert.rejects(mutation, /returned working directory.*actual.*instead of.*next/); + + assert.deepStrictEqual({ + workingDirectory: session.workingDirectory?.toString(), + customizationDirectory: session.customizationDirectory?.toString(), + reconcileCalls: reconcileCalls.map(uri => uri.toString()), + rpcCalls: mockSession.workingDirectorySetCalls, + runtimeOptionCalls: mockSession.workingDirectoryOptionUpdateCalls, + shellSetCalls: shell.setCalls.map(uri => uri.toString()), + sendRequests: mockSession.sendRequests, + }, { + workingDirectory: actualWorkingDirectory.toString(), + customizationDirectory: actualWorkingDirectory.toString(), + reconcileCalls: [actualWorkingDirectory.toString()], + rpcCalls: [{ workingDirectory: nextWorkingDirectory.fsPath }], + runtimeOptionCalls: [actualWorkingDirectory.fsPath], + shellSetCalls: [actualWorkingDirectory.toString()], + sendRequests: [], + }); + }); + + test('preserves mismatch, runtime, local, and reconciliation failures while adopting SDK state', async () => { + const workingDirectory = URI.file('/workspace/original'); + const nextWorkingDirectory = URI.file('/workspace/next'); + const actualWorkingDirectory = URI.file('/workspace/actual'); + const shell = createTestShellManager(disposables, workingDirectory, undefined, [new Error('shell alignment failed')]); + const { session, mockSession } = await createAgentSession(disposables, { workingDirectory, shellManager: shell.shellManager }); + mockSession.workingDirectorySetResults.push({ workingDirectory: actualWorkingDirectory.fsPath }); + mockSession.workingDirectoryOptionUpdateErrors.push(new Error('runtime alignment failed')); + + await assert.rejects(() => session.setWorkingDirectory(nextWorkingDirectory, { + prepare: async () => { }, + rollback: async () => { }, + reconcile: async () => { throw new Error('reconciliation failed'); }, + }), /returned working directory.*actual.*runtime alignment failed.*failed to align.*shell alignment failed.*failed to reconcile.*reconciliation failed/); + + assert.deepStrictEqual({ + workingDirectory: session.workingDirectory?.toString(), + customizationDirectory: session.customizationDirectory?.toString(), + rpcCalls: mockSession.workingDirectorySetCalls, + runtimeOptionCalls: mockSession.workingDirectoryOptionUpdateCalls, + }, { + workingDirectory: actualWorkingDirectory.toString(), + customizationDirectory: actualWorkingDirectory.toString(), + rpcCalls: [{ workingDirectory: nextWorkingDirectory.fsPath }], + runtimeOptionCalls: [actualWorkingDirectory.fsPath], + }); + }); + + test('reports runtime alignment failure after adopting the requested SDK working directory', async () => { + const workingDirectory = URI.file('/workspace/original'); + const nextWorkingDirectory = URI.file('/workspace/next'); + const shell = createTestShellManager(disposables, workingDirectory); + const { session, mockSession } = await createAgentSession(disposables, { workingDirectory, shellManager: shell.shellManager }); + mockSession.workingDirectoryOptionUpdateSuccess = false; + + await assert.rejects( + () => session.setWorkingDirectory(nextWorkingDirectory, noOpWorkingDirectoryChangeTransaction), + /runtime alignment failed.*SDK rejected the runtime working directory update/i + ); + + assert.deepStrictEqual({ + workingDirectory: session.workingDirectory?.toString(), + customizationDirectory: session.customizationDirectory?.toString(), + rpcCalls: mockSession.workingDirectorySetCalls, + runtimeOptionCalls: mockSession.workingDirectoryOptionUpdateCalls, + shellSetCalls: shell.setCalls.map(uri => uri.toString()), + }, { + workingDirectory: nextWorkingDirectory.toString(), + customizationDirectory: nextWorkingDirectory.toString(), + rpcCalls: [{ workingDirectory: nextWorkingDirectory.fsPath }], + runtimeOptionCalls: [nextWorkingDirectory.fsPath], + shellSetCalls: [nextWorkingDirectory.toString()], + }); + }); + test('collects SDK debug logs with process logs', async () => { const { session, mockSession } = await createAgentSession(disposables); const outputDirectory = URI.file('/tmp/agent-host-debug'); @@ -6187,6 +6653,37 @@ Use the attached image as context. }); }); + test('late aborted idle completes a running replacement turn without cancelling it', async () => { + const abortGate = new DeferredPromise(); + const { session, mockSession, signals } = await createAgentSession(disposables); + session.resetTurnState('turn-1'); + mockSession.fire('assistant.turn_start', { turnId: 'sdk-turn-1' } as SessionEventPayload<'assistant.turn_start'>['data']); + mockSession.abortGate = abortGate.p; + + const abortPromise = session.abort(); + await timeout(0); + abortGate.complete(); + await abortPromise; + session.resetTurnState('turn-2'); + mockSession.fire('assistant.turn_start', { turnId: 'sdk-turn-2' } as SessionEventPayload<'assistant.turn_start'>['data']); + mockSession.fire('assistant.message', { + messageId: 'replacement-message', + content: 'Replacement response', + toolRequests: [], + } as SessionEventPayload<'assistant.message'>['data']); + mockSession.fire('session.idle', { aborted: true } as SessionEventPayload<'session.idle'>['data']); + + assert.deepStrictEqual({ + active: session.hasActiveTurn, + abortCalls: mockSession.abortCalls, + actions: getActions(signals).filter(action => action.type === ActionType.ChatResponsePart || action.type === ActionType.ChatTurnComplete).map(action => action.type), + }, { + active: false, + abortCalls: 1, + actions: [ActionType.ChatResponsePart, ActionType.ChatTurnComplete], + }); + }); + test('quarantines late cancelled events until the next provider turn starts', async () => { const abortGate = new DeferredPromise(); const logService = new CapturingLogService(); diff --git a/src/vs/platform/agentHost/test/node/copilotGitProject.test.ts b/src/vs/platform/agentHost/test/node/copilotGitProject.test.ts index 36037d10ed78c..c91f53e04aadb 100644 --- a/src/vs/platform/agentHost/test/node/copilotGitProject.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotGitProject.test.ts @@ -32,6 +32,7 @@ class TestAgentHostGitService implements IAgentHostGitService { async removeWorktree(): Promise { } async branchExists(): Promise { return false; } async createBranch(): Promise { } + async checkout(): Promise { } async hasUncommittedChanges(): Promise { return false; } async commitAll(): Promise { } async mergeBranch(): Promise { return ''; } diff --git a/src/vs/platform/agentHost/test/node/copilotShellTools.test.ts b/src/vs/platform/agentHost/test/node/copilotShellTools.test.ts index ec64414bf30ba..8bfdee31a470b 100644 --- a/src/vs/platform/agentHost/test/node/copilotShellTools.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotShellTools.test.ts @@ -41,6 +41,7 @@ class TestAgentHostTerminalManager implements IAgentHostTerminalManager { readonly writes: { uri: string; data: string }[] = []; readonly sentTexts: { uri: string; data: string; options: ISendTextOptions }[] = []; readonly existingTerminalUris = new Set(); + readonly disposedTerminalUris: string[] = []; commandDetectionSupported = false; readonly commandFinishedListenerRegistered = new DeferredPromise(); private readonly _onCommandFinished = new Emitter(); @@ -87,7 +88,10 @@ class TestAgentHostTerminalManager implements IAgentHostTerminalManager { getClaim(): TerminalClaim | undefined { return undefined; } hasTerminal(uri: string): boolean { return this.existingTerminalUris.has(uri); } supportsCommandDetection(): boolean { return this.commandDetectionSupported; } - disposeTerminal(): void { } + disposeTerminal(uri: string): void { + this.disposedTerminalUris.push(uri); + this.existingTerminalUris.delete(uri); + } getTerminalInfos(): TerminalInfo[] { return []; } getTerminalState(): undefined { return undefined; } async getDefaultShell(): Promise { return this.defaultShell; } @@ -255,6 +259,101 @@ suite('CopilotShellTools', () => { ]); }); + test('setWorkingDirectory disposes idle shells, resets associations, and uses the new cwd', async () => { + const { instantiationService, terminalManager } = createServices(); + const initialWorkingDirectory = URI.file('/workspace/initial'); + const newWorkingDirectory = URI.file('/workspace/reanchored'); + const shellManager = disposables.add(instantiationService.createInstance(ShellManager, URI.parse('copilot:/session-1'), initialWorkingDirectory)); + const initialShell = await shellManager.getOrCreateShell('bash', TEST_CHAT_URI, 'turn-1', 'tool-1'); + terminalManager.existingTerminalUris.add(initialShell.object.terminalUri); + initialShell.dispose(); + + shellManager.setWorkingDirectory(newWorkingDirectory); + const shellCountAfterReanchor = shellManager.listShells().length; + const reanchoredShell = await shellManager.getOrCreateShell('bash', TEST_CHAT_URI, 'turn-2', 'tool-2'); + + assert.deepStrictEqual({ + workingDirectory: shellManager.workingDirectory?.toString(), + disposedTerminalUris: terminalManager.disposedTerminalUris, + oldAssociation: shellManager.getTerminalUriForToolCall('tool-1'), + shellCountAfterReanchor, + createdCwds: terminalManager.created.map(entry => entry.params.cwd), + }, { + workingDirectory: newWorkingDirectory.toString(), + disposedTerminalUris: [initialShell.object.terminalUri], + oldAssociation: undefined, + shellCountAfterReanchor: 0, + createdCwds: [initialWorkingDirectory.fsPath, newWorkingDirectory.fsPath], + }); + reanchoredShell.dispose(); + }); + + test('setWorkingDirectory rejects while a shell is busy without changing state', async () => { + const { instantiationService, terminalManager } = createServices(); + const initialWorkingDirectory = URI.file('/workspace/initial'); + const shellManager = disposables.add(instantiationService.createInstance(ShellManager, URI.parse('copilot:/session-1'), initialWorkingDirectory)); + const shell = await shellManager.getOrCreateShell('bash', TEST_CHAT_URI, 'turn-1', 'tool-1'); + terminalManager.existingTerminalUris.add(shell.object.terminalUri); + + assert.throws(() => shellManager.setWorkingDirectory(URI.file('/workspace/rejected')), /while a shell is busy/); + assert.deepStrictEqual({ + workingDirectory: shellManager.workingDirectory?.toString(), + shellIds: shellManager.listShells().map(shell => shell.id), + toolCallTerminalUri: shellManager.getTerminalUriForToolCall('tool-1'), + disposedTerminalUris: terminalManager.disposedTerminalUris, + }, { + workingDirectory: initialWorkingDirectory.toString(), + shellIds: [shell.object.id], + toolCallTerminalUri: shell.object.terminalUri, + disposedTerminalUris: [], + }); + shell.dispose(); + }); + + test('assertCanSetWorkingDirectory rejects without changing shell state', async () => { + const { instantiationService, terminalManager } = createServices(); + const initialWorkingDirectory = URI.file('/workspace/initial'); + const shellManager = disposables.add(instantiationService.createInstance(ShellManager, URI.parse('copilot:/session-1'), initialWorkingDirectory)); + const shell = await shellManager.getOrCreateShell('bash', TEST_CHAT_URI, 'turn-1', 'tool-1'); + terminalManager.existingTerminalUris.add(shell.object.terminalUri); + + assert.throws(() => shellManager.assertCanSetWorkingDirectory(), /while a shell is busy/); + assert.deepStrictEqual({ + workingDirectory: shellManager.workingDirectory?.toString(), + shellIds: shellManager.listShells().map(shell => shell.id), + toolCallTerminalUri: shellManager.getTerminalUriForToolCall('tool-1'), + disposedTerminalUris: terminalManager.disposedTerminalUris, + }, { + workingDirectory: initialWorkingDirectory.toString(), + shellIds: [shell.object.id], + toolCallTerminalUri: shell.object.terminalUri, + disposedTerminalUris: [], + }); + shell.dispose(); + }); + + test('setWorkingDirectory rejects a held shell even after its reference is released', async () => { + const { instantiationService, terminalManager } = createServices(); + const initialWorkingDirectory = URI.file('/workspace/initial'); + const shellManager = disposables.add(instantiationService.createInstance(ShellManager, URI.parse('copilot:/session-1'), initialWorkingDirectory)); + const shell = await shellManager.getOrCreateShell('bash', TEST_CHAT_URI, 'turn-1', 'tool-1'); + terminalManager.existingTerminalUris.add(shell.object.terminalUri); + shellManager.holdShellUntilCommandFinishes(shell.object); + shell.dispose(); + + assert.throws(() => shellManager.setWorkingDirectory(URI.file('/workspace/rejected')), /while a shell is busy/); + assert.deepStrictEqual({ + workingDirectory: shellManager.workingDirectory?.toString(), + toolCallTerminalUri: shellManager.getTerminalUriForToolCall('tool-1'), + disposedTerminalUris: terminalManager.disposedTerminalUris, + }, { + workingDirectory: initialWorkingDirectory.toString(), + toolCallTerminalUri: shell.object.terminalUri, + disposedTerminalUris: [], + }); + terminalManager.fireCommandFinished({ commandId: 'cmd-1', exitCode: 0, command: 'sleep 100', output: '' }); + }); + test('opts every managed shell into shell-history suppression and non-interactive mode', async () => { const { instantiationService, terminalManager } = createServices(); const shellManager = disposables.add(instantiationService.createInstance(ShellManager, URI.parse('copilot:/session-1'), undefined)); @@ -714,6 +813,39 @@ suite('CopilotShellTools', () => { assert.strictEqual(engineA, engineB, 'Sandbox engine should be cached across calls'); }); + test('setWorkingDirectory invalidates the captured sandbox engine roots', async () => { + const createdFiles = new Map(); + const initialWorkingDirectory = URI.file('/workspace/initial'); + const newWorkingDirectory = URI.file('/workspace/reanchored'); + const { instantiationService } = createServices({ sandboxEnabled: true, createdFiles }); + const shellManager = disposables.add(instantiationService.createInstance(ShellManager, URI.parse('copilot:/session-1'), initialWorkingDirectory)); + const engine = shellManager.getOrCreateSandboxEngine(); + await engine.wrapCommand('echo initial'); + const sandboxConfigPath = [...createdFiles.keys()].find(path => /vscode-sandbox-settings-.*\.json$/.test(path)); + assert.ok(sandboxConfigPath); + const initialConfig = JSON.parse(createdFiles.get(sandboxConfigPath)!); + + shellManager.setWorkingDirectory(newWorkingDirectory); + await engine.wrapCommand('echo reanchored'); + const reanchoredConfig = JSON.parse(createdFiles.get(sandboxConfigPath)!); + const initialWritablePaths: string[] = platform.isWindows ? initialConfig.filesystem.readwritePaths : initialConfig.filesystem.allowWrite; + const reanchoredWritablePaths: string[] = platform.isWindows ? reanchoredConfig.filesystem.readwritePaths : reanchoredConfig.filesystem.allowWrite; + const initialPath = platform.isWindows ? '\\workspace\\initial' : '/workspace/initial'; + const reanchoredPath = platform.isWindows ? '\\workspace\\reanchored' : '/workspace/reanchored'; + + assert.deepStrictEqual({ + enginePreserved: shellManager.getOrCreateSandboxEngine() === engine, + initialRootPresent: initialWritablePaths.includes(initialPath), + oldRootPresent: reanchoredWritablePaths.includes(initialPath), + newRootPresent: reanchoredWritablePaths.includes(reanchoredPath), + }, { + enginePreserved: true, + initialRootPresent: true, + oldRootPresent: false, + newRootPresent: true, + }); + }); + test('primary shell tool schema only exposes requestUnsandboxedExecution params when the sandbox is enabled', async () => { const enabled = createServices({ sandboxEnabled: true }); const enabledShell = disposables.add(enabled.instantiationService.createInstance(ShellManager, URI.parse('copilot:/session-enabled'), undefined)); diff --git a/src/vs/platform/agentHost/test/node/copilotToolDisplay.test.ts b/src/vs/platform/agentHost/test/node/copilotToolDisplay.test.ts index 69e5909a14c27..20d7952b92b47 100644 --- a/src/vs/platform/agentHost/test/node/copilotToolDisplay.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotToolDisplay.test.ts @@ -211,6 +211,27 @@ suite('getPermissionDisplay — read confirmation title', () => { }); }); +suite('getPermissionDisplay — server tool confirmation', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('uses the plain-language set_workspace confirmation without raw input', () => { + assert.deepStrictEqual( + getPermissionDisplay(customToolPermissionRequest('set_workspace', { + workspaceFolder: '/workspace/app', + isolation: false, + })), + { + confirmationTitle: 'Continue in app?', + invocationMessage: 'Continue this session in /workspace/app and make changes directly in that folder?', + toolInput: undefined, + permissionKind: 'custom-tool', + permissionPath: undefined, + }, + ); + }); +}); + suite('getPermissionDisplay — cd-prefix stripping', () => { ensureNoDisposablesAreLeakedInTestSuite(); diff --git a/src/vs/platform/agentHost/test/node/e2e/harness/agentHostE2ETestHarness.ts b/src/vs/platform/agentHost/test/node/e2e/harness/agentHostE2ETestHarness.ts index 3c89971d46df9..ad12e5ba70d38 100644 --- a/src/vs/platform/agentHost/test/node/e2e/harness/agentHostE2ETestHarness.ts +++ b/src/vs/platform/agentHost/test/node/e2e/harness/agentHostE2ETestHarness.ts @@ -1123,9 +1123,12 @@ export class AgentHostE2EServerLease { const client = this._client; const cleanupErrors: Error[] = []; if (client) { + // A session left unrestored after a host restart is restored on subscribe. + const restoreTimeout = getAgentHostE2ETestTimeout(10_000, 30_000); + const disposeTimeout = getAgentHostE2ETestTimeout(30_000, 90_000); for (const session of createdSessions) { try { - const state = await fetchSessionWithChat(client, session); + const state = await fetchSessionWithChat(client, session, restoreTimeout); if (state.activeTurn) { const chat = buildDefaultChatUri(session); const turnId = state.activeTurn.id; @@ -1141,14 +1144,14 @@ export class AgentHostE2EServerLease { 10_000, ); } - const root = await client.call('subscribe', { channel: ROOT_STATE_URI }); + const root = await client.call('subscribe', { channel: ROOT_STATE_URI }, restoreTimeout); const terminals = (root.snapshot!.state as RootState).terminals ?? []; for (const terminal of terminals) { if (terminal.claim.kind === TerminalClaimKind.Session && terminal.claim.session === session) { - await client.call('disposeTerminal', { channel: terminal.resource }, getAgentHostE2ETestTimeout(30_000, 90_000)); + await client.call('disposeTerminal', { channel: terminal.resource }, disposeTimeout); } } - await client.call('disposeSession', { channel: session }, getAgentHostE2ETestTimeout(30_000, 90_000)); + await client.call('disposeSession', { channel: session }, disposeTimeout); } catch (error) { cleanupErrors.push(error instanceof Error ? error : new Error(String(error))); } diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-haiku-4_5.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-haiku-4_5.prompt.md index 8d34b8f9ff8c7..224bd3aa3ca00 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-haiku-4_5.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-haiku-4_5.prompt.md @@ -771,7 +771,7 @@ }, { "name": "send_message", - "description": "Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link; a link carrying a chat id targets that specific chat. The message is delivered asynchronously — this tool does not wait for or return the reply.", + "description": "Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link; a link carrying a chat id targets that specific chat. If the target chat is busy, the message is queued and starts after the active turn completes successfully. Delivery is asynchronous — this tool does not wait for or return the reply.", "input_schema": { "type": "object", "properties": { diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_5.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_5.prompt.md index 37aae4df211bf..88d3faa90ed74 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_5.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_5.prompt.md @@ -771,7 +771,7 @@ }, { "name": "send_message", - "description": "Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link; a link carrying a chat id targets that specific chat. The message is delivered asynchronously — this tool does not wait for or return the reply.", + "description": "Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link; a link carrying a chat id targets that specific chat. If the target chat is busy, the message is queued and starts after the active turn completes successfully. Delivery is asynchronous — this tool does not wait for or return the reply.", "input_schema": { "type": "object", "properties": { diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_6.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_6.prompt.md index 3c2a7d3467547..fc7650db5b919 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_6.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_6.prompt.md @@ -771,7 +771,7 @@ }, { "name": "send_message", - "description": "Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link; a link carrying a chat id targets that specific chat. The message is delivered asynchronously — this tool does not wait for or return the reply.", + "description": "Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link; a link carrying a chat id targets that specific chat. If the target chat is busy, the message is queued and starts after the active turn completes successfully. Delivery is asynchronous — this tool does not wait for or return the reply.", "input_schema": { "type": "object", "properties": { diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_7.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_7.prompt.md index 6f19917d9e224..a8f698a3b51f4 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_7.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_7.prompt.md @@ -771,7 +771,7 @@ }, { "name": "send_message", - "description": "Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link; a link carrying a chat id targets that specific chat. The message is delivered asynchronously — this tool does not wait for or return the reply.", + "description": "Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link; a link carrying a chat id targets that specific chat. If the target chat is busy, the message is queued and starts after the active turn completes successfully. Delivery is asynchronous — this tool does not wait for or return the reply.", "input_schema": { "type": "object", "properties": { diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_8.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_8.prompt.md index fe4ddef229f64..4f6e9c308c01e 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_8.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_8.prompt.md @@ -771,7 +771,7 @@ }, { "name": "send_message", - "description": "Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link; a link carrying a chat id targets that specific chat. The message is delivered asynchronously — this tool does not wait for or return the reply.", + "description": "Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link; a link carrying a chat id targets that specific chat. If the target chat is busy, the message is queued and starts after the active turn completes successfully. Delivery is asynchronous — this tool does not wait for or return the reply.", "input_schema": { "type": "object", "properties": { diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-5.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-5.prompt.md index 9b3e859aa64ec..38fc3b56132e6 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-5.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-5.prompt.md @@ -771,7 +771,7 @@ }, { "name": "send_message", - "description": "Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link; a link carrying a chat id targets that specific chat. The message is delivered asynchronously — this tool does not wait for or return the reply.", + "description": "Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link; a link carrying a chat id targets that specific chat. If the target chat is busy, the message is queued and starts after the active turn completes successfully. Delivery is asynchronous — this tool does not wait for or return the reply.", "input_schema": { "type": "object", "properties": { diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-4_5.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-4_5.prompt.md index ce19249380cf4..3bde585137ef7 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-4_5.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-4_5.prompt.md @@ -771,7 +771,7 @@ }, { "name": "send_message", - "description": "Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link; a link carrying a chat id targets that specific chat. The message is delivered asynchronously — this tool does not wait for or return the reply.", + "description": "Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link; a link carrying a chat id targets that specific chat. If the target chat is busy, the message is queued and starts after the active turn completes successfully. Delivery is asynchronous — this tool does not wait for or return the reply.", "input_schema": { "type": "object", "properties": { diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-4_6.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-4_6.prompt.md index 032e34e9af926..2d306031176b4 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-4_6.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-4_6.prompt.md @@ -771,7 +771,7 @@ }, { "name": "send_message", - "description": "Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link; a link carrying a chat id targets that specific chat. The message is delivered asynchronously — this tool does not wait for or return the reply.", + "description": "Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link; a link carrying a chat id targets that specific chat. If the target chat is busy, the message is queued and starts after the active turn completes successfully. Delivery is asynchronous — this tool does not wait for or return the reply.", "input_schema": { "type": "object", "properties": { diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-5.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-5.prompt.md index 6b5bf42ca69b2..969abbe8fb4f5 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-5.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-5.prompt.md @@ -771,7 +771,7 @@ }, { "name": "send_message", - "description": "Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link; a link carrying a chat id targets that specific chat. The message is delivered asynchronously — this tool does not wait for or return the reply.", + "description": "Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link; a link carrying a chat id targets that specific chat. If the target chat is busy, the message is queued and starts after the active turn completes successfully. Delivery is asynchronous — this tool does not wait for or return the reply.", "input_schema": { "type": "object", "properties": { diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gemini-2_0-flash.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gemini-2_0-flash.prompt.md index 1eea02ad187db..7bec1bc62d023 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gemini-2_0-flash.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gemini-2_0-flash.prompt.md @@ -805,7 +805,7 @@ }, { "name": "send_message", - "description": "Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link; a link carrying a chat id targets that specific chat. The message is delivered asynchronously — this tool does not wait for or return the reply.", + "description": "Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link; a link carrying a chat id targets that specific chat. If the target chat is busy, the message is queued and starts after the active turn completes successfully. Delivery is asynchronous — this tool does not wait for or return the reply.", "parameters": { "type": "object", "properties": { diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5-codex.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5-codex.prompt.md index 59827e7e0394a..4589a2396b3f1 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5-codex.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5-codex.prompt.md @@ -766,7 +766,7 @@ }, { "name": "send_message", - "description": "Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link; a link carrying a chat id targets that specific chat. The message is delivered asynchronously — this tool does not wait for or return the reply.", + "description": "Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link; a link carrying a chat id targets that specific chat. If the target chat is busy, the message is queued and starts after the active turn completes successfully. Delivery is asynchronous — this tool does not wait for or return the reply.", "parameters": { "type": "object", "properties": { diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5-mini.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5-mini.prompt.md index b13a39c93b867..063a87373067e 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5-mini.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5-mini.prompt.md @@ -805,7 +805,7 @@ }, { "name": "send_message", - "description": "Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link; a link carrying a chat id targets that specific chat. The message is delivered asynchronously — this tool does not wait for or return the reply.", + "description": "Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link; a link carrying a chat id targets that specific chat. If the target chat is busy, the message is queued and starts after the active turn completes successfully. Delivery is asynchronous — this tool does not wait for or return the reply.", "parameters": { "type": "object", "properties": { diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5.prompt.md index ea898ee4fee22..180e0a9031009 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5.prompt.md @@ -805,7 +805,7 @@ }, { "name": "send_message", - "description": "Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link; a link carrying a chat id targets that specific chat. The message is delivered asynchronously — this tool does not wait for or return the reply.", + "description": "Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link; a link carrying a chat id targets that specific chat. If the target chat is busy, the message is queued and starts after the active turn completes successfully. Delivery is asynchronous — this tool does not wait for or return the reply.", "parameters": { "type": "object", "properties": { diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1-codex-mini.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1-codex-mini.prompt.md index ee2e959381887..3cf34126e2f44 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1-codex-mini.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1-codex-mini.prompt.md @@ -766,7 +766,7 @@ }, { "name": "send_message", - "description": "Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link; a link carrying a chat id targets that specific chat. The message is delivered asynchronously — this tool does not wait for or return the reply.", + "description": "Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link; a link carrying a chat id targets that specific chat. If the target chat is busy, the message is queued and starts after the active turn completes successfully. Delivery is asynchronous — this tool does not wait for or return the reply.", "parameters": { "type": "object", "properties": { diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1-codex.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1-codex.prompt.md index 80c0b6c5d6e9b..b5fb7809f3f48 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1-codex.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1-codex.prompt.md @@ -766,7 +766,7 @@ }, { "name": "send_message", - "description": "Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link; a link carrying a chat id targets that specific chat. The message is delivered asynchronously — this tool does not wait for or return the reply.", + "description": "Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link; a link carrying a chat id targets that specific chat. If the target chat is busy, the message is queued and starts after the active turn completes successfully. Delivery is asynchronous — this tool does not wait for or return the reply.", "parameters": { "type": "object", "properties": { diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1.prompt.md index 426025d22f64c..2c3011c9f5e57 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1.prompt.md @@ -805,7 +805,7 @@ }, { "name": "send_message", - "description": "Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link; a link carrying a chat id targets that specific chat. The message is delivered asynchronously — this tool does not wait for or return the reply.", + "description": "Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link; a link carrying a chat id targets that specific chat. If the target chat is busy, the message is queued and starts after the active turn completes successfully. Delivery is asynchronous — this tool does not wait for or return the reply.", "parameters": { "type": "object", "properties": { diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-luna.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-luna.prompt.md index 56cba54b3e351..75162a227fa56 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-luna.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-luna.prompt.md @@ -766,7 +766,7 @@ }, { "name": "send_message", - "description": "Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link; a link carrying a chat id targets that specific chat. The message is delivered asynchronously — this tool does not wait for or return the reply.", + "description": "Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link; a link carrying a chat id targets that specific chat. If the target chat is busy, the message is queued and starts after the active turn completes successfully. Delivery is asynchronous — this tool does not wait for or return the reply.", "parameters": { "type": "object", "properties": { diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-sol.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-sol.prompt.md index 3a7e678d0bc5a..d09c2dd4d3f13 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-sol.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-sol.prompt.md @@ -766,7 +766,7 @@ }, { "name": "send_message", - "description": "Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link; a link carrying a chat id targets that specific chat. The message is delivered asynchronously — this tool does not wait for or return the reply.", + "description": "Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link; a link carrying a chat id targets that specific chat. If the target chat is busy, the message is queued and starts after the active turn completes successfully. Delivery is asynchronous — this tool does not wait for or return the reply.", "parameters": { "type": "object", "properties": { diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-terra.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-terra.prompt.md index 1dc4aa5972edb..30f1d3b7ad491 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-terra.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-terra.prompt.md @@ -766,7 +766,7 @@ }, { "name": "send_message", - "description": "Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link; a link carrying a chat id targets that specific chat. The message is delivered asynchronously — this tool does not wait for or return the reply.", + "description": "Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link; a link carrying a chat id targets that specific chat. If the target chat is busy, the message is queued and starts after the active turn completes successfully. Delivery is asynchronous — this tool does not wait for or return the reply.", "parameters": { "type": "object", "properties": { diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/changesetSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/changesetSuite.ts index aecccd0db49db..3ab533818d623 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/changesetSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/changesetSuite.ts @@ -46,7 +46,7 @@ import { buildUncommittedChangesetUri, } from '../../../../common/changesetUri.js'; import { createRealSession, dispatchTurn, driveChatTurnToCompletion, driveTurnToCompletion, initTestGitRepo, resolveGitHubToken } from '../harness/agentHostE2ETestHarness.js'; -import { getActionEnvelope, isActionNotification } from '../../serverIntegrationTestHelpers.js'; +import { getActionEnvelope, getAgentHostE2ETestTimeout, isActionNotification } from '../../serverIntegrationTestHelpers.js'; import { conformanceTest, type IAgentHostE2ETestContext } from './e2eTestContext.js'; /** The subset of `ChangesetFile` these tests assert on. */ @@ -284,6 +284,9 @@ export function defineChangesetTests(context: IAgentHostE2ETestContext): void { return state; } + // Re-reading git state is slow on a contended CI agent. + const operationPollRetries = getAgentHostE2ETestTimeout(100, 300); + async function waitForOperation(channel: string, operationId: string): Promise { return retry(async () => { const operation = (await changesetState(channel)).operations?.find(operation => operation.id === operationId); @@ -291,7 +294,7 @@ export function defineChangesetTests(context: IAgentHostE2ETestContext): void { throw new Error(`Changeset ${channel} has not advertised idle operation ${operationId}`); } return operation; - }, 100, 100); + }, 100, operationPollRetries); } async function waitForOperationRemoved(channel: string, operationId: string): Promise { @@ -299,7 +302,7 @@ export function defineChangesetTests(context: IAgentHostE2ETestContext): void { if ((await changesetState(channel)).operations?.some(operation => operation.id === operationId)) { throw new Error(`Changeset ${channel} still advertises operation ${operationId}`); } - }, 100, 100); + }, 100, operationPollRetries); } async function invokeChangesetOperation(channel: string, operationId: string): Promise<{ @@ -1486,7 +1489,7 @@ export function defineChangesetTests(context: IAgentHostE2ETestContext): void { const workspace = createGitWorkspace(`ahp-provider-session-changeset-${config.provider}-`); const sessionUri = await createSessionIn(workspace, 'provider-session-changeset'); const peerUri = buildChatUri(sessionUri, generateUuid()); - await context.client.call('createChat', { channel: sessionUri, chat: peerUri, title: 'Changes Peer' }); + await context.client.call('createChat', { channel: sessionUri, chat: peerUri, title: 'Changes Peer' }, 30_000); await context.client.call('subscribe', { channel: peerUri }); const sessionChangeset = buildSessionChangesetUri(sessionUri); await context.client.call('subscribe', { channel: sessionChangeset }); diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/sessionPersistenceSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/sessionPersistenceSuite.ts index f789ee53d7df0..235f5e5be4fe4 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/sessionPersistenceSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/sessionPersistenceSuite.ts @@ -18,7 +18,7 @@ import { ActionType, type ChatToolCallCompleteAction } from '../../../../common/ import { buildChatUri, buildDefaultChatUri, MessageKind, ROOT_STATE_URI, SessionStatus, ToolResultContentType, type ChatState, type SessionState, type ToolResultFileEditContent } from '../../../../common/state/sessionState.js'; import { PROTOCOL_VERSION } from '../../../../common/state/protocol/version/registry.js'; import { createRealSession, driveTurnToCompletion, resolveGitHubToken } from '../harness/agentHostE2ETestHarness.js'; -import { fetchSessionWithChat, getActionEnvelope, isActionNotification } from '../../serverIntegrationTestHelpers.js'; +import { fetchSessionWithChat, getActionEnvelope, getAgentHostE2ETestTimeout, isActionNotification } from '../../serverIntegrationTestHelpers.js'; import type { IAgentHostE2ETestContext } from './e2eTestContext.js'; import { GITHUB_COPILOT_PROTECTED_RESOURCE } from '../../../../common/agent.js'; @@ -232,6 +232,12 @@ export function defineSessionPersistenceTests(context: IAgentHostE2ETestContext) restored: true, isArchived: true, }); + + await context.client.call('disposeSession', { channel: sessionUri }, getAgentHostE2ETestTimeout(30_000, 90_000)); + const trackedIndex = createdSessions.indexOf(sessionUri); + if (trackedIndex >= 0) { + createdSessions.splice(trackedIndex, 1); + } }); const peerChatPersistenceEnabled = config.supportsMultipleChats diff --git a/src/vs/platform/agentHost/test/node/mockAgent.ts b/src/vs/platform/agentHost/test/node/mockAgent.ts index b888346e968e7..fe321991e33c8 100644 --- a/src/vs/platform/agentHost/test/node/mockAgent.ts +++ b/src/vs/platform/agentHost/test/node/mockAgent.ts @@ -11,7 +11,7 @@ import { join } from '../../../../base/common/path.js'; import { URI } from '../../../../base/common/uri.js'; import { AgentHostClientType } from '../../common/agentHostClientInfo.js'; import { type ISyncedCustomization } from '../../common/agentPluginManager.js'; -import { AgentSession, type AgentChatMigrationResult, type AgentProvider, type AgentSignal, type IActiveClient, type IAgent, type IAgentActionSignal, type IAgentChatConfigCompletionsParams, type IAgentChatContext, type IAgentChatMetadata, type IAgentChats, type IAgentCreateChatOptions, type IAgentCreateChatResult, type IAgentCreateSessionConfig, type IAgentDescriptor, type IAgentDiscoveredChat, type IAgentModelInfo, type IAgentResolveChatConfigParams, type IAgentSessionMetadata, type IAgentToolPendingConfirmationSignal, resolveAgentChatContext } from '../../common/agent.js'; +import { AgentSession, type AgentChatMigrationResult, type AgentProvider, type AgentSignal, type IActiveClient, type IAgent, type IAgentActionSignal, type IAgentCapabilities, type IAgentChatConfigCompletionsParams, type IAgentChatContext, type IAgentChatMetadata, type IAgentChats, type IAgentCreateChatOptions, type IAgentCreateChatResult, type IAgentCreateSessionConfig, type IAgentDescriptor, type IAgentDiscoveredChat, type IAgentModelInfo, type IAgentResolveChatConfigParams, type IAgentSessionMetadata, type IAgentToolPendingConfirmationSignal, resolveAgentChatContext } from '../../common/agent.js'; import { buildSubagentTurnsFromHistory, buildTurnsFromHistory, type IHistoryRecord } from './historyRecordFixtures.js'; import { ProtectedResourceMetadata, ToolCallContributorKind, type AgentSelection, type MessageAttachment, type ModelSelection, type ToolDefinition } from '../../common/state/protocol/state.js'; import type { ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../common/state/protocol/commands.js'; @@ -128,7 +128,11 @@ export class MockAgent implements IAgent { /** Optional overrides applied to session metadata from listSessions. */ sessionMetadataOverrides: Partial> = {}; - constructor(readonly id: AgentProvider = 'mock') { + constructor( + readonly id: AgentProvider = 'mock', + private readonly _capabilities: IAgentCapabilities = { multipleChats: { fork: true } }, + readonly agentHostCapabilities: IAgent['agentHostCapabilities'] = { workspaceConversion: false }, + ) { queueMicrotask(() => { void this.listExternalChats().then(chats => { if (chats) { @@ -143,7 +147,11 @@ export class MockAgent implements IAgent { } getDescriptor(): IAgentDescriptor { - return { provider: this.id, displayName: `Agent ${this.id}`, description: `Test ${this.id} agent`, capabilities: { multipleChats: { fork: true } } }; + return { provider: this.id, displayName: `Agent ${this.id}`, description: `Test ${this.id} agent`, capabilities: this._capabilities }; + } + + async setWorkingDirectory(_chat: URI, _context: URI | IAgentChatContext, _workingDirectory: URI): Promise { + throw new Error(`Agent '${this.id}' does not support changing the working directory of an existing session.`); } getProtectedResources(): ProtectedResourceMetadata[] { @@ -494,6 +502,7 @@ export class ScriptedMockAgent implements IAgent { private readonly _discoveredChatsEmitter = new Emitter(); readonly onDidDiscoverChats = this._discoveredChatsEmitter.event; readonly id: AgentProvider = 'mock'; + readonly agentHostCapabilities = { workspaceConversion: false } as const; private readonly _onDidChatProgress = new Emitter(); readonly onDidChatProgress = this._onDidChatProgress.event; @@ -554,6 +563,10 @@ export class ScriptedMockAgent implements IAgent { return { provider: 'mock', displayName: 'Mock Agent', description: 'Scripted test agent' }; } + async setWorkingDirectory(_chat: URI, _context: URI | IAgentChatContext, _workingDirectory: URI): Promise { + throw new Error('The scripted mock agent does not support changing the working directory of an existing session.'); + } + getProtectedResources(): IAuthorizationProtectedResourceMetadata[] { return []; } diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index def42ccb47300..a5cf88db06497 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -17,6 +17,7 @@ import { NullTelemetryService } from '../../../telemetry/common/telemetryUtils.j import { ITelemetryService, TelemetryLevel } from '../../../telemetry/common/telemetry.js'; import { type IAgentCreateChatRequestOptions, type IAgentCreateSessionConfig, type IAgentResolveSessionConfigParams, type IAgentSessionConfigCompletionsParams, type IAgentSessionMetadata, type AuthenticateParams, type AuthenticateResult } from '../../common/agent.js'; import { type IAgentHostManagedSettingsDiagnostics, type IAgentHostNetworkDiagnosticsInfo, type IAgentHostNetworkFetchResult, type IAgentService } from '../../common/agentService.js'; +import { RequestAgentHostWorkspaceTrustExtensionMethod } from '../../common/agentHostExtensionProtocol.js'; import { ChatSourceKind, CompletionsParams, CompletionsResult, ContentEncoding, ListSessionsResult, ResourceReadResult, ResolveSessionConfigResult, SessionConfigCompletionsResult, ResourceMkdirParams, ResourceMkdirResult, ResourceResolveParams, ResourceResolveResult, ResourceCopyParams, ResourceCopyResult } from '../../common/state/protocol/commands.js'; import type { AutomationCapabilities, Implementation } from '../../common/state/protocol/common/commands.js'; import type { FetchAutomationRunsParams, FetchAutomationRunsResult, ListAutomationTriggerDefinitionsParams, ListAutomationTriggerDefinitionsResult, RunAutomationParams, RunAutomationResult } from '../../common/state/protocol/channels-automation/commands.js'; @@ -349,6 +350,25 @@ function findResponse(sent: ProtocolMessage[], id: number): ProtocolMessage | un return sent.find(message => isJsonRpcResponse(message) && message.id === id); } +function findRequest(sent: ProtocolMessage[], method: string): { readonly jsonrpc: '2.0'; readonly id: number; readonly method: string; readonly params?: unknown } | undefined { + for (const message of sent) { + if ( + hasKey(message, { id: true, method: true }) + && typeof message.id === 'number' + && typeof message.method === 'string' + && message.method === method + ) { + return { + jsonrpc: '2.0', + id: message.id, + method: message.method, + ...(hasKey(message, { params: true }) ? { params: message.params } : {}), + }; + } + } + return undefined; +} + function waitForResponse(transport: MockProtocolTransport, id: number): Promise { return Event.toPromise(Event.filter(transport.onDidSend, message => isJsonRpcResponse(message) && message.id === id)); } @@ -453,6 +473,53 @@ suite('ProtocolServerHandler', () => { }); }); + test('routes a workspace trust request to the initiating client', async () => { + const transport = connectClient('client-1'); + while (!findResponse(transport.sent, 1)) { + await Promise.resolve(); + } + + const trustPromise = clientConnections.requestWorkspaceTrust('client-1', { + workspace: 'file:///workspace/project', + }); + const reverseRequest = findRequest(transport.sent, RequestAgentHostWorkspaceTrustExtensionMethod); + if (!reverseRequest) { + assert.fail('Expected a reverse workspace trust request.'); + } + transport.simulateMessage({ + jsonrpc: '2.0', + id: reverseRequest.id, + result: { trusted: true }, + }); + + assert.deepStrictEqual({ + request: reverseRequest, + trusted: await trustPromise, + }, { + request: { + jsonrpc: '2.0', + id: reverseRequest.id, + method: RequestAgentHostWorkspaceTrustExtensionMethod, + params: { workspace: 'file:///workspace/project' }, + }, + trusted: true, + }); + }); + + test('rejects a pending workspace trust request when the client disconnects', async () => { + const transport = connectClient('client-1'); + while (!findResponse(transport.sent, 1)) { + await Promise.resolve(); + } + + const trustPromise = clientConnections.requestWorkspaceTrust('client-1', { + workspace: 'file:///workspace/project', + }); + transport.simulateClose(); + + await assert.rejects(trustPromise, /disconnected/); + }); + test('handshake advertises only implemented automation capabilities', () => { agentService.automationCapabilities = { create: {}, runCancellation: {} }; const transport = connectClient('automation-client'); diff --git a/src/vs/platform/agentHost/test/node/sandboxConfigForSdk.test.ts b/src/vs/platform/agentHost/test/node/sandboxConfigForSdk.test.ts index 16fc5fa0fd05f..829bfaf62a92a 100644 --- a/src/vs/platform/agentHost/test/node/sandboxConfigForSdk.test.ts +++ b/src/vs/platform/agentHost/test/node/sandboxConfigForSdk.test.ts @@ -66,8 +66,8 @@ function expectedSandboxConfig(options?: { addCurrentWorkingDirectory: true, allowDevToolAccess: true, auth: { - git: false, - gh: false, + git: true, + gh: true, }, userPolicy: { filesystem: { @@ -78,7 +78,7 @@ function expectedSandboxConfig(options?: { }, network: { allowOutbound: options?.allowOutbound === true, - allowLocalNetwork: true, + allowLocalNetwork: false, }, }, }; @@ -120,6 +120,12 @@ suite('buildSandboxConfigForSdk', () => { } }); + test('preserves an explicit outbound network restriction', () => { + for (const platform of ['darwin', 'linux', 'win32'] as const) { + assert.deepStrictEqual(buildSandboxConfigForSdk(platform, sandbox(platform, AgentSandboxEnabledValue.On, undefined, undefined, false)), expectedSandboxConfig({ allowOutbound: false })); + } + }); + test('maps the unsandboxed commands setting to SDK bypass', () => { assert.deepStrictEqual([ buildSandboxConfigForSdk('linux', { @@ -228,7 +234,7 @@ suite('buildSandboxConfigForSdk', () => { for (const platform of ['darwin', 'linux'] as const) { assert.deepStrictEqual(buildSandboxConfigForSdk(platform, sandbox(platform, AgentSandboxEnabledValue.On, undefined, { allowedHosts: ['github.com'], blockedHosts: ['evil.example'] }))?.userPolicy?.network, { allowOutbound: false, - allowLocalNetwork: true, + allowLocalNetwork: false, }, platform); } }); @@ -237,7 +243,7 @@ suite('buildSandboxConfigForSdk', () => { for (const platform of ['darwin', 'linux'] as const) { assert.deepStrictEqual(buildSandboxConfigForSdk(platform, sandbox(platform, AgentSandboxEnabledValue.On, undefined, { allowedHosts: ['a.example'], blockedHosts: ['b.example'] }, true))?.userPolicy?.network, { allowOutbound: true, - allowLocalNetwork: true, + allowLocalNetwork: false, }, platform); } }); @@ -245,7 +251,7 @@ suite('buildSandboxConfigForSdk', () => { test('ignores empty host lists', () => { assert.deepStrictEqual(buildSandboxConfigForSdk('linux', sandbox('linux', AgentSandboxEnabledValue.On, undefined, { allowedHosts: [], blockedHosts: [] }))?.userPolicy?.network, { allowOutbound: false, - allowLocalNetwork: true, + allowLocalNetwork: false, }); }); }); diff --git a/src/vs/platform/agentHost/test/node/serverIntegrationTestHelpers.ts b/src/vs/platform/agentHost/test/node/serverIntegrationTestHelpers.ts index 4355b3dbe4c6e..44a2fc6bb5283 100644 --- a/src/vs/platform/agentHost/test/node/serverIntegrationTestHelpers.ts +++ b/src/vs/platform/agentHost/test/node/serverIntegrationTestHelpers.ts @@ -76,10 +76,18 @@ interface IPendingCall { reject: (err: Error) => void; } +/** + * Default bound for one protocol request or notification wait. Short locally + * so a wedged host fails fast; longer on CI, where the E2E entrypoints run in + * parallel on a shared agent and contention alone can push a call past 5s. + */ function getProtocolOperationTimeout(): number { if (AGENT_HOST_E2E_COVERAGE) { return 30_000; } + if (isCI) { + return 20_000; + } return isWindows ? 8_000 : 5_000; } @@ -1057,11 +1065,11 @@ export function dispatchTurnStarted(c: TestProtocolClient, session: string, turn * requests) live on the session's default chat channel, so reading them * requires merging the session snapshot with its default chat snapshot. */ -export async function fetchSessionWithChat(c: TestProtocolClient, sessionUri: string): Promise { +export async function fetchSessionWithChat(c: TestProtocolClient, sessionUri: string, timeoutMs?: number): Promise { const owningSession = parseDefaultChatUri(sessionUri) ?? sessionUri; const chatUri = parseDefaultChatUri(sessionUri) ? sessionUri : buildDefaultChatUri(sessionUri); - const sessionSnap = await c.call('subscribe', { channel: owningSession }); - const chatSnap = await c.call('subscribe', { channel: chatUri }); + const sessionSnap = await c.call('subscribe', { channel: owningSession }, timeoutMs); + const chatSnap = await c.call('subscribe', { channel: chatUri }, timeoutMs); return mergeSessionWithDefaultChat( sessionSnap.snapshot!.state as SessionState, chatSnap.snapshot?.state as ChatState | undefined, diff --git a/src/vs/platform/agentHost/test/node/serverToolGroups.test.ts b/src/vs/platform/agentHost/test/node/serverToolGroups.test.ts index 563b1573bcebb..9937e8af939c0 100644 --- a/src/vs/platform/agentHost/test/node/serverToolGroups.test.ts +++ b/src/vs/platform/agentHost/test/node/serverToolGroups.test.ts @@ -69,6 +69,33 @@ suite('serverToolGroups display', () => { }); }); + test('set_workspace resolves to outcome-oriented confirmation display', () => { + const confirmation = (isolation: boolean) => { + const display = getServerToolDisplay('set_workspace', { workspaceFolder: '/workspace/app', isolation }); + return { + title: display?.confirmationTitle, + message: text(display?.confirmationMessage), + hideInput: display?.hideConfirmationInput, + }; + }; + + assert.deepStrictEqual({ + direct: confirmation(false), + isolated: confirmation(true), + }, { + direct: { + title: 'Continue in app?', + message: 'Continue this session in /workspace/app and make changes directly in that folder?', + hideInput: true, + }, + isolated: { + title: 'Continue in app?', + message: 'Continue this session in /workspace/app with changes isolated from the existing folder?', + hideInput: true, + }, + }); + }); + test('fast tools omit a duplicate completion message', () => { const past = (resultText?: string) => text(getServerToolDisplay('listComments', undefined, { text: resultText, success: true })?.pastTenseMessage); diff --git a/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts b/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts index 1bec533c4334c..c1313b937fb88 100644 --- a/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts +++ b/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts @@ -763,6 +763,27 @@ suite('SessionDatabase', () => { assert.strictEqual(await db.getMetadata('customTitle'), 'Second'); }); + test('deleteMetadata removes only the requested keys', async () => { + db = disposables.add(await SessionDatabase.open(':memory:')); + await db.setMetadataValues({ + customTitle: 'Title', + customTitleSource: 'user', + unrelated: 'preserved', + }); + + await db.deleteMetadata(['customTitle', 'customTitleSource']); + + assert.deepStrictEqual(await db.getMetadataObject({ + customTitle: true, + customTitleSource: true, + unrelated: true, + }), { + customTitle: undefined, + customTitleSource: undefined, + unrelated: 'preserved', + }); + }); + test('setMetadataValues rolls back every key when one write fails', async () => { const database = disposables.add(await TestableSessionDatabase.open(':memory:')); db = database; diff --git a/src/vs/platform/agentHost/test/node/sessionServerTools.test.ts b/src/vs/platform/agentHost/test/node/sessionServerTools.test.ts index f4e3969ea7782..327df814b4126 100644 --- a/src/vs/platform/agentHost/test/node/sessionServerTools.test.ts +++ b/src/vs/platform/agentHost/test/node/sessionServerTools.test.ts @@ -12,15 +12,17 @@ import { NullLogService } from '../../../log/common/log.js'; import type { IAgentCreateSessionConfig, IAgentModelInfo, IAgentSessionMetadata } from '../../common/agent.js'; import { SessionStatus } from '../../common/state/protocol/channels-session/state.js'; import { ActionType } from '../../common/state/sessionActions.js'; -import { buildChatUri, buildDefaultChatUri, MessageKind, readSessionCreationReference, ResponsePartKind, ToolCallConfirmationReason, ToolCallStatus, TurnState, withSessionGitState, withSessionGitHubState, type ModelSelection, type ResponsePart, type ToolCallState, type Turn } from '../../common/state/sessionState.js'; +import { buildChatUri, buildDefaultChatUri, MessageKind, PendingMessageKind, readSessionCreationReference, ResponsePartKind, ToolCallConfirmationReason, ToolCallStatus, TurnState, withSessionGitState, withSessionGitHubState, type ModelSelection, type ResponsePart, type ToolCallState, type Turn } from '../../common/state/sessionState.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; import { SessionServerToolName } from '../../common/serverToolNames.js'; import { withEphemeralSessionMeta } from '../../common/meta/agentEphemeralSessionMeta.js'; +import { readAgentMessageDelegationMeta } from '../../common/meta/agentMessageDelegationMeta.js'; import { AgentServerToolHost, type IServerToolGroup } from '../../node/shared/agentServerToolHost.js'; import { applyCreateChatTool, applyCreateSessionTool, + applySetWorkspaceTool, applyDeleteSessionTool, applyRenameChatTool, applySendMessageTool, @@ -29,6 +31,7 @@ import { getCreateChatArgs, getCreateSessionArgs, getDeleteSessionArgs, + getSetWorkspaceArgs, getRenameChatArgs, getSendMessageArgs, getSessionContextArgs, @@ -62,6 +65,7 @@ suite('SessionServerTools', () => { const depths = overrides?.depths ?? new Map(); return { isActiveAgentTitleGenerationEnabled: overrides?.isActiveAgentTitleGenerationEnabled ?? (() => true), + canConvertWorkspace: overrides?.canConvertWorkspace ?? (() => true), listSessions: overrides?.listSessions ?? (async () => [sessionMeta('s1', SessionStatus.InProgress, workspace)]), getSession: overrides?.getSession ?? (async session => session.toString() === 'copilot:/s1' ? sessionMeta('s1', SessionStatus.InProgress, workspace) : undefined), createSession: overrides?.createSession ?? (async config => { overrides?.onCreate?.(config); return URI.parse('copilot:/new'); }), @@ -75,6 +79,7 @@ suite('SessionServerTools', () => { getChatContext: overrides?.getChatContext ?? (async () => undefined), getSessionSpawnDepth: overrides?.getSessionSpawnDepth ?? (session => depths.get(session.toString()) ?? 0), setSessionSpawnDepth: overrides?.setSessionSpawnDepth ?? ((session, depth) => { depths.set(session.toString(), depth); }), + requestSessionWorkspaceUpdate: overrides?.requestSessionWorkspaceUpdate ?? (() => { }), }; } @@ -91,11 +96,13 @@ suite('SessionServerTools', () => { } test('definitions and confirmation', () => { - assert.deepStrictEqual(sessionServerToolDefinitions.map(d => d.name), [SessionServerToolName.ListSessions, SessionServerToolName.GetCurrentSession, SessionServerToolName.CreateSession, SessionServerToolName.RenameChat, SessionServerToolName.SendMessage, SessionServerToolName.GetSessionContext, SessionServerToolName.DeleteSession]); + assert.deepStrictEqual(sessionServerToolDefinitions.map(d => d.name), [SessionServerToolName.ListSessions, SessionServerToolName.GetCurrentSession, SessionServerToolName.SetWorkspace, SessionServerToolName.CreateSession, SessionServerToolName.RenameChat, SessionServerToolName.SendMessage, SessionServerToolName.GetSessionContext, SessionServerToolName.DeleteSession]); assert.match(sessionServerToolDefinitions.find(definition => definition.name === SessionServerToolName.ListSessions)?.description ?? '', /`openLink` for clickable Markdown links/); + assert.match(sessionServerToolDefinitions.find(definition => definition.name === SessionServerToolName.SendMessage)?.description ?? '', /target chat is busy.*message is queued/); assert.deepStrictEqual(sessionServerToolDefinitions.filter(definition => definition.enabledForEphemeralSessions).map(definition => definition.name), []); assert.strictEqual(sessionToolRequiresConfirmation(SessionServerToolName.CreateSession), true); assert.strictEqual(sessionToolRequiresConfirmation(SessionServerToolName.CreateChat), true); + assert.strictEqual(sessionToolRequiresConfirmation(SessionServerToolName.SetWorkspace), true); assert.strictEqual(sessionToolRequiresConfirmation(SessionServerToolName.SendMessage), true); assert.strictEqual(sessionToolRequiresConfirmation(SessionServerToolName.DeleteSession), true); assert.strictEqual(sessionToolRequiresConfirmation(SessionServerToolName.RenameChat), false); @@ -117,6 +124,29 @@ suite('SessionServerTools', () => { }, required: ['relationship', 'prompt', 'title'], }); + const setWorkspaceDefinition = sessionServerToolDefinitions.find(def => def.name === SessionServerToolName.SetWorkspace); + assert.deepStrictEqual({ + title: setWorkspaceDefinition?.title, + description: setWorkspaceDefinition?.description, + inputSchema: setWorkspaceDefinition?.inputSchema, + }, { + title: 'Set Workspace', + description: 'Set the current session\'s workspace when the task should continue in a workspace not yet attached to this session. This preserves the session, chat, and conversation history. Immediately before every call to this tool, always use the available user-input tool to ask the user to confirm both the workspace and whether the work should be isolated, even if the user previously mentioned or requested those choices. Tool approval is separate and does not replace this confirmation. Set `isolation` to true to create a managed Git worktree, or false to work directly in the folder. The workspace change is deferred until the current turn ends, then the host automatically continues the original task in the selected workspace. Make this the final tool call of the turn.', + inputSchema: { + type: 'object', + properties: { + workspaceFolder: { + type: 'string', + description: 'Absolute local folder path or file URI to set as the current session\'s workspace. Use an exact path from the user or `list_sessions`; do not guess.', + }, + isolation: { + type: 'boolean', + description: 'Whether to create an isolated Git worktree and use it as the workspace. Include this choice in the required user confirmation immediately before calling this tool.', + }, + }, + required: ['workspaceFolder', 'isolation'], + }, + }); assert.strictEqual(sessionServerToolDefinitions.find(def => def.name === SessionServerToolName.ListSessions)?.inputSchema?.properties?.label, undefined); const renameDefinition = sessionServerToolDefinitions.find(def => def.name === SessionServerToolName.RenameChat); assert.deepStrictEqual([{ name: renameDefinition?.name, required: renameDefinition?.inputSchema?.required }], [ @@ -130,6 +160,48 @@ suite('SessionServerTools', () => { assert.ok(renameDescription?.includes('peer-chat titles remain independent')); }); + test('set_workspace accepts only exact local workspace folders', () => { + assert.deepStrictEqual({ + absolutePath: getSetWorkspaceArgs({ workspaceFolder: '/workspace/app', isolation: false }), + fileUri: getSetWorkspaceArgs({ workspaceFolder: 'file:///workspace/other', isolation: true }), + }, { + absolutePath: { workspaceFolder: URI.parse('file:///workspace/app'), isolation: false }, + fileUri: { workspaceFolder: URI.parse('file:///workspace/other'), isolation: true }, + }); + assert.throws(() => getSetWorkspaceArgs({ workspaceFolder: 'workspace/app', isolation: false }), /absolute local path or file URI/); + assert.throws(() => getSetWorkspaceArgs({ workspaceFolder: 'vscode-remote://ssh-remote+host/workspace/app', isolation: false }), /absolute local path or file URI/); + assert.throws(() => getSetWorkspaceArgs({ workspaceFolder: '/workspace/app' }), /isolation must be a boolean/); + }); + + test('set_workspace requests an update for the current chat and active turn', () => { + const requests: { chat: string; turnId: string; workspaceFolder: string; isolation: boolean }[] = []; + const chat = URI.parse(buildDefaultChatUri('copilot:/s1')); + const accessor = createAccessor({ + requestSessionWorkspaceUpdate: (targetChat, turnId, workspaceFolder, isolation) => requests.push({ + chat: targetChat.toString(), + turnId, + workspaceFolder: workspaceFolder.toString(), + isolation, + }), + }); + + const result = applySetWorkspaceTool(accessor, { workspaceFolder: '/workspace/app', isolation: true }, chat, 'turn-1'); + + assert.deepStrictEqual({ + requests, + result, + }, { + requests: [{ + chat: chat.toString(), + turnId: 'turn-1', + workspaceFolder: 'file:///workspace/app', + isolation: true, + }], + result: 'An isolated worktree will be created from file:///workspace/app and set as the workspace after this turn ends. End this turn now without calling more tools or replying; the host will continue the original task automatically in the isolated workspace.', + }); + assert.throws(() => applySetWorkspaceTool(accessor, { workspaceFolder: '/workspace/app', isolation: false }, chat, undefined), /must run from an active chat turn/); + }); + test('ephemeral sessions advertise no default session-management tools', () => { const stateManager = new AgentHostStateManager(new NullLogService()); const session = 'copilot:/ephemeral'; @@ -197,6 +269,7 @@ suite('SessionServerTools', () => { disabledTools: [ SessionServerToolName.ListSessions, SessionServerToolName.GetCurrentSession, + SessionServerToolName.SetWorkspace, SessionServerToolName.CreateSession, SessionServerToolName.SendMessage, SessionServerToolName.GetSessionContext, @@ -234,6 +307,70 @@ suite('SessionServerTools', () => { stateManager.dispose(); }); + test('set_workspace is not advertised or executable when the provider cannot change working directory', async () => { + const stateManager = new AgentHostStateManager(new NullLogService()); + const session = 'claude:/s1'; + stateManager.createSession({ + resource: session, + provider: 'claude', + title: 'Session', + status: SessionStatus.Idle, + createdAt: new Date(0).toISOString(), + modifiedAt: new Date(0).toISOString(), + }); + const host = new AgentServerToolHost(stateManager, [ + createSessionServerToolGroup(createAccessor({ canConvertWorkspace: () => false })), + ]); + + host.advertise(session); + const advertisedTools = stateManager.getSessionState(session)?.serverTools?.map(tool => tool.name); + stateManager.dispatchServerAction(session, { + type: ActionType.SessionServerToolsChanged, + tools: sessionServerToolDefinitions, + }); + + await assert.rejects( + async () => host.executeTool(buildDefaultChatUri(session), SessionServerToolName.SetWorkspace, { workspaceFolder: '/workspace', isolation: false }), + /Server tool "set_workspace" is disabled/, + ); + assert.deepStrictEqual({ + advertisedTools, + restoredTools: host.getDefinitionsForSession(session).map(tool => tool.name), + }, { + advertisedTools: sessionServerToolDefinitions.filter(tool => tool.name !== SessionServerToolName.SetWorkspace).map(tool => tool.name), + restoredTools: sessionServerToolDefinitions.filter(tool => tool.name !== SessionServerToolName.SetWorkspace).map(tool => tool.name), + }); + stateManager.dispose(); + }); + + test('set_workspace is removed when the session can no longer be converted', async () => { + const stateManager = new AgentHostStateManager(new NullLogService()); + const session = 'copilot:/s1'; + stateManager.createSession({ + resource: session, + provider: 'copilot', + title: 'Session', + status: SessionStatus.Idle, + createdAt: new Date(0).toISOString(), + modifiedAt: new Date(0).toISOString(), + }); + let canConvertWorkspace = true; + const host = new AgentServerToolHost(stateManager, [ + createSessionServerToolGroup(createAccessor({ canConvertWorkspace: () => canConvertWorkspace })), + ]); + + host.advertise(session); + canConvertWorkspace = false; + host.advertise(session); + + await assert.rejects( + async () => host.executeTool(buildDefaultChatUri(session), SessionServerToolName.SetWorkspace, { workspaceFolder: '/workspace', isolation: false }), + /Server tool "set_workspace" is disabled/, + ); + assert.ok(!stateManager.getSessionState(session)?.serverTools?.some(tool => tool.name === SessionServerToolName.SetWorkspace)); + stateManager.dispose(); + }); + test('re-advertise updates dynamic groups while materialized session tools stay fixed', () => { let sessionToolsEnabled = false; let dynamicToolsEnabled = false; @@ -250,6 +387,7 @@ suite('SessionServerTools', () => { const dynamicGroup: IServerToolGroup = { definitions: [{ name: 'dynamic_tool', description: 'Dynamic tool.', inputSchema: { type: 'object', properties: {} } }], isEnabled: () => dynamicToolsEnabled, + isEnabledForSession: () => true, execute: () => '', }; const host = new AgentServerToolHost(stateManager, [ @@ -272,6 +410,7 @@ suite('SessionServerTools', () => { enabledTools: [ SessionServerToolName.ListSessions, SessionServerToolName.GetCurrentSession, + SessionServerToolName.SetWorkspace, SessionServerToolName.CreateSession, SessionServerToolName.SendMessage, SessionServerToolName.GetSessionContext, @@ -281,6 +420,7 @@ suite('SessionServerTools', () => { disabledTools: [ SessionServerToolName.ListSessions, SessionServerToolName.GetCurrentSession, + SessionServerToolName.SetWorkspace, SessionServerToolName.CreateSession, SessionServerToolName.SendMessage, SessionServerToolName.GetSessionContext, @@ -1426,6 +1566,138 @@ suite('SessionServerTools', () => { assert.throws(() => getSendMessageArgs({ session: 'copilot:/s2' }, []), /message/); }); + test('send_message queues agent-originated messages in FIFO order while the target chat is busy', async () => { + const store = new DisposableStore(); + const stateManager = store.add(new AgentHostStateManager(new NullLogService())); + const targetSession = 'copilot:/s2'; + const targetChat = buildDefaultChatUri(targetSession); + stateManager.createSession({ + resource: targetSession, + provider: 'copilot', + title: 'Target', + status: SessionStatus.InProgress, + createdAt: new Date(0).toISOString(), + modifiedAt: new Date(0).toISOString(), + }); + stateManager.dispatchServerAction(targetChat, { + type: ActionType.ChatTurnStarted, + turnId: 'active-turn', + startedAt: new Date(0).toISOString(), + message: { text: 'running', origin: { kind: MessageKind.User } }, + }); + const prompts: string[] = []; + const group = createSessionServerToolGroup(createAccessor({ + listSessions: async () => [sessionMeta('s1', SessionStatus.InProgress, workspace), sessionMeta('s2', SessionStatus.InProgress, workspace)], + onPrompt: (_session, _chat, prompt) => { prompts.push(prompt); }, + })); + const context = executionContext('copilot:/s1'); + + const firstResult = await group.execute(stateManager, context, SessionServerToolName.SendMessage, { session: targetSession, message: 'first' }); + const secondResult = await group.execute(stateManager, context, SessionServerToolName.SendMessage, { session: targetSession, message: 'second' }); + + const targetState = stateManager.getChatState(targetChat); + assert.deepStrictEqual({ + results: [firstResult, secondResult], + activeTurn: targetState?.activeTurn?.id, + queuedMessages: targetState?.queuedMessages?.map(queued => ({ + text: queued.message.text, + origin: queued.message.origin, + delegation: readAgentMessageDelegationMeta(queued.message), + })), + prompts, + }, { + results: [ + 'Message queued (agent-host-session://copilot/s2).', + 'Message queued (agent-host-session://copilot/s2).', + ], + activeTurn: 'active-turn', + queuedMessages: [ + { + text: 'first', + origin: { kind: MessageKind.Agent }, + delegation: { + sourceSession: 'copilot:/s1', + sourceChat: buildDefaultChatUri('copilot:/s1'), + sourceTurnId: 'turn-1', + }, + }, + { + text: 'second', + origin: { kind: MessageKind.Agent }, + delegation: { + sourceSession: 'copilot:/s1', + sourceChat: buildDefaultChatUri('copilot:/s1'), + sourceTurnId: 'turn-1', + }, + }, + ], + prompts: [], + }); + store.dispose(); + }); + + test('send_message queues behind pending messages when the target chat has no active turn', async () => { + const store = new DisposableStore(); + const stateManager = store.add(new AgentHostStateManager(new NullLogService())); + const queuedSession = 'copilot:/s2'; + const steeringSession = 'copilot:/s3'; + for (const resource of [queuedSession, steeringSession]) { + stateManager.createSession({ + resource, + provider: 'copilot', + title: 'Target', + status: SessionStatus.Idle, + createdAt: new Date(0).toISOString(), + modifiedAt: new Date(0).toISOString(), + }); + } + const queuedChat = buildDefaultChatUri(queuedSession); + stateManager.dispatchServerAction(queuedChat, { + type: ActionType.ChatPendingMessageSet, + kind: PendingMessageKind.Queued, + id: 'older-message', + message: { text: 'older', origin: { kind: MessageKind.User } }, + }); + const steeringChat = buildDefaultChatUri(steeringSession); + stateManager.dispatchServerAction(steeringChat, { + type: ActionType.ChatPendingMessageSet, + kind: PendingMessageKind.Steering, + id: 'steering-message', + message: { text: 'steering', origin: { kind: MessageKind.User } }, + }); + const prompts: string[] = []; + const group = createSessionServerToolGroup(createAccessor({ + listSessions: async () => [ + sessionMeta('s1', SessionStatus.InProgress, workspace), + sessionMeta('s2', SessionStatus.Idle, workspace), + sessionMeta('s3', SessionStatus.Idle, workspace), + ], + onPrompt: (_session, _chat, prompt) => { prompts.push(prompt); }, + })); + const context = executionContext('copilot:/s1'); + + const queuedResult = await group.execute(stateManager, context, SessionServerToolName.SendMessage, { session: queuedSession, message: 'after queued' }); + const steeringResult = await group.execute(stateManager, context, SessionServerToolName.SendMessage, { session: steeringSession, message: 'after steering' }); + + assert.deepStrictEqual({ + results: [queuedResult, steeringResult], + queuedMessages: stateManager.getChatState(queuedChat)?.queuedMessages?.map(message => message.message.text), + steeringQueuedMessages: stateManager.getChatState(steeringChat)?.queuedMessages?.map(message => message.message.text), + steeringMessage: stateManager.getChatState(steeringChat)?.steeringMessage?.message.text, + prompts, + }, { + results: [ + 'Message queued (agent-host-session://copilot/s2).', + 'Message queued (agent-host-session://copilot/s3).', + ], + queuedMessages: ['older', 'after queued'], + steeringQueuedMessages: ['after steering'], + steeringMessage: 'steering', + prompts: [], + }); + store.dispose(); + }); + suite('get_session_context', () => { const toolCall = (toolName: string, input: object): ToolCallState => ({ toolCallId: 't', toolName, displayName: toolName, diff --git a/src/vs/platform/agentHost/test/node/sessionWorkspaceConversion.test.ts b/src/vs/platform/agentHost/test/node/sessionWorkspaceConversion.test.ts new file mode 100644 index 0000000000000..43caf85bbeaf1 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/sessionWorkspaceConversion.test.ts @@ -0,0 +1,974 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { DeferredPromise, timeout } from '../../../../base/common/async.js'; +import { URI } from '../../../../base/common/uri.js'; +import { mock } from '../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { NullLogService } from '../../../log/common/log.js'; +import { AgentWorkingDirectoryChangedError, type IAgent } from '../../common/agent.js'; +import { schemaProperty } from '../../common/agentHostSchema.js'; +import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; +import { ActionType } from '../../common/state/sessionActions.js'; +import { AH_META_WORKSPACE_CONVERSION_QUARANTINED_DB_KEY, AH_META_WORKSPACELESS_DB_KEY, buildDefaultChatUri, createErrorResponsePart, customizationId, CustomizationLoadStatus, CustomizationType, isMessageHiddenFromTranscript, MessageKind, readMessageSystemInitiatedLabel, readSessionWorkspaceless, ResponsePartKind, SessionStatus, withSessionWorkspaceless, type ErrorInfo, type Message } from '../../common/state/sessionState.js'; +import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; +import type { IAgentHostClientConnectionService } from '../../node/agentHostClientConnectionService.js'; +import type { IAgentHostTurnService, IDeferredAgentHostTurn } from '../../node/agentHostTurnService.js'; +import { SessionWorkspaceConversionService } from '../../node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionService.js'; +import type { IAgentHostServerToolService } from '../../node/shared/agentServerToolHost.js'; +import { NullAgentHostWorktreeIsolation, type IIsolationConfigContribution, type IResolveIsolationConfigRequest, type IResolveWorkingDirectoryRequest, type ISessionWorktree } from '../../node/shared/worktreeIsolation.js'; +import { createSessionDataService, TestSessionDatabase } from '../common/sessionTestHelpers.js'; +import { MockAgent } from './mockAgent.js'; +import { createTestAgentHostProviderService } from './testAgentHostProviderService.js'; + +class TestWorktreeIsolation extends NullAgentHostWorktreeIsolation { + override readonly supported = true; + readonly requests: IResolveWorkingDirectoryRequest[] = []; + readonly createdWorktrees: URI[] = []; + readonly removedWorktrees: ISessionWorktree[] = []; + + constructor(readonly worktree: URI, readonly repository = URI.file('/workspace/project')) { + super(); + } + + override async resolveIsolationConfig(_request: IResolveIsolationConfigRequest): Promise { + return { + isolationProperty: schemaProperty<'folder' | 'worktree'>({ + type: 'string', + title: 'Isolation', + description: 'Isolation', + enum: ['folder', 'worktree'], + default: 'worktree', + }), + branchProperty: schemaProperty({ + type: 'string', + title: 'Branch', + description: 'Branch', + default: 'main', + }), + worktreeBranchPrefixProperty: undefined, + worktreeIncludeFilesProperty: undefined, + worktreeBranchTrackProperty: undefined, + worktreeCreateNewBranchProperty: undefined, + isolationValue: 'worktree', + branchDefault: 'main', + branchValue: 'main', + }; + } + + override async resolveOnFirstSend(request: IResolveWorkingDirectoryRequest): Promise { + this.requests.push(request); + await request.onWillCreate?.({ + repositoryRoot: this.repository, + worktreePath: this.worktree, + baseBranch: 'main', + branchName: 'feature', + }); + this.createdWorktrees.push(this.worktree); + return this.worktree; + } + + override sessionWorktreeProject(_sessionId: string): { uri: URI; displayName: string } { + return { uri: this.repository, displayName: 'project' }; + } + + override async prepareSessionDeletion(_sessionUri: URI, _sessionId: string): Promise { + return { repositoryRoot: this.repository, worktree: this.worktree }; + } + + override async removeSessionWorktree(_sessionId: string, worktree: ISessionWorktree | undefined): Promise { + if (worktree) { + this.removedWorktrees.push(worktree); + } + } + + override async discardSessionWorktree(_sessionUri: URI, sessionId: string, worktree: ISessionWorktree | undefined): Promise { + await this.removeSessionWorktree(sessionId, worktree); + } +} + +class GatedConversionDatabase extends TestSessionDatabase { + readonly writeStarted = new DeferredPromise(); + readonly releaseWrite = new DeferredPromise(); + + override async setMetadataValues(values: Readonly>): Promise { + this.writeStarted.complete(); + await this.releaseWrite.p; + await super.setMetadataValues(values); + } +} + +suite('SessionWorkspaceConversionService', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + function createHarness( + worktreeIsolation = new NullAgentHostWorktreeIsolation(), + requestWorkspaceTrust: IAgentHostClientConnectionService['requestWorkspaceTrust'] = async () => true, + database = new TestSessionDatabase(), + ) { + const logService = new NullLogService(); + const stateManager = disposables.add(new AgentHostStateManager(logService)); + const sessionDataService = createSessionDataService(database); + const agent = new MockAgent('copilot', { multipleChats: { fork: true } }, { workspaceConversion: true }); + disposables.add({ dispose: () => agent.dispose() }); + const providerService = createTestAgentHostProviderService(() => agent); + const trustRequests: { clientId: string; workspace: string; trustedParent?: string }[] = []; + const clientConnections = new class extends mock() { + override async requestWorkspaceTrust(clientId: string, request: { readonly workspace: string; readonly trustedParent?: string }): Promise { + trustRequests.push({ clientId, ...request }); + return requestWorkspaceTrust(clientId, request); + } + }(); + const continuations: { chat: string; message: Message }[] = []; + const deferredContinuations: { chat: string; message: Message; turnId: string }[] = []; + const failedContinuations: { chat: string; error: ErrorInfo; turnId: string }[] = []; + let deferredTurnCounter = 0; + const turnService = new class extends mock() { + override beginDeferredTurnMessage(targetChat: URI, message: Message): IDeferredAgentHostTurn { + const turnId = `continuation-${++deferredTurnCounter}`; + stateManager.dispatchServerAction(targetChat.toString(), { + type: ActionType.ChatTurnStarted, + turnId, + startedAt: new Date(2).toISOString(), + message, + }); + deferredContinuations.push({ chat: targetChat.toString(), message, turnId }); + return { turnId }; + } + + override continueDeferredTurnMessage(targetChat: URI, turn: IDeferredAgentHostTurn, message: Message): boolean { + if (stateManager.getActiveTurnId(targetChat.toString()) !== turn.turnId) { + return false; + } + continuations.push({ chat: targetChat.toString(), message }); + return true; + } + + override failDeferredTurnMessage(targetChat: URI, turn: IDeferredAgentHostTurn, error: ErrorInfo): boolean { + if (stateManager.getActiveTurnId(targetChat.toString()) !== turn.turnId) { + return false; + } + failedContinuations.push({ chat: targetChat.toString(), error, turnId: turn.turnId }); + stateManager.dispatchServerAction(targetChat.toString(), { + type: ActionType.ChatError, + turnId: turn.turnId, + duration: 1, + part: createErrorResponsePart(error), + }); + return true; + } + }(); + const refreshedServerTools: string[] = []; + const serverToolHost = new class extends mock() { + override advertise(targetSession: string): void { + refreshedServerTools.push(targetSession); + } + }(); + const service = disposables.add(new SessionWorkspaceConversionService(stateManager, providerService, sessionDataService, worktreeIsolation, clientConnections, turnService, serverToolHost, logService)); + const session = URI.parse('copilot:/workspace-less'); + const chat = URI.parse(buildDefaultChatUri(session)); + const scratch = URI.file('/tmp/copilot-scratch/workspace-less'); + stateManager.createSession({ + resource: session.toString(), + provider: 'copilot', + title: 'Workspace-less Session', + status: SessionStatus.Idle, + createdAt: new Date(0).toISOString(), + modifiedAt: new Date(0).toISOString(), + workingDirectories: [scratch.toString()], + _meta: withSessionWorkspaceless(undefined, true), + }); + return { service, stateManager, database, agent, session, chat, scratch, continuations, deferredContinuations, failedContinuations, trustRequests, refreshedServerTools }; + } + + function startTurn(stateManager: AgentHostStateManager, chat: URI, turnId = 'turn-1'): void { + stateManager.dispatchServerAction(chat.toString(), { + type: ActionType.ChatTurnStarted, + turnId, + startedAt: new Date(1).toISOString(), + message: { text: 'Implement the feature', origin: { kind: MessageKind.User } }, + }); + } + + function completeTurn(stateManager: AgentHostStateManager, chat: URI, turnId = 'turn-1'): void { + stateManager.dispatchServerAction(chat.toString(), { + type: ActionType.ChatTurnComplete, + turnId, + duration: 1, + }); + } + + function updateSessionWorkspace(harness: ReturnType): Promise { + return harness.service.updateSessionWorkspace(harness.chat.toString(), 'turn-1'); + } + + test('keeps a visible continuation in progress while converting after the invoking turn', async () => { + const trustDecision = new DeferredPromise(); + const harness = createHarness(new NullAgentHostWorktreeIsolation(), () => trustDecision.p); + const workspaceFolder = URI.file('/workspace/project'); + const providerMutation = new DeferredPromise(); + const providerCalls: { chat: string; session: string; workspaceFolder: string }[] = []; + const customization = { + type: CustomizationType.Plugin, + id: customizationId('file:///workspace/project/plugin'), + uri: 'file:///workspace/project/plugin', + name: 'Workspace Plugin', + load: { kind: CustomizationLoadStatus.Loaded }, + } as const; + let stateWhenCustomizationsRefreshed: { workingDirectories: readonly string[] | undefined; workspaceless: boolean } | undefined; + harness.agent.getSessionCustomizations = async () => { + const state = harness.stateManager.getSessionState(harness.session.toString()); + stateWhenCustomizationsRefreshed = { + workingDirectories: state?.workingDirectories, + workspaceless: readSessionWorkspaceless(state?._meta), + }; + return [customization]; + }; + const provider: IAgent = harness.agent; + provider.setWorkingDirectory = async (chat, context, workingDirectory) => { + providerCalls.push({ + chat: chat.toString(), + session: URI.isUri(context) ? context.toString() : context.resource.toString(), + workspaceFolder: workingDirectory.toString(), + }); + await providerMutation.p; + }; + startTurn(harness.stateManager, harness.chat); + await harness.database.setMetadata(AH_META_WORKSPACELESS_DB_KEY, 'true'); + harness.service.requestSessionWorkspaceUpdate(harness.chat, 'turn-1', workspaceFolder, false, 'client-1'); + completeTurn(harness.stateManager, harness.chat); + + const conversion = updateSessionWorkspace(harness); + await Promise.resolve(); + const stateDuringSetup = harness.stateManager.getSessionState(harness.session.toString()); + const chatDuringSetup = harness.stateManager.getChatState(harness.chat.toString()); + assert.deepStrictEqual({ + pending: harness.service.isPending(harness.chat.toString()), + providerCalls, + sessionStatus: stateDuringSetup?.status, + chatStatus: chatDuringSetup?.status, + activity: chatDuringSetup?.activity, + activeTurnId: chatDuringSetup?.activeTurn?.id, + responseParts: chatDuringSetup?.activeTurn?.responseParts, + deferredContinuations: harness.deferredContinuations.map(entry => ({ + chat: entry.chat, + hidden: isMessageHiddenFromTranscript(entry.message), + label: readMessageSystemInitiatedLabel(entry.message), + origin: entry.message.origin.kind, + text: entry.message.text, + turnId: entry.turnId, + })), + continuations: harness.continuations, + }, { + pending: true, + providerCalls: [], + sessionStatus: SessionStatus.InProgress, + chatStatus: SessionStatus.InProgress, + activity: undefined, + activeTurnId: 'continuation-1', + responseParts: [], + deferredContinuations: [{ + chat: harness.chat.toString(), + hidden: false, + label: 'Continue in Requested Workspace', + origin: MessageKind.SystemNotification, + text: 'Continue in the requested workspace.', + turnId: 'continuation-1', + }], + continuations: [], + }); + trustDecision.complete(true); + await Promise.resolve(); + providerMutation.complete(); + await conversion; + + const state = harness.stateManager.getSessionState(harness.session.toString()); + const activeTurn = harness.stateManager.getChatState(harness.chat.toString())?.activeTurn; + assert.deepStrictEqual({ + providerCalls, + trustRequests: harness.trustRequests, + pending: harness.service.isPending(harness.chat.toString()), + workingDirectories: state?.workingDirectories, + workspaceless: readSessionWorkspaceless(state?._meta), + persistedWorkspaceless: await harness.database.getMetadata(AH_META_WORKSPACELESS_DB_KEY), + refreshedServerTools: harness.refreshedServerTools, + stateWhenCustomizationsRefreshed, + customizations: state?.customizations, + activity: harness.stateManager.getChatState(harness.chat.toString())?.activity, + activeTurnId: harness.stateManager.getActiveTurnId(harness.chat.toString()), + outcomeNotifications: activeTurn?.responseParts.flatMap(part => part.kind === ResponsePartKind.SystemNotification ? [part.content] : []), + continuations: harness.continuations.map(entry => ({ + chat: entry.chat, + hidden: isMessageHiddenFromTranscript(entry.message), + label: readMessageSystemInitiatedLabel(entry.message), + origin: entry.message.origin.kind, + text: entry.message.text, + })), + }, { + providerCalls: [{ + chat: harness.chat.toString(), + session: harness.session.toString(), + workspaceFolder: 'file:///workspace/project', + }], + trustRequests: [{ + clientId: 'client-1', + workspace: 'file:///workspace/project', + }], + pending: false, + workingDirectories: ['file:///workspace/project'], + workspaceless: false, + persistedWorkspaceless: 'false', + refreshedServerTools: [harness.session.toString()], + stateWhenCustomizationsRefreshed: { + workingDirectories: ['file:///workspace/project'], + workspaceless: false, + }, + customizations: [customization], + activity: undefined, + activeTurnId: 'continuation-1', + outcomeNotifications: ['Workspace Set'], + continuations: [{ + chat: harness.chat.toString(), + hidden: false, + label: 'Workspace Set', + origin: MessageKind.SystemNotification, + text: `The current session is now attached to ${workspaceFolder.fsPath}. Continue the user's original task in this workspace. Do not request another session or workspace conversion.`, + }], + }); + }); + + test('creates an isolated worktree and sets it as the workspace', async () => { + const worktreeIsolation = new TestWorktreeIsolation(URI.file('/workspace/project.worktrees/implement-feature')); + const harness = createHarness(worktreeIsolation); + const workspaceFolder = URI.file('/workspace/project'); + const providerCalls: string[] = []; + const projectNotifications: Array<{ uri: string; displayName: string } | undefined> = []; + disposables.add(harness.stateManager.onDidChangeSessionSummary(event => { + if (event.session === harness.session.toString()) { + projectNotifications.push(event.changes.project); + } + })); + const provider: IAgent = harness.agent; + provider.setWorkingDirectory = async (_chat, _context, workingDirectory) => { + providerCalls.push(workingDirectory.toString()); + }; + startTurn(harness.stateManager, harness.chat); + await harness.database.setMetadata(AH_META_WORKSPACELESS_DB_KEY, 'true'); + harness.service.requestSessionWorkspaceUpdate(harness.chat, 'turn-1', workspaceFolder, true, 'client-1'); + completeTurn(harness.stateManager, harness.chat); + + await updateSessionWorkspace(harness); + await timeout(120); + + const state = harness.stateManager.getSessionState(harness.session.toString()); + const summary = harness.stateManager.getSessionSummary(harness.session.toString()); + assert.deepStrictEqual({ + worktreeRequests: worktreeIsolation.requests.map(request => ({ + session: request.sessionUri.toString(), + workspaceFolder: request.workingDirectory?.toString(), + prompt: request.prompt, + isolation: request.config?.[SessionConfigKey.Isolation], + branch: request.config?.[SessionConfigKey.Branch], + })), + trustRequests: harness.trustRequests, + providerCalls, + sessionStateProject: state?.project, + summaryProject: summary?.project, + workingDirectories: state?.workingDirectories, + projectNotifications, + isolation: state?.config?.values[SessionConfigKey.Isolation], + branch: state?.config?.values[SessionConfigKey.Branch], + persistedConfig: JSON.parse((await harness.database.getMetadata('configValues')) ?? '{}'), + continuationText: harness.continuations[0]?.message.text, + }, { + worktreeRequests: [{ + session: harness.session.toString(), + workspaceFolder: workspaceFolder.toString(), + prompt: 'Implement the feature', + isolation: 'worktree', + branch: 'main', + }], + trustRequests: [{ + clientId: 'client-1', + workspace: workspaceFolder.toString(), + }, { + clientId: 'client-1', + workspace: worktreeIsolation.worktree.toString(), + trustedParent: workspaceFolder.toString(), + }], + providerCalls: [worktreeIsolation.worktree.toString()], + sessionStateProject: undefined, + summaryProject: { + uri: workspaceFolder.toString(), + displayName: 'project', + }, + workingDirectories: [worktreeIsolation.worktree.toString()], + projectNotifications: [{ + uri: workspaceFolder.toString(), + displayName: 'project', + }], + isolation: 'worktree', + branch: 'main', + persistedConfig: { + [SessionConfigKey.Isolation]: 'worktree', + [SessionConfigKey.Branch]: 'main', + }, + continuationText: `The current session is now attached to ${worktreeIsolation.worktree.fsPath} in an isolated worktree. Continue the user's original task in this workspace. Do not request another session or workspace conversion.`, + }); + }); + + test('keeps the session workspace-less when workspace trust is declined', async () => { + const harness = createHarness(new NullAgentHostWorktreeIsolation(), async () => false); + const workspaceFolder = URI.file('/workspace/project'); + const providerCalls: string[] = []; + const provider: IAgent = harness.agent; + provider.setWorkingDirectory = async (_chat, _context, workingDirectory) => { + providerCalls.push(workingDirectory.toString()); + }; + startTurn(harness.stateManager, harness.chat); + await harness.database.setMetadata(AH_META_WORKSPACELESS_DB_KEY, 'true'); + harness.service.requestSessionWorkspaceUpdate(harness.chat, 'turn-1', workspaceFolder, false, 'client-1'); + completeTurn(harness.stateManager, harness.chat); + + await updateSessionWorkspace(harness); + + const state = harness.stateManager.getSessionState(harness.session.toString()); + assert.deepStrictEqual({ + trustRequests: harness.trustRequests, + providerCalls, + workingDirectories: state?.workingDirectories, + workspaceless: readSessionWorkspaceless(state?._meta), + persistedWorkspaceless: await harness.database.getMetadata(AH_META_WORKSPACELESS_DB_KEY), + continuation: harness.continuations.map(entry => ({ + label: readMessageSystemInitiatedLabel(entry.message), + text: entry.message.text, + })), + }, { + trustRequests: [{ + clientId: 'client-1', + workspace: 'file:///workspace/project', + }], + providerCalls: [], + workingDirectories: [harness.scratch.toString()], + workspaceless: true, + persistedWorkspaceless: 'true', + continuation: [{ + label: 'Workspace Setup Failed', + text: `The requested workspace setup did not complete successfully: Workspace trust was not granted for '${workspaceFolder.fsPath}'. Do not run the user's task. Tell the user that workspace setup failed and include this error.`, + }], + }); + }); + + test('does not create a worktree when trust for it is declined', async () => { + const worktreeIsolation = new TestWorktreeIsolation(URI.file('/workspace/project.worktrees/implement-feature')); + let trustRequestCount = 0; + const harness = createHarness(worktreeIsolation, async () => ++trustRequestCount === 1); + const providerCalls: string[] = []; + const provider: IAgent = harness.agent; + provider.setWorkingDirectory = async (_chat, _context, workingDirectory) => { + providerCalls.push(workingDirectory.toString()); + }; + startTurn(harness.stateManager, harness.chat); + await harness.database.setMetadata(AH_META_WORKSPACELESS_DB_KEY, 'true'); + harness.service.requestSessionWorkspaceUpdate(harness.chat, 'turn-1', URI.file('/workspace/project'), true, 'client-1'); + completeTurn(harness.stateManager, harness.chat); + + await updateSessionWorkspace(harness); + + const state = harness.stateManager.getSessionState(harness.session.toString()); + assert.deepStrictEqual({ + trustRequests: harness.trustRequests, + providerCalls, + createdWorktrees: worktreeIsolation.createdWorktrees, + removedWorktrees: worktreeIsolation.removedWorktrees, + workingDirectories: state?.workingDirectories, + workspaceless: readSessionWorkspaceless(state?._meta), + }, { + trustRequests: [{ + clientId: 'client-1', + workspace: 'file:///workspace/project', + }, { + clientId: 'client-1', + workspace: worktreeIsolation.worktree.toString(), + trustedParent: 'file:///workspace/project', + }], + providerCalls: [], + createdWorktrees: [], + removedWorktrees: [], + workingDirectories: [harness.scratch.toString()], + workspaceless: true, + }); + }); + + test('trusts the repository root before creating an isolated worktree', async () => { + const repository = URI.file('/workspace/project'); + const worktreeIsolation = new TestWorktreeIsolation(URI.file('/workspace/project.worktrees/implement-feature'), repository); + const harness = createHarness(worktreeIsolation); + const workspaceFolder = URI.file('/workspace/project/packages/app'); + startTurn(harness.stateManager, harness.chat); + harness.service.requestSessionWorkspaceUpdate(harness.chat, 'turn-1', workspaceFolder, true, 'client-1'); + completeTurn(harness.stateManager, harness.chat); + + await updateSessionWorkspace(harness); + + assert.deepStrictEqual({ + trustRequests: harness.trustRequests, + createdWorktrees: worktreeIsolation.createdWorktrees, + }, { + trustRequests: [{ + clientId: 'client-1', + workspace: workspaceFolder.toString(), + }, { + clientId: 'client-1', + workspace: repository.toString(), + }, { + clientId: 'client-1', + workspace: worktreeIsolation.worktree.toString(), + trustedParent: repository.toString(), + }], + createdWorktrees: [worktreeIsolation.worktree], + }); + }); + + test('removes a newly created worktree when provider mutation fails', async () => { + const worktreeIsolation = new TestWorktreeIsolation(URI.file('/workspace/project.worktrees/implement-feature')); + const harness = createHarness(worktreeIsolation); + const provider: IAgent = harness.agent; + provider.setWorkingDirectory = async () => { + throw new Error('provider failed'); + }; + startTurn(harness.stateManager, harness.chat); + harness.service.requestSessionWorkspaceUpdate(harness.chat, 'turn-1', URI.file('/workspace/project'), true, 'client-1'); + completeTurn(harness.stateManager, harness.chat); + + await updateSessionWorkspace(harness); + + const state = harness.stateManager.getSessionState(harness.session.toString()); + assert.deepStrictEqual({ + removedWorktrees: worktreeIsolation.removedWorktrees.map(entry => ({ + repositoryRoot: entry.repositoryRoot.toString(), + worktree: entry.worktree.toString(), + })), + workingDirectories: state?.workingDirectories, + workspaceless: readSessionWorkspaceless(state?._meta), + continuationText: harness.continuations[0]?.message.text, + }, { + removedWorktrees: [{ + repositoryRoot: 'file:///workspace/project', + worktree: worktreeIsolation.worktree.toString(), + }], + workingDirectories: [harness.scratch.toString()], + workspaceless: true, + continuationText: 'The requested workspace setup did not complete successfully: provider failed. Do not run the user\'s task. Tell the user that workspace setup failed and include this error.', + }); + }); + + test('keeps the session workspace-less and continues with a visible failure explanation request', async () => { + const harness = createHarness(); + const provider: IAgent = harness.agent; + provider.setWorkingDirectory = async () => { + throw new Error('provider failed'); + }; + startTurn(harness.stateManager, harness.chat); + harness.service.requestSessionWorkspaceUpdate(harness.chat, 'turn-1', URI.file('/workspace/project'), false, 'client-1'); + completeTurn(harness.stateManager, harness.chat); + + await updateSessionWorkspace(harness); + + const state = harness.stateManager.getSessionState(harness.session.toString()); + const activeTurn = harness.stateManager.getChatState(harness.chat.toString())?.activeTurn; + assert.deepStrictEqual({ + pending: harness.service.isPending(harness.chat.toString()), + workingDirectories: state?.workingDirectories, + workspaceless: readSessionWorkspaceless(state?._meta), + continuationHidden: harness.continuations[0] ? isMessageHiddenFromTranscript(harness.continuations[0].message) : undefined, + continuationLabel: harness.continuations[0] ? readMessageSystemInitiatedLabel(harness.continuations[0].message) : undefined, + continuationOrigin: harness.continuations[0]?.message.origin.kind, + continuationText: harness.continuations[0]?.message.text, + outcomeNotifications: activeTurn?.responseParts.flatMap(part => part.kind === ResponsePartKind.SystemNotification ? [part.content] : []), + }, { + pending: false, + workingDirectories: [harness.scratch.toString()], + workspaceless: true, + continuationHidden: false, + continuationLabel: 'Workspace Setup Failed', + continuationOrigin: MessageKind.SystemNotification, + continuationText: 'The requested workspace setup did not complete successfully: provider failed. Do not run the user\'s task. Tell the user that workspace setup failed and include this error.', + outcomeNotifications: ['Workspace Setup Failed'], + }); + }); + + test('adopts an irreversible provider directory before reporting an alignment failure', async () => { + const harness = createHarness(); + const authoritative = URI.file('/workspace/authoritative'); + const provider: IAgent = harness.agent; + provider.setWorkingDirectory = async () => { + throw new AgentWorkingDirectoryChangedError(authoritative, 'SDK returned a different directory'); + }; + startTurn(harness.stateManager, harness.chat); + harness.service.requestSessionWorkspaceUpdate(harness.chat, 'turn-1', URI.file('/workspace/requested'), false, 'client-1'); + completeTurn(harness.stateManager, harness.chat); + + await updateSessionWorkspace(harness); + + const state = harness.stateManager.getSessionState(harness.session.toString()); + assert.deepStrictEqual({ + workingDirectories: state?.workingDirectories, + workspaceless: readSessionWorkspaceless(state?._meta), + persistedWorkspaceless: await harness.database.getMetadata(AH_META_WORKSPACELESS_DB_KEY), + continuationText: harness.continuations[0]?.message.text, + }, { + workingDirectories: ['file:///workspace/authoritative'], + workspaceless: false, + persistedWorkspaceless: 'false', + continuationText: `The requested workspace setup did not complete successfully: The workspace changed to '${authoritative.fsPath}', but conversion did not complete cleanly: SDK returned a different directory. Do not run the user's task. Tell the user that workspace setup failed and include this error.`, + }); + }); + + test('disposes the provider without continuing when its authoritative directory is not trusted', async () => { + let trustRequestCount = 0; + const harness = createHarness(new NullAgentHostWorktreeIsolation(), async () => ++trustRequestCount === 1); + const authoritative = URI.file('/workspace/authoritative'); + const disposedChats: { session: string; chat: string }[] = []; + const provider: IAgent = harness.agent; + provider.setWorkingDirectory = async () => { + throw new AgentWorkingDirectoryChangedError(authoritative, 'SDK returned a different directory'); + }; + harness.agent.disposeChat = async (session, chat) => { + disposedChats.push({ session: session.toString(), chat: chat.toString() }); + }; + startTurn(harness.stateManager, harness.chat); + harness.service.requestSessionWorkspaceUpdate(harness.chat, 'turn-1', URI.file('/workspace/requested'), false, 'client-1'); + completeTurn(harness.stateManager, harness.chat); + + await updateSessionWorkspace(harness); + + const state = harness.stateManager.getSessionState(harness.session.toString()); + const endedTurn = harness.stateManager.getChatState(harness.chat.toString())?.turns.at(-1); + assert.deepStrictEqual({ + trustRequests: harness.trustRequests, + disposedChats, + pending: harness.service.isPending(harness.chat.toString()), + persistedQuarantine: await harness.database.getMetadata(AH_META_WORKSPACE_CONVERSION_QUARANTINED_DB_KEY), + workingDirectories: state?.workingDirectories, + workspaceless: readSessionWorkspaceless(state?._meta), + continuations: harness.continuations, + failedContinuations: harness.failedContinuations, + activity: harness.stateManager.getChatState(harness.chat.toString())?.activity, + activeTurnId: harness.stateManager.getActiveTurnId(harness.chat.toString()), + outcomeNotifications: endedTurn?.responseParts.flatMap(part => part.kind === ResponsePartKind.SystemNotification ? [part.content] : []), + }, { + trustRequests: [{ + clientId: 'client-1', + workspace: 'file:///workspace/requested', + }, { + clientId: 'client-1', + workspace: authoritative.toString(), + }], + disposedChats: [{ + session: harness.session.toString(), + chat: harness.chat.toString(), + }], + pending: true, + persistedQuarantine: 'true', + workingDirectories: [harness.scratch.toString()], + workspaceless: true, + continuations: [], + failedContinuations: [{ + chat: harness.chat.toString(), + error: { + errorType: 'workspaceConversionFailed', + message: `The provider changed to an untrusted working directory and was disposed: Workspace trust was not granted for '${authoritative.fsPath}'`, + }, + turnId: 'continuation-1', + }], + activity: undefined, + activeTurnId: undefined, + outcomeNotifications: ['Workspace Setup Failed'], + }); + }); + + test('does not continue a setup turn that the user cancelled during conversion', async () => { + const trustDecision = new DeferredPromise(); + const harness = createHarness(new NullAgentHostWorktreeIsolation(), () => trustDecision.p); + const provider: IAgent = harness.agent; + provider.setWorkingDirectory = async () => { }; + startTurn(harness.stateManager, harness.chat); + harness.service.requestSessionWorkspaceUpdate(harness.chat, 'turn-1', URI.file('/workspace/project'), false, 'client-1'); + completeTurn(harness.stateManager, harness.chat); + + const conversion = updateSessionWorkspace(harness); + await Promise.resolve(); + harness.stateManager.dispatchServerAction(harness.chat.toString(), { + type: ActionType.ChatTurnCancelled, + turnId: 'continuation-1', + duration: 1, + }); + trustDecision.complete(true); + await conversion; + + const state = harness.stateManager.getSessionState(harness.session.toString()); + assert.deepStrictEqual({ + workingDirectories: state?.workingDirectories, + workspaceless: readSessionWorkspaceless(state?._meta), + continuations: harness.continuations, + failedContinuations: harness.failedContinuations, + activity: harness.stateManager.getChatState(harness.chat.toString())?.activity, + activeTurnId: harness.stateManager.getActiveTurnId(harness.chat.toString()), + }, { + workingDirectories: ['file:///workspace/project'], + workspaceless: false, + continuations: [], + failedContinuations: [], + activity: undefined, + activeTurnId: undefined, + }); + }); + + test('durably quarantines the session when an untrusted provider cannot be disposed', async () => { + let trustRequestCount = 0; + const harness = createHarness(new NullAgentHostWorktreeIsolation(), async () => ++trustRequestCount === 1); + const provider: IAgent = harness.agent; + provider.setWorkingDirectory = async () => { + throw new AgentWorkingDirectoryChangedError(URI.file('/workspace/authoritative'), 'SDK returned a different directory'); + }; + harness.agent.disposeChat = async () => { + throw new Error('dispose failed'); + }; + startTurn(harness.stateManager, harness.chat); + harness.service.requestSessionWorkspaceUpdate(harness.chat, 'turn-1', URI.file('/workspace/requested'), false, 'client-1'); + completeTurn(harness.stateManager, harness.chat); + + await updateSessionWorkspace(harness); + + assert.deepStrictEqual({ + pending: harness.service.isPending(harness.chat.toString()), + persistedQuarantine: await harness.database.getMetadata(AH_META_WORKSPACE_CONVERSION_QUARANTINED_DB_KEY), + continuations: harness.continuations, + }, { + pending: true, + persistedQuarantine: 'true', + continuations: [], + }); + }); + + test('atomically persists conversion metadata or quarantines before publishing state', async () => { + class FailingConversionDatabase extends TestSessionDatabase { + override async setMetadataValues(): Promise { + throw new Error('atomic commit failed'); + } + } + const database = new FailingConversionDatabase(); + const harness = createHarness(new NullAgentHostWorktreeIsolation(), async () => true, database); + const provider: IAgent = harness.agent; + provider.setWorkingDirectory = async () => { }; + startTurn(harness.stateManager, harness.chat); + harness.service.requestSessionWorkspaceUpdate(harness.chat, 'turn-1', URI.file('/workspace/project'), false, 'client-1'); + completeTurn(harness.stateManager, harness.chat); + + await updateSessionWorkspace(harness); + + const state = harness.stateManager.getSessionState(harness.session.toString()); + assert.deepStrictEqual({ + pending: harness.service.isPending(harness.chat.toString()), + persistedQuarantine: await database.getMetadata(AH_META_WORKSPACE_CONVERSION_QUARANTINED_DB_KEY), + workingDirectories: state?.workingDirectories, + workspaceless: readSessionWorkspaceless(state?._meta), + continuations: harness.continuations, + }, { + pending: true, + persistedQuarantine: 'true', + workingDirectories: [harness.scratch.toString()], + workspaceless: true, + continuations: [], + }); + }); + + test('keeps the session quarantined in memory when durable quarantine persistence fails', async () => { + class FailingQuarantineDatabase extends TestSessionDatabase { + override async setMetadataValues(): Promise { + throw new Error('atomic commit failed'); + } + + override async setMetadata(key: string, value: string): Promise { + if (key === AH_META_WORKSPACE_CONVERSION_QUARANTINED_DB_KEY) { + throw new Error('quarantine persistence failed'); + } + await super.setMetadata(key, value); + } + } + const database = new FailingQuarantineDatabase(); + const harness = createHarness(new NullAgentHostWorktreeIsolation(), async () => true, database); + const provider: IAgent = harness.agent; + provider.setWorkingDirectory = async () => { }; + startTurn(harness.stateManager, harness.chat); + harness.service.requestSessionWorkspaceUpdate(harness.chat, 'turn-1', URI.file('/workspace/project'), false, 'client-1'); + completeTurn(harness.stateManager, harness.chat); + + await updateSessionWorkspace(harness); + + const state = harness.stateManager.getSessionState(harness.session.toString()); + assert.deepStrictEqual({ + pending: harness.service.isPending(harness.chat.toString()), + persistedQuarantine: await database.getMetadata(AH_META_WORKSPACE_CONVERSION_QUARANTINED_DB_KEY), + workingDirectories: state?.workingDirectories, + workspaceless: readSessionWorkspaceless(state?._meta), + continuations: harness.continuations, + }, { + pending: true, + persistedQuarantine: undefined, + workingDirectories: [harness.scratch.toString()], + workspaceless: true, + continuations: [], + }); + }); + + test('does not mutate the provider when the session is archived before conversion starts', async () => { + const harness = createHarness(); + const providerCalls: string[] = []; + const provider: IAgent = harness.agent; + provider.setWorkingDirectory = async (_chat, _session, workingDirectory) => { + providerCalls.push(workingDirectory.toString()); + }; + startTurn(harness.stateManager, harness.chat); + harness.service.requestSessionWorkspaceUpdate(harness.chat, 'turn-1', URI.file('/workspace/project'), false, 'client-1'); + harness.stateManager.dispatchServerAction(harness.session.toString(), { + type: ActionType.SessionIsArchivedChanged, + isArchived: true, + }); + completeTurn(harness.stateManager, harness.chat); + + await updateSessionWorkspace(harness); + + const state = harness.stateManager.getSessionState(harness.session.toString()); + assert.deepStrictEqual({ + archived: state ? (state.status & SessionStatus.IsArchived) === SessionStatus.IsArchived : undefined, + pending: harness.service.isPending(harness.chat.toString()), + providerCalls, + workingDirectories: state?.workingDirectories, + workspaceless: readSessionWorkspaceless(state?._meta), + continuationText: harness.continuations[0]?.message.text, + }, { + archived: true, + pending: false, + providerCalls: [], + workingDirectories: [harness.scratch.toString()], + workspaceless: true, + continuationText: 'The requested workspace setup did not complete successfully: An archived session cannot be converted to a workspace session. Do not run the user\'s task. Tell the user that workspace setup failed and include this error.', + }); + }); + + test('quarantines without publishing when session state changes during conversion metadata persistence', async () => { + const database = new GatedConversionDatabase(); + const harness = createHarness(new NullAgentHostWorktreeIsolation(), async () => true, database); + const disposedChats: { session: string; chat: string }[] = []; + const provider: IAgent = harness.agent; + provider.setWorkingDirectory = async () => { }; + harness.agent.disposeChat = async (session, chat) => { + disposedChats.push({ session: session.toString(), chat: chat.toString() }); + }; + startTurn(harness.stateManager, harness.chat); + harness.service.requestSessionWorkspaceUpdate(harness.chat, 'turn-1', URI.file('/workspace/project'), false, 'client-1'); + completeTurn(harness.stateManager, harness.chat); + + const conversion = updateSessionWorkspace(harness); + await database.writeStarted.p; + const replacement = URI.file('/workspace/other'); + harness.stateManager.dispatchServerAction(harness.session.toString(), { + type: ActionType.SessionWorkingDirectoryReplaced, + directory: harness.scratch.toString(), + replacement: replacement.toString(), + }); + database.releaseWrite.complete(); + await conversion; + + const state = harness.stateManager.getSessionState(harness.session.toString()); + assert.deepStrictEqual({ + pending: harness.service.isPending(harness.chat.toString()), + persistedQuarantine: await database.getMetadata(AH_META_WORKSPACE_CONVERSION_QUARANTINED_DB_KEY), + workingDirectories: state?.workingDirectories, + workspaceless: readSessionWorkspaceless(state?._meta), + disposedChats, + continuations: harness.continuations, + }, { + pending: true, + persistedQuarantine: 'true', + workingDirectories: [replacement.toString()], + workspaceless: true, + disposedChats: [{ + session: harness.session.toString(), + chat: harness.chat.toString(), + }], + continuations: [], + }); + }); + + test('quarantines without publishing when the session is archived during conversion metadata persistence', async () => { + const database = new GatedConversionDatabase(); + const harness = createHarness(new NullAgentHostWorktreeIsolation(), async () => true, database); + const disposedChats: { session: string; chat: string }[] = []; + const provider: IAgent = harness.agent; + provider.setWorkingDirectory = async () => { }; + harness.agent.disposeChat = async (session, chat) => { + disposedChats.push({ session: session.toString(), chat: chat.toString() }); + }; + startTurn(harness.stateManager, harness.chat); + harness.service.requestSessionWorkspaceUpdate(harness.chat, 'turn-1', URI.file('/workspace/project'), false, 'client-1'); + completeTurn(harness.stateManager, harness.chat); + + const conversion = updateSessionWorkspace(harness); + await database.writeStarted.p; + harness.stateManager.dispatchServerAction(harness.session.toString(), { + type: ActionType.SessionIsArchivedChanged, + isArchived: true, + }); + database.releaseWrite.complete(); + await conversion; + + const state = harness.stateManager.getSessionState(harness.session.toString()); + assert.deepStrictEqual({ + archived: state ? (state.status & SessionStatus.IsArchived) === SessionStatus.IsArchived : undefined, + pending: harness.service.isPending(harness.chat.toString()), + persistedQuarantine: await database.getMetadata(AH_META_WORKSPACE_CONVERSION_QUARANTINED_DB_KEY), + workingDirectories: state?.workingDirectories, + workspaceless: readSessionWorkspaceless(state?._meta), + disposedChats, + continuations: harness.continuations, + }, { + archived: true, + pending: true, + persistedQuarantine: 'true', + workingDirectories: [harness.scratch.toString()], + workspaceless: true, + disposedChats: [{ + session: harness.session.toString(), + chat: harness.chat.toString(), + }], + continuations: [], + }); + }); + + test('rejects invalid requests and clears cancelled conversions', async () => { + const harness = createHarness(); + startTurn(harness.stateManager, harness.chat); + + assert.throws(() => harness.service.requestSessionWorkspaceUpdate(harness.chat, 'other-turn', URI.file('/workspace/project'), false, 'client-1'), /active turn/); + assert.throws(() => harness.service.requestSessionWorkspaceUpdate(harness.chat, 'turn-1', URI.parse('vscode-remote://host/workspace/project'), false, 'client-1'), /absolute local path or file URI/); + harness.service.requestSessionWorkspaceUpdate(harness.chat, 'turn-1', URI.file('/workspace/project'), false, 'client-1'); + assert.throws(() => harness.service.requestSessionWorkspaceUpdate(harness.chat, 'turn-1', URI.file('/workspace/other'), false, 'client-1'), /already pending/); + completeTurn(harness.stateManager, harness.chat); + + harness.service.cancel(harness.chat.toString(), 'turn-1'); + + assert.deepStrictEqual({ + pending: harness.service.isPending(harness.chat.toString()), + continuations: harness.continuations, + }, { + pending: false, + continuations: [], + }); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/shared/worktreeIsolation.test.ts b/src/vs/platform/agentHost/test/node/shared/worktreeIsolation.test.ts index 5a40399edfd6e..c2d3705bed5ff 100644 --- a/src/vs/platform/agentHost/test/node/shared/worktreeIsolation.test.ts +++ b/src/vs/platform/agentHost/test/node/shared/worktreeIsolation.test.ts @@ -13,7 +13,7 @@ import { basename, getComparisonKey } from '../../../../../base/common/resources import { URI } from '../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { NullLogService } from '../../../../log/common/log.js'; -import { GitRefType, IAgentHostGitService, type IAddWorktreeOptions } from '../../../common/agentHostGitService.js'; +import { GitRefType, IAgentHostGitService, META_DIFF_BASE_BRANCH, type IAddWorktreeOptions } from '../../../common/agentHostGitService.js'; import { SessionConfigKey } from '../../../common/sessionConfigKeys.js'; import { AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_DONE_DB_KEY, MessageKind, ResponsePartKind, TurnState, type Turn } from '../../../common/state/sessionState.js'; import { AgentBranchNameGenerator, IAgentBranchNameGenerator } from '../../../node/shared/agentBranchNameGenerator.js'; @@ -195,20 +195,23 @@ suite('WorktreeIsolation', () => { const repoWorktree = await isolation.resolveIsolationConfig({ workingDirectory: repoRoot, config: undefined }); const repoWorktreeSelected = await isolation.resolveIsolationConfig({ workingDirectory: repoRoot, config: { [SessionConfigKey.Isolation]: 'worktree', [SessionConfigKey.Branch]: 'feature' } }); const repoFolder = await isolation.resolveIsolationConfig({ workingDirectory: repoRoot, config: { [SessionConfigKey.Isolation]: 'folder' } }); + const repoFolderSelected = await isolation.resolveIsolationConfig({ workingDirectory: repoRoot, config: { [SessionConfigKey.Isolation]: 'folder', [SessionConfigKey.Branch]: 'main' } }); headCommit = undefined; // unborn HEAD (no commits) const noCommits = await isolation.resolveIsolationConfig({ workingDirectory: repoRoot, config: undefined }); assert.deepStrictEqual({ noRepo: { enum: noRepo.isolationProperty.protocol.enum, value: noRepo.isolationValue, branch: noRepo.branchProperty, prefix: noRepo.worktreeBranchPrefixProperty, includeFiles: noRepo.worktreeIncludeFilesProperty, branchTrack: noRepo.worktreeBranchTrackProperty, createNewBranch: noRepo.worktreeCreateNewBranchProperty }, - repoWorktree: { enum: repoWorktree.isolationProperty.protocol.enum, value: repoWorktree.isolationValue, branchDefault: repoWorktree.branchDefault, branchReadOnly: repoWorktree.branchProperty?.protocol.readOnly, prefixReadOnly: repoWorktree.worktreeBranchPrefixProperty?.protocol.readOnly, includeFilesReadOnly: repoWorktree.worktreeIncludeFilesProperty?.protocol.readOnly, branchTrackReadOnly: repoWorktree.worktreeBranchTrackProperty?.protocol.readOnly, createNewBranchReadOnly: repoWorktree.worktreeCreateNewBranchProperty?.protocol.readOnly }, + repoWorktree: { enum: repoWorktree.isolationProperty.protocol.enum, value: repoWorktree.isolationValue, branchDefault: repoWorktree.branchDefault, branchDynamic: repoWorktree.branchProperty?.protocol.enumDynamic, branchReadOnly: repoWorktree.branchProperty?.protocol.readOnly, prefixReadOnly: repoWorktree.worktreeBranchPrefixProperty?.protocol.readOnly, includeFilesReadOnly: repoWorktree.worktreeIncludeFilesProperty?.protocol.readOnly, branchTrackReadOnly: repoWorktree.worktreeBranchTrackProperty?.protocol.readOnly, createNewBranchReadOnly: repoWorktree.worktreeCreateNewBranchProperty?.protocol.readOnly }, repoWorktreeSelected: { branchDefault: repoWorktreeSelected.branchDefault, branchValue: repoWorktreeSelected.branchValue, branchEnum: repoWorktreeSelected.branchProperty?.protocol.enum }, - repoFolder: { value: repoFolder.isolationValue, branchDefault: repoFolder.branchDefault, branchReadOnly: repoFolder.branchProperty?.protocol.readOnly, hasPrefix: !!repoFolder.worktreeBranchPrefixProperty, hasIncludeFiles: !!repoFolder.worktreeIncludeFilesProperty, hasBranchTrack: !!repoFolder.worktreeBranchTrackProperty, hasCreateNewBranch: !!repoFolder.worktreeCreateNewBranchProperty }, + repoFolder: { value: repoFolder.isolationValue, branchDefault: repoFolder.branchDefault, branchDynamic: repoFolder.branchProperty?.protocol.enumDynamic, branchReadOnly: repoFolder.branchProperty?.protocol.readOnly, hasPrefix: !!repoFolder.worktreeBranchPrefixProperty, hasIncludeFiles: !!repoFolder.worktreeIncludeFilesProperty, hasBranchTrack: !!repoFolder.worktreeBranchTrackProperty, hasCreateNewBranch: !!repoFolder.worktreeCreateNewBranchProperty }, + repoFolderSelected: { branchDefault: repoFolderSelected.branchDefault, branchValue: repoFolderSelected.branchValue }, noCommits: { enum: noCommits.isolationProperty.protocol.enum, value: noCommits.isolationValue, branch: noCommits.branchProperty, prefix: noCommits.worktreeBranchPrefixProperty, includeFiles: noCommits.worktreeIncludeFilesProperty, branchTrack: noCommits.worktreeBranchTrackProperty, createNewBranch: noCommits.worktreeCreateNewBranchProperty }, }, { noRepo: { enum: ['folder'], value: 'folder', branch: undefined, prefix: undefined, includeFiles: undefined, branchTrack: undefined, createNewBranch: undefined }, - repoWorktree: { enum: ['folder', 'worktree'], value: 'worktree', branchDefault: 'main', branchReadOnly: false, prefixReadOnly: true, includeFilesReadOnly: true, branchTrackReadOnly: true, createNewBranchReadOnly: true }, + repoWorktree: { enum: ['folder', 'worktree'], value: 'worktree', branchDefault: 'main', branchDynamic: true, branchReadOnly: false, prefixReadOnly: true, includeFilesReadOnly: true, branchTrackReadOnly: true, createNewBranchReadOnly: true }, repoWorktreeSelected: { branchDefault: 'main', branchValue: 'feature', branchEnum: ['main'] }, - repoFolder: { value: 'folder', branchDefault: 'feature', branchReadOnly: true, hasPrefix: true, hasIncludeFiles: true, hasBranchTrack: true, hasCreateNewBranch: true }, + repoFolder: { value: 'folder', branchDefault: 'feature', branchDynamic: true, branchReadOnly: false, hasPrefix: true, hasIncludeFiles: true, hasBranchTrack: true, hasCreateNewBranch: true }, + repoFolderSelected: { branchDefault: 'feature', branchValue: 'main' }, noCommits: { enum: ['folder'], value: 'folder', branch: undefined, prefix: undefined, includeFiles: undefined, branchTrack: undefined, createNewBranch: undefined }, }); }); @@ -1100,6 +1103,31 @@ suite('WorktreeIsolation', () => { }); }); + test('discardSessionWorktree removes provisional worktree metadata', async () => { + const isolation = createIsolation(disposables); + const worktree = await isolation.resolveWorkingDirectory({ sessionUri, sessionId, workingDirectory: repoRoot, config: { [SessionConfigKey.Isolation]: 'worktree', [SessionConfigKey.Branch]: 'main' } }); + + await isolation.discardSessionWorktree(sessionUri, sessionId, await isolation.prepareSessionDeletion(sessionUri, sessionId)); + + assert.deepStrictEqual({ + removeCalls: removeCalls.map(call => ({ worktree: call.worktree.toString(), force: call.force })), + metadata: await db.getMetadataObject({ + 'copilot.worktree.branchName': true, + 'copilot.worktree.path': true, + 'copilot.worktree.repositoryRoot': true, + [META_DIFF_BASE_BRANCH]: true, + }), + }, { + removeCalls: [{ worktree: worktree!.toString(), force: true }], + metadata: { + 'copilot.worktree.branchName': undefined, + 'copilot.worktree.path': undefined, + 'copilot.worktree.repositoryRoot': undefined, + [META_DIFF_BASE_BRANCH]: undefined, + }, + }); + }); + test('session deletion removes a persisted worktree after a process restart', async () => { const worktree = URI.joinPath(worktreesRoot, 'persisted-worktree'); mkdirSync(worktree.fsPath, { recursive: true }); diff --git a/src/vs/platform/environment/common/argv.ts b/src/vs/platform/environment/common/argv.ts index 3571749ab1e8f..f6108fb1799d4 100644 --- a/src/vs/platform/environment/common/argv.ts +++ b/src/vs/platform/environment/common/argv.ts @@ -55,7 +55,7 @@ export interface NativeParsedArgs { 'new-window'?: boolean; 'reuse-window'?: boolean; 'agents'?: boolean; - 'session-title'?: string; + 'session-title-base64'?: string; locale?: string; 'user-data-dir'?: string; 'prof-startup'?: boolean; diff --git a/src/vs/platform/environment/node/argv.ts b/src/vs/platform/environment/node/argv.ts index 0cfada5200fe8..03fbc91d547c2 100644 --- a/src/vs/platform/environment/node/argv.ts +++ b/src/vs/platform/environment/node/argv.ts @@ -109,7 +109,7 @@ export const OPTIONS: OptionDescriptions> = { 'new-window': { type: 'boolean', cat: 'o', alias: 'n', description: localize('newWindow', "Force to open a new window.") }, 'reuse-window': { type: 'boolean', cat: 'o', alias: 'r', description: localize('reuseWindow', "Force to open a file or folder in an already opened window.") }, 'agents': { type: 'boolean', cat: 'o', deprecates: ['sessions'], description: localize('agents', "Opens the agents window.") }, - 'session-title': { type: 'string' }, + 'session-title-base64': { type: 'string' }, 'wait': { type: 'boolean', cat: 'o', alias: 'w', description: localize('wait', "Wait for the files to be closed before returning.") }, 'waitMarkerFilePath': { type: 'string' }, 'locale': { type: 'string', cat: 'o', args: 'locale', description: localize('locale', "The locale to use (e.g. en-US or zh-TW).") }, diff --git a/src/vs/platform/github/common/githubTransport.ts b/src/vs/platform/github/common/githubTransport.ts index a7ccf5cd8a11b..e7452b6d590d3 100644 --- a/src/vs/platform/github/common/githubTransport.ts +++ b/src/vs/platform/github/common/githubTransport.ts @@ -212,7 +212,6 @@ export class GitHubTransport extends Disposable implements IGitHubTransport { for (let redirectCount = 0; redirectCount <= maximumRedirects; redirectCount++) { const headers: Record = { 'Accept': authenticated ? 'application/vnd.github+json' : 'text/plain, application/octet-stream', - 'Cache-Control': 'no-store', 'X-GitHub-Api-Version': defaultApiVersion, }; if (authenticated) { @@ -322,7 +321,6 @@ export class GitHubTransport extends Disposable implements IGitHubTransport { headers: { 'Accept': 'application/json', 'Authorization': `Bearer ${token}`, - 'Cache-Control': 'no-store', 'Content-Type': 'application/json', 'X-GitHub-Api-Version': defaultApiVersion, }, @@ -415,7 +413,6 @@ export class GitHubTransport extends Disposable implements IGitHubTransport { const headers: Record = { 'Accept': request.accept ?? 'application/vnd.github+json', 'Authorization': `Bearer ${token}`, - 'Cache-Control': 'no-store', 'X-GitHub-Api-Version': request.apiVersion ?? defaultApiVersion, }; if (cached) { diff --git a/src/vs/platform/github/test/node/githubTransport.test.ts b/src/vs/platform/github/test/node/githubTransport.test.ts index 4b40dc6737197..a3c2cda1ed7dc 100644 --- a/src/vs/platform/github/test/node/githubTransport.test.ts +++ b/src/vs/platform/github/test/node/githubTransport.test.ts @@ -33,7 +33,7 @@ suite('GitHubTransport', () => { } } - test('always reaches the injected fetch with explicit no-store behavior', async () => { + test('uses fetch cache mode without a Cache-Control request header', async () => { await withServer(async server => { server.enqueue( gitHubRestStep({ method: 'GET', path: '/repos/o/r/issues/1', response: gitHubJsonResponse({ value: 1 }) }), @@ -60,8 +60,8 @@ suite('GitHubTransport', () => { values: [1, 2], serverRequests: 2, fetchOptions: [ - { cache: 'no-store', cacheControl: 'no-store' }, - { cache: 'no-store', cacheControl: 'no-store' }, + { cache: 'no-store', cacheControl: undefined }, + { cache: 'no-store', cacheControl: undefined }, ], }); server.assertSatisfied(); @@ -245,7 +245,7 @@ suite('GitHubTransport', () => { errors: [{ message: 'field denied', type: 'FORBIDDEN', path: ['repository', 'viewerPermission'] }], rateLimit: { limit: 5000, remaining: 7, used: 3, resetAt: Date.parse('2030-01-01T00:00:00.000Z') }, authorizationIsExpected: true, - requestHeaders: { cacheControl: 'no-store', authorization: '******' }, + requestHeaders: { cacheControl: undefined, authorization: '******' }, }); server.assertSatisfied(); }); diff --git a/src/vs/platform/policy/common/copilotManagedSettings.ts b/src/vs/platform/policy/common/copilotManagedSettings.ts index 8f2d250245410..da7352e8f1924 100644 --- a/src/vs/platform/policy/common/copilotManagedSettings.ts +++ b/src/vs/platform/policy/common/copilotManagedSettings.ts @@ -16,6 +16,11 @@ export type { ManagedSettingsData } from '../../../base/common/policy.js'; export type RawManagedSettingsData = Readonly>; +/** Whether a raw managed-settings document contains at least one top-level setting. */ +export function hasRawManagedSettings(data: RawManagedSettingsData | undefined): boolean { + return data !== undefined && Object.keys(data).length > 0; +} + /** Windows registry root for GitHub Copilot policies. */ export const GITHUB_COPILOT_WIN32_REGISTRY_PATH = 'SOFTWARE\\Policies\\GitHubCopilot'; @@ -64,6 +69,9 @@ export const COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY = 'forceRemoteSettingsRef */ export const COPILOT_SANDBOX_ENABLED_KEY = 'sandbox.enabled'; +/** Managed-settings key that permits explicitly bypassing the sandbox. */ +export const COPILOT_SANDBOX_ALLOW_BYPASS_KEY = 'sandbox.allowBypass'; + /** * Managed-settings controls consumed by the delivery pipeline itself rather than by a * configuration policy. Native MDM must watch these even though no setting declares them. @@ -71,6 +79,7 @@ export const COPILOT_SANDBOX_ENABLED_KEY = 'sandbox.enabled'; export const MANAGED_SETTINGS_CONTROL_DEFINITIONS: IManagedSettingsPolicyDefinitions = { [COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY]: { type: 'boolean' }, [COPILOT_SANDBOX_ENABLED_KEY]: { type: 'boolean' }, + [COPILOT_SANDBOX_ALLOW_BYPASS_KEY]: { type: 'boolean' }, }; /** Policy-only configuration delivery slot for {@link COPILOT_STRICT_PLUGIN_ONLY_CUSTOMIZATION_KEY}. */ @@ -213,6 +222,11 @@ export function managedModelValue(): (policyData: IPolicyData) => ManagedSetting return managedModelValueCallback; } +/** Forces a boolean setting off while the user is governed by managed settings. */ +export function managedSettingsDisabledValue(policyData: IPolicyData): boolean | undefined { + return policyData.managedSettingsActive === true ? false : undefined; +} + /** * `value` callback shared by the third-party agent harness policies (`Claude3PIntegration`, * `Codex3PIntegration`): forces the harness off when the account disables chat preview features, @@ -223,9 +237,7 @@ export function managedModelValue(): (policyData: IPolicyData) => ManagedSetting * every managed control the enterprise set. */ export function thirdPartyAgentEnabledValue(policyData: IPolicyData): boolean | undefined { - return policyData.chat_preview_features_enabled === false || policyData.managedSettingsActive === true - ? false - : undefined; + return policyData.chat_preview_features_enabled === false ? false : managedSettingsDisabledValue(policyData); } export const INativeManagedSettingsService = createDecorator('nativeManagedSettingsService'); diff --git a/src/vs/platform/policy/test/common/copilotManagedSettings.test.ts b/src/vs/platform/policy/test/common/copilotManagedSettings.test.ts index a88d7edae7c0b..1c509b8f0671b 100644 --- a/src/vs/platform/policy/test/common/copilotManagedSettings.test.ts +++ b/src/vs/platform/policy/test/common/copilotManagedSettings.test.ts @@ -7,7 +7,7 @@ import assert from 'assert'; import { IStringDictionary } from '../../../../base/common/collections.js'; import { IPolicyData } from '../../../../base/common/defaultAccount.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { collectManagedSettingsDefinitions, COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY, COPILOT_MODEL_KEY, COPILOT_TOP_LEVEL_MODEL_KEY, hasManagedSettingsDefinitions, managedModelValue, managedSettingValue, projectManagedSettings, pickManagedSettings, resolveForceRemoteSettingsRefresh } from '../../common/copilotManagedSettings.js'; +import { collectManagedSettingsDefinitions, COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY, COPILOT_MODEL_KEY, COPILOT_TOP_LEVEL_MODEL_KEY, hasManagedSettingsDefinitions, managedModelValue, managedSettingsDisabledValue, managedSettingValue, projectManagedSettings, pickManagedSettings, resolveForceRemoteSettingsRefresh } from '../../common/copilotManagedSettings.js'; import { PolicyDefinition } from '../../common/policy.js'; suite('Copilot managed settings projection', () => { @@ -106,6 +106,20 @@ suite('Copilot managed settings projection', () => { assert.strictEqual(managedModelValue(), managedModelValue()); }); + test('managedSettingsDisabledValue forces false only while managed settings are active', () => { + assert.deepStrictEqual({ + active: managedSettingsDisabledValue({ managedSettingsActive: true }), + inactive: managedSettingsDisabledValue({ managedSettingsActive: false }), + unset: managedSettingsDisabledValue({}), + previewFeaturesDisabled: managedSettingsDisabledValue({ chat_preview_features_enabled: false }), + }, { + active: false, + inactive: undefined, + unset: undefined, + previewFeaturesDisabled: undefined, + }); + }); + test('forceRemoteSettingsRefresh resolves across all channels and reports the winning source', () => { const key = COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY; assert.deepStrictEqual({ diff --git a/src/vs/platform/policy/test/node/nativeManagedSettingsService.test.ts b/src/vs/platform/policy/test/node/nativeManagedSettingsService.test.ts index 9d98995a30bbe..9e88d1c884768 100644 --- a/src/vs/platform/policy/test/node/nativeManagedSettingsService.test.ts +++ b/src/vs/platform/policy/test/node/nativeManagedSettingsService.test.ts @@ -10,7 +10,7 @@ import { ManagedSettingsData } from '../../../../base/common/policy.js'; import { IChannel } from '../../../../base/parts/ipc/common/ipc.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { NullLogService } from '../../../log/common/log.js'; -import { COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY, COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY, COPILOT_SANDBOX_ENABLED_KEY } from '../../common/copilotManagedSettings.js'; +import { COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY, COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY, COPILOT_SANDBOX_ALLOW_BYPASS_KEY, COPILOT_SANDBOX_ENABLED_KEY } from '../../common/copilotManagedSettings.js'; import { NativeManagedSettingsChannelClient } from '../../common/nativeManagedSettingsIpc.js'; import { PolicyValue } from '../../common/policy.js'; import { NativeManagedSettingsService, NativePolicyWatcherFactory } from '../../node/nativeManagedSettingsService.js'; @@ -27,6 +27,7 @@ suite('NativeManagedSettingsService', () => { [COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY]: { type: 'string' }, [COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY]: { type: 'boolean' }, [COPILOT_SANDBOX_ENABLED_KEY]: { type: 'boolean' }, + [COPILOT_SANDBOX_ALLOW_BYPASS_KEY]: { type: 'boolean' }, }); onDidChange = callback; callback({}); @@ -68,6 +69,7 @@ suite('NativeManagedSettingsService', () => { watchedSettings: { [COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY]: { type: 'boolean' }, [COPILOT_SANDBOX_ENABLED_KEY]: { type: 'boolean' }, + [COPILOT_SANDBOX_ALLOW_BYPASS_KEY]: { type: 'boolean' }, }, managedSettings: { [COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY]: true }, }); diff --git a/src/vs/platform/sandbox/test/common/terminalSandboxEngine.test.ts b/src/vs/platform/sandbox/test/common/terminalSandboxEngine.test.ts index efdcd9e56de16..dc0c560122926 100644 --- a/src/vs/platform/sandbox/test/common/terminalSandboxEngine.test.ts +++ b/src/vs/platform/sandbox/test/common/terminalSandboxEngine.test.ts @@ -179,6 +179,7 @@ suite('TerminalSandboxEngine', () => { fileService = new MockFileService(); sandboxSettings.set(AgentSandboxSettingId.AgentSandboxEnabled, AgentSandboxEnabledValue.On); + sandboxSettings.set(AgentSandboxSettingId.AgentSandboxAllowNetwork, false); sandboxSettings.set(AgentSandboxSettingId.AgentSandboxRetryWithAllowNetworkRequests, true); instantiationService.stub(IFileService, fileService); diff --git a/src/vs/sessions/browser/parts/chatGroupView.ts b/src/vs/sessions/browser/parts/chatGroupView.ts index dd6bc1ad8af62..548207bcddec9 100644 --- a/src/vs/sessions/browser/parts/chatGroupView.ts +++ b/src/vs/sessions/browser/parts/chatGroupView.ts @@ -13,16 +13,19 @@ import { localize } from '../../../nls.js'; import { ICommandService } from '../../../platform/commands/common/commands.js'; import { IConfigurationService } from '../../../platform/configuration/common/configuration.js'; import { IInstantiationService } from '../../../platform/instantiation/common/instantiation.js'; +import { ServiceCollection } from '../../../platform/instantiation/common/serviceCollection.js'; +import { IContextKey, IContextKeyService } from '../../../platform/contextkey/common/contextkey.js'; import { getChatSessionArchiveActionPresentation, getChatSessionArchiveActionWording } from '../../../platform/chat/common/sessionArchiveActions.js'; import { ChatInteractivity, IChat, SessionStatus } from '../../services/sessions/common/session.js'; import { IActiveSession } from '../../services/sessions/common/sessionsManagement.js'; import { UNARCHIVE_SESSION_COMMAND_ID } from '../../common/sessionCommands.js'; +import { SessionFocusedChatIsRenameTargetContext } from '../../common/contextkeys.js'; import { IChatViewFactory } from '../../services/chatView/browser/chatViewFactory.js'; import { ChatCompositeBar, IChatCompositeBarDelegate } from './chatCompositeBar.js'; import { type IRemoteHostUnavailableEmptyStateContent, RemoteHostUnavailableEmptyState } from './remoteHostUnavailableEmptyState.js'; import { SessionRemoteConnection } from './sessionRemoteConnection.js'; import { ISessionReadOnlyBannerContent, SessionReadOnlyBanner } from './sessionReadOnlyBanner.js'; -import { AbstractChatView, ChatViewKind, IChatViewOptions } from './chatView.js'; +import { AbstractChatView, ChatViewKind, IChatViewOptions, ISelectWorkspaceOptions } from './chatView.js'; /** * The data + callbacks a {@link ChatGroupView} needs from its owning @@ -97,6 +100,8 @@ export class ChatGroupView extends Disposable implements ISerializableView { private readonly _currentView = this._register(new MutableDisposable()); private readonly _contextDisposables = this._register(new DisposableStore()); + private readonly _scopedInstantiationService: IInstantiationService; + private readonly _focusedChatIsRenameTargetKey: IContextKey; private readonly _connection: SessionRemoteConnection; /** The configured wording for the archive/unarchive action (Archive vs Delete). */ @@ -120,8 +125,12 @@ export class ChatGroupView extends Disposable implements ISerializableView { @IInstantiationService private readonly _instantiationService: IInstantiationService, @ICommandService private readonly _commandService: ICommandService, @IConfigurationService configurationService: IConfigurationService, + @IContextKeyService contextKeyService: IContextKeyService, ) { super(); + const scopedContextKeyService = this._register(contextKeyService.createScoped(this.element)); + this._scopedInstantiationService = this._register(this._instantiationService.createChild(new ServiceCollection([IContextKeyService, scopedContextKeyService]))); + this._focusedChatIsRenameTargetKey = SessionFocusedChatIsRenameTargetContext.bindTo(scopedContextKeyService); // Assigned here rather than as a field initializer: `_instantiationService` // is a parameter property of this class, which class-field semantics @@ -137,7 +146,7 @@ export class ChatGroupView extends Disposable implements ISerializableView { this._barContainer = $('.chat-group-view-bar'); this.element.appendChild(this._barContainer); - this._compositeBar = this._register(this._instantiationService.createInstance(ChatCompositeBar, undefined)); + this._compositeBar = this._register(this._scopedInstantiationService.createInstance(ChatCompositeBar, undefined)); this._barContainer.appendChild(this._compositeBar.element); // Single status banner, shown flush below this group's tab bar when the @@ -179,6 +188,7 @@ export class ChatGroupView extends Disposable implements ISerializableView { this._connection.setSession(context?.session); if (!context) { + this._focusedChatIsRenameTargetKey.reset(); this._compositeBar.setGroup(undefined); this._currentView.clear(); this._setRemoteHostUnavailableEmptyState(undefined); @@ -203,6 +213,11 @@ export class ChatGroupView extends Disposable implements ISerializableView { const activeResource = context.activeChatResource.read(reader); return context.chats.read(reader).find(c => c.resource.toString() === activeResource); }); + this._contextDisposables.add(autorun(reader => { + const activeResource = context.activeChatResource.read(reader); + const mainResource = context.mainChatResource.read(reader); + this._focusedChatIsRenameTargetKey.set(activeResource !== mainResource); + })); const currentView = observableValue(this._contextDisposables, this._currentView.value); const readOnlyContent = derived(reader => { @@ -238,7 +253,6 @@ export class ChatGroupView extends Disposable implements ISerializableView { if (recovery) { return { banner: undefined, recovery }; } - return { banner: this._connection.bannerContent.read(reader), recovery: undefined }; }); @@ -260,8 +274,8 @@ export class ChatGroupView extends Disposable implements ISerializableView { let view = this._currentView.value; if (!view || view.kind !== desiredKind) { view = desiredKind === 'chat' - ? this._chatViewFactory.createChatView() - : this._chatViewFactory.createNewChatView(desiredKind === 'newChatInSession', context.options); + ? this._chatViewFactory.createChatView(this._scopedInstantiationService) + : this._chatViewFactory.createNewChatView(desiredKind === 'newChatInSession', context.options, this._scopedInstantiationService); this._contentContainer.replaceChildren(view.element, this._remoteHostUnavailableEmptyState.domNode); this._currentView.value = view; currentView.set(view, undefined); @@ -333,8 +347,8 @@ export class ChatGroupView extends Disposable implements ISerializableView { return this._currentView.value?.submitInput() ?? Promise.resolve(false); } - selectWorkspace(folderUri: URI, providerId?: string): void { - this._currentView.value?.selectWorkspace(folderUri, providerId); + selectWorkspace(folderUri: URI, options?: ISelectWorkspaceOptions): void { + this._currentView.value?.selectWorkspace(folderUri, options); } prefillInput(text: string): void { diff --git a/src/vs/sessions/browser/parts/chatGroupsView.ts b/src/vs/sessions/browser/parts/chatGroupsView.ts index da55bf5bce6c3..a5e7cb1532168 100644 --- a/src/vs/sessions/browser/parts/chatGroupsView.ts +++ b/src/vs/sessions/browser/parts/chatGroupsView.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import './media/chatGroupsView.css'; -import { $, size } from '../../../base/browser/dom.js'; +import { $, isAncestorOfActiveElement, size } from '../../../base/browser/dom.js'; import { Color } from '../../../base/common/color.js'; import { onUnexpectedError } from '../../../base/common/errors.js'; import { DisposableMap, DisposableStore, MutableDisposable, toDisposable } from '../../../base/common/lifecycle.js'; @@ -19,7 +19,7 @@ import { agentsPanelBorder } from '../../common/theme.js'; import { IChat } from '../../services/sessions/common/session.js'; import { IActiveSession } from '../../services/sessions/common/sessionsManagement.js'; import { ISessionsService } from '../../services/sessions/browser/sessionsService.js'; -import { IChatViewOptions } from './chatView.js'; +import { IChatViewOptions, ISelectWorkspaceOptions } from './chatView.js'; import { ChatGroupView, IChatGroupContext } from './chatGroupView.js'; import { ChatDropZone, ChatGroupDropTarget, IChatGroupDropTargetDelegate } from './chatGroupDropTarget.js'; import { IDraggedSessionChat, isSessionChatDrag } from '../dnd.js'; @@ -669,6 +669,19 @@ export class ChatGroupsView extends Themable { this._persistLayout(); } + getFocusedChat(): IChat | undefined { + const group = this._getFocusedGroup(); + if (!group) { + return undefined; + } + const activeResource = group.activeResourceId.get(); + return group.chats.get().find(chat => chat.resource.toString() === activeResource); + } + + private _getFocusedGroup(): IGroupEntry | undefined { + return this._groups.find(group => isAncestorOfActiveElement(group.view.element)); + } + /** * Handles focus entering a group: promotes it to the active group and, when * that group is currently collapsed to its minimum size in a split, expands it @@ -792,8 +805,8 @@ export class ChatGroupsView extends Themable { return this._activeGroup?.view.submitInput() ?? Promise.resolve(false); } - selectWorkspace(folderUri: URI, providerId?: string): void { - this._activeGroup?.view.selectWorkspace(folderUri, providerId); + selectWorkspace(folderUri: URI, options?: ISelectWorkspaceOptions): void { + this._activeGroup?.view.selectWorkspace(folderUri, options); } prefillInput(text: string): void { diff --git a/src/vs/sessions/browser/parts/chatView.ts b/src/vs/sessions/browser/parts/chatView.ts index 7ad49b776284e..f1638f696d91c 100644 --- a/src/vs/sessions/browser/parts/chatView.ts +++ b/src/vs/sessions/browser/parts/chatView.ts @@ -26,6 +26,11 @@ export type ChatViewKind = 'newSession' | 'newChatInSession' | 'chat'; export interface IChatViewOptions { } +export interface ISelectWorkspaceOptions { + readonly providerId?: string; + readonly preferDevContainer?: boolean; +} + /** * Base class for a view that lives inside the {@link SessionsPart} internal grid. * Each instance occupies a single grid leaf. Subclasses populate {@link element} @@ -81,7 +86,7 @@ export abstract class AbstractChatView extends Disposable implements ISerializab * implementation is a no-op; subclasses that host a workspace picker * (e.g. `NewChatView`) override this to forward the selection. */ - selectWorkspace(_folderUri: URI, _providerId?: string): void { + selectWorkspace(_folderUri: URI, _options?: ISelectWorkspaceOptions): void { // no-op by default } diff --git a/src/vs/sessions/browser/parts/sessionRemoteConnection.ts b/src/vs/sessions/browser/parts/sessionRemoteConnection.ts index cadf62c0953f6..e9b094ecb2ac3 100644 --- a/src/vs/sessions/browser/parts/sessionRemoteConnection.ts +++ b/src/vs/sessions/browser/parts/sessionRemoteConnection.ts @@ -17,7 +17,13 @@ import { ISessionsProvidersService } from '../../services/sessions/browser/sessi import { IRemoteHostUnavailableEmptyStateContent } from './remoteHostUnavailableEmptyState.js'; import { ISessionReadOnlyBannerContent } from './sessionReadOnlyBanner.js'; -const RECONNECTING_BANNER_DELAY = 1_000; +/** + * How long a host must stay unreachable before the banner appears. + * + * Sized to outlast a transport blip the protocol client heals by itself: such a + * reconnect preserves session state, so the user would not otherwise notice it. + */ +const RECONNECTING_BANNER_DELAY = 5_000; function isSameRemoteConnectionStatus(a: SessionRemoteConnectionStatus | undefined, b: SessionRemoteConnectionStatus | undefined): boolean { if (!a || !b) { @@ -76,6 +82,17 @@ export class SessionRemoteConnection extends Disposable { && this._attempt.read(reader) === undefined; }); + /** + * When the current outage began, or `undefined` while the host is reachable. + * + * Recomputed eagerly so the cache cannot outlive its observers: nothing reads + * the reconnecting state while the host is connected, and a derived that + * stops being observed keeps its last value without ever recomputing it, so a + * later outage would inherit the previous one's start time and skip the delay. + * + * Keyed by session only to guard future reuse: a ChatGroupView is currently + * created per session, so the cache cannot outlive the session it belongs to. + */ private readonly _reconnectingSince = derivedObservableWithCache<{ readonly session: IActiveSession; readonly since: number } | undefined>(this, (reader, last) => { const session = this._session.read(reader); const status = this._getEffectiveStatus(reader); @@ -83,10 +100,8 @@ export class SessionRemoteConnection extends Disposable { if (!session || status?.kind !== 'reconnecting' || attempt?.kind === 'active') { return undefined; } - // Keyed by session only to guard future reuse: a ChatGroupView is currently - // created per session, so the cache cannot outlive the session it belongs to. return last?.session === session ? last : { session, since: Date.now() }; - }); + }).recomputeInitiallyAndOnChange(this._store); private readonly _reconnectingBannerVisible = derived(this, reader => { const reconnecting = this._reconnectingSince.read(reader); diff --git a/src/vs/sessions/browser/parts/sessionView.ts b/src/vs/sessions/browser/parts/sessionView.ts index 7b8e1e96e9641..637668bf2ea6a 100644 --- a/src/vs/sessions/browser/parts/sessionView.ts +++ b/src/vs/sessions/browser/parts/sessionView.ts @@ -14,7 +14,8 @@ import { ServiceCollection } from '../../../platform/instantiation/common/servic import { IContextKey, IContextKeyService } from '../../../platform/contextkey/common/contextkey.js'; import { IThemeService } from '../../../platform/theme/common/themeService.js'; import { IActiveSession } from '../../services/sessions/common/sessionsManagement.js'; -import { AbstractChatView, IChatViewOptions } from './chatView.js'; +import { IChat } from '../../services/sessions/common/session.js'; +import { AbstractChatView, IChatViewOptions, ISelectWorkspaceOptions } from './chatView.js'; import { ChatGroupsView } from './chatGroupsView.js'; import { SessionHeader, SessionViewFloatingToolbar } from './sessionHeader.js'; import { ISessionContext, SessionContext } from '../../services/sessions/browser/sessionContext.js'; @@ -284,9 +285,17 @@ export class SessionView extends Disposable implements ISerializableView { return this._isVisible && this._header.startTitleEditing(); } - selectWorkspace(folderUri: URI, providerId?: string): void { + getFocusedChat(): IChat | undefined { + return this._groupsView.getFocusedChat(); + } + + getSession(): IActiveSession | undefined { + return this._currentSession; + } + + selectWorkspace(folderUri: URI, options?: ISelectWorkspaceOptions): void { const standaloneView = this._standaloneView.value; - standaloneView ? standaloneView.selectWorkspace(folderUri, providerId) : this._groupsView.selectWorkspace(folderUri, providerId); + standaloneView ? standaloneView.selectWorkspace(folderUri, options) : this._groupsView.selectWorkspace(folderUri, options); } /** Opens the given chat in a group beside the active one ("open to the side"). */ diff --git a/src/vs/sessions/browser/parts/sessionsPart.ts b/src/vs/sessions/browser/parts/sessionsPart.ts index d1c0c890c7bff..413b18caa5404 100644 --- a/src/vs/sessions/browser/parts/sessionsPart.ts +++ b/src/vs/sessions/browser/parts/sessionsPart.ts @@ -15,7 +15,7 @@ import { LayoutPriority } from '../../../base/browser/ui/splitview/splitview.js' import { Direction, SerializableGrid, Sizing } from '../../../base/browser/ui/grid/grid.js'; import { Part } from '../../../workbench/browser/part.js'; import { ActiveSessionsContext, MultipleSessionsVisibleContext, SessionsFocusContext } from '../../common/contextkeys.js'; -import { $, addDisposableGenericMouseDownListener, addDisposableListener, EventType, isAncestor, trackFocus } from '../../../base/browser/dom.js'; +import { $, addDisposableGenericMouseDownListener, addDisposableListener, EventType, isAncestor, isAncestorOfActiveElement, trackFocus } from '../../../base/browser/dom.js'; import { IActiveSession } from '../../services/sessions/common/sessionsManagement.js'; import { SessionView } from './sessionView.js'; import { DisposableStore } from '../../../base/common/lifecycle.js'; @@ -288,6 +288,10 @@ export class SessionsPart extends Part { return this._slots.find(s => s.boundSessionId === sessionId)?.view; } + getFocusedSessionView(): SessionView | undefined { + return this._slots.find(slot => isAncestorOfActiveElement(slot.view.element))?.view; + } + /** * Moves keyboard focus into the session view hosting the given session id (or * the placeholder view when `sessionId` is `undefined`), first revealing it in diff --git a/src/vs/sessions/browser/parts/sessionsParts.ts b/src/vs/sessions/browser/parts/sessionsParts.ts index 39791dd3ee5b9..54fe1f4da0c32 100644 --- a/src/vs/sessions/browser/parts/sessionsParts.ts +++ b/src/vs/sessions/browser/parts/sessionsParts.ts @@ -77,6 +77,10 @@ export class SessionsParts extends Disposable implements ISessionsPartService { return this._mainPart.getSessionView(sessionId); } + getFocusedSessionView(): SessionView | undefined { + return this._mainPart.getFocusedSessionView(); + } + getProgressIndicator(): IProgressIndicator { return this._mainPart.getProgressIndicator(); } diff --git a/src/vs/sessions/browser/workbench.ts b/src/vs/sessions/browser/workbench.ts index b7ed0dd55b1c5..b07fe04791440 100644 --- a/src/vs/sessions/browser/workbench.ts +++ b/src/vs/sessions/browser/workbench.ts @@ -82,9 +82,20 @@ import { ICustomViewGridPartService } from '../services/customView/browser/custo import { ICustomViewDescriptor } from '../services/customView/browser/customView.js'; import { ISessionsSetUpService } from './sessionsSetUpService.js'; import { AGENTS_FLOATING_PANEL_GAP } from '../common/layoutConstants.js'; +import { ITelemetryService } from '../../platform/telemetry/common/telemetry.js'; const PHONE_NOTIFICATION_ROW_HEIGHT = 44; +type SessionsWindowLayoutEvent = { + layout: string; +}; + +type SessionsWindowLayoutClassification = { + owner: 'sandy081'; + comment: 'Tracks the layout selected when an Agents window opens.'; + layout: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The Agents window layout selected at startup: classic or sidePane.' }; +}; + //#region Workbench Options export interface IWorkbenchOptions { @@ -604,6 +615,7 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic })); SinglePaneLayoutEnabledContext.bindTo(contextKeyService).set(this.isSinglePaneLayoutEnabled); + this.logWindowLayout(accessor.get(ITelemetryService)); // Virtual keyboard tracking (visualViewport): publishes the // keyboard height as an observable, mirrors it onto the @@ -652,6 +664,12 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic } } + private logWindowLayout(telemetryService: ITelemetryService): void { + telemetryService.publicLog2('agents/windowLayout', { + layout: this.isSinglePaneLayoutEnabled ? 'sidePane' : 'classic' + }); + } + private initServices(serviceCollection: ServiceCollection): IInstantiationService { // Layout Service serviceCollection.set(IAgentWorkbenchLayoutService, this); diff --git a/src/vs/sessions/common/agentHostSessionsProvider.ts b/src/vs/sessions/common/agentHostSessionsProvider.ts index 5a47d165a3349..fa99397f2e590 100644 --- a/src/vs/sessions/common/agentHostSessionsProvider.ts +++ b/src/vs/sessions/common/agentHostSessionsProvider.ts @@ -171,6 +171,8 @@ export interface IAgentHostSessionsProvider extends ISessionsProvider { isDevContainerEnabled?(sessionId: string): boolean; /** Set whether this draft should run on a Dev Container Agent Host. */ setDevContainerEnabled?(sessionId: string, enabled: boolean): void; + /** Enable Dev Container execution once availability resolves for this draft. */ + preferDevContainer?(sessionId: string): void; // -- Dynamic Session Config -- @@ -186,6 +188,8 @@ export interface IAgentHostSessionsProvider extends ISessionsProvider { isSessionConfigResolving(sessionId: string): IObservable; /** Sets one dynamic configuration property and re-resolves the schema. */ setSessionConfigValue(sessionId: string, property: string, value: unknown): Promise; + /** Tracks a draft configuration side effect that must finish before the first request. */ + trackSessionConfigOperation(sessionId: string, operation: Promise): void; /** * Replaces the full set of running-session config values atomically. * diff --git a/src/vs/sessions/common/contextkeys.ts b/src/vs/sessions/common/contextkeys.ts index 151124e19ced6..3c195263dd674 100644 --- a/src/vs/sessions/common/contextkeys.ts +++ b/src/vs/sessions/common/contextkeys.ts @@ -35,6 +35,7 @@ export const SessionHasSideChatsContext = new RawContextKey('sessionHas export const SessionShouldShowChatTabsContext = new RawContextKey('sessionShouldShowChatTabs', false, localize('sessionShouldShowChatTabs', "Whether the session view's chat tab strip is shown, i.e. the session has more than one chat actually showing as a tab. A single visible tab always hides the strip")); export const SessionHasMultipleOpenChatsContext = new RawContextKey('sessionHasMultipleOpenChats', false, localize('sessionHasMultipleOpenChats', "Whether the session view's session has more than one open chat (the tabs shown in the strip, including in-composer drafts). Used to scope chat-to-chat navigation (next/previous chat, the Ctrl+Tab chat switcher)")); export const SessionActiveChatIsClosableContext = new RawContextKey('sessionActiveChatIsClosable', false, localize('sessionActiveChatIsClosable', "Whether the session's active chat can be closed (hidden) from the tab strip, i.e. it is not the main chat. Includes read-only subagent chats. Used to scope the close-chat keybinding so it closes the tab instead of the session")); +export const SessionFocusedChatIsRenameTargetContext = new RawContextKey('sessionFocusedChatIsRenameTarget', false, localize('sessionFocusedChatIsRenameTarget', "Whether the focused chat group's visible chat is a non-main chat that should receive the chat-specific rename command instead of the session rename command")); export const SessionActiveChatIsDeletableContext = new RawContextKey('sessionActiveChatIsDeletable', false, localize('sessionActiveChatIsDeletable', "Whether the session's active chat can be permanently deleted from the tab strip, i.e. it is a real, user-created non-main chat (not the main chat and not a tool-spawned subagent chat, which are transient children). Used to scope the delete-chat keybinding")); export const SessionIsReadContext = new RawContextKey('sessionIsRead', true, localize('sessionIsRead', "Whether the session has been marked as read")); export const SessionIsArchivedContext = new RawContextKey('sessionIsArchived', false, localize('sessionIsArchived', "Whether the session in scope is archived/marked as done (the active session globally, or a specific session within an isolated component such as the session view or a context menu overlay)")); diff --git a/src/vs/sessions/common/sessionCommands.ts b/src/vs/sessions/common/sessionCommands.ts index fd1ef1b11b64a..8d3af81312995 100644 --- a/src/vs/sessions/common/sessionCommands.ts +++ b/src/vs/sessions/common/sessionCommands.ts @@ -16,6 +16,9 @@ export const UNARCHIVE_SESSION_COMMAND_ID = 'sessionsViewPane.unarchiveSession'; /** Renames a session. Registered in `sessionsViewActions.ts`. */ export const RENAME_SESSION_COMMAND_ID = 'sessionsViewPane.renameSession'; +/** Renames a chat. Registered in `sessionsActions.ts`. */ +export const RENAME_CHAT_COMMAND_ID = 'sessions.chatCompositeBar.renameChat'; + /** Archives one or more sessions. Registered in `sessionsViewActions.ts`. */ export const ARCHIVE_SESSION_COMMAND_ID = 'sessionsViewPane.archiveSession'; diff --git a/src/vs/sessions/contrib/changes/browser/changes.contribution.ts b/src/vs/sessions/contrib/changes/browser/changes.contribution.ts index 1d2c25162cdc1..0e7a942f7c58b 100644 --- a/src/vs/sessions/contrib/changes/browser/changes.contribution.ts +++ b/src/vs/sessions/contrib/changes/browser/changes.contribution.ts @@ -25,7 +25,6 @@ import './changesActions.js'; import './changesViewActions.js'; import './changesetReviewActions.js'; import './checksActions.js'; -import './media/multiFileDiffEditor.css'; import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; import { Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; import { ChangesViewService } from './changesViewService.js'; diff --git a/src/vs/sessions/contrib/changes/browser/changesActions.ts b/src/vs/sessions/contrib/changes/browser/changesActions.ts index 0c2234bb598f1..9cf6218dfbf1f 100644 --- a/src/vs/sessions/contrib/changes/browser/changesActions.ts +++ b/src/vs/sessions/contrib/changes/browser/changesActions.ts @@ -23,7 +23,7 @@ import { MultiDiffEditor } from '../../../../workbench/contrib/multiDiffEditor/b import { DiffEditorWidget } from '../../../../editor/browser/widget/diffEditor/diffEditorWidget.js'; import { IAgentWorkbenchLayoutService } from '../../../browser/workbench.js'; import { Menus } from '../../../browser/menus.js'; -import { AGENT_HOST_COMMIT_CHANGESET_OPERATION_ID, AGENT_HOST_PULL_REQUEST_OPERATION_IDS, AGENT_HOST_SYNC_CHANGESET_OPERATION_ID } from '../../../../platform/agentHost/common/agentHostChangesetOperationService.js'; +import { AGENT_HOST_CHECKOUT_CHANGESET_OPERATION_ID, AGENT_HOST_COMMIT_CHANGESET_OPERATION_ID, AGENT_HOST_PULL_REQUEST_OPERATION_IDS, AGENT_HOST_SYNC_CHANGESET_OPERATION_ID } from '../../../../platform/agentHost/common/agentHostChangesetOperationService.js'; import { SessionHasOpenPullRequestContext, SessionPrimaryPullRequestOperationContext } from '../../../common/contextkeys.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; import { SessionChangesetOperationScope, SessionChangesetOperationStatus, SessionStatus, UNCOMMITTED_CHANGES_CHANGESET_ID } from '../../../services/sessions/common/session.js'; @@ -410,6 +410,7 @@ export class NewSessionUncommittedChangesetOperationsActionContribution extends ?.find(candidate => candidate.id === UNCOMMITTED_CHANGES_CHANGESET_ID && candidate.isEnabled.read(reader)); const operations = changeset?.operations.read(reader) .filter(operation => operation.id !== AGENT_HOST_SYNC_CHANGESET_OPERATION_ID) + .filter(operation => operation.id !== AGENT_HOST_CHECKOUT_CHANGESET_OPERATION_ID) .filter(operation => operation.scopes.includes(SessionChangesetOperationScope.Changeset)) ?? []; const hasUncommittedChanges = (activeSession.workspace.read(reader)?.folders[0]?.gitRepository?.uncommittedChanges ?? 0) > 0; diff --git a/src/vs/sessions/contrib/changes/browser/changesViewService.ts b/src/vs/sessions/contrib/changes/browser/changesViewService.ts index 3ed3018713496..941fde829ba18 100644 --- a/src/vs/sessions/contrib/changes/browser/changesViewService.ts +++ b/src/vs/sessions/contrib/changes/browser/changesViewService.ts @@ -11,7 +11,7 @@ import { autorun, derived, derivedObservableWithCache, derivedOpts, IObservable, import { isEqual } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; import { IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; -import { AGENT_HOST_MERGE_CHANGESET_OPERATION_ID } from '../../../../platform/agentHost/common/agentHostChangesetOperationService.js'; +import { AGENT_HOST_CHECKOUT_CHANGESET_OPERATION_ID, AGENT_HOST_MERGE_CHANGESET_OPERATION_ID } from '../../../../platform/agentHost/common/agentHostChangesetOperationService.js'; import { bindContextKey } from '../../../../platform/observable/common/platformObservableUtils.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; @@ -206,7 +206,8 @@ export class ChangesViewService extends Disposable implements IChangesViewServic this.activeSessionChangesetOperationsObs = derived(reader => { const changeset = this.activeSessionChangesetObs.read(reader); - const operations = changeset?.operations.read(reader) ?? []; + const operations = (changeset?.operations.read(reader) ?? []) + .filter(operation => operation.id !== AGENT_HOST_CHECKOUT_CHANGESET_OPERATION_ID); return activeSessionBaseBranchProtected.read(reader) ? operations.filter(operation => operation.id !== AGENT_HOST_MERGE_CHANGESET_OPERATION_ID) : operations; diff --git a/src/vs/sessions/contrib/changes/browser/media/multiFileDiffEditor.css b/src/vs/sessions/contrib/changes/browser/media/multiFileDiffEditor.css deleted file mode 100644 index e546345b978c8..0000000000000 --- a/src/vs/sessions/contrib/changes/browser/media/multiFileDiffEditor.css +++ /dev/null @@ -1,273 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/* Agents window styling for the multi-file diff editor (the embedded multi-diff - * used by the Changes view / editor). Kept out of the shared workbench style so - * these overrides live next to the Changes contribution that owns them. */ - -.agent-sessions-workbench .part.editor .multiDiffEntry::after { - content: ''; - position: absolute; - right: var(--vscode-spacing-size100); - bottom: 0; - left: var(--vscode-spacing-size100); - height: var(--vscode-strokeThickness); - background: var(--vscode-panel-border); - z-index: 1001; - pointer-events: none; -} - -.agent-sessions-workbench .part.editor .multiDiffEntry .header { - cursor: pointer; - background: var(--vscode-multiDiffEditor-background); - position: relative; -} - -.agent-sessions-workbench .part.editor .multiDiffEntry.header-hovered, -.agent-sessions-workbench .part.editor .multiDiffEntry.header-focused, -.agent-sessions-workbench .part.editor .multiDiffEditor:focus-within .multiDiffEntry.active { - overflow: visible; - z-index: 1002; -} - -.agent-sessions-workbench .part.editor .multiDiffEntry.header-hovered::after, -.agent-sessions-workbench .part.editor .multiDiffEntry.header-focused::after, -.agent-sessions-workbench .part.editor .multiDiffEditor:focus-within .multiDiffEntry.active::after { - right: 0; - left: 0; - z-index: 1004; -} - -.agent-sessions-workbench .part.editor .multiDiffEntry.header-hovered::before, -.agent-sessions-workbench .part.editor .multiDiffEntry.header-focused::before, -.agent-sessions-workbench .part.editor .multiDiffEditor:focus-within .multiDiffEntry.active::before { - content: ''; - position: absolute; - top: calc(-1 * var(--vscode-strokeThickness)); - right: 0; - left: 0; - height: var(--vscode-strokeThickness); - background: var(--vscode-panel-border); - z-index: 1004; - pointer-events: none; -} - -.agent-sessions-workbench .part.editor .multiDiffEntry.first-diff-entry.header-hovered::before, -.agent-sessions-workbench .part.editor .multiDiffEntry.first-diff-entry.header-focused::before, -.agent-sessions-workbench .part.editor .multiDiffEditor:focus-within .multiDiffEntry.first-diff-entry.active::before { - top: 0; -} - -.agent-sessions-workbench .part.editor .multiDiffEntry .header .header-content { - height: var(--vscode-spacing-size320); - box-sizing: border-box; - border: none; - background: transparent; - margin: 0; - padding: var(--vscode-spacing-sizeNone); - align-items: center; -} - -.agent-sessions-workbench .part.editor .multiDiffEntry .header:not(.collapsed) .header-content { - border-bottom: none; -} - -.agent-sessions-workbench .part.editor .multiDiffEditor:focus-within .multiDiffEntry.active .header .header-content { - background: var(--vscode-list-inactiveSelectionBackground); -} - -.agent-sessions-workbench .part.editor .multiDiffEntry .header:hover .header-content { - background: var(--vscode-list-hoverBackground); -} - -.agent-sessions-workbench .part.editor .multiDiffEntry .header:active .header-content { - background: var(--vscode-toolbar-activeBackground); -} - -.agent-sessions-workbench .part.editor .multiDiffEditor:focus-within .multiDiffEntry.active .header:hover .header-content, -.agent-sessions-workbench .part.editor .multiDiffEditor:focus-within .multiDiffEntry.active .header:active .header-content { - background: var(--vscode-list-inactiveSelectionBackground); -} - -.agent-sessions-workbench .part.editor .multiDiffEntry .header[aria-expanded="false"] + .editorParent { - border: none; -} - -.agent-sessions-workbench .part.editor .multiDiffEntry .header-content .file-path .title { - font-size: var(--vscode-fontSize-body1); - line-height: 22px; -} - -.agent-sessions-workbench .part.editor .multiDiffEntry .header-content .file-path .title.modified { - display: flex; - align-items: center; - flex: 0 1 auto; - min-width: 0; -} - -.agent-sessions-workbench .part.editor .multiDiffEntry .header-content .file-path .title.modified > .monaco-icon-label { - flex: 0 1 auto; - min-width: 0; -} - -.agent-sessions-workbench .part.editor .multiDiffEntry .header-content .session-changes-file-stats { - display: inline-flex; - align-items: center; - flex: 0 0 auto; - gap: var(--vscode-spacing-size40); - margin-left: var(--vscode-spacing-size60); - font-size: var(--vscode-fontSize-body2); -} - -.agent-sessions-workbench .part.editor .multiDiffEntry .header-content .session-changes-file-stats .working-set-lines-added { - color: var(--vscode-chat-linesAddedForeground); -} - -.agent-sessions-workbench .part.editor .multiDiffEntry .header-content .session-changes-file-stats .working-set-lines-removed { - color: var(--vscode-chat-linesRemovedForeground); -} - -.agent-sessions-workbench .part.editor .multiDiffEntry .header-content .actions-container { - gap: 4px; -} - -.agent-sessions-workbench .part.editor .multiDiffEntry .header-content .actions-container .action-item:not(.changeset-review-action) { - visibility: hidden; -} - -.agent-sessions-workbench .part.editor .multiDiffEntry:hover .header-content .actions-container .action-item:not(.changeset-review-action), -.agent-sessions-workbench .part.editor .multiDiffEntry:focus-within .header-content .actions-container .action-item:not(.changeset-review-action) { - visibility: visible; -} - -.agent-sessions-workbench .part.editor .multiDiffEntry .header:focus { - outline: none; -} - -.agent-sessions-workbench .part.editor .multiDiffEntry .header:focus-visible .header-content { - outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); - outline-offset: calc(-1 * var(--vscode-strokeThickness)); -} - -.agent-sessions-workbench .part.editor .multiDiffEntry .collapse-button a { - display: flex; - align-items: center; - justify-content: center; - padding: var(--vscode-spacing-size40); - border-radius: var(--vscode-cornerRadius-medium); -} - -.agent-sessions-workbench .part.editor .multiDiffEntry .header-content .actions-container .action-item:not(.changeset-review-action) .action-label { - padding: var(--vscode-spacing-size40); -} - -.agent-sessions-workbench .part.editor .multiDiffEntry .collapse-button a:hover { - background: var(--vscode-toolbar-hoverBackground); -} - -.agent-sessions-workbench .part.editor .multiDiffEntry .collapse-button a:active { - background: var(--vscode-toolbar-activeBackground); -} - -.agent-sessions-workbench .part.editor .multiDiffEntry .collapse-button a:focus-visible { - outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); - outline-offset: calc(-1 * var(--vscode-strokeThickness)); -} - -.agent-sessions-workbench .part.editor .multiDiffEntry .collapse-button a:focus:not(:focus-visible) { - outline: none; -} - -.agent-sessions-workbench .part.editor .multiDiffEntry .editorParent { - border: none; - overflow: hidden; -} - -.agent-sessions-workbench .part.editor .diff-hidden-lines .center { - box-shadow: none; - overflow: hidden; -} - -/* Left-align the hidden-lines expand/collapse control (`$(unfold)`) so it lines - * up with the file header twistie, instead of being centered in the original - * line-number gutter width (which pushes it to the right). */ -.agent-sessions-workbench .part.editor .diff-hidden-lines .center > div:first-child { - justify-content: flex-start !important; - padding-left: var(--vscode-spacing-size60); - box-sizing: border-box; -} - -/* Align the "Fold Unchanged Region" glyph-margin control with the same offset so - * the collapse (fold) and expand (unfold) controls share the header twistie's - * horizontal position. */ -.agent-sessions-workbench .part.editor .fold-unchanged { - box-sizing: border-box; -} - -/* The hidden-lines bar is split into two halves (original / modified) sitting - * side by side. Round only the outer corners so the pair forms a single pill. */ -.agent-sessions-workbench .part.editor .editor.original .diff-hidden-lines .center { - border-top-left-radius: var(--vscode-cornerRadius-medium); - border-bottom-left-radius: var(--vscode-cornerRadius-medium); -} - -.agent-sessions-workbench .part.editor .editor.modified .diff-hidden-lines .center { - border-top-right-radius: var(--vscode-cornerRadius-medium); - border-bottom-right-radius: var(--vscode-cornerRadius-medium); -} - -.agent-sessions-workbench .part.editor .multiDiffEntry .header-content .status { - display: inline-flex; - align-items: center; - justify-content: center; - width: 16px; - min-width: 16px; - font-size: var(--vscode-fontSize-body2); - font-weight: var(--vscode-fontWeight-semiBold); - opacity: 0.9; -} - -.agent-sessions-workbench .part.editor .multiDiffEntry .header-content .status.added { - color: var(--vscode-gitDecoration-addedResourceForeground); -} - -.agent-sessions-workbench .part.editor .multiDiffEntry .header-content .status.deleted { - color: var(--vscode-gitDecoration-deletedResourceForeground); -} - -.agent-sessions-workbench .part.editor .multiDiffEntry .header-content .status.renamed { - color: var(--vscode-gitDecoration-modifiedResourceForeground); -} - -.agent-sessions-workbench .part.editor .multiDiffEntry .header-content .action-item.checkbox-action-item.changeset-review-action { - display: flex; - align-items: center; - padding: 0 4px; - cursor: pointer; - color: var(--vscode-foreground); - border-radius: var(--vscode-cornerRadius-small); - border: var(--vscode-strokeThickness) solid var(--vscode-toolbar-hoverBackground); - height: 20px; -} - -.agent-sessions-workbench .part.editor .multiDiffEntry .header-content .action-item.checkbox-action-item.changeset-review-action:has(> .monaco-checkbox.checked) { - background: var(--vscode-actionBar-toggledBackground) !important; -} - -.agent-sessions-workbench .part.editor .multiDiffEntry .header-content .changeset-review-action .checkbox-label { - font-size: var(--vscode-fontSize-body2); - line-height: 20px; - white-space: nowrap; - cursor: pointer; -} - -.agent-sessions-workbench .part.editor .multiDiffEntry .header-content .changeset-review-action.disabled { - opacity: 0.5; - cursor: default; -} - -.agent-sessions-workbench .part.editor .multiDiffEntry .header-content .changeset-review-action.disabled .checkbox-label { - cursor: default; -} diff --git a/src/vs/sessions/contrib/changes/browser/media/sessionChangesEditor.css b/src/vs/sessions/contrib/changes/browser/media/sessionChangesEditor.css index e0ef7f03e2b8d..74b519ce1dc62 100644 --- a/src/vs/sessions/contrib/changes/browser/media/sessionChangesEditor.css +++ b/src/vs/sessions/contrib/changes/browser/media/sessionChangesEditor.css @@ -11,6 +11,54 @@ color: var(--vscode-agentsPanel-foreground); } +.session-changes-file-stats { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + gap: var(--vscode-spacing-size40); + margin-left: var(--vscode-spacing-size60); + font-size: var(--vscode-fontSize-body2); +} + +.session-changes-file-stats .working-set-lines-added { + color: var(--vscode-chat-linesAddedForeground); +} + +.session-changes-file-stats .working-set-lines-removed { + color: var(--vscode-chat-linesRemovedForeground); +} + +.changeset-review-action { + display: flex; + align-items: center; + height: var(--vscode-spacing-size200); + padding: 0 var(--vscode-spacing-size40); + cursor: pointer; + color: var(--vscode-foreground); + border: var(--vscode-strokeThickness) solid var(--vscode-toolbar-hoverBackground); + border-radius: var(--vscode-cornerRadius-small); +} + +.changeset-review-action.checked { + background: var(--vscode-actionBar-toggledBackground); +} + +.changeset-review-action .checkbox-label { + font-size: var(--vscode-fontSize-body2); + line-height: var(--vscode-spacing-size200); + white-space: nowrap; + cursor: pointer; +} + +.changeset-review-action.disabled { + cursor: default; + opacity: 0.5; +} + +.changeset-review-action.disabled .checkbox-label { + cursor: default; +} + .session-changes-editor-header { display: flex; align-items: center; diff --git a/src/vs/sessions/contrib/changes/browser/media/sessionChangesEditorInput.css b/src/vs/sessions/contrib/changes/browser/media/sessionChangesEditorInput.css index 297b290476899..4e5d1ec6c8560 100644 --- a/src/vs/sessions/contrib/changes/browser/media/sessionChangesEditorInput.css +++ b/src/vs/sessions/contrib/changes/browser/media/sessionChangesEditorInput.css @@ -18,6 +18,5 @@ color: var(--vscode-agentsBadge-foreground); font-size: var(--vscode-fontSize-label3); font-weight: var(--vscode-fontWeight-semiBold); - line-height: normal; opacity: 1; } diff --git a/src/vs/sessions/contrib/changes/browser/sessionChangesEditor.ts b/src/vs/sessions/contrib/changes/browser/sessionChangesEditor.ts index 2f296da8bebfa..f6699a9159688 100644 --- a/src/vs/sessions/contrib/changes/browser/sessionChangesEditor.ts +++ b/src/vs/sessions/contrib/changes/browser/sessionChangesEditor.ts @@ -30,6 +30,7 @@ import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actio import { IEditorGroup, IEditorGroupsService } from '../../../../workbench/services/editor/common/editorGroupsService.js'; import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js'; import { MultiDiffEditorWidget } from '../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.js'; +import { MultiDiffEditorVariant } from '../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorOptions.js'; import { MultiDiffEditorViewModel } from '../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorViewModel.js'; import { IMultiDiffEditorLayoutDebugState, IMultiDiffEditorViewState } from '../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.js'; import { MultiDiffEditorLogger } from '../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorLogging.js'; @@ -69,16 +70,8 @@ const CHANGES_DIFF_EDITOR_OPTIONS: IDiffEditorOptions = { }; const CHANGES_LIST_BOTTOM_PADDING_PX = 24; -const CHANGES_ENTRY_HEADER_HEIGHT_PX = 32; -const CHANGES_ENTRY_CONTENT_BOTTOM_PADDING_PX = 8; - class SessionChangesUIElementFactory implements IWorkbenchUIElementFactory { - readonly headerClickToCollapse = true; - readonly diffEditorItemHorizontalInsets = { left: 0, right: 0 }; - readonly diffEditorItemHeaderHeight = CHANGES_ENTRY_HEADER_HEIGHT_PX; - readonly diffEditorItemContentBottomPadding = CHANGES_ENTRY_CONTENT_BOTTOM_PADDING_PX; - constructor( private readonly changesObs: IObservable, @ICommandService private readonly commandService: ICommandService, @@ -87,10 +80,10 @@ class SessionChangesUIElementFactory implements IWorkbenchUIElementFactory { @IEditorService private readonly editorService: IEditorService, ) { } - createResourceLabel(element: HTMLElement, kind: MultiDiffEditorItemLabelKind): IResourceLabel { + createResourceLabel(element: HTMLElement, kind: MultiDiffEditorItemLabelKind, accessoryContainer: HTMLElement): IResourceLabel { const label = this.instantiationService.createInstance(ResourceLabel, element, {}); const showDiffStats = kind === MultiDiffEditorItemLabelKind.Primary; - return new SessionChangesResourceLabel(label, element, showDiffStats, this.changesObs); + return new SessionChangesResourceLabel(label, accessoryContainer, showDiffStats, this.changesObs); } handleHeaderMiddleClick(resource: URI): boolean { @@ -129,7 +122,7 @@ class SessionChangesResourceLabel extends Disposable implements IResourceLabel { constructor( private readonly label: ResourceLabel, - element: HTMLElement, + accessoryContainer: HTMLElement, showDiffStats: boolean, changesObs: IObservable, ) { @@ -137,9 +130,9 @@ class SessionChangesResourceLabel extends Disposable implements IResourceLabel { this._register(label); if (showDiffStats) { - const statsContainer = append(element, $('.session-changes-file-stats')); - const added = append(statsContainer, $('.working-set-lines-added')); - const removed = append(statsContainer, $('.working-set-lines-removed')); + accessoryContainer.classList.add('session-changes-file-stats'); + const added = append(accessoryContainer, $('.working-set-lines-added')); + const removed = append(accessoryContainer, $('.working-set-lines-removed')); added.setAttribute('aria-hidden', 'true'); removed.setAttribute('aria-hidden', 'true'); @@ -148,15 +141,15 @@ class SessionChangesResourceLabel extends Disposable implements IResourceLabel { const stats = resource ? getChangesEditorFileStats(resource, changesObs.read(reader)) : undefined; - statsContainer.style.display = stats ? '' : 'none'; + accessoryContainer.style.display = stats ? '' : 'none'; if (stats) { added.textContent = `+${stats.insertions}`; removed.textContent = `-${stats.deletions}`; - statsContainer.setAttribute('aria-label', localize('sessionChangesEditor.fileCounts', '{0} lines added, {1} lines removed', stats.insertions, stats.deletions)); + accessoryContainer.setAttribute('aria-label', localize('sessionChangesEditor.fileCounts', '{0} lines added, {1} lines removed', stats.insertions, stats.deletions)); } else { added.textContent = ''; removed.textContent = ''; - statsContainer.removeAttribute('aria-label'); + accessoryContainer.removeAttribute('aria-label'); } })); } @@ -276,7 +269,10 @@ export class SessionChangesEditor extends AbstractEditorWithViewState { @@ -486,12 +482,13 @@ class ChangesetReviewActionViewItem extends CheckboxActionViewItem { override render(container: HTMLElement): void { super.render(container); - container.classList.add('changeset-review-action'); + container.classList.add('changeset-review-action', 'multi-diff-action-always-visible'); } override updateChecked(): void { super.updateChecked(); + this.element?.classList.toggle('checked', !!this.action.checked); this.updateAriaLabel(); this.updateTooltip(); } diff --git a/src/vs/sessions/contrib/changes/test/browser/agentsDiffEditor.fixture.ts b/src/vs/sessions/contrib/changes/test/browser/agentsDiffEditor.fixture.ts index f0119da538325..647788c946cdd 100644 --- a/src/vs/sessions/contrib/changes/test/browser/agentsDiffEditor.fixture.ts +++ b/src/vs/sessions/contrib/changes/test/browser/agentsDiffEditor.fixture.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import '../../browser/media/multiFileDiffEditor.css'; +import '../../browser/media/sessionChangesEditor.css'; import '../../../agentFeedback/browser/media/agentFeedbackEditorInput.css'; import '../../../../../base/browser/ui/codicons/codiconStyles.js'; import { $, Dimension, getWindow } from '../../../../../base/browser/dom.js'; @@ -15,6 +15,7 @@ import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { MultiDiffEditorWidget } from '../../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.js'; +import { MultiDiffEditorVariant } from '../../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorOptions.js'; import { IDiffProviderFactoryService } from '../../../../../editor/browser/widget/diffEditor/diffProviderFactoryService.js'; import { RefCounted } from '../../../../../editor/browser/widget/diffEditor/utils.js'; import { DiffItemSource, IDocumentDiffItem } from '../../../../../editor/browser/widget/multiDiffEditor/model.js'; @@ -60,13 +61,6 @@ class FixtureAgentFeedbackMenuService implements IMenuService { ) { } createMenu(id: MenuId): IMenu { - if (id !== Menus.AgentFeedbackEditorContent) { - return { - onDidChange: Event.None, - dispose: () => { }, - getActions: () => [], - }; - } const createAction = (actionId: string, title: string, icon: ThemeIcon) => this.instantiationService.createInstance( MenuItemAction, { id: actionId, title, icon }, @@ -75,6 +69,20 @@ class FixtureAgentFeedbackMenuService implements IMenuService { undefined, undefined, ); + if (id === MenuId.MultiDiffEditorFileToolbar) { + return { + onDidChange: Event.None, + dispose: () => { }, + getActions: () => [['navigation', [createAction('fixture.expandFullFile', 'Expand Full File', Codicon.unfold)]]], + }; + } + if (id !== Menus.AgentFeedbackEditorContent) { + return { + onDidChange: Event.None, + dispose: () => { }, + getActions: () => [], + }; + } const navigateActions = [ createAction(navigationBearingFakeActionId, 'Navigation Status', Codicon.commentDiscussion), createAction(navigatePreviousFeedbackActionId, 'Previous', Codicon.arrowUp), @@ -101,11 +109,6 @@ class FixtureAgentFeedbackMenuService implements IMenuService { class AgentsDiffUIElementFactory implements IWorkbenchUIElementFactory { - readonly headerClickToCollapse = true; - readonly diffEditorItemHorizontalInsets = { left: 0, right: 0 }; - readonly diffEditorItemHeaderHeight = 32; - readonly diffEditorItemContentBottomPadding = 8; - constructor( @IInstantiationService private readonly instantiationService: IInstantiationService, ) { } @@ -273,10 +276,13 @@ async function renderAgentsDiffEditor({ container, disposableStore, disposableSt editorInstance, instantiationService.createInstance(AgentsDiffUIElementFactory), { - hideOriginalLineNumbers: true, - folding: false, - hideUnchangedRegions: { enabled: true }, - lineNumbersMinChars: 3, + variant: MultiDiffEditorVariant.Compact, + diffEditorOptions: { + hideOriginalLineNumbers: true, + folding: false, + hideUnchangedRegions: { enabled: true }, + lineNumbersMinChars: 3, + }, }, )); widget.setRenderSideBySide(false); diff --git a/src/vs/sessions/contrib/changes/test/browser/changesActions.test.ts b/src/vs/sessions/contrib/changes/test/browser/changesActions.test.ts index 15e082d42ffb5..d0e49caa17d2f 100644 --- a/src/vs/sessions/contrib/changes/test/browser/changesActions.test.ts +++ b/src/vs/sessions/contrib/changes/test/browser/changesActions.test.ts @@ -10,7 +10,7 @@ import { URI } from '../../../../../base/common/uri.js'; import { mock, upcastPartial } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { isIMenuItem, MenuRegistry } from '../../../../../platform/actions/common/actions.js'; -import { AGENT_HOST_COMMIT_CHANGESET_OPERATION_ID, AGENT_HOST_SYNC_CHANGESET_OPERATION_ID } from '../../../../../platform/agentHost/common/agentHostChangesetOperationService.js'; +import { AGENT_HOST_CHECKOUT_CHANGESET_OPERATION_ID, AGENT_HOST_COMMIT_CHANGESET_OPERATION_ID, AGENT_HOST_SYNC_CHANGESET_OPERATION_ID } from '../../../../../platform/agentHost/common/agentHostChangesetOperationService.js'; import { CommandsRegistry } from '../../../../../platform/commands/common/commands.js'; import { Context } from '../../../../../platform/contextkey/browser/contextKeyService.js'; import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; @@ -39,6 +39,11 @@ suite('Changes Actions', () => { label: 'Discard File', scopes: [SessionChangesetOperationScope.Resource], status: SessionChangesetOperationStatus.Idle, + }, { + id: AGENT_HOST_CHECKOUT_CHANGESET_OPERATION_ID, + label: 'Checkout', + scopes: [SessionChangesetOperationScope.Changeset], + status: SessionChangesetOperationStatus.Idle, }, { id: AGENT_HOST_SYNC_CHANGESET_OPERATION_ID, label: 'Sync Changes', @@ -112,6 +117,7 @@ suite('Changes Actions', () => { visibleForChangesTab, visibleForTextTab, resourceOperationRegistered: CommandsRegistry.getCommand(`${actionPrefix}discard-file`) !== undefined, + checkoutOperationRegistered: CommandsRegistry.getCommand(`${actionPrefix}${AGENT_HOST_CHECKOUT_CHANGESET_OPERATION_ID}`) !== undefined, syncOperationRegistered: CommandsRegistry.getCommand(`${actionPrefix}${AGENT_HOST_SYNC_CHANGESET_OPERATION_ID}`) !== undefined, }, { actions: [{ @@ -128,6 +134,7 @@ suite('Changes Actions', () => { visibleForChangesTab: true, visibleForTextTab: false, resourceOperationRegistered: false, + checkoutOperationRegistered: false, syncOperationRegistered: false, }); diff --git a/src/vs/sessions/contrib/changes/test/browser/changesViewService.test.ts b/src/vs/sessions/contrib/changes/test/browser/changesViewService.test.ts index 926bfc3f33151..1ecd1750ea8e9 100644 --- a/src/vs/sessions/contrib/changes/test/browser/changesViewService.test.ts +++ b/src/vs/sessions/contrib/changes/test/browser/changesViewService.test.ts @@ -10,6 +10,7 @@ import { constObservable, observableValue } from '../../../../../base/common/obs import { URI } from '../../../../../base/common/uri.js'; import { mock, upcastPartial } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { AGENT_HOST_CHECKOUT_CHANGESET_OPERATION_ID } from '../../../../../platform/agentHost/common/agentHostChangesetOperationService.js'; import { MockContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js'; import { TestStorageService } from '../../../../../workbench/test/common/workbenchTestServices.js'; import { IActiveSession, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; @@ -316,6 +317,26 @@ suite('ChangesViewService', () => { }); }); + test('hides checkout from generic changeset operations', () => { + const changeset = createChangeset([ + { + id: AGENT_HOST_CHECKOUT_CHANGESET_OPERATION_ID, + label: 'Checkout', + scopes: [SessionChangesetOperationScope.Changeset], + status: SessionChangesetOperationStatus.Idle, + }, + { + id: 'create-pr', + label: 'Create PR', + scopes: [SessionChangesetOperationScope.Changeset], + status: SessionChangesetOperationStatus.Idle, + }, + ]); + const { service } = createHarness(createSession('draft', { changesets: [changeset] })); + + assert.deepStrictEqual(service.activeSessionChangesetOperationsObs.get().map(operation => operation.id), ['create-pr']); + }); + test('hides the Agent Host merge operation when the base branch is protected', () => { const operations: readonly ISessionChangesetOperation[] = [ { diff --git a/src/vs/sessions/contrib/chat/browser/agentsWindowOpenIntent.ts b/src/vs/sessions/contrib/chat/browser/agentsWindowOpenIntent.ts new file mode 100644 index 0000000000000..bdb64ba2424e4 --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/agentsWindowOpenIntent.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 { decodeHex } from '../../../../base/common/buffer.js'; +import { Schemas } from '../../../../base/common/network.js'; +import { URI } from '../../../../base/common/uri.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { DevContainerAgentHostEnabledSettingId } from '../../../common/devContainerAgentHostService.js'; + +const DEV_CONTAINER_REMOTE_AUTHORITY_PREFIX = 'dev-container+'; + +export interface IAgentsWindowFolderIntent { + readonly folderUri: URI | undefined; + readonly preferDevContainer: boolean; +} + +export function resolveAgentsWindowFolderIntent(workspaceUri: URI | undefined, configurationService: IConfigurationService): IAgentsWindowFolderIntent { + if (workspaceUri?.scheme === Schemas.file) { + return { folderUri: workspaceUri, preferDevContainer: false }; + } + if (workspaceUri?.scheme !== Schemas.vscodeRemote || !workspaceUri.authority.startsWith(DEV_CONTAINER_REMOTE_AUTHORITY_PREFIX)) { + return { folderUri: undefined, preferDevContainer: false }; + } + try { + return { + folderUri: URI.file(decodeHex(workspaceUri.authority.slice(DEV_CONTAINER_REMOTE_AUTHORITY_PREFIX.length)).toString()), + preferDevContainer: configurationService.getValue(DevContainerAgentHostEnabledSettingId) === true, + }; + } catch (error) { + if (error instanceof SyntaxError) { + return { folderUri: undefined, preferDevContainer: false }; + } + throw error; + } +} diff --git a/src/vs/sessions/contrib/chat/browser/chatView.ts b/src/vs/sessions/contrib/chat/browser/chatView.ts index 6d2df37212794..1dae88cd3a7fa 100644 --- a/src/vs/sessions/contrib/chat/browser/chatView.ts +++ b/src/vs/sessions/contrib/chat/browser/chatView.ts @@ -36,7 +36,7 @@ import { IChatModel } from '../../../../workbench/contrib/chat/common/model/chat import { ChatAgentLocation, ChatModeKind } from '../../../../workbench/contrib/chat/common/constants.js'; import { getChatSessionType } from '../../../../workbench/contrib/chat/common/model/chatUri.js'; import { IChatSessionsService, localChatSessionType } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; -import { AbstractChatView, ChatViewKind, IChatViewOptions } from '../../../browser/parts/chatView.js'; +import { AbstractChatView, ChatViewKind, IChatViewOptions, ISelectWorkspaceOptions } from '../../../browser/parts/chatView.js'; import { ChatInteractivity, getSessionStatusMessage, IChat, isActiveSessionStatus, ISession, SessionStatus } from '../../../services/sessions/common/session.js'; import { IChatViewFactory } from '../../../services/chatView/browser/chatViewFactory.js'; import { NewChatWidget } from './newChatWidget.js'; @@ -104,9 +104,9 @@ export class NewChatView extends AbstractChatView { this._widget.focusInput(); } - override selectWorkspace(folderUri: URI, providerId?: string): void { + override selectWorkspace(folderUri: URI, options?: ISelectWorkspaceOptions): void { if (this._widget instanceof NewChatWidget) { - this._widget.selectWorkspace(folderUri, providerId); + this._widget.selectWorkspace(folderUri, options); } } @@ -732,11 +732,11 @@ export class ChatViewFactory implements IChatViewFactory { @IInstantiationService private readonly instantiationService: IInstantiationService ) { } - createNewChatView(isNewChatInSession: boolean, options: IChatViewOptions): AbstractChatView { - return this.instantiationService.createInstance(NewChatView, isNewChatInSession, options); + createNewChatView(isNewChatInSession: boolean, options: IChatViewOptions, instantiationService = this.instantiationService): AbstractChatView { + return instantiationService.createInstance(NewChatView, isNewChatInSession, options); } - createChatView(): AbstractChatView { - return this.instantiationService.createInstance(ChatView); + createChatView(instantiationService = this.instantiationService): AbstractChatView { + return instantiationService.createInstance(ChatView); } } diff --git a/src/vs/sessions/contrib/chat/browser/media/chatView.css b/src/vs/sessions/contrib/chat/browser/media/chatView.css index 85dfa9afa1930..c62c86613a223 100644 --- a/src/vs/sessions/contrib/chat/browser/media/chatView.css +++ b/src/vs/sessions/contrib/chat/browser/media/chatView.css @@ -250,6 +250,19 @@ display: none; } +.monaco-workbench.agent-sessions-workbench .interactive-session .chat-input-picker-item > .action-label:focus, +.monaco-workbench.agent-sessions-workbench .interactive-session .sessions-chat-picker-slot > .action-label:focus, +.monaco-workbench.agent-sessions-workbench .interactive-session .agent-host-chat-input-picker-slot > .action-label:focus { + outline: none; +} + +.monaco-workbench.agent-sessions-workbench .interactive-session .chat-input-picker-item > .action-label:focus-visible, +.monaco-workbench.agent-sessions-workbench .interactive-session .sessions-chat-picker-slot > .action-label:focus-visible, +.monaco-workbench.agent-sessions-workbench .interactive-session .agent-host-chat-input-picker-slot > .action-label:focus-visible { + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: calc(-1 * var(--vscode-strokeThickness)); +} + .agent-sessions-workbench .interactive-session .compact-picker .sessions-chat-dropdown-label { display: none; } diff --git a/src/vs/sessions/contrib/chat/browser/media/chatWidget.css b/src/vs/sessions/contrib/chat/browser/media/chatWidget.css index 9d75adee29b29..8ff7de17eda66 100644 --- a/src/vs/sessions/contrib/chat/browser/media/chatWidget.css +++ b/src/vs/sessions/contrib/chat/browser/media/chatWidget.css @@ -207,7 +207,7 @@ * model picker font in the input toolbar. */ .new-chat-widget-container .new-chat-bottom-container .action-label { height: 16px; - padding: 3px 8px; + padding: var(--vscode-spacing-size40) var(--vscode-spacing-size80); font-size: var(--vscode-fontSize-label2, 11px); color: var(--vscode-icon-foreground); } @@ -283,7 +283,7 @@ border-radius: var(--vscode-cornerRadius-circle); box-sizing: border-box; color: var(--vscode-descriptionForeground); - font-size: var(--vscode-fontSize-body2); + font-size: var(--vscode-fontSize-body1); line-height: var(--vscode-spacing-size160); } @@ -307,6 +307,10 @@ font-size: var(--vscode-codiconFontSize-compact); } +.action-widget .sessions-new-chat-picker-list .monaco-list-row.action .codicon { + font-size: var(--vscode-codiconFontSize); +} + .sessions-workspace-category-picker .sessions-chat-dropdown-label { margin-left: 0; } @@ -339,7 +343,7 @@ display: flex; align-items: center; height: 16px; - padding: 3px 7px 3px 7px; + padding: var(--vscode-spacing-size40) var(--vscode-spacing-size80); background-color: transparent; border: none; color: var(--vscode-icon-foreground); @@ -425,7 +429,7 @@ display: inline-flex; align-items: center; justify-content: center; - font-size: 10px; + font-size: var(--vscode-codiconFontSize-compact); margin-left: 2px; line-height: 1; width: 12px; diff --git a/src/vs/sessions/contrib/chat/browser/newChatWidget.ts b/src/vs/sessions/contrib/chat/browser/newChatWidget.ts index 3099ac24e9f4d..429fd585751ac 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatWidget.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatWidget.ts @@ -10,7 +10,7 @@ import { Action } from '../../../../base/common/actions.js'; import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; import { Event } from '../../../../base/common/event.js'; import { Disposable, DisposableMap, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; -import { constObservable, derived, derivedObservableWithCache, autorun, IObservable, observableFromEvent, observableSignalFromEvent } from '../../../../base/common/observable.js'; +import { constObservable, derived, derivedObservableWithCache, autorun, IObservable, observableFromEvent, observableSignalFromEvent, observableValue } from '../../../../base/common/observable.js'; import { isWeb } from '../../../../base/common/platform.js'; import { basename } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; @@ -22,20 +22,22 @@ import { ILogService } from '../../../../platform/log/common/log.js'; import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; import { IDefaultAccountService } from '../../../../platform/defaultAccount/common/defaultAccount.js'; import { localize } from '../../../../nls.js'; -import { IActiveSession, ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; +import { IActiveSession, ICreateNewSessionOptions, ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { ISession, SESSION_WORKSPACE_GROUP_GITHUB } from '../../../services/sessions/common/session.js'; import { IOpenNewSessionResult, ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; +import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; import { isAllowSignedOutWhenUsableEnabled, shouldShowGitHubWorkspaceGroupSignIn } from '../../../browser/sessionsAuthGate.js'; import { AGENTIC_SIGN_IN_COMMAND_ID } from '../../../common/sessionCommands.js'; +import { isAgentHostProvider } from '../../../common/agentHostSessionsProvider.js'; import { IAquariumService, IMountedToggleHandle } from '../../aquarium/browser/aquariumOverlay.js'; -import { IWorkspacePickerTrigger, WorkspacePicker } from './sessionWorkspacePicker.js'; +import { IWorkspacePickerNoWorkspaceOption, IWorkspacePickerTrigger, WorkspacePicker } from './sessionWorkspacePicker.js'; import { WebWorkspacePicker } from './webWorkspacePicker.js'; import { IPreferredSessionType } from './sessionTypePicker.js'; import { NewChatInputWidget } from './newChatInput.js'; import { NoAgentHostEmptyState } from './noAgentHostEmptyState.js'; import { IChatRequestVariableEntry } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; import { IAgentHostFilterService } from '../../../services/agentHostFilter/common/agentHostFilter.js'; -import { IChatViewOptions } from '../../../browser/parts/chatView.js'; +import { IChatViewOptions, ISelectWorkspaceOptions } from '../../../browser/parts/chatView.js'; import { SessionWorkspacePickerVisibleContext } from '../../../common/contextkeys.js'; import { AGENT_FEEDBACK_NEW_SESSION_RESOURCE, AgentFeedbackState, IAgentFeedback, IAgentFeedbackService } from '../../agentFeedback/browser/agentFeedbackService.js'; import { buildNewSessionPrompt } from '../../agentFeedback/browser/agentFeedbackAttachmentEntry.js'; @@ -46,7 +48,7 @@ import { chatInputStackClass, ChatInputStackSlot, setChatInputStackSlot } from ' import { IChatPetService } from '../../../../workbench/contrib/chat/browser/chatPetService.js'; import { IChatTipService } from '../../../../workbench/contrib/chat/browser/chatTipService.js'; import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; -import { ChatModeKind } from '../../../../workbench/contrib/chat/common/constants.js'; +import { ChatConfiguration, ChatModeKind } from '../../../../workbench/contrib/chat/common/constants.js'; import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import { IStorageService, StorageScope } from '../../../../platform/storage/common/storage.js'; import { TOTAL_SESSIONS_KEY } from '../../sessions/browser/sessionsLifecycleTracker.js'; @@ -70,6 +72,7 @@ export class NewChatWidget extends Disposable { /** Recreates the draft once a better/late-registering provider can serve the folder (see {@link _createNewSession}). */ private readonly _pendingPreferredUpgrade = new MutableDisposable(); private readonly _newSessionCreation = new MutableDisposable(); + private _preferredDevContainerFolderUri: URI | undefined; /** * The currently mounted no-agent-host empty state, if any. Set by @@ -83,8 +86,11 @@ export class NewChatWidget extends Disposable { private readonly _session: IObservable; - /** Whether the active draft is a workspace-less quick chat (hides the workspace picker). */ + /** Whether the active draft is a workspace-less quick chat. */ private readonly _isQuickChatComposer: IObservable; + private readonly _isWorkspacePickerQuickChat: IObservable; + private readonly _workspacePickerQuickChatSessionId = observableValue(this, undefined); + private readonly _useConsolidatedRemoteWorkspaces: IObservable; /** Draft comments shared by every uncreated new-session composer. */ private readonly _feedbackItems: IObservable; @@ -111,6 +117,7 @@ export class NewChatWidget extends Disposable { @ILogService private readonly logService: ILogService, @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, @ISessionsService private readonly sessionsService: ISessionsService, + @ISessionsProvidersService private readonly sessionsProvidersService: ISessionsProvidersService, @IAquariumService private readonly aquariumService: IAquariumService, @IAgentHostFilterService private readonly agentHostFilterService: IAgentHostFilterService, @IUriIdentityService private readonly uriIdentityService: IUriIdentityService, @@ -143,6 +150,15 @@ export class NewChatWidget extends Disposable { const session = this._session.read(reader); return session?.isQuickChat?.read(reader) ?? false; }); + this._isWorkspacePickerQuickChat = derived(this, reader => { + const session = this._session.read(reader); + return !!session?.isQuickChat?.read(reader) && session.sessionId === this._workspacePickerQuickChatSessionId.read(reader); + }); + this._useConsolidatedRemoteWorkspaces = observableFromEvent( + this, + Event.filter(this.configurationService.onDidChangeConfiguration, event => event.affectsConfiguration(ChatConfiguration.ConsolidatedRemoteWorkspaces)), + () => this.configurationService.getValue(ChatConfiguration.ConsolidatedRemoteWorkspaces), + ); // On web (vscode.dev / insiders.vscode.dev), use {@link WebWorkspacePicker} // which scopes recents to the active host and renders as a bottom @@ -165,6 +181,7 @@ export class NewChatWidget extends Disposable { } return undefined; }, + getNoWorkspaceOption: () => this._getNoWorkspaceOption(), })); const feedbackChanged = observableSignalFromEvent(this, this.agentFeedbackService.onDidChangeFeedback); @@ -297,7 +314,7 @@ export class NewChatWidget extends Disposable { // A quick chat has no folder: re-create the draft with the picked // type via openQuickChat (mirrors the folder path's draft recreation). if (this._isQuickChatComposer.get()) { - this.sessionsService.openQuickChat(pick ? { providerId: pick.providerId, sessionTypeId: pick.sessionTypeId } : undefined); + this._openQuickChat(pick ? { providerId: pick.providerId, sessionTypeId: pick.sessionTypeId } : undefined); this._newChatInput.focus(); return; } @@ -450,22 +467,25 @@ export class NewChatWidget extends Disposable { this._newChatInput.noticeHost, ); - // Quick chat composer: hide the workspace picker for workspace-less - // drafts (there is nothing to pick) and reflect it in the picker-visible - // context key. Quick chats are only created on desktop (the local agent - // host), so leave the web empty-state gate's key management untouched. + // Quick chat composer: retain the picker only when it created the + // workspace-less draft, so the user can switch back to a workspace. + // Quick chats are only created on desktop (the local agent host), so + // leave the web empty-state gate's key management untouched. this._register(autorun(reader => { const isQuickChat = this._isQuickChatComposer.read(reader); - chatWidgetContent.classList.toggle('quick-chat', isQuickChat); + const isWorkspacePickerQuickChat = this._isWorkspacePickerQuickChat.read(reader); + chatWidgetContent.classList.toggle('quick-chat', isQuickChat && !isWorkspacePickerQuickChat); + this._workspacePicker.refreshPresentation(); if (!isWeb) { - this._workspacePickerVisibleKey.set(!isQuickChat); + this._workspacePickerVisibleKey.set(!isQuickChat || isWorkspacePickerQuickChat); } })); if (!isWeb) { this._register(autorun(reader => { const isQuickChat = this._isQuickChatComposer.read(reader); - const target = isQuickChat ? this._quickChatHeaderPickerHost : this._workspacePickerRow; + const isWorkspacePickerQuickChat = this._isWorkspacePickerQuickChat.read(reader); + const target = isQuickChat && !isWorkspacePickerQuickChat ? this._quickChatHeaderPickerHost : this._workspacePickerRow; if (!target) { return; } @@ -602,13 +622,16 @@ export class NewChatWidget extends Disposable { } else { return result; } + this._applyPreferredDevContainer(result.session, folderUri); if (result.trustDeclined) { + this._preferredDevContainerFolderUri = undefined; // The user explicitly declined trust: don't schedule a retry, which // would silently recreate (and possibly re-prompt) the draft once a // provider registers/changes without any further user action. this._pendingPreferredUpgrade.clear(); return result; } + // Keep the draft in sync with late-registering providers. Agent hosts // connect lazily, so there is no timeout — the listener lives until the // draft is sent or replaced. We watch when: @@ -624,6 +647,18 @@ export class NewChatWidget extends Disposable { return result; } + private _applyPreferredDevContainer(session: ISession | undefined, folderUri: URI): void { + if (!session || !this._preferredDevContainerFolderUri || !this.uriIdentityService.extUri.isEqual(this._preferredDevContainerFolderUri, folderUri)) { + return; + } + const provider = this.sessionsProvidersService.getProvider(session.providerId); + if (!provider || !isAgentHostProvider(provider) || !provider.preferDevContainer) { + return; + } + provider.preferDevContainer(session.sessionId); + this._preferredDevContainerFolderUri = undefined; + } + private async _createSessionNow(folderUri: URI, userPick: IPreferredSessionType | undefined, token: CancellationToken): Promise { // Prefer the user's explicit pick when its provider can serve the // folder; otherwise fall back to the preferred (first) session type. @@ -687,7 +722,33 @@ export class NewChatWidget extends Disposable { * Returns the workspace URI for the context picker based on the current workspace selection. */ private _getContextFolderUri(): URI | undefined { - return this._workspacePicker.selectedFolderUri; + return this._isQuickChatComposer.get() ? undefined : this._workspacePicker.selectedFolderUri; + } + + private _selectNoWorkspace(): void { + this._pendingPreferredUpgrade.clear(); + this._newSessionCreation.clear(); + this._openQuickChat(undefined, true); + } + + private _openQuickChat(options?: ICreateNewSessionOptions, keepWorkspacePickerVisible = this._isWorkspacePickerQuickChat.get()): IActiveSession | undefined { + const session = this.sessionsService.openQuickChat(options); + this._workspacePickerQuickChatSessionId.set(keepWorkspacePickerVisible ? session?.sessionId : undefined, undefined); + return session; + } + + private _getNoWorkspaceOption(): IWorkspacePickerNoWorkspaceOption | undefined { + const isWorkspacePickerQuickChat = this._isWorkspacePickerQuickChat.get(); + if (isWeb + || !this._useConsolidatedRemoteWorkspaces.get() + || (!isWorkspacePickerQuickChat && !this.sessionsManagementService.isQuickChatTargetAvailable())) { + return undefined; + } + return { + description: localize('newSessionWorkspacePicker.noWorkspaceDescription', "Start without a backing workspace"), + isSelected: isWorkspacePickerQuickChat, + select: () => this._selectNoWorkspace(), + }; } private _renderWorkspacePicker(container: HTMLElement): IDisposable { @@ -819,8 +880,7 @@ export class NewChatWidget extends Disposable { return false; } const feedbackItems = [...this._feedbackItems.get()]; - const workspaceRoots = session.workspace.get()?.folders.map(folder => folder.root) - ?? (this._workspacePicker.selectedFolderUri ? [this._workspacePicker.selectedFolderUri] : []); + const workspaceRoots = this._getWorkspaceRoots(session); const request = buildNewSessionPrompt(query, feedbackItems, workspaceRoots); const requestContext = new Map(); for (const context of attachedContext ?? []) { @@ -876,7 +936,7 @@ export class NewChatWidget extends Disposable { // session-type/model pickers for the next message. if (background) { if (wasQuickChat) { - this.sessionsService.openQuickChat(); + this._openQuickChat(); } else if (reseedFolderUri) { await this._createNewSession(reseedFolderUri); } @@ -884,6 +944,15 @@ export class NewChatWidget extends Disposable { return true; } + private _getWorkspaceRoots(session: ISession): readonly URI[] { + const sessionWorkspace = session.workspace.get(); + if (sessionWorkspace) { + return sessionWorkspace.folders.map(folder => folder.root); + } + const selectedFolderUri = this._isQuickChatComposer.get() ? undefined : this._workspacePicker.selectedFolderUri; + return selectedFolderUri ? [selectedFolderUri] : []; + } + private _renderFeedbackBanner(container: HTMLElement): void { const host = dom.append(container, dom.$('.session-input-banners.new-session-feedback-banners')); const content = this._register(new MutableDisposable()); @@ -947,6 +1016,9 @@ export class NewChatWidget extends Disposable { private async _onWorkspaceSelected(folderUri: URI | undefined): Promise { // Cancel any in-flight upgrade for a previous selection. this._pendingPreferredUpgrade.clear(); + if (!folderUri || !this._preferredDevContainerFolderUri || !this.uriIdentityService.extUri.isEqual(this._preferredDevContainerFolderUri, folderUri)) { + this._preferredDevContainerFolderUri = undefined; + } const currentFolderUri = this._session.get()?.workspace.get()?.folders[0]?.root; const refreshingPromptOptions = !!currentFolderUri && (!folderUri || !this.uriIdentityService.extUri.isEqual(currentFolderUri, folderUri)) @@ -1004,8 +1076,9 @@ export class NewChatWidget extends Disposable { this._newChatInput.attach(uris); } - selectWorkspace(folderUri: URI, providerId?: string): void { - this._workspacePicker.setSelectedWorkspace(folderUri, { providerId }); + selectWorkspace(folderUri: URI, options?: ISelectWorkspaceOptions): void { + this._preferredDevContainerFolderUri = options?.preferDevContainer ? folderUri : undefined; + this._workspacePicker.setSelectedWorkspace(folderUri, { providerId: options?.providerId }); } } diff --git a/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts b/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts index 8f461f8782633..f10c272bb3215 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts @@ -538,7 +538,7 @@ export class SessionTypePicker extends Disposable { getAriaLabel: (element) => element.item?.groupLabel ? localize('sessionTypePicker.itemAriaLabel', "{0}, {1}", element.label ?? '', element.item.groupLabel) : (element.label ?? ''), getWidgetAriaLabel: () => localize('sessionTypePicker.ariaLabel', "Session Type"), }, - { minWidth: 200 }, + { className: 'sessions-new-chat-picker-list', minWidth: 200 }, ); } diff --git a/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts b/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts index da0d4b525832f..4c29f38ab44a4 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts @@ -95,6 +95,7 @@ export interface IWorkspacePickerOptions { readonly restoreFromSessions?: boolean; readonly sessionWorkspaceProviderFilter?: (providerId: string) => boolean; readonly getWorkspaceGroupAction?: (group: string | undefined) => IWorkspacePickerGroupAction | undefined; + readonly getNoWorkspaceOption?: () => IWorkspacePickerNoWorkspaceOption | undefined; } export interface IWorkspacePickerGroupAction { @@ -105,6 +106,12 @@ export interface IWorkspacePickerGroupAction { readonly hideWorkspaceItems?: boolean; } +export interface IWorkspacePickerNoWorkspaceOption { + readonly description: string; + readonly isSelected: boolean; + readonly select: () => void; +} + export interface IWorkspacePickerTrigger { readonly label?: string; readonly ariaLabel: string; @@ -654,6 +661,10 @@ export class WorkspacePicker extends Disposable { } } + refreshPresentation(): void { + this._updateTriggerLabel(); + } + /** * Subclasses may opt out of the categorical tab bar (e.g. when scoped to * a single host). @@ -719,8 +730,8 @@ export class WorkspacePicker extends Disposable { const showFilter = isConsolidatedWorkspacePicker || items.filter(i => i.kind === ActionListItemKind.Action).length > FILTER_THRESHOLD; return showFilter - ? { showFilter: true, focusFilterOnOpen: isConsolidatedWorkspacePicker, filterPlaceholder: localize('workspacePicker.filter', "Search Workspaces..."), reserveSubmenuSpace: false, inlineDescription: true, showGroupTitleOnFirstItem: true, minWidth: pickerWidth, maxWidth: pickerWidth, hideDefaultKeybindingTooltip: true } - : { reserveSubmenuSpace: false, inlineDescription: true, showGroupTitleOnFirstItem: true, minWidth: pickerWidth, maxWidth: pickerWidth, hideDefaultKeybindingTooltip: true }; + ? { className: 'sessions-new-chat-picker-list', showFilter: true, focusFilterOnOpen: isConsolidatedWorkspacePicker, filterPlaceholder: localize('workspacePicker.filter', "Search Workspaces..."), reserveSubmenuSpace: false, inlineDescription: true, showGroupTitleOnFirstItem: true, minWidth: pickerWidth, maxWidth: pickerWidth, hideDefaultKeybindingTooltip: true } + : { className: 'sessions-new-chat-picker-list', reserveSubmenuSpace: false, inlineDescription: true, showGroupTitleOnFirstItem: true, minWidth: pickerWidth, maxWidth: pickerWidth, hideDefaultKeybindingTooltip: true }; } /** @@ -782,7 +793,7 @@ export class WorkspacePicker extends Disposable { const items = this._buildItems(); const listOptions = this._useConsolidatedRemoteWorkspaces() ? this._buildListOptions(items, undefined) - : { inlineDescription: true, showGroupTitleOnFirstItem: true, hideDefaultKeybindingTooltip: true }; + : { className: 'sessions-new-chat-picker-list', inlineDescription: true, showGroupTitleOnFirstItem: true, hideDefaultKeybindingTooltip: true }; return { items, listOptions }; }, delegate, @@ -1477,7 +1488,32 @@ export class WorkspacePicker extends Disposable { }); } - return items; + const noWorkspaceOption = this._getNoWorkspaceOption(); + if (!noWorkspaceOption || this._directPickerAttachesContext === true) { + return items; + } + + const noWorkspace: IActionListItem = { + kind: ActionListItemKind.Action, + label: localize('workspacePicker.noWorkspace', "No workspace"), + description: noWorkspaceOption.description, + group: { title: '', icon: Codicon.commentDiscussion }, + item: { + checked: noWorkspaceOption.isSelected || undefined, + run: () => { + noWorkspaceOption.select(); + this._updateTriggerLabel(); + this._onDidChangeSelection.fire(); + }, + }, + }; + return items.length > 0 + ? [noWorkspace, { kind: ActionListItemKind.Separator, label: '' }, ...items] + : [noWorkspace]; + } + + protected _getNoWorkspaceOption(): IWorkspacePickerNoWorkspaceOption | undefined { + return this.options.getNoWorkspaceOption?.(); } private _showRemoteHostOptionsDelayed(provider: IAgentHostSessionsProvider): void { @@ -1502,8 +1538,9 @@ export class WorkspacePicker extends Disposable { return; } if (options) { - const workspace = this._selectedResolved?.workspace; const reflectsWorkspace = options.reflectsWorkspace === true; + const noWorkspaceSelected = reflectsWorkspace && this._getNoWorkspaceOption()?.isSelected === true; + const workspace = noWorkspaceSelected ? undefined : this._selectedResolved?.workspace; const isSelectedCategory = options.attachesContext !== true && options.group !== undefined && options.group === workspace?.group; @@ -1530,8 +1567,10 @@ export class WorkspacePicker extends Disposable { const hideForMissingGitHubRepository = options.hideWhenNoGitHubRepository === true && this._getCurrentRepositoryId() === undefined; trigger.parentElement?.toggleAttribute('hidden', hideForSelectedWorkspace || hideForMissingWorkspace || hideForMissingGitHubRepository); - trigger.classList.toggle('selected', (reflectsWorkspace && workspace !== undefined) || isSelectedCategory || badgeCount > 0 || relatedGitHubInfo !== undefined); - const icon = (reflectsWorkspace ? workspace?.icon : undefined) + trigger.classList.toggle('selected', noWorkspaceSelected || (reflectsWorkspace && workspace !== undefined) || isSelectedCategory || badgeCount > 0 || relatedGitHubInfo !== undefined); + const icon = noWorkspaceSelected + ? Codicon.commentDiscussion + : (reflectsWorkspace ? workspace?.icon : undefined) ?? (relatedGitHubInfo ? Codicon.repo : (isSelectedCategory && workspace ? workspace.icon : options.icon)); if (!icon || (options.hideIconWhenAttached === true && badgeCount > 0)) { contents.icon?.remove(); @@ -1543,7 +1582,9 @@ export class WorkspacePicker extends Disposable { } contents.icon.className = ThemeIcon.asClassName(icon); } - const label = (reflectsWorkspace ? workspace?.label : undefined) + const label = noWorkspaceSelected + ? localize('workspacePicker.noWorkspace', "No workspace") + : (reflectsWorkspace ? workspace?.label : undefined) ?? (relatedGitHubInfo ? `${relatedGitHubInfo.owner}/${relatedGitHubInfo.repo}` : (isSelectedCategory && workspace ? workspace.label : options.label)); trigger.setAttribute('aria-label', badgeCount > 0 ? localize('workspacePicker.attachedContextCountAriaLabel', "{0}, {1} attached", options.ariaLabel, badgeCount) @@ -1574,13 +1615,18 @@ export class WorkspacePicker extends Disposable { } dom.clearNode(trigger); - const workspace = this._selectedResolved?.workspace; - const label = workspace ? workspace.label : localize('pickWorkspace', "workspace"); - const icon = workspace ? workspace.icon : Codicon.project; - - trigger.setAttribute('aria-label', workspace - ? localize('workspacePicker.selectedAriaLabel', "New session in {0}", label) - : localize('workspacePicker.pickAriaLabel', "Start by picking a workspace")); + const noWorkspaceSelected = this._getNoWorkspaceOption()?.isSelected === true; + const workspace = noWorkspaceSelected ? undefined : this._selectedResolved?.workspace; + const label = noWorkspaceSelected + ? localize('workspacePicker.noWorkspace', "No workspace") + : workspace?.label ?? localize('pickWorkspace', "workspace"); + const icon = noWorkspaceSelected ? Codicon.commentDiscussion : workspace?.icon ?? Codicon.project; + + trigger.setAttribute('aria-label', noWorkspaceSelected + ? localize('workspacePicker.noWorkspaceSelectedAriaLabel', "New session with no workspace") + : workspace + ? localize('workspacePicker.selectedAriaLabel', "New session in {0}", label) + : localize('workspacePicker.pickAriaLabel', "Start by picking a workspace")); contents.icon = dom.append(trigger, renderIcon(icon)); contents.label = dom.append(trigger, dom.$('span.sessions-chat-dropdown-label')); @@ -1647,7 +1693,7 @@ export class WorkspacePicker extends Disposable { } protected _isSelectedFolder(folderUri: URI | undefined): boolean { - if (!this._selectedFolderUri || !folderUri) { + if (this._getNoWorkspaceOption()?.isSelected || !this._selectedFolderUri || !folderUri) { return false; } return this.uriIdentityService.extUri.isEqual(this._selectedFolderUri, folderUri); diff --git a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts index 46980c64880e5..75b92c4c4a82c 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts @@ -16,7 +16,7 @@ import { FOCUS_AI_CUSTOMIZATION_VIEW_ID } from '../../aiCustomizationTreeView/br import { ISessionsPartService } from '../../../services/sessions/browser/sessionsPartService.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; import { REPLACE_PROMPT_TEMPLATE_PLACEHOLDER_COMMAND_ID } from './promptTemplatePlaceholder.js'; -import { ARCHIVE_SESSION_COMMAND_ID, FOCUS_ACTIVE_SESSION_COMMAND_ID, FOCUS_NEXT_CHAT_GROUP_COMMAND_ID, FOCUS_PREVIOUS_CHAT_GROUP_COMMAND_ID, MOVE_CHAT_TO_NEXT_GROUP_COMMAND_ID, MOVE_CHAT_TO_PREVIOUS_GROUP_COMMAND_ID, RENAME_SESSION_COMMAND_ID, SPLIT_CHAT_GROUP_DOWN_COMMAND_ID, SPLIT_CHAT_GROUP_RIGHT_COMMAND_ID } from '../../../common/sessionCommands.js'; +import { ARCHIVE_SESSION_COMMAND_ID, FOCUS_ACTIVE_SESSION_COMMAND_ID, FOCUS_NEXT_CHAT_GROUP_COMMAND_ID, FOCUS_PREVIOUS_CHAT_GROUP_COMMAND_ID, MOVE_CHAT_TO_NEXT_GROUP_COMMAND_ID, MOVE_CHAT_TO_PREVIOUS_GROUP_COMMAND_ID, RENAME_CHAT_COMMAND_ID, RENAME_SESSION_COMMAND_ID, SPLIT_CHAT_GROUP_DOWN_COMMAND_ID, SPLIT_CHAT_GROUP_RIGHT_COMMAND_ID } from '../../../common/sessionCommands.js'; export class SessionsChatAccessibilityHelp implements IAccessibleViewImplementation { readonly priority = 120; readonly name = 'sessionsChat'; @@ -42,7 +42,7 @@ export class SessionsChatAccessibilityHelp implements IAccessibleViewImplementat content.push(localize('sessionsChat.feedbackComments', "When pull requests have failing checks or unreviewed comments, one banner appears above the input. If several pull requests need attention, use the Previous Banner and Next Banner buttons to move between them. A pull request with both failing checks and comments uses a split button: activate the main action to address both, or use its More Actions button to address only the checks or comments. In-product agent review comments appear as their own carousel item.")); content.push(localize('sessionsChat.feedbackAttachment', "When a feedback comments attachment appears above the input, focus it and press Enter or Space. A single comment opens directly. Multiple comments open a tree grouped by file; use the arrow keys to navigate, Enter to reveal a comment, and Escape to close the tree.")); content.push(localize('sessionsChat.inputBackground', "Press Alt+Enter to start the session in the background without navigating into it. The started session appears in the Chat Sessions view.")); - content.push(localize('sessionsChat.workspace', "Shift+Tab to navigate to the workspace picker and choose a workspace for your session. When consolidated remote workspaces are enabled, opening the picker focuses its search input so you can immediately type to filter workspaces.")); + content.push(localize('sessionsChat.workspace', "Shift+Tab to navigate to the workspace picker and choose a workspace for your session. When consolidated remote workspaces are enabled, opening the picker focuses its search input so you can immediately type to filter workspaces. If quick chats are available, you can also choose No workspace to start a workspace-less chat.")); content.push(localize('sessionsChat.githubContext', "Use Add Context to attach files, images, and, when available, GitHub issues or pull requests.")); content.push(localize('sessionsChat.devContainer', "When Dev Container Agent Host sessions are enabled, Docker is available, and the selected local folder contains a Dev Container configuration, a Dev Container checkbox appears before New Worktree. Select it to run the session on an Agent Host inside that folder's Dev Container.")); content.push(localize('sessionsChat.pullRequestSession', "In a repository section where New Session is a split button, focus New Session and press Right Arrow to reach its dropdown, then activate New Session from Pull Request to open a searchable pull request picker. Pull requests are grouped by review and assignment status. Use the arrow keys to navigate, Enter to create the session, and Escape to close the picker.")); @@ -77,7 +77,8 @@ export class SessionsChatAccessibilityHelp implements IAccessibleViewImplementat content.push(localize('sessionsChat.goForward', "Go forward through visited sessions{0}.", '')); content.push(localize('sessionsChat.navigatePreviousSession', "Navigate to the previous session in the list{0}.", '')); content.push(localize('sessionsChat.navigateNextSession', "Navigate to the next session in the list{0}.", '')); - content.push(localize('sessionsChat.renameSession', "To rename a session, focus it in the Sessions list or focus its chat transcript or input, then invoke Rename{0}. You can also double-click its title in the Sessions list or open its context menu and choose Rename.", ``)); + content.push(localize('sessionsChat.renameSession', "To rename a session, focus its row in the Sessions list or focus its main chat transcript or input, then invoke Rename{0}. You can also double-click its title in the Sessions list or open its context menu and choose Rename.", ``)); + content.push(localize('sessionsChat.renameChat', "When Rename is available for a non-main chat, focus its transcript or input or its nested row in the Sessions list, then invoke Rename{0}.", ``)); content.push(localize('sessionsChat.archiveSession', "To archive or mark one or more sessions as done, focus them in the Sessions list and invoke Archive or Mark as Done{0}.", ``)); content.push(localize('sessionsChat.deleteSession', "To permanently delete a session, open its context menu and choose Delete. This is destructive and cannot be undone.")); content.push(localize('sessionsChat.changes', "Focus the Changes view{0}.", '')); diff --git a/src/vs/sessions/contrib/chat/electron-browser/chat.contribution.ts b/src/vs/sessions/contrib/chat/electron-browser/chat.contribution.ts index e8371d55cece6..4a4f1ab768ed9 100644 --- a/src/vs/sessions/contrib/chat/electron-browser/chat.contribution.ts +++ b/src/vs/sessions/contrib/chat/electron-browser/chat.contribution.ts @@ -8,6 +8,7 @@ import { URI, UriComponents } from '../../../../base/common/uri.js'; import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; import { IAgentHostByokLmHandler } from '../../../../platform/agentHost/common/agentHostByokLm.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { AgentHostByokLmHandler } from '../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostByokLmHandler.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; @@ -27,6 +28,7 @@ import { ITelemetryService } from '../../../../platform/telemetry/common/telemet import { TOTAL_SESSIONS_KEY } from '../../sessions/browser/sessionsLifecycleTracker.js'; import { ISessionsWindowOpenViewState, SessionsWindowOpenTelemetry, SessionsWindowSessionStartTelemetry } from '../../sessions/browser/sessionsWindowOpenTelemetry.js'; import { INewSessionComposerService, NewSessionWorkspacePreselectionSource } from '../browser/newSessionComposerService.js'; +import { resolveAgentsWindowFolderIntent } from '../browser/agentsWindowOpenIntent.js'; class SelectAgentsFolderContribution extends Disposable implements IWorkbenchContribution { @@ -46,16 +48,18 @@ class SelectAgentsFolderContribution extends Disposable implements IWorkbenchCon @IStorageService private readonly storageService: IStorageService, @ITelemetryService private readonly telemetryService: ITelemetryService, @INewSessionComposerService private readonly newSessionComposerService: INewSessionComposerService, + @IConfigurationService private readonly configurationService: IConfigurationService, ) { super(); const handleSelectAgentsFolder = (_: unknown, ...args: unknown[]) => { - const folderUri = args[0] ? URI.revive(args[0] as UriComponents) : undefined; + const workspaceUri = args[0] ? URI.revive(args[0] as UriComponents) : undefined; + const { folderUri, preferDevContainer } = resolveAgentsWindowFolderIntent(workspaceUri, this.configurationService); const sessionResource = args[1] ? URI.revive(args[1] as UriComponents) : undefined; const source = isAgentsWindowOpenSource(args[2]) ? args[2] : AgentsWindowOpenSource.Unknown; this.logService.info(`[AgentsHandoff] IPC received: folderUri=${folderUri?.toString() ?? '(none)'} sessionResource=${sessionResource?.toString() ?? '(none)'}`); this._startWindowOpenTelemetry(source); - this._handleOpenIntentAndCaptureInitialState(folderUri, sessionResource) + this._handleOpenIntentAndCaptureInitialState(folderUri, sessionResource, preferDevContainer) .catch(err => this.logService.error('[AgentsHandoff] handleOpenIntent failed', err)); }; ipcRenderer.on('vscode:selectAgentsFolder', handleSelectAgentsFolder); @@ -87,9 +91,9 @@ class SelectAgentsFolderContribution extends Disposable implements IWorkbenchCon this._windowOpenTelemetry.value?.captureInitialViewState(); } - private async _handleOpenIntentAndCaptureInitialState(folderUri: URI | undefined, sessionResource: URI | undefined): Promise { + private async _handleOpenIntentAndCaptureInitialState(folderUri: URI | undefined, sessionResource: URI | undefined, preferDevContainer: boolean): Promise { try { - await this.handleOpenIntent(folderUri, sessionResource); + await this.handleOpenIntent(folderUri, sessionResource, preferDevContainer); } finally { await this._captureInitialWindowViewState(); } @@ -114,7 +118,7 @@ class SelectAgentsFolderContribution extends Disposable implements IWorkbenchCon }; } - private async handleOpenIntent(folderUri: URI | undefined, sessionResource: URI | undefined): Promise { + private async handleOpenIntent(folderUri: URI | undefined, sessionResource: URI | undefined, preferDevContainer: boolean): Promise { // Opening an existing session establishes its own workspace context, so // the folder selection is only needed for the folder-only handoff (no // session to restore). @@ -123,7 +127,7 @@ class SelectAgentsFolderContribution extends Disposable implements IWorkbenchCon return; } if (folderUri) { - await this.selectFolder(folderUri); + await this.selectFolder(folderUri, preferDevContainer); } } @@ -186,11 +190,11 @@ class SelectAgentsFolderContribution extends Disposable implements IWorkbenchCon }); } - private async selectFolder(folderUri: URI): Promise { + private async selectFolder(folderUri: URI, preferDevContainer: boolean): Promise { // Wait for the welcome/setup flow to complete before selecting the folder await this.sessionsSetUpService.whenWelcomeDone(); - this.sessionsService.openNewSession(); + await this.sessionsService.openNewSession({ cancelRestore: true }); // Tell the sessions list this folder is the open-window source folder // so it ranks the matching folder section first. Get the view if it @@ -198,27 +202,30 @@ class SelectAgentsFolderContribution extends Disposable implements IWorkbenchCon const sessionsView = this.viewsService.getViewWithId(SessionsListViewId); sessionsView?.sessionsControl?.setOpenWindowSourceFolder(folderUri); - if (this.tryResolveAndSelect(folderUri)) { + if (this.tryResolveAndSelect(folderUri, preferDevContainer)) { return; } // Provider not registered yet — wait for it, but give up at Eventually phase const disposable = this.sessionsProvidersService.onDidChangeProviders(() => { - if (this.tryResolveAndSelect(folderUri)) { + if (this.tryResolveAndSelect(folderUri, preferDevContainer)) { disposable.dispose(); } }); this.lifecycleService.when(LifecyclePhase.Eventually).then(() => disposable.dispose()); } - private tryResolveAndSelect(folderUri: URI): boolean { + private tryResolveAndSelect(folderUri: URI, preferDevContainer: boolean): boolean { const resolved = this.sessionsManagementService.resolveWorkspace(folderUri); if (!resolved) { return false; } const activeSession = this.sessionsService.activeSession.get(); if (activeSession === undefined || activeSession.status.get() === SessionStatus.Untitled) { - this.sessionsPartService.getSessionView(activeSession?.sessionId)?.selectWorkspace(folderUri, resolved.providerId); + this.sessionsPartService.getSessionView(activeSession?.sessionId)?.selectWorkspace(folderUri, { + providerId: resolved.providerId, + preferDevContainer, + }); } return true; } diff --git a/src/vs/sessions/contrib/chat/test/browser/agentsWindowOpenIntent.test.ts b/src/vs/sessions/contrib/chat/test/browser/agentsWindowOpenIntent.test.ts new file mode 100644 index 0000000000000..cb9c4254ab1bf --- /dev/null +++ b/src/vs/sessions/contrib/chat/test/browser/agentsWindowOpenIntent.test.ts @@ -0,0 +1,51 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { encodeHex, VSBuffer } from '../../../../../base/common/buffer.js'; +import { Schemas } from '../../../../../base/common/network.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { DevContainerAgentHostEnabledSettingId } from '../../../../common/devContainerAgentHostService.js'; +import { resolveAgentsWindowFolderIntent } from '../../browser/agentsWindowOpenIntent.js'; + +suite('Agents Window open intent', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('resolves local and Dev Container editor workspaces', () => { + const configurationService = (enabled: boolean) => new TestConfigurationService({ + [DevContainerAgentHostEnabledSettingId]: enabled, + }); + const localFolder = URI.file('/workspace'); + const hostFolder = URI.file('/host/workspace'); + const devContainerFolder = URI.from({ + scheme: Schemas.vscodeRemote, + authority: `dev-container+${encodeHex(VSBuffer.fromString(hostFolder.fsPath))}`, + path: '/workspaces/project', + }); + const resolve = (uri: URI | undefined, enabled: boolean) => { + const result = resolveAgentsWindowFolderIntent(uri, configurationService(enabled)); + return { + folderUri: result.folderUri?.toString(), + preferDevContainer: result.preferDevContainer, + }; + }; + + assert.deepStrictEqual({ + local: resolve(localFolder, true), + devContainerEnabled: resolve(devContainerFolder, true), + devContainerDisabled: resolve(devContainerFolder, false), + otherRemote: resolve(URI.parse('vscode-remote://ssh-remote+host/workspace'), true), + invalidDevContainer: resolve(URI.parse('vscode-remote://dev-container+invalid/workspace'), true), + }, { + local: { folderUri: localFolder.toString(), preferDevContainer: false }, + devContainerEnabled: { folderUri: hostFolder.toString(), preferDevContainer: true }, + devContainerDisabled: { folderUri: hostFolder.toString(), preferDevContainer: false }, + otherRemote: { folderUri: undefined, preferDevContainer: false }, + invalidDevContainer: { folderUri: undefined, preferDevContainer: false }, + }); + }); +}); diff --git a/src/vs/sessions/contrib/chat/test/browser/newChatWidget.fixture.ts b/src/vs/sessions/contrib/chat/test/browser/newChatWidget.fixture.ts index 0e68827486220..a8a3794a8c206 100644 --- a/src/vs/sessions/contrib/chat/test/browser/newChatWidget.fixture.ts +++ b/src/vs/sessions/contrib/chat/test/browser/newChatWidget.fixture.ts @@ -413,7 +413,7 @@ export default defineThemedFixtureGroup({ path: 'sessions/chat/newWidget/' }, { }), NewSessionWorkspacePicker: defineComponentFixture({ labels: { kind: 'screenshot', blocksCi: true }, - expectedVisualDescriptions: ['The new-session composer shows Copilot, microsoft/vscode, and Issue/PR pills. The microsoft/vscode workspace pill has the active treatment after opening the workspace picker.'], + expectedVisualDescriptions: ['The new-session composer shows Copilot, microsoft/vscode, and Issue/PR pills. The microsoft/vscode workspace pill has the active treatment after opening the workspace picker. Pill and dropdown labels use the same body text size, and their leading icons use the same base icon size.'], render: context => renderNewChatWidget(context, { withWorkspace: true, openWorkspacePicker: true }), }), NewSessionGitHubContextPicker: defineComponentFixture({ diff --git a/src/vs/sessions/contrib/chat/test/browser/newChatWidget.test.ts b/src/vs/sessions/contrib/chat/test/browser/newChatWidget.test.ts index 6c3f0b6905e14..af7c9b1928279 100644 --- a/src/vs/sessions/contrib/chat/test/browser/newChatWidget.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/newChatWidget.test.ts @@ -4,11 +4,12 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { DeferredPromise } from '../../../../../base/common/async.js'; +import { DeferredPromise, timeout } from '../../../../../base/common/async.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { IDisposable, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; import { autorun, constObservable, IObservable, observableValue } from '../../../../../base/common/observable.js'; +import { isWeb } from '../../../../../base/common/platform.js'; import { extUri } from '../../../../../base/common/resources.js'; import { URI } from '../../../../../base/common/uri.js'; import { upcastPartial } from '../../../../../base/test/common/mock.js'; @@ -21,6 +22,8 @@ import { NewChatWidget } from '../../browser/newChatWidget.js'; import { IChatRequestVariableEntry, toFileVariableEntry, toPasteVariableEntry } from '../../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { getAdditionalFolderContextId, getAdditionalRepositoryContextId } from '../../common/newChatContextIds.js'; +import { LOCAL_AGENT_HOST_PROVIDER_ID } from '../../../../common/agentHostSessionsProvider.js'; +import { IWorkspacePickerNoWorkspaceOption } from '../../browser/sessionWorkspacePicker.js'; /** The part of the active session `_recreateOnProviderChange` actually reads. */ interface IActiveDraft { @@ -65,6 +68,7 @@ interface INewChatWidgetHarness extends IRecreateHarness { }; }; _createSessionNow(folderUri: URI, userPick: IPreferredSessionType | undefined, token: CancellationToken): Promise; + _applyPreferredDevContainer(session: ISession | undefined, folderUri: URI): void; _scheduleRecreateOnProviderChange(folderUri: URI, userPick: IPreferredSessionType | undefined, created: ISession | undefined, replayMissedChange: boolean): void; _recreateOnProviderChange(folderUri: URI, userPick: IPreferredSessionType | undefined, created: ISession | undefined): void; } @@ -79,6 +83,17 @@ const createSessionNow = Reflect.get(NewChatWidget.prototype, '_createSessionNow userPick: IPreferredSessionType | undefined, token: CancellationToken, ) => Promise; +const applyPreferredDevContainer = Reflect.get(NewChatWidget.prototype, '_applyPreferredDevContainer') as ( + this: { + _preferredDevContainerFolderUri: URI | undefined; + readonly uriIdentityService: { readonly extUri: typeof extUri }; + readonly sessionsProvidersService: { + getProvider(providerId: string): { readonly id: string; preferDevContainer?(sessionId: string): void } | undefined; + }; + }, + session: ISession | undefined, + folderUri: URI, +) => void; const scheduleRecreateOnProviderChange = Reflect.get(NewChatWidget.prototype, '_scheduleRecreateOnProviderChange') as INewChatWidgetHarness['_scheduleRecreateOnProviderChange']; const recreateOnProviderChange = Reflect.get(NewChatWidget.prototype, '_recreateOnProviderChange') as ( this: IRecreateHarness, @@ -112,6 +127,7 @@ interface ISendHarness { readonly agentFeedbackService: { removeFeedback(resource: URI, id: string): void }; readonly sessionsManagementService: { sendNewChatRequest(session: ISession, options: ISendRequestOptions): Promise }; readonly logService: { error(message: string, ...args: unknown[]): void }; + _getWorkspaceRoots(session: ISession): readonly URI[]; } interface IRenderSessionTypePickerHarness { @@ -131,8 +147,33 @@ interface IRenderWorkspacePickerHarness extends IRenderSessionTypePickerHarness _workspacePickerRow: HTMLElement | undefined; } +interface ISelectNoWorkspaceHarness { + readonly _pendingPreferredUpgrade: MutableDisposable; + readonly _newSessionCreation: MutableDisposable; + readonly _isWorkspacePickerQuickChat: IObservable; + readonly _workspacePickerQuickChatSessionId: ReturnType>; + readonly sessionsService: { openQuickChat(): { readonly sessionId: string } }; + _openQuickChat(options?: undefined, keepWorkspacePickerVisible?: boolean): { readonly sessionId: string } | undefined; +} + +interface INoWorkspaceOptionHarness { + readonly _useConsolidatedRemoteWorkspaces: IObservable; + readonly _isWorkspacePickerQuickChat: IObservable; + readonly sessionsManagementService: { isQuickChatTargetAvailable(): boolean }; + _selectNoWorkspace(): void; +} + +interface IWorkspaceRootsHarness { + readonly _isQuickChatComposer: IObservable; + readonly _workspacePicker: { readonly selectedFolderUri: URI | undefined }; +} + const renderWorkspacePicker = Reflect.get(NewChatWidget.prototype, '_renderWorkspacePicker') as (this: IRenderWorkspacePickerHarness, container: HTMLElement) => IDisposable; const renderSessionTypePicker = Reflect.get(NewChatWidget.prototype, '_renderSessionTypePicker') as (this: IRenderSessionTypePickerHarness, container: HTMLElement, isQuickChat: boolean) => void; +const selectNoWorkspace = Reflect.get(NewChatWidget.prototype, '_selectNoWorkspace') as (this: ISelectNoWorkspaceHarness) => void; +const openQuickChat = Reflect.get(NewChatWidget.prototype, '_openQuickChat') as ISelectNoWorkspaceHarness['_openQuickChat']; +const getNoWorkspaceOption = Reflect.get(NewChatWidget.prototype, '_getNoWorkspaceOption') as (this: INoWorkspaceOptionHarness) => IWorkspacePickerNoWorkspaceOption | undefined; +const getWorkspaceRoots = Reflect.get(NewChatWidget.prototype, '_getWorkspaceRoots') as (this: IWorkspaceRootsHarness, session: ISession) => readonly URI[]; function createHarness( pendingPreferredUpgrade: MutableDisposable, @@ -153,6 +194,7 @@ function createHarness( }, _isPreferredServable: () => false, _createSessionNow: (_folderUri, _userPick, token) => stubCreateSessionNow(token), + _applyPreferredDevContainer: () => { }, _createNewSession: folderUri => createNewSession.call(harness, folderUri), _scheduleRecreateOnProviderChange: (folderUri, userPick, created, replayMissedChange) => scheduleRecreateOnProviderChange.call(harness, folderUri, userPick, created, replayMissedChange), _recreateOnProviderChange: (folderUri, userPick, created) => recreateOnProviderChange.call(harness, folderUri, userPick, created), @@ -261,6 +303,107 @@ suite('NewChatWidget', () => { }); }); + test('selecting No workspace cancels pending workspace creation', () => { + let pendingUpgradeDisposed = false; + let sessionCreationDisposed = false; + let quickChatOpenCount = 0; + const pendingPreferredUpgrade = disposables.add(new MutableDisposable()); + const newSessionCreation = disposables.add(new MutableDisposable()); + const workspacePickerQuickChatSessionId = observableValue('workspacePickerQuickChatSessionId', undefined); + pendingPreferredUpgrade.value = toDisposable(() => pendingUpgradeDisposed = true); + newSessionCreation.value = toDisposable(() => sessionCreationDisposed = true); + + const harness: ISelectNoWorkspaceHarness = { + _pendingPreferredUpgrade: pendingPreferredUpgrade, + _newSessionCreation: newSessionCreation, + _isWorkspacePickerQuickChat: constObservable(false), + _workspacePickerQuickChatSessionId: workspacePickerQuickChatSessionId, + sessionsService: { + openQuickChat: () => { + quickChatOpenCount++; + return { sessionId: 'quick-chat' }; + }, + }, + _openQuickChat: (options, keepWorkspacePickerVisible) => openQuickChat.call(harness, options, keepWorkspacePickerVisible), + }; + selectNoWorkspace.call(harness); + + assert.deepStrictEqual({ + pendingUpgradeDisposed, + sessionCreationDisposed, + quickChatOpenCount, + workspacePickerQuickChatSessionId: workspacePickerQuickChatSessionId.get(), + }, { + pendingUpgradeDisposed: true, + sessionCreationDisposed: true, + quickChatOpenCount: 1, + workspacePickerQuickChatSessionId: 'quick-chat', + }); + }); + + test('ordinary quick chats do not retain the workspace picker', () => { + const workspacePickerQuickChatSessionId = observableValue('workspacePickerQuickChatSessionId', 'previous-quick-chat'); + const harness: ISelectNoWorkspaceHarness = { + _pendingPreferredUpgrade: disposables.add(new MutableDisposable()), + _newSessionCreation: disposables.add(new MutableDisposable()), + _isWorkspacePickerQuickChat: constObservable(false), + _workspacePickerQuickChatSessionId: workspacePickerQuickChatSessionId, + sessionsService: { openQuickChat: () => ({ sessionId: 'ordinary-quick-chat' }) }, + _openQuickChat: (options, keepWorkspacePickerVisible) => openQuickChat.call(harness, options, keepWorkspacePickerVisible), + }; + + openQuickChat.call(harness); + + assert.strictEqual(workspacePickerQuickChatSessionId.get(), undefined); + }); + + test('offers No workspace only when enabled and quick chats are available', () => { + const cases = [ + { enabled: false, available: true, isWorkspacePickerQuickChat: false }, + { enabled: true, available: false, isWorkspacePickerQuickChat: false }, + { enabled: true, available: true, isWorkspacePickerQuickChat: false }, + { enabled: true, available: false, isWorkspacePickerQuickChat: true }, + ]; + + const options = cases.map(testCase => { + const option = getNoWorkspaceOption.call({ + _useConsolidatedRemoteWorkspaces: constObservable(testCase.enabled), + _isWorkspacePickerQuickChat: constObservable(testCase.isWorkspacePickerQuickChat), + sessionsManagementService: { isQuickChatTargetAvailable: () => testCase.available }, + _selectNoWorkspace: () => { }, + }); + return option && { description: option.description, isSelected: option.isSelected }; + }); + + assert.deepStrictEqual(options, isWeb + ? [undefined, undefined, undefined, undefined] + : [ + undefined, + undefined, + { description: 'Start without a backing workspace', isSelected: false }, + { description: 'Start without a backing workspace', isSelected: true }, + ]); + }); + + test('workspace-less chats do not inherit the previous picker workspace', () => { + const staleFolder = URI.file('/previous-workspace'); + const session = upcastPartial({ workspace: constObservable(undefined) }); + + assert.deepStrictEqual({ + quickChat: getWorkspaceRoots.call({ + _isQuickChatComposer: constObservable(true), + _workspacePicker: { selectedFolderUri: staleFolder }, + }, session).map(uri => uri.toString()), + workspaceDraft: getWorkspaceRoots.call({ + _isQuickChatComposer: constObservable(false), + _workspacePicker: { selectedFolderUri: staleFolder }, + }, session).map(uri => uri.toString()), + }, { + quickChat: [], + workspaceDraft: [staleFolder.toString()], + }); + }); + test('replays a provider change that arrives while creating the draft', async () => { const sessionTypesChanged = disposables.add(new Emitter()); const pendingPreferredUpgrade = disposables.add(new MutableDisposable()); @@ -283,21 +426,102 @@ suite('NewChatWidget', () => { assert.strictEqual(createCount, 2); }); - test('waits for another provider change after creation fails', async () => { + test('applies the Dev Container preference when a late provider creates the draft', async () => { const sessionTypesChanged = disposables.add(new Emitter()); const pendingPreferredUpgrade = disposables.add(new MutableDisposable()); const newSessionCreation = disposables.add(new MutableDisposable()); let createCount = 0; + const applied: string[] = []; + const session = upcastPartial({ sessionId: 'draft', providerId: LOCAL_AGENT_HOST_PROVIDER_ID }); const harness = createHarness(pendingPreferredUpgrade, newSessionCreation, sessionTypesChanged.event, async () => { createCount++; - return { session: undefined, trustDeclined: false }; + return { session: createCount === 1 ? undefined : session, trustDeclined: false }; }); + harness._applyPreferredDevContainer = created => { + if (created) { + applied.push(created.sessionId); + } + }; await harness._createNewSession(URI.file('/project')); const countBeforeChange = createCount; sessionTypesChanged.fire(); + await timeout(0); - assert.deepStrictEqual({ countBeforeChange, countAfterChange: createCount }, { countBeforeChange: 1, countAfterChange: 2 }); + assert.deepStrictEqual({ + countBeforeChange, + countAfterChange: createCount, + applied, + }, { + countBeforeChange: 1, + countAfterChange: 2, + applied: ['draft'], + }); + }); + + test('applies the Dev Container preference after the composer creates the requested draft', async () => { + const sessionTypesChanged = disposables.add(new Emitter()); + const pendingPreferredUpgrade = disposables.add(new MutableDisposable()); + const newSessionCreation = disposables.add(new MutableDisposable()); + const folder = URI.file('/project'); + const session = upcastPartial({ sessionId: 'draft', providerId: LOCAL_AGENT_HOST_PROVIDER_ID }); + const applied: Array<{ sessionId: string; folder: string }> = []; + const harness = createHarness(pendingPreferredUpgrade, newSessionCreation, sessionTypesChanged.event, async () => ({ + session, + trustDeclined: false, + })); + harness._applyPreferredDevContainer = (created, createdFolder) => { + if (created) { + applied.push({ sessionId: created.sessionId, folder: createdFolder.toString() }); + } + }; + + await harness._createNewSession(folder); + + assert.deepStrictEqual(applied, [{ sessionId: 'draft', folder: folder.toString() }]); + }); + + test('applies a pending Dev Container preference only to a matching Agent Host draft', () => { + const folder = URI.file('/project'); + const preferred: string[] = []; + const harness = { + _preferredDevContainerFolderUri: folder, + uriIdentityService: { extUri }, + sessionsProvidersService: { + getProvider: (providerId: string) => providerId === LOCAL_AGENT_HOST_PROVIDER_ID + ? { id: providerId, preferDevContainer: (sessionId: string) => preferred.push(sessionId) } + : { id: providerId }, + }, + }; + applyPreferredDevContainer.call( + harness, + upcastPartial({ sessionId: 'other', providerId: 'other-provider' }), + folder, + ); + const pendingAfterOtherProvider = harness._preferredDevContainerFolderUri?.toString(); + applyPreferredDevContainer.call( + harness, + upcastPartial({ sessionId: 'local', providerId: LOCAL_AGENT_HOST_PROVIDER_ID }), + URI.file('/other-project'), + ); + const pendingAfterOtherFolder = harness._preferredDevContainerFolderUri?.toString(); + applyPreferredDevContainer.call( + harness, + upcastPartial({ sessionId: 'local', providerId: LOCAL_AGENT_HOST_PROVIDER_ID }), + folder, + ); + + assert.deepStrictEqual({ + preferred, + pendingAfterOtherProvider, + pendingAfterOtherFolder, + pendingAfterMatch: harness._preferredDevContainerFolderUri, + }, { + preferred: ['local'], + pendingAfterOtherProvider: folder.toString(), + pendingAfterOtherFolder: folder.toString(), + pendingAfterMatch: undefined, + }); }); test('cancels an in-flight creation when a newer one starts', async () => { @@ -500,6 +724,7 @@ suite('NewChatWidget', () => { }, }, logService: { error: () => { } }, + _getWorkspaceRoots: () => [primaryFolder], }, 'work across contexts', [ composerAttachment, duplicateRepositoryAttachment, @@ -549,6 +774,7 @@ suite('NewChatWidget', () => { }, }, logService: { error: () => { } }, + _getWorkspaceRoots: () => [], }, 'work across contexts'); assert.deepStrictEqual({ result, pickerOpenCount, sendCount }, { diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts index e8649159a643a..c682f75aec161 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts @@ -3164,6 +3164,65 @@ suite('WorkspacePicker - Tab discovery', () => { }); }); + test('selects No workspace through the consolidated picker', async () => { + let noWorkspaceSelected = false; + const picker = createTestablePicker(disposables, providersService, true, { + getNoWorkspaceOption: () => ({ + description: 'Start without a backing workspace', + isSelected: noWorkspaceSelected, + select: () => noWorkspaceSelected = true, + }), + }, undefined, undefined, true); + const container = document.createElement('div'); + picker.renderCategoryTriggers(container, [{ + label: 'Workspace', + ariaLabel: 'Choose a workspace for the new session', + icon: Codicon.project, + reflectsWorkspace: true, + }]); + + const before = { + items: picker.getItems().filter(item => item.kind === ActionListItemKind.Action).map(item => ({ + label: item.label, + description: item.description, + checked: item.item?.checked, + })), + triggerLabel: container.querySelector('.sessions-chat-dropdown-label')?.textContent, + }; + await picker.select('No workspace'); + + assert.deepStrictEqual({ + before, + after: { + items: picker.getItems().filter(item => item.kind === ActionListItemKind.Action).map(item => ({ + label: item.label, + description: item.description, + checked: item.item?.checked, + })), + triggerLabel: container.querySelector('.sessions-chat-dropdown-label')?.textContent, + triggerAriaLabel: container.querySelector('.action-label')?.getAttribute('aria-label'), + }, + }, { + before: { + items: [{ + label: 'No workspace', + description: 'Start without a backing workspace', + checked: undefined, + }], + triggerLabel: 'Workspace', + }, + after: { + items: [{ + label: 'No workspace', + description: 'Start without a backing workspace', + checked: true, + }], + triggerLabel: 'No workspace', + triggerAriaLabel: 'Workspace: No workspace', + }, + }); + }); + test('keeps GitHub context actions separate when groups are combined', () => { providersService.setProviders([ createMockProvider('github', { diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionChangesets.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionChangesets.ts index fd38a742ac663..73f4ab29f702d 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionChangesets.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionChangesets.ts @@ -354,15 +354,15 @@ abstract class AbstractAgentHostChangeset implements ISessionChangeset { return changes; } - async invokeOperation(operationId: string, target?: ISessionChangesetOperationTarget): Promise { + async invokeOperation(operationId: string, target?: ISessionChangesetOperationTarget, _meta?: Record): Promise { const connection = this._options.getConnection(); if (!connection) { - return; + throw new Error(`Cannot invoke changeset operation '${operationId}' because the agent host connection is unavailable.`); } const channel = this.channelUriObs.get(); if (!channel) { - return; + throw new Error(`Cannot invoke changeset operation '${operationId}' because the changeset channel is unavailable.`); } const operation = this.operations.get().find(o => o.id === operationId); @@ -393,6 +393,7 @@ abstract class AbstractAgentHostChangeset implements ISessionChangeset { resource: target.resource.toString() } : undefined, + _meta }); } finally { this._setOperationLocallyRunning(operationId, false); diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionConfigPicker.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionConfigPicker.ts index bee227e2999fe..1ce1e12b80d85 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionConfigPicker.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionConfigPicker.ts @@ -12,7 +12,7 @@ import { IActionWidgetService } from '../../../../../platform/actionWidget/brows import { BaseActionViewItem } from '../../../../../base/browser/ui/actionbar/actionViewItems.js'; import { Checkbox } from '../../../../../base/browser/ui/toggle/toggle.js'; import { toAction } from '../../../../../base/common/actions.js'; -import { Delayer } from '../../../../../base/common/async.js'; +import { Delayer, SequencerByKey } from '../../../../../base/common/async.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { Disposable, DisposableMap, DisposableStore, IDisposable, MutableDisposable } from '../../../../../base/common/lifecycle.js'; import { autorun, IObservable, observableValue } from '../../../../../base/common/observable.js'; @@ -54,17 +54,20 @@ import { type IAgentHostSessionsProvider, isAgentHostProvider, LOCAL_AGENT_HOST_ import { PermissionPicker } from '../../copilotChatSessions/browser/permissionPicker.js'; import { MobilePermissionPicker } from '../../copilotChatSessions/browser/mobilePermissionPicker.js'; import { isPhoneLayout } from '../../../../browser/parts/mobile/mobileLayout.js'; -import { showMobilePickerSheet, IMobilePickerSheetItem, IMobilePickerSheetSearchSource } from '../../../../browser/parts/mobile/mobilePickerSheet.js'; +import { showMobilePickerSheet, IMobilePickerSheetItem, IMobilePickerSheetSearchSource, MOBILE_PICKER_SHEET_CONFIRM } from '../../../../browser/parts/mobile/mobilePickerSheet.js'; import { AgentHostModePicker } from './agentHostModePicker.js'; import { MobileAgentHostModePicker } from './mobile/mobileAgentHostModePicker.js'; import { AgentHostPermissionPickerActionItem } from './agentHostPermissionPickerActionItem.js'; import { AgentHostPermissionPickerDelegate, isWellKnownAutoApproveSchema, isWellKnownClaudePermissionModeSchema, isWellKnownCodexApprovalsSchema, isWellKnownModeSchema } from './agentHostPermissionPickerDelegate.js'; import { SessionConfigKey } from '../../../../../platform/agentHost/common/sessionConfigKeys.js'; +import { AGENT_HOST_CHECKOUT_CHANGESET_OPERATION_ID } from '../../../../../platform/agentHost/common/agentHostChangesetOperationService.js'; +import { checkoutOperationMeta } from '../../../../../platform/agentHost/common/meta/agentCheckoutOperationMeta.js'; import { AgentHostClaudePermissionModePicker } from './agentHostClaudePermissionModePicker.js'; import { ClaudeSessionConfigKey } from '../../../../../platform/agentHost/common/claudeSessionConfigKeys.js'; import { AgentHostCodexApprovalsPicker } from './agentHostCodexApprovalsPicker.js'; import { isAutoApproveValuePolicyRestricted } from '../../../../../workbench/contrib/chat/common/agentHostConfigPolicy.js'; import { CodexSessionConfigKey } from '../../../../../platform/agentHost/common/codexSessionConfigKeys.js'; +import { type ISessionChangeset, UNCOMMITTED_CHANGES_CHANGESET_ID } from '../../../../services/sessions/common/session.js'; const IsActiveSessionRemoteAgentHost = ContextKeyExpr.regex(SessionProviderIdContext.key, REMOTE_AGENT_HOST_PROVIDER_RE); const IsActiveSessionLocalAgentHost = ContextKeyExpr.equals(SessionProviderIdContext.key, LOCAL_AGENT_HOST_PROVIDER_ID); @@ -346,6 +349,7 @@ export class AgentHostSessionConfigPicker extends Disposable { private readonly _devContainerCheckbox = this._register(new MutableDisposable()); private readonly _isolationCheckbox = this._register(new MutableDisposable()); protected readonly _filterDelayer = this._register(new Delayer[]>(200)); + private readonly _repositoryConfigSequencer = new SequencerByKey(); private _container: HTMLElement | undefined; /** @@ -386,7 +390,10 @@ export class AgentHostSessionConfigPicker extends Disposable { super(); this._register(autorun(reader => { - this._session.read(reader); + const session = this._session.read(reader); + for (const changeset of session?.changesets?.read(reader) ?? []) { + changeset.operations?.read(reader); + } this._renderConfigPickers(); })); @@ -521,7 +528,8 @@ export class AgentHostSessionConfigPicker extends Disposable { continue; } const value = resolvedConfig.values[property] ?? schema.default; - const isReadOnly = this._isReadOnlyChip(property, schema, isNewSession); + const isReadOnly = this._isReadOnlyChip(property, schema, isNewSession) + || (this._requiresBranchCheckout(provider, session.sessionId, property) && !this._getCheckoutChangeset(session.sessionId)); // Isolation renders as a Worktree checkbox on desktop; the phone layout keeps the chip for the unified repo sheet. if (property === SessionConfigKey.Isolation && this._shouldRenderIsolationAsCheckbox(schema)) { this._renderIsolationCheckbox(provider, session.sessionId, schema, value, isReadOnly, !isReadOnly && isLoading); @@ -625,6 +633,78 @@ export class AgentHostSessionConfigPicker extends Disposable { return !!schema.readOnly; } + protected async _setSessionConfigValue(provider: IAgentHostSessionsProvider, sessionId: string, property: string, value: unknown): Promise { + const isDraftRepositoryProperty = provider.getCreateSessionConfig(sessionId) !== undefined + && (property === SessionConfigKey.Isolation || property === SessionConfigKey.Branch); + if (!isDraftRepositoryProperty) { + await provider.setSessionConfigValue(sessionId, property, value); + return; + } + + let treeish: string | undefined; + if (property === SessionConfigKey.Branch) { + if (typeof value !== 'string' || value.length === 0) { + throw new Error('Branch configuration values must be non-empty strings.'); + } + treeish = value; + } + + const configOperation = this._repositoryConfigSequencer.queue(sessionId, async () => { + const shouldCheckout = this._requiresBranchCheckout(provider, sessionId, property); + if (!shouldCheckout) { + await provider.setSessionConfigValue(sessionId, property, value); + return; + } + if (!treeish) { + throw new Error('Branch checkout requires a non-empty treeish.'); + } + + const changeset = this._getCheckoutChangeset(sessionId); + if (!changeset) { + throw new Error('Branch checkout is not available for this session.'); + } + + const confirmedValue = provider.getSessionConfig(sessionId)?.values[property]; + try { + await provider.setSessionConfigValue(sessionId, property, value); + await changeset.invokeOperation( + AGENT_HOST_CHECKOUT_CHANGESET_OPERATION_ID, + undefined, + checkoutOperationMeta(treeish), + ); + } catch (checkoutError) { + if (provider.getSessionConfig(sessionId)?.values[property] === value) { + try { + await provider.setSessionConfigValue(sessionId, property, confirmedValue); + } catch (rollbackError) { + throw new AggregateError([checkoutError, rollbackError], 'Checkout failed and the branch configuration could not be restored.'); + } + } + throw checkoutError; + } + }); + provider.trackSessionConfigOperation(sessionId, configOperation); + await configOperation; + } + + protected _requiresBranchCheckout(provider: IAgentHostSessionsProvider, sessionId: string, property: string): boolean { + return property === SessionConfigKey.Branch + && provider.getCreateSessionConfig(sessionId) !== undefined + && provider.getSessionConfig(sessionId)?.values[SessionConfigKey.Isolation] === 'folder'; + } + + protected _getCheckoutChangeset(sessionId: string): ISessionChangeset | undefined { + const session = this._session.get(); + if (session?.sessionId !== sessionId) { + return undefined; + } + + return session.changesets.get()?.find(changeset => + changeset.id === UNCOMMITTED_CHANGES_CHANGESET_ID + && changeset.operations.get().some(operation => operation.id === AGENT_HOST_CHECKOUT_CHANGESET_OPERATION_ID) + ); + } + protected _renderTrigger(trigger: HTMLElement, sessionId: string, property: string, schema: SessionConfigPropertySchema, value: unknown | undefined, isReadOnly: boolean): void { dom.clearNode(trigger); @@ -802,7 +882,7 @@ export class AgentHostSessionConfigPicker extends Disposable { optionLabelAfter: this._getLabel(sessionId, SessionConfigKey.Isolation, schema, nextValue), isPII: false, }); - provider.setSessionConfigValue(sessionId, SessionConfigKey.Isolation, nextValue).catch(() => { /* best-effort */ }); + this._setSessionConfigValue(provider, sessionId, SessionConfigKey.Isolation, nextValue).catch(() => { /* best-effort */ }); } protected async _showPicker(provider: IAgentHostSessionsProvider, sessionId: string, property: string, schema: SessionConfigPropertySchema, trigger: HTMLElement): Promise { @@ -854,7 +934,7 @@ export class AgentHostSessionConfigPicker extends Disposable { } const nextValue = schema.type === 'boolean' ? item.value === 'true' : item.value; - provider.setSessionConfigValue(sessionId, property, nextValue).catch(() => { /* best-effort */ }); + this._setSessionConfigValue(provider, sessionId, property, nextValue).catch(() => { /* best-effort */ }); }, onFilter: schema.enumDynamic ? query => this._filterDelayer.trigger(async () => { @@ -1106,12 +1186,13 @@ class MobileAgentHostSessionConfigPicker extends AgentHostSessionConfigPicker { const isolationSchema = config.schema.properties[SessionConfigKey.Isolation]; const branchSchema = config.schema.properties[SessionConfigKey.Branch]; + const canSelectBranch = !this._requiresBranchCheckout(provider, sessionId, SessionConfigKey.Branch) || !!this._getCheckoutChangeset(sessionId); const [isolationItems, branchItems] = await Promise.all([ isolationSchema && !isolationSchema.readOnly ? this._getItems(provider, sessionId, SessionConfigKey.Isolation, isolationSchema) : Promise.resolve([] as readonly IConfigPickerItem[]), - branchSchema && !branchSchema.readOnly + branchSchema && !branchSchema.readOnly && canSelectBranch ? this._getItems(provider, sessionId, SessionConfigKey.Branch, branchSchema) : Promise.resolve([] as readonly IConfigPickerItem[]), ]); @@ -1162,7 +1243,7 @@ class MobileAgentHostSessionConfigPicker extends AgentHostSessionConfigPicker { } let search: IMobilePickerSheetSearchSource | undefined; - if (branchSchema?.enumDynamic && !branchSchema.readOnly) { + if (branchSchema?.enumDynamic && !branchSchema.readOnly && canSelectBranch) { search = { placeholder: localize('mobileAgentHostSessionConfig.repoSheet.branchSearchPlaceholder', "Search branches"), ariaLabel: localize('mobileAgentHostSessionConfig.repoSheet.branchSearchAria', "Search base branches"), @@ -1199,9 +1280,8 @@ class MobileAgentHostSessionConfigPicker extends AgentHostSessionConfigPicker { sheetItems, { search, - // Keep the sheet open on row taps so the user can adjust - // both isolation mode and branch without reopening. Each - // tap writes through immediately; Done just dismisses. + // Branch picks stay open for further branch searches. Isolation + // changes close the sheet so its branch rows cannot become stale. stayOpenOnSelect: true, onDidSelect: (id) => { const selection = idToConfig.get(id); @@ -1216,8 +1296,12 @@ class MobileAgentHostSessionConfigPicker extends AgentHostSessionConfigPicker { optionLabelAfter: selection.label, isPII: selection.isPII, }); - provider.setSessionConfigValue(sessionId, selection.property, selection.value).catch(() => { /* best-effort */ }); + this._setSessionConfigValue(provider, sessionId, selection.property, selection.value).catch(() => { /* best-effort */ }); + if (selection.property === SessionConfigKey.Isolation) { + return MOBILE_PICKER_SHEET_CONFIRM; + } } + return undefined; }, }, ); @@ -1251,31 +1335,50 @@ export class PickerActionViewItem extends BaseActionViewItem implements IChatInp override focus(): void { if (this._focusableElement) { this._focusableElement.focus(); + } else if (this.element) { + this._focusFirstTabStop(this.element); } else { super.focus(); } } override isFocused(): boolean { - return this._focusableElement - ? this._focusableElement === dom.getActiveElement() + return this.element + ? dom.isAncestorOfActiveElement(this.element) : super.isFocused(); } override blur(): void { - if (this._focusableElement) { - this._focusableElement.blur(); + const activeElement = dom.getActiveElement(); + if (this.element && dom.isHTMLElement(activeElement) && dom.isAncestor(activeElement, this.element)) { + activeElement.blur(); } else { super.blur(); } } - override setFocusable(focusable: boolean): void { - if (this._focusableElement) { - this.element?.removeAttribute('tabindex'); - } else { - super.setFocusable(focusable); + override setFocusable(_focusable: boolean): void { + if (this.element) { + this.element.tabIndex = -1; + } + } + + private _focusFirstTabStop(container: HTMLElement): boolean { + for (const child of container.children) { + if (!dom.isHTMLElement(child)) { + continue; + } + if (child.tabIndex >= 0) { + child.focus(); + if (dom.isActiveElement(child)) { + return true; + } + } + if (this._focusFirstTabStop(child)) { + return true; + } } + return false; } isCompact(): boolean { diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index 49f058d851e42..1944b6b622f50 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -35,7 +35,7 @@ import { readAgentDevContainerWorktreeMetadata, withAgentDevContainerWorktreeMet import type { IAgentSubscription } from '../../../../../platform/agentHost/common/state/agentSubscription.js'; import { ResolveSessionConfigResult, type SessionConfigPropertySchema } from '../../../../../platform/agentHost/common/state/protocol/commands.js'; import { AgentCustomization, ChangesSummary, ChatInteractivity as ProtocolChatInteractivity, ChatOriginKind as ProtocolChatOriginKind, type ClientPluginCustomization, Customization, CustomizationEnablementKind, CustomizationType, type CustomizationEnablement, ModelSelection, SessionStatus as ProtocolSessionStatus, RootConfigState, RootState, type SessionActiveClient, SessionState, SessionSummary, type Changeset } from '../../../../../platform/agentHost/common/state/protocol/state.js'; -import { ActionType, isChatAction, isSessionAction, NotificationType } from '../../../../../platform/agentHost/common/state/sessionActions.js'; +import { ActionType, isChatAction, isSessionAction, NotificationType, type SessionSummaryChanges } from '../../../../../platform/agentHost/common/state/sessionActions.js'; import { AgentCapabilities, AgentInfo, buildChatUri, buildDefaultChatUri, buildSubagentChatUri, DEFAULT_CHAT_ID, getSessionChatResource, getSessionRelatedPullRequestUrls, isDefaultChatUri, isSessionStatusArchived, isSessionStatusRead, parseChatUri, readSessionCreationReference, readSessionEhcliAdoptable, readSessionExternal, readSessionGitHubState, readSessionGitState, readSessionMultiRootMetadata, readSessionSourceControlState, readSessionWorkspaceless, ROOT_STATE_URI, SESSION_META_MULTI_ROOT_KEY, SessionMeta, SessionSourceControlOutcome, StateComponents, withSessionCreationReference, withSessionExternal, withSessionGitHubState, withSessionMultiRootMetadata, withSessionStatusFlag, withSessionWorkspaceless, type ChatState, type ChatSummary, type ISessionCreationReference as IProtocolSessionCreationReference, type ISessionGitHubState, type ISessionGitState, type ISessionMultiRootMetadata } from '../../../../../platform/agentHost/common/state/sessionState.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; @@ -632,6 +632,12 @@ function toPresentedSessionStatus(owner: object, status: IObservable; +type AgentHostSessionSummaryWorkspaceMetadata = { + project?: IAgentSessionMetadata['project']; + workingDirectories?: IAgentSessionMetadata['workingDirectories']; +}; + /** * Maps the protocol {@link ProtocolChatInteractivity} to the provider-agnostic * {@link ChatInteractivity}. Absent interactivity defaults to {@link @@ -912,9 +918,9 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { */ readonly backendUri: URI; - // Retained so we can rebuild `workspace` when only `_meta` changes via - // a `SessionMetaChanged` action dispatched on session open (without a full - // list refresh). See `_applySessionMetaFromState` / `setMeta`. + // Retained so we can rebuild `workspace` when session state changes via + // actions dispatched on session open (without a full list refresh). + // See `_applySessionMetadataFromState` / `applySessionStateMetadata`. private _project: IAgentSessionMetadata['project']; private _workingDirectories: readonly URI[] | undefined; /** Working-directory set used to resolve session customizations. */ @@ -927,11 +933,7 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { private _meta: SessionMeta | undefined; /** The latest session metadata used to build startup-cache presentation state. */ get sessionMeta(): SessionMeta | undefined { return this._meta; } - /** - * Whether this session is a workspace-less quick chat. Seeded from the - * constructor metadata and only ever promoted by - * {@link _promoteToQuickChatIfWorkspaceless}. - */ + /** Settable so authoritative session metadata can change the session kind in place. */ private readonly _isQuickChat: ISettableObservable; /** Session-kind strategy (quick chat vs. workspace), derived from {@link _isQuickChat}. */ private get _kind(): IAgentHostSessionKind { return sessionKind(this._isQuickChat.get()); } @@ -1674,9 +1676,10 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { * Returns `true` iff the activity observable changed. Callers inside a * transaction MUST pass it — see {@link setChangesSummary}. */ - setActivity(activity: string | undefined, tx?: ITransaction): boolean { - if (this._activity.get() !== activity) { - this._activity.set(activity, tx); + setActivity(activity: string | null | undefined, tx?: ITransaction): boolean { + const value = activity ?? undefined; + if (this._activity.get() !== value) { + this._activity.set(value, tx); return true; } @@ -1685,11 +1688,10 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { /** * Apply a `_meta` delta (the shared session-state / session-summary bag, - * fed from `_applySessionMetaFromState` or a `SessionSummaryChanged` - * notification), promote the session kind if the delta reports it - * workspace-less, and rebuild the workspace if the git state changed. + * fed from `_applySessionMetadataFromState` or a `SessionSummaryChanged` + * notification), synchronize the session kind, and rebuild the workspace. * Returns `true` iff anything observable changed, so the list regroups a - * session that became a quick chat without ever having had a workspace. + * session whose kind changed even when its workspace did not. * * Callers that are already inside a transaction MUST pass it: a plain * `transaction()` here would finish (and therefore notify) mid-way through @@ -1703,7 +1705,7 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { let didChange = metaChanged; subtransaction(tx, tx => { this._metaObs.set(this._meta, tx); - if (this._promoteToQuickChatIfWorkspaceless(tx)) { + if (this._syncQuickChatFromMeta(tx)) { didChange = true; } const workspace = this._computeWorkspace(); @@ -1714,6 +1716,61 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { return didChange; } + /** + * Applies the workspace-bearing fields from authoritative session state in + * one transaction so observers cannot see a converted session paired with + * its previous working directory. On the first state snapshot, absent + * optional fields retain catalogue metadata for compatibility; a later + * present-to-absent transition clears them. + */ + applySessionStateMetadata(metadata: AgentHostSessionStateMetadata, previous: SessionState | undefined): boolean { + let didChange = false; + transaction(tx => { + if (metadata.project !== undefined || previous?.project !== undefined) { + this._project = metadata.project; + } + if (metadata.workingDirectories !== undefined || previous?.workingDirectories !== undefined) { + this._workingDirectories = metadata.workingDirectories; + } + if (metadata._meta !== undefined || previous?._meta !== undefined) { + didChange = this.setMeta(metadata._meta, tx); + } else { + didChange = this._setWorkspace(this._computeWorkspace(), tx); + } + }); + return didChange; + } + + /** + * Applies project and working-directory fields from a session-summary delta. + * Property presence distinguishes an omitted field from an explicit clear. + */ + applySessionSummaryWorkspaceMetadata(metadata: AgentHostSessionSummaryWorkspaceMetadata, tx: ITransaction): boolean { + let didChange = false; + if (Object.prototype.hasOwnProperty.call(metadata, 'project')) { + const project = metadata.project; + const projectMatches = this._project === project + || (!!this._project && !!project && this._project.displayName === project.displayName && isEqual(this._project.uri, project.uri)); + if (!projectMatches) { + this._project = project; + didChange = true; + } + } + if (Object.prototype.hasOwnProperty.call(metadata, 'workingDirectories')) { + const workingDirectories = metadata.workingDirectories; + const directoriesMatch = this._workingDirectories === workingDirectories + || (!!this._workingDirectories && !!workingDirectories && arrayEquals(this._workingDirectories, workingDirectories, (a, b) => isEqual(a, b))); + if (!directoriesMatch) { + this._workingDirectories = workingDirectories; + didChange = true; + } + } + if (didChange) { + this._setWorkspace(this._computeWorkspace(), tx); + } + return didChange; + } + refreshWorkspace(): boolean { let didChange = false; transaction(tx => { @@ -1731,17 +1788,12 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { this._worktreeIsolation.set(isolated, undefined); } - /** - * Heal an adapter born mis-classified because the path that materialized it - * carried no `_meta` (a stale persisted cache, an older host). One-way: an - * absent marker means "not included", never "cleared", so a quick chat is - * never demoted back into a workspace session rooted at its scratch cwd. - */ - private _promoteToQuickChatIfWorkspaceless(tx: ITransaction): boolean { - if (this._isQuickChat.get() || !readSessionWorkspaceless(this._meta)) { + private _syncQuickChatFromMeta(tx: ITransaction): boolean { + const isQuickChat = readSessionWorkspaceless(this._meta); + if (this._isQuickChat.get() === isQuickChat) { return false; } - this._isQuickChat.set(true, tx); + this._isQuickChat.set(isQuickChat, tx); return true; } @@ -2009,6 +2061,7 @@ class NewSession extends Disposable { */ private _config: ResolveSessionConfigResult | undefined = { schema: { type: 'object', properties: {} }, values: {} }; private _configResolution: Promise | undefined; + private _configOperation: Promise | undefined; /** * Monotonic counter for in-flight {@link resolveConfig} calls. Each call @@ -2278,23 +2331,54 @@ class NewSession extends Disposable { } } + trackConfigOperation(operation: Promise): void { + this._configOperation = operation; + void operation.then( + () => this._clearConfigOperation(operation), + () => this._clearConfigOperation(operation), + ); + } + + async waitForConfigurationReady(): Promise { + while (this._configOperation || this._configResolution) { + if (this._configOperation) { + await raceCancellationError(this._configOperation, this.cancellationToken); + } else { + await this.waitForConfigResolution(); + } + } + } + private _clearConfigResolution(promise: Promise): void { if (this._configResolution === promise) { this._configResolution = undefined; } } + private _clearConfigOperation(promise: Promise): void { + if (this._configOperation === promise) { + this._configOperation = undefined; + } + } + /** - * Optimistically merges a single property into the cached config. + * Optimistically updates a single property in the cached config. + * An undefined value removes the property. * Preserves the existing schema so schema-driven pickers don't flash * during the async re-resolve. {@link resolveConfig} replaces both * schema and values when its response lands. */ setConfigValue(property: string, value: unknown): void { const current = this._config; + const values = { ...(current?.values ?? {}) }; + if (value === undefined) { + delete values[property]; + } else { + values[property] = value; + } this._config = { schema: current?.schema ?? { type: 'object', properties: {} }, - values: { ...(current?.values ?? {}), [property]: value }, + values, }; this._syncWorktreePending(); } @@ -3797,13 +3881,16 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement // Mark resolution before firing so the first picker render is already inert. const newSession = this._getNewSession(sessionId); if (newSession) { - // Defense-in-depth: pickers render disabled during a resolve, - // but keyboard dropdown and mobile sheet paths bypass that. - // Drop the second pick so it can't race the schema replacement. - if (newSession.isResolvingConfig.get()) { - return; + while (newSession.isResolvingConfig.get()) { + await newSession.waitForConfigResolution(); + if (this._getNewSession(sessionId) !== newSession) { + return; + } } newSession.beginResolveConfigSync(); + if (property === SessionConfigKey.Isolation) { + newSession.setConfigValue(SessionConfigKey.Branch, undefined); + } newSession.setConfigValue(property, normalizedValue); this._onDidChangeSessionConfig.fire(sessionId); await newSession.trackConfigResolution(this._refreshNewSessionConfig(newSession)); @@ -3816,6 +3903,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement if (!runningConfig || !connection) { return; } + const schema = runningConfig.schema.properties[property]; if (!schema?.sessionMutable) { return; @@ -3840,6 +3928,10 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement } } + trackSessionConfigOperation(sessionId: string, operation: Promise): void { + this._getNewSession(sessionId)?.trackConfigOperation(operation); + } + async replaceSessionConfig(sessionId: string, values: Record): Promise { const runningConfig = this._runningSessionConfigs.get(sessionId); const connection = this.connection; @@ -3996,7 +4088,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement mode === 'workspace' ? 'folder' : mode, policyRestricted, ); - await this._setTransientNewSessionConfigValue(sessionId, SessionConfigKey.Isolation, value); + await this._setTransientNewSessionConfigValues(sessionId, { [SessionConfigKey.Isolation]: value }, true, [SessionConfigKey.Branch]); } async setWorktreeConfiguration(sessionId: string, configuration: ISessionWorktreeConfiguration): Promise { @@ -4018,7 +4110,8 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement if (configuration.branch) { values[SessionConfigKey.Branch] = normalizeSessionConfigValue(SessionConfigKey.Branch, configuration.branch, policyRestricted); } - await this._setTransientNewSessionConfigValues(sessionId, values, false); + const unsetProperties = configuration.isolationMode && !configuration.branch ? [SessionConfigKey.Branch] : undefined; + await this._setTransientNewSessionConfigValues(sessionId, values, false, unsetProperties); } async setWorktreeBranchTrack(sessionId: string, enabled: boolean): Promise { @@ -4039,7 +4132,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement await this._setTransientNewSessionConfigValues(sessionId, { [property]: value }, true); } - private async _setTransientNewSessionConfigValues(sessionId: string, values: Readonly>, waitForCurrentResolve: boolean): Promise { + private async _setTransientNewSessionConfigValues(sessionId: string, values: Readonly>, waitForCurrentResolve: boolean, unsetProperties?: readonly string[]): Promise { const newSession = this._getNewSession(sessionId); if (!newSession) { throw new Error('Cannot configure repository settings after session creation.'); @@ -4053,6 +4146,9 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement } newSession.beginResolveConfigSync(); + for (const property of unsetProperties ?? []) { + newSession.setConfigValue(property, undefined); + } for (const [property, value] of Object.entries(values)) { newSession.setConfigValue(property, value); } @@ -4878,7 +4974,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement if (!this.connection) { throw new Error(this._notConnectedSendErrorMessage()); } - await newSession.waitForConfigResolution(); + await newSession.waitForConfigurationReady(); await newSession.waitForEagerCreate(); if (this._getNewSession(newSession.sessionId) !== newSession) { throw new Error('Session was disposed before its configuration could be applied.'); @@ -5394,7 +5490,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement this._onDidChangeCustomizations.fire(); } this._seedRunningConfigFromState(sessionId, state); - this._applySessionMetaFromState(sessionId, state); + this._applySessionMetadataFromState(sessionId, state, previous); this._applyChatCatalogFromState(sessionId, state); if (!previous) { @@ -5497,7 +5593,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement } } - private _applySessionMetaFromState(sessionId: string, state: SessionState): void { + private _applySessionMetadataFromState(sessionId: string, state: SessionState, previous: SessionState | undefined): void { const rawId = this._rawIdFromChatId(sessionId); if (!rawId) { return; @@ -5507,7 +5603,15 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement return; } - if (cached.setMeta(state._meta)) { + const metadata: AgentHostSessionStateMetadata = { + project: state.project ? { + displayName: state.project.displayName, + uri: this.mapProjectUri(URI.parse(state.project.uri)), + } : undefined, + workingDirectories: state.workingDirectories?.map(directory => this.mapWorkingDirectoryUri(URI.parse(directory))), + _meta: state._meta, + }; + if (cached.applySessionStateMetadata(metadata, previous)) { this._onDidChangeSessions.fire({ added: [], removed: [], changed: [cached] }); } } @@ -5655,6 +5759,8 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement changes: adapter.changesSummary.get(), // A project assigned by `backfillProject` lives only on the adapter. project: adapter.project ?? base.project, + // Session-state and summary updates can relocate an existing session. + workingDirectories: adapter.workingDirectories, status: withSessionStatusFlag( withSessionStatusFlag(base.status ?? ProtocolSessionStatus.Idle, ProtocolSessionStatus.IsRead, adapter.isRead.get()), ProtocolSessionStatus.IsArchived, @@ -6053,7 +6159,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement } } - private _handleSessionSummaryChanged(session: string, changes: Partial): void { + private _handleSessionSummaryChanged(session: string, changes: SessionSummaryChanges): void { // Set when a delta clears the adoptable-legacy marker so we can reopen the // passive state subscription after the transaction commits (the observable // updates in `_ensureSessionStateSubscription` must not run nested in `tx`). @@ -6104,6 +6210,20 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement didChange = true; } + const workspaceMetadata: AgentHostSessionSummaryWorkspaceMetadata = {}; + if (Object.prototype.hasOwnProperty.call(changes, 'project')) { + workspaceMetadata.project = changes.project ? { + displayName: changes.project.displayName, + uri: this.mapProjectUri(URI.parse(changes.project.uri)), + } : undefined; + } + if (Object.prototype.hasOwnProperty.call(changes, 'workingDirectories')) { + workspaceMetadata.workingDirectories = changes.workingDirectories?.map(directory => this.mapWorkingDirectoryUri(URI.parse(directory))); + } + if (cached.applySessionSummaryWorkspaceMetadata(workspaceMetadata, tx)) { + didChange = true; + } + if (Object.prototype.hasOwnProperty.call(changes, '_meta')) { // Keep the guard map in sync (mirrors `updateAdapter`) so a cleared // adoptable-legacy marker reopens the passive session-state diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts index 8631d5c598acb..6f746ec0c4c3b 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts @@ -123,6 +123,7 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide private _automationSessionResources = new ResourceSet(); private readonly _devContainerAvailableDrafts = new Set(); private readonly _devContainerDrafts = new Set(); + private readonly _pendingDevContainerEnablement = new Set(); override get order(): number { return -1; } @@ -308,11 +309,16 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide try { const available = await this._devContainerAgentHostService.isAvailable(workspaceUri); if (!available || !this._getNewSession(sessionId)) { + this._pendingDevContainerEnablement.delete(sessionId); return; } this._devContainerAvailableDrafts.add(sessionId); + if (this._pendingDevContainerEnablement.delete(sessionId)) { + this._devContainerDrafts.add(sessionId); + } this._onDidChangeSessionConfig.fire(sessionId); } catch (error) { + this._pendingDevContainerEnablement.delete(sessionId); this._logService.warn(`[${this.id}] Failed to resolve Dev Container availability for ${workspaceUri.toString()}`, error); } } @@ -325,6 +331,18 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide return this._devContainerDrafts.has(sessionId); } + preferDevContainer(sessionId: string): void { + if (!this._getNewSession(sessionId)) { + throw new Error(`Cannot configure unknown new session '${sessionId}'.`); + } + if (this._devContainerAvailableDrafts.has(sessionId)) { + this._devContainerDrafts.add(sessionId); + this._onDidChangeSessionConfig.fire(sessionId); + } else { + this._pendingDevContainerEnablement.add(sessionId); + } + } + setDevContainerEnabled(sessionId: string, enabled: boolean): void { if (!this._getNewSession(sessionId)) { throw new Error(`Cannot configure unknown new session '${sessionId}'.`); @@ -333,9 +351,11 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide throw new Error(`Cannot enable Dev Container execution for unavailable session '${sessionId}'.`); } if (enabled) { + this._pendingDevContainerEnablement.delete(sessionId); this._devContainerDrafts.add(sessionId); } else { this._devContainerDrafts.delete(sessionId); + this._pendingDevContainerEnablement.delete(sessionId); } this._onDidChangeSessionConfig.fire(sessionId); } @@ -524,12 +544,14 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide override deleteNewSession(sessionId: string): void { this._devContainerAvailableDrafts.delete(sessionId); this._devContainerDrafts.delete(sessionId); + this._pendingDevContainerEnablement.delete(sessionId); super.deleteNewSession(sessionId); } protected override _disposeAllNewSessions(): void { this._devContainerAvailableDrafts.clear(); this._devContainerDrafts.clear(); + this._pendingDevContainerEnablement.clear(); super._disposeAllNewSessions(); } diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHost/agentHostPermissionPickerDelegate.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHost/agentHostPermissionPickerDelegate.test.ts index f0731be1bf72f..d2b1d1f92d88a 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHost/agentHostPermissionPickerDelegate.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHost/agentHostPermissionPickerDelegate.test.ts @@ -134,6 +134,7 @@ function setup(store: Pick, activeSession: IActiveSessio _serviceBrand: undefined, enabled: constObservable(true), managedSandboxEnforced, + managedSandboxAllowsBypass: constObservable(false), }); const delegate = store.add(insta.createInstance(AgentHostPermissionPickerDelegate, activeSessionObs)); diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHost/agentHostSessionConfigPicker.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHost/agentHostSessionConfigPicker.test.ts index 5037fadd3b1b2..282739eb26a00 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHost/agentHostSessionConfigPicker.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHost/agentHostSessionConfigPicker.test.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { DeferredPromise } from '../../../../../../../base/common/async.js'; import { Codicon } from '../../../../../../../base/common/codicons.js'; import { Emitter, Event } from '../../../../../../../base/common/event.js'; import { toDisposable } from '../../../../../../../base/common/lifecycle.js'; @@ -14,6 +15,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../ba import { isIMenuItem, MenuId, MenuRegistry } from '../../../../../../../platform/actions/common/actions.js'; import { ActionListItemKind, IActionListDelegate, IActionListItem } from '../../../../../../../platform/actionWidget/browser/actionList.js'; import { IActionWidgetService } from '../../../../../../../platform/actionWidget/browser/actionWidget.js'; +import { AGENT_HOST_CHECKOUT_CHANGESET_OPERATION_ID } from '../../../../../../../platform/agentHost/common/agentHostChangesetOperationService.js'; import { SessionConfigKey } from '../../../../../../../platform/agentHost/common/sessionConfigKeys.js'; import { ResolveSessionConfigResult, SessionConfigPropertySchema, SessionConfigValueItem } from '../../../../../../../platform/agentHost/common/state/protocol/commands.js'; import { IConfigurationService } from '../../../../../../../platform/configuration/common/configuration.js'; @@ -35,7 +37,7 @@ import { ISessionChangesService } from '../../../../../../contrib/changes/browse import { CHANGES_VIEW_ID } from '../../../../../../contrib/changes/common/changes.js'; import { ISessionsProvidersService } from '../../../../../../services/sessions/browser/sessionsProvidersService.js'; import { IActiveSession } from '../../../../../../services/sessions/common/sessionsManagement.js'; -import { ISessionWorkspace } from '../../../../../../services/sessions/common/session.js'; +import { ISessionChangeset, ISessionChangesetOperationTarget, ISessionWorkspace, SessionChangesetOperationScope, SessionChangesetOperationStatus, UNCOMMITTED_CHANGES_CHANGESET_ID } from '../../../../../../services/sessions/common/session.js'; import { ISessionsProvider } from '../../../../../../services/sessions/common/sessionsProvider.js'; import { AgentHostSessionConfigPicker, IConfigPickerItem, PickerActionViewItem } from '../../../browser/agentHostSessionConfigPicker.js'; @@ -89,7 +91,7 @@ function makeRepoConfig(branchValue?: string, isolation: 'folder' | 'worktree' = } /** A config whose Branch property is resolved dynamically (no static `enum`), as the real branch picker is. */ -function makeDynamicBranchConfig(branchValue: string): ResolveSessionConfigResult { +function makeDynamicBranchConfig(branchValue: string, isolation: 'folder' | 'worktree' = 'worktree'): ResolveSessionConfigResult { return { schema: { type: 'object', @@ -105,7 +107,7 @@ function makeDynamicBranchConfig(branchValue: string): ResolveSessionConfigResul }, }, }, - values: { [SessionConfigKey.Isolation]: 'worktree', [SessionConfigKey.Branch]: branchValue }, + values: { [SessionConfigKey.Isolation]: isolation, [SessionConfigKey.Branch]: branchValue }, } as ResolveSessionConfigResult; } @@ -130,26 +132,40 @@ function makeNoGitConfig(): ResolveSessionConfigResult { * provider (not the picker) owns the seeded schema, so a picker recreated by a * toolbar rebuild still reads the seeded chips from here. */ -class FakeProvider implements Pick { +class FakeProvider implements Pick { readonly id = LOCAL_AGENT_HOST_PROVIDER_ID; readonly onDidChangeSessionConfig: Event; config: ResolveSessionConfigResult = makeRepoConfig('main'); readonly resolving = observableValue('resolving', false); isNew = true; setSessionConfigValueCalls = 0; + readonly setSessionConfigValueArguments: { sessionId: string; property: string; value: unknown }[] = []; devContainerEnabled = false; devContainerAvailable = true; /** Completions returned by `getSessionConfigCompletions`, e.g. for the dynamic branch picker. */ completions: readonly SessionConfigValueItem[] = []; - constructor(private readonly _emitter: Emitter) { + constructor( + private readonly _emitter: Emitter, + private readonly _onSetSessionConfigValue?: (value: unknown) => void, + ) { this.onDidChangeSessionConfig = _emitter.event; } getSessionConfig(): ResolveSessionConfigResult | undefined { return this.config; } getCreateSessionConfig(): Record | undefined { return this.isNew ? {} : undefined; } isSessionConfigResolving() { return this.resolving; } - async setSessionConfigValue(): Promise { this.setSessionConfigValueCalls++; } + async setSessionConfigValue(sessionId: string, property: string, value: unknown): Promise { + this.setSessionConfigValueCalls++; + this.setSessionConfigValueArguments.push({ sessionId, property, value }); + this._onSetSessionConfigValue?.(value); + this.config = { + ...this.config, + values: { ...this.config.values, [property]: value }, + }; + this._emitter.fire(sessionId); + } + trackSessionConfigOperation(_sessionId: string, _operation: Promise): void { } async getSessionConfigCompletions(): Promise { return this.completions; } isDevContainerAvailable(): boolean { return this.devContainerAvailable; } isDevContainerEnabled(): boolean { return this.devContainerEnabled; } @@ -171,6 +187,10 @@ class AlwaysRenderConfigPicker extends AgentHostSessionConfigPicker { return true; } + setSessionConfigValueForTest(provider: FakeProvider, property: string, value: unknown): Promise { + return this._setSessionConfigValue(provider as unknown as IAgentHostSessionsProvider, SESSION_ID, property, value); + } + renderTriggerForTest(trigger: HTMLElement, property: string, schema: SessionConfigPropertySchema, value: unknown, isReadOnly: boolean): void { this._renderTrigger(trigger, SESSION_ID, property, schema, value, isReadOnly); } @@ -209,9 +229,14 @@ class CapturingActionWidgetHolder { readonly events: string[] = []; } -function setupServices(store: Pick, 'add'>, options?: { devContainerWorktreeEnabled?: boolean }) { +function setupServices( + store: Pick, 'add'>, + options?: { devContainerWorktreeEnabled?: boolean }, + onCheckout?: () => Promise, +) { const emitter = store.add(new Emitter()); - const provider = new FakeProvider(emitter); + const branchSelectionEvents: string[] = []; + const provider = new FakeProvider(emitter, value => branchSelectionEvents.push(`set:${String(value)}`)); const actionWidget = new CapturingActionWidgetHolder(); const instantiationService = store.add(new TestInstantiationService()); @@ -264,13 +289,31 @@ function setupServices(store: Pick('workspace', makeWorkspace(undefined)); const workspace: IObservable = workspaceObs; - const sessionObs = observableValue('activeSession', { - providerId: LOCAL_AGENT_HOST_PROVIDER_ID, - sessionId: SESSION_ID, - resource: SESSION_RESOURCE, - workspace, - } as IActiveSession); - return { instantiationService, provider, sessionObs, workspaceObs, actionWidget }; + const checkoutInvocations: { operationId: string; _meta: Record | undefined }[] = []; + const uncommittedChangeset = new class extends mock() { + override readonly id = UNCOMMITTED_CHANGES_CHANGESET_ID; + override readonly operations = constObservable([{ + id: AGENT_HOST_CHECKOUT_CHANGESET_OPERATION_ID, + label: 'Checkout', + scopes: [SessionChangesetOperationScope.Changeset], + status: SessionChangesetOperationStatus.Idle, + }]); + override async invokeOperation(operationId: string, _target?: ISessionChangesetOperationTarget, _meta?: Record): Promise { + checkoutInvocations.push({ operationId, _meta }); + branchSelectionEvents.push('checkout'); + await onCheckout?.(); + } + }(); + const changesetsObs = observableValue('changesets', [uncommittedChangeset]); + const activeSession = new class extends mock() { + override readonly providerId = LOCAL_AGENT_HOST_PROVIDER_ID; + override readonly sessionId = SESSION_ID; + override readonly resource = SESSION_RESOURCE; + override readonly workspace = workspace; + override readonly changesets = changesetsObs; + }(); + const sessionObs = observableValue('activeSession', activeSession); + return { instantiationService, provider, sessionObs, workspaceObs, changesetsObs, uncommittedChangeset, actionWidget, checkoutInvocations, branchSelectionEvents }; } /** Create and render a fresh picker instance, as the toolbar does on a rebuild. */ @@ -372,6 +415,8 @@ suite('Agent Host Session Config Picker', () => { store.add(toDisposable(() => container.remove())); const overflowAnchor = document.createElement('button'); item.render(container); + item.setFocusable(true); + item.focus(); const expanded = { compact: item.isCompact(), className: container.classList.contains('compact-picker'), @@ -403,6 +448,42 @@ suite('Agent Host Session Config Picker', () => { }); }); + test('picker action view items delegate focus to nested controls', () => { + let pickerContainer: HTMLElement | undefined; + const item = store.add(new PickerActionViewItem({ + render: container => { + pickerContainer = document.createElement('div'); + container.appendChild(pickerContainer); + }, + dispose: () => { }, + })); + const container = document.createElement('div'); + document.body.appendChild(container); + store.add(toDisposable(() => container.remove())); + item.render(container); + + item.setFocusable(true); + const focusTarget = document.createElement('button'); + focusTarget.tabIndex = 0; + pickerContainer?.appendChild(focusTarget); + item.focus(); + const focused = { + wrapperTabIndex: container.tabIndex, + focusedInnerControl: document.activeElement === focusTarget, + itemFocused: item.isFocused(), + }; + item.blur(); + + assert.deepStrictEqual({ focused, focusedAfterBlur: document.activeElement === focusTarget }, { + focused: { + wrapperTabIndex: -1, + focusedInnerControl: true, + itemFocused: true, + }, + focusedAfterBlur: false, + }); + }); + test('generic auto-approve chips retain their contextual accessible name', () => { const services = setupServices(store); const picker = store.add(services.instantiationService.createInstance(AlwaysRenderConfigPicker, services.sessionObs)); @@ -546,6 +627,258 @@ suite('Agent Host Session Config Picker', () => { }); }); + test('folder sessions populate the branch picker and invoke Checkout when a branch is selected', async () => { + const services = setupServices(store); + services.provider.config = makeDynamicBranchConfig('main', 'folder'); + services.provider.completions = [ + { value: 'main', label: 'main' }, + { value: 'dev', label: 'dev' }, + ]; + const { container } = renderPicker(store, services); + + const trigger = branchSlot(container)!.querySelector('a.action-label'); + trigger?.click(); + await new Promise(resolve => setTimeout(resolve)); + services.actionWidget.delegate?.onSelect({ value: 'dev', label: 'dev' }); + await new Promise(resolve => setTimeout(resolve)); + + assert.deepStrictEqual({ + hasInteractiveTrigger: !!trigger, + ariaReadOnly: trigger?.getAttribute('aria-readonly'), + items: services.actionWidget.items.filter(item => item.kind === ActionListItemKind.Action).map(item => item.label), + setConfigArguments: services.provider.setSessionConfigValueArguments, + checkoutInvocations: services.checkoutInvocations, + branchSelectionEvents: services.branchSelectionEvents, + }, { + hasInteractiveTrigger: true, + ariaReadOnly: null, + items: ['main', 'dev'], + setConfigArguments: [{ sessionId: SESSION_ID, property: SessionConfigKey.Branch, value: 'dev' }], + checkoutInvocations: [{ + operationId: AGENT_HOST_CHECKOUT_CHANGESET_OPERATION_ID, + _meta: { treeish: 'dev' }, + }], + branchSelectionEvents: ['set:dev', 'checkout'], + }); + }); + + test('folder branch picker stays read-only until Checkout capability hydrates', () => { + const services = setupServices(store); + services.provider.config = makeDynamicBranchConfig('main', 'folder'); + services.changesetsObs.set(undefined, undefined); + const { container } = renderPicker(store, services); + + const before = branchSlot(container)?.querySelector('.action-label'); + services.changesetsObs.set([services.uncommittedChangeset], undefined); + const after = branchSlot(container)?.querySelector('.action-label'); + + assert.deepStrictEqual({ + before: { + tagName: before?.tagName, + ariaReadOnly: before?.getAttribute('aria-readonly'), + }, + after: { + tagName: after?.tagName, + role: after?.getAttribute('role'), + }, + }, { + before: { + tagName: 'SPAN', + ariaReadOnly: 'true', + }, + after: { + tagName: 'A', + role: 'button', + }, + }); + }); + + test('failed folder branch checkout restores the previous configuration value', async () => { + const services = setupServices(store, {}, async () => { + throw new Error('Checkout failed'); + }); + services.provider.config = makeDynamicBranchConfig('main', 'folder'); + services.provider.completions = [ + { value: 'main', label: 'main' }, + { value: 'featureA', label: 'featureA' }, + ]; + const { container } = renderPicker(store, services); + + branchSlot(container)!.querySelector('a.action-label')!.click(); + await new Promise(resolve => setTimeout(resolve)); + services.actionWidget.delegate?.onSelect({ value: 'featureA', label: 'featureA' }); + await new Promise(resolve => setTimeout(resolve)); + + assert.deepStrictEqual({ + branch: services.provider.config.values[SessionConfigKey.Branch], + configUpdates: services.provider.setSessionConfigValueArguments, + checkoutInvocations: services.checkoutInvocations, + branchSelectionEvents: services.branchSelectionEvents, + }, { + branch: 'main', + configUpdates: [ + { sessionId: SESSION_ID, property: SessionConfigKey.Branch, value: 'featureA' }, + { sessionId: SESSION_ID, property: SessionConfigKey.Branch, value: 'main' }, + ], + checkoutInvocations: [{ + operationId: AGENT_HOST_CHECKOUT_CHANGESET_OPERATION_ID, + _meta: { treeish: 'featureA' }, + }], + branchSelectionEvents: ['set:featureA', 'checkout', 'set:main'], + }); + }); + + test('serializes repeated folder branch checkouts with their selected treeish', async () => { + const firstCheckoutStarted = new DeferredPromise(); + const releaseFirstCheckout = new DeferredPromise(); + const secondCheckoutStarted = new DeferredPromise(); + let checkoutCount = 0; + const services = setupServices(store, {}, async () => { + checkoutCount++; + if (checkoutCount === 1) { + firstCheckoutStarted.complete(); + await releaseFirstCheckout.p; + } else { + secondCheckoutStarted.complete(); + } + }); + services.provider.config = makeDynamicBranchConfig('main', 'folder'); + services.provider.completions = [ + { value: 'main', label: 'main' }, + { value: 'featureA', label: 'featureA' }, + ]; + const { container } = renderPicker(store, services); + const trigger = branchSlot(container)!.querySelector('a.action-label')!; + trigger.click(); + await new Promise(resolve => setTimeout(resolve)); + + services.actionWidget.delegate?.onSelect({ value: 'featureA', label: 'featureA' }); + await firstCheckoutStarted.p; + services.actionWidget.delegate?.onSelect({ value: 'main', label: 'main' }); + await new Promise(resolve => setTimeout(resolve)); + assert.deepStrictEqual({ + label: branchLabel(container), + branch: services.provider.config.values[SessionConfigKey.Branch], + configUpdates: services.provider.setSessionConfigValueArguments, + checkoutInvocations: services.checkoutInvocations, + }, { + label: 'featureA', + branch: 'featureA', + configUpdates: [ + { sessionId: SESSION_ID, property: SessionConfigKey.Branch, value: 'featureA' }, + ], + checkoutInvocations: [{ + operationId: AGENT_HOST_CHECKOUT_CHANGESET_OPERATION_ID, + _meta: { treeish: 'featureA' }, + }], + }); + + releaseFirstCheckout.complete(); + await secondCheckoutStarted.p; + await new Promise(resolve => setTimeout(resolve)); + + assert.deepStrictEqual({ + configUpdates: services.provider.setSessionConfigValueArguments, + checkoutInvocations: services.checkoutInvocations, + }, { + configUpdates: [ + { sessionId: SESSION_ID, property: SessionConfigKey.Branch, value: 'featureA' }, + { sessionId: SESSION_ID, property: SessionConfigKey.Branch, value: 'main' }, + ], + checkoutInvocations: [{ + operationId: AGENT_HOST_CHECKOUT_CHANGESET_OPERATION_ID, + _meta: { treeish: 'featureA' }, + }, { + operationId: AGENT_HOST_CHECKOUT_CHANGESET_OPERATION_ID, + _meta: { treeish: 'main' }, + }], + }); + assert.deepStrictEqual(services.branchSelectionEvents, ['set:featureA', 'checkout', 'set:main', 'checkout']); + }); + + test('serializes interleaved branch and isolation selections before deciding checkout', async () => { + const services = setupServices(store); + services.provider.config = makeDynamicBranchConfig('main', 'worktree'); + const picker = store.add(services.instantiationService.createInstance(AlwaysRenderConfigPicker, services.sessionObs)); + + await Promise.all([ + picker.setSessionConfigValueForTest(services.provider, SessionConfigKey.Branch, 'featureA'), + picker.setSessionConfigValueForTest(services.provider, SessionConfigKey.Isolation, 'folder'), + picker.setSessionConfigValueForTest(services.provider, SessionConfigKey.Branch, 'featureB'), + ]); + + assert.deepStrictEqual({ + values: services.provider.config.values, + configUpdates: services.provider.setSessionConfigValueArguments, + checkoutInvocations: services.checkoutInvocations, + }, { + values: { + [SessionConfigKey.Isolation]: 'folder', + [SessionConfigKey.Branch]: 'featureB', + }, + configUpdates: [ + { sessionId: SESSION_ID, property: SessionConfigKey.Branch, value: 'featureA' }, + { sessionId: SESSION_ID, property: SessionConfigKey.Isolation, value: 'folder' }, + { sessionId: SESSION_ID, property: SessionConfigKey.Branch, value: 'featureB' }, + ], + checkoutInvocations: [{ + operationId: AGENT_HOST_CHECKOUT_CHANGESET_OPERATION_ID, + _meta: { treeish: 'featureB' }, + }], + }); + }); + + test('rolls back failed queued checkouts to the last checked-out branch', async () => { + const firstCheckoutStarted = new DeferredPromise(); + const releaseFirstCheckout = new DeferredPromise(); + const secondCheckoutStarted = new DeferredPromise(); + let checkoutCount = 0; + const services = setupServices(store, {}, async () => { + checkoutCount++; + if (checkoutCount === 1) { + firstCheckoutStarted.complete(); + await releaseFirstCheckout.p; + throw new Error('First Checkout failed'); + } + secondCheckoutStarted.complete(); + throw new Error('Second Checkout failed'); + }); + services.provider.config = makeDynamicBranchConfig('main', 'folder'); + services.provider.completions = [ + { value: 'main', label: 'main' }, + { value: 'featureA', label: 'featureA' }, + { value: 'featureB', label: 'featureB' }, + ]; + const { container } = renderPicker(store, services); + branchSlot(container)!.querySelector('a.action-label')!.click(); + await new Promise(resolve => setTimeout(resolve)); + + services.actionWidget.delegate?.onSelect({ value: 'featureA', label: 'featureA' }); + await firstCheckoutStarted.p; + services.actionWidget.delegate?.onSelect({ value: 'featureB', label: 'featureB' }); + await new Promise(resolve => setTimeout(resolve)); + assert.strictEqual(branchLabel(container), 'featureA'); + + releaseFirstCheckout.complete(); + await secondCheckoutStarted.p; + await new Promise(resolve => setTimeout(resolve)); + + assert.deepStrictEqual({ + branch: services.provider.config.values[SessionConfigKey.Branch], + configUpdates: services.provider.setSessionConfigValueArguments, + branchSelectionEvents: services.branchSelectionEvents, + }, { + branch: 'main', + configUpdates: [ + { sessionId: SESSION_ID, property: SessionConfigKey.Branch, value: 'featureA' }, + { sessionId: SESSION_ID, property: SessionConfigKey.Branch, value: 'main' }, + { sessionId: SESSION_ID, property: SessionConfigKey.Branch, value: 'featureB' }, + { sessionId: SESSION_ID, property: SessionConfigKey.Branch, value: 'main' }, + ], + branchSelectionEvents: ['set:featureA', 'checkout', 'set:main', 'set:featureB', 'checkout', 'set:main'], + }); + }); + test('dirty branch action selects the Changes tab before focusing the Changes view', async () => { const services = setupServices(store); services.provider.config = makeDynamicBranchConfig('main'); diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostSessionChangesets.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostSessionChangesets.test.ts index 84eda27e44c7a..531aaf7a4d3b8 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostSessionChangesets.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostSessionChangesets.test.ts @@ -249,6 +249,26 @@ suite('AgentHostSessionChangesets', () => { selectDefault(['uncommitted'], ChangesetKind.Session), ['uncommitted*']); }); + + test('rejects operation invocation while disconnected', async () => { + const instantiationService = disposables.add(new TestInstantiationService()); + instantiationService.stub(IDialogService, { confirm: async () => ({ confirmed: true }) }); + const options: IAgentHostAdapterOptions = { + icon: Codicon.copilot, + loading: constObservable(false), + buildWorkspace: () => undefined, + instantiationService, + getConnection: () => undefined, + agentCapabilities: constObservable(undefined), + mapBackendSessionResource: resource => resource, + }; + const [changeset] = createChangesets(sessionUri, options, constObservable(false), [entry(ChangesetKind.Uncommitted)]); + + await assert.rejects( + () => changeset.invokeOperation('checkout'), + /agent host connection is unavailable/, + ); + }); }); test('binds Agent Merge changes to completed repair turns after the last default-chat user turn', () => { diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts index cb033f3541df1..046178e6b0006 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts @@ -20,10 +20,10 @@ import { AgentSession, type IAgentCreateChatRequestOptions, type IAgentCreateSes import { AgentHostCodexAgentEnabledSettingId, IAgentHostService } from '../../../../../../platform/agentHost/common/agentService.js'; import type { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; import type { ResolveSessionConfigResult } from '../../../../../../platform/agentHost/common/state/protocol/commands.js'; -import { ChatInteractivity as ProtocolChatInteractivity, ChatOriginKind as ProtocolChatOriginKind, CustomizationEnablementKind, CustomizationLoadStatus, CustomizationType, McpServerStatus, MessageKind, SessionLifecycle, type AgentCustomization, type AgentInfo, type AutomationState, type ChangesSummary, type Customization, type RootState, type SessionActiveClient, type SessionConfigState, type SessionState, type SessionSummary } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; +import { ChatInteractivity as ProtocolChatInteractivity, ChatOriginKind as ProtocolChatOriginKind, CustomizationEnablementKind, CustomizationLoadStatus, CustomizationType, McpServerStatus, MessageKind, SessionLifecycle, type AgentCustomization, type AgentInfo, type AutomationState, type ChangesSummary, type Customization, type RootState, type SessionActiveClient, type SessionConfigState, type SessionState } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { buildChatUri, buildDefaultChatUri, buildSubagentChatUri, ChangesetStatus, isAhpAutomationCatalogChannel, ResponsePartKind, SessionSourceControlOutcome, SessionStatus as ProtocolSessionStatus, StateComponents, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, withSessionCreationReference, withSessionEhcliAdoptable, withSessionGitHubState, withSessionGitState, withSessionMultiRootMetadata, withSessionSourceControlState, withSessionWorkspaceless, type ChangesetState, type ChatState, type ChatSummary } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { SessionArtifactType, withSessionArtifacts } from '../../../../../../platform/agentHost/common/sessionArtifacts.js'; -import { ActionType, NotificationType, type ActionEnvelope, type IRootConfigChangedAction, type ChatAction, type SessionAction, type TerminalAction, type INotification, type ClientAnnotationsAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; +import { ActionType, NotificationType, type ActionEnvelope, type IRootConfigChangedAction, type ChatAction, type SessionAction, type TerminalAction, type INotification, type ClientAnnotationsAction, type SessionSummaryChangedParams } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import { SessionConfigKey } from '../../../../../../platform/agentHost/common/sessionConfigKeys.js'; import { ConfigurationTarget, IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; @@ -409,7 +409,7 @@ class MockAgentHostService extends mock() { // ---- Test helpers ----------------------------------------------------------- -function createSession(id: string, opts?: { provider?: string; summary?: string; project?: { uri: URI; displayName: string }; workingDirectory?: URI; startTime?: number; modifiedTime?: number; quickChat?: boolean; multiRoot?: { workspaceFile: string }; adoptable?: boolean; _meta?: IAgentSessionMetadata['_meta'] }): IAgentSessionMetadata { +function createSession(id: string, opts?: { provider?: string; summary?: string; status?: ProtocolSessionStatus; activity?: string; project?: { uri: URI; displayName: string }; workingDirectory?: URI; startTime?: number; modifiedTime?: number; quickChat?: boolean; multiRoot?: { workspaceFile: string }; adoptable?: boolean; _meta?: IAgentSessionMetadata['_meta'] }): IAgentSessionMetadata { let _meta = opts?._meta; _meta = opts?.quickChat ? withSessionWorkspaceless(_meta, true) : _meta; _meta = withSessionMultiRootMetadata(_meta, opts?.multiRoot); @@ -421,6 +421,8 @@ function createSession(id: string, opts?: { provider?: string; summary?: string; startTime: opts?.startTime ?? 1000, modifiedTime: opts?.modifiedTime ?? 2000, summary: opts?.summary, + status: opts?.status, + activity: opts?.activity, project: opts?.project, workingDirectories: opts?.workingDirectory ? [opts?.workingDirectory] : undefined, _meta, @@ -620,7 +622,7 @@ function fireSessionRemoved(agentHost: MockAgentHostService, rawId: string, prov }); } -function fireSessionSummaryChanged(agentHost: MockAgentHostService, rawId: string, changes: Partial, provider = 'copilotcli'): void { +function fireSessionSummaryChanged(agentHost: MockAgentHostService, rawId: string, changes: SessionSummaryChangedParams['changes'], provider = 'copilotcli'): void { const sessionUri = AgentSession.uri(provider, rawId); agentHost.fireNotification({ channel: 'ahp-root://', @@ -3311,6 +3313,112 @@ suite('LocalAgentHostSessionsProvider', () => { }); })); + test('authoritative session state converts a quick chat to a workspace session in place', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const scratchDirectory = URI.file('/tmp/copilot-scratch/quick-converted'); + const workspaceDirectory = URI.file('/home/user/project'); + agentHost.addSession(createSession('quick-converted', { + summary: 'Quick Chat', + workingDirectory: scratchDirectory, + quickChat: true, + })); + + const provider = createProvider(disposables, agentHost); + provider.getSessions(); + await timeout(0); + + const session = provider.getSessions()[0] as AgentHostSessionAdapter; + provider.getSessionConfig(session.sessionId); + const sessionUri = AgentSession.uri('copilotcli', 'quick-converted').toString(); + const defaultChat = buildDefaultChatUri(sessionUri); + agentHost.setSessionState('quick-converted', 'copilotcli', { + provider: 'copilotcli', + title: 'Quick Chat', + status: ProtocolSessionStatus.Idle, + lifecycle: SessionLifecycle.Ready, + activeClients: [], + defaultChat, + workingDirectories: [scratchDirectory.toString()], + _meta: withSessionWorkspaceless(undefined, true), + chats: [{ resource: defaultChat, title: '', status: ProtocolSessionStatus.Idle, modifiedAt: new Date(0).toISOString() }], + }); + const observed: Array<{ isQuickChat: boolean | undefined; workspace: string | undefined }> = []; + disposables.add(autorun(reader => { + observed.push({ + isQuickChat: session.isQuickChat?.read(reader), + workspace: session.workspace.read(reader)?.uri.toString(), + }); + })); + const changed: string[] = []; + disposables.add(provider.onDidChangeSessions(event => changed.push(...event.changed.map(candidate => candidate.sessionId)))); + + agentHost.setSessionState('quick-converted', 'copilotcli', { + provider: 'copilotcli', + title: 'Quick Chat', + status: ProtocolSessionStatus.Idle, + lifecycle: SessionLifecycle.Ready, + activeClients: [], + defaultChat, + workingDirectories: [workspaceDirectory.toString()], + chats: [{ resource: defaultChat, title: '', status: ProtocolSessionStatus.Idle, modifiedAt: new Date(0).toISOString() }], + }); + + assert.deepStrictEqual({ + observed, + workingDirectories: session.workingDirectories.map(directory => directory.toString()), + announced: changed.includes(session.sessionId), + sameAdapter: provider.getSessions()[0] === session, + }, { + observed: [ + { isQuickChat: true, workspace: undefined }, + { isQuickChat: false, workspace: workspaceDirectory.toString() }, + ], + workingDirectories: [workspaceDirectory.toString()], + announced: true, + sameAdapter: true, + }); + })); + + test('isolated conversion groups by repository while running in the worktree', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const scratchDirectory = URI.file('/tmp/copilot-scratch/quick-isolated'); + const repository = URI.file('/home/user/project'); + const worktree = URI.file('/home/user/project.worktrees/implement-feature'); + agentHost.addSession(createSession('quick-isolated', { + summary: 'Quick Chat', + workingDirectory: scratchDirectory, + quickChat: true, + })); + + const provider = createProvider(disposables, agentHost); + provider.getSessions(); + await timeout(0); + const session = provider.getSessions()[0]; + assert.ok(session); + + fireSessionSummaryChanged(agentHost, 'quick-isolated', { + project: { uri: repository.toString(), displayName: 'project' }, + workingDirectories: [worktree.toString()], + _meta: withSessionWorkspaceless(undefined, false), + }); + await timeout(0); + + const workspace = session.workspace.get(); + assert.deepStrictEqual({ + isQuickChat: session.isQuickChat?.get(), + workspace: workspace?.uri.toString(), + folderRoot: workspace?.folders[0]?.root.toString(), + workingDirectory: workspace?.folders[0]?.workingDirectory.toString(), + workTreeUri: workspace?.folders[0]?.gitRepository?.workTreeUri?.toString(), + sameAdapter: provider.getSessions()[0] === session, + }, { + isQuickChat: false, + workspace: repository.toString(), + folderRoot: repository.toString(), + workingDirectory: worktree.toString(), + workTreeUri: worktree.toString(), + sameAdapter: true, + }); + })); + test('committed quick chat announced via sessionAdded stays workspace-less despite a scratch working directory', () => runWithFakedTimers({ useFakeTimers: true }, async () => { // Regression: when a quick-chat draft graduates, the host announces the // committed session via a `sessionAdded` notification whose summary @@ -3348,6 +3456,94 @@ suite('LocalAgentHostSessionsProvider', () => { assert.strictEqual(provider.getSessionConfig(session.sessionId), undefined); }); + test('enables a preferred Dev Container after asynchronous availability resolves', async () => { + const availability = new DeferredPromise(); + const provider = createProvider(disposables, agentHost, undefined, { + devContainerAgentHostService: new class extends mock() { + override async isAvailable(): Promise { + return availability.p; + } + }(), + }); + const session = provider.createNewSession( + URI.file('/home/user/project'), + provider.sessionTypes[0].id, + ); + provider.preferDevContainer(session.sessionId); + const beforeResolution = provider.isDevContainerEnabled(session.sessionId); + availability.complete(true); + await timeout(0); + + assert.deepStrictEqual({ + beforeResolution, + available: provider.isDevContainerAvailable(session.sessionId), + enabled: provider.isDevContainerEnabled(session.sessionId), + }, { + beforeResolution: false, + available: true, + enabled: true, + }); + }); + + test('enables a preferred Dev Container immediately after availability resolved', async () => { + const provider = createProvider(disposables, agentHost, undefined, { + devContainerAgentHostService: new class extends mock() { + override async isAvailable(): Promise { + return true; + } + }(), + }); + const session = provider.createNewSession( + URI.file('/home/user/project'), + provider.sessionTypes[0].id, + ); + await timeout(0); + provider.preferDevContainer(session.sessionId); + + assert.deepStrictEqual({ + available: provider.isDevContainerAvailable(session.sessionId), + enabled: provider.isDevContainerEnabled(session.sessionId), + }, { + available: true, + enabled: true, + }); + }); + + test('does not enable a preferred Dev Container when unavailable or canceled', async () => { + const unavailable = new DeferredPromise(); + const canceled = new DeferredPromise(); + let call = 0; + const provider = createProvider(disposables, agentHost, undefined, { + devContainerAgentHostService: new class extends mock() { + override async isAvailable(): Promise { + return ++call === 1 ? unavailable.p : canceled.p; + } + }(), + }); + const unavailableSession = provider.createNewSession(URI.file('/unavailable'), provider.sessionTypes[0].id); + provider.preferDevContainer(unavailableSession.sessionId); + unavailable.complete(false); + await timeout(0); + + const canceledSession = provider.createNewSession(URI.file('/canceled'), provider.sessionTypes[0].id); + provider.preferDevContainer(canceledSession.sessionId); + provider.setDevContainerEnabled(canceledSession.sessionId, false); + canceled.complete(true); + await timeout(0); + + assert.deepStrictEqual({ + unavailableAvailable: provider.isDevContainerAvailable(unavailableSession.sessionId), + unavailableEnabled: provider.isDevContainerEnabled(unavailableSession.sessionId), + canceledAvailable: provider.isDevContainerAvailable(canceledSession.sessionId), + canceledEnabled: provider.isDevContainerEnabled(canceledSession.sessionId), + }, { + unavailableAvailable: false, + unavailableEnabled: false, + canceledAvailable: true, + canceledEnabled: false, + }); + }); + test('prepareNewSession does not start a Dev Container when workspace trust is denied', async () => { let connectCalls = 0; const devContainerAgentHostService = new class extends mock() { @@ -3745,6 +3941,70 @@ suite('LocalAgentHostSessionsProvider', () => { }); }); + test('serializes draft config writes instead of dropping selections during resolution', async () => { + const provider = createProvider(disposables, agentHost); + const session = provider.createNewSession(URI.parse('file:///home/user/project'), provider.sessionTypes[0].id); + await waitForSessionConfig(provider, session.sessionId, config => config?.values.isolation === 'worktree'); + agentHost.resolveSessionConfigResult = { + schema: { type: 'object', properties: {} }, + values: { isolation: 'folder' }, + }; + const barrier = agentHost.resolveSessionConfigBarrier = new DeferredPromise(); + + const isolationUpdate = provider.setSessionConfigValue(session.sessionId, SessionConfigKey.Isolation, 'folder'); + const branchUpdate = provider.setSessionConfigValue(session.sessionId, SessionConfigKey.Branch, 'feature'); + await timeout(0); + const requestsWhileResolving = agentHost.resolveSessionConfigRequests.slice(-1).map(request => request.config); + + barrier.complete(); + await Promise.all([isolationUpdate, branchUpdate]); + + assert.deepStrictEqual({ + requestsWhileResolving, + finalRequests: agentHost.resolveSessionConfigRequests.slice(-2).map(request => request.config), + }, { + requestsWhileResolving: [{ isolation: 'folder' }], + finalRequests: [ + { isolation: 'folder' }, + { isolation: 'folder', branch: 'feature' }, + ], + }); + }); + + test('first send waits for tracked draft config operations', async () => { + let sendCalls = 0; + const provider = createProvider(disposables, agentHost, undefined, { + openSession: true, + sendRequest: async (): Promise => { + sendCalls++; + agentHost.addSession(createSession('config-operation-send', { summary: 'Config Operation' })); + return { kind: 'sent' as const, data: {} as ChatSendResult extends { kind: 'sent'; data: infer D } ? D : never }; + }, + }); + const session = provider.createNewSession(URI.parse('file:///home/user/project'), provider.sessionTypes[0].id); + await waitForSessionConfig(provider, session.sessionId, config => config?.values.isolation === 'worktree'); + const chat = await provider.createNewChat(session.sessionId); + const barrier = new DeferredPromise(); + provider.trackSessionConfigOperation(session.sessionId, barrier.p); + + const send = provider.sendRequest(session.sessionId, chat.resource, { query: 'hello' }); + await timeout(0); + const pendingSendCalls = sendCalls; + + barrier.complete(); + const committed = await send; + + assert.deepStrictEqual({ + pendingSendCalls, + sendCalls, + title: committed.title.get(), + }, { + pendingSendCalls: 0, + sendCalls: 1, + title: 'Config Operation', + }); + }); + test('first send waits for trusted eager backend creation', async () => { const workspaceTrustBarrier = new DeferredPromise(); let sendCalls = 0; @@ -3860,14 +4120,18 @@ suite('LocalAgentHostSessionsProvider', () => { test('maps the existing isolation setter to agent-host config without remembering it', async () => { const storageService = disposables.add(new InMemoryStorageService()); + agentHost.resolveSessionConfigResult = { + schema: { type: 'object', properties: {} }, + values: { isolation: 'worktree', branch: 'feature' }, + }; const provider = createProvider(disposables, agentHost, undefined, { storageService }); const session = provider.createNewSession(URI.parse('file:///home/user/project'), provider.sessionTypes[0].id); - await timeout(0); + await waitForSessionConfig(provider, session.sessionId, config => config?.values.branch === 'feature'); const firstAutomationRequest = agentHost.resolveSessionConfigRequests.length; agentHost.resolveSessionConfigResult = { schema: { type: 'object', properties: {} }, - values: { isolation: 'folder', branch: 'main' }, + values: { isolation: 'folder', branch: 'feature' }, }; await provider.setIsolationMode(session.sessionId, 'workspace'); @@ -3884,6 +4148,40 @@ suite('LocalAgentHostSessionsProvider', () => { }); }); + test('resets the branch to the isolation default when New Worktree is toggled', async () => { + agentHost.resolveSessionConfigResult = { + schema: { type: 'object', properties: {} }, + values: { isolation: 'worktree', branch: 'main' }, + }; + const provider = createProvider(disposables, agentHost); + const session = provider.createNewSession(URI.parse('file:///home/user/project'), provider.sessionTypes[0].id); + await waitForSessionConfig(provider, session.sessionId, config => config?.values.branch === 'main'); + const firstToggleRequest = agentHost.resolveSessionConfigRequests.length; + + agentHost.resolveSessionConfigResult = { + schema: { type: 'object', properties: {} }, + values: { isolation: 'folder', branch: 'feature' }, + }; + await provider.setSessionConfigValue(session.sessionId, SessionConfigKey.Isolation, 'folder'); + + agentHost.resolveSessionConfigResult = { + schema: { type: 'object', properties: {} }, + values: { isolation: 'worktree', branch: 'main' }, + }; + await provider.setSessionConfigValue(session.sessionId, SessionConfigKey.Isolation, 'worktree'); + + assert.deepStrictEqual({ + requests: agentHost.resolveSessionConfigRequests.slice(firstToggleRequest).map(request => request.config), + config: provider.getCreateSessionConfig(session.sessionId), + }, { + requests: [ + { isolation: 'folder' }, + { isolation: 'worktree' }, + ], + config: { isolation: 'worktree', branch: 'main' }, + }); + }); + test('maps the programmatic branch tracking setter to hidden agent-host config without remembering it', async () => { const storageService = disposables.add(new InMemoryStorageService()); const provider = createProvider(disposables, agentHost, undefined, { storageService }); diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/openSubagentChat.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/openSubagentChat.test.ts index 513bbb1323075..7d2d3cf370d92 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/openSubagentChat.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/openSubagentChat.test.ts @@ -10,6 +10,9 @@ import { Event } from '../../../../../../base/common/event.js'; import { Disposable } from '../../../../../../base/common/lifecycle.js'; import { observableValue } from '../../../../../../base/common/observable.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { ChatConfiguration } from '../../../../../../workbench/contrib/chat/common/constants.js'; import { ILanguageModelsService } from '../../../../../../workbench/contrib/chat/common/languageModels.js'; import { workbenchInstantiationService } from '../../../../../../workbench/test/browser/workbenchTestServices.js'; import { ISessionsService } from '../../../../../services/sessions/browser/sessionsService.js'; @@ -235,8 +238,9 @@ suite('OpenSubagentChatActionViewItem', () => { }); }); - test('renders the credit cost alongside the model', () => { + test('renders the credit cost alongside the model when enabled', () => { const instantiationService = workbenchInstantiationService(undefined, store); + (instantiationService.get(IConfigurationService) as TestConfigurationService).setUserConfiguration(ChatConfiguration.SubagentsShowCreditUsage, true); instantiationService.stub(ISessionsService, { activeSession: observableValue('activeSession', undefined), visibleSessions: observableValue('visibleSessions', []), diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/mobilePermissionPicker.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/mobilePermissionPicker.ts index 96e0056fdc4dd..376aa64666b18 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/mobilePermissionPicker.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/mobilePermissionPicker.ts @@ -7,6 +7,7 @@ import { URI } from '../../../../../base/common/uri.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { localize } from '../../../../../nls.js'; import { IActionWidgetService } from '../../../../../platform/actionWidget/browser/actionWidget.js'; +import { IAgentHostEnablementService } from '../../../../../platform/agentHost/common/agentHostEnablementService.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; @@ -42,8 +43,9 @@ export class MobilePermissionPicker extends PermissionPicker { @ITelemetryService telemetryService: ITelemetryService, @IHoverService hoverService: IHoverService, @IWorkbenchLayoutService private readonly _layoutService: IWorkbenchLayoutService, + @IAgentHostEnablementService agentHostEnablementService: IAgentHostEnablementService, ) { - super(_delegate, actionWidgetService, configurationService, dialogService, openerService, storageService, telemetryService, hoverService); + super(_delegate, actionWidgetService, configurationService, dialogService, openerService, storageService, telemetryService, hoverService, agentHostEnablementService); } override showPicker(): void { diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/permissionPicker.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/permissionPicker.ts index e0ab11b8eaded..948d2b0c8d8cc 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/permissionPicker.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/permissionPicker.ts @@ -14,6 +14,7 @@ import { URI } from '../../../../../base/common/uri.js'; import { localize } from '../../../../../nls.js'; import { ActionListItemKind, IActionListDelegate, IActionListItem, IActionListOptions } from '../../../../../platform/actionWidget/browser/actionList.js'; import { IActionWidgetService } from '../../../../../platform/actionWidget/browser/actionWidget.js'; +import { IAgentHostEnablementService } from '../../../../../platform/agentHost/common/agentHostEnablementService.js'; import { IConfigurationChangeEvent, IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; @@ -149,6 +150,9 @@ export class PermissionPicker extends Disposable { protected _currentLevel: ChatPermissionLevel = ChatPermissionLevel.Default; protected _triggerElement: HTMLElement | undefined; protected readonly _renderDisposables = this._register(new DisposableStore()); + private readonly _pickerDisposables = this._register(new DisposableStore()); + private readonly _sandboxToggleDisabled = derived(this, reader => this._delegate.managedSandboxEnforced?.read(reader) === true + && !this.agentHostEnablementService.managedSandboxAllowsBypass.read(reader)); constructor( protected readonly _delegate: IPermissionPickerDelegate, @@ -159,6 +163,7 @@ export class PermissionPicker extends Disposable { @IStorageService protected readonly storageService: IStorageService, @ITelemetryService protected readonly telemetryService: ITelemetryService, @IHoverService protected readonly hoverService: IHoverService, + @IAgentHostEnablementService private readonly agentHostEnablementService: IAgentHostEnablementService, ) { super(); } @@ -347,6 +352,7 @@ export class PermissionPicker extends Disposable { } }, onHide: () => { + this._pickerDisposables.clear(); triggerElement.focus(); }, }; @@ -365,6 +371,20 @@ export class PermissionPicker extends Disposable { }, listOptions, ); + if (sandboxToggle) { + this._pickerDisposables.add(autorun(reader => { + this._delegate.managedSandboxEnforced?.read(reader); + this._sandboxToggleDisabled.read(reader); + const standaloneToggle = this._getSandboxStandaloneToggle(); + const disabled = standaloneToggle?.disabled === true; + this.actionWidgetService.updateItems(items.map(item => item.standaloneToggle ? { + ...item, + standaloneToggle, + disabled, + hover: disabled ? { content: localize('permissions.policyDescription', "Disabled by enterprise policy") } : undefined, + } : item)); + })); + } } protected _isResolving(): boolean { @@ -438,15 +458,18 @@ export class PermissionPicker extends Disposable { return undefined; } const managed = this._isSandboxManaged(); + const disabled = this._isSandboxToggleDisabled(); return { label: localize('permissionPicker.sandboxToggle', "Sandboxing for terminal"), title: managed - ? localize('permissionPicker.managedSandboxToggleTitle', "Sandboxing is managed by your organization") + ? disabled + ? localize('permissionPicker.requiredSandboxToggleTitle', "Sandboxing is required by your organization") + : localize('permissionPicker.editableManagedSandboxToggleTitle', "Sandboxing is enabled by your organization, but you may disable it") : localize('permissionPicker.sandboxToggleTitle', "Run terminal commands inside a sandbox that restricts file system and network access"), checked: this._isSandboxingEnabled(), - disabled: managed, + disabled, onChange: (checked: boolean) => { - if (this._isSandboxManaged()) { + if (this._isSandboxToggleDisabled()) { return; } const settingId = this._delegate.getSandboxToggleSettingId?.(); @@ -478,6 +501,10 @@ export class PermissionPicker extends Disposable { return this._delegate.managedSandboxEnforced?.get() === true; } + private _isSandboxToggleDisabled(): boolean { + return this._sandboxToggleDisabled.get(); + } + private _affectsSandboxToggle(event: IConfigurationChangeEvent): boolean { const settingId = this._delegate.getSandboxToggleSettingId?.(); return event.affectsConfiguration(ChatConfiguration.PermissionsSandboxToggleEnabled) diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/permissionPicker.test.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/permissionPicker.test.ts index ee9ea1507d423..a2760fe9f1154 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/permissionPicker.test.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/permissionPicker.test.ts @@ -9,10 +9,14 @@ import { mock } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { IActionListDelegate, IActionListItem } from '../../../../../../platform/actionWidget/browser/actionList.js'; import { IActionWidgetService } from '../../../../../../platform/actionWidget/browser/actionWidget.js'; +import { IAgentHostEnablementService } from '../../../../../../platform/agentHost/common/agentHostEnablementService.js'; import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; import { IDialogService } from '../../../../../../platform/dialogs/common/dialogs.js'; import { IHoverService } from '../../../../../../platform/hover/browser/hover.js'; import { IOpenerService } from '../../../../../../platform/opener/common/opener.js'; +import { COPILOT_SANDBOX_ALLOW_BYPASS_KEY, IManagedSettingsService } from '../../../../../../platform/policy/common/copilotManagedSettings.js'; +import { Emitter } from '../../../../../../base/common/event.js'; +import { constObservable, observableFromEvent, observableValue } from '../../../../../../base/common/observable.js'; import { AgentSandboxEnabledValue } from '../../../../../../platform/sandbox/common/settings.js'; import { NullTelemetryService } from '../../../../../../platform/telemetry/common/telemetryUtils.js'; import { ChatConfiguration, ChatPermissionLevel } from '../../../../../../workbench/contrib/chat/common/constants.js'; @@ -21,6 +25,12 @@ import { DEFAULT_PERMISSION_LEVELS, getPermissionLevelMeta, IPermissionPickerDel suite('Copilot PermissionPicker', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); + const unmanagedEnablementService: IAgentHostEnablementService = { + _serviceBrand: undefined, + enabled: constObservable(true), + managedSandboxEnforced: constObservable(false), + managedSandboxAllowsBypass: constObservable(false), + }; test('restores trigger focus after pointer and keyboard activation', () => { let onHide: (() => void) | undefined; @@ -46,6 +56,7 @@ suite('Copilot PermissionPicker', () => { new class extends mock() { override setupDelayedHover() { return { dispose: () => { } }; } }(), + unmanagedEnablementService, )); const container = document.createElement('div'); picker.render(container); @@ -72,6 +83,124 @@ suite('Copilot PermissionPicker', () => { }); }); + test('sandbox toggle editability follows managed bypass policy', async () => { + const sandboxSettingId = 'test.sandbox.enabled'; + const writes: unknown[] = []; + const configurationService = new class extends TestConfigurationService { + override async updateValue(key: string, value: unknown): Promise { + writes.push({ key, value }); + } + }(); + store.add(configurationService.onDidChangeConfigurationEmitter); + await configurationService.setUserConfiguration(ChatConfiguration.PermissionsSandboxToggleEnabled, true); + const managedSandboxEnforced = observableValue('managedSandboxEnforced', false); + let allowBypass: boolean | undefined; + const managedSettingsChanged = store.add(new Emitter()); + const managedSettingsService: IManagedSettingsService = { + _serviceBrand: undefined, + onDidChangeManagedSettings: managedSettingsChanged.event, + getManagedSettingValue: key => key === COPILOT_SANDBOX_ALLOW_BYPASS_KEY ? allowBypass : undefined, + }; + const enablementService: IAgentHostEnablementService = { + _serviceBrand: undefined, + enabled: constObservable(true), + managedSandboxEnforced, + managedSandboxAllowsBypass: observableFromEvent(managedSettingsService, managedSettingsChanged.event, () => allowBypass === true), + }; + const visibleStates: { disabled: boolean | undefined; rowDisabled: boolean | undefined; title: string | undefined; hasHover: boolean }[] = []; + let onHide: (() => void) | undefined; + const recordVisibleState = (items: readonly IActionListItem[]) => { + const item = items.find(item => item.standaloneToggle); + assert.ok(item?.standaloneToggle); + visibleStates.push({ disabled: item.standaloneToggle.disabled, rowDisabled: item.disabled, title: item.standaloneToggle.title, hasHover: !!item.hover }); + }; + const actionWidgetService = new class extends mock() { + override readonly isVisible = false; + override show(_user: string, _supportsPreview: boolean, items: readonly IActionListItem[], delegate: IActionListDelegate): void { + onHide = delegate.onHide; + recordVisibleState(items); + } + override updateItems(items: readonly IActionListItem[]): void { + recordVisibleState(items); + } + }(); + const picker = store.add(new PermissionPicker( + { + getPermissionLevelMeta: (_level, meta) => meta, + setPermissionLevel: () => { }, + sandboxTogglePresentation: 'standalone', + isSandboxToggleApplicable: () => true, + getSandboxToggleSettingId: () => sandboxSettingId, + managedSandboxEnforced, + }, + actionWidgetService, + configurationService, + new class extends mock() { }(), + new class extends mock() { }(), + store.add(new TestStorageService()), + NullTelemetryService, + new class extends mock() { }(), + enablementService, + )); + + for (const managed of [false, true]) { + managedSandboxEnforced.set(managed, undefined); + for (const bypass of [undefined, false, true]) { + allowBypass = bypass; + for (const configured of [AgentSandboxEnabledValue.Off, AgentSandboxEnabledValue.On]) { + await configurationService.setUserConfiguration(sandboxSettingId, configured); + const toggle = picker['_getSandboxStandaloneToggle']()!; + writes.length = 0; + toggle.onChange(false); + toggle.onChange(true); + const disabled = managed && bypass !== true; + assert.deepStrictEqual({ checked: toggle.checked, disabled: toggle.disabled, title: toggle.title, writes }, { + checked: managed || configured === AgentSandboxEnabledValue.On, + disabled, + title: managed + ? disabled ? 'Sandboxing is required by your organization' : 'Sandboxing is enabled by your organization, but you may disable it' + : 'Run terminal commands inside a sandbox that restricts file system and network access', + writes: disabled ? [] : [ + { key: sandboxSettingId, value: AgentSandboxEnabledValue.Off }, + { key: sandboxSettingId, value: AgentSandboxEnabledValue.On }, + ], + }); + } + } + } + + const toggle = picker['_getSandboxStandaloneToggle']()!; + allowBypass = false; + writes.length = 0; + toggle.onChange(false); + assert.deepStrictEqual({ writes, disabled: picker['_getSandboxStandaloneToggle']()!.disabled }, { writes: [], disabled: true }); + allowBypass = true; + assert.strictEqual(picker['_getSandboxStandaloneToggle']()!.disabled, false); + + picker['_triggerElement'] = document.createElement('div'); + allowBypass = false; + picker.showPicker(); + allowBypass = true; + managedSettingsChanged.fire(); + managedSettingsChanged.fire(); + managedSandboxEnforced.set(false, undefined); + managedSandboxEnforced.set(true, undefined); + allowBypass = false; + managedSettingsChanged.fire(); + assert.ok(onHide); + onHide(); + allowBypass = true; + managedSettingsChanged.fire(); + assert.deepStrictEqual(visibleStates, [ + { disabled: true, rowDisabled: true, title: 'Sandboxing is required by your organization', hasHover: true }, + { disabled: true, rowDisabled: true, title: 'Sandboxing is required by your organization', hasHover: true }, + { disabled: false, rowDisabled: false, title: 'Sandboxing is enabled by your organization, but you may disable it', hasHover: false }, + { disabled: false, rowDisabled: false, title: 'Run terminal commands inside a sandbox that restricts file system and network access', hasHover: false }, + { disabled: false, rowDisabled: false, title: 'Sandboxing is enabled by your organization, but you may disable it', hasHover: false }, + { disabled: true, rowDisabled: true, title: 'Sandboxing is required by your organization', hasHover: true }, + ]); + }); + test('uses descriptions aligned with the agent host permission picker', () => { assert.deepStrictEqual(DEFAULT_PERMISSION_LEVELS.map(level => ({ level, @@ -119,6 +248,7 @@ suite('Copilot PermissionPicker', () => { store.add(new TestStorageService()), NullTelemetryService, new class extends mock() { }(), + unmanagedEnablementService, )); const container = document.createElement('div'); picker.render(container); diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostActions.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostActions.ts index 3f8652cb77324..e44b688959b30 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostActions.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostActions.ts @@ -1099,7 +1099,7 @@ async function promptForTunnelFolder( } sessionsService.openNewSession(); - sessionsPartService.getSessionView(sessionsService.activeSession.get()?.sessionId)?.selectWorkspace(folderUri, provider.id); + sessionsPartService.getSessionView(sessionsService.activeSession.get()?.sessionId)?.selectWorkspace(folderUri, { providerId: provider.id }); } registerAction2(class extends Action2 { @@ -1290,7 +1290,7 @@ async function promptForWSLFolder( } sessionsService.openNewSession(); - sessionsPartService.getSessionView(sessionsService.activeSession.get()?.sessionId)?.selectWorkspace(folderUri, provider.id); + sessionsPartService.getSessionView(sessionsService.activeSession.get()?.sessionId)?.selectWorkspace(folderUri, { providerId: provider.id }); } registerAction2(class extends Action2 { diff --git a/src/vs/sessions/contrib/sessions/browser/media/newSessionActionViewItem.css b/src/vs/sessions/contrib/sessions/browser/media/newSessionActionViewItem.css index 17ca4d416afd1..69542790edcf2 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/newSessionActionViewItem.css +++ b/src/vs/sessions/contrib/sessions/browser/media/newSessionActionViewItem.css @@ -59,6 +59,37 @@ unscoped so the widget renders identically wherever it is mounted. */ box-shadow: none; } +.agent-sessions-compact-new-button.lightweight .new-session-keybinding-hint { + display: inline-flex; + align-items: center; + font-family: inherit; + font-size: var(--vscode-fontSize-label2, 11px); + line-height: inherit; + padding: 0; + border: 0; + border-radius: 0; + background-color: transparent; + color: inherit; +} + +.agent-sessions-compact-new-button.lightweight .new-session-keybinding-hint .monaco-keybinding { + gap: 0; + line-height: inherit; +} + +.agent-sessions-compact-new-button.lightweight .new-session-keybinding-hint .monaco-keybinding > .monaco-keybinding-key { + margin: 0; + font-family: inherit; + font-size: inherit; +} + +.agent-sessions-compact-new-button.lightweight-keybinding-background .new-session-keybinding-hint { + padding: 0 var(--vscode-spacing-size20); + border-radius: var(--vscode-cornerRadius-xSmall); + background-color: var(--vscode-keybindingLabel-background); + color: var(--vscode-keybindingLabel-foreground); +} + .agent-sessions-compact-new-button .new-session-keybinding-hint .monaco-keybinding { line-height: 1; } diff --git a/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts b/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts index 4a2518f6be12f..a539ec8ccb533 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts @@ -13,7 +13,7 @@ import { ViewPaneContainer } from '../../../../workbench/browser/parts/views/vie import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { SessionsTitleBarContribution } from './sessionsTitleBarWidget.js'; import { SessionsTelemetryContribution } from './sessionsTelemetry.contribution.js'; -import { NewSessionActionViewItemContribution, SessionConversationActionsContribution } from './sessionsActions.js'; +import { NEW_SESSION_BUTTON_STYLE_SETTING, NEW_SESSION_BUTTON_STYLE_TREATMENT, NewSessionActionViewItemContribution, SessionConversationActionsContribution } from './sessionsActions.js'; import { SessionsView, SessionsViewId } from './views/sessionsView.js'; import { AutomationsCustomViewContribution } from './views/automationsView.js'; import './views/sessionsViewActions.js'; @@ -83,6 +83,19 @@ Registry.as(ConfigurationExtensions.Configuration).regis }, description: localize('sessions.automations.newBadgeStyle', "Controls the visual style of the Automations first-use badge."), }, + [NEW_SESSION_BUTTON_STYLE_SETTING]: { + type: 'string', + enum: ['default', 'lightweight', 'lightweightWithKeybindingBackground'], + default: 'default', + scope: ConfigurationScope.APPLICATION, + included: false, + tags: ['experimental'], + experiment: { + mode: 'auto', + name: NEW_SESSION_BUTTON_STYLE_TREATMENT, + }, + description: localize('sessions.newSessionButton.style', "Controls the visual style of the New Session button."), + }, }, }); diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts b/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts index 6d415191959cd..eb07462e50a69 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts @@ -5,31 +5,38 @@ import { Codicon } from '../../../../base/common/codicons.js'; import { fromNow } from '../../../../base/common/date.js'; +import { onUnexpectedError } from '../../../../base/common/errors.js'; import { KeyChord, KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; -import { autorun, IReader, observableSignalFromEvent } from '../../../../base/common/observable.js'; +import { autorun, IObservable, IReader, observableSignalFromEvent, observableValue } from '../../../../base/common/observable.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { localize, localize2 } from '../../../../nls.js'; import { Action2, MenuRegistry, MenuId, registerAction2, MenuItemAction } from '../../../../platform/actions/common/actions.js'; import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { IConfigurationService, isConfigured } from '../../../../platform/configuration/common/configuration.js'; import { ContextKeyExpr, IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { InputFocusedContext } from '../../../../platform/contextkey/common/contextkeys.js'; import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; import { KeybindingsRegistry, KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; +import { WorkbenchListFocusContextKey } from '../../../../platform/list/browser/listService.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { observableConfigValue } from '../../../../platform/observable/common/platformObservableUtils.js'; import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; import { IWorkbenchContribution } from '../../../../workbench/common/contributions.js'; import { IQuickInputService, IQuickPickItem, IQuickPickSeparator } from '../../../../platform/quickinput/common/quickInput.js'; -import { EditorAreaFocusContext, IsAuxiliaryWindowContext, IsSessionsWindowContext } from '../../../../workbench/common/contextkeys.js'; +import { EditorAreaFocusContext, FocusedViewContext, IsAuxiliaryWindowContext, IsSessionsWindowContext } from '../../../../workbench/common/contextkeys.js'; import { IWorkbenchLayoutService, Parts } from '../../../../workbench/services/layout/browser/layoutService.js'; +import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; import { getQuickNavigateHandler, inQuickPickContext } from '../../../../workbench/browser/quickaccess.js'; +import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; import { Menus } from '../../../browser/menus.js'; import { SessionsCategories } from '../../../common/categories.js'; -import { CanGoBackContext, CanGoForwardContext, SessionProviderIdContext, MultipleSessionsVisibleContext, SessionIsArchivedContext, SessionIsCreatedContext, SessionIsMaximizedContext, SessionIsStickyContext, SessionsFocusContext, SessionSupportsMultipleChatsContext, SessionSupportsRenameContext, SessionsWelcomeVisibleContext, SessionIdContext, SessionHasMultipleCommittedChatsContext, SessionHasMultipleOpenChatsContext, SessionsPickerVisibleContext, SessionActiveChatIsClosableContext, SessionActiveChatIsDeletableContext, SessionChatsPickerVisibleContext, SessionHasSideChatsContext, SessionsTitleBarNewSessionEnabledContext, SessionsEditorScopeContext, SessionsHasClosedItemContext, IsQuickChatSessionContext } from '../../../common/contextkeys.js'; +import { CanGoBackContext, CanGoForwardContext, SessionProviderIdContext, MultipleSessionsVisibleContext, SessionIsArchivedContext, SessionIsCreatedContext, SessionIsMaximizedContext, SessionIsStickyContext, SessionsFocusContext, SessionSupportsMultipleChatsContext, SessionSupportsRenameContext, SessionsWelcomeVisibleContext, SessionIdContext, SessionHasMultipleCommittedChatsContext, SessionHasMultipleOpenChatsContext, SessionsPickerVisibleContext, SessionActiveChatIsClosableContext, SessionFocusedChatIsRenameTargetContext, SessionActiveChatIsDeletableContext, SessionChatsPickerVisibleContext, SessionHasSideChatsContext, SessionsTitleBarNewSessionEnabledContext, SessionsEditorScopeContext, SessionsHasClosedItemContext, IsQuickChatSessionContext } from '../../../common/contextkeys.js'; import { ANY_AGENT_HOST_PROVIDER_RE } from '../../../common/agentHostSessionsProvider.js'; -import { CLOSE_CHAT_COMMAND_ID, FOCUS_ACTIVE_SESSION_COMMAND_ID, FOCUS_NEXT_CHAT_GROUP_COMMAND_ID, FOCUS_PREVIOUS_CHAT_GROUP_COMMAND_ID, MOVE_CHAT_TO_NEXT_GROUP_COMMAND_ID, MOVE_CHAT_TO_PREVIOUS_GROUP_COMMAND_ID, RENAME_SESSION_COMMAND_ID, SPLIT_CHAT_GROUP_DOWN_COMMAND_ID, SPLIT_CHAT_GROUP_RIGHT_COMMAND_ID } from '../../../common/sessionCommands.js'; +import { CLOSE_CHAT_COMMAND_ID, FOCUS_ACTIVE_SESSION_COMMAND_ID, FOCUS_NEXT_CHAT_GROUP_COMMAND_ID, FOCUS_PREVIOUS_CHAT_GROUP_COMMAND_ID, MOVE_CHAT_TO_NEXT_GROUP_COMMAND_ID, MOVE_CHAT_TO_PREVIOUS_GROUP_COMMAND_ID, RENAME_CHAT_COMMAND_ID, RENAME_SESSION_COMMAND_ID, SPLIT_CHAT_GROUP_DOWN_COMMAND_ID, SPLIT_CHAT_GROUP_RIGHT_COMMAND_ID } from '../../../common/sessionCommands.js'; import { IActiveSession, ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; import { ChatOriginKind, getChatCapabilities, getGitHubPullRequestRefs, getHighestPriorityPullRequestIcon, getUntitledSessionTitle, IChat, ISession, SessionStatus } from '../../../services/sessions/common/session.js'; @@ -55,9 +62,14 @@ import { logSessionsInteraction, SessionsInteractionSource } from '../../../comm import { NEW_SESSION_ACTION_ID } from '../../chat/common/constants.js'; import { groupSessionsForPicker } from './sessionsPicker.js'; import { getSessionConversationActionId, isSessionConversationSideChat, SESSION_CONVERSATION_SIDE_CHATS_GROUP } from '../../../browser/sessionConversationGroups.js'; -import { ISessionChatItem, SessionChatItemCanDeleteContext, SessionChatItemCanRenameContext, SessionChatItemIsUntitledContext } from './views/sessionsList.js'; +import { ISessionChatItem, SessionChatItemCanDeleteContext, SessionChatItemCanRenameContext, SessionChatItemIsUntitledContext, SessionsList, SessionsListFocusedChatItemContext } from './views/sessionsList.js'; +import { SessionsView, SessionsViewId } from './views/sessionsView.js'; import './media/newSessionActionViewItem.css'; +export const NEW_SESSION_BUTTON_STYLE_SETTING = 'sessions.newSessionButton.style'; +export const NEW_SESSION_BUTTON_STYLE_TREATMENT = 'agentSessionsNewSessionButtonStyle'; +export type NewSessionButtonStyle = 'default' | 'lightweight' | 'lightweightWithKeybindingBackground'; + // -- Show Sessions Picker -- export const SHOW_SESSIONS_PICKER_COMMAND_ID = 'sessions.showSessionsPicker'; @@ -535,13 +547,101 @@ registerAction2(class CloseAllSessionsAction extends Action2 { } }); -// -- Chat tab navigation, new chat, & close (within the active session's tab strip) -- +// -- Chat tab navigation, rename, new chat, & close (within the active session's tab strip) -- // These chords sit just above the session-level navigation/close commands so // they win while a multi-chat session is focused, falling back to the // session-level commands when the tab strip is not shown. const CHAT_TAB_KEYBINDING_WEIGHT = KeybindingWeight.SessionsContrib + 10; +interface IChatRenameContext { + readonly session: ISession; + readonly chat: IChat; +} + +function getSessionsList(accessor: ServicesAccessor): SessionsList | undefined { + return accessor.get(IViewsService).getViewWithId(SessionsViewId)?.sessionsControl; +} + +function getChatRenameContext(accessor: ServicesAccessor, context?: IChatRenameContext): IChatRenameContext | undefined { + if (context) { + return context; + } + + const sessionsList = getSessionsList(accessor); + const focusedChat = sessionsList?.getFocusedChatItem(); + if (focusedChat) { + return focusedChat; + } + if (sessionsList?.getFocusedSessions() !== undefined) { + return undefined; + } + + const sessionView = accessor.get(ISessionsPartService).getFocusedSessionView(); + const session = sessionView?.getSession(); + const chat = sessionView?.getFocusedChat(); + return session && chat ? { session, chat } : undefined; +} + +async function renameChatWithQuickInput(accessor: ServicesAccessor, context: IChatRenameContext): Promise { + const extUri = accessor.get(IUriIdentityService).extUri; + const quickInputService = accessor.get(IQuickInputService); + const sessionsManagementService = accessor.get(ISessionsManagementService); + const { session, chat } = context; + const resource = chat.resource; + const initialChat = session.chats.get().find(candidate => extUri.isEqual(candidate.resource, resource)); + if (!initialChat || extUri.isEqual(resource, session.mainChat.get().resource) || initialChat.status.get() === SessionStatus.Untitled || !getChatCapabilities(initialChat, session, undefined).canRename) { + return; + } + + const initialTitle = initialChat.title.get().trim() || localize('untitledChat', "Untitled Chat"); + const newTitle = await quickInputService.input({ + value: initialTitle, + prompt: localize('renameChat.prompt', "New chat title"), + validateInput: async value => value.trim() ? undefined : localize('renameChat.empty', "Title cannot be empty"), + }); + const trimmedTitle = newTitle?.trim(); + if (!trimmedTitle || trimmedTitle === initialTitle) { + return; + } + + const currentChat = session.chats.get().find(candidate => extUri.isEqual(candidate.resource, resource)); + if (!currentChat || extUri.isEqual(resource, session.mainChat.get().resource) || currentChat.status.get() === SessionStatus.Untitled || !getChatCapabilities(currentChat, session, undefined).canRename) { + return; + } + + await sessionsManagementService.renameChat(session, resource, trimmedTitle); +} + +registerAction2(class RenameChatAction extends Action2 { + constructor() { + super({ + id: RENAME_CHAT_COMMAND_ID, + title: localize2('renameActiveChat', "Rename..."), + f1: false, + category: SessionsCategories.Sessions, + keybinding: { + primary: KeyCode.F2, + weight: CHAT_TAB_KEYBINDING_WEIGHT, + when: ContextKeyExpr.and( + IsSessionsWindowContext, + ContextKeyExpr.or( + ContextKeyExpr.and(ChatContextKeys.inChatSession, SessionFocusedChatIsRenameTargetContext), + ContextKeyExpr.and(FocusedViewContext.isEqualTo(SessionsViewId), WorkbenchListFocusContextKey, SessionsListFocusedChatItemContext), + ), + ), + }, + }); + } + + override async run(accessor: ServicesAccessor, context?: IChatRenameContext): Promise { + const target = getChatRenameContext(accessor, context); + if (target) { + await renameChatWithQuickInput(accessor, target); + } + } +}); + registerAction2(class RenameSessionListChatAction extends Action2 { constructor() { super({ @@ -558,21 +658,10 @@ registerAction2(class RenameSessionListChatAction extends Action2 { } override async run(accessor: ServicesAccessor, context?: ISessionChatItem): Promise { - if (!context || !getChatCapabilities(context.chat, context.session, undefined).canRename || context.chat.status.get() === SessionStatus.Untitled) { + if (!context) { return; } - const quickInputService = accessor.get(IQuickInputService); - const sessionsManagementService = accessor.get(ISessionsManagementService); - const currentTitle = context.chat.title.get().trim() || localize('untitledChat', "Untitled Chat"); - const newTitle = await quickInputService.input({ - value: currentTitle, - prompt: localize('renameChat.prompt', "New chat title"), - validateInput: async value => value.trim() ? undefined : localize('renameChat.empty', "Title cannot be empty"), - }); - const trimmedTitle = newTitle?.trim(); - if (trimmedTitle && trimmedTitle !== currentTitle) { - await sessionsManagementService.renameChat(context.session, context.chat.resource, trimmedTitle); - } + await renameChatWithQuickInput(accessor, context); } }); @@ -1181,6 +1270,8 @@ export abstract class CompactButtonActionViewItem extends BaseActionViewItem { /** Hook invoked right before the action runs (e.g. for telemetry). */ protected onRun(): void { } + protected configureButton(_button: Button): void { } + override render(container: HTMLElement): void { super.render(container); @@ -1198,6 +1289,7 @@ export abstract class CompactButtonActionViewItem extends BaseActionViewItem { supportIcons: true, })); button.element.classList.add('agent-sessions-compact-new-button'); + this.configureButton(button); const onboardingTargetId = this.onboardingTargetId; if (onboardingTargetId) { this._register(markOnboardingTarget(button.element, onboardingTargetId)); @@ -1275,6 +1367,7 @@ class NewSessionActionViewItem extends CompactButtonActionViewItem { constructor( action: IAction, private readonly telemetrySource: SessionsInteractionSource, + private readonly newSessionButtonStyle: IObservable, @IKeybindingService keybindingService: IKeybindingService, @IHoverService hoverService: IHoverService, @ITelemetryService private readonly telemetryService: ITelemetryService, @@ -1295,6 +1388,14 @@ class NewSessionActionViewItem extends CompactButtonActionViewItem { return 'sessions.newSession.button'; } + protected override configureButton(button: Button): void { + this._register(autorun(reader => { + const style = this.newSessionButtonStyle.read(reader); + button.element.classList.toggle('lightweight', style === 'lightweight' || style === 'lightweightWithKeybindingBackground'); + button.element.classList.toggle('lightweight-keybinding-background', style === 'lightweightWithKeybindingBackground'); + })); + } + protected override getHoverContent(keybindingLabel: string | undefined): string { return keybindingLabel ? localize('newSessionButtonTitle', "New Session ({0})", keybindingLabel) @@ -1324,6 +1425,8 @@ export class NewSessionActionViewItemContribution extends Disposable implements private static readonly NEW_SESSION_TITLEBAR_TREATMENT = 'agentSessionsTitleBarNewSession'; private readonly titleBarEnabledContext: IContextKey; + private readonly newSessionButtonStyle = observableValue(this, 'default'); + private newSessionButtonStyleRequest = 0; constructor( @IActionViewItemService actionViewItemService: IActionViewItemService, @@ -1331,10 +1434,19 @@ export class NewSessionActionViewItemContribution extends Disposable implements @IWorkbenchAssignmentService private readonly assignmentService: IWorkbenchAssignmentService, @IEnvironmentService private readonly environmentService: IEnvironmentService, @IProductService private readonly productService: IProductService, + @IConfigurationService private readonly configurationService: IConfigurationService, + @ILogService private readonly logService: ILogService, ) { super(); this.titleBarEnabledContext = SessionsTitleBarNewSessionEnabledContext.bindTo(contextKeyService); + const configuredStyle = observableConfigValue(NEW_SESSION_BUTTON_STYLE_SETTING, 'default', this.configurationService); + const assignmentsChanged = observableSignalFromEvent(this, this.assignmentService.onDidRefetchAssignments); + this._register(autorun(reader => { + configuredStyle.read(reader); + assignmentsChanged.read(reader); + void this.updateNewSessionButtonStyle().catch(onUnexpectedError); + })); const onDidRegister = this._register(new Emitter()); const menus: MenuId[] = [Menus.SidebarSessionsHeader, Menus.TitleBarLeftLayout]; @@ -1344,7 +1456,7 @@ export class NewSessionActionViewItemContribution extends Disposable implements if (!(action instanceof MenuItemAction)) { return undefined; } - return instantiationService.createInstance(NewSessionActionViewItem, action, source); + return instantiationService.createInstance(NewSessionActionViewItem, action, source, this.newSessionButtonStyle); }, onDidRegister.event)); } onDidRegister.fire(); @@ -1354,6 +1466,33 @@ export class NewSessionActionViewItemContribution extends Disposable implements this.updateTitleBarTreatment(); } + private async updateNewSessionButtonStyle(): Promise { + const request = ++this.newSessionButtonStyleRequest; + const inspection = this.configurationService.inspect(NEW_SESSION_BUTTON_STYLE_SETTING); + let value: string | undefined; + if (isConfigured(inspection)) { + value = inspection.value; + } else { + try { + value = await this.assignmentService.getTreatment(NEW_SESSION_BUTTON_STYLE_TREATMENT); + } catch (error) { + this.logService.warn('[NewSessionActionViewItemContribution] Failed to resolve the New Session button style treatment; using default.', error); + } + } + if (request !== this.newSessionButtonStyleRequest) { + return; + } + this.newSessionButtonStyle.set(this.normalizeNewSessionButtonStyle(value), undefined); + } + + private normalizeNewSessionButtonStyle(value: string | undefined): NewSessionButtonStyle { + if (value === undefined || value === 'default' || value === 'lightweight' || value === 'lightweightWithKeybindingBackground') { + return value ?? 'default'; + } + this.logService.warn(`[NewSessionActionViewItemContribution] Unsupported New Session button style treatment '${value}'; using 'default'.`); + return 'default'; + } + private async updateTitleBarTreatment(): Promise { // Always show in dev builds (running from sources) to ease development, regardless of the experiment. if (!this.environmentService.isBuilt) { diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts index 6f4d91d9ec37d..eaed70b620011 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts @@ -124,6 +124,7 @@ export const SessionItemStatusContext = new RawContextKey('sessio export const SessionChatItemCanRenameContext = new RawContextKey('sessionChatItem.canRename', false); export const SessionChatItemCanDeleteContext = new RawContextKey('sessionChatItem.canDelete', false); export const SessionChatItemIsUntitledContext = new RawContextKey('sessionChatItem.isUntitled', false); +export const SessionsListFocusedChatItemContext = new RawContextKey('sessionsList.focusedChatItem', false); /** Whether the focused session item currently belongs to a user group. */ export const SessionItemInGroupContext = new RawContextKey('sessionItem.inGroup', false); export const SessionSectionTypeContext = new RawContextKey('sessionSection.type', ''); @@ -2695,6 +2696,7 @@ export class SessionsList extends Disposable implements ISessionsList { : 'force-no-twistie', } )); + const focusedChatItemContext = SessionsListFocusedChatItemContext.bindTo(this.tree.contextKeyService); this.tree.updateOptions({ indent: 0, defaultIndent: 0, expandOnDoubleClick: false }); // Hierarchy guides: resolve any row (a session or one of its chats) to @@ -2736,11 +2738,13 @@ export class SessionsList extends Disposable implements ISessionsList { })); const updateFocusedGuideSessionIds = () => { this.focusedGuideSessionIds.set(guideOwnerSessionIds(this.tree.getFocus()), undefined); + focusedChatItemContext.set(DOM.isAncestorOfActiveElement(this.listContainer) && this.tree.getFocus().some(item => !!item && isSessionChatItem(item))); }; this._register(this.tree.onDidChangeFocus(updateFocusedGuideSessionIds)); this._register(this.tree.onDidFocus(updateFocusedGuideSessionIds)); this._register(this.tree.onDidBlur(() => { this.focusedGuideSessionIds.set(EMPTY_GUIDE_SESSION_IDS, undefined); + focusedChatItemContext.reset(); })); this._register(this.tree.onDidOpen(async e => { @@ -3464,6 +3468,15 @@ export class SessionsList extends Disposable implements ISessionsList { return focusedSession ? this.getMultiSelectedSessions(focusedSession) : []; } + /** Returns the focused chat row while this list owns DOM focus. */ + getFocusedChatItem(): ISessionChatItem | undefined { + if (!DOM.isAncestorOfActiveElement(this.listContainer)) { + return undefined; + } + + return this.tree.getFocus().find((item): item is ISessionChatItem => !!item && isSessionChatItem(item)); + } + setVisible(visible: boolean): void { if (this.visible === visible) { return; diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts index 4fcb2e70c0d84..88cbe99e0b0bb 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts @@ -491,7 +491,7 @@ registerAction2(class NewSessionForWorkspaceAction extends Action2 { const newSession = sessionsService.activeSession.get(); if (folderUri) { - sessionsPartService.getSessionView(newSession?.sessionId)?.selectWorkspace(folderUri, providerId); + sessionsPartService.getSessionView(newSession?.sessionId)?.selectWorkspace(folderUri, { providerId }); } // On mobile web, the sidebar drawer covers the viewport; close it so diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts index 541fc0caec372..0e61d7171980a 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts @@ -42,7 +42,7 @@ import { ChatInteractivity, ChatOriginKind, IChat, ISession, SessionStatus } fro import { IActiveSession, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; import { ISessionsProvider } from '../../../../services/sessions/common/sessionsProvider.js'; import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; -import { computeReorderSortChanges, groupByDate, groupByWorkspace, groupSessionsForList, ISessionSection, limitSessionsForList, SessionSectionRenderer, SessionsFlatList, SessionsList, sortSessions, SessionsGrouping, SessionsSorting } from '../../browser/views/sessionsList.js'; +import { computeReorderSortChanges, groupByDate, groupByWorkspace, groupSessionsForList, ISessionSection, limitSessionsForList, SessionSectionRenderer, SessionsFlatList, SessionsList, SessionsListFocusedChatItemContext, sortSessions, SessionsGrouping, SessionsSorting } from '../../browser/views/sessionsList.js'; import { AgentSessionApprovalKind, AgentSessionApprovalModel, IAgentSessionApprovalInfo } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentSessionApprovalModel.js'; import { getSessionSummaryHoverData } from '../../browser/sessionHoverContent.js'; import { createListHarness, createTestSession } from './sessionsListTestUtils.js'; @@ -1590,6 +1590,67 @@ suite('Sessions - SessionsList', () => { }]); }); + test('reports a focused nested chat only while the Sessions list owns focus', () => { + const main = createChat('Main chat'); + const peer = createChat('Peer chat', ChatOriginKind.User); + const base = createTestSession('Session').session; + const session: ISession = { + ...base, + chats: constObservable([main, peer]), + mainChat: constObservable(main), + capabilities: constObservable({ supportsMultipleChats: true }), + }; + const harness = createListHarness(disposables, [session], instantiationService => { + instantiationService.stub(IContextKeyService, disposables.add(new ContextKeyService(new TestConfigurationService()))); + }); + const contextKeyService = harness.instantiationService.get(IContextKeyService); + const container = harness.createContainer(); + const list = harness.store.add(harness.instantiationService.createInstance(SessionsList, container, { + grouping: () => SessionsGrouping.Date, + sorting: () => SessionsSorting.Created, + onSessionOpen: () => { }, + onChatOpen: () => { }, + })); + list.layout(300, 400); + list.reveal(session.resource); + list.focus(); + const contextTarget = container.querySelector('.monaco-list'); + assert.ok(contextTarget); + const getFocusedChatItemContext = () => contextKeyService.getContext(contextTarget).getValue(SessionsListFocusedChatItemContext.key); + const sessionRowValue = getFocusedChatItemContext(); + const beforeChatFocus = list.getFocusedChatItem(); + + const peerRow = [...container.querySelectorAll('.session-chat-item')] + .find(element => element.textContent === 'Peer chat'); + assert.ok(peerRow); + peerRow.dispatchEvent(new MouseEvent('click', { bubbles: true, button: 0 })); + list.focus(); + const focusedChat = list.getFocusedChatItem(); + const chatRowValue = getFocusedChatItemContext(); + + const outside = mainWindow.document.createElement('button'); + mainWindow.document.body.appendChild(outside); + harness.store.add({ dispose: () => outside.remove() }); + outside.focus(); + contextTarget.dispatchEvent(new FocusEvent('blur')); + + assert.deepStrictEqual({ + sessionRowValue, + beforeChatFocus, + focusedChat: focusedChat?.chat.resource.toString(), + chatRowValue, + afterBlur: list.getFocusedChatItem(), + afterBlurValue: getFocusedChatItemContext(), + }, { + sessionRowValue: false, + beforeChatFocus: undefined, + focusedChat: peer.resource.toString(), + chatRowValue: true, + afterBlur: undefined, + afterBlurValue: false, + }); + }); + test('opens a nested chat to the side with the session row modifier gesture', () => { const main = createChat('Main chat'); const peer = createChat('Peer chat', ChatOriginKind.User); diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsListTestUtils.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsListTestUtils.ts index fe645926b28fa..2fdb901e39e18 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsListTestUtils.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsListTestUtils.ts @@ -49,6 +49,7 @@ export class TestSessionsManagementService extends mock { this.renamedChats.push({ session, chatResource, title }); + if (this.renameChatError) { + throw this.renameChatError; + } } } diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsRename.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsRename.test.ts index fc46a989b638f..a7add286667e7 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsRename.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsRename.test.ts @@ -5,18 +5,23 @@ import assert from 'assert'; import { mainWindow } from '../../../../../base/browser/window.js'; -import { constObservable } from '../../../../../base/common/observable.js'; +import { DeferredPromise } from '../../../../../base/common/async.js'; +import { constObservable, observableValue } from '../../../../../base/common/observable.js'; +import { extUri } from '../../../../../base/common/resources.js'; import { URI } from '../../../../../base/common/uri.js'; -import { mock } from '../../../../../base/test/common/mock.js'; +import { mock, upcastPartial } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { CommandsRegistry, ICommandService } from '../../../../../platform/commands/common/commands.js'; import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { IInputOptions, IQuickInputService } from '../../../../../platform/quickinput/common/quickInput.js'; -import { ARCHIVE_SESSION_COMMAND_ID, RENAME_SESSION_COMMAND_ID } from '../../../../common/sessionCommands.js'; +import { IUriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentity.js'; +import { IViewsService } from '../../../../../workbench/services/views/common/viewsService.js'; +import { ARCHIVE_SESSION_COMMAND_ID, RENAME_CHAT_COMMAND_ID, RENAME_SESSION_COMMAND_ID } from '../../../../common/sessionCommands.js'; import { SessionView } from '../../../../browser/parts/sessionView.js'; import { ISessionsPartService } from '../../../../services/sessions/browser/sessionsPartService.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; import { IActiveSession, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; +import { ChatInteractivity, IChat, ISession, SessionStatus } from '../../../../services/sessions/common/session.js'; import { SessionsChatAccessibilityHelp } from '../../../chat/browser/sessionsChatAccessibilityHelp.js'; import { SessionsFlatList, SessionsGrouping, SessionsList, SessionsSorting } from '../../browser/views/sessionsList.js'; import { createListHarness, createTestSession, TestCommandService, TestSessionsManagementService } from './sessionsListTestUtils.js'; @@ -27,10 +32,14 @@ class TestQuickInputService extends mock() { result: string | undefined; options: IInputOptions | undefined; calls = 0; + inputHandler: ((options?: IInputOptions) => Promise) | undefined; override async input(options?: IInputOptions): Promise { this.calls++; this.options = options; + if (this.inputHandler) { + return this.inputHandler(options); + } return this.result; } } @@ -240,6 +249,176 @@ suite('Sessions rename', () => { }); }); + suite('chat action', () => { + function createChatHarness(options: { readonly status?: SessionStatus; readonly canRename?: boolean } = {}) { + const instantiationService = disposables.add(new TestInstantiationService()); + const quickInputService = new TestQuickInputService(); + const managementService = new TestSessionsManagementService([]); + const baseSession = createTestSession('Explore Jitter Issue').session; + const mainChat = baseSession.mainChat.get(); + const peerChat = new class extends mock() { + override readonly resource = URI.parse('test-chat:///grill-and-plan'); + override readonly title = constObservable('Grill and Plan'); + override readonly status = constObservable(options.status ?? SessionStatus.Completed); + override readonly interactivity = constObservable(ChatInteractivity.Full); + override readonly capabilities = constObservable({ canRename: options.canRename ?? true, canDelete: true }); + }(); + const otherPeerChat = new class extends mock() { + override readonly resource = URI.parse('test-chat:///other-peer'); + override readonly title = constObservable('Other Peer'); + override readonly status = constObservable(SessionStatus.Completed); + override readonly interactivity = constObservable(ChatInteractivity.Full); + override readonly capabilities = constObservable({ canRename: true, canDelete: true }); + }(); + const chats = observableValue('renameChats', [mainChat, peerChat, otherPeerChat]); + const session: ISession = { + ...baseSession, + chats, + mainChat: constObservable(mainChat), + }; + const activeChat = observableValue('renameActiveChat', peerChat); + const focusedChat = observableValue('renameFocusedChat', peerChat); + const activeSession = upcastPartial({ + ...session, + activeChat, + }); + instantiationService.stub(IQuickInputService, quickInputService); + instantiationService.stub(ISessionsManagementService, managementService); + instantiationService.stub(ISessionsService, new class extends mock() { + override readonly activeSession = constObservable(activeSession); + }()); + instantiationService.stub(ISessionsPartService, new class extends mock() { + override getFocusedSessionView(): SessionView { + return upcastPartial({ getSession: () => activeSession, getFocusedChat: () => focusedChat.get() }); + } + }()); + instantiationService.stub(IViewsService, new class extends mock() { + override getViewWithId() { return null; } + }()); + instantiationService.stub(IUriIdentityService, new class extends mock() { + override readonly extUri = extUri; + }()); + const handler = CommandsRegistry.getCommand(RENAME_CHAT_COMMAND_ID)?.handler; + assert.ok(handler); + return { handler, instantiationService, quickInputService, managementService, session, activeSession, mainChat, peerChat, otherPeerChat, activeChat, focusedChat, chats }; + } + + test('renames the exact peer chat with the peer title as the prompt value', async () => { + const harness = createChatHarness(); + harness.quickInputService.result = ' Renamed Peer '; + + await harness.handler(harness.instantiationService, { session: harness.session, chat: harness.peerChat }); + + assert.deepStrictEqual({ + inputValue: harness.quickInputService.options?.value, + inputPrompt: harness.quickInputService.options?.prompt, + renamedSessions: harness.managementService.renamed, + renamedChats: harness.managementService.renamedChats, + }, { + inputValue: 'Grill and Plan', + inputPrompt: 'New chat title', + renamedSessions: [], + renamedChats: [{ session: harness.session, chatResource: harness.peerChat.resource, title: 'Renamed Peer' }], + }); + }); + + test('rejects main, unsupported, untitled, cancelled, blank, and unchanged chat renames', async () => { + const main = createChatHarness(); + await main.handler(main.instantiationService, { session: main.session, chat: main.mainChat }); + + const unsupported = createChatHarness({ canRename: false }); + await unsupported.handler(unsupported.instantiationService, { session: unsupported.session, chat: unsupported.peerChat }); + + const untitled = createChatHarness({ status: SessionStatus.Untitled }); + await untitled.handler(untitled.instantiationService, { session: untitled.session, chat: untitled.peerChat }); + + const cancelled = createChatHarness(); + cancelled.quickInputService.result = undefined; + await cancelled.handler(cancelled.instantiationService, { session: cancelled.session, chat: cancelled.peerChat }); + + const blank = createChatHarness(); + blank.quickInputService.result = ' '; + await blank.handler(blank.instantiationService, { session: blank.session, chat: blank.peerChat }); + + const unchanged = createChatHarness(); + unchanged.quickInputService.result = ' Grill and Plan '; + await unchanged.handler(unchanged.instantiationService, { session: unchanged.session, chat: unchanged.peerChat }); + + assert.deepStrictEqual({ + inputCalls: { + main: main.quickInputService.calls, + unsupported: unsupported.quickInputService.calls, + untitled: untitled.quickInputService.calls, + cancelled: cancelled.quickInputService.calls, + blank: blank.quickInputService.calls, + unchanged: unchanged.quickInputService.calls, + }, + renamedChatCounts: [ + main, + unsupported, + untitled, + cancelled, + blank, + unchanged, + ].map(harness => harness.managementService.renamedChats.length), + }, { + inputCalls: { + main: 0, + unsupported: 0, + untitled: 0, + cancelled: 1, + blank: 1, + unchanged: 1, + }, + renamedChatCounts: [0, 0, 0, 0, 0, 0], + }); + }); + + test('captures the peer target and fails closed if it disappears while Quick Input is open', async () => { + const harness = createChatHarness(); + const input = new DeferredPromise(); + harness.quickInputService.inputHandler = async () => input.p; + + const rename = harness.handler(harness.instantiationService, { session: harness.session, chat: harness.peerChat }); + harness.chats.set([harness.mainChat], undefined); + input.complete('Renamed Peer'); + await rename; + + assert.deepStrictEqual(harness.managementService.renamedChats, []); + }); + + test('uses and captures the focused group chat while the session active chat is stale', async () => { + const harness = createChatHarness(); + const input = new DeferredPromise(); + harness.quickInputService.inputHandler = async () => input.p; + harness.activeChat.set(harness.mainChat, undefined); + + const rename = harness.handler(harness.instantiationService); + harness.focusedChat.set(harness.otherPeerChat, undefined); + input.complete('Renamed Peer'); + await rename; + + assert.deepStrictEqual(harness.managementService.renamedChats, [{ + session: harness.activeSession, + chatResource: harness.peerChat.resource, + title: 'Renamed Peer', + }]); + }); + + test('propagates provider errors', async () => { + const harness = createChatHarness(); + harness.quickInputService.result = 'Renamed Peer'; + harness.managementService.renameChatError = new Error('rename chat failed'); + + await assert.rejects( + async () => { + await harness.handler(harness.instantiationService, { session: harness.session, chat: harness.peerChat }); + }, + harness.managementService.renameChatError, + ); + }); + }); + suite('session header action', () => { function createHeaderHarness(inlineRename: boolean | undefined) { const instantiationService = disposables.add(new TestInstantiationService()); @@ -331,8 +510,11 @@ suite('Sessions rename', () => { assert.deepStrictEqual({ hasDoubleClick: content.includes('double-click its title'), hasContextMenu: content.includes('open its context menu'), - hasChatFocus: content.includes('chat transcript or input'), - hasRenameKeybinding: content.includes(``), + hasMainChatFocus: content.includes('main chat transcript or input'), + hasPeerChatFocus: content.includes('non-main chat') && content.includes('nested row'), + scopesChatRenameToAvailability: content.includes('When Rename is available for a non-main chat'), + hasSessionRenameKeybinding: content.includes(``), + hasChatRenameKeybinding: content.includes(``), hasArchiveKeybinding: content.includes(``), hasPermanentDelete: content.includes('open its context menu and choose Delete'), hasDevContainerAvailability: content.includes('Docker is available') && content.includes('selected local folder contains a Dev Container configuration'), @@ -344,8 +526,11 @@ suite('Sessions rename', () => { }, { hasDoubleClick: true, hasContextMenu: true, - hasChatFocus: true, - hasRenameKeybinding: true, + hasMainChatFocus: true, + hasPeerChatFocus: true, + scopesChatRenameToAvailability: true, + hasSessionRenameKeybinding: true, + hasChatRenameKeybinding: true, hasArchiveKeybinding: true, hasPermanentDelete: true, hasDevContainerAvailability: true, diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsSessionManagementActions.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsSessionManagementActions.test.ts index da485c4e86bc4..bd00f7b04a54c 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsSessionManagementActions.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsSessionManagementActions.test.ts @@ -8,7 +8,9 @@ import { decodeKeybinding } from '../../../../../base/common/keybindings.js'; import { KeyCode, KeyMod } from '../../../../../base/common/keyCodes.js'; import { constObservable } from '../../../../../base/common/observable.js'; import { OperatingSystem } from '../../../../../base/common/platform.js'; -import { upcastPartial } from '../../../../../base/test/common/mock.js'; +import { extUri } from '../../../../../base/common/resources.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { mock, upcastPartial } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { CommandsRegistry } from '../../../../../platform/commands/common/commands.js'; import { ContextKeyValue, IContext } from '../../../../../platform/contextkey/common/contextkey.js'; @@ -17,17 +19,20 @@ import { TestInstantiationService } from '../../../../../platform/instantiation/ import { KeybindingsRegistry, KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; import { RawWorkbenchListFocusContextKey } from '../../../../../platform/list/browser/listService.js'; import { IQuickInputService } from '../../../../../platform/quickinput/common/quickInput.js'; +import { IUriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentity.js'; import { FocusedViewContext, IsSessionsWindowContext } from '../../../../../workbench/common/contextkeys.js'; import { IView } from '../../../../../workbench/common/views.js'; import { ChatContextKeys } from '../../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; import { IViewsService } from '../../../../../workbench/services/views/common/viewsService.js'; -import { ARCHIVE_SESSION_COMMAND_ID, RENAME_SESSION_COMMAND_ID } from '../../../../common/sessionCommands.js'; -import { SessionActiveChatIsDeletableContext, SessionSupportsRenameContext, SessionsFocusContext } from '../../../../common/contextkeys.js'; +import { ARCHIVE_SESSION_COMMAND_ID, RENAME_CHAT_COMMAND_ID, RENAME_SESSION_COMMAND_ID } from '../../../../common/sessionCommands.js'; +import { SessionActiveChatIsDeletableContext, SessionFocusedChatIsRenameTargetContext, SessionSupportsRenameContext, SessionsFocusContext } from '../../../../common/contextkeys.js'; +import { ISessionsPartService } from '../../../../services/sessions/browser/sessionsPartService.js'; +import { SessionView } from '../../../../browser/parts/sessionView.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; import { IActiveSession, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; -import { ISession } from '../../../../services/sessions/common/session.js'; +import { ChatInteractivity, IChat, ISession, SessionStatus } from '../../../../services/sessions/common/session.js'; import { ArchiveSessionAction } from '../../browser/views/sessionsViewActions.js'; -import { SessionsList } from '../../browser/views/sessionsList.js'; +import { ISessionChatItem, SessionsList, SessionsListFocusedChatItemContext } from '../../browser/views/sessionsList.js'; import { SessionsView, SessionsViewId } from '../../browser/views/sessionsView.js'; import { createTestSession, TestSessionsManagementService } from './sessionsListTestUtils.js'; import '../../browser/sessionsActions.js'; @@ -49,11 +54,13 @@ suite('Sessions - Session management actions', () => { test('scopes Rename and Archive keybindings to their Agents Window surfaces', () => { const renameRule = getKeybindingRule(RENAME_SESSION_COMMAND_ID, KeyCode.F2); + const renameChatRule = getKeybindingRule(RENAME_CHAT_COMMAND_ID, KeyCode.F2); const archiveSessionRule = getKeybindingRule(ARCHIVE_SESSION_COMMAND_ID, KeyCode.Delete); const archiveSessionMacRule = getKeybindingRule(ARCHIVE_SESSION_COMMAND_ID, KeyMod.CtrlCmd | KeyCode.Backspace, OperatingSystem.Macintosh); const deleteSessionRule = getKeybindingRule('sessionsViewPane.deleteSession', KeyCode.Delete); const deleteChatRule = getKeybindingRule(DELETE_CHAT_COMMAND_ID, KeyCode.Delete); assert.ok(renameRule?.when); + assert.ok(renameChatRule?.when); assert.ok(archiveSessionRule?.when); assert.ok(archiveSessionMacRule?.when); assert.ok(deleteChatRule?.when); @@ -64,22 +71,35 @@ suite('Sessions - Session management actions', () => { ...sessionsWindow, [FocusedViewContext.key]: SessionsViewId, [RawWorkbenchListFocusContextKey.key]: true, + [SessionsListFocusedChatItemContext.key]: false, }; const chatTranscript = { ...sessionsWindow, [ChatContextKeys.inChatSession.key]: true, [SessionSupportsRenameContext.key]: true, [SessionsFocusContext.key]: true, + [SessionFocusedChatIsRenameTargetContext.key]: false, }; + const peerChatTranscript = { ...chatTranscript, [SessionFocusedChatIsRenameTargetContext.key]: true }; + const nestedChat = { ...sessionsList, [SessionsListFocusedChatItemContext.key]: true }; assert.deepStrictEqual({ renameWeight: renameRule.weight1, - renameInList: evaluate(renameRule, sessionsList), + renameChatWeight: renameChatRule.weight1, + renameSessionRow: evaluate(renameRule, sessionsList), + renameChatOnSessionRow: evaluate(renameChatRule, sessionsList), + renameNestedChatAsSession: evaluate(renameRule, nestedChat), + renameNestedChat: evaluate(renameChatRule, nestedChat), renameInListFindInput: evaluate(renameRule, { ...sessionsList, [InputFocusedContext.key]: true }), - renameInTranscript: evaluate(renameRule, chatTranscript), - renameInChatInput: evaluate(renameRule, { ...chatTranscript, [ChatContextKeys.inChatInput.key]: true, [InputFocusedContext.key]: true }), - renameUnsupportedChat: evaluate(renameRule, { ...chatTranscript, [SessionSupportsRenameContext.key]: false }), + renameChatInListFindInput: evaluate(renameChatRule, { ...nestedChat, [InputFocusedContext.key]: true }), + renameMainTranscriptAsSession: evaluate(renameRule, chatTranscript), + renameMainTranscriptAsChat: evaluate(renameChatRule, chatTranscript), + renamePeerTranscriptAsSession: evaluate(renameRule, peerChatTranscript), + renamePeerTranscriptAsChat: evaluate(renameChatRule, peerChatTranscript), + renamePeerChatInput: evaluate(renameChatRule, { ...peerChatTranscript, [ChatContextKeys.inChatInput.key]: true, [InputFocusedContext.key]: true }), + renameUnsupportedPeerAsChat: evaluate(renameChatRule, { ...peerChatTranscript, [SessionSupportsRenameContext.key]: false }), renameOutsideAgentsWindow: evaluate(renameRule, { [ChatContextKeys.inChatSession.key]: true, [SessionSupportsRenameContext.key]: true }), + renameChatOutsideAgentsWindow: evaluate(renameChatRule, { [ChatContextKeys.inChatSession.key]: true, [SessionFocusedChatIsRenameTargetContext.key]: true }), archiveWeight: archiveSessionRule.weight1, archiveMacWeight: archiveSessionMacRule.weight1, archiveInList: evaluate(archiveSessionRule, sessionsList), @@ -91,12 +111,21 @@ suite('Sessions - Session management actions', () => { deleteChatInInput: evaluate(deleteChatRule, { ...chatTranscript, [SessionActiveChatIsDeletableContext.key]: true, [InputFocusedContext.key]: true }), }, { renameWeight: KeybindingWeight.SessionsContrib, - renameInList: true, + renameChatWeight: KeybindingWeight.SessionsContrib + 10, + renameSessionRow: true, + renameChatOnSessionRow: false, + renameNestedChatAsSession: true, + renameNestedChat: true, renameInListFindInput: false, - renameInTranscript: true, - renameInChatInput: true, - renameUnsupportedChat: false, + renameChatInListFindInput: false, + renameMainTranscriptAsSession: true, + renameMainTranscriptAsChat: false, + renamePeerTranscriptAsSession: true, + renamePeerTranscriptAsChat: true, + renamePeerChatInput: true, + renameUnsupportedPeerAsChat: true, renameOutsideAgentsWindow: false, + renameChatOutsideAgentsWindow: false, archiveWeight: KeybindingWeight.SessionsContrib, archiveMacWeight: KeybindingWeight.SessionsContrib, archiveInList: true, @@ -109,10 +138,13 @@ suite('Sessions - Session management actions', () => { }); }); - function createActionHarness(focusedSessions: readonly ISession[] | undefined, activeSession: ISession | undefined) { + function createActionHarness(focusedSessions: readonly ISession[] | undefined, activeSession: IActiveSession | undefined, focusedChat?: ISessionChatItem, focusedGroupChat?: IChat) { const instantiationService = disposables.add(new TestInstantiationService()); const managementService = new TestSessionsManagementService([]); - const sessionsControl = upcastPartial({ getFocusedSessions: () => focusedSessions }); + const sessionsControl = upcastPartial({ + getFocusedSessions: () => focusedSessions, + getFocusedChatItem: () => focusedChat, + }); const sessionsView = upcastPartial({ sessionsControl }); const getViewWithId = (id: string): T | null => id === SessionsViewId ? sessionsView as unknown as T : null; @@ -120,7 +152,15 @@ suite('Sessions - Session management actions', () => { instantiationService.stub(ISessionsService, upcastPartial({ activeSession: constObservable(activeSession ? upcastPartial(activeSession) : undefined), })); + instantiationService.stub(ISessionsPartService, new class extends mock() { + override getFocusedSessionView(): SessionView | undefined { + return focusedGroupChat && activeSession + ? upcastPartial({ getSession: () => activeSession, getFocusedChat: () => focusedGroupChat }) + : undefined; + } + }()); instantiationService.stub(ISessionsManagementService, managementService); + instantiationService.stub(IUriIdentityService, upcastPartial({ extUri })); instantiationService.stub(IQuickInputService, upcastPartial({ input: async () => 'Renamed', })); @@ -128,32 +168,55 @@ suite('Sessions - Session management actions', () => { return { instantiationService, managementService }; } - test('routes keybinding invocations to the focused list session or active chat session', async () => { + test('routes session and chat rename commands to their focused targets', async () => { const listSession = createTestSession('List').session; - const listActiveSession = createTestSession('Other active').session; + const listActiveSession = upcastPartial(createTestSession('Other active').session); const listHarness = createActionHarness([listSession], listActiveSession); - const activeSession = createTestSession('Active chat').session; - const chatHarness = createActionHarness(undefined, activeSession); + const base = createTestSession('Explore Jitter Issue').session; + const mainChat = base.mainChat.get(); + const peerChat = upcastPartial({ + resource: URI.parse('test-chat:///grill-and-plan'), + title: constObservable('Grill and Plan'), + status: constObservable(SessionStatus.Completed), + interactivity: constObservable(ChatInteractivity.Full), + capabilities: constObservable({ canRename: true, canDelete: true }), + }); + const activeSession = upcastPartial({ + ...base, + chats: constObservable([mainChat, peerChat]), + mainChat: constObservable(mainChat), + activeChat: constObservable(peerChat), + }); + const chatHarness = createActionHarness(undefined, activeSession, undefined, peerChat); + const nestedChatHarness = createActionHarness([], listActiveSession, { session: activeSession, chat: peerChat }); const archiveSession = createTestSession('Archive target').session; const archivedSession = createTestSession('Already archived', { isArchived: true }).session; const archiveHarness = createActionHarness([archiveSession, archivedSession], listActiveSession); const inactiveArchiveHarness = createActionHarness(undefined, listActiveSession); - const renameHandler = CommandsRegistry.getCommand(RENAME_SESSION_COMMAND_ID)?.handler; - assert.ok(renameHandler); + const renameSessionHandler = CommandsRegistry.getCommand(RENAME_SESSION_COMMAND_ID)?.handler; + const renameChatHandler = CommandsRegistry.getCommand(RENAME_CHAT_COMMAND_ID)?.handler; + assert.ok(renameSessionHandler); + assert.ok(renameChatHandler); - await renameHandler(listHarness.instantiationService); - await renameHandler(chatHarness.instantiationService); + await renameSessionHandler(listHarness.instantiationService); + await renameSessionHandler(chatHarness.instantiationService); + await renameChatHandler(chatHarness.instantiationService); + await renameChatHandler(nestedChatHarness.instantiationService); await archiveHarness.instantiationService.invokeFunction(accessor => new ArchiveSessionAction().run(accessor)); await inactiveArchiveHarness.instantiationService.invokeFunction(accessor => new ArchiveSessionAction().run(accessor)); assert.deepStrictEqual({ listRename: listHarness.managementService.renamed.map(({ session, title }) => ({ sessionId: session.sessionId, title })), - chatRename: chatHarness.managementService.renamed.map(({ session, title }) => ({ sessionId: session.sessionId, title })), + sessionRenameFromChat: chatHarness.managementService.renamed.map(({ session, title }) => ({ sessionId: session.sessionId, title })), + activeChatRename: chatHarness.managementService.renamedChats.map(({ session, chatResource, title }) => ({ sessionId: session.sessionId, chatResource: chatResource.toString(), title })), + nestedChatRename: nestedChatHarness.managementService.renamedChats.map(({ session, chatResource, title }) => ({ sessionId: session.sessionId, chatResource: chatResource.toString(), title })), archived: archiveHarness.managementService.archived.map(session => session.sessionId), inactiveArchived: inactiveArchiveHarness.managementService.archived, }, { listRename: [{ sessionId: listSession.sessionId, title: 'Renamed' }], - chatRename: [{ sessionId: activeSession.sessionId, title: 'Renamed' }], + sessionRenameFromChat: [{ sessionId: activeSession.sessionId, title: 'Renamed' }], + activeChatRename: [{ sessionId: activeSession.sessionId, chatResource: peerChat.resource.toString(), title: 'Renamed' }], + nestedChatRename: [{ sessionId: activeSession.sessionId, chatResource: peerChat.resource.toString(), title: 'Renamed' }], archived: [archiveSession.sessionId], inactiveArchived: [], }); diff --git a/src/vs/sessions/services/chatView/browser/chatViewFactory.ts b/src/vs/sessions/services/chatView/browser/chatViewFactory.ts index e2b811dcd4f51..35ad1edf29905 100644 --- a/src/vs/sessions/services/chatView/browser/chatViewFactory.ts +++ b/src/vs/sessions/services/chatView/browser/chatViewFactory.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; +import { createDecorator, IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { AbstractChatView, IChatViewOptions } from '../../../browser/parts/chatView.js'; export const IChatViewFactory = createDecorator('chatViewFactory'); @@ -22,10 +22,10 @@ export interface IChatViewFactory { * Creates a "new chat" view that lets the user pick a workspace and * start a new chat. This is the view the grid is seeded with on startup. */ - createNewChatView(isNewChatInSession: boolean, options: IChatViewOptions): AbstractChatView; + createNewChatView(isNewChatInSession: boolean, options: IChatViewOptions, instantiationService?: IInstantiationService): AbstractChatView; /** * Creates a chat view that hosts a chat widget for an active session. */ - createChatView(): AbstractChatView; + createChatView(instantiationService?: IInstantiationService): AbstractChatView; } diff --git a/src/vs/sessions/services/sessions/browser/sessionsPartService.ts b/src/vs/sessions/services/sessions/browser/sessionsPartService.ts index af7dd7d2a3151..f81f1a94b1d38 100644 --- a/src/vs/sessions/services/sessions/browser/sessionsPartService.ts +++ b/src/vs/sessions/services/sessions/browser/sessionsPartService.ts @@ -70,6 +70,11 @@ export interface ISessionsPartService { */ getSessionView(sessionId: string | undefined): SessionView | undefined; + /** + * Returns the session view that currently contains DOM focus. + */ + getFocusedSessionView(): SessionView | undefined; + /** * Returns the progress indicator for the sessions part, which drives the * progress bar shown at the top of the part's content area. diff --git a/src/vs/sessions/services/sessions/browser/sessionsService.ts b/src/vs/sessions/services/sessions/browser/sessionsService.ts index 72b52483b4323..fce727cd2d9bd 100644 --- a/src/vs/sessions/services/sessions/browser/sessionsService.ts +++ b/src/vs/sessions/services/sessions/browser/sessionsService.ts @@ -62,6 +62,8 @@ export interface IOpenNewSessionOptions extends ICreateNewSessionOptions { * (restoring any pending draft). */ readonly folderUri?: URI; + /** Cancel startup session restoration so this new-session navigation wins. */ + readonly cancelRestore?: boolean; } /** @@ -343,8 +345,8 @@ export class SessionsService extends Disposable implements ISessionsService { * Cancellation for the in-flight {@link restoreVisibleSessions}. Kept * separate from {@link _openSessionCts} so that additive new-session * operations (the new-chat composer eagerly creating a draft on startup) - * do not abort restoring the previously visible grid. Only an explicit - * navigation to a specific session cancels a restore. + * do not abort restoring the previously visible grid. Explicit navigation + * to a session, or a new-session handoff with `cancelRestore`, cancels it. */ private readonly _restoreCts = this._register(new MutableDisposable()); @@ -718,8 +720,8 @@ export class SessionsService extends Disposable implements ISessionsService { /** * Cancel an in-flight {@link restoreVisibleSessions}. Called when the user - * explicitly navigates to a specific session, so restore stops fighting - * the user's choice. Additive new-session operations do NOT call this. + * explicitly navigates to a session, including a new-session handoff that + * sets `cancelRestore`, so restore stops fighting the user's choice. */ private _cancelRestore(): void { // `cancel()` (not just `clear()`/dispose) so the in-flight restore's @@ -1050,6 +1052,9 @@ export class SessionsService extends Disposable implements ISessionsService { } private async _openNewSession(options: IOpenNewSessionOptions | undefined, token: CancellationToken, intent: SessionNavigationIntent): Promise { + if (options?.cancelRestore) { + this._cancelRestore(); + } const folderUri = options?.folderUri; if (folderUri) { // Single trust gate for every path that creates a concrete session for diff --git a/src/vs/sessions/services/sessions/common/session.ts b/src/vs/sessions/services/sessions/common/session.ts index 150141410cd5f..fdd399a20d4fd 100644 --- a/src/vs/sessions/services/sessions/common/session.ts +++ b/src/vs/sessions/services/sessions/common/session.ts @@ -476,11 +476,10 @@ export interface ISessionChangeset { /** * Invoke an operation declared in {@link operations}. `target` must be - * provided for resource-scoped operations and omitted for changeset- - * scoped ones — implementations are expected to validate this against - * the corresponding {@link ISessionChangesetOperation.scopes}. + * provided for resource-scoped operations and omitted for changeset-scoped + * ones. `_meta` carries optional operation-specific request metadata. */ - invokeOperation(operationId: string, target?: ISessionChangesetOperationTarget): Promise; + invokeOperation(operationId: string, target?: ISessionChangesetOperationTarget, _meta?: Record): Promise; /** * Sets the review state for a list of resources when the changeset supports review. diff --git a/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts b/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts index 05d954673f50d..d912e48affe8d 100644 --- a/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts +++ b/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts @@ -817,6 +817,57 @@ suite('SessionsManagementService', () => { }); }); + test('RACE: an explicit new-session handoff cancels startup restoration', async () => { + const targetSession = stubSession({ sessionId: 'target', providerId: 'test' }); + const newSession = stubSession({ sessionId: 'fresh', providerId: 'test' }); + const onDidChangeSessions = disposables.add(new Emitter()); + let sessions: ISession[] = []; + const provider = new class extends TestSessionsProvider { + override readonly onDidChangeSessions = onDidChangeSessions.event; + constructor() { super(targetSession); } + override getSessions(): ISession[] { return sessions; } + override createNewSession(): ISession { return newSession; } + override resolveWorkspace(): ISessionWorkspace { + return { folders: [], requiresWorkspaceTrust: false, isVirtualWorkspace: false } as unknown as ISessionWorkspace; + } + }; + const storage = disposables.add(new InMemoryStorageService()); + storage.store( + 'agentSessions.activeSessionStates', + JSON.stringify([{ sessionResource: targetSession.resource.toString(), visibleOrder: 0, isActive: true }]), + 1 /* StorageScope.WORKSPACE */, + 1 /* StorageTarget.MACHINE */, + ); + const instantiationService = disposables.add(new TestInstantiationService()); + instantiationService.stub(IStorageService, storage); + instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(IContextKeyService, disposables.add(new MockContextKeyService())); + instantiationService.stub(ISessionsProvidersService, new TestSessionsProvidersService([provider])); + instantiationService.stub(IUriIdentityService, { extUri: extUriBiasedIgnorePathCase }); + instantiationService.stub(IChatWidgetService, new TestChatWidgetService()); + instantiationService.stub(IProgressService, new TestProgressService()); + instantiationService.stub(IChatService, new class extends mock() { + override readonly onDidSubmitRequest = Event.None; + }); + const service = disposables.add(instantiationService.createInstance(SessionsManagementService)); + const view = createView(instantiationService, service, disposables); + const restorePromise = view.restoreVisibleSessions(); + await Promise.resolve(); + + await view.openNewSession({ folderUri: URI.file('/folder'), cancelRestore: true }); + sessions = [targetSession]; + onDidChangeSessions.fire({ added: [targetSession], removed: [], changed: [] }); + await restorePromise; + + assert.deepStrictEqual({ + hasTarget: view.visibleSessions.get().some(session => session?.sessionId === 'target'), + active: view.activeSession.get()?.sessionId, + }, { + hasTarget: false, + active: 'fresh', + }); + }); + test.skip('openNewSession inherits the active session workspace when requested', async () => { const makeWorkspace = (uri: URI): ISessionWorkspace => ({ uri, diff --git a/src/vs/sessions/services/sessions/test/common/sessionContextKeys.test.ts b/src/vs/sessions/services/sessions/test/common/sessionContextKeys.test.ts index d70400ef3a5b1..1820760360bbf 100644 --- a/src/vs/sessions/services/sessions/test/common/sessionContextKeys.test.ts +++ b/src/vs/sessions/services/sessions/test/common/sessionContextKeys.test.ts @@ -247,4 +247,5 @@ suite('setSessionContextKeys - side chat', () => { withToolChatHasSideChats: false, }); }); + }); diff --git a/src/vs/sessions/test/browser/chatGroupsView.test.ts b/src/vs/sessions/test/browser/chatGroupsView.test.ts index f91b0ad60e7e5..99046a3919d61 100644 --- a/src/vs/sessions/test/browser/chatGroupsView.test.ts +++ b/src/vs/sessions/test/browser/chatGroupsView.test.ts @@ -14,12 +14,17 @@ import { URI } from '../../../base/common/uri.js'; import { mock } from '../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../base/test/common/utils.js'; import { runWithFakedTimers } from '../../../base/test/common/timeTravelScheduler.js'; +import { TestConfigurationService } from '../../../platform/configuration/test/common/testConfigurationService.js'; +import { ContextKeyService } from '../../../platform/contextkey/browser/contextKeyService.js'; +import { IContextKeyService } from '../../../platform/contextkey/common/contextkey.js'; +import { IInstantiationService } from '../../../platform/instantiation/common/instantiation.js'; import { TestInstantiationService } from '../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { DEFAULT_EDITOR_PART_OPTIONS } from '../../../workbench/browser/parts/editor/editor.js'; import { IEditorGroupsService } from '../../../workbench/services/editor/common/editorGroupsService.js'; import { workbenchInstantiationService } from '../../../workbench/test/browser/workbenchTestServices.js'; -import { AbstractChatView, ChatViewKind } from '../../browser/parts/chatView.js'; +import { AbstractChatView, ChatViewKind, IChatViewOptions } from '../../browser/parts/chatView.js'; import { ChatGroupsView } from '../../browser/parts/chatGroupsView.js'; +import { SessionFocusedChatIsRenameTargetContext } from '../../common/contextkeys.js'; import { type IAgentHostAutoConnect, type IAgentHostConnectProgress, IAgentHostSessionsProvider } from '../../common/agentHostSessionsProvider.js'; import { IChatViewFactory } from '../../services/chatView/browser/chatViewFactory.js'; import { ISessionsProvidersService } from '../../services/sessions/browser/sessionsProvidersService.js'; @@ -35,8 +40,12 @@ class TestChatView extends AbstractChatView { layoutCount = 0; primary = false; - constructor(readonly kind: ChatViewKind) { + constructor( + readonly kind: ChatViewKind, + @IContextKeyService contextKeyService: IContextKeyService, + ) { super(); + this._register(contextKeyService.createScoped(this.element)); this.element.dataset.kind = kind; this.element.appendChild(this._focusTarget); } @@ -61,16 +70,17 @@ class TestChatView extends AbstractChatView { class TestChatViewFactory extends mock() { readonly views: TestChatView[] = []; - override createNewChatView(isNewChatInSession: boolean): AbstractChatView { - return this._createView(isNewChatInSession ? 'newChatInSession' : 'newSession'); + override createNewChatView(isNewChatInSession: boolean, _options: IChatViewOptions, instantiationService?: IInstantiationService): AbstractChatView { + return this._createView(isNewChatInSession ? 'newChatInSession' : 'newSession', instantiationService); } - override createChatView(): AbstractChatView { - return this._createView('chat'); + override createChatView(instantiationService?: IInstantiationService): AbstractChatView { + return this._createView('chat', instantiationService); } - private _createView(kind: ChatViewKind): TestChatView { - const view = new TestChatView(kind); + private _createView(kind: ChatViewKind, instantiationService?: IInstantiationService): TestChatView { + assert.ok(instantiationService); + const view = instantiationService.createInstance(TestChatView, kind); this.views.push(view); return view; } @@ -222,6 +232,7 @@ function createHarness(disposables: Pick, tabsReplaceHea const instantiationService = workbenchInstantiationService(undefined, store); const sessionsService = new TestSessionsService(); const chatViewFactory = new TestChatViewFactory(); + instantiationService.stub(IContextKeyService, store.add(new ContextKeyService(new TestConfigurationService()))); const sessionsProvidersService = new TestSessionsProvidersService(); instantiationService.stub(IChatViewFactory, chatViewFactory); instantiationService.stub(IEditorGroupsService, new class extends mock() { @@ -351,6 +362,69 @@ suite('Sessions - ChatGroupsView', () => { }); }); + test('focused group publishes its rename target before the session active chat updates', async () => { + const { instantiationService, sessionsService, view } = createHarness(disposables); + const main = createChat('main'); + const secondary = createChat('secondary'); + const session = new TestActiveSession([main, secondary]); + sessionsService.activeSession.set(session, undefined); + view.setSession(session, options); + view.splitChatToSide(secondary.resource); + view.focusAdjacentGroup('previous'); + + const gate = new DeferredPromise(); + sessionsService.openChatGate = gate.p; + view.focusAdjacentGroup('next'); + + const contextKeyService = instantiationService.get(IContextKeyService); + const focusedContext = contextKeyService.getContext(mainWindow.document.activeElement); + const beforeOpenSettles = { + sessionActiveChat: session.activeChat.get().resource.toString(), + focusedChat: view.getFocusedChat()?.resource.toString(), + focusedChatClaimsRename: focusedContext.getValue(SessionFocusedChatIsRenameTargetContext.key), + }; + gate.complete(); + await gate.p; + await Promise.resolve(); + + assert.deepStrictEqual({ + beforeOpenSettles, + sessionActiveChatAfterOpen: session.activeChat.get().resource.toString(), + }, { + beforeOpenSettles: { + sessionActiveChat: main.resource.toString(), + focusedChat: secondary.resource.toString(), + focusedChatClaimsRename: true, + }, + sessionActiveChatAfterOpen: secondary.resource.toString(), + }); + }); + + test('focused DOM group remains the rename target when session reconciliation promotes another group', async () => { + const { instantiationService, sessionsService, view } = createHarness(disposables); + const main = createChat('main'); + const secondary = createChat('secondary'); + const session = new TestActiveSession([main, secondary]); + sessionsService.activeSession.set(session, undefined); + view.setSession(session, options); + view.splitChatToSide(secondary.resource); + view.focusAdjacentGroup('previous'); + + await sessionsService.openChat(session, secondary.resource); + + const contextKeyService = instantiationService.get(IContextKeyService); + const focusedContext = contextKeyService.getContext(mainWindow.document.activeElement); + assert.deepStrictEqual({ + sessionActiveChat: session.activeChat.get().resource.toString(), + focusedChat: view.getFocusedChat()?.resource.toString(), + focusedChatClaimsRename: focusedContext.getValue(SessionFocusedChatIsRenameTargetContext.key), + }, { + sessionActiveChat: secondary.resource.toString(), + focusedChat: main.resource.toString(), + focusedChatClaimsRename: false, + }); + }); + test('restoration settles when an already-loaded catalog no longer contains a saved chat', () => { const { view } = createHarness(disposables); const main = createChat('main'); @@ -1087,7 +1161,9 @@ suite('Sessions - ChatGroupsView', () => { remoteConnectionStatus.set({ kind: 'reconnecting' }, undefined); remoteConnectionStatus.set({ kind: 'connected' }, undefined); - await timeout(1_000); + // Past the delay, so this proves the settled connection suppresses the + // banner rather than the threshold simply not having elapsed. + await timeout(6_000); assert.deepStrictEqual(readBanner(view), { visible: false, message: 'This chat is read-only', action: undefined }); }); @@ -1102,9 +1178,9 @@ suite('Sessions - ChatGroupsView', () => { const session = new TestActiveSession([chat], undefined, true, provider.id, { kind: 'reconnecting' }); view.setSession(session, options); - await timeout(500); + await timeout(3_000); chat.status.set(SessionStatus.Error, undefined); - await timeout(500); + await timeout(3_000); assert.deepStrictEqual(readBanner(view), { visible: true, @@ -1119,16 +1195,16 @@ suite('Sessions - ChatGroupsView', () => { const { chatViewFactory, sessionsProvidersService, view } = createHarness(disposables); const provider = new TestAgentHostProvider(); sessionsProvidersService.provider = provider; - const session = new TestActiveSession([createChat('main')], undefined, true, provider.id, { kind: 'reconnecting', nextAttemptAt: Date.now() + 6_000 }); + const session = new TestActiveSession([createChat('main')], undefined, true, provider.id, { kind: 'reconnecting', nextAttemptAt: Date.now() + 12_000 }); view.setSession(session, options); chatViewFactory.views[chatViewFactory.views.length - 1].hasVisibleTranscriptContent.set(true, undefined); - await timeout(1_000); + await timeout(5_500); const banner = readBanner(view); view.element.querySelector('.session-readonly-banner-action-link')?.click(); assert.deepStrictEqual({ banner, reconnectNowCalls: provider.reconnectNowCalls }, { - banner: { visible: true, message: 'Reconnecting to WSL: Ubuntu in 5s', action: 'Try Now' }, + banner: { visible: true, message: 'Reconnecting to WSL: Ubuntu in 7s', action: 'Try Now' }, reconnectNowCalls: 1, }); }); @@ -1139,21 +1215,47 @@ suite('Sessions - ChatGroupsView', () => { const { chatViewFactory, sessionsProvidersService, view } = createHarness(disposables); const provider = new TestAgentHostProvider(); sessionsProvidersService.provider = provider; - const session = new TestActiveSession([createChat('main')], undefined, true, provider.id, { kind: 'reconnecting', nextAttemptAt: Date.now() + 7_000 }); + const session = new TestActiveSession([createChat('main')], undefined, true, provider.id, { kind: 'reconnecting', nextAttemptAt: Date.now() + 13_000 }); view.setSession(session, options); chatViewFactory.views[chatViewFactory.views.length - 1].hasVisibleTranscriptContent.set(true, undefined); - await timeout(1_000); + await timeout(5_500); const beforeTick = readBanner(view); await timeout(1_000); assert.deepStrictEqual({ beforeTick, afterTick: readBanner(view) }, { - beforeTick: { visible: true, message: 'Reconnecting to WSL: Ubuntu in 6s', action: 'Try Now' }, - afterTick: { visible: true, message: 'Reconnecting to WSL: Ubuntu in 5s', action: 'Try Now' }, + beforeTick: { visible: true, message: 'Reconnecting to WSL: Ubuntu in 8s', action: 'Try Now' }, + afterTick: { visible: true, message: 'Reconnecting to WSL: Ubuntu in 7s', action: 'Try Now' }, }); }); }); + test('stays quiet while a flapping transport keeps healing itself', async () => { + await runWithFakedTimers({ useFakeTimers: true }, async () => { + const { chatViewFactory, sessionsProvidersService, view } = createHarness(disposables); + const provider = new TestAgentHostProvider(); + sessionsProvidersService.provider = provider; + const session = new TestActiveSession([createChat('main')], undefined, true, provider.id, { kind: 'connected' }); + const remoteConnectionStatus = session.remoteConnectionStatus; + assert.ok(remoteConnectionStatus); + view.setSession(session, options); + chatViewFactory.views[chatViewFactory.views.length - 1].hasVisibleTranscriptContent.set(true, undefined); + + // Every outage heals well inside the delay, so none is worth a banner. + const banners: boolean[] = []; + for (let i = 0; i < 5; i++) { + remoteConnectionStatus.set({ kind: 'reconnecting' }, undefined); + await timeout(2_100); + banners.push(readBanner(view).visible); + remoteConnectionStatus.set({ kind: 'connected' }, undefined); + await timeout(3_700); + banners.push(readBanner(view).visible); + } + + assert.deepStrictEqual(banners, [false, false, false, false, false, false, false, false, false, false]); + }); + }); + test('shows a plain reconnecting banner while a reconnect attempt is in flight', async () => { await runWithFakedTimers({ useFakeTimers: true }, async () => { const { chatViewFactory, sessionsProvidersService, view } = createHarness(disposables); @@ -1163,7 +1265,7 @@ suite('Sessions - ChatGroupsView', () => { view.setSession(session, options); chatViewFactory.views[chatViewFactory.views.length - 1].hasVisibleTranscriptContent.set(true, undefined); - await timeout(1_000); + await timeout(6_000); assert.deepStrictEqual(readBanner(view), { visible: true, @@ -1196,7 +1298,7 @@ suite('Sessions - ChatGroupsView', () => { } remoteConnectionStatus.set({ kind: 'reconnecting' }, undefined); - await timeout(1_000); + await timeout(6_000); assert.deepStrictEqual({ connectCalls: provider.connectCalls, banner: readBanner(view) }, { connectCalls: 1, diff --git a/src/vs/sessions/test/browser/permissionPickerList.fixture.ts b/src/vs/sessions/test/browser/permissionPickerList.fixture.ts index f5759e41ffe7c..3a99d1cb3ec4a 100644 --- a/src/vs/sessions/test/browser/permissionPickerList.fixture.ts +++ b/src/vs/sessions/test/browser/permissionPickerList.fixture.ts @@ -12,7 +12,7 @@ function render(context: ComponentFixtureContext, checked: boolean, width = 320) renderPermissionPickerList(context, { showStandaloneSandboxToggle: true, sandboxingEnabled: checked, width }); const row = context.container.querySelector('.has-standalone-toggle'); - const toggle = row?.querySelector('.action-list-inline-switch'); + const toggle = row?.querySelector('.monaco-switch'); if (!row || !toggle) { throw new Error('Expected a standalone sandbox toggle row'); } diff --git a/src/vs/sessions/test/browser/remoteHostUnavailableEmptyState.fixture.ts b/src/vs/sessions/test/browser/remoteHostUnavailableEmptyState.fixture.ts index 00aa9d3efa572..93198287022c5 100644 --- a/src/vs/sessions/test/browser/remoteHostUnavailableEmptyState.fixture.ts +++ b/src/vs/sessions/test/browser/remoteHostUnavailableEmptyState.fixture.ts @@ -5,6 +5,7 @@ import { ComponentFixtureContext, defineComponentFixture, defineThemedFixtureGroup } from '../../../workbench/test/browser/componentFixtures/fixtureUtils.js'; import { type IRemoteHostUnavailableEmptyStateContent, RemoteHostUnavailableEmptyState } from '../../browser/parts/remoteHostUnavailableEmptyState.js'; +import { AGENTS_CENTERED_CONTENT_MAX_WIDTH } from '../../common/layoutConstants.js'; export default defineThemedFixtureGroup({ path: 'sessions/remoteHostUnavailable/' }, { HostNotRunning: defineComponentFixture({ @@ -47,7 +48,8 @@ export default defineThemedFixtureGroup({ path: 'sessions/remoteHostUnavailable/ function renderUnavailableState({ container, disposableStore }: ComponentFixtureContext, content: IRemoteHostUnavailableEmptyStateContent): void { container.style.position = 'relative'; - container.style.width = 'var(--session-view-centered-content-max-width)'; + container.style.width = `${AGENTS_CENTERED_CONTENT_MAX_WIDTH}px`; + container.style.setProperty('--session-view-centered-content-max-width', container.style.width); container.style.height = 'calc(var(--vscode-spacing-size400) * 6)'; container.style.backgroundColor = 'var(--vscode-editorWidget-background)'; diff --git a/src/vs/sessions/test/browser/workbench.test.ts b/src/vs/sessions/test/browser/workbench.test.ts index ee013c65ef024..ec527cc894936 100644 --- a/src/vs/sessions/test/browser/workbench.test.ts +++ b/src/vs/sessions/test/browser/workbench.test.ts @@ -22,6 +22,7 @@ import { GroupDirection, GroupOrientation } from '../../../workbench/services/ed import { SESSIONS_LIST_MINIMUM_WIDTH } from '../../browser/parts/sidebarPart.js'; import { Menus } from '../../browser/menus.js'; import { DEFAULT_NOTIFICATION_ROW_HEIGHT, onDidChangeNotificationRowHeight, setNotificationRowHeight } from '../../../workbench/browser/parts/notifications/notificationsViewer.js'; +import { NullTelemetryServiceShape } from '../../../platform/telemetry/common/telemetryUtils.js'; interface IViewSize { width: number; height: number } @@ -31,6 +32,20 @@ class TestDockedEditorInput extends DockedEditorInput { override get resource(): undefined { return undefined; } } +function isTelemetryData(data: unknown): data is Record { + return typeof data === 'object' && data !== null; +} + +class TestTelemetryService extends NullTelemetryServiceShape { + readonly events: { readonly name: string; readonly data: Record }[] = []; + + override publicLog2(eventName?: string, data?: unknown): void { + if (eventName && isTelemetryData(data)) { + this.events.push({ name: eventName, data }); + } + } +} + suite('Sessions - Workbench', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -77,6 +92,7 @@ suite('Sessions - Workbench', () => { layoutPolicy: { isPhoneLayout: IObservable }; _register(disposable: T): T; }) => void; + const logWindowLayout = Reflect.get(Workbench.prototype, 'logWindowLayout') as (this: ITestWorkbench, telemetryService: TestTelemetryService) => void; // --- Harness ------------------------------------------------------------ @@ -379,6 +395,18 @@ suite('Sessions - Workbench', () => { // --- Notifications ------------------------------------------------------ + test('logs the selected Agents window layout', () => { + const telemetryService = new TestTelemetryService(); + + logWindowLayout.call(createHost(), telemetryService); + logWindowLayout.call(createHost({ single: true }), telemetryService); + + assert.deepStrictEqual(telemetryService.events, [ + { name: 'agents/windowLayout', data: { layout: 'classic' } }, + { name: 'agents/windowLayout', data: { layout: 'sidePane' } }, + ]); + }); + test('uses touch-sized notification rows on phone layouts', () => { setNotificationRowHeight(DEFAULT_NOTIFICATION_ROW_HEIGHT); const registeredDisposables = new DisposableStore(); diff --git a/src/vs/workbench/api/browser/mainThreadDataChannels.ts b/src/vs/workbench/api/browser/mainThreadDataChannels.ts index edb18aaf53a82..ef43b3e7d4e65 100644 --- a/src/vs/workbench/api/browser/mainThreadDataChannels.ts +++ b/src/vs/workbench/api/browser/mainThreadDataChannels.ts @@ -9,6 +9,7 @@ import { autorun, observableValue } from '../../../base/common/observable.js'; import { URI, UriComponents } from '../../../base/common/uri.js'; import { localize } from '../../../nls.js'; import { IDataChannelService, ILinkPresentation, ILinkPresentationProvider, ILinkPresentationService, ILinkPresentationWatcher, LinkPresentationKind, parseLinkPresentation } from '../../../platform/dataChannel/common/dataChannel.js'; +import { ILogService } from '../../../platform/log/common/log.js'; import { extHostNamedCustomer, IExtHostContext } from '../../services/extensions/common/extHostCustomers.js'; import { ExtHostContext, ExtHostDataChannelsShape, MainContext, MainThreadDataChannelsShape } from '../common/extHost.protocol.js'; @@ -25,6 +26,7 @@ export class MainThreadDataChannels extends Disposable implements MainThreadData extHostContext: IExtHostContext, @IDataChannelService private readonly _dataChannelService: IDataChannelService, @ILinkPresentationService private readonly _linkPresentationService: ILinkPresentationService, + @ILogService private readonly _logService: ILogService, ) { super(); this._proxy = extHostContext.getProxy(ExtHostContext.ExtHostDataChannels); @@ -47,6 +49,7 @@ export class MainThreadDataChannels extends Disposable implements MainThreadData $createLinkPresentationWatcher(handle: number, providerId: string, kind: LinkPresentationKind, resource: UriComponents): void { const watcher = this._linkPresentationService.createLinkPresentationWatcher(providerId, URI.revive(resource)); if (!watcher) { + this._logService.warn(`[MainThreadDataChannels] Link presentation provider '${providerId}' does not accept '${URI.revive(resource).toString(true)}'`); this._proxy.$acceptLinkPresentation(handle, { kind, status: { kind: 'error', label: localize('linkPresentation.unavailable', "Not available") }, diff --git a/src/vs/workbench/api/test/browser/mainThreadDataChannels.test.ts b/src/vs/workbench/api/test/browser/mainThreadDataChannels.test.ts index db0bd51cd049d..13a471148829b 100644 --- a/src/vs/workbench/api/test/browser/mainThreadDataChannels.test.ts +++ b/src/vs/workbench/api/test/browser/mainThreadDataChannels.test.ts @@ -44,6 +44,7 @@ suite('MainThreadDataChannels', () => { new TestConfigurationService(), store.add(new TestStorageService()), )), + new NullLogService(), )); mainThread.$createLinkPresentationWatcher(1, 'missing', 'pullRequest', URI.parse('https://example.com/pull/1')); @@ -103,6 +104,7 @@ suite('MainThreadDataChannels', () => { SingleProxyRPCProtocol(extHostProxy), store.add(new DataChannelService()), linkPresentationService, + new NullLogService(), )); const extHost = new ExtHostDataChannels(SingleProxyRPCProtocol(mainThread)); extHostHolder.value = extHost; @@ -180,6 +182,7 @@ suite('MainThreadDataChannels', () => { SingleProxyRPCProtocol(extHostProxy), store.add(new DataChannelService()), linkPresentationService, + new NullLogService(), )); const extHost = new ExtHostDataChannels(SingleProxyRPCProtocol(mainThread)); const extension = { 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 e48ef83bf1bdf..7a69461a8f94c 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts @@ -108,7 +108,7 @@ class BrowserEditorResolverContribution implements IWorkbenchContribution { } ); - for (const extension of ['html', 'htm']) { + for (const extension of ['html', 'htm', 'mhtml', 'mht']) { editorResolverService.registerEditor( `${Schemas.file}:/**/*.${extension}`, { 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 8c0dfd27b3bf3..471aed5624101 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/features/browserTabManagementFeatures.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserTabManagementFeatures.ts @@ -340,9 +340,9 @@ class OpenIntegratedBrowserAction extends Action2 { class OpenFileInIntegratedBrowserAction extends Action2 { constructor() { - const IS_LOCAL_HTML_FILE = ContextKeyExpr.and( + const IS_SUPPORTED_LOCAL_BROWSER_FILE = ContextKeyExpr.and( ResourceContextKey.Scheme.isEqualTo(Schemas.file), - ContextKeyExpr.regex(ResourceContextKey.Extension.key, /\.html?$/i), + ContextKeyExpr.regex(ResourceContextKey.Extension.key, /\.(?:html?|mht(?:ml)?)$/i), ); super({ id: BrowserViewCommandId.OpenFile, @@ -350,25 +350,25 @@ class OpenFileInIntegratedBrowserAction extends Action2 { category: BrowserActionCategory, icon: Codicon.globe, f1: true, - precondition: IS_LOCAL_HTML_FILE, + precondition: IS_SUPPORTED_LOCAL_BROWSER_FILE, menu: [ { id: MenuId.ExplorerContext, group: 'navigation', order: 29, - when: IS_LOCAL_HTML_FILE, + when: IS_SUPPORTED_LOCAL_BROWSER_FILE, }, { id: MenuId.EditorTitleContext, group: '1_open', order: 5, - when: IS_LOCAL_HTML_FILE, + when: IS_SUPPORTED_LOCAL_BROWSER_FILE, }, { id: MenuId.EditorTitle, group: 'navigation', order: 99, - when: IS_LOCAL_HTML_FILE, + when: IS_SUPPORTED_LOCAL_BROWSER_FILE, }, ] }); diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/webContentsViewRendererFeature.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/webContentsViewRendererFeature.ts index be59092f2f6c1..ccca278dd48c2 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/features/webContentsViewRendererFeature.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/webContentsViewRendererFeature.ts @@ -181,8 +181,8 @@ class WebContentsViewRendererFeature extends BrowserEditorContribution { store.add(model.onDidChangeVisibility(() => void this._doScreenshot())); store.add(model.onDidKeyCommand(keyEvent => void this._handleKeyEvent(keyEvent))); - store.add(model.onDidNavigate(() => this._refresh())); - store.add(model.onDidChangeLoadingState(() => this._refresh())); + store.add(model.onDidNavigate(() => this._refresh(true))); + store.add(model.onDidChangeLoadingState(() => this._refresh(true))); this._refresh(); void this._doScreenshot(); @@ -217,7 +217,7 @@ class WebContentsViewRendererFeature extends BrowserEditorContribution { * Recompute visibility of our content layers and the underlying page based * on the latest editor/overlay/model state. */ - private _refresh(): void { + private _refresh(restartScreenshot = false): void { // Placeholder screenshot: shown whenever there's a page to render // (covered by the WCV when it's up, visible during hide/show swaps). const placeholderActive = !!this._model?.url && !this._model?.error; @@ -232,6 +232,9 @@ class WebContentsViewRendererFeature extends BrowserEditorContribution { } const show = this._shouldShowPage(); if (show === this._model.visible) { + if (show && restartScreenshot) { + void this._doScreenshot(); + } return; } if (show) { @@ -268,11 +271,8 @@ class WebContentsViewRendererFeature extends BrowserEditorContribution { } private async _doScreenshot(): Promise { - if (!this._model) { - return; - } this._screenshotHandle.clear(); - if (!this._model.visible) { + if (!this._model?.url || this._model.error || !this._model.visible) { return; } try { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts index 5028d80a5bbd7..10556e77706ae 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts @@ -12,7 +12,7 @@ import { Delayer } from '../../../../../../base/common/async.js'; import { CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; -import { autorun } from '../../../../../../base/common/observable.js'; +import { autorun, derived } from '../../../../../../base/common/observable.js'; import { ThemeIcon } from '../../../../../../base/common/themables.js'; import { URI } from '../../../../../../base/common/uri.js'; import { localize } from '../../../../../../nls.js'; @@ -365,6 +365,9 @@ export class AgentHostChatInputPicker extends Disposable { private _initialResolved: { readonly sessionResource: URI; readonly result: ResolveSessionConfigResult } | undefined; private readonly _initialResolveCts = this._registerInitialResolveCts(); private readonly _renderDisposables = this._register(new DisposableStore()); + private readonly _pickerDisposables = this._register(new DisposableStore()); + private readonly _sandboxToggleDisabled = derived(this, reader => this._agentHostEnablementService.managedSandboxEnforced.read(reader) + && !this._agentHostEnablementService.managedSandboxAllowsBypass.read(reader)); private readonly _filterDelayer = this._register(new Delayer[]>(200)); private readonly _subRef = this._register(new MutableDisposable; readonly backendSession: URI }>()); @@ -700,7 +703,10 @@ export class AgentHostChatInputPicker extends Disposable { return toActionItems(this._property, await this._getItems(refreshed.schema, query), refreshed.value, isAutoApprovePolicyRestricted(this._configurationService), this._getSandboxStandaloneToggle()); }) : undefined, - onHide: () => trigger.focus(), + onHide: () => { + this._pickerDisposables.clear(); + trigger.focus(); + }, }; this._actionWidgetService.show( @@ -722,6 +728,15 @@ export class AgentHostChatInputPicker extends Disposable { : {}), }), ); + if (actionItems.some(item => item.standaloneToggle)) { + this._pickerDisposables.add(autorun(reader => { + this._agentHostEnablementService.managedSandboxEnforced.read(reader); + this._sandboxToggleDisabled.read(reader); + this._actionWidgetService.updateItems(actionItems.map(item => item.standaloneToggle + ? { ...item, standaloneToggle: this._getSandboxStandaloneToggle() } + : item)); + })); + } } private _getSandboxSettingId(): ReturnType { @@ -743,21 +758,28 @@ export class AgentHostChatInputPicker extends Disposable { return settingId !== undefined && isAgentSandboxEnabledValue(this._configurationService.getValue(settingId)); } + private _isSandboxToggleDisabled(): boolean { + return this._sandboxToggleDisabled.get(); + } + private _getSandboxStandaloneToggle(): IActionListItemInlineToggle | undefined { const settingId = this._getSandboxSettingId(); if (this._property !== SessionConfigKey.AutoApprove || !this._isSandboxToggleSettingEnabled() || !settingId) { return undefined; } const managed = this._agentHostEnablementService.managedSandboxEnforced.get(); + const disabled = this._isSandboxToggleDisabled(); return { label: localize('agentHostChatInputPicker.defaultSandboxToggle', "Sandboxing for terminal"), title: managed - ? localize('agentHostChatInputPicker.managedSandboxToggleTitle', "Sandboxing is managed by your organization") + ? disabled + ? localize('agentHostChatInputPicker.requiredSandboxToggleTitle', "Sandboxing is required by your organization") + : localize('agentHostChatInputPicker.editableManagedSandboxToggleTitle', "Sandboxing is enabled by your organization, but you may disable it") : localize('agentHostChatInputPicker.defaultSandboxToggleTitle', "Run terminal commands inside a sandbox that restricts file system and network access"), checked: this._isSandboxingEnabled(), - disabled: managed, + disabled, onChange: checked => { - if (this._agentHostEnablementService.managedSandboxEnforced.get()) { + if (this._isSandboxToggleDisabled()) { return; } const target = checked ? AgentSandboxEnabledValue.On : AgentSandboxEnabledValue.Off; 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 ff51c76d74df0..b971ba5473603 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -48,7 +48,7 @@ import { ConfirmationOptionKind, CustomizationType, JsonPrimitive, McpServerAuth import { compareProtocolVersions } from '../../../../../../platform/agentHost/common/state/protocol/version/registry.js'; import { ActionType, ChatTurnStartedAction, isChatAction, type ClientChatAction, type ClientSessionAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import { AHP_AUTH_REQUIRED, AHP_NOT_FOUND, ProtocolError } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; -import { buildChatUri, buildDefaultChatUri, buildSubagentChatUri, ChatOriginKind, getErrorResponsePart, getInlineToolInput, getToolSubagentContent, getTurnError, isChatReadOnly, isDefaultChatUri, isMessageHiddenFromTranscript, isMessageRequestHiddenFromTranscript, MessageAttachmentKind, MessageKind, PendingMessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, SessionStatus, StateComponents, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, TurnState, parseChatUri, mergeSessionWithDefaultChat, readSessionWorkspaceless, readUsageInfoMeta, withMessageHiddenFromTranscript, type ChatState, type ISessionWithDefaultChat, type ICompletedToolCall, type InputRequestResponsePart, type MarkdownResponsePart, type Message, type MessageAttachment, type MessageAnnotationsAttachment, type MessageChatAttachment, type MessageResourceAttachment, type MessageEmbeddedResourceAttachment, type ModelSelection, type PendingMessage, type ReasoningResponsePart, type RootState, type ChatInputAnswer, type ChatInputQuestion, type ChatInputRequest, type ChatSummary, type SessionState, type StringOrMarkdown, type ToolCallPendingConfirmationState, type ToolCallResponsePart, type ToolCallRunningState, type ToolCallState, type ToolInput, type Turn, type UsageInfo } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { buildChatUri, buildDefaultChatUri, buildSubagentChatUri, ChatOriginKind, getErrorResponsePart, getInlineToolInput, getToolSubagentContent, getTurnError, isChatReadOnly, isDefaultChatUri, isMessageHiddenFromTranscript, isMessageRequestHiddenFromTranscript, MessageAttachmentKind, MessageKind, PendingMessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, SessionStatus, StateComponents, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, TurnState, parseChatUri, mergeSessionWithDefaultChat, readMessageSystemInitiatedLabel, readSessionWorkspaceless, readUsageInfoMeta, withMessageHiddenFromTranscript, type ChatState, type ISessionWithDefaultChat, type ICompletedToolCall, type InputRequestResponsePart, type MarkdownResponsePart, type Message, type MessageAttachment, type MessageAnnotationsAttachment, type MessageChatAttachment, type MessageResourceAttachment, type MessageEmbeddedResourceAttachment, type ModelSelection, type PendingMessage, type ReasoningResponsePart, type RootState, type ChatInputAnswer, type ChatInputQuestion, type ChatInputRequest, type ChatSummary, type SessionState, type StringOrMarkdown, type ToolCallPendingConfirmationState, type ToolCallResponsePart, type ToolCallRunningState, type ToolCallState, type ToolInput, type Turn, type UsageInfo } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { ExtensionIdentifier } from '../../../../../../platform/extensions/common/extensions.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; @@ -315,6 +315,7 @@ function getMcpAuthenticationRequiredServers(sessionResource: URI, state: ISessi interface IStartServerRequestOptions { readonly isSystemInitiated?: boolean; + readonly systemInitiatedLabel?: string; readonly isHidden?: boolean; readonly isRequestHidden?: boolean; readonly timestamp?: number; @@ -805,6 +806,7 @@ class AgentHostChatSession extends Disposable implements IChatSession { prompt, variableData, isSystemInitiated: options?.isSystemInitiated, + systemInitiatedLabel: options?.systemInitiatedLabel, isHidden: options?.isHidden, isRequestHidden: options?.isRequestHidden, timestamp: options?.timestamp, @@ -2421,6 +2423,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC messageToVariableData(activeTurn.message, this._config.connectionAuthority), { isSystemInitiated: activeTurn.message.origin.kind === MessageKind.SystemNotification, + systemInitiatedLabel: readMessageSystemInitiatedLabel(activeTurn.message), isHidden: isMessageHiddenFromTranscript(activeTurn.message), isRequestHidden: isMessageRequestHiddenFromTranscript(activeTurn.message), timestamp: parseTimestamp(activeTurn.startedAt), diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts index 05476439941e7..ce2c77d838364 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts @@ -310,11 +310,16 @@ export class AgentHostSessionListStore extends Disposable { return; } + const { activity, ...changes } = notification.changes; const updated: IAgentHostSessionListEntry = { provider, rawId, statusKnown: cached.statusKnown || notification.changes.status !== undefined, - summary: { ...cached.summary, ...notification.changes }, + summary: { + ...cached.summary, + ...changes, + ...(Object.prototype.hasOwnProperty.call(notification.changes, 'activity') ? { activity: activity ?? undefined } : {}), + }, }; if (!this._isSessionInWorkspace(updated)) { this._mutationGeneration++; diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts index d413de68f5e42..f273f9ad66b85 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts @@ -14,7 +14,7 @@ import { Schemas } from '../../../../../../base/common/network.js'; import { posix, win32 } from '../../../../../../base/common/path.js'; import { URI } from '../../../../../../base/common/uri.js'; import { generateUuid } from '../../../../../../base/common/uuid.js'; -import { buildSubagentChatUri, getTurnError, isMessageHiddenFromTranscript, isMessageRequestHiddenFromTranscript, MessageKind, parseChatUri, ToolCallCancellationReason, ToolCallContributorKind, ToolCallRiskAssessmentStatus, ToolCallStatus, ResponsePartKind, getInlineToolInput, getToolFileEdits, getToolOutputText, getToolSubagentContent, hasReportedUsage, readUsageInfoMeta, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, type ActiveTurn, type ChatInputAnswer, type ChatInputRequest, type ICompletedToolCall, type InputRequestResponsePart, type Message, type TerminalCommandResult, type ToolCallPendingConfirmationState, type ToolCallState, type ToolResultSubagentContent, type Turn, FileEditKind, ToolResultContentType, type ToolResultContent, type UsageInfo, type UsageInfoMeta } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { buildSubagentChatUri, getTurnError, isMessageHiddenFromTranscript, isMessageRequestHiddenFromTranscript, MessageKind, parseChatUri, ToolCallCancellationReason, ToolCallContributorKind, ToolCallRiskAssessmentStatus, ToolCallStatus, ResponsePartKind, getInlineToolInput, getToolFileEdits, getToolOutputText, getToolSubagentContent, hasReportedUsage, readMessageSystemInitiatedLabel, readUsageInfoMeta, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, type ActiveTurn, type ChatInputAnswer, type ChatInputRequest, type ICompletedToolCall, type InputRequestResponsePart, type Message, type TerminalCommandResult, type ToolCallPendingConfirmationState, type ToolCallState, type ToolResultSubagentContent, type Turn, FileEditKind, ToolResultContentType, type ToolResultContent, type UsageInfo, type UsageInfoMeta } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import type { ChatInputRequestWithPlanReview, IAgentHostPlanReview } from '../../../../../../platform/agentHost/common/agentHostPlanReview.js'; import { getToolKind } from '../../../../../../platform/agentHost/common/state/sessionReducers.js'; import { readToolCallMeta } from '../../../../../../platform/agentHost/common/meta/agentToolCallMeta.js'; @@ -77,6 +77,10 @@ function isRenameChatTool(toolCall: ToolCallState): boolean { return toolCall.toolName === SessionServerToolName.RenameChat || toolCall.toolName.endsWith(`__${SessionServerToolName.RenameChat}`); } +function isSetWorkspaceTool(toolCall: ToolCallState): boolean { + return toolCall.toolName === SessionServerToolName.SetWorkspace || toolCall.toolName.endsWith(`__${SessionServerToolName.SetWorkspace}`); +} + function isAutomaticTitleRename(toolCall: ToolCallState): boolean { if (!isRenameChatTool(toolCall) || toolCall.status === ToolCallStatus.Streaming) { return false; @@ -942,6 +946,7 @@ export function turnsToHistory(backendSession: URI, turns: readonly Turn[], part ...(isMessageRequestHiddenFromTranscript(turn.message) ? { isRequestHidden: true } : {}), ...(isSystemInitiated ? { isSystemInitiated: true, + systemInitiatedLabel: readMessageSystemInitiatedLabel(turn.message), } : {}), ...(isTerminalRequest ? { isTerminalRequest: true, @@ -2353,7 +2358,7 @@ export function toolCallStateToInvocation(tc: ToolCallState, subAgentInvocationI }; } else if (getToolKind(tc) === 'terminal' && getInlineToolInput(tc.toolInput)) { toolSpecificData = buildTerminalToolSpecificData(tc, sessionResource); - } else { + } else if (!isSetWorkspaceTool(tc)) { const toolInput = getInlineToolInput(tc.toolInput); if (toolInput) { let rawInput: unknown; diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts index b01895e416851..b826c4b32e68c 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts @@ -49,7 +49,7 @@ import { ICustomizationHarnessService } from '../../common/customizationHarnessS import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { IAICustomizationListItem } from './aiCustomizationItemSource.js'; import { IAICustomizationItemsModel, ItemsModelSection } from './aiCustomizationItemsModel.js'; -import { createCustomizationCardPrimaryAction, CustomizationCardListController } from './customizationCardList.js'; +import { createCustomizationCardPrimaryAction, CustomizationCardListController, layoutVirtualizedSectionList, layoutVirtualizedSections, renderVirtualizedSectionLoadingPlaceholder, setupCollapsibleSection } from './customizationCardList.js'; import { DomScrollableElement } from '../../../../../base/browser/ui/scrollbar/scrollableElement.js'; import { ScrollbarVisibility } from '../../../../../base/common/scrollable.js'; @@ -102,6 +102,13 @@ interface IFileItemEntry { type IListEntry = IGroupHeaderEntry | IFileItemEntry; +interface ICustomizationSectionList { + readonly list: WorkbenchList; + readonly items: readonly IAICustomizationListItem[]; + readonly container: HTMLElement; + readonly key: string; +} + /** * Delegate for the AI Customization list. */ @@ -245,6 +252,7 @@ class AICustomizationItemRenderer implements IListRenderer void, @IHoverService private readonly hoverService: IHoverService, @ILabelService private readonly labelService: ILabelService, @IMenuService private readonly menuService: IMenuService, @@ -357,7 +365,7 @@ class AICustomizationItemRenderer implements IListRenderer { - const actions = menu.getActions({ arg: context, shouldForwardArgs: true }); - const { primary } = getContextMenuActions(actions, 'inline'); templateData.actionBar.clear(); - templateData.actionBar.push(primary, { icon: true, label: false }); + if (element.promptType === PromptsType.agent || element.promptType === PromptsType.skill || element.promptType === PromptsType.instructions) { + const moreAction = templateData.elementDisposables.add(new Action( + 'aiCustomization.moreActions', + localize('customizationMoreActionsAria', "More actions for {0}", displayName), + ThemeIcon.asClassName(Codicon.ellipsis), + true, + () => this.showItemActions(element, templateData.actionsContainer), + )); + templateData.actionBar.push(moreAction, { icon: true, label: false }); + } else { + const actions = menu.getActions({ arg: context, shouldForwardArgs: true }); + const { primary } = getContextMenuActions(actions, 'inline'); + templateData.actionBar.push(primary, { icon: true, label: false }); + } }; updateActions(); templateData.elementDisposables.add(menu.onDidChange(updateActions)); @@ -600,6 +621,29 @@ interface ICustomizationItemGroup { /** * Widget that displays a searchable list of AI customization items. */ +export function getCollapsedCustomizationGroupKey(section: AICustomizationManagementSection, groupKey: string): string { + return `${section}:${groupKey}`; +} + +export function getCustomizationItemStatusLabel(item: IAICustomizationListItem): string | undefined { + switch (item.status) { + case 'loading': return localize('customizationStatusLoading', "Loading"); + case 'loaded': return localize('customizationStatusLoaded', "Loaded"); + case 'degraded': return localize('customizationStatusDegraded', "Needs attention"); + case 'error': return localize('customizationStatusError', "Error"); + default: return undefined; + } +} + +export function getCustomizationItemAriaLabel(item: IAICustomizationListItem): string { + const displayName = item.displayName ?? formatDisplayName(item.name); + const secondaryText = getCustomizationSecondaryText(item.description, item.filename, item.promptType); + const statusLabel = getCustomizationItemStatusLabel(item); + const accessibleSecondaryText = [secondaryText, statusLabel].filter(Boolean).join('. '); + const nameAndDescription = accessibleSecondaryText ? localize('itemAriaLabel', "{0}. {1}", displayName, accessibleSecondaryText) : displayName; + return item.disabled ? localize('itemAriaLabelDisabled', "{0}, disabled", nameAndDescription) : nameAndDescription; +} + export class AICustomizationListWidget extends Disposable { readonly element: HTMLElement; @@ -620,6 +664,8 @@ export class AICustomizationListWidget extends Disposable { private cardContainer!: HTMLElement; private cardScrollable!: DomScrollableElement; private cardScrollableNode!: HTMLElement; + private cardSectionLayoutContainer: HTMLElement | undefined; + private cardSectionLists: ICustomizationSectionList[] = []; private firstCardFocusElement: HTMLElement | undefined; private readonly cardRowsByUri = new Map(); private readonly cardRowsById = new Map(); @@ -634,13 +680,17 @@ export class AICustomizationListWidget extends Disposable { private allItems: readonly IAICustomizationListItem[] = []; private displayEntries: IListEntry[] = []; private searchQuery: string = ''; + private sectionLoading = false; private readonly collapsedGroups = new Set(); private _layoutDeferred = false; + private readonly revealLastItemScheduler = this._register(new MutableDisposable()); private lastLayoutWidth = 0; private lastLayoutHeight = 0; private lastHeaderHeight = 0; private readonly dropdownActionDisposables = this._register(new DisposableStore()); private readonly cardDisposables = this._register(new DisposableStore()); + private readonly pendingCardSectionLayout = this._register(new MutableDisposable()); + private readonly cardSectionScrollPositions = new Map(); /** Monotonically increasing counter; guards the post-load announcement against stale calls. */ private _sectionLoadId = 0; @@ -799,7 +849,7 @@ export class AICustomizationListWidget extends Disposable { this.element.appendChild(this.cardScrollableNode); const cardResizeObserver = this._register(new DOM.DisposableResizeObserver( 'AICustomizationListWidget.cardScrollable', - () => this.cardScrollable.scanDomNode(), + () => this.scheduleCardSectionLayout(), )); this._register(cardResizeObserver.observe(this.cardScrollableNode)); @@ -814,7 +864,10 @@ export class AICustomizationListWidget extends Disposable { this.emptyStateContainer.style.display = 'none'; // Create list - const itemRenderer = this.instantiationService.createInstance(AICustomizationItemRenderer); + const itemRenderer = this.instantiationService.createInstance( + AICustomizationItemRenderer, + (item: IAICustomizationListItem, anchor: HTMLElement) => this.showCardItemActions(item, anchor), + ); this.list = this._register(this.instantiationService.createInstance( WorkbenchList, 'AICustomizationManagementList', @@ -1029,7 +1082,9 @@ export class AICustomizationListWidget extends Disposable { getActions: () => actions, onHide: () => { this.cardMenuOpen = false; - (this.cardMenuButtonsById.get(item.id) ?? this.cardRowsById.get(item.id) ?? this.firstCardFocusElement)?.focus(); + if (!this.focusCardSectionItem(item.id)) { + (this.cardMenuButtonsById.get(item.id) ?? this.cardRowsById.get(item.id) ?? this.firstCardFocusElement)?.focus(); + } disposables.dispose(); }, }); @@ -1076,6 +1131,7 @@ export class AICustomizationListWidget extends Disposable { const modelSection = toItemsModelSection(section); if (!modelSection) { + this.sectionLoading = false; this.currentSectionSubscription.clear(); this.allItems = []; const matchCount = this.filterItems(); @@ -1086,6 +1142,7 @@ export class AICustomizationListWidget extends Disposable { } const observable = this.itemsModel.getItems(modelSection); + this.sectionLoading = true; this.currentSectionSubscription.value = autorun(reader => { const items = observable.read(reader); this.allItems = items; @@ -1098,6 +1155,8 @@ export class AICustomizationListWidget extends Disposable { // setSection() call may have already taken over and will make its own // announcement once its own load resolves. if (loadId === this._sectionLoadId) { + this.sectionLoading = false; + this.filterItems(); this.announceItemCount(this.applySearchFilter(this.allItems).length); } } @@ -1499,7 +1558,7 @@ export class AICustomizationListWidget extends Disposable { continue; } - const collapsed = !this.usesCardLayout() && this.collapsedGroups.has(group.groupKey); + const collapsed = !this.usesCardLayout() && this.collapsedGroups.has(this.getCollapsedGroupKey(group.groupKey)); this.displayEntries.push({ type: 'group-header', @@ -1608,13 +1667,16 @@ export class AICustomizationListWidget extends Disposable { const usesTargetedCreateActions = this.usesTargetedCreateActions(); const createGroupKey = isFiltering || usesTargetedCreateActions ? undefined : this.getCreateActionGroupKey(); const alwaysVisibleGroupKeys = new Set(getAlwaysVisibleCustomizationGroupKeys(this.currentSection, isFiltering)); - const visibleGroups = groups.filter(group => group.items.length > 0 || alwaysVisibleGroupKeys.has(group.groupKey) || group.groupKey === createGroupKey); + const visibleGroups = groups.filter(group => group.items.length > 0 || alwaysVisibleGroupKeys.has(group.groupKey) || group.groupKey === createGroupKey || this.sectionLoading && !isFiltering); if (visibleGroups.length === 0) { + this.captureCardSectionScrollPositions(); this.cardDisposables.clear(); + this.cardSectionLists = []; this.cardRowsByUri.clear(); this.cardRowsById.clear(); this.cardMenuButtonsById.clear(); this.firstCardFocusElement = undefined; + this.cardSectionLayoutContainer = undefined; DOM.clearNode(this.cardContainer); this.cardScrollableNode.style.display = 'none'; this.updateEmptyState(); @@ -1627,18 +1689,28 @@ export class AICustomizationListWidget extends Disposable { } } + this.captureCardSectionScrollPositions(); this.cardDisposables.clear(); + this.cardSectionLists = []; this.cardRowsByUri.clear(); this.cardRowsById.clear(); this.cardMenuButtonsById.clear(); this.firstCardFocusElement = undefined; + this.cardSectionLayoutContainer = undefined; DOM.clearNode(this.cardContainer); this.listContainer.style.display = 'none'; this.emptyStateContainer.style.display = 'none'; this.cardScrollableNode.style.display = ''; - const content = DOM.append(this.cardContainer, $('.plugin-card-scroll.plugin-card-scroll-content.customization-card-scroll')); + const content = DOM.append(this.cardContainer, $('.plugin-card-scroll.plugin-card-scroll-content.customization-card-scroll.distributed-section-layout')); + this.cardSectionLayoutContainer = content; + const contentResizeObserver = this.cardDisposables.add(new DOM.DisposableResizeObserver( + 'AICustomizationListWidget.cardScrollContent', + () => this.scheduleCardSectionLayout(), + )); + this.cardDisposables.add(contentResizeObserver.observe(content)); for (const group of visibleGroups) { + const collapsedGroupKey = this.getCollapsedGroupKey(group.groupKey); const section = DOM.append(content, $('.plugin-card-section.customization-card-section')); const header = DOM.append(section, $('.plugin-card-section-header')); const text = DOM.append(header, $('.plugin-card-section-text')); @@ -1657,26 +1729,141 @@ export class AICustomizationListWidget extends Disposable { this.renderCardCreateActions(header); } - const inventory = DOM.append(section, $('.plugin-card-grid.plugin-inventory-list.customization-inventory-list')); - const cardList = this.cardDisposables.add(new CustomizationCardListController(inventory, group.label)); + const inventory = DOM.append(section, $('.plugin-card-grid.plugin-inventory-list.customization-inventory-list.virtualized-section-list')); + setupCollapsibleSection( + headingRow, + inventory, + group.label, + this.cardDisposables, + this.collapsedGroups.has(collapsedGroupKey), + collapsed => { + if (collapsed) { + this.collapsedGroups.add(collapsedGroupKey); + } else { + this.collapsedGroups.delete(collapsedGroupKey); + } + this.layoutCardSectionLists(); + this.cardScrollable.scanDomNode(); + this.scheduleCardSectionLayout(); + }, + ); if (group.items.length === 0) { - const empty = DOM.append(inventory, $('.plugin-inventory-empty')); - empty.textContent = this.getEmptyGroupMessage(group.groupKey); + if (this.sectionLoading) { + renderVirtualizedSectionLoadingPlaceholder(inventory, localize('loadingCustomizations', "Loading customizations..."), ITEM_HEIGHT); + } else { + const empty = DOM.append(inventory, $('.plugin-inventory-empty')); + empty.textContent = this.getEmptyGroupMessage(group.groupKey); + } continue; } - for (const item of group.items) { - this.appendCustomizationCardRow(inventory, item, group.label, cardList); - } - cardList.finalize(); + this.createCustomizationSectionList(inventory, group.groupKey, group.label, group.items); } + this.layoutCardSectionLists(); this.cardScrollable.scanDomNode(); + this.scheduleCardSectionLayout(); if (shouldRestoreFocus) { DOM.getWindow(this.element).requestAnimationFrame(() => { - (this.cardMenuButtonsById.get(focusItemId ?? '') ?? this.cardRowsById.get(focusItemId ?? '') ?? this.firstCardFocusElement)?.focus(); + if (!focusItemId || !this.focusCardSectionItem(focusItemId)) { + this.firstCardFocusElement?.focus(); + } }); } } + private createCustomizationSectionList(container: HTMLElement, groupKey: string, label: string, items: readonly IAICustomizationListItem[]): void { + const key = `${this.currentSection}:${groupKey}`; + container.style.height = `${ITEM_HEIGHT}px`; + const itemRenderer = this.instantiationService.createInstance( + AICustomizationItemRenderer, + (item: IAICustomizationListItem, anchor: HTMLElement) => this.showCardItemActions(item, anchor), + ); + const list = this.cardDisposables.add(this.instantiationService.createInstance( + WorkbenchList, + `AICustomizationManagementList.${label}`, + container, + new AICustomizationListDelegate(), + [itemRenderer], + { + identityProvider: { getId: entry => entry.item.id }, + accessibilityProvider: { + getAriaLabel: entry => getCustomizationItemAriaLabel(entry.item), + getWidgetAriaLabel: () => label, + getSetSize: (_entry, _index, listLength) => listLength, + getPosInSet: (_entry, index) => index + 1, + }, + keyboardNavigationLabelProvider: { + getKeyboardNavigationLabel: entry => entry.item.name, + }, + multipleSelectionSupport: false, + openOnSingleClick: true, + }, + )); + list.splice(0, 0, items.map(item => ({ type: 'file-item', item }))); + list.scrollTop = this.cardSectionScrollPositions.get(key) ?? 0; + this.cardDisposables.add(list.onDidOpen(event => { + if (event.element) { + this._onDidSelectItem.fire(event.element.item); + } + })); + this.cardDisposables.add(list.onDidChangeFocus(event => { + itemRenderer.setFocusedIndex(event.indexes.length ? event.indexes[0] : -1); + if (event.elements.length > 0) { + this.lastCardFocusItemId = event.elements[0].item.id; + } + })); + this.cardDisposables.add(list.onDidFocus(() => { + if (list.getFocus().length === 0 && items.length > 0) { + list.setFocus([0]); + } + })); + this.cardDisposables.add(list.onContextMenu(event => this.onContextMenu(event as IListContextMenuEvent))); + this.cardSectionLists.push({ list, items, container, key }); + } + + private captureCardSectionScrollPositions(): void { + for (const section of this.cardSectionLists) { + this.cardSectionScrollPositions.set(section.key, section.list.scrollTop); + } + } + + private layoutCardSectionLists(): void { + const content = this.cardSectionLayoutContainer; + if (!content) { + return; + } + const heights = layoutVirtualizedSections(content, this.cardSectionLists.map(section => ({ + container: section.container, + contentHeight: section.items.length * ITEM_HEIGHT, + minimumHeight: ITEM_HEIGHT, + }))); + for (let index = 0; index < this.cardSectionLists.length; index++) { + const section = this.cardSectionLists[index]; + const height = heights[index]; + layoutVirtualizedSectionList(section.list, section.container, height, section.container.clientWidth || undefined); + } + } + + private scheduleCardSectionLayout(): void { + const targetWindow = DOM.getWindow(this.element); + this.pendingCardSectionLayout.value = DOM.scheduleAtNextAnimationFrame(targetWindow, () => { + this.layoutCardSectionLists(); + this.cardScrollable.scanDomNode(); + }); + } + + private focusCardSectionItem(itemId: string): boolean { + for (const section of this.cardSectionLists) { + const index = section.items.findIndex(item => item.id === itemId); + if (index >= 0) { + section.list.reveal(index); + section.list.setFocus([index]); + section.list.domFocus(); + return true; + } + } + return false; + } + private usesTargetedCreateActions(): boolean { return this.currentSection === AICustomizationManagementSection.Agents || this.currentSection === AICustomizationManagementSection.Skills @@ -1825,12 +2012,12 @@ export class AICustomizationListWidget extends Disposable { this.cardDisposables.add(button.onDidClick(() => this.executePrimaryCreateAction())); } - private appendCustomizationCardRow(parent: HTMLElement, item: IAICustomizationListItem, groupLabel: string, cardList: CustomizationCardListController): void { + protected appendCustomizationCardRow(parent: HTMLElement, item: IAICustomizationListItem, groupLabel: string, cardList: CustomizationCardListController): void { const row = DOM.append(parent, $('.plugin-list-item.plugin-home-row.customization-home-row')); row.classList.toggle('disabled', item.disabled); const displayName = item.displayName ?? formatDisplayName(item.name); const secondaryText = getCustomizationSecondaryText(item.description, item.filename, item.promptType); - const statusLabel = this.getItemStatusLabel(item); + const statusLabel = getCustomizationItemStatusLabel(item); const accessibleSecondaryText = [secondaryText, statusLabel].filter(Boolean).join('. '); const accessibleLabel = item.disabled ? localize('customizationCardAriaLabelDisabled', "{0}. {1}. Disabled", displayName, accessibleSecondaryText || groupLabel) @@ -1904,16 +2091,6 @@ export class AICustomizationListWidget extends Disposable { }); } - private getItemStatusLabel(item: IAICustomizationListItem): string | undefined { - switch (item.status) { - case 'loading': return localize('customizationStatusLoading', "Loading"); - case 'loaded': return localize('customizationStatusLoaded', "Loaded"); - case 'degraded': return localize('customizationStatusDegraded', "Needs attention"); - case 'error': return localize('customizationStatusError', "Error"); - default: return undefined; - } - } - /** * Filters items based on the current search query and builds grouped display entries. */ @@ -1928,14 +2105,19 @@ export class AICustomizationListWidget extends Disposable { * Toggles the collapsed state of a group. */ private toggleGroup(entry: IGroupHeaderEntry): void { - if (this.collapsedGroups.has(entry.groupKey)) { - this.collapsedGroups.delete(entry.groupKey); + const collapsedGroupKey = this.getCollapsedGroupKey(entry.groupKey); + if (this.collapsedGroups.has(collapsedGroupKey)) { + this.collapsedGroups.delete(collapsedGroupKey); } else { - this.collapsedGroups.add(entry.groupKey); + this.collapsedGroups.add(collapsedGroupKey); } this.filterItems(); } + private getCollapsedGroupKey(groupKey: string): string { + return getCollapsedCustomizationGroupKey(this.currentSection, groupKey); + } + private updateEmptyState(): void { const hasItems = this.displayEntries.length > 0; if (!hasItems) { @@ -2017,7 +2199,15 @@ export class AICustomizationListWidget extends Disposable { */ focusList(): void { if (this.usesCardLayout()) { - this.firstCardFocusElement?.focus(); + if (!this.firstCardFocusElement) { + const firstSection = this.cardSectionLists[0]; + if (firstSection?.items.length) { + firstSection.list.setFocus([0]); + firstSection.list.domFocus(); + } + } else { + this.firstCardFocusElement.focus(); + } return; } this.list.domFocus(); @@ -2031,7 +2221,16 @@ export class AICustomizationListWidget extends Disposable { */ revealLastItem(): void { if (this.usesCardLayout()) { - this.cardScrollable.setScrollPosition({ scrollTop: this.cardContainer.scrollHeight }); + const reveal = () => { + const section = this.cardSectionLists.at(-1); + if (section?.items.length) { + section.list.reveal(section.items.length - 1); + } + this.cardScrollable.scanDomNode(); + this.cardScrollable.setScrollPosition({ scrollTop: this.cardContainer.scrollHeight }); + }; + reveal(); + this.revealLastItemScheduler.value = DOM.scheduleAtNextAnimationFrame(DOM.getWindow(this.element), reveal); return; } if (this.displayEntries.length > 0) { @@ -2044,11 +2243,13 @@ export class AICustomizationListWidget extends Disposable { */ revealAndSelectFirstItemByUri(uris: readonly URI[]): boolean { if (this.usesCardLayout()) { - for (const uri of uris) { - const row = this.cardRowsByUri.get(uri.toString()); - if (row) { - row.scrollIntoView({ block: 'nearest' }); - row.focus(); + for (const section of this.cardSectionLists) { + const index = section.items.findIndex(item => uris.some(uri => isEqual(item.uri, uri))); + if (index >= 0) { + section.list.reveal(index); + section.list.setFocus([index]); + section.list.setSelection([index]); + section.list.domFocus(); return true; } } @@ -2074,6 +2275,9 @@ export class AICustomizationListWidget extends Disposable { layout(height: number, width: number): void { this.lastLayoutHeight = height; this.lastLayoutWidth = width; + if (this.element.parentElement?.style.display === 'none') { + return; + } this.element.classList.toggle('narrow-layout', width < 500); this.element.classList.toggle('wide-layout', width >= 600); // Use the CSS-computed height within the padded parent. @@ -2105,7 +2309,9 @@ export class AICustomizationListWidget extends Disposable { this.cardScrollableNode.style.height = `${listHeight}px`; this.listContainer.style.height = `${listHeight}px`; if (this.usesCardLayout()) { + this.layoutCardSectionLists(); this.cardScrollable.scanDomNode(); + this.scheduleCardSectionLayout(); } else { this.list.layout(listHeight, width); } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts index 4f31883ebe5fc..7c0041161c5b8 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts @@ -11,7 +11,7 @@ import { RunOnceScheduler, timeout } from '../../../../../base/common/async.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { VSBuffer } from '../../../../../base/common/buffer.js'; import { getErrorMessage, onUnexpectedError } from '../../../../../base/common/errors.js'; -import { DisposableStore, IReference, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { DisposableStore, IReference, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; import { Action } from '../../../../../base/common/actions.js'; import { Event } from '../../../../../base/common/event.js'; import { MarkdownString } from '../../../../../base/common/htmlContent.js'; @@ -36,6 +36,7 @@ import { WorkbenchList } from '../../../../../platform/list/browser/listService. import { IListVirtualDelegate, IListRenderer } from '../../../../../base/browser/ui/list/list.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { Codicon } from '../../../../../base/common/codicons.js'; +import { KeyCode } from '../../../../../base/common/keyCodes.js'; import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; import { basename, dirname, isEqual } from '../../../../../base/common/resources.js'; import { URI } from '../../../../../base/common/uri.js'; @@ -99,6 +100,7 @@ import { IAgentPluginItem } from '../agentPluginEditor/agentPluginItems.js'; import { IExtension } from '../../../extensions/common/extensions.js'; import { EmbeddedMcpServerDetail, IMcpServerDetailInput } from './embeddedMcpServerDetail.js'; import { EmbeddedAgentPluginDetail } from './embeddedAgentPluginDetail.js'; +import { layoutVirtualizedSectionList, layoutVirtualizedSections, setupCollapsibleSection } from './customizationCardList.js'; import { EmbeddedExtensionToolsDetail } from './embeddedExtensionToolsDetail.js'; import { ICustomizationHarnessService, type ICustomizationSourceFolder } from '../../common/customizationHarnessService.js'; import { ChatConfiguration } from '../../common/constants.js'; @@ -278,6 +280,154 @@ class SectionItemRenderer implements IListRenderer; + readonly renderer: MigrationItemRenderer; + readonly container: HTMLElement; + readonly items: readonly MigratableConfiguration[]; + readonly key: string; +} + +interface IMigrationItemTemplateData { + readonly container: HTMLElement; + readonly checkbox: Checkbox; + readonly openButton: Button; + readonly nameLabel: HTMLElement; + readonly pathLabel: HTMLElement; + readonly moreButton: HTMLButtonElement; + readonly templateDisposables: DisposableStore; + readonly elementDisposables: DisposableStore; + currentIndex?: number; + currentElement?: MigratableConfiguration; +} + +class MigrationItemDelegate implements IListVirtualDelegate { + getHeight(): number { + return MIGRATION_ITEM_HEIGHT; + } + + getTemplateId(): string { + return 'migrationItem'; + } +} + +class MigrationItemRenderer implements IListRenderer { + readonly templateId = 'migrationItem'; + private readonly templates = new Set(); + + constructor( + private readonly isSelected: (customization: MigratableConfiguration) => boolean, + private readonly getRelativePath: (customization: MigratableConfiguration) => string, + private readonly onSelectionChange: (customization: MigratableConfiguration, selected: boolean) => void, + private readonly onOpen: (customization: MigratableConfiguration) => void, + private readonly onMore: (customization: MigratableConfiguration, anchor: HTMLElement) => void, + private readonly onFirstFocusable: (element: HTMLElement) => void, + private readonly hoverService: IHoverService, + ) { } + + renderTemplate(container: HTMLElement): IMigrationItemTemplateData { + container.classList.add('ai-customization-list-item', 'prompt-migration-item'); + const templateDisposables = new DisposableStore(); + const elementDisposables = templateDisposables.add(new DisposableStore()); + + const checkboxContainer = DOM.append(container, $('.item-sync-checkbox.prompt-migration-checkbox')); + const checkbox = templateDisposables.add(new Checkbox('', false, defaultCheckboxStyles)); + checkboxContainer.replaceChildren(checkbox.domNode); + + const itemLeft = DOM.append(container, $('span.item-left')); + const openButton = templateDisposables.add(new Button(itemLeft, {})); + DOM.clearNode(openButton.element); + openButton.element.classList.add('item-text', 'prompt-migration-open-button'); + const nameRow = DOM.append(openButton.element, $('span.item-name-row')); + const nameLabel = DOM.append(nameRow, $('span.item-name.prompt-migration-item-name')); + const pathLabel = DOM.append(openButton.element, $('span.item-description.is-filename.prompt-migration-item-path')); + + const itemRight = DOM.append(container, $('span.item-right')); + const moreButton = DOM.append(itemRight, $('button.icon-button.prompt-migration-more-action', { type: 'button' })) as HTMLButtonElement; + moreButton.classList.add(...ThemeIcon.asClassNameArray(Codicon.ellipsis)); + + const template: IMigrationItemTemplateData = { container, checkbox, openButton, nameLabel, pathLabel, moreButton, templateDisposables, elementDisposables }; + this.templates.add(template); + return template; + } + + renderElement(customization: MigratableConfiguration, index: number, templateData: IMigrationItemTemplateData): void { + templateData.elementDisposables.clear(); + templateData.container.removeAttribute('aria-selected'); + templateData.currentIndex = index; + templateData.currentElement = customization; + const displayName = customization.name ?? basename(customization.uri); + const relativePath = this.getRelativePath(customization); + const checkboxTitle = localize('customizationMigrationSelectAriaLabel', "Select {0}", displayName); + this.updateCheckboxState(templateData, customization); + templateData.checkbox.domNode.setAttribute('aria-label', checkboxTitle); + templateData.openButton.element.setAttribute('aria-label', localize('openCustomizationFile', "Open {0}, {1}", displayName, relativePath)); + templateData.nameLabel.textContent = displayName; + templateData.pathLabel.textContent = relativePath; + templateData.moreButton.setAttribute('aria-label', localize('customizationMigrationMoreActions', "More actions for {0}", displayName)); + if (index === 0) { + this.onFirstFocusable(templateData.checkbox.domNode); + } + + templateData.elementDisposables.add(templateData.checkbox.onChange(() => { + this.onSelectionChange(customization, templateData.checkbox.checked); + })); + templateData.elementDisposables.add(templateData.openButton.onDidClick(() => this.onOpen(customization))); + templateData.elementDisposables.add(this.hoverService.setupManagedHover( + getDefaultHoverDelegate('element'), + templateData.moreButton, + localize('moreActions', "More Actions"), + )); + templateData.elementDisposables.add(DOM.addDisposableListener(templateData.moreButton, 'click', event => { + event.stopPropagation(); + this.onMore(customization, templateData.moreButton); + })); + } + + refreshSelectionState(): void { + for (const template of this.templates) { + if (template.currentElement) { + this.updateCheckboxState(template, template.currentElement); + } + } + } + + private updateCheckboxState(templateData: IMigrationItemTemplateData, customization: MigratableConfiguration): void { + const selected = this.isSelected(customization); + templateData.checkbox.checked = selected; + templateData.checkbox.domNode.setAttribute('aria-checked', String(selected)); + } + + getIndex(target: HTMLElement): number | undefined { + for (const template of this.templates) { + if (template.container.contains(target)) { + return template.currentIndex; + } + } + return undefined; + } + + getControls(index: number): HTMLElement[] { + for (const template of this.templates) { + if (template.currentIndex === index) { + return [template.checkbox.domNode, template.openButton.element, template.moreButton]; + } + } + return []; + } + + disposeTemplate(templateData: IMigrationItemTemplateData): void { + this.templates.delete(templateData); + templateData.templateDisposables.dispose(); + } +} + +//#endregion + /** * Editor pane for the AI Customizations Management Editor. * Provides a global view of all AI customizations with a sidebar for navigation @@ -352,6 +502,9 @@ export class AICustomizationManagementEditor extends EditorPane { private migrationLinkElement: HTMLAnchorElement | undefined; private migrationSelectedCountElement: HTMLElement | undefined; private migrationFirstFocusableElement: HTMLElement | undefined; + private migrationSectionLists: IMigrationSectionList[] = []; + private collapsedMigrationSections: Set | undefined = new Set(); + private readonly migrationSectionScrollPositions = new Map(); private activeMigrationCategoryId: CustomizationMigrationCategoryId | undefined; private selectedCustomizationMigrationItems = new ResourceMap>(); private readonly migrationPageDisposables = this._register(new DisposableStore()); @@ -391,6 +544,7 @@ export class AICustomizationManagementEditor extends EditorPane { private customizationMigrationWritesInProgress = false; private readonly editorDisposables = this._register(new DisposableStore()); + private readonly pendingMigrationLayout = this._register(new MutableDisposable()); private _editorContentChanged = false; private _previousActiveHarnessId: string | undefined; @@ -489,6 +643,7 @@ export class AICustomizationManagementEditor extends EditorPane { } protected override createEditor(parent: HTMLElement): void { + this.pendingMigrationLayout.clear(); this.editorDisposables.clear(); this.contributedSectionContainers.clear(); this.contributedSectionWidgets.clear(); @@ -537,10 +692,18 @@ export class AICustomizationManagementEditor extends EditorPane { layout: (width, _, height) => { this.contentContainer.style.width = `${width}px`; if (height !== undefined) { - this.listWidget.layout(height - 16, width - 24); - this.mcpListWidget?.layout(height - 16, width - 24); - this.pluginListWidget?.layout(height - 16, width - 24); - this.toolsListWidget?.layout(height - 16, width - 24); + if (this.promptsContentContainer?.style.display !== 'none') { + this.listWidget.layout(height - 16, width - 24); + } + if (this.mcpContentContainer?.style.display !== 'none') { + this.mcpListWidget?.layout(height - 16, width - 24); + } + if (this.pluginContentContainer?.style.display !== 'none') { + this.pluginListWidget?.layout(height - 16, width - 24); + } + if (this.toolsContentContainer?.style.display !== 'none') { + this.toolsListWidget?.layout(height - 16, width - 24); + } const modelsFooterHeight = this.modelsFooterElement?.offsetHeight || 80; this.modelsWidget?.layout(height - 16 - modelsFooterHeight, width); if (this.viewMode === 'editor' && this.embeddedEditor && this.embeddedEditorContainer) { @@ -894,7 +1057,7 @@ export class AICustomizationManagementEditor extends EditorPane { this.migrationBannerContainer = DOM.append(this.migrationContentContainer, $('.customization-migration-banner')); this.migrationBannerContainer.style.display = 'none'; - this.migrationListContainer = $('.prompt-migration-list.list-container'); + this.migrationListContainer = $('.prompt-migration-list.list-container.distributed-section-layout'); this.migrationListScrollable = this.editorDisposables.add(new DomScrollableElement(this.migrationListContainer, { horizontal: ScrollbarVisibility.Hidden, vertical: ScrollbarVisibility.Auto, @@ -903,13 +1066,6 @@ export class AICustomizationManagementEditor extends EditorPane { const migrationListScrollableNode = this.migrationListScrollable.getDomNode(); migrationListScrollableNode.classList.add('prompt-migration-list-scrollable'); this.migrationContentContainer.appendChild(migrationListScrollableNode); - const targetWindow = DOM.getWindow(this.migrationContentContainer); - const migrationResizeObserver = this.editorDisposables.add(new DOM.DisposableResizeObserver( - 'AICustomizationManagementEditor.promptMigrationListScrollable', - () => this.migrationListScrollable?.scanDomNode(), - targetWindow, - )); - this.editorDisposables.add(migrationResizeObserver.observe(migrationListScrollableNode)); const footer = DOM.append(this.migrationContentContainer, $('.prompt-migration-footer')); this.migrationSelectedCountElement = DOM.append(footer, $('span.prompt-migration-selected-count')); @@ -927,7 +1083,17 @@ export class AICustomizationManagementEditor extends EditorPane { .filter(customization => this.isCustomizationSelectedForMigration(customization)); void this.migrateSelectedCustomizations(category, selectedCustomizations); })); + const targetWindow = DOM.getWindow(this.migrationContentContainer); + const migrationResizeObserver = this.editorDisposables.add(new DOM.DisposableResizeObserver( + 'AICustomizationManagementEditor.promptMigrationListScrollable', + () => this.scheduleMigrationSectionLayout(), + targetWindow, + )); + this.editorDisposables.add(migrationResizeObserver.observe(migrationListScrollableNode)); this.renderCustomizationMigrationPage(); + if (this.viewMode === 'migration') { + this.scheduleMigrationSectionLayout(); + } } private createContent(): void { @@ -1391,9 +1557,13 @@ export class AICustomizationManagementEditor extends EditorPane { return; } + for (const section of this.migrationSectionLists) { + this.migrationSectionScrollPositions?.set(section.key, section.list.scrollTop); + } this.migrationPageDisposables.clear(); DOM.clearNode(this.migrationListContainer); this.migrationFirstFocusableElement = undefined; + this.migrationSectionLists = []; const category = this.getActiveMigrationCategory() ?? CUSTOMIZATION_MIGRATION_CATEGORIES[0]; const candidates = this.getMigrationCandidates(category); @@ -1426,78 +1596,6 @@ export class AICustomizationManagementEditor extends EditorPane { return; } - const openCustomizationInEmbeddedEditor = (customization: MigratableConfiguration): void => { - const isWorkspaceFile = customization.storage === PromptsStorage.local; - void this.showEmbeddedEditor( - customization.uri, - customization.name ?? basename(customization.uri), - customization.type, - customization.storage, - isWorkspaceFile, - ); - }; - const renderSelectionCheckbox = (row: HTMLElement, customization: MigratableConfiguration, onSelectionChange?: () => void): Checkbox => { - const checkboxContainer = DOM.append(row, $('.item-sync-checkbox.prompt-migration-checkbox')); - const checkboxTitle = localize('customizationMigrationSelectAriaLabel', "Select {0}", customization.name ?? basename(customization.uri)); - const checkbox = this.migrationPageDisposables.add(new Checkbox(checkboxTitle, this.isCustomizationSelectedForMigration(customization), defaultCheckboxStyles)); - checkboxContainer.replaceChildren(checkbox.domNode); - this.migrationFirstFocusableElement ??= checkbox.domNode; - this.migrationPageDisposables.add(checkbox.onChange(() => { - this.setCustomizationSelectedForMigration(customization, checkbox.checked); - this.updateCustomizationMigrationActionState(); - onSelectionChange?.(); - })); - return checkbox; - }; - - const renderItem = (container: HTMLElement, customization: MigratableConfiguration, onSelectionChange?: () => void): Checkbox => { - const row = DOM.append(container, $('div.ai-customization-list-item.prompt-migration-item')); - const checkbox = renderSelectionCheckbox(row, customization, onSelectionChange); - - const itemLeft = DOM.append(row, $('span.item-left')); - const displayName = customization.name ?? basename(customization.uri); - const relativePath = this.labelService.getUriLabel(customization.uri, { relative: true }); - const openButton = this.migrationPageDisposables.add(new Button(itemLeft, { - ariaLabel: localize('openCustomizationFile', "Open {0}, {1}", displayName, relativePath), - })); - openButton.label = displayName; - DOM.clearNode(openButton.element); - openButton.element.classList.add('item-text', 'prompt-migration-open-button'); - this.migrationPageDisposables.add(openButton.onDidClick(() => openCustomizationInEmbeddedEditor(customization))); - const itemText = openButton.element; - const nameRow = DOM.append(itemText, $('span.item-name-row')); - const nameLabel = DOM.append(nameRow, $('span.item-name.prompt-migration-item-name')); - nameLabel.textContent = displayName; - - const pathLabel = DOM.append(itemText, $('span.item-description.is-filename.prompt-migration-item-path')); - pathLabel.textContent = relativePath; - - const itemRight = DOM.append(row, $('span.item-right')); - const moreButton = DOM.append(itemRight, $('button.icon-button.prompt-migration-more-action', { - type: 'button', - 'aria-label': localize('customizationMigrationMoreActions', "More actions for {0}", customization.name ?? basename(customization.uri)), - })) as HTMLButtonElement; - moreButton.classList.add(...ThemeIcon.asClassNameArray(Codicon.ellipsis)); - this.migrationPageDisposables.add(this.hoverService.setupManagedHover(getDefaultHoverDelegate('element'), moreButton, localize('moreActions', "More Actions"))); - this.migrationPageDisposables.add(DOM.addDisposableListener(moreButton, 'click', event => { - event.stopPropagation(); - const actions = new DisposableStore(); - const deleteAction = actions.add(new Action( - 'customizationMigration.delete', - localize('delete', "Delete"), - ThemeIcon.asClassName(Codicon.trash), - true, - () => this.deleteCustomizationFile(customization), - )); - this.contextMenuService.showContextMenu({ - getAnchor: () => moreButton, - getActions: () => [deleteAction], - onHide: () => actions.dispose(), - }); - })); - return checkbox; - }; - const renderGroup = (groupKey: string, groupLabel: string, customizations: readonly MigratableConfiguration[]): void => { const group = DOM.append(this.migrationListContainer!, $('.prompt-migration-group')); const groupHeader = DOM.append(group, $('.prompt-migration-group-header')); @@ -1513,6 +1611,23 @@ export class AICustomizationManagementEditor extends EditorPane { "No customizations are available to migrate from {0}.", groupLabel, ); + const sectionKey = `${category.id}:${groupKey}`; + const collapsedSections = this.collapsedMigrationSections ??= new Set(); + setupCollapsibleSection( + groupHeading, + emptyItems, + groupLabel, + this.migrationPageDisposables, + collapsedSections.has(sectionKey), + collapsed => { + if (collapsed) { + collapsedSections.add(sectionKey); + } else { + collapsedSections.delete(sectionKey); + } + this.scheduleMigrationSectionLayout(); + }, + ); return; } const selectedInGroup = customizations.filter(customization => this.isCustomizationSelectedForMigration(customization)).length; @@ -1532,15 +1647,37 @@ export class AICustomizationManagementEditor extends EditorPane { groupCheckbox.domNode.setAttribute('aria-checked', String(state)); }; setGroupCheckboxState(initialGroupState); - const itemCheckboxes: Checkbox[] = []; + const updateGroupCheckboxState = (): void => { + const selectedCount = customizations.filter(customization => this.isCustomizationSelectedForMigration(customization)).length; + setGroupCheckboxState(selectedCount === customizations.length ? true : selectedCount === 0 ? false : 'mixed'); + }; + const groupId = `prompt-migration-group-${category.id}-${groupKey}`; + const groupItems = DOM.append(group, $('.prompt-migration-group-items.virtualized-section-list')); + groupItems.id = `${groupId}-items`; + const sectionKey = `${category.id}:${groupKey}`; + const collapsedSections = this.collapsedMigrationSections ??= new Set(); + setupCollapsibleSection( + groupHeading, + groupItems, + groupLabel, + this.migrationPageDisposables, + collapsedSections.has(sectionKey), + collapsed => { + if (collapsed) { + collapsedSections.add(sectionKey); + } else { + collapsedSections.delete(sectionKey); + } + this.scheduleMigrationSectionLayout(); + }, + ); + const section = this.createMigrationSectionList(groupItems, sectionKey, groupLabel, customizations, updateGroupCheckboxState); const setGroupSelection = (selected: boolean): void => { for (const customization of customizations) { this.setCustomizationSelectedForMigration(customization, selected); } - for (const itemCheckbox of itemCheckboxes) { - itemCheckbox.checked = selected; - } this.updateCustomizationMigrationActionState(); + section.renderer.refreshSelectionState(); }; this.migrationPageDisposables.add(groupCheckbox.onChange(() => setGroupSelection(groupCheckbox.checked === true))); this.migrationPageDisposables.add(DOM.addDisposableListener(selectAllLabel, 'click', e => { @@ -1550,17 +1687,6 @@ export class AICustomizationManagementEditor extends EditorPane { setGroupSelection(selected); groupCheckbox.focus(); })); - const updateGroupCheckboxState = (): void => { - const selectedCount = customizations.filter(customization => this.isCustomizationSelectedForMigration(customization)).length; - setGroupCheckboxState(selectedCount === customizations.length ? true : selectedCount === 0 ? false : 'mixed'); - }; - const groupId = `prompt-migration-group-${category.id}-${groupKey}`; - const groupItems = DOM.append(group, $('.prompt-migration-group-items')); - groupItems.id = `${groupId}-items`; - - for (const customization of customizations) { - itemCheckboxes.push(renderItem(groupItems, customization, updateGroupCheckboxState)); - } }; const groups = category.group(candidates); @@ -1572,12 +1698,161 @@ export class AICustomizationManagementEditor extends EditorPane { renderGroup(group.key, group.label, group.customizations); } - for (const customization of candidates.filter(item => !groupedUris.has(item.uri))) { - renderItem(this.migrationListContainer, customization); + const ungroupedCandidates = candidates.filter(item => !groupedUris.has(item.uri)); + if (ungroupedCandidates.length > 0) { + const ungroupedItems = DOM.append(this.migrationListContainer, $('.prompt-migration-group-items.virtualized-section-list')); + this.createMigrationSectionList(ungroupedItems, `${category.id}:ungrouped`, category.pageTitle, ungroupedCandidates); } this.updateCustomizationMigrationActionState(); - this.migrationListScrollable?.scanDomNode(); + this.scheduleMigrationSectionLayout(); + } + + private createMigrationSectionList( + container: HTMLElement, + key: string, + label: string, + items: readonly MigratableConfiguration[], + onSelectionChange?: () => void, + ): IMigrationSectionList { + container.style.height = `${MIGRATION_ITEM_HEIGHT}px`; + const renderer = new MigrationItemRenderer( + customization => this.isCustomizationSelectedForMigration(customization), + customization => this.labelService.getUriLabel(customization.uri, { relative: true }), + (customization, selected) => { + this.setCustomizationSelectedForMigration(customization, selected); + this.updateCustomizationMigrationActionState(); + onSelectionChange?.(); + }, + customization => { + const isWorkspaceFile = customization.storage === PromptsStorage.local; + void this.showEmbeddedEditor( + customization.uri, + customization.name ?? basename(customization.uri), + customization.type, + customization.storage, + isWorkspaceFile, + ); + }, + (customization, anchor) => this.showCustomizationMigrationItemActions(customization, anchor), + element => this.migrationFirstFocusableElement ??= element, + this.hoverService, + ); + const list = this.migrationPageDisposables.add(this.instantiationService.createInstance( + WorkbenchList, + `CustomizationMigration.${label}`, + container, + new MigrationItemDelegate(), + [renderer], + { + multipleSelectionSupport: false, + horizontalScrolling: false, + accessibilityProvider: { + getWidgetAriaLabel: () => label, + getAriaLabel: customization => localize( + 'customizationMigrationItemAriaLabel', + "{0}, {1}", + customization.name ?? basename(customization.uri), + this.labelService.getUriLabel(customization.uri, { relative: true }), + ), + getSetSize: (_element, _index, listLength) => listLength, + getPosInSet: (_element, index) => index + 1, + }, + identityProvider: { + getId: customization => `${customization.storage}:${customization.uri.toString()}`, + }, + }, + )); + list.splice(0, 0, items); + list.scrollTop = this.migrationSectionScrollPositions?.get(key) ?? 0; + this.migrationPageDisposables.add(list.onDidChangeSelection(event => { + if (event.indexes.length > 0) { + list.setSelection([]); + } + })); + this.migrationPageDisposables.add(DOM.addStandardDisposableListener(container, DOM.EventType.KEY_DOWN, event => { + if (event.keyCode !== KeyCode.Tab) { + return; + } + const target = event.target; + if (!DOM.isHTMLElement(target)) { + return; + } + const index = renderer.getIndex(target); + if (index === undefined) { + return; + } + const controls = renderer.getControls(index); + const controlIndex = controls.findIndex(control => control === target || control.contains(target)); + const targetIndex = event.shiftKey ? index - 1 : index + 1; + const crossesRowBoundary = event.shiftKey ? controlIndex === 0 : controlIndex === controls.length - 1; + if (!crossesRowBoundary || targetIndex < 0 || targetIndex >= items.length) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + list.setFocus([targetIndex]); + list.reveal(targetIndex); + const targetControls = renderer.getControls(targetIndex); + targetControls[event.shiftKey ? targetControls.length - 1 : 0]?.focus(); + })); + const section = { list, renderer, container, items, key }; + this.migrationSectionLists.push(section); + return section; + } + + private showCustomizationMigrationItemActions(customization: MigratableConfiguration, anchor: HTMLElement): void { + const actions = new DisposableStore(); + const deleteAction = actions.add(new Action( + 'customizationMigration.delete', + localize('delete', "Delete"), + ThemeIcon.asClassName(Codicon.trash), + true, + () => this.deleteCustomizationFile(customization), + )); + this.contextMenuService.showContextMenu({ + getAnchor: () => anchor, + getActions: () => [deleteAction], + onHide: () => actions.dispose(), + }); + } + + private layoutMigrationSectionLists(): void { + if (!this.migrationListContainer) { + return; + } + const heights = layoutVirtualizedSections(this.migrationListContainer, this.migrationSectionLists.map(section => ({ + container: section.container, + contentHeight: section.items.length * MIGRATION_ITEM_HEIGHT, + minimumHeight: MIGRATION_ITEM_HEIGHT, + }))); + for (let index = 0; index < this.migrationSectionLists.length; index++) { + const section = this.migrationSectionLists[index]; + const height = heights[index]; + layoutVirtualizedSectionList(section.list, section.container, height, section.container.clientWidth || undefined); + } + } + + private scheduleMigrationSectionLayout(): void { + if (this.migrationContentContainer?.style.display === 'none') { + this.pendingMigrationLayout?.clear(); + return; + } + if (!this.pendingMigrationLayout) { + this.layoutMigrationSectionLists(); + this.migrationListScrollable?.scanDomNode(); + return; + } + + if (!this.migrationListContainer) { + this.pendingMigrationLayout.clear(); + return; + } + this.pendingMigrationLayout.value = DOM.scheduleAtNextAnimationFrame(DOM.getWindow(this.migrationListContainer), () => { + this.layoutMigrationSectionLists(); + this.migrationListScrollable?.scanDomNode(); + }); } private renderCustomizationMigrationState(title: string, description: string, retry?: () => void): void { @@ -2342,7 +2617,9 @@ export class AICustomizationManagementEditor extends EditorPane { for (const widget of this.contributedSectionWidgets.values()) { widget.layout?.(dimension); } - this.migrationListScrollable?.scanDomNode(); + if (this.viewMode === 'migration') { + this.scheduleMigrationSectionLayout(); + } } override focus(): void { diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationCardList.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationCardList.ts index 7cddeff5b590e..2aa042851f1ba 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationCardList.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationCardList.ts @@ -5,9 +5,13 @@ import * as DOM from '../../../../../base/browser/dom.js'; import { disposableTimeout } from '../../../../../base/common/async.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; import { Disposable, DisposableStore, MutableDisposable } from '../../../../../base/common/lifecycle.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { localize } from '../../../../../nls.js'; const $ = DOM.$; +let collapsibleSectionIdPool = 0; export interface ICustomizationCardListItem { readonly row: HTMLElement; @@ -30,6 +34,161 @@ export function createCustomizationCardPrimaryAction(parent: HTMLElement, ariaLa return button; } +export function setupCollapsibleSection( + headingRow: HTMLElement, + content: HTMLElement, + label: string, + disposables: DisposableStore, + initiallyCollapsed: boolean, + onDidChange: (collapsed: boolean) => void, +): HTMLButtonElement { + const toggle = $('.customization-section-toggle') as HTMLButtonElement; + toggle.type = 'button'; + headingRow.prepend(toggle); + content.id ||= `customization-section-content-${++collapsibleSectionIdPool}`; + toggle.setAttribute('aria-controls', content.id); + + let collapsed = initiallyCollapsed; + const expandedDisplay = content.style.display; + const update = () => { + toggle.className = 'customization-section-toggle'; + toggle.classList.add(...ThemeIcon.asClassName(collapsed ? Codicon.chevronRight : Codicon.chevronDown).split(' ')); + toggle.setAttribute('aria-expanded', String(!collapsed)); + toggle.setAttribute('aria-label', collapsed + ? localize('expandCustomizationSection', "Expand {0}", label) + : localize('collapseCustomizationSection', "Collapse {0}", label)); + content.hidden = collapsed; + content.style.display = collapsed ? 'none' : expandedDisplay; + }; + update(); + + disposables.add(DOM.addDisposableListener(toggle, DOM.EventType.CLICK, event => { + DOM.EventHelper.stop(event, true); + collapsed = !collapsed; + update(); + onDidChange(collapsed); + })); + return toggle; +} + +export interface IVirtualizedSectionLayout { + readonly container: HTMLElement; + readonly contentHeight: number; + readonly minimumHeight: number; +} + +export function renderVirtualizedSectionLoadingPlaceholder(container: HTMLElement, label: string, height: number): HTMLElement { + const placeholder = DOM.append(container, $('.virtualized-section-loading')); + placeholder.style.height = `${height}px`; + placeholder.textContent = label; + return placeholder; +} + +export interface IVirtualizedSectionList { + scrollTop: number; + layout(height: number, width?: number): void; +} + +export function layoutVirtualizedSectionList(list: IVirtualizedSectionList, container: HTMLElement, height: number, width?: number): void { + if (height === 0) { + container.style.height = '0px'; + return; + } + + const scrollTop = list.scrollTop; + container.style.height = `${height}px`; + list.layout(height, width); + list.scrollTop = scrollTop; +} + +export function setVirtualizedRowActionsTabbable(container: HTMLElement, tabbable: boolean): void { + const visit = (element: Element): void => { + if (DOM.isHTMLElement(element)) { + const role = element.getAttribute('role'); + const isAction = DOM.isHTMLButtonElement(element) + || DOM.isHTMLAnchorElement(element) && element.hasAttribute('href') + || role === 'button' + || role === 'switch' + || role === 'checkbox' + || role === 'menuitem'; + if (isAction) { + const disabled = DOM.isHTMLButtonElement(element) && element.disabled || element.getAttribute('aria-disabled') === 'true'; + element.tabIndex = tabbable && !disabled ? 0 : -1; + } + } + for (const child of element.children) { + visit(child); + } + }; + visit(container); +} + +export function layoutVirtualizedSections(root: HTMLElement, sections: readonly IVirtualizedSectionLayout[]): readonly number[] { + const visibleSections = sections.filter(section => !section.container.hidden); + const availableRootHeight = root.clientHeight; + if (availableRootHeight <= 0) { + root.classList.remove('virtualized-section-layout-overflow'); + root.style.overflow = ''; + return sections.map(section => section.container.hidden ? 0 : section.contentHeight); + } + + const targetWindow = DOM.getWindow(root); + const rootStyle = targetWindow.getComputedStyle(root); + let fixedHeight = (parseFloat(rootStyle.paddingTop) || 0) + (parseFloat(rootStyle.paddingBottom) || 0); + const children = Array.from(root.children) as HTMLElement[]; + const rowGap = parseFloat(rootStyle.rowGap) || 0; + fixedHeight += Math.max(0, children.length - 1) * rowGap; + + for (const child of children) { + const childStyle = targetWindow.getComputedStyle(child); + let childHeight = child.offsetHeight + (parseFloat(childStyle.marginTop) || 0) + (parseFloat(childStyle.marginBottom) || 0); + for (const section of visibleSections) { + if (child === section.container || child.contains(section.container)) { + childHeight -= section.container.clientHeight || section.container.offsetHeight; + } + } + fixedHeight += childHeight; + } + + const availableListHeightBeforeMinimums = availableRootHeight - fixedHeight; + const availableListHeight = Math.max(0, availableListHeightBeforeMinimums); + const allocations = new Map(); + const minimumAllocations = new Map(visibleSections.map(section => [ + section, + Math.min(section.contentHeight, section.minimumHeight), + ])); + const minimumListHeight = visibleSections.reduce((height, section) => height + minimumAllocations.get(section)!, 0); + const requiresPageScroll = minimumListHeight - availableListHeightBeforeMinimums > 1; + root.classList.toggle('virtualized-section-layout-overflow', requiresPageScroll); + root.style.overflow = requiresPageScroll ? 'visible' : ''; + if (availableListHeight <= minimumListHeight) { + return sections.map(section => section.container.hidden ? 0 : minimumAllocations.get(section) ?? 0); + } + + for (const section of visibleSections) { + allocations.set(section, minimumAllocations.get(section)!); + } + let remainingHeight = availableListHeight - minimumListHeight; + let remainingSections = visibleSections.filter(section => section.contentHeight > minimumAllocations.get(section)!); + while (remainingSections.length > 0) { + const equalShare = remainingHeight / remainingSections.length; + const completed = remainingSections.filter(section => section.contentHeight - allocations.get(section)! <= equalShare); + if (completed.length === 0) { + for (const section of remainingSections) { + allocations.set(section, allocations.get(section)! + equalShare); + } + break; + } + for (const section of completed) { + remainingHeight -= section.contentHeight - allocations.get(section)!; + allocations.set(section, section.contentHeight); + } + remainingSections = remainingSections.filter(section => !completed.includes(section)); + } + + return sections.map(section => section.container.hidden ? 0 : Math.max(0, Math.floor(allocations.get(section) ?? 0))); +} + export class CustomizationCardListController extends Disposable { private readonly items: ICardListItem[] = []; diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts index 28aec0ee2b992..8f3d2853cbc62 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts @@ -5,14 +5,16 @@ import './media/aiCustomizationManagement.css'; import * as DOM from '../../../../../base/browser/dom.js'; +import { IMouseEvent } from '../../../../../base/browser/mouseEvent.js'; import { Disposable, DisposableStore, isDisposable, MutableDisposable } from '../../../../../base/common/lifecycle.js'; import { Emitter } from '../../../../../base/common/event.js'; import { localize } from '../../../../../nls.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; -import { IListRenderer } from '../../../../../base/browser/ui/list/list.js'; +import { IListRenderer, IListVirtualDelegate } from '../../../../../base/browser/ui/list/list.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { Button } from '../../../../../base/browser/ui/button/button.js'; +import { Switch } from '../../../../../base/browser/ui/toggle/switch.js'; import { defaultButtonStyles, defaultInputBoxStyles, getButtonStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; @@ -24,7 +26,7 @@ import { MCP_PLUGIN_COLLECTION_ID_PREFIX } from '../../../mcp/common/discovery/p import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; import { ContributionEnablementState, isContributionDisabled, isContributionEnabled } from '../../common/enablement.js'; import { McpCommandIds } from '../../../../contrib/mcp/common/mcpCommandIds.js'; -import { autorun } from '../../../../../base/common/observable.js'; +import { autorun, derived, IObservable, observableSignalFromEvent } from '../../../../../base/common/observable.js'; import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; import { URI } from '../../../../../base/common/uri.js'; import { InputBox, MessageType } from '../../../../../base/browser/ui/inputbox/inputBox.js'; @@ -54,13 +56,15 @@ import { status } from '../../../../../base/browser/ui/aria/aria.js'; import { Range } from '../../../../../editor/common/core/range.js'; import { IMcpServerConfiguration, McpServerType } from '../../../../../platform/mcp/common/mcpPlatformTypes.js'; import { createWorkbenchMcpServerDetailInput, IMcpServerDetailInput } from './embeddedMcpServerDetail.js'; -import { createCustomizationCardPrimaryAction, CustomizationCardListController } from './customizationCardList.js'; +import { createCustomizationCardPrimaryAction, CustomizationCardListController, layoutVirtualizedSectionList, layoutVirtualizedSections, renderVirtualizedSectionLoadingPlaceholder, setVirtualizedRowActionsTabbable, setupCollapsibleSection } from './customizationCardList.js'; import { DomScrollableElement } from '../../../../../base/browser/ui/scrollbar/scrollableElement.js'; import { ScrollbarVisibility } from '../../../../../base/common/scrollable.js'; +import { WorkbenchList } from '../../../../../platform/list/browser/listService.js'; const $ = DOM.$; const PLUGIN_COLLECTION_PREFIX = MCP_PLUGIN_COLLECTION_ID_PREFIX; +const MCP_SECTION_ITEM_HEIGHT = 66; const COPILOT_EXTENSION_IDS = ['github.copilot', 'github.copilot-chat']; @@ -118,6 +122,30 @@ export function isMcpServerCollectionVisible(collectionId: string, hiddenCollect type IMcpInstalledEntry = IMcpServerItemEntry | IMcpSessionServerItemEntry | IMcpBuiltinItemEntry; +interface IMcpMarketplaceEntry { + readonly type: 'marketplace-item'; + readonly server: IWorkbenchMcpServer; +} + +type IMcpSectionEntry = IMcpInstalledEntry | IMcpMarketplaceEntry; + +interface IMcpSectionList { + readonly list: WorkbenchList; + readonly entries: readonly IMcpSectionEntry[]; + readonly container: HTMLElement; + readonly key: string; +} + +class McpSectionDelegate implements IListVirtualDelegate { + getHeight(): number { + return MCP_SECTION_ITEM_HEIGHT; + } + + getTemplateId(element: IMcpSectionEntry): string { + return element.type === 'marketplace-item' ? 'mcpMarketplaceItem' : 'mcpServerItem'; + } +} + interface IMcpInstalledPresentation { readonly entry: IMcpInstalledEntry; } @@ -141,6 +169,7 @@ interface IMcpServerItemTemplateData { readonly container: HTMLElement; readonly typeIcon: HTMLElement; readonly name: HTMLElement; + readonly statusBadge: HTMLElement; readonly description: HTMLElement; readonly actions: HTMLElement; readonly elementDisposables: DisposableStore; @@ -149,6 +178,7 @@ interface IMcpServerItemTemplateData { renderedRowKey?: string; /** What the actions currently show, so an unchanged status does not rebuild them. */ renderedStatusSignature?: string; + currentIndex: number; } /** @@ -162,9 +192,12 @@ interface IMcpServerItemTemplateData { */ export class McpServerItemRenderer implements IListRenderer { readonly templateId = 'mcpServerItem'; + private readonly _templates = new Set(); + private _focusedIndex = -1; constructor( private readonly _afterShowOutput: () => Promise, + private readonly _renderManagementActions: (element: IMcpInstalledEntry, actions: HTMLElement, disposables: DisposableStore, updateTabbability: () => void) => void, @IAICustomizationWorkspaceService private readonly workspaceService: IAICustomizationWorkspaceService, @IAgentPluginService private readonly agentPluginService: IAgentPluginService, @IHoverService private readonly hoverService: IHoverService, @@ -182,23 +215,30 @@ export class McpServerItemRenderer implements IListRenderer this.updateActionsTabbability(templateData)); + this.updateActionsTabbability(templateData); return; } @@ -367,6 +418,8 @@ export class McpServerItemRenderer implements IListRenderer this.updateActionsTabbability(templateData)); + this.updateActionsTabbability(templateData); return; } @@ -382,21 +435,101 @@ export class McpServerItemRenderer implements IListRenderer this.updateActionsTabbability(templateData)); + this.updateActionsTabbability(templateData); + } - const statusElement = DOM.append(templateData.actions, $('.mcp-server-status')); - statusElement.classList.add(presentation.className, ...ThemeIcon.asClassNameArray(presentation.icon)); - statusElement.setAttribute('aria-hidden', 'true'); - templateData.actionDisposables.add(this.hoverService.setupManagedHover(getDefaultHoverDelegate('element'), statusElement, presentation.label)); + setFocusedIndex(index: number): void { + this._focusedIndex = index; + for (const template of this._templates) { + this.updateActionsTabbability(template); + } + } + + private updateActionsTabbability(templateData: IMcpServerItemTemplateData): void { + setVirtualizedRowActionsTabbable(templateData.actions, templateData.currentIndex === this._focusedIndex); } disposeTemplate(templateData: IMcpServerItemTemplateData): void { + this._templates.delete(templateData); templateData.elementDisposables.dispose(); templateData.actionDisposables.dispose(); } } +interface IMcpMarketplaceItemTemplateData { + readonly container: HTMLElement; + readonly name: HTMLElement; + readonly description: HTMLElement; + readonly installButton: Button; + readonly elementDisposables: DisposableStore; + readonly templateDisposables: DisposableStore; + currentIndex: number; +} + +class McpMarketplaceItemRenderer implements IListRenderer { + readonly templateId = 'mcpMarketplaceItem'; + private readonly _templates = new Set(); + private _focusedIndex = -1; + + constructor( + private readonly _install: (server: IWorkbenchMcpServer, button: Button) => Promise, + ) { } + + renderTemplate(container: HTMLElement): IMcpMarketplaceItemTemplateData { + container.classList.add('plugin-list-item', 'plugin-marketplace-home-row'); + const details = DOM.append(container, $('.plugin-list-item-details')); + const name = DOM.append(DOM.append(details, $('.plugin-list-item-name-row')), $('.plugin-list-item-name')); + const description = DOM.append(details, $('.plugin-list-item-description')); + const actionContainer = DOM.append(container, $('.plugin-list-item-action')); + const installButton = new Button(actionContainer, defaultButtonStyles); + installButton.element.classList.add('plugin-list-item-install-button'); + const templateDisposables = new DisposableStore(); + templateDisposables.add(installButton); + templateDisposables.add(DOM.addDisposableGenericMouseDownListener(installButton.element, event => DOM.EventHelper.stop(event, true))); + const template = { container, name, description, installButton, elementDisposables: new DisposableStore(), templateDisposables, currentIndex: -1 }; + this._templates.add(template); + return template; + } + + renderElement(element: IMcpMarketplaceEntry, index: number, templateData: IMcpMarketplaceItemTemplateData): void { + templateData.elementDisposables.clear(); + templateData.currentIndex = index; + templateData.name.textContent = element.server.label; + templateData.description.textContent = truncateToFirstLine(element.server.description || localize('mcpNoDescription', "No description provided.")); + templateData.installButton.label = localize('install', "Install"); + templateData.installButton.enabled = true; + templateData.installButton.element.tabIndex = index === this._focusedIndex ? 0 : -1; + templateData.elementDisposables.add(templateData.installButton.onDidClick(event => { + DOM.EventHelper.stop(event, true); + void this._install(element.server, templateData.installButton); + })); + } + + setFocusedIndex(index: number): void { + this._focusedIndex = index; + for (const template of this._templates) { + template.installButton.element.tabIndex = template.currentIndex === index ? 0 : -1; + } + } + + disposeElement(_element: IMcpMarketplaceEntry, _index: number, templateData: IMcpMarketplaceItemTemplateData): void { + templateData.elementDisposables.clear(); + } + + disposeTemplate(templateData: IMcpMarketplaceItemTemplateData): void { + this._templates.delete(templateData); + templateData.elementDisposables.dispose(); + templateData.templateDisposables.dispose(); + } +} + function createMcpSignInButton(parent: HTMLElement, store: Pick, serverLabel: string): Button { const signInLabel = localize('signInToMcpServer', "Sign in to {0}", serverLabel); const signInButton = store.add(new Button(parent, { @@ -1068,6 +1201,7 @@ export class McpListWidget extends Disposable { private cardContainer!: HTMLElement; private cardScrollable!: DomScrollableElement; private cardScrollableNode!: HTMLElement; + private sectionLayoutContainer: HTMLElement | undefined; private emptyContainer!: HTMLElement; private emptyText!: HTMLElement; private emptySubtext!: HTMLElement; @@ -1097,11 +1231,17 @@ export class McpListWidget extends Disposable { private lastWidth: number = 0; private lastHeaderHeight = 0; private _layoutDeferred = false; + private readonly revealLastItemScheduler = this._register(new MutableDisposable()); + private readonly sectionScrollPositions = new Map(); private galleryCts: CancellationTokenSource | undefined; private readonly cardDisposables = this._register(new DisposableStore()); + private readonly pendingSectionLayout = this._register(new MutableDisposable()); private readonly cardListControllers = new WeakMap(); + private sectionLists: IMcpSectionList[] = []; + private collapsedSections: Set | undefined = new Set(); private readonly delayedFilter = new Delayer(200); private readonly delayedGallerySearch = new Delayer(400); + private readonly agentHostCustomizationsChanged: IObservable; constructor( @IInstantiationService private readonly instantiationService: IInstantiationService, @@ -1122,6 +1262,7 @@ export class McpListWidget extends Disposable { @IMcpGalleryManifestService mcpGalleryManifestService: IMcpGalleryManifestService, ) { super(); + this.agentHostCustomizationsChanged = observableSignalFromEvent(this, this.agentHostCustomizationService.onDidChangeCustomizations); this.element = $('.mcp-list-widget.plugin-list-widget'); this.create(); const resizeObserver = this._register(new DOM.DisposableResizeObserver( @@ -1377,6 +1518,9 @@ export class McpListWidget extends Disposable { this.galleryCts?.dispose(true); const cts = this.galleryCts = new CancellationTokenSource(); this.gallerySnapshotLoading = true; + if (!revealMarketplace && !this.searchQuery.trim()) { + this.renderMcpHome(); + } try { const pager = await this.mcpWorkbenchService.queryGallery(undefined, cts.token); @@ -1454,9 +1598,10 @@ export class McpListWidget extends Disposable { private createCardScrollContent(...classNames: string[]): HTMLElement { const content = DOM.append(this.cardContainer, $('.plugin-card-scroll.plugin-card-scroll-content')); content.classList.add(...classNames); + this.sectionLayoutContainer = classNames.includes('distributed-section-layout') ? content : undefined; const resizeObserver = this.cardDisposables.add(new DOM.DisposableResizeObserver( 'McpListWidget.cardScrollContent', - () => this.cardScrollable.scanDomNode(), + () => this.scheduleMcpSectionLayout(), )); this.cardDisposables.add(resizeObserver.observe(content)); return content; @@ -1487,23 +1632,214 @@ export class McpListWidget extends Disposable { } renderActions?.(header); const list = DOM.append(section, $('.plugin-card-grid')); + list.dataset.virtualizedSectionKey = className; + const collapsedSections = this.collapsedSections ??= new Set(); + setupCollapsibleSection( + headingRow, + list, + title, + this.cardDisposables, + collapsedSections.has(className), + collapsed => { + if (collapsed) { + collapsedSections.add(className); + } else { + collapsedSections.delete(className); + } + this.scheduleMcpSectionLayout(); + }, + ); this.cardListControllers.set(list, this.cardDisposables.add(new CustomizationCardListController(list, title))); return list; } + private createMcpSectionList(container: HTMLElement, label: string, entries: readonly IMcpSectionEntry[]): void { + const key = container.dataset.virtualizedSectionKey ?? label; + container.style.height = `${MCP_SECTION_ITEM_HEIGHT}px`; + container.classList.add('virtualized-section-list'); + this.cardListControllers.get(container)?.dispose(); + this.cardListControllers.delete(container); + container.removeAttribute('role'); + container.removeAttribute('aria-label'); + const itemRenderer = this.instantiationService.createInstance( + McpServerItemRenderer, + () => Promise.resolve(), + (entry, actions, disposables, updateTabbability) => this.renderMcpListActions(entry, actions, disposables, updateTabbability), + ); + const marketplaceRenderer = new McpMarketplaceItemRenderer((server, button) => this.installMarketplaceServer(server, button)); + const list = this.cardDisposables.add(this.instantiationService.createInstance( + WorkbenchList, + `McpManagementList.${label}`, + container, + new McpSectionDelegate(), + [itemRenderer, marketplaceRenderer], + { + multipleSelectionSupport: false, + setRowLineHeight: false, + horizontalScrolling: false, + accessibilityProvider: { + getAriaLabel: entry => entry.type === 'marketplace-item' + ? localize('marketplaceMcpServerRowAriaLabel', "{0}. Available to install from the MCP marketplace.", entry.server.label) + : this.getMcpEntryAriaLabel(entry), + getWidgetAriaLabel: () => label, + getSetSize: (_entry, _index, listLength) => listLength, + getPosInSet: (_entry, index) => index + 1, + }, + openOnSingleClick: true, + identityProvider: { + getId: entry => entry.type === 'marketplace-item' ? `marketplace:${entry.server.id}` : getMcpRowKey(entry), + }, + }, + )); + list.splice(0, 0, entries); + list.scrollTop = this.sectionScrollPositions.get(key) ?? 0; + this.cardDisposables.add(list.onDidOpen(event => { + const entry = event.element; + if (!entry) { + return; + } + this._onDidSelectServer.fire(entry.type === 'marketplace-item' + ? createWorkbenchMcpServerDetailInput(entry.server) + : createInstalledMcpServerDetailInput(entry)); + })); + this.cardDisposables.add(list.onContextMenu(event => { + if (event.element && event.element.type !== 'marketplace-item') { + this.showMcpServerActions(event.element, event.anchor); + } + })); + this.cardDisposables.add(list.onDidChangeFocus(event => { + const index = event.indexes[0] ?? -1; + itemRenderer.setFocusedIndex(index); + marketplaceRenderer.setFocusedIndex(index); + })); + this.cardDisposables.add(list.onDidFocus(() => { + if (list.getFocus().length === 0 && entries.length > 0) { + list.setFocus([0]); + } + })); + this.sectionLists.push({ list, entries, container, key }); + } + + private captureSectionScrollPositions(): void { + for (const section of this.sectionLists) { + this.sectionScrollPositions.set(section.key, section.list.scrollTop); + } + } + + private getMcpEntryAriaLabel(entry: IMcpInstalledEntry): IObservable { + return derived(this, reader => { + this.agentHostCustomizationsChanged.read(reader); + const label = getMcpEntryLabel(entry); + const activeSessionResource = this.customizationHarnessService.activeSessionResource.read(reader); + let statusKind: McpStatusKind | undefined; + let disabledReason: CustomizationDisabledReason | undefined; + if (entry.type === 'session-server-item') { + const server = this.agentHostCustomizationService.getMcpServers(activeSessionResource).find(server => server.id === entry.server.id) ?? entry.server; + const presentation = getActiveSessionServerPresentation(server); + statusKind = presentation.status; + disabledReason = presentation.enabled ? undefined : server.disabledReason; + } else if (entry.activeSessionServer !== undefined) { + const server = this.agentHostCustomizationService.getMcpServers(activeSessionResource).find(server => server.id === entry.activeSessionServer?.id) ?? entry.activeSessionServer; + const presentation = getActiveSessionServerPresentation(server); + statusKind = presentation.status; + disabledReason = presentation.enabled ? undefined : server.disabledReason; + } else if (entry.localServer && isContributionDisabled(entry.localServer.enablement.read(reader))) { + statusKind = 'disabled'; + disabledReason = getMcpDisabledReason(entry); + } else if (entry.type === 'server-item' && !this.workspaceService.isSessionsWindow) { + statusKind = entry.localServer?.connectionState.read(reader).state; + } + const status = getMcpStatusPresentation(statusKind, disabledReason); + return status ? localize('mcpServerAriaLabelWithStatus', "{0}, {1}", label, status.label) : label; + }); + } + + private renderMcpListActions(entry: IMcpInstalledEntry, actions: HTMLElement, disposables: DisposableStore, updateTabbability: () => void): void { + const label = getMcpEntryLabel(entry); + let enabled = this.isInstalledEntryEnabled(entry); + const switchElement = DOM.append(actions, $('button.plugin-enable-switch')) as HTMLButtonElement; + switchElement.type = 'button'; + switchElement.setAttribute('role', 'switch'); + DOM.append(switchElement, $('.plugin-enable-switch-thumb')); + const update = () => { + enabled = this.isInstalledEntryEnabled(entry); + const blockedByPlugin = getMcpDisabledReason(entry)?.source === 'plugin'; + const toggleLabel = enabled ? localize('disableMcpServerAria', "Disable {0}", label) : localize('enableMcpServerAria', "Enable {0}", label); + const accessibleLabel = blockedByPlugin ? localize('mcpServerManagedByPluginAria', "{0} is disabled by its plugin", label) : toggleLabel; + switchElement.disabled = blockedByPlugin; + switchElement.classList.toggle('checked', enabled); + switchElement.setAttribute('aria-checked', String(enabled)); + switchElement.setAttribute('aria-label', accessibleLabel); + switchElement.title = accessibleLabel; + updateTabbability(); + }; + update(); + disposables.add(DOM.addDisposableGenericMouseDownListener(switchElement, event => DOM.EventHelper.stop(event, true))); + disposables.add(DOM.addDisposableListener(switchElement, 'click', event => { + DOM.EventHelper.stop(event, true); + enabled = !enabled; + this.setInstalledEntryEnabled(entry, enabled); + update(); + status(enabled ? localize('mcpServerEnabledStatus', "{0} enabled.", label) : localize('mcpServerDisabledStatus', "{0} disabled.", label)); + })); + if (entry.type !== 'session-server-item' && entry.localServer) { + disposables.add(autorun(reader => { + entry.localServer?.enablement.read(reader); + update(); + })); + } + disposables.add(this.agentHostCustomizationService.onDidChangeCustomizations(update)); + + const more = disposables.add(new Button(actions, { + ...getButtonStyles({ buttonSecondaryBackground: undefined, buttonSecondaryBorder: undefined }), + secondary: true, + supportIcons: true, + ariaLabel: localize('mcpMoreActionsAria', "More actions for {0}", label), + })); + more.element.classList.add('plugin-card-icon-button'); + more.label = `$(${Codicon.ellipsis.id})`; + registerMcpInlineButtonAction(disposables, more, () => this.showMcpServerActions(entry, more.element)); + } + + private layoutMcpSectionLists(): void { + const content = this.sectionLayoutContainer; + if (!content) { + return; + } + const heights = layoutVirtualizedSections(content, this.sectionLists.map(section => ({ + container: section.container, + contentHeight: section.entries.length * MCP_SECTION_ITEM_HEIGHT, + minimumHeight: MCP_SECTION_ITEM_HEIGHT, + }))); + for (let index = 0; index < this.sectionLists.length; index++) { + const section = this.sectionLists[index]; + const height = heights[index]; + layoutVirtualizedSectionList(section.list, section.container, height, section.container.clientWidth || undefined); + } + } + + private scheduleMcpSectionLayout(): void { + this.pendingSectionLayout.value = DOM.scheduleAtNextAnimationFrame(DOM.getWindow(this.element), () => { + this.layoutMcpSectionLists(); + this.cardScrollable.scanDomNode(); + }); + } + private renderMcpHome(): void { if (this.searchQuery.trim()) { return; } + this.captureSectionScrollPositions(); this.cardDisposables.clear(); + this.sectionLists = []; this.installedAddButton = undefined; this.firstCardFocusElement = undefined; this.availableSection = undefined; DOM.clearNode(this.cardContainer); this.showCardSurface(); - const content = this.createCardScrollContent(); + const content = this.createCardScrollContent('distributed-section-layout'); this.renderFeaturedServers(content); const installedList = this.renderCardSection( @@ -1519,13 +1855,11 @@ export class McpListWidget extends Disposable { const empty = DOM.append(installedList, $('.plugin-inventory-empty')); empty.textContent = localize('noInstalledMcpServers', "No MCP servers are installed."); } else { - for (const presentation of this.installedEntries) { - this.appendInstalledServerRow(installedList, presentation); - } + this.createMcpSectionList(installedList, localize('installedMcpServersSection', "Installed"), this.installedEntries.map(presentation => presentation.entry)); } - this.cardListControllers.get(installedList)?.finalize(); this.renderAvailableServers(content, this.getAvailableGalleryServers(), true); + this.scheduleMcpSectionLayout(); } private renderInstalledSectionActions(header: HTMLElement): void { @@ -1564,10 +1898,8 @@ export class McpListWidget extends Disposable { localize('featuredMcpServersDescription', "Discover MCP servers that connect agents to popular tools and services."), 'plugin-discovery-section', ); - for (const server of featured) { - this.appendMarketplaceServerCard(grid, server); - } - this.cardListControllers.get(grid)?.finalize(); + grid.classList.add('plugin-inventory-list'); + this.createMcpSectionList(grid, localize('featuredMcpServers', "Featured"), featured.map(server => ({ type: 'marketplace-item', server }))); } private renderAvailableServers(parent: HTMLElement, servers: readonly IWorkbenchMcpServer[], showDescription: boolean): void { @@ -1581,20 +1913,19 @@ export class McpListWidget extends Disposable { this.availableSection = availableList.parentElement ?? undefined; availableList.classList.add('plugin-inventory-list'); if (servers.length === 0) { - const empty = DOM.append(availableList, $('.plugin-inventory-empty')); - empty.textContent = this.gallerySnapshotLoading - ? localize('loadingMcpMarketplace', "Loading marketplace MCP servers...") - : localize('noAvailableMcpServers', "No marketplace MCP servers are available."); + if (this.gallerySnapshotLoading) { + renderVirtualizedSectionLoadingPlaceholder(availableList, localize('loadingMcpMarketplace', "Loading marketplace MCP servers..."), MCP_SECTION_ITEM_HEIGHT); + } else { + const empty = DOM.append(availableList, $('.plugin-inventory-empty')); + empty.textContent = localize('noAvailableMcpServers', "No marketplace MCP servers are available."); + } this.cardListControllers.get(availableList)?.finalize(); return; } - for (const server of servers) { - this.appendMarketplaceServerRow(availableList, server); - } - this.cardListControllers.get(availableList)?.finalize(); + this.createMcpSectionList(availableList, localize('availableMcpServersSection', "Available"), servers.map(server => ({ type: 'marketplace-item', server }))); } - private appendInstalledServerRow(parent: HTMLElement, presentation: IMcpInstalledPresentation): void { + protected appendInstalledServerRow(parent: HTMLElement, presentation: IMcpInstalledPresentation): void { let entry = presentation.entry; const rowKey = getMcpRowKey(entry); const label = getMcpEntryLabel(entry); @@ -1694,10 +2025,9 @@ export class McpListWidget extends Disposable { private appendInstalledServerToggle(parent: HTMLElement, getEntry: () => IMcpInstalledEntry): { readonly element: HTMLButtonElement; update(): void } { const label = getMcpEntryLabel(getEntry()); let enabled = this.isInstalledEntryEnabled(getEntry()); - const switchElement = DOM.append(parent, $('button.plugin-enable-switch')) as HTMLButtonElement; - switchElement.type = 'button'; - switchElement.setAttribute('role', 'switch'); - switchElement.setAttribute('aria-checked', String(enabled)); + const toggle = this.cardDisposables.add(new Switch({ ariaLabel: label, checked: enabled })); + const switchElement = toggle.domNode; + DOM.append(parent, switchElement); const updateLabel = () => { const blockedByPlugin = getMcpDisabledReason(getEntry())?.source === 'plugin'; const toggleLabel = enabled @@ -1706,16 +2036,11 @@ export class McpListWidget extends Disposable { const accessibleLabel = blockedByPlugin ? localize('mcpServerManagedByPluginAria', "{0} is disabled by its plugin", label) : toggleLabel; - switchElement.setAttribute('aria-label', accessibleLabel); - switchElement.title = accessibleLabel; + toggle.setAriaLabel(accessibleLabel); }; - switchElement.classList.toggle('checked', enabled); updateLabel(); - DOM.append(switchElement, $('.plugin-enable-switch-thumb')); - this.cardDisposables.add(DOM.addDisposableListener(switchElement, 'click', () => { - enabled = !enabled; - switchElement.classList.toggle('checked', enabled); - switchElement.setAttribute('aria-checked', String(enabled)); + this.cardDisposables.add(toggle.onChange(checked => { + enabled = checked; updateLabel(); this.setInstalledEntryEnabled(getEntry(), enabled); status(enabled @@ -1724,16 +2049,15 @@ export class McpListWidget extends Disposable { })); const update = () => { enabled = this.isInstalledEntryEnabled(getEntry()); - switchElement.disabled = getMcpDisabledReason(getEntry())?.source === 'plugin'; - switchElement.classList.toggle('checked', enabled); - switchElement.setAttribute('aria-checked', String(enabled)); + toggle.disabled = getMcpDisabledReason(getEntry())?.source === 'plugin'; + toggle.checked = enabled; updateLabel(); }; update(); return { element: switchElement, update }; } - private appendMarketplaceServerRow(parent: HTMLElement, server: IWorkbenchMcpServer): void { + protected appendMarketplaceServerRow(parent: HTMLElement, server: IWorkbenchMcpServer): void { const row = DOM.append(parent, $('.plugin-list-item.plugin-home-row.plugin-marketplace-home-row')); const primaryAction = this.addSurfaceActivation(row, localize('marketplaceMcpServerRowAriaLabel', "{0}. Available to install from the MCP marketplace.", server.label), () => this._onDidSelectServer.fire(createWorkbenchMcpServerDetailInput(server))); const details = DOM.append(primaryAction, $('.plugin-list-item-details')); @@ -1756,27 +2080,6 @@ export class McpListWidget extends Disposable { }); } - private appendMarketplaceServerCard(parent: HTMLElement, server: IWorkbenchMcpServer): void { - const card = DOM.append(parent, $('.plugin-card.plugin-marketplace-card')); - const header = DOM.append(card, $('.plugin-card-header')); - const titleBlock = this.addSurfaceActivation(header, localize('marketplaceMcpServerCardAriaLabel', "{0}. Featured MCP server available to install.", server.label), () => this._onDidSelectServer.fire(createWorkbenchMcpServerDetailInput(server)), 'plugin-card-title-block'); - const name = DOM.append(titleBlock, $('.plugin-card-title')); - name.textContent = server.label; - name.title = server.label; - const description = DOM.append(titleBlock, $('.plugin-card-subtitle')); - description.textContent = truncateToFirstLine(server.description || localize('mcpNoDescription', "No description provided.")); - const actions = DOM.append(header, $('.plugin-card-actions')); - const install = this.cardDisposables.add(new Button(actions, { ...defaultButtonStyles, ariaLabel: localize('installMcpServerAria', "Install {0}", server.label) })); - install.label = localize('install', "Install"); - this.cardDisposables.add(install.onDidClick(() => this.installMarketplaceServer(server, install))); - this.cardListControllers.get(parent)?.addItem({ - row: card, - primaryAction: titleBlock, - label: server.label, - actions: [install.element], - }); - } - private async installMarketplaceServer(server: IWorkbenchMcpServer, button: Button): Promise { button.label = localize('installing', "Installing..."); button.enabled = false; @@ -1864,24 +2167,24 @@ export class McpListWidget extends Disposable { return; } + this.captureSectionScrollPositions(); this.cardDisposables.clear(); + this.sectionLists = []; this.installedAddButton = undefined; this.firstCardFocusElement = undefined; this.availableSection = undefined; DOM.clearNode(this.cardContainer); this.showCardSurface(); - const content = this.createCardScrollContent('plugin-search-results'); + const content = this.createCardScrollContent('plugin-search-results', 'distributed-section-layout'); if (this.installedEntries.length > 0) { const installedList = this.renderCardSection(content, localize('installedSearchHeader', "Installed"), undefined, 'installed-mcp-servers-section', this.installedEntries.length); installedList.classList.add('plugin-inventory-list'); - for (const presentation of this.installedEntries) { - this.appendInstalledServerRow(installedList, presentation); - } - this.cardListControllers.get(installedList)?.finalize(); + this.createMcpSectionList(installedList, localize('installedSearchHeader', "Installed"), this.installedEntries.map(presentation => presentation.entry)); } if (available.length > 0) { this.renderAvailableServers(content, available, false); } + this.scheduleMcpSectionLayout(); } private filterServers(render = true): void { @@ -2009,6 +2312,9 @@ export class McpListWidget extends Disposable { layout(height: number, width: number): void { this.lastHeight = height; this.lastWidth = width; + if (!this.visible || this.element.parentElement?.style.display === 'none') { + return; + } this.element.style.height = `${height}px`; this.updateResponsiveLayout(width); @@ -2036,7 +2342,7 @@ export class McpListWidget extends Disposable { const listHeight = Math.max(0, availableHeight - searchBarHeight - headerHeight); this.cardScrollableNode.style.height = `${listHeight}px`; - this.cardScrollable.scanDomNode(); + this.scheduleMcpSectionLayout(); } /** @@ -2050,7 +2356,16 @@ export class McpListWidget extends Disposable { * Scrolls the list so the last item is visible. */ revealLastItem(): void { - this.cardScrollable.setScrollPosition({ scrollTop: this.cardContainer.scrollHeight }); + const reveal = () => { + const section = this.sectionLists.at(-1); + if (section?.entries.length) { + section.list.reveal(section.entries.length - 1); + } + this.cardScrollable.scanDomNode(); + this.cardScrollable.setScrollPosition({ scrollTop: this.cardContainer.scrollHeight }); + }; + reveal(); + this.revealLastItemScheduler.value = DOM.scheduleAtNextAnimationFrame(DOM.getWindow(this.element), reveal); } /** @@ -2058,7 +2373,15 @@ export class McpListWidget extends Disposable { */ focus(): void { if (this.cardScrollableNode.style.display !== 'none') { - this.firstCardFocusElement?.focus(); + if (this.firstCardFocusElement) { + this.firstCardFocusElement.focus(); + } else { + const section = this.sectionLists[0]; + if (section?.entries.length) { + section.list.setFocus([0]); + section.list.domFocus(); + } + } } } @@ -2077,7 +2400,7 @@ export class McpListWidget extends Disposable { } } - private showMcpServerActions(entry: IMcpInstalledEntry, anchor: HTMLElement): void { + private showMcpServerActions(entry: IMcpInstalledEntry, anchor: HTMLElement | IMouseEvent): void { const disposables = new DisposableStore(); const actions = this.getMcpServerActions(entry, disposables); if (actions.length === 0) { diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css b/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css index e83efb3cc2ba3..475687031de55 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css @@ -796,11 +796,6 @@ per-word capitalization does not survive translation. */ line-height: 1.45; color: var(--vscode-descriptionForeground); margin: 0 0 8px 0; - /* Reserve space for ~2 lines so the header height is stable across sections. */ - /* Rounded up to a whole pixel so naturally-wrapped 2-line descriptions and */ - /* shorter 1-line descriptions render at the same height instead of differing */ - /* by a sub-pixel rounding. */ - min-height: 38px; } .ai-customization-list-widget .section-title-header .section-title-link, @@ -962,16 +957,25 @@ per-word capitalization does not survive translation. */ flex: 1; min-height: 0; width: 100%; - max-width: calc(840px + var(--vscode-spacing-size160)); + max-width: 840px; margin-inline: auto; } +.ai-customization-management-editor .prompt-migration-content-container > .section-title-header, +.ai-customization-management-editor .prompt-migration-content-container > .customization-migration-banner, +.ai-customization-management-editor .prompt-migration-content-container > .prompt-migration-list-scrollable, +.ai-customization-management-editor .prompt-migration-content-container > .prompt-migration-footer { + width: min(100%, 840px); + margin-inline: auto; + box-sizing: border-box; +} + .ai-customization-management-editor .prompt-migration-footer { flex-shrink: 0; display: flex; align-items: center; justify-content: space-between; - gap: var(--vscode-spacing-size120); + gap: var(--vscode-spacing-size80); padding-top: var(--vscode-spacing-size120); border-top: var(--vscode-strokeThickness) solid var(--vscode-widget-border); } @@ -1020,7 +1024,7 @@ per-word capitalization does not survive translation. */ /* Migration banner — an Inner-tier callout for customizations needing attention. */ .ai-customization-management-editor .customization-migration-banner { flex-shrink: 0; - margin: var(--vscode-spacing-size120) 0; + margin-block: var(--vscode-spacing-size120); padding: var(--vscode-spacing-size120); background: color-mix(in srgb, var(--vscode-inputValidation-warningBackground) 35%, var(--vscode-agentsPanel-background)); border: var(--vscode-strokeThickness) solid color-mix(in srgb, var(--vscode-inputValidation-warningBorder) 70%, var(--vscode-widget-border)); @@ -1071,18 +1075,15 @@ per-word capitalization does not survive translation. */ display: flex; flex-direction: column; overflow: hidden; - padding: 0 var(--vscode-spacing-size160) var(--vscode-spacing-size60) 0; + gap: var(--vscode-spacing-size100); + padding: 0; box-sizing: border-box; } .ai-customization-management-editor .prompt-migration-group { display: flex; flex-direction: column; - margin-bottom: var(--vscode-spacing-size200); -} - -.ai-customization-management-editor .prompt-migration-group + .prompt-migration-group { - margin-top: var(--vscode-spacing-size40); + margin: 0; } .ai-customization-management-editor .prompt-migration-group-header { @@ -1102,6 +1103,36 @@ per-word capitalization does not survive translation. */ min-width: 0; } +.ai-customization-management-editor .customization-section-toggle { + flex: 0 0 auto; + width: var(--vscode-spacing-size160); + height: var(--vscode-spacing-size160); + padding: 0; + border: 0; + border-radius: var(--vscode-cornerRadius-small); + background: transparent; + color: var(--vscode-icon-foreground); + line-height: var(--vscode-spacing-size160); + cursor: pointer; +} + +.ai-customization-management-editor .customization-section-toggle:hover { + background: var(--vscode-toolbar-hoverBackground); +} + +.ai-customization-management-editor .customization-section-toggle:focus-visible { + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: calc(-1 * var(--vscode-strokeThickness)); +} + +.ai-customization-management-editor .distributed-section-layout.virtualized-section-layout-overflow { + padding-right: var(--vscode-spacing-size100); +} + +.vscode-high-contrast .ai-customization-management-editor .customization-section-toggle:focus-visible { + outline-color: var(--vscode-contrastActiveBorder); +} + .ai-customization-management-editor .prompt-migration-group-title { margin: 0; font-size: var(--vscode-agents-fontSize-heading3); @@ -1145,6 +1176,14 @@ per-word capitalization does not survive translation. */ overflow: hidden; } +.ai-customization-management-editor .prompt-migration-group-items.virtualized-section-list { + display: block; +} + +.ai-customization-management-editor .prompt-migration-group-items.virtualized-section-list .monaco-list-row { + box-sizing: border-box; +} + .ai-customization-management-editor .prompt-migration-group-empty { padding: var(--vscode-spacing-size160); color: var(--vscode-descriptionForeground); @@ -1161,8 +1200,8 @@ per-word capitalization does not survive translation. */ box-sizing: border-box; } -.ai-customization-management-editor .prompt-migration-item:not(:last-child) { - box-shadow: inset 0 calc(-1 * var(--vscode-strokeThickness)) var(--vscode-widget-border); +.ai-customization-management-editor .prompt-migration-group-items.virtualized-section-list .monaco-list-row:not(:first-child) { + box-shadow: inset 0 var(--vscode-strokeThickness) var(--vscode-widget-border); } .ai-customization-management-editor .prompt-migration-checkbox { @@ -1341,7 +1380,7 @@ per-word capitalization does not survive translation. */ flex: 1; min-height: 0; width: 100%; - max-width: calc(840px + var(--vscode-spacing-size160)); + max-width: 840px; margin: 0 auto; } @@ -1352,8 +1391,8 @@ per-word capitalization does not survive translation. */ overflow: hidden; display: flex; flex-direction: column; - gap: var(--vscode-spacing-size240); - padding: 0 var(--vscode-spacing-size160) var(--vscode-spacing-size200) 0; + gap: var(--vscode-spacing-size100); + padding: 0 0 var(--vscode-spacing-size200); box-sizing: border-box; } @@ -1465,23 +1504,14 @@ per-word capitalization does not survive translation. */ outline-offset: -1px; } -.ai-customization-management-editor .tools-list-setrow:not(:last-child), -.ai-customization-management-editor .tools-list-children:not(:last-child) .tools-list-toolrow:last-child { - box-shadow: inset 0 calc(-1 * var(--vscode-strokeThickness)) var(--vscode-widget-border); -} - .ai-customization-management-editor .tools-list-setrow:hover, .ai-customization-management-editor .tools-list-toolrow:hover { background: var(--vscode-list-hoverBackground); } -/* Match the other customization lists' focus ring: :focus-within for inner checkbox/chevron, :focus for a row click. */ -.ai-customization-management-editor .tools-list-setrow:focus, -.ai-customization-management-editor .tools-list-setrow:focus-within, -.ai-customization-management-editor .tools-list-toolrow:focus, -.ai-customization-management-editor .tools-list-toolrow:focus-within { - outline: 1px solid var(--vscode-list-focusOutline); - background: var(--vscode-list-focusBackground, var(--vscode-list-hoverBackground)); +/* A row-to-row divider between every adjacent row (set-to-set, set-to-tool, tool-to-tool, tool-to-set). */ +.ai-customization-management-editor .tools-inventory-list .monaco-list-row:not(:first-child) { + box-shadow: inset 0 var(--vscode-strokeThickness) var(--vscode-widget-border); } .ai-customization-management-editor .tools-list-chevron { @@ -1498,13 +1528,7 @@ per-word capitalization does not survive translation. */ margin-left: auto; } -/* Child tool row (indented under the tool set) */ -.ai-customization-management-editor .tools-list-children { - display: flex; - flex-direction: column; - gap: 2px; -} - +/* Tool row: visually indented under its tool set via its own `padding-inline-start` (no DOM nesting). */ .ai-customization-management-editor .tools-list-toolrow { display: flex; align-items: center; @@ -2520,6 +2544,26 @@ per-word capitalization does not survive translation. */ overflow: hidden; } +.plugin-list-widget .plugin-card-grid.plugin-inventory-list.virtualized-section-list { + display: block; +} + +.plugin-list-widget .virtualized-section-list .monaco-list-row { + box-sizing: border-box; +} + +.plugin-list-widget .customization-inventory-list .monaco-list-row.ai-customization-list-item { + background-color: var(--vscode-editorWidget-background); +} + +.plugin-list-widget .customization-inventory-list .monaco-list-row.ai-customization-list-item:hover { + background-color: var(--vscode-list-hoverBackground); +} + +.plugin-list-widget .virtualized-section-list .monaco-list-row:not(:first-child) { + box-shadow: inset 0 var(--vscode-strokeThickness) var(--vscode-widget-border, var(--vscode-agentsPanel-border)); +} + .plugin-list-widget .plugin-home-row { width: 100%; min-height: 64px; @@ -2685,10 +2729,15 @@ per-word capitalization does not survive translation. */ } .plugin-list-widget .plugin-list-item-action { + display: flex; + align-items: center; + flex-flow: row nowrap; + gap: var(--vscode-spacing-size40); flex-shrink: 0; + margin-left: auto; } -.plugin-list-widget .plugin-home-row .plugin-card-icon-button.monaco-button { +.plugin-list-widget .plugin-list-item-action .plugin-card-icon-button.monaco-button { width: var(--vscode-spacing-size240); min-width: var(--vscode-spacing-size240); height: var(--vscode-spacing-size240); @@ -2698,67 +2747,38 @@ per-word capitalization does not survive translation. */ color: var(--vscode-descriptionForeground); } -.plugin-list-widget .plugin-home-row .plugin-card-icon-button.monaco-button:hover { - background: transparent; - border-color: var(--vscode-widget-border); +.plugin-list-widget .plugin-list-item-action .plugin-card-icon-button.monaco-button:hover { + background: var(--vscode-toolbar-hoverBackground); + border-color: transparent; color: var(--vscode-textLink-activeForeground); } -.plugin-list-widget .customization-home-row:hover { - background: color-mix(in srgb, var(--vscode-list-hoverBackground) 65%, transparent); -} - -.plugin-list-widget .plugin-enable-switch { - position: relative; - flex: 0 0 auto; - width: var(--vscode-spacing-size280); - height: var(--vscode-spacing-size160); +.plugin-list-widget .mcp-server-actions .plugin-card-icon-button.monaco-button { + width: var(--vscode-spacing-size240); + min-width: var(--vscode-spacing-size240); + height: var(--vscode-spacing-size240); padding: 0; - border: var(--vscode-strokeThickness) solid transparent; - border-radius: var(--vscode-cornerRadius-circle); - background: color-mix(in srgb, var(--vscode-descriptionForeground) 36%, transparent); - cursor: pointer; - transition: background-color 120ms ease, border-color 120ms ease; -} - -.plugin-list-widget .plugin-enable-switch.checked { - background: var(--vscode-button-background); - border-color: var(--vscode-button-background); -} - -.plugin-list-widget .plugin-enable-switch:disabled { - cursor: default; - opacity: 0.5; -} - -.plugin-list-widget .plugin-enable-switch:focus-visible { - outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); - outline-offset: var(--vscode-spacing-size20); + border-color: transparent; + background: transparent; + color: var(--vscode-descriptionForeground); } -.plugin-list-widget .plugin-enable-switch-thumb { - position: absolute; - top: var(--vscode-strokeThickness); - left: var(--vscode-strokeThickness); - width: var(--vscode-spacing-size120); - height: var(--vscode-spacing-size120); - border-radius: var(--vscode-cornerRadius-circle); - background: var(--vscode-button-foreground); - transition: transform 120ms ease; +.plugin-list-widget .mcp-server-actions .plugin-card-icon-button.monaco-button:hover { + background: var(--vscode-toolbar-hoverBackground); + border-color: transparent; + color: var(--vscode-textLink-activeForeground); } -.plugin-list-widget .plugin-enable-switch.checked .plugin-enable-switch-thumb { - transform: translateX(var(--vscode-spacing-size120)); +.vscode-high-contrast .plugin-list-widget .plugin-list-item-action .plugin-card-icon-button.monaco-button:focus-visible, +.vscode-high-contrast .plugin-list-widget .mcp-server-actions .plugin-card-icon-button.monaco-button:focus-visible { + border-color: var(--vscode-contrastActiveBorder); } -.vscode-high-contrast .plugin-list-widget .plugin-enable-switch { - border-color: var(--vscode-contrastBorder); +.plugin-list-widget .customization-home-row:hover { + background: color-mix(in srgb, var(--vscode-list-hoverBackground) 65%, transparent); } -.monaco-workbench.monaco-reduce-motion .plugin-list-widget .plugin-enable-switch, -.monaco-workbench.monaco-reduce-motion .plugin-list-widget .plugin-enable-switch-thumb { - transition: none; -} +/* The enablement switch is the shared `Switch` widget; its styling lives in base. */ .plugin-list-widget .plugin-list-item-install-button { font-size: var(--vscode-agents-fontSize-body2); @@ -2780,7 +2800,7 @@ per-word capitalization does not survive translation. */ flex: 1; min-height: 0; width: 100%; - max-width: calc(840px + var(--vscode-spacing-size160)); + max-width: 840px; margin: 0 auto; } @@ -2792,14 +2812,33 @@ per-word capitalization does not survive translation. */ .plugin-list-widget .plugin-card-scroll { height: 100%; overflow: auto; - padding: 0 max(var(--vscode-spacing-size20), calc((100% - 840px) / 2)) var(--vscode-spacing-size200); + padding: 0 0 var(--vscode-spacing-size200); box-sizing: border-box; } +.plugin-list-widget .plugin-card-scroll.distributed-section-layout { + display: flex; + flex-direction: column; + gap: var(--vscode-spacing-size100); + height: 100%; + min-height: 0; + padding-bottom: 0; + overflow: hidden; +} + +.plugin-list-widget .distributed-section-layout > .plugin-card-section { + flex: 0 0 auto; + margin-bottom: 0; +} + +.plugin-list-widget .distributed-section-layout .plugin-card-section-header { + margin-bottom: var(--vscode-spacing-size20); +} + .plugin-list-widget .plugin-card-scroll-content { height: auto; overflow: visible; - padding: 0 var(--vscode-spacing-size160) var(--vscode-spacing-size200) 0; + padding: 0 0 var(--vscode-spacing-size200); } .plugin-list-widget .plugin-marketplace-back-container { @@ -2813,10 +2852,6 @@ per-word capitalization does not survive translation. */ margin-bottom: var(--vscode-spacing-size240); } -.plugin-list-widget .plugin-discovery-section { - margin-bottom: var(--vscode-spacing-size320); -} - .plugin-list-widget .plugin-card-section-header { display: flex; align-items: flex-end; @@ -2913,6 +2948,15 @@ per-word capitalization does not survive translation. */ line-height: 16px; } +.plugin-list-widget .virtualized-section-loading { + display: flex; + align-items: center; + box-sizing: border-box; + padding-inline: var(--vscode-spacing-size160); + color: var(--vscode-descriptionForeground); + font-size: var(--vscode-fontSize-body2); +} + .plugin-list-widget .plugin-marketplace-home-row .plugin-list-item-install-button.monaco-button { width: auto; flex: 0 0 auto; diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts index a6d1729aa6b96..edb1ed8d80fcd 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts @@ -15,7 +15,7 @@ import { ThemeIcon } from '../../../../../base/common/themables.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { Button, ButtonWithDropdown } from '../../../../../base/browser/ui/button/button.js'; import { defaultButtonStyles, defaultCheckboxStyles, defaultInputBoxStyles, getButtonStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; -import { autorun } from '../../../../../base/common/observable.js'; +import { autorun, derived, IObservable } from '../../../../../base/common/observable.js'; import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; import { URI } from '../../../../../base/common/uri.js'; import { InputBox, MessageType } from '../../../../../base/browser/ui/inputbox/inputBox.js'; @@ -41,12 +41,13 @@ import { IConfigurationService } from '../../../../../platform/configuration/com import { ChatConfiguration } from '../../common/constants.js'; import { IAICustomizationItemsModel } from './aiCustomizationItemsModel.js'; import { UpdateAgentPluginsCommandId } from '../chat.js'; +import { Switch } from '../../../../../base/browser/ui/toggle/switch.js'; import { Checkbox } from '../../../../../base/browser/ui/toggle/toggle.js'; import { INotificationService } from '../../../../../platform/notification/common/notification.js'; import { getErrorMessage } from '../../../../../base/common/errors.js'; import { getPluginInclusionLabel } from './aiCustomizationPresentation.js'; import { status } from '../../../../../base/browser/ui/aria/aria.js'; -import { createCustomizationCardPrimaryAction, CustomizationCardListController } from './customizationCardList.js'; +import { createCustomizationCardPrimaryAction, CustomizationCardListController, layoutVirtualizedSectionList, layoutVirtualizedSections, renderVirtualizedSectionLoadingPlaceholder, setVirtualizedRowActionsTabbable, setupCollapsibleSection } from './customizationCardList.js'; import { DomScrollableElement } from '../../../../../base/browser/ui/scrollbar/scrollableElement.js'; import { ScrollbarVisibility } from '../../../../../base/common/scrollable.js'; @@ -150,6 +151,13 @@ interface IPluginSearchHeaderEntry { type IPluginListEntry = IPluginGroupHeaderEntry | IPluginSearchHeaderEntry | IPluginInstalledItemEntry | IPluginMarketplaceItemEntry | IPluginRemoteItemEntry; +interface IPluginSectionList { + readonly list: WorkbenchList; + readonly entries: readonly IPluginListEntry[]; + readonly container: HTMLElement; + readonly key: string; +} + //#endregion //#region Delegate @@ -224,14 +232,20 @@ interface IPluginInstalledItemTemplateData { readonly source: HTMLElement; readonly description: HTMLElement; readonly metadata: HTMLElement; + readonly actions: HTMLElement; readonly disposables: DisposableStore; + currentIndex: number; } class PluginInstalledItemRenderer implements IListRenderer { readonly templateId = 'pluginInstalledItem'; + private readonly _templates = new Set(); + private _focusedIndex = -1; constructor( private readonly _harnessService: ICustomizationHarnessService, + private readonly _renderActions: (item: IInstalledPluginItem, container: HTMLElement, actions: HTMLElement, disposables: DisposableStore) => void, + private readonly _showSyncCheckbox = true, ) { } renderTemplate(container: HTMLElement): IPluginInstalledItemTemplateData { @@ -244,12 +258,16 @@ class PluginInstalledItemRenderer implements IListRenderer { readonly templateId = 'pluginRemoteItem'; + private readonly _templates = new Set(); + private _focusedIndex = -1; + + constructor( + private readonly _renderActions: (item: ICustomizationItem, actions: HTMLElement, disposables: DisposableStore) => void, + ) { } renderTemplate(container: HTMLElement): IPluginRemoteItemTemplateData { container.classList.add('plugin-list-item', 'plugin-remote-item'); @@ -324,11 +362,16 @@ class PluginRemoteItemRenderer implements IListRenderer): string { @@ -402,18 +457,22 @@ interface IPluginMarketplaceItemTemplateData { readonly installButton: Button; readonly elementDisposables: DisposableStore; readonly templateDisposables: DisposableStore; + currentIndex: number; } const PLUGIN_MARKETPLACE_ITEM_TEMPLATE_ID = 'pluginMarketplaceItem'; class PluginMarketplaceItemRenderer implements IListRenderer { readonly templateId = PLUGIN_MARKETPLACE_ITEM_TEMPLATE_ID; + private readonly _templates = new Set(); + private _focusedIndex = -1; constructor( private readonly pluginInstallService: IPluginInstallService, private readonly agentPluginService: IAgentPluginService, private readonly pluginMarketplaceService: IPluginMarketplaceService, private readonly notificationService: INotificationService, + private readonly showRecommendedBadge = true, ) { } renderTemplate(container: HTMLElement): IPluginMarketplaceItemTemplateData { @@ -432,15 +491,19 @@ class PluginMarketplaceItemRenderer implements IListRenderer DOM.EventHelper.stop(event, true))); - return { container, name, recommendedBadge, publisher, description, metadata, installButton, elementDisposables: new DisposableStore(), templateDisposables }; + const template = { container, name, recommendedBadge, publisher, description, metadata, installButton, elementDisposables: new DisposableStore(), templateDisposables, currentIndex: -1 }; + this._templates.add(template); + return template; } - renderElement(element: IPluginMarketplaceItemEntry, _index: number, templateData: IPluginMarketplaceItemTemplateData): void { + renderElement(element: IPluginMarketplaceItemEntry, index: number, templateData: IPluginMarketplaceItemTemplateData): void { templateData.elementDisposables.clear(); + templateData.currentIndex = index; templateData.name.textContent = element.item.name; - templateData.recommendedBadge.style.display = this.isRecommended(element.item) ? '' : 'none'; + templateData.recommendedBadge.style.display = this.showRecommendedBadge && this.isRecommended(element.item) ? '' : 'none'; templateData.publisher.textContent = ''; templateData.publisher.style.display = 'none'; templateData.description.textContent = element.item.description || ''; @@ -462,13 +525,16 @@ class PluginMarketplaceItemRenderer implements IListRenderer { + templateData.elementDisposables.add(templateData.installButton.onDidClick(async event => { + DOM.EventHelper.stop(event, true); templateData.installButton.label = localize('installing', "Installing..."); templateData.installButton.enabled = false; try { @@ -484,19 +550,33 @@ class PluginMarketplaceItemRenderer implements IListRenderer; private emptyContainer!: HTMLElement; @@ -658,7 +739,10 @@ export class PluginListWidget extends Disposable { private updatePluginsButton!: Button; private readonly addDropdownActions = this._register(new DisposableStore()); private readonly cardDisposables = this._register(new DisposableStore()); + private readonly pendingSectionLayout = this._register(new MutableDisposable()); private readonly cardListControllers = new WeakMap(); + private sectionLists: IPluginSectionList[] = []; + private collapsedSections: Set | undefined = new Set(); private installedItems: IInstalledPluginItem[] = []; private remoteItems: ICustomizationItem[] = []; @@ -674,7 +758,9 @@ export class PluginListWidget extends Disposable { private lastWidth: number = 0; private lastHeaderHeight = 0; private _layoutDeferred = false; + private readonly revealLastItemScheduler = this._register(new MutableDisposable()); private readonly collapsedGroups = new Set(); + private readonly sectionScrollPositions = new Map(); private marketplaceCts: CancellationTokenSource | undefined; private marketplaceSnapshotCts: CancellationTokenSource | undefined; private readonly delayedFilter = new Delayer(200); @@ -890,8 +976,8 @@ export class PluginListWidget extends Disposable { const delegate = new PluginItemDelegate(); const groupHeaderRenderer = new CustomizationGroupHeaderRenderer('pluginGroupHeader', this.hoverService); const searchHeaderRenderer = new PluginSearchHeaderRenderer(); - const installedRenderer = new PluginInstalledItemRenderer(this.harnessService); - const remoteRenderer = new PluginRemoteItemRenderer(); + const installedRenderer = new PluginInstalledItemRenderer(this.harnessService, (item, container, actions, disposables) => this.renderInstalledListActions(item, container, actions, disposables)); + const remoteRenderer = new PluginRemoteItemRenderer((item, actions, disposables) => this.renderRemoteListActions(item, actions, disposables)); const marketplaceRenderer = new PluginMarketplaceItemRenderer(this.pluginInstallService, this.agentPluginService, this.pluginMarketplaceService, this.notificationService); this.list = this._register(this.instantiationService.createInstance( @@ -983,6 +1069,12 @@ export class PluginListWidget extends Disposable { } } })); + this._register(this.list.onDidChangeFocus(event => { + const index = event.indexes[0] ?? -1; + installedRenderer.setFocusedIndex(index); + remoteRenderer.setFocusedIndex(index); + marketplaceRenderer.setFocusedIndex(index); + })); // Handle context menu this._register(this.list.onContextMenu(e => this.onContextMenu(e as IListContextMenuEvent))); @@ -1212,9 +1304,10 @@ export class PluginListWidget extends Disposable { private createCardScrollContent(...classNames: string[]): HTMLElement { const content = DOM.append(this.cardContainer, $('.plugin-card-scroll.plugin-card-scroll-content')); content.classList.add(...classNames); + this.sectionLayoutContainer = classNames.includes('distributed-section-layout') ? content : undefined; const resizeObserver = this.cardDisposables.add(new DOM.DisposableResizeObserver( 'PluginListWidget.cardScrollContent', - () => this.cardScrollable.scanDomNode(), + () => this.schedulePluginSectionLayout(), )); this.cardDisposables.add(resizeObserver.observe(content)); return content; @@ -1247,29 +1340,240 @@ export class PluginListWidget extends Disposable { } renderActions?.(header); const list = DOM.append(section, $('.plugin-card-grid')); + const sectionKey = className ?? title; + list.dataset.virtualizedSectionKey = sectionKey; + const collapsedSections = this.collapsedSections ??= new Set(); + setupCollapsibleSection( + headingRow, + list, + title, + this.cardDisposables, + collapsedSections.has(sectionKey), + collapsed => { + if (collapsed) { + collapsedSections.add(sectionKey); + } else { + collapsedSections.delete(sectionKey); + } + this.schedulePluginSectionLayout(); + }, + ); this.cardListControllers.set(list, this.cardDisposables.add(new CustomizationCardListController(list, title))); return list; } + private createPluginSectionList(container: HTMLElement, label: string, entries: readonly IPluginListEntry[], showRecommendedBadge = true): void { + const key = container.dataset.virtualizedSectionKey ?? label; + const delegate = new PluginItemDelegate(); + container.style.height = `${entries.length > 0 ? delegate.getHeight(entries[0]) : PLUGIN_ITEM_HEIGHT}px`; + container.classList.add('virtualized-section-list'); + this.cardListControllers.get(container)?.dispose(); + this.cardListControllers.delete(container); + container.removeAttribute('role'); + container.removeAttribute('aria-label'); + const installedRenderer = new PluginInstalledItemRenderer(this.harnessService, (item, row, actions, disposables) => this.renderInstalledListActions(item, row, actions, disposables), false); + const remoteRenderer = new PluginRemoteItemRenderer((item, actions, disposables) => this.renderRemoteListActions(item, actions, disposables)); + const marketplaceRenderer = new PluginMarketplaceItemRenderer(this.pluginInstallService, this.agentPluginService, this.pluginMarketplaceService, this.notificationService, showRecommendedBadge); + const list = this.cardDisposables.add(this.instantiationService.createInstance( + WorkbenchList, + `PluginManagementList.${label}`, + container, + delegate, + [installedRenderer, remoteRenderer, marketplaceRenderer], + { + multipleSelectionSupport: false, + setRowLineHeight: false, + horizontalScrolling: false, + accessibilityProvider: { + getAriaLabel: element => this.getPluginEntryAriaLabel(element), + getWidgetAriaLabel: () => label, + getSetSize: (_element, _index, listLength) => listLength, + getPosInSet: (_element, index) => index + 1, + }, + openOnSingleClick: true, + identityProvider: { getId: element => this.getPluginEntryId(element) }, + }, + )); + list.splice(0, 0, entries); + list.scrollTop = this.sectionScrollPositions.get(key) ?? 0; + this.cardDisposables.add(list.onDidOpen(event => { + const entry = event.element; + if (entry?.type === 'plugin-item' || entry?.type === 'marketplace-item') { + this._onDidSelectPlugin.fire(entry.item); + } + })); + this.cardDisposables.add(list.onContextMenu(event => this.onContextMenu(event))); + this.cardDisposables.add(list.onDidChangeFocus(event => { + const index = event.indexes[0] ?? -1; + installedRenderer.setFocusedIndex(index); + remoteRenderer.setFocusedIndex(index); + marketplaceRenderer.setFocusedIndex(index); + })); + this.cardDisposables.add(list.onDidFocus(() => { + if (list.getFocus().length === 0 && entries.length > 0) { + list.setFocus([0]); + } + })); + this.sectionLists.push({ list, entries, container, key }); + } + + private captureSectionScrollPositions(): void { + for (const section of this.sectionLists) { + this.sectionScrollPositions.set(section.key, section.list.scrollTop); + } + } + + private getPluginEntryAriaLabel(element: IPluginListEntry): string | IObservable { + if (element.type === 'group-header' || element.type === 'search-header') { + return element.label; + } + const name = formatDisplayName(element.item.name); + const description = element.item.description ? truncateToFirstLine(element.item.description) : undefined; + const nameAndDescription = description ? localize('pluginItemAriaLabel', "{0}. {1}", name, description) : name; + if (element.type === 'plugin-item') { + const metadata = getInstalledPluginMetadata(element.item); + const withMetadata = metadata ? localize('pluginInstalledItemAriaLabelWithMetadata', "{0}. {1}", nameAndDescription, metadata) : nameAndDescription; + return derived(this, reader => isContributionEnabled(element.item.plugin.enablement.read(reader)) + ? localize('pluginInstalledItemAriaLabelEnabled', "{0}. Enabled", withMetadata) + : localize('pluginInstalledItemAriaLabelDisabled', "{0}. Disabled", withMetadata)); + } + if (element.type === 'remote-item') { + const statusLabel = getRemotePluginStatusLabel(element.item); + return statusLabel + ? localize('pluginRemoteItemAriaLabelWithStatus', "{0}. Remote agent host. Status: {1}", nameAndDescription, statusLabel) + : localize('pluginRemoteItemAriaLabel', "{0}. Remote agent host", nameAndDescription); + } + const marketplaceLabel = localize('pluginMarketplaceItemAriaLabel', "{0}. From {1}", nameAndDescription, element.item.marketplace); + return this.pluginMarketplaceService.recommendedPlugins.get().has(getMarketplaceRecommendationKey(element.item)) + ? localize('pluginMarketplaceItemAriaLabelRecommended', "{0}. Recommended for this workspace", marketplaceLabel) + : marketplaceLabel; + } + + private getPluginEntryId(element: IPluginListEntry): string { + if (element.type === 'group-header' || element.type === 'search-header') { + return element.id; + } + if (element.type === 'marketplace-item') { + return `marketplace-${element.item.marketplaceReference.canonicalId}/${element.item.source}`; + } + if (element.type === 'remote-item') { + return element.item.itemKey ?? `remote-${element.item.groupKey ?? 'default'}-${element.item.uri.toString()}`; + } + return element.item.plugin.uri.toString(); + } + + private renderInstalledListActions(item: IInstalledPluginItem, row: HTMLElement, actions: HTMLElement, disposables: DisposableStore): void { + let renderedState = item.plugin.enablement.get(); + const switchElement = DOM.append(actions, $('button.plugin-enable-switch')) as HTMLButtonElement; + switchElement.type = 'button'; + switchElement.setAttribute('role', 'switch'); + DOM.append(switchElement, $('.plugin-enable-switch-thumb')); + disposables.add(DOM.addDisposableGenericMouseDownListener(switchElement, event => DOM.EventHelper.stop(event, true))); + const update = (state: ContributionEnablementState, blocked: boolean) => { + renderedState = state; + const checked = isContributionEnabled(state); + const workspaceScope = state === ContributionEnablementState.EnabledWorkspace || state === ContributionEnablementState.DisabledWorkspace; + const toggleLabel = checked + ? (workspaceScope ? localize('excludePluginWorkspaceAria', "Exclude {0} from Workspace", item.name) : localize('excludePluginProfileAria', "Exclude {0} from Profile", item.name)) + : (workspaceScope ? localize('includePluginWorkspaceAria', "Include {0} in Workspace", item.name) : localize('includePluginProfileAria', "Include {0} for Profile", item.name)); + switchElement.disabled = blocked; + switchElement.setAttribute('aria-checked', String(checked)); + switchElement.setAttribute('aria-label', blocked ? localize('pluginManagedByOrganizationAria', "{0} is managed by your organization", item.name) : toggleLabel); + switchElement.classList.toggle('checked', checked); + switchElement.title = blocked ? localize('pluginPolicyBlockedSwitch', "This plugin is managed by your organization.") : toggleLabel; + row.classList.toggle('disabled', !checked || blocked); + }; + disposables.add(autorun(reader => update(item.plugin.enablement.read(reader), item.plugin.policyBlocked?.read(reader) === true))); + disposables.add(DOM.addDisposableListener(switchElement, 'click', event => { + DOM.EventHelper.stop(event, true); + const nextState = getToggledPluginEnablementState(renderedState); + update(nextState, isPluginPolicyBlocked(item.plugin)); + this.agentPluginService.enablementModel.setEnabled(item.plugin.uri.toString(), nextState); + status(localize('pluginInclusionChanged', "{0}. {1}.", item.name, getPluginInclusionLabel(item.plugin))); + })); + + const more = disposables.add(new Button(actions, { + ...getButtonStyles({ buttonSecondaryBackground: undefined, buttonSecondaryBorder: undefined }), + secondary: true, + supportIcons: true, + ariaLabel: localize('pluginMoreActionsAria', "More actions for {0}", item.name), + })); + more.element.classList.add('plugin-card-icon-button'); + more.label = `$(${Codicon.ellipsis.id})`; + disposables.add(DOM.addDisposableGenericMouseDownListener(more.element, event => DOM.EventHelper.stop(event, true))); + disposables.add(more.onDidClick(event => { + DOM.EventHelper.stop(event, true); + this.showInstalledPluginActions(item, more.element); + })); + } + + private renderRemoteListActions(item: ICustomizationItem, actions: HTMLElement, disposables: DisposableStore): void { + if (!item.actions?.length) { + actions.style.display = 'none'; + return; + } + actions.style.display = ''; + const more = disposables.add(new Button(actions, { + ...getButtonStyles({ buttonSecondaryBackground: undefined, buttonSecondaryBorder: undefined }), + secondary: true, + supportIcons: true, + ariaLabel: localize('pluginMoreActionsAria', "More actions for {0}", item.name), + })); + more.element.classList.add('plugin-card-icon-button'); + more.label = `$(${Codicon.ellipsis.id})`; + disposables.add(DOM.addDisposableGenericMouseDownListener(more.element, event => DOM.EventHelper.stop(event, true))); + disposables.add(more.onDidClick(event => { + DOM.EventHelper.stop(event, true); + this.showRemotePluginActions(item, more.element); + })); + } + + private layoutPluginSectionLists(): void { + const delegate = new PluginItemDelegate(); + const content = this.sectionLayoutContainer; + if (!content) { + return; + } + const heights = layoutVirtualizedSections(content, this.sectionLists.map(section => ({ + container: section.container, + contentHeight: section.entries.reduce((height, entry) => height + delegate.getHeight(entry), 0), + minimumHeight: section.entries.length > 0 ? delegate.getHeight(section.entries[0]) : 0, + }))); + for (let index = 0; index < this.sectionLists.length; index++) { + const section = this.sectionLists[index]; + const height = heights[index]; + layoutVirtualizedSectionList(section.list, section.container, height, section.container.clientWidth || undefined); + } + } + + private schedulePluginSectionLayout(): void { + this.pendingSectionLayout.value = DOM.scheduleAtNextAnimationFrame(DOM.getWindow(this.element), () => { + this.layoutPluginSectionLists(); + this.cardScrollable.scanDomNode(); + }); + } + private renderPluginHome(): void { if (this.browseMode || this.searchQuery.trim()) { return; } + this.captureSectionScrollPositions(); this.cardDisposables.clear(); + this.sectionLists = []; this.installedCreateButton = undefined; this.firstCardFocusElement = undefined; DOM.clearNode(this.cardContainer); this.showCardSurface(); - const content = this.createCardScrollContent(); + const content = this.createCardScrollContent('distributed-section-layout'); const installedPlugins = this.installedItems; const hasMarketplaceInstalledPlugins = this.pluginMarketplaceService.installedPlugins.get().length > 0; - this.renderDiscoverySnapshot(content); if (shouldLoadPluginMarketplaceSnapshot(this.visible, this.marketplaceSnapshot.state, this.isBrowseMarketplaceAvailable())) { void this.queryMarketplaceSnapshot(); } + this.renderDiscoverySnapshot(content); const installedList = this.renderCardSection( content, @@ -1284,11 +1588,8 @@ export class PluginListWidget extends Disposable { const empty = DOM.append(installedList, $('.plugin-inventory-empty')); empty.textContent = localize('noInstalledPlugins', "No plugins are installed."); } else { - for (const item of installedPlugins) { - this.appendInstalledPluginRow(installedList, item); - } + this.createPluginSectionList(installedList, localize('installedPluginsSection', "Installed"), installedPlugins.map(item => ({ type: 'plugin-item', item }))); } - this.cardListControllers.get(installedList)?.finalize(); const installedNames = new Set(this.installedItems.map(item => item.name.toLowerCase())); const remoteItems = this.remoteItems.filter(item => item.groupKey !== 'remote-client' && (!item.name || !installedNames.has(item.name.toLowerCase()))); @@ -1301,13 +1602,11 @@ export class PluginListWidget extends Disposable { remoteItems.length, ); remoteList.classList.add('plugin-inventory-list'); - for (const item of remoteItems) { - this.appendRemotePluginRow(remoteList, item); - } - this.cardListControllers.get(remoteList)?.finalize(); + this.createPluginSectionList(remoteList, localize('remotePluginsSection', "Remote session plugins"), remoteItems.map(item => ({ type: 'remote-item', item }))); } this.renderAvailablePlugins(content, this.getUninstalledMarketplaceItems(this.marketplaceSnapshot.items), true); + this.schedulePluginSectionLayout(); } private renderInstalledSectionActions(header: HTMLElement, hasInstalledPlugins: boolean): void { @@ -1345,15 +1644,16 @@ export class PluginListWidget extends Disposable { ); availableList.classList.add('plugin-inventory-list'); if (items.length === 0) { - const empty = DOM.append(availableList, $('.plugin-inventory-empty')); - empty.textContent = localize('noAvailablePlugins', "No marketplace plugins are available."); + if (this.marketplaceSnapshot.state === 'loading') { + renderVirtualizedSectionLoadingPlaceholder(availableList, localize('loadingMarketplace', "Loading marketplace..."), PLUGIN_MARKETPLACE_ITEM_HEIGHT); + } else { + const empty = DOM.append(availableList, $('.plugin-inventory-empty')); + empty.textContent = localize('noAvailablePlugins', "No marketplace plugins are available."); + } this.cardListControllers.get(availableList)?.finalize(); return; } - for (const item of items) { - this.appendMarketplacePluginRow(availableList, item); - } - this.cardListControllers.get(availableList)?.finalize(); + this.createPluginSectionList(availableList, title, items.map(item => ({ type: 'marketplace-item', item }))); } private renderAvailableSectionActions(header: HTMLElement): void { @@ -1386,7 +1686,7 @@ export class PluginListWidget extends Disposable { } } - private appendInstalledPluginRow(parent: HTMLElement, item: IInstalledPluginItem): void { + protected appendInstalledPluginRow(parent: HTMLElement, item: IInstalledPluginItem): void { const row = DOM.append(parent, $('.plugin-list-item.plugin-home-row.plugin-installed-item')); const primaryAction = this.addSurfaceActivation(row, localize('installedPluginRowAriaLabel', "{0}. {1}", item.name, getPluginInclusionLabel(item.plugin)), () => this._onDidSelectPlugin.fire(item)); @@ -1424,10 +1724,9 @@ export class PluginListWidget extends Disposable { private appendInstalledPluginToggle(parent: HTMLElement, row: HTMLElement, primaryAction: HTMLElement, item: IInstalledPluginItem): HTMLButtonElement { let renderedState = item.plugin.enablement.get(); - const switchElement = DOM.append(parent, $('button.plugin-enable-switch')) as HTMLButtonElement; - switchElement.type = 'button'; - switchElement.setAttribute('role', 'switch'); - DOM.append(switchElement, $('.plugin-enable-switch-thumb')); + const toggle = this.cardDisposables.add(new Switch({ ariaLabel: item.name })); + const switchElement = toggle.domNode; + DOM.append(parent, switchElement); const update = (state: ContributionEnablementState, blocked: boolean) => { renderedState = state; const checked = isContributionEnabled(state); @@ -1436,11 +1735,9 @@ export class PluginListWidget extends Disposable { ? (workspaceScope ? localize('excludePluginWorkspaceAria', "Exclude {0} from Workspace", item.name) : localize('excludePluginProfileAria', "Exclude {0} from Profile", item.name)) : (workspaceScope ? localize('includePluginWorkspaceAria', "Include {0} in Workspace", item.name) : localize('includePluginProfileAria', "Include {0} for Profile", item.name)); const accessibleLabel = blocked ? localize('pluginManagedByOrganizationAria', "{0} is managed by your organization", item.name) : toggleLabel; - switchElement.disabled = blocked; - switchElement.setAttribute('aria-checked', String(checked)); - switchElement.setAttribute('aria-label', accessibleLabel); - switchElement.classList.toggle('checked', checked); - switchElement.title = blocked ? localize('pluginPolicyBlockedSwitch', "This plugin is managed by your organization.") : toggleLabel; + toggle.disabled = blocked; + toggle.checked = checked; + toggle.setAriaLabel(accessibleLabel, blocked ? localize('pluginPolicyBlockedSwitch', "This plugin is managed by your organization.") : toggleLabel); row.classList.toggle('disabled', !checked || blocked); primaryAction.setAttribute('aria-label', localize('installedPluginRowAriaLabel', "{0}. {1}", item.name, getPluginInclusionLabel(item.plugin))); }; @@ -1449,7 +1746,7 @@ export class PluginListWidget extends Disposable { const blocked = item.plugin.policyBlocked?.read(reader) === true; update(state, blocked); })); - this.cardDisposables.add(DOM.addDisposableListener(switchElement, 'click', () => { + this.cardDisposables.add(toggle.onChange(() => { const nextState = getToggledPluginEnablementState(renderedState); update(nextState, isPluginPolicyBlocked(item.plugin)); this.agentPluginService.enablementModel.setEnabled(item.plugin.uri.toString(), nextState); @@ -1458,7 +1755,7 @@ export class PluginListWidget extends Disposable { return switchElement; } - private appendRemotePluginRow(parent: HTMLElement, item: ICustomizationItem): void { + protected appendRemotePluginRow(parent: HTMLElement, item: ICustomizationItem): void { const row = DOM.append(parent, $('.plugin-list-item.plugin-home-row.plugin-remote-item')); row.setAttribute('role', 'listitem'); row.setAttribute('aria-label', localize('pluginRemoteCardAria', "{0}. Remote plugin", item.name)); @@ -1503,7 +1800,7 @@ export class PluginListWidget extends Disposable { }); } - private appendMarketplacePluginRow(parent: HTMLElement, item: IMarketplacePluginItem): void { + protected appendMarketplacePluginRow(parent: HTMLElement, item: IMarketplacePluginItem): void { const row = DOM.append(parent, $('.plugin-list-item.plugin-home-row.plugin-marketplace-home-row')); const primaryAction = this.addSurfaceActivation(row, localize('marketplacePluginRowAriaLabel', "{0}. Available to install from {1}.", item.name, item.marketplace), () => this._onDidSelectPlugin.fire(item)); @@ -1528,40 +1825,10 @@ export class PluginListWidget extends Disposable { }); } - private appendMarketplacePluginCard(parent: HTMLElement, item: IMarketplacePluginItem, showRecommendedBadge = true): void { - const card = DOM.append(parent, $('.plugin-card.plugin-marketplace-card')); - const header = DOM.append(card, $('.plugin-card-header')); - const titleBlock = this.addSurfaceActivation(header, localize('marketplacePluginCardAriaLabel', "{0}. Available to install from {1}.", item.name, item.marketplace), () => this._onDidSelectPlugin.fire(item), 'plugin-card-title-block'); - const name = DOM.append(titleBlock, $('.plugin-card-title')); - name.textContent = item.name; - name.title = item.name; - const descriptionLine = DOM.append(titleBlock, $('.plugin-card-subtitle')); - descriptionLine.textContent = truncateToFirstLine(item.description || localize('pluginNoDescription', "No description provided.")); - const actions = DOM.append(header, $('.plugin-card-actions')); - const install = this.cardDisposables.add(new Button(actions, { ...defaultButtonStyles, ariaLabel: localize('installPluginAria', "Install {0}", item.name) })); - install.label = localize('install', "Install"); - this.cardDisposables.add(install.onDidClick(() => this.installMarketplacePlugin(item, install))); - if (showRecommendedBadge && this.pluginMarketplaceService.recommendedPlugins.get().has(getMarketplaceRecommendationKey(item))) { - const badges = DOM.append(card, $('.plugin-card-badges')); - this.appendCardBadge(badges, localize('recommendedBadge', "Recommended")); - } - this.cardListControllers.get(parent)?.addItem({ - row: card, - primaryAction: titleBlock, - label: item.name, - actions: [install.element], - }); - } - private rememberCardFocusElement(element: HTMLElement): void { this.firstCardFocusElement ??= element; } - private appendCardBadge(parent: HTMLElement, label: string): void { - const badge = DOM.append(parent, $('.inline-badge.plugin-card-badge')); - badge.textContent = label; - } - private renderDiscoverySnapshot(parent: HTMLElement): void { const marketplaceItems = this.getUninstalledMarketplaceItems(this.marketplaceSnapshot.items); if (marketplaceItems.length === 0) { @@ -1576,39 +1843,38 @@ export class PluginListWidget extends Disposable { ...recommended, ...marketplaceItems.filter(item => !recommendedKeys.has(getMarketplaceRecommendationKey(item))), ].slice(0, 3); - const section = DOM.append(parent, $('.plugin-card-section.plugin-discovery-section')); - const header = DOM.append(section, $('.plugin-card-section-header')); - const text = DOM.append(header, $('.plugin-card-section-text')); - const title = DOM.append(text, $('h3.plugin-card-section-title')); - title.textContent = localize('featuredPlugins', "Featured"); - const description = DOM.append(text, $('.plugin-card-section-description')); - description.textContent = localize('discoverMorePluginsDescription', "Curated plugins that add tools and expertise."); - const grid = DOM.append(section, $('.plugin-card-grid')); - this.cardListControllers.set(grid, this.cardDisposables.add(new CustomizationCardListController(grid, localize('featuredPlugins', "Featured")))); - for (const item of snapshotItems) { - this.appendMarketplacePluginCard(grid, item, false); - } - this.cardListControllers.get(grid)?.finalize(); + const grid = this.renderCardSection( + parent, + localize('featuredPlugins', "Featured"), + localize('discoverMorePluginsDescription', "Curated plugins that add tools and expertise."), + 'plugin-discovery-section', + ); + grid.classList.add('plugin-inventory-list'); + this.createPluginSectionList(grid, localize('featuredPlugins', "Featured"), snapshotItems.map(item => ({ type: 'marketplace-item', item })), false); } private renderDiscoveryError(parent: HTMLElement): void { - const section = DOM.append(parent, $('.plugin-card-section.plugin-discovery-section')); - const header = DOM.append(section, $('.plugin-card-section-header')); - const text = DOM.append(header, $('.plugin-card-section-text')); - const title = DOM.append(text, $('h3.plugin-card-section-title')); - title.textContent = localize('pluginDiscoveryUnavailable', "Available plugins could not be loaded"); - const description = DOM.append(text, $('.plugin-card-section-description')); - description.textContent = localize('pluginDiscoveryUnavailableDescription', "Check your connection, then try loading results from the configured marketplaces again."); - const retry = this.cardDisposables.add(new Button(header, { ...defaultButtonStyles, secondary: true, ariaLabel: localize('retryPluginDiscovery', "Retry Loading Plugins") })); - retry.label = localize('retry', "Retry"); - this.cardDisposables.add(retry.onDidClick(() => { - this.marketplaceSnapshot.reset(); - void this.queryMarketplaceSnapshot(); - })); + this.renderCardSection( + parent, + localize('pluginDiscoveryUnavailable', "Available plugins could not be loaded"), + localize('pluginDiscoveryUnavailableDescription', "Check your connection, then try loading results from the configured marketplaces again."), + 'plugin-discovery-section', + undefined, + header => { + const retry = this.cardDisposables.add(new Button(header, { ...defaultButtonStyles, secondary: true, ariaLabel: localize('retryPluginDiscovery', "Retry Loading Plugins") })); + retry.label = localize('retry', "Retry"); + this.cardDisposables.add(retry.onDidClick(() => { + this.marketplaceSnapshot.reset(); + void this.queryMarketplaceSnapshot(); + })); + }, + ); } private renderBrowseMarketplaceCards(): void { + this.captureSectionScrollPositions(); this.cardDisposables.clear(); + this.sectionLists = []; this.installedCreateButton = undefined; this.firstCardFocusElement = undefined; DOM.clearNode(this.cardContainer); @@ -1621,7 +1887,7 @@ export class PluginListWidget extends Disposable { } this.showCardSurface(); - const content = this.createCardScrollContent(); + const content = this.createCardScrollContent('distributed-section-layout'); const recommendedKeys = this.pluginMarketplaceService.recommendedPlugins.get(); const recommended = marketplaceItems.filter(item => recommendedKeys.has(getMarketplaceRecommendationKey(item))); const allPlugins = marketplaceItems.filter(item => !recommendedKeys.has(getMarketplaceRecommendationKey(item))); @@ -1632,10 +1898,8 @@ export class PluginListWidget extends Disposable { localize('recommendedGroupDescription', "Plugins recommended by workspace configuration."), 'plugin-marketplace-recommended-section' ); - for (const item of recommended) { - this.appendMarketplacePluginCard(recommendedGrid, item); - } - this.cardListControllers.get(recommendedGrid)?.finalize(); + recommendedGrid.classList.add('plugin-inventory-list'); + this.createPluginSectionList(recommendedGrid, localize('recommendedGroup', "Recommended for this workspace"), recommended.map(item => ({ type: 'marketplace-item', item }))); } const allGrid = this.renderCardSection( content, @@ -1643,10 +1907,11 @@ export class PluginListWidget extends Disposable { localize('allMarketplaceGroupDescription', "Plugins available from configured marketplaces."), 'plugin-marketplace-all-section' ); - for (const item of allPlugins) { - this.appendMarketplacePluginCard(allGrid, item); - } - this.cardListControllers.get(allGrid)?.finalize(); + allGrid.classList.add('plugin-inventory-list'); + this.createPluginSectionList(allGrid, localize('allMarketplaceGroup', "All plugins"), allPlugins.map(item => ({ type: 'marketplace-item', item }))); + this.layoutPluginSectionLists(); + this.cardScrollable.scanDomNode(); + this.schedulePluginSectionLayout(); } private getUninstalledMarketplaceItems(items: readonly IMarketplacePluginItem[] = this.marketplaceItems): IMarketplacePluginItem[] { @@ -1934,26 +2199,26 @@ export class PluginListWidget extends Disposable { return; } + this.captureSectionScrollPositions(); this.cardDisposables.clear(); + this.sectionLists = []; this.installedCreateButton = undefined; this.firstCardFocusElement = undefined; DOM.clearNode(this.cardContainer); this.showCardSurface(); - const content = this.createCardScrollContent('plugin-search-results'); + const content = this.createCardScrollContent('plugin-search-results', 'distributed-section-layout'); if (installedCount > 0) { const installedList = this.renderCardSection(content, localize('installedSearchHeader', "Installed"), undefined, 'installed-plugins-section', installedCount); installedList.classList.add('plugin-inventory-list'); - for (const item of this.installedItems) { - this.appendInstalledPluginRow(installedList, item); - } - for (const item of remoteItems) { - this.appendRemotePluginRow(installedList, item); - } - this.cardListControllers.get(installedList)?.finalize(); + this.createPluginSectionList(installedList, localize('installedSearchHeader', "Installed"), [ + ...this.installedItems.map(item => ({ type: 'plugin-item' as const, item })), + ...remoteItems.map(item => ({ type: 'remote-item' as const, item })), + ]); } if (this.marketplaceItems.length > 0) { this.renderAvailablePlugins(content, this.marketplaceItems, false, localize('availableSearchHeader', "Available to install"), undefined); } + this.schedulePluginSectionLayout(); this.list.splice(0, this.list.length, []); } @@ -2061,6 +2326,9 @@ export class PluginListWidget extends Disposable { layout(height: number, width: number): void { this.lastHeight = height; this.lastWidth = width; + if (!this.visible || this.element.parentElement?.style.display === 'none') { + return; + } this.element.style.height = `${height}px`; this.updateResponsiveLayout(width); @@ -2090,7 +2358,7 @@ export class PluginListWidget extends Disposable { this.cardScrollableNode.style.height = `${listHeight}px`; this.listContainer.style.height = `${listHeight}px`; this.list.layout(listHeight, width); - this.cardScrollable.scanDomNode(); + this.schedulePluginSectionLayout(); } focusSearch(): void { @@ -2099,7 +2367,16 @@ export class PluginListWidget extends Disposable { revealLastItem(): void { if (this.cardScrollableNode.style.display !== 'none') { - this.cardScrollable.setScrollPosition({ scrollTop: this.cardContainer.scrollHeight }); + const reveal = () => { + const section = this.sectionLists.at(-1); + if (section?.entries.length) { + section.list.reveal(section.entries.length - 1); + } + this.cardScrollable.scanDomNode(); + this.cardScrollable.setScrollPosition({ scrollTop: this.cardContainer.scrollHeight }); + }; + reveal(); + this.revealLastItemScheduler.value = DOM.scheduleAtNextAnimationFrame(DOM.getWindow(this.element), reveal); return; } if (this.list.length > 0) { @@ -2109,7 +2386,15 @@ export class PluginListWidget extends Disposable { focus(): void { if (this.cardScrollableNode.style.display !== 'none') { - this.firstCardFocusElement?.focus(); + if (this.firstCardFocusElement) { + this.firstCardFocusElement.focus(); + } else { + const section = this.sectionLists[0]; + if (section?.entries.length) { + section.list.setFocus([0]); + section.list.domFocus(); + } + } } else if (this.list.length > 0) { this.list.domFocus(); this.list.setFocus([0]); diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/toolsListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/toolsListWidget.ts index 099bd6093598e..feeb74e6dd2be 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/toolsListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/toolsListWidget.ts @@ -8,7 +8,7 @@ import { IKeyboardEvent } from '../../../../../base/browser/keyboardEvent.js'; import { Button } from '../../../../../base/browser/ui/button/button.js'; import { HighlightedLabel } from '../../../../../base/browser/ui/highlightedlabel/highlightedLabel.js'; import { InputBox } from '../../../../../base/browser/ui/inputbox/inputBox.js'; -import { IListContextMenuEvent, IListVirtualDelegate } from '../../../../../base/browser/ui/list/list.js'; +import { IListContextMenuEvent, IListRenderer, IListVirtualDelegate } from '../../../../../base/browser/ui/list/list.js'; import { DomScrollableElement } from '../../../../../base/browser/ui/scrollbar/scrollableElement.js'; import { Checkbox, TriStateCheckbox } from '../../../../../base/browser/ui/toggle/toggle.js'; import { StandardMouseEvent } from '../../../../../base/browser/mouseEvent.js'; @@ -20,7 +20,7 @@ import { Codicon } from '../../../../../base/common/codicons.js'; import { Emitter } from '../../../../../base/common/event.js'; import { IMatch, matchesContiguousSubString } from '../../../../../base/common/filters.js'; import { KeyCode } from '../../../../../base/common/keyCodes.js'; -import { Disposable, DisposableStore, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; import { autorun, derived, IObservable, IReader, observableSignalFromEvent, observableValue } from '../../../../../base/common/observable.js'; import { ScrollbarVisibility } from '../../../../../base/common/scrollable.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; @@ -31,6 +31,7 @@ import { IContextMenuService, IContextViewService } from '../../../../../platfor import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { WorkbenchList } from '../../../../../platform/list/browser/listService.js'; +import { layoutVirtualizedSectionList, layoutVirtualizedSections, setupCollapsibleSection } from './customizationCardList.js'; import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; import { defaultButtonStyles, defaultCheckboxStyles, defaultInputBoxStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; import { IExtensionManifestPropertiesService } from '../../../../services/extensions/common/extensionManifestPropertiesService.js'; @@ -47,18 +48,6 @@ export function isToolsTreeKeyboardTarget(target: HTMLElement, row: HTMLElement) return target === row; } -interface ITreeRow { - readonly kind: 'set' | 'tool'; - readonly rowId: string; - readonly toolSetId: string; - readonly element: HTMLElement; - readonly toggleNode: HTMLElement; - readonly group?: HTMLElement; - readonly children?: ITreeRow[]; - readonly parent?: ITreeRow; - readonly readOnly?: boolean; -} - interface IToolViewModel { readonly tool: IToolData; readonly nameMatches?: IMatch[]; @@ -72,8 +61,286 @@ interface IToolSetViewModel { /** When searching, sets are force-expanded to reveal matching tools regardless of user state. */ readonly forceExpanded: boolean; readonly readOnly: boolean; + /** Precomputed subtitle (own `detail`, or an extension description) shown under the set's name. */ + readonly detail?: string; +} + +//#region Virtualized tool rows + +/** A flattened row in a section's virtualized list: either a tool-set header or one of its member tools. */ +interface IToolsSetRowEntry { + readonly kind: 'set'; + readonly vm: IToolSetViewModel; +} + +interface IToolsToolRowEntry { + readonly kind: 'tool'; + readonly setVm: IToolSetViewModel; + readonly toolVm: IToolViewModel; +} + +type IToolsRowEntry = IToolsSetRowEntry | IToolsToolRowEntry; + +/** One section (Built-in / Connected / Extension) rendered as its own virtualized `WorkbenchList`. */ +interface IToolsSectionList { + readonly list: WorkbenchList; + /** Flattened rows currently spliced into {@link list}; reassigned (not mutated) on refresh. */ + entries: readonly IToolsRowEntry[]; + /** Stable set view models backing this section, used to recompute {@link entries} on expand/collapse. */ + readonly setVms: readonly IToolSetViewModel[]; + readonly container: HTMLElement; + readonly label: string; +} + +const TOOLS_SET_ROW_TEMPLATE_ID = 'toolsSetRow'; +const TOOLS_TOOL_ROW_TEMPLATE_ID = 'toolsToolRow'; +// Row heights derived from the fixed single-line label/subtext CSS plus each row kind's vertical padding. +const TOOLS_SET_ROW_PADDING = 16; // --vscode-spacing-size80 (8px) top + bottom +const TOOLS_TOOL_ROW_PADDING = 12; // --vscode-spacing-size60 (6px) top + bottom +const TOOLS_ROW_LABEL_HEIGHT = 18; +const TOOLS_ROW_SUBTEXT_HEIGHT = 14; +/** Caps a section's own scroll viewport height; sections with more content scroll internally. */ +const TOOLS_SECTION_MAX_HEIGHT = 320; + +function computeToolsRowHeight(entry: IToolsRowEntry): number { + if (entry.kind === 'set') { + return TOOLS_SET_ROW_PADDING + TOOLS_ROW_LABEL_HEIGHT + (entry.vm.detail ? TOOLS_ROW_SUBTEXT_HEIGHT : 0); + } + const description = entry.toolVm.tool.userDescription ?? entry.toolVm.tool.modelDescription; + return TOOLS_TOOL_ROW_PADDING + TOOLS_ROW_LABEL_HEIGHT + (description ? TOOLS_ROW_SUBTEXT_HEIGHT : 0); +} + +class ToolsRowDelegate implements IListVirtualDelegate { + getHeight(entry: IToolsRowEntry): number { + return computeToolsRowHeight(entry); + } + getTemplateId(entry: IToolsRowEntry): string { + return entry.kind === 'set' ? TOOLS_SET_ROW_TEMPLATE_ID : TOOLS_TOOL_ROW_TEMPLATE_ID; + } +} + +interface IToolsSetRowTemplateData { + readonly container: HTMLElement; + readonly checkbox: TriStateCheckbox; + readonly label: HighlightedLabel; + readonly subtext: HTMLElement; + readonly count: HTMLElement; + readonly alwaysAvailable: HTMLElement; + readonly moreButton: HTMLButtonElement; + readonly chevron: HTMLElement; + readonly templateDisposables: DisposableStore; + readonly elementDisposables: DisposableStore; + currentIndex: number; +} + +/** Renders a tool-set header row: checkbox/tri-state, name + detail, enabled count, more actions, chevron. */ +class ToolsSetRowRenderer implements IListRenderer { + readonly templateId = TOOLS_SET_ROW_TEMPLATE_ID; + private readonly _templates = new Set(); + private _focusedIndex = -1; + + constructor( + private readonly _sessionType: string, + private readonly _enablementService: IAgentHostToolSetEnablementService, + private readonly _isExpanded: (vm: IToolSetViewModel, reader: IReader) => boolean, + private readonly _toggleExpand: (setId: string) => void, + private readonly _resolveExtension: (ts: IToolSet) => IExtension | undefined, + private readonly _showExtensionMenu: (anchor: HTMLElement, extension: IExtension) => void, + ) { } + + renderTemplate(container: HTMLElement): IToolsSetRowTemplateData { + container.classList.add('tools-list-setrow'); + const templateDisposables = new DisposableStore(); + + const checkbox = templateDisposables.add(new TriStateCheckbox('', false, defaultCheckboxStyles)); + checkbox.domNode.tabIndex = -1; + container.appendChild(checkbox.domNode); + templateDisposables.add(DOM.addDisposableGenericMouseDownListener(checkbox.domNode, event => DOM.EventHelper.stop(event, true))); + + const main = DOM.append(container, $('.tools-list-row-main')); + const text = DOM.append(main, $('.tools-list-row-text')); + const labelEl = DOM.append(text, $('span.tools-list-row-label')); + const label = templateDisposables.add(new HighlightedLabel(labelEl)); + const subtext = DOM.append(text, $('span.tools-list-row-subtext')); + + const count = DOM.append(container, $('span.tools-list-row-count')); + const alwaysAvailable = DOM.append(container, $('span.tools-list-always-available')); + alwaysAvailable.textContent = localize('toolsAlwaysAvailable', "Always Available"); + + const moreButton = DOM.append(container, $('button.tools-list-more-action')) as HTMLButtonElement; + moreButton.type = 'button'; + moreButton.tabIndex = -1; + moreButton.classList.add(...ThemeIcon.asClassNameArray(Codicon.ellipsis)); + templateDisposables.add(DOM.addDisposableGenericMouseDownListener(moreButton, event => DOM.EventHelper.stop(event, true))); + + const chevron = DOM.append(container, $('a.tools-list-chevron.codicon')) as HTMLAnchorElement; + chevron.setAttribute('aria-hidden', 'true'); + + const template = { container, checkbox, label, subtext, count, alwaysAvailable, moreButton, chevron, templateDisposables, elementDisposables: templateDisposables.add(new DisposableStore()), currentIndex: -1 }; + this._templates.add(template); + return template; + } + + renderElement(entry: IToolsSetRowEntry, index: number, data: IToolsSetRowTemplateData): void { + data.elementDisposables.clear(); + data.currentIndex = index; + data.container.removeAttribute('aria-selected'); + const vm = entry.vm; + const ts = vm.toolSet; + const setName = ts.description ?? ts.referenceName; + + data.label.set(setName, vm.nameMatches); + data.subtext.style.display = vm.detail ? '' : 'none'; + data.subtext.textContent = vm.detail ?? ''; + data.alwaysAvailable.style.display = vm.readOnly ? '' : 'none'; + data.checkbox.domNode.style.display = vm.readOnly ? 'none' : ''; + + if (!vm.readOnly) { + data.checkbox.setTitle(localize('toolsSetCheckbox', "Enable {0}", setName)); + data.elementDisposables.add(data.checkbox.onChange(() => { + this._enablementService.setToolSetEnabled(this._sessionType, ts.id, vm.allToolIds, data.checkbox.checked === true); + })); + } + + // Tri-state, enabled count and aria-checked all follow the same enablement observable. + data.elementDisposables.add(autorun(reader => { + const state = this._enablementService.observe(this._sessionType).read(reader); + const triState = getToolSetTriState(state, ts.id, vm.allToolIds); + if (!vm.readOnly) { + data.checkbox.checked = triState; + data.container.setAttribute('aria-checked', triState === 'mixed' ? 'mixed' : String(triState)); + } else { + data.container.removeAttribute('aria-checked'); + } + const enabledCount = vm.allToolIds.reduce((n, id) => n + (isToolEnabledInSet(state, ts.id, id) ? 1 : 0), 0); + data.count.textContent = `${enabledCount}/${vm.allToolIds.length}`; + data.count.setAttribute('aria-label', localize('toolsRowEnabledOfTotal', "{0} of {1} tools enabled", enabledCount, vm.allToolIds.length)); + })); + + data.elementDisposables.add(autorun(reader => { + const expanded = this._isExpanded(vm, reader); + data.chevron.classList.toggle('codicon-chevron-down-compact', expanded); + data.chevron.classList.toggle('codicon-chevron-right-compact', !expanded); + data.container.setAttribute('aria-expanded', String(expanded)); + })); + + const extension = this._resolveExtension(ts); + data.moreButton.style.display = extension ? '' : 'none'; + data.moreButton.tabIndex = extension && index === this._focusedIndex ? 0 : -1; + if (extension) { + const moreLabel = localize('toolsSetMoreActions', "More actions for {0}", setName); + data.moreButton.setAttribute('aria-label', moreLabel); + data.moreButton.title = moreLabel; + data.elementDisposables.add(DOM.addDisposableListener(data.moreButton, 'click', e => { + DOM.EventHelper.stop(e, true); + this._showExtensionMenu(data.moreButton, extension); + })); + } + + // Clicking the row body (not the checkbox/more-actions button) toggles expand/collapse. + data.elementDisposables.add(DOM.addDisposableListener(data.container, 'click', e => { + if (data.checkbox.domNode.contains(e.target as Node) || data.moreButton.contains(e.target as Node)) { + return; + } + this._toggleExpand(ts.id); + })); + } + + setFocusedIndex(index: number): void { + this._focusedIndex = index; + for (const template of this._templates) { + template.moreButton.tabIndex = template.moreButton.style.display !== 'none' && template.currentIndex === index ? 0 : -1; + } + } + + disposeTemplate(data: IToolsSetRowTemplateData): void { + this._templates.delete(data); + data.templateDisposables.dispose(); + } +} + +interface IToolsToolRowTemplateData { + readonly container: HTMLElement; + readonly checkbox: Checkbox; + readonly label: HighlightedLabel; + readonly subtext: HTMLElement; + readonly alwaysAvailable: HTMLElement; + readonly templateDisposables: DisposableStore; + readonly elementDisposables: DisposableStore; +} + +/** Renders a member-tool row nested (visually, via padding) under its tool-set header. */ +class ToolsToolRowRenderer implements IListRenderer { + readonly templateId = TOOLS_TOOL_ROW_TEMPLATE_ID; + + constructor( + private readonly _sessionType: string, + private readonly _enablementService: IAgentHostToolSetEnablementService, + ) { } + + renderTemplate(container: HTMLElement): IToolsToolRowTemplateData { + container.classList.add('tools-list-toolrow'); + const templateDisposables = new DisposableStore(); + + const checkbox = templateDisposables.add(new Checkbox('', false, defaultCheckboxStyles)); + checkbox.domNode.tabIndex = -1; + container.appendChild(checkbox.domNode); + templateDisposables.add(DOM.addDisposableGenericMouseDownListener(checkbox.domNode, event => DOM.EventHelper.stop(event, true))); + + const text = DOM.append(container, $('.tools-list-row-text')); + const labelEl = DOM.append(text, $('span.tools-list-row-label')); + const label = templateDisposables.add(new HighlightedLabel(labelEl)); + const subtext = DOM.append(text, $('span.tools-list-row-subtext')); + + const alwaysAvailable = DOM.append(container, $('span.tools-list-always-available')); + alwaysAvailable.textContent = localize('toolsAlwaysAvailable', "Always Available"); + + return { container, checkbox, label, subtext, alwaysAvailable, templateDisposables, elementDisposables: templateDisposables.add(new DisposableStore()) }; + } + + renderElement(entry: IToolsToolRowEntry, _index: number, data: IToolsToolRowTemplateData): void { + data.elementDisposables.clear(); + data.container.removeAttribute('aria-selected'); + const { setVm, toolVm } = entry; + const tool = toolVm.tool; + const toolName = tool.displayName ?? tool.id; + + data.container.classList.toggle('readonly', setVm.readOnly); + data.label.set(toolName, toolVm.nameMatches); + const description = tool.userDescription ?? tool.modelDescription; + data.subtext.style.display = description ? '' : 'none'; + data.subtext.textContent = description ?? ''; + data.alwaysAvailable.style.display = setVm.readOnly ? '' : 'none'; + data.checkbox.domNode.style.display = setVm.readOnly ? 'none' : ''; + + if (!setVm.readOnly) { + data.checkbox.setTitle(localize('toolsToolCheckbox', "Enable {0}", toolName)); + data.elementDisposables.add(data.checkbox.onChange(() => { + this._enablementService.setToolEnabled(this._sessionType, setVm.toolSet.id, tool.id, data.checkbox.checked); + })); + data.elementDisposables.add(autorun(reader => { + const enabled = isToolEnabledInSet(this._enablementService.observe(this._sessionType).read(reader), setVm.toolSet.id, tool.id); + data.checkbox.checked = enabled; + data.container.setAttribute('aria-checked', String(enabled)); + })); + data.elementDisposables.add(DOM.addDisposableListener(data.container, 'click', e => { + if (data.checkbox.domNode.contains(e.target as Node)) { + return; + } + this._enablementService.setToolEnabled(this._sessionType, setVm.toolSet.id, tool.id, !data.checkbox.checked); + })); + } else { + data.container.removeAttribute('aria-checked'); + } + } + + disposeTemplate(data: IToolsToolRowTemplateData): void { + data.templateDisposables.dispose(); + } } +//#endregion + /** * Marketplace search used when browsing for tool-contributing extensions. The marketplace cannot * be filtered server-side by contributed feature, so this is a text query. @@ -143,6 +410,7 @@ export class ToolsListWidget extends Disposable { readonly onDidSelectExtension = this._onDidSelectExtension.event; private readonly _rowStore = this._register(new DisposableStore()); + private readonly _pendingSectionLayout = this._register(new MutableDisposable()); private readonly _searchQuery = observableValue('toolsSearchQuery', ''); private readonly _expanded = observableValue>('toolsExpanded', new Set()); private readonly _delayedSearch = this._register(new Delayer(200)); @@ -164,9 +432,9 @@ export class ToolsListWidget extends Disposable { private _lastHeight = 0; private _lastWidth = 0; - private _activeRowId: string | undefined; - private _rows: ITreeRow[] = []; - private readonly _rowByElement = new Map(); + private _sectionLists: IToolsSectionList[] = []; + private _collapsedSections: Set | undefined = new Set(); + private readonly _sectionScrollPositions = new Map(); /** Read-only tool sets injected for the current session type (e.g. the Copilot CLI built-ins). */ private readonly _staticReadOnlySets: readonly IToolSet[]; @@ -194,16 +462,7 @@ export class ToolsListWidget extends Disposable { // Wrap the tree in a DomScrollableElement for an overlay scrollbar (not the native one). this._treeContainer = $('.tools-list-tree'); - this._treeContainer.setAttribute('role', 'tree'); - this._treeContainer.setAttribute('aria-label', localize('toolsTreeAria', "Tool groups")); - // Tree-style keyboard navigation with a roving tabIndex, so the tree is a single tab stop. - this._register(DOM.addStandardDisposableListener(this._treeContainer, DOM.EventType.KEY_DOWN, e => this._onTreeKeyDown(e))); - this._register(DOM.addDisposableListener(this._treeContainer, DOM.EventType.FOCUS_IN, e => { - const row = this._rowFromTarget(e.target as HTMLElement); - if (row) { - this._setRovingRow(row); - } - })); + this._treeContainer.classList.add('distributed-section-layout'); this._treeScrollable = this._register(new DomScrollableElement(this._treeContainer, { horizontal: ScrollbarVisibility.Hidden, vertical: ScrollbarVisibility.Auto, @@ -221,6 +480,12 @@ export class ToolsListWidget extends Disposable { this._render(viewModel.read(reader)); })); + // Expand/collapse never rebuilds the DOM; it only re-splices the affected section's rows in place. + this._register(autorun(reader => { + this._expanded.read(reader); + this._refreshAllSectionEntries(); + })); + this._register(autorun(reader => { // Badge counts enabled individual tools across all visible sets, ignoring the search filter. const count = countEnabledCustomizationTools(this._toolsService.toolSets.read(reader), this._readState(reader), reader); @@ -397,16 +662,20 @@ export class ToolsListWidget extends Disposable { visibleTools, nameMatches, forceExpanded: query !== '', - readOnly: ts.id === 'copilot-cli' + readOnly: ts.id === 'copilot-cli', + detail: this._resolveSetDetail(ts) }; } layout(height: number, width: number): void { this._lastHeight = height; this._lastWidth = width; + if (this.element.parentElement?.style.display === 'none') { + return; + } this.element.classList.toggle('narrow-layout', width < 500); this._searchInput.layout(); - this._treeScrollable.scanDomNode(); + this._scheduleSectionListLayout(); const galleryOffset = this._galleryContainer.getBoundingClientRect().top - this.element.getBoundingClientRect().top; this._galleryList.layout(Math.max(0, height - galleryOffset), width); @@ -537,11 +806,15 @@ export class ToolsListWidget extends Disposable { } private _render(model: readonly IToolSetViewModel[]): void { - // A live update (search/tool-set change) rebuilds rows; keep keyboard focus in the tree if it was there. - const hadFocus = DOM.isAncestor(this._treeContainer.ownerDocument.activeElement, this._treeContainer); + // A live update (search/tool-set change) rebuilds sections; keep keyboard focus if it was in the tree. + const focusedSection = this._sectionLists.find(s => DOM.isAncestor(this._treeContainer.ownerDocument.activeElement, s.container)); + const focusedRowId = focusedSection ? this._currentFocusedRowId(focusedSection) : undefined; + for (const section of this._sectionLists) { + this._sectionScrollPositions.set(section.label, section.list.scrollTop); + } + this._rowStore.clear(); - this._rows = []; - this._rowByElement.clear(); + this._sectionLists = []; DOM.clearNode(this._treeContainer); const query = this._searchQuery.get().trim(); @@ -589,8 +862,11 @@ export class ToolsListWidget extends Disposable { this._rowStore.add(browseButton.onDidClick(() => this._setBrowseMode(true))); } : undefined, ); - this._initRovingTabIndex(hadFocus); - this._treeScrollable.scanDomNode(); + + this._scheduleSectionListLayout(); + if (focusedRowId) { + this._restoreFocus(focusedRowId); + } } private _renderToolSection( @@ -614,340 +890,241 @@ export class ToolsListWidget extends Disposable { DOM.append(text, $('p.tools-inventory-section-description')).textContent = description; renderActions?.(header); - const inventory = DOM.append(section, $('.tools-inventory-list')); - inventory.setAttribute('role', 'group'); - inventory.setAttribute('aria-label', title); + let inventory: HTMLElement; if (model.length === 0) { + inventory = DOM.append(section, $('.tools-inventory-list')); DOM.append(inventory, $('.plugin-inventory-empty')).textContent = emptyMessage; - return; - } - for (const vm of model) { - const setRow = this._renderToolSet(inventory, vm); - this._addRow(setRow); - for (const child of setRow.children!) { - this._addRow(child); - } + } else { + inventory = this._createToolsSectionList(section, title, model).container; } + const collapsedSections = this._collapsedSections ??= new Set(); + setupCollapsibleSection( + headingRow, + inventory, + title, + this._rowStore, + collapsedSections.has(title), + collapsed => { + if (collapsed) { + collapsedSections.add(title); + } else { + collapsedSections.delete(title); + } + this._scheduleSectionListLayout(); + }, + ); } - private _addRow(row: ITreeRow): void { - this._rows.push(row); - this._rowByElement.set(row.element, row); - } - - private _renderToolSet(container: HTMLElement, vm: IToolSetViewModel): ITreeRow { - const ts = vm.toolSet; - const row = DOM.append(container, $('.tools-list-setrow')); - // Tree item with a roving tabIndex: navigated with arrows, toggled with Space; not a Tab stop. - row.setAttribute('role', 'treeitem'); - row.setAttribute('aria-level', '1'); - row.tabIndex = -1; - - const setName = ts.description ?? ts.referenceName; - const toggleExpand = () => this._toggleCollapsed(ts.id); - - let checkbox: TriStateCheckbox | undefined; - if (!vm.readOnly) { - checkbox = this._rowStore.add(new TriStateCheckbox( - localize('toolsSetCheckbox', "Enable {0}", setName), - getToolSetTriState(this._currentState(), ts.id, vm.allToolIds), - defaultCheckboxStyles, - )); - checkbox.domNode.tabIndex = -1; - row.prepend(checkbox.domNode); - this._rowStore.add(checkbox.onChange(() => { - const enabled = checkbox!.checked === true; - this._enablementService.setToolSetEnabled(this._sessionType, ts.id, vm.allToolIds, enabled); - })); - } - - const main = DOM.append(row, $('.tools-list-row-main')); - const text = DOM.append(main, $('.tools-list-row-text')); - const label = DOM.append(text, $('span.tools-list-row-label')); - const labelHighlight = this._rowStore.add(new HighlightedLabel(label)); - labelHighlight.set(setName, vm.nameMatches); - const detail = this._resolveSetDetail(ts); - if (detail) { - DOM.append(text, $('span.tools-list-row-subtext')).textContent = detail; - } - - const count = DOM.append(row, $('span.tools-list-row-count')); - if (vm.readOnly) { - DOM.append(row, $('span.tools-list-always-available')).textContent = localize('toolsAlwaysAvailable', "Always Available"); - } - const extension = this._resolveExtensionForToolSet(ts); - let moreButton: HTMLButtonElement | undefined; - if (extension) { - const moreLabel = localize('toolsSetMoreActions', "More actions for {0}", setName); - moreButton = DOM.append(row, $('button.tools-list-more-action')) as HTMLButtonElement; - moreButton.type = 'button'; - moreButton.classList.add(...ThemeIcon.asClassNameArray(Codicon.ellipsis)); - moreButton.setAttribute('aria-label', moreLabel); - moreButton.title = moreLabel; - this._rowStore.add(DOM.addDisposableListener(moreButton, 'click', e => { - DOM.EventHelper.stop(e, true); - this._showExtensionContextMenu(moreButton!, extension); - })); - } - - // Decorative chevron: expand state is on the row (aria-expanded); toggled by row click or arrows. - const chevron = DOM.append(row, $('a.tools-list-chevron.codicon')) as HTMLAnchorElement; - chevron.setAttribute('aria-hidden', 'true'); - - this._rowStore.add(DOM.addDisposableListener(row, 'click', e => { - if (checkbox?.domNode.contains(e.target as Node) || moreButton?.contains(e.target as Node)) { - return; - } - row.focus(); - toggleExpand(); - })); - - const group = DOM.append(container, $('.tools-list-children')); - group.id = `tools-group-${ts.id}`; - group.setAttribute('role', 'group'); - group.setAttribute('aria-label', setName); - // The child group is a DOM sibling (flat flex layout), so associate it with the parent item via aria-owns. - row.setAttribute('aria-owns', group.id); - - const setRow: ITreeRow = { - kind: 'set', - rowId: `set:${ts.id}`, - toolSetId: ts.id, - element: row, - toggleNode: checkbox?.domNode ?? row, - group, - children: [], - readOnly: vm.readOnly, + /** Creates one virtualized `WorkbenchList` for a section, flattening its sets/tools into rows. */ + private _createToolsSectionList(sectionEl: HTMLElement, label: string, setVms: readonly IToolSetViewModel[]): IToolsSectionList { + const listContainer = DOM.append(sectionEl, $('.tools-inventory-list')); + + const setRenderer = new ToolsSetRowRenderer( + this._sessionType, + this._enablementService, + (vm, reader) => vm.forceExpanded || this._expanded.read(reader).has(vm.toolSet.id), + setId => this._toggleCollapsed(setId), + ts => this._resolveExtensionForToolSet(ts), + (anchor, extension) => this._showExtensionContextMenu(anchor, extension), + ); + const list = this._rowStore.add(this._instantiationService.createInstance( + WorkbenchList, + 'ToolsSectionList', + listContainer, + new ToolsRowDelegate(), + [ + setRenderer, + new ToolsToolRowRenderer(this._sessionType, this._enablementService), + ], + { + multipleSelectionSupport: false, + horizontalScrolling: false, + accessibilityProvider: { + getWidgetAriaLabel: () => label, + getWidgetRole: () => 'tree', + getRole: () => 'treeitem', + getAriaLevel: (entry: IToolsRowEntry) => entry.kind === 'set' ? 1 : 2, + // Rows carry no explicit aria-label, same as the original DOM tree: assistive tech + // derives the accessible name from each row's own label/subtext/count text content. + getAriaLabel: () => null, + }, + identityProvider: { getId: (entry: IToolsRowEntry) => this._entryRowId(entry) }, + }, + )) as WorkbenchList; + + const section: IToolsSectionList = { + list, + entries: this._computeSectionEntries(setVms), + setVms, + container: listContainer, + label, }; - for (const tool of vm.visibleTools) { - setRow.children!.push(this._renderTool(group, setRow, vm, tool)); + if (section.entries.length > 0) { + listContainer.style.height = `${computeToolsRowHeight(section.entries[0])}px`; } - - // Tri-state and count reflect enablement; update in place so a toggle never rebuilds the row. - this._rowStore.add(autorun(reader => { - const state = this._readState(reader); - const triState = getToolSetTriState(state, ts.id, vm.allToolIds); - if (checkbox) { - checkbox.checked = triState; - this._updateRowAriaChecked(row, triState); - } else { - row.removeAttribute('aria-checked'); + list.splice(0, list.length, section.entries as IToolsRowEntry[]); + list.scrollTop = this._sectionScrollPositions.get(label) ?? 0; + this._rowStore.add(list.onDidChangeSelection(event => { + if (event.indexes.length > 0) { + list.setSelection([]); } - const enabledCount = vm.allToolIds.reduce((n, id) => n + (isToolEnabledInSet(state, ts.id, id) ? 1 : 0), 0); - count.textContent = `${enabledCount}/${vm.allToolIds.length}`; - count.setAttribute('aria-label', localize('toolsRowEnabledOfTotal', "{0} of {1} tools enabled", enabledCount, vm.allToolIds.length)); })); - - // Expand/collapse toggles child visibility in place (no rebuild) so row focus is kept. - this._rowStore.add(autorun(reader => { - const expanded = vm.forceExpanded || this._expanded.read(reader).has(ts.id); - group.style.display = expanded ? '' : 'none'; - chevron.classList.toggle('codicon-chevron-down-compact', expanded); - chevron.classList.toggle('codicon-chevron-right-compact', !expanded); - row.setAttribute('aria-expanded', String(expanded)); - this._treeScrollable.scanDomNode(); + this._rowStore.add(list.onDidChangeFocus(event => setRenderer.setFocusedIndex(event.indexes[0] ?? -1))); + + // Captured (via a capture-phase listener on an ancestor of the list, so it runs strictly before + // the list's own bubble-phase key handler) so Up/Down at a section's edge can be told apart from + // a normal in-section move that merely lands on the edge. + let focusBeforeKeyDown: number | undefined; + this._rowStore.add(DOM.addStandardDisposableListener(listContainer, DOM.EventType.KEY_DOWN, () => { + focusBeforeKeyDown = list.getFocus()[0]; + }, true)); + // Registered after `createInstance` above, so on the list's own DOM node this listener runs + // after the list's internal keyboard controller (same-node listeners fire in registration order). + // This lets Up/Down/Enter/PageUp/PageDown/Escape/Ctrl+A keep working exactly as List implements + // them; only the keys List does not handle (Space/Left/Right/Home/End) are handled here. + this._rowStore.add(DOM.addStandardDisposableListener(list.getHTMLElement(), DOM.EventType.KEY_DOWN, e => { + this._onSectionKeyDown(section, e, () => focusBeforeKeyDown); })); - return setRow; + this._sectionLists.push(section); + return section; } - private _renderTool(group: HTMLElement, parent: ITreeRow, vm: IToolSetViewModel, toolVm: IToolViewModel): ITreeRow { - const tool = toolVm.tool; - const enabled = isToolEnabledInSet(this._currentState(), vm.toolSet.id, tool.id); - const toolName = tool.displayName ?? tool.id; - - const row = DOM.append(group, $('.tools-list-toolrow')); - row.classList.toggle('readonly', vm.readOnly); - // Tree item at level 2; read-only tools stay navigable (only the checkbox is disabled). - row.setAttribute('role', 'treeitem'); - row.setAttribute('aria-level', '2'); - row.tabIndex = -1; - - let checkbox: Checkbox | undefined; - if (!vm.readOnly) { - checkbox = this._rowStore.add(new Checkbox( - localize('toolsToolCheckbox', "Enable {0}", toolName), - enabled, - defaultCheckboxStyles, - )); - checkbox.domNode.tabIndex = -1; - row.prepend(checkbox.domNode); - this._updateRowAriaChecked(row, enabled); - this._rowStore.add(checkbox.onChange(() => { - this._enablementService.setToolEnabled(this._sessionType, vm.toolSet.id, tool.id, checkbox!.checked); - })); - - this._rowStore.add(DOM.addDisposableListener(row, 'click', e => { - if (checkbox!.domNode.contains(e.target as Node)) { - return; + /** Flattens a section's tool sets into rows, expanding each set's tools when the set is expanded. */ + private _computeSectionEntries(setVms: readonly IToolSetViewModel[]): IToolsRowEntry[] { + const entries: IToolsRowEntry[] = []; + for (const vm of setVms) { + entries.push({ kind: 'set', vm }); + if (this._isRowExpanded(vm)) { + for (const toolVm of vm.visibleTools) { + entries.push({ kind: 'tool', setVm: vm, toolVm }); } - row.focus(); - this._enablementService.setToolEnabled(this._sessionType, vm.toolSet.id, tool.id, !checkbox!.checked); - })); - - // Keep the checkbox and the treeitem's aria-checked in sync (e.g. when the parent set is toggled). - this._rowStore.add(autorun(reader => { - const toolEnabled = isToolEnabledInSet(this._readState(reader), vm.toolSet.id, tool.id); - checkbox!.checked = toolEnabled; - this._updateRowAriaChecked(row, toolEnabled); - })); - } - - const text = DOM.append(row, $('.tools-list-row-text')); - const label = DOM.append(text, $('span.tools-list-row-label')); - const labelHighlight = this._rowStore.add(new HighlightedLabel(label)); - labelHighlight.set(toolName, toolVm.nameMatches); - const description = tool.userDescription ?? tool.modelDescription; - if (description) { - const subtext = DOM.append(text, $('span.tools-list-row-subtext')); - subtext.textContent = description; - } - if (vm.readOnly) { - DOM.append(row, $('span.tools-list-always-available')).textContent = localize('toolsAlwaysAvailable', "Always Available"); + } } - - return { - kind: 'tool', - rowId: `tool:${vm.toolSet.id}:${tool.id}`, - toolSetId: vm.toolSet.id, - element: row, - toggleNode: checkbox?.domNode ?? row, - parent, - readOnly: vm.readOnly, - }; + return entries; } - /** - * Subtitle for a tool-set row: the set's own `detail`, or for extension sets the extension's - * description (falling back to a generic "contributed by" label). - */ - private _resolveSetDetail(ts: IToolSet): string | undefined { - if (ts.detail) { - return ts.detail; - } - if (ts.source.type !== 'extension') { - return undefined; - } - const source = ts.source; - const extension = this._extensionsWorkbenchService.local.find(e => ExtensionIdentifier.equals(e.identifier.id, source.extensionId)); - return extension?.description || localize('toolsSetExtensionDetail', "Tools contributed by {0}", source.label); + private _isRowExpanded(vm: IToolSetViewModel): boolean { + return vm.forceExpanded || this._expanded.get().has(vm.toolSet.id); } - /** Mirror a row's enablement onto its `treeitem` so assistive tech announces it while navigating. */ - private _updateRowAriaChecked(element: HTMLElement, state: boolean | 'mixed'): void { - element.setAttribute('aria-checked', state === 'mixed' ? 'mixed' : String(state)); + private _entryRowId(entry: IToolsRowEntry): string { + return entry.kind === 'set' ? `set:${entry.vm.toolSet.id}` : `tool:${entry.setVm.toolSet.id}:${entry.toolVm.tool.id}`; } - private _toggleCollapsed(toolSetId: string): void { - const next = new Set(this._expanded.get()); - if (next.has(toolSetId)) { - next.delete(toolSetId); - } else { - next.add(toolSetId); - } - this._expanded.set(next, undefined); + private _currentFocusedRowId(section: IToolsSectionList): string | undefined { + const index = section.list.getFocus()[0]; + const entry = index !== undefined ? section.entries[index] : undefined; + return entry ? this._entryRowId(entry) : undefined; } - private _setExpanded(toolSetId: string, expanded: boolean): void { - const next = new Set(this._expanded.get()); - if (expanded === next.has(toolSetId)) { - return; - } - if (expanded) { - next.add(toolSetId); - } else { - next.delete(toolSetId); + /** Re-splices every section's rows in place (no DOM teardown) after an `_expanded` state change. */ + private _refreshAllSectionEntries(): void { + for (const section of this._sectionLists) { + this._refreshSectionEntries(section); } - this._expanded.set(next, undefined); } - // --- Tree keyboard navigation --- - - private _isExpanded(setRow: ITreeRow): boolean { - return setRow.group!.style.display !== 'none'; + private _refreshSectionEntries(section: IToolsSectionList): void { + const focusedRowId = this._currentFocusedRowId(section); + const nextEntries = this._computeSectionEntries(section.setVms); + section.entries = nextEntries; + section.list.splice(0, section.list.length, nextEntries as IToolsRowEntry[]); + if (focusedRowId) { + const index = nextEntries.findIndex(e => this._entryRowId(e) === focusedRowId); + if (index !== -1) { + section.list.setFocus([index]); + section.list.domFocus(); + } + } + this._scheduleSectionListLayout(); } - /** Rows the user can currently land on: all set rows plus tool rows inside expanded sets, in tree order. */ - private _visibleRows(): ITreeRow[] { - return this._rows.filter(r => r.kind === 'set' || this._isExpanded(r.parent!)); + /** Restore keyboard focus to a row by its stable id after a full re-render, falling back to the first row. */ + private _restoreFocus(rowId: string): void { + for (const section of this._sectionLists) { + if (section.container.hidden) { + continue; + } + const index = section.entries.findIndex(e => this._entryRowId(e) === rowId); + if (index !== -1) { + section.list.setFocus([index]); + section.list.reveal(index); + section.list.domFocus(); + return; + } + } + this._focusFirstOverall(); } - /** Keep a single roving `tabIndex=0` on the given row so the tree is one tab stop. */ - private _setRovingRow(row: ITreeRow): void { - for (const r of this._rows) { - r.element.tabIndex = r === row ? 0 : -1; + private _layoutSectionLists(): void { + const heights = layoutVirtualizedSections(this._treeContainer, this._sectionLists.map(section => ({ + container: section.container, + contentHeight: section.entries.reduce((sum, entry) => sum + computeToolsRowHeight(entry), 0), + minimumHeight: section.entries.length > 0 ? computeToolsRowHeight(section.entries[0]) : 0, + }))); + for (let index = 0; index < this._sectionLists.length; index++) { + this._layoutSection(this._sectionLists[index], heights[index]); } - this._activeRowId = row.rowId; } - private _focusRow(row: ITreeRow): void { - this._setRovingRow(row); - row.element.focus(); + private _scheduleSectionListLayout(): void { + this._pendingSectionLayout.value = DOM.scheduleAtNextAnimationFrame(DOM.getWindow(this.element), () => { + this._layoutSectionLists(); + this._treeScrollable.scanDomNode(); + }); } - /** Resolve the row owning a focus/keyboard target by walking up to a known row element. */ - private _rowFromTarget(target: HTMLElement | null): ITreeRow | undefined { - for (let el = target; el && el !== this._treeContainer; el = el.parentElement) { - const row = this._rowByElement.get(el); - if (row) { - return row; - } - } - return undefined; + private _layoutSection(section: IToolsSectionList, allocatedHeight?: number): void { + const contentHeight = section.entries.reduce((sum, e) => sum + computeToolsRowHeight(e), 0); + const height = allocatedHeight ?? Math.min(contentHeight, TOOLS_SECTION_MAX_HEIGHT); + layoutVirtualizedSectionList(section.list, section.container, height, section.container.clientWidth || this._lastWidth || undefined); } - /** After a (re)render, restore the roving tabIndex to the previously active row, else the first row. */ - private _initRovingTabIndex(refocus = false): void { - let active = this._activeRowId ? this._rows.find(r => r.rowId === this._activeRowId) : undefined; - if (!active || (active.kind === 'tool' && !this._isExpanded(active.parent!))) { - active = this._visibleRows()[0]; - } - for (const r of this._rows) { - r.element.tabIndex = r === active ? 0 : -1; - } - this._activeRowId = active?.rowId; - if (refocus && active) { - active.element.focus(); - } - } + // --- Tree keyboard navigation (supplemental to WorkbenchList's own Up/Down/Enter/PageUp/PageDown/Escape) --- - private _onTreeKeyDown(e: IKeyboardEvent): void { - const row = this._rowFromTarget(e.target); - if (!row || !isToolsTreeKeyboardTarget(e.target, row.element)) { + private _onSectionKeyDown(section: IToolsSectionList, e: IKeyboardEvent, getFocusBeforeKeyDown: () => number | undefined): void { + const entries = section.entries; + if (entries.length === 0) { return; } + const focusIndex = section.list.getFocus()[0]; + const entry = entries[focusIndex ?? 0]; let handled = true; switch (e.keyCode) { - case KeyCode.DownArrow: - this._focusRelative(row, 1); + case KeyCode.DownArrow: { + const before = getFocusBeforeKeyDown(); + handled = before !== undefined && before === entries.length - 1; + if (handled) { + this._focusAdjacentSection(section, 1); + } break; - case KeyCode.UpArrow: - this._focusRelative(row, -1); + } + case KeyCode.UpArrow: { + const before = getFocusBeforeKeyDown(); + handled = before !== undefined && before === 0; + if (handled) { + this._focusAdjacentSection(section, -1); + } break; + } case KeyCode.RightArrow: - handled = this._onExpandKey(row); + handled = this._onExpandKey(section, entry); break; case KeyCode.LeftArrow: - handled = this._onCollapseKey(row); + handled = this._onCollapseKey(section, entry); break; case KeyCode.Home: - this._focusEdge(true); + this._focusFirstOverall(); break; case KeyCode.End: - this._focusEdge(false); + this._focusLastOverall(); break; case KeyCode.Space: - if (row.readOnly) { - break; - } - row.toggleNode.click(); + this._onActivateKey(entry, false); break; case KeyCode.Enter: - if (row.kind === 'set' && row.readOnly) { - this._toggleCollapsed(row.toolSetId); - } else if (!row.readOnly) { - row.toggleNode.click(); - } + this._onActivateKey(entry, true); break; default: handled = false; @@ -958,46 +1135,149 @@ export class ToolsListWidget extends Disposable { } } - private _focusRelative(row: ITreeRow, delta: number): void { - const rows = this._visibleRows(); - const index = rows.indexOf(row); - const next = index === -1 ? undefined : rows[index + delta]; - if (next) { - this._focusRow(next); + /** + * Space always toggles enablement (no-op for read-only rows). Enter toggles enablement too, except + * on a read-only *set* row, where it expands/collapses instead (a read-only tool row does nothing). + * This mirrors the original mouse-vs-keyboard asymmetry, where clicking the row body (not its + * checkbox) toggles expand/collapse but Space/Enter on a focused row toggle its checkbox. + */ + private _onActivateKey(entry: IToolsRowEntry, viaEnter: boolean): void { + const readOnly = entry.kind === 'set' ? entry.vm.readOnly : entry.setVm.readOnly; + if (readOnly) { + if (viaEnter && entry.kind === 'set') { + this._toggleCollapsed(entry.vm.toolSet.id); + } + return; + } + if (entry.kind === 'set') { + const vm = entry.vm; + const current = getToolSetTriState(this._currentState(), vm.toolSet.id, vm.allToolIds); + this._enablementService.setToolSetEnabled(this._sessionType, vm.toolSet.id, vm.allToolIds, current !== true); + } else { + const { setVm, toolVm } = entry; + const current = isToolEnabledInSet(this._currentState(), setVm.toolSet.id, toolVm.tool.id); + this._enablementService.setToolEnabled(this._sessionType, setVm.toolSet.id, toolVm.tool.id, !current); } } - private _focusEdge(first: boolean): void { - const rows = this._visibleRows(); - this._focusRow(first ? rows[0] : rows[rows.length - 1]); - } - - /** Right arrow: expand a collapsed set, or move into its first child when already expanded. */ - private _onExpandKey(row: ITreeRow): boolean { - if (row.kind !== 'set') { + /** Right arrow: expand a collapsed set, or move into its first tool row when already expanded. */ + private _onExpandKey(section: IToolsSectionList, entry: IToolsRowEntry): boolean { + if (entry.kind !== 'set') { return false; } - if (!this._isExpanded(row)) { - this._setExpanded(row.toolSetId, true); - } else if (row.children!.length) { - this._focusRow(row.children![0]); + const vm = entry.vm; + if (!this._isRowExpanded(vm)) { + this._setExpanded(vm.toolSet.id, true); + } else if (vm.visibleTools.length) { + this._focusEntryInSection(section, `tool:${vm.toolSet.id}:${vm.visibleTools[0].tool.id}`); } return true; } /** Left arrow: collapse an expanded set, or move a tool row up to its parent set. */ - private _onCollapseKey(row: ITreeRow): boolean { - if (row.kind === 'set') { - if (this._isExpanded(row)) { - this._setExpanded(row.toolSetId, false); + private _onCollapseKey(section: IToolsSectionList, entry: IToolsRowEntry): boolean { + if (entry.kind === 'set') { + if (this._isRowExpanded(entry.vm)) { + this._setExpanded(entry.vm.toolSet.id, false); return true; } return false; } - this._focusRow(row.parent!); + this._focusEntryInSection(section, `set:${entry.setVm.toolSet.id}`); return true; } + private _focusEntryInSection(section: IToolsSectionList, rowId: string): void { + const index = section.entries.findIndex(e => this._entryRowId(e) === rowId); + if (index === -1) { + return; + } + section.list.setFocus([index]); + section.list.reveal(index); + section.list.domFocus(); + } + + /** Crosses into the adjacent section's first/last row when Up/Down hits the current section's edge. */ + private _focusAdjacentSection(from: IToolsSectionList, delta: 1 | -1): void { + let targetIndex = this._sectionLists.indexOf(from) + delta; + while (this._sectionLists[targetIndex]?.container.hidden) { + targetIndex += delta; + } + const target = this._sectionLists[targetIndex]; + if (!target) { + return; + } + if (target.entries.length === 0) { + this._focusAdjacentSection(target, delta); + return; + } + const index = delta === 1 ? 0 : target.entries.length - 1; + target.list.setFocus([index]); + target.list.reveal(index); + target.list.domFocus(); + } + + private _focusFirstOverall(): void { + const section = this._sectionLists.find(s => !s.container.hidden && s.entries.length > 0); + if (section) { + section.list.setFocus([0]); + section.list.reveal(0); + section.list.domFocus(); + } + } + + private _focusLastOverall(): void { + for (let i = this._sectionLists.length - 1; i >= 0; i--) { + const section = this._sectionLists[i]; + if (!section.container.hidden && section.entries.length > 0) { + const index = section.entries.length - 1; + section.list.setFocus([index]); + section.list.reveal(index); + section.list.domFocus(); + return; + } + } + } + + /** + * Subtitle for a tool-set row: the set's own `detail`, or for extension sets the extension's + * description (falling back to a generic "contributed by" label). + */ + private _resolveSetDetail(ts: IToolSet): string | undefined { + if (ts.detail) { + return ts.detail; + } + if (ts.source.type !== 'extension') { + return undefined; + } + const source = ts.source; + const extension = this._extensionsWorkbenchService.local.find(e => ExtensionIdentifier.equals(e.identifier.id, source.extensionId)); + return extension?.description || localize('toolsSetExtensionDetail', "Tools contributed by {0}", source.label); + } + + private _toggleCollapsed(toolSetId: string): void { + const next = new Set(this._expanded.get()); + if (next.has(toolSetId)) { + next.delete(toolSetId); + } else { + next.add(toolSetId); + } + this._expanded.set(next, undefined); + } + + private _setExpanded(toolSetId: string, expanded: boolean): void { + const next = new Set(this._expanded.get()); + if (expanded === next.has(toolSetId)) { + return; + } + if (expanded) { + next.add(toolSetId); + } else { + next.delete(toolSetId); + } + this._expanded.set(next, undefined); + } + private _currentState(): IToolEnablementState { return this._enablementService.getState(this._sessionType); } diff --git a/src/vs/workbench/contrib/chat/browser/attachments/implicitContextAttachment.ts b/src/vs/workbench/contrib/chat/browser/attachments/implicitContextAttachment.ts index a5fecc91b8431..f6642d3f8b72a 100644 --- a/src/vs/workbench/contrib/chat/browser/attachments/implicitContextAttachment.ts +++ b/src/vs/workbench/contrib/chat/browser/attachments/implicitContextAttachment.ts @@ -13,9 +13,10 @@ import { ThemeIcon } from '../../../../../base/common/themables.js'; import { KeyCode } from '../../../../../base/common/keyCodes.js'; import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js'; import { Schemas } from '../../../../../base/common/network.js'; -import { basename, dirname } from '../../../../../base/common/resources.js'; +import { basename, dirname, isEqual } from '../../../../../base/common/resources.js'; import { URI } from '../../../../../base/common/uri.js'; import { isLocation, Location } from '../../../../../editor/common/languages.js'; +import { IRange, Range } from '../../../../../editor/common/core/range.js'; import { getIconClasses } from '../../../../../editor/common/services/getIconClasses.js'; import { ILanguageService } from '../../../../../editor/common/languages/language.js'; import { IModelService } from '../../../../../editor/common/services/model.js'; @@ -30,17 +31,40 @@ import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; import { ILabelService } from '../../../../../platform/label/common/label.js'; import { IResourceLabel, ResourceLabels } from '../../../../browser/labels.js'; import { ResourceContextKey } from '../../../../common/contextkeys.js'; -import { ChatContextIconPath, IChatRequestStringVariableEntry, isStringImplicitContextValue, resolveChatContextIcon } from '../../common/attachments/chatVariableEntries.js'; +import { ChatContextIconPath, IChatRequestStringVariableEntry, IChatRequestVariableEntry, isStringImplicitContextValue, isStringVariableEntry, resolveChatContextIcon } from '../../common/attachments/chatVariableEntries.js'; import { IThemeService } from '../../../../../platform/theme/common/themeService.js'; import { isDark } from '../../../../../platform/theme/common/theme.js'; import { IChatWidget } from '../chat.js'; import { ChatAttachmentModel } from './chatAttachmentModel.js'; import { IChatContextService } from '../contextContrib/chatContextService.js'; import { ChatImplicitContext, ChatImplicitContexts } from './chatImplicitContext.js'; -import { IRange } from '../../../../../editor/common/core/range.js'; import { IBrowserViewWorkbenchService } from '../../../browserView/common/browserView.js'; import { BrowserViewUri } from '../../../../../platform/browserView/common/browserViewUri.js'; +export function isImplicitContextAlreadyAttached(attachments: readonly IChatRequestVariableEntry[], targetUri: URI | undefined, targetRange: IRange | undefined, targetHandle: number | undefined): boolean { + return attachments.some(attachment => { + if (targetHandle !== undefined) { + return isStringVariableEntry(attachment) + && (attachment.handle === targetHandle || (targetUri !== undefined && isEqual(targetUri, attachment.uri))); + } + if (isStringVariableEntry(attachment)) { + return false; + } + const attachmentUri = URI.isUri(attachment.value) + ? attachment.value + : isLocation(attachment.value) + ? attachment.value.uri + : undefined; + const attachmentRange = isLocation(attachment.value) ? attachment.value.range : undefined; + if (targetUri && attachmentUri && isEqual(targetUri, attachmentUri)) { + return targetRange && attachmentRange + ? Range.equalsRange(targetRange, attachmentRange) + : !targetRange && !attachmentRange; + } + return false; + }); +} + export class ImplicitContextAttachmentWidget extends Disposable { private readonly renderDisposables = this._register(new DisposableStore()); diff --git a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts index 03d38ce9451e5..85ef33db1f842 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts @@ -37,7 +37,7 @@ import { DEFAULT_LOCAL_TRANSCRIPTION_MODEL } from '../../../../platform/localTra import { McpAccessValue, McpAutoStartValue, mcpAccessConfig, mcpAllowedServersConfig, mcpAppsEnabledConfig, mcpAutoStartConfig, mcpDeniedServersConfig, mcpGalleryServiceEnablementConfig, mcpGalleryServiceUrlConfig } from '../../../../platform/mcp/common/mcpManagement.js'; import { AgentNetworkFilterService, IAgentNetworkFilterService } from '../../../../platform/networkFilter/common/networkFilterService.js'; import { AgentNetworkDomainSettingId } from '../../../../platform/networkFilter/common/settings.js'; -import { COPILOT_ALLOWED_MCP_SERVERS_KEY, COPILOT_ALLOW_MANAGED_HOOKS_ONLY_CONFIG, COPILOT_ALLOW_MANAGED_HOOKS_ONLY_KEY, COPILOT_ALLOW_MANAGED_MCP_SERVERS_ONLY_CONFIG, COPILOT_ALLOW_MANAGED_MCP_SERVERS_ONLY_KEY, COPILOT_DENIED_MCP_SERVERS_KEY, COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY, COPILOT_ENABLED_PLUGINS_KEY, COPILOT_EXTRA_MARKETPLACES_KEY, COPILOT_MODEL_KEY, COPILOT_STRICT_MARKETPLACES_KEY, COPILOT_STRICT_PLUGIN_ONLY_CUSTOMIZATION_CONFIG, COPILOT_STRICT_PLUGIN_ONLY_CUSTOMIZATION_KEY, COPILOT_TOP_LEVEL_MODEL_KEY, managedModelValue, managedSettingValue } from '../../../../platform/policy/common/copilotManagedSettings.js'; +import { COPILOT_ALLOWED_MCP_SERVERS_KEY, COPILOT_ALLOW_MANAGED_HOOKS_ONLY_CONFIG, COPILOT_ALLOW_MANAGED_HOOKS_ONLY_KEY, COPILOT_ALLOW_MANAGED_MCP_SERVERS_ONLY_CONFIG, COPILOT_ALLOW_MANAGED_MCP_SERVERS_ONLY_KEY, COPILOT_DENIED_MCP_SERVERS_KEY, COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY, COPILOT_ENABLED_PLUGINS_KEY, COPILOT_EXTRA_MARKETPLACES_KEY, COPILOT_MODEL_KEY, COPILOT_STRICT_MARKETPLACES_KEY, COPILOT_STRICT_PLUGIN_ONLY_CUSTOMIZATION_CONFIG, COPILOT_STRICT_PLUGIN_ONLY_CUSTOMIZATION_KEY, COPILOT_TOP_LEVEL_MODEL_KEY, managedModelValue, managedSettingsDisabledValue, managedSettingValue } from '../../../../platform/policy/common/copilotManagedSettings.js'; import product from '../../../../platform/product/common/product.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; import { AgentSandboxEnabledValue, AgentSandboxSettingId } from '../../../../platform/sandbox/common/settings.js'; @@ -274,6 +274,12 @@ configurationRegistry.registerConfiguration({ tags: ['experimental'], agentsWindow: { default: true }, }, + 'chat.experimentalModelPicker': { + type: 'boolean', + description: nls.localize('chat.experimentalModelPicker', "When enabled, the model picker uses a tab per model provider and configures thinking effort and context from a detail card next to each model, instead of a separate configuration button."), + default: false, + tags: ['experimental'], + }, 'chat.fontSize': { type: 'number', description: nls.localize('chat.fontSize', "Controls the font size in pixels in chat messages."), @@ -374,7 +380,7 @@ configurationRegistry.registerConfiguration({ }, 'dictation.experimental.llmCleanupModel': { type: 'string', - enum: ['auto', 'copilot-utility-small', 'gpt-5.4-nano', 'gpt-5.6-luna'], + enum: ['auto', 'copilot-utility-small', 'gpt-5.6-luna'], markdownDescription: nls.localize('dictation.experimental.llmCleanupModel', "Controls the language model used for experimental dictation cleanup. `auto` follows the experiment-provided default."), default: 'auto', tags: ['experimental'], @@ -465,8 +471,8 @@ configurationRegistry.registerConfiguration({ type: 'boolean', default: false, scope: ConfigurationScope.APPLICATION, - description: nls.localize('chat.agentSessions.consolidatedRemoteWorkspaces', "Controls whether GitHub and remote workspaces are combined under Remote in the Agents Window workspace picker, with search always available."), - tags: ['preview'], + description: nls.localize('chat.agentSessions.consolidatedRemoteWorkspaces', "Controls whether GitHub and remote workspaces are combined under Remote in the Agents Window workspace picker, with search always available and, when supported, a No workspace option."), + tags: ['experimental'], experiment: { mode: 'auto' }, }, [ChatConfiguration.SaveBeforeSend]: { @@ -1625,6 +1631,18 @@ configurationRegistry.registerConfiguration({ description: nls.localize('chat.agentHost.customTerminalTool.enabled', "When enabled, Copilot SDK sessions use the Agent Host terminal tool override instead of the SDK's default terminal behavior."), default: false, tags: ['experimental', 'advanced'], + policy: { + name: 'ChatAgentHostCustomTerminalTool', + category: PolicyCategory.InteractiveSession, + minimumVersion: '1.137', + value: managedSettingsDisabledValue, + localization: { + description: { + key: 'chat.agentHost.customTerminalTool.enabled.policy', + value: nls.localize('chat.agentHost.customTerminalTool.enabled.policy', "Enable the Agent Host custom terminal tool override for Copilot SDK sessions."), + } + } + }, }, [AgentHostShellToolInitScriptEnabledSettingId]: { type: 'boolean', @@ -2418,11 +2436,25 @@ configurationRegistry.registerConfiguration({ mode: 'auto' } }, + [ChatConfiguration.SubagentsDefaultToAuto]: { + type: 'boolean', + markdownDescription: nls.localize('chat.subagents.defaultToAuto', "Controls whether local subagents use the Auto model when neither the tool call nor the selected agent specifies a model. Explicit tool and agent model selections take precedence. Subagents of a BYOK main model continue to use that model. Auto routing is not constrained by the main model's fixed cost tier."), + default: false, + tags: ['experimental'], + experiment: { + mode: 'auto' + }, + }, [ChatConfiguration.SubagentsUseRichRendering]: { type: 'boolean', description: nls.localize('chat.subagents.useRichRendering', "Controls whether subagents in chat editors use a rich presentation that opens each subagent in its own editor instead of rendering its full activity inline in the parent chat."), default: true, }, + [ChatConfiguration.SubagentsShowCreditUsage]: { + type: 'boolean', + description: nls.localize('chat.subagents.showCreditUsage', "Controls whether AI credit usage is shown next to the duration for subagents."), + default: false, + }, [ChatConfiguration.TerminalAgentHostEnabled]: { type: 'boolean', description: nls.localize('chat.terminal.agentHost.enabled', "Controls whether Terminal Chat is backed by the Agent Host instead of the extension host. Applied on startup."), diff --git a/src/vs/workbench/contrib/chat/browser/speechToText/chatSpeechToTextService.ts b/src/vs/workbench/contrib/chat/browser/speechToText/chatSpeechToTextService.ts index 802f985b171ff..da0201a7e87c7 100644 --- a/src/vs/workbench/contrib/chat/browser/speechToText/chatSpeechToTextService.ts +++ b/src/vs/workbench/contrib/chat/browser/speechToText/chatSpeechToTextService.ts @@ -141,12 +141,10 @@ const LLM_CLEANUP_TIMEOUT_MS = 5000; const LLM_CLEANUP_MODEL_SELECTOR = { vendor: 'copilot', id: 'copilot-utility-small' } as const; const LLM_CLEANUP_MODEL_SETTING = 'dictation.experimental.llmCleanupModel'; -const LLM_CLEANUP_NANO_MODEL_ID = 'gpt-5.4-nano'; -const LLM_CLEANUP_NANO_MODEL_SELECTOR = { vendor: 'copilot', id: 'copilot-dictation-cleanup-nano' } as const; const LLM_CLEANUP_LUNA_MODEL_ID = 'gpt-5.6-luna'; -const LLM_CLEANUP_LUNA_MODEL_SELECTOR = { vendor: 'copilot', id: 'copilot-dictation-cleanup-luna' } as const; +const LLM_CLEANUP_LUNA_MODEL_SELECTOR = { vendor: 'copilot', id: LLM_CLEANUP_LUNA_MODEL_ID } as const; -type DictationCleanupModel = 'none' | 'copilot-utility-small' | 'gpt-5.4-nano' | 'gpt-5.6-luna'; +type DictationCleanupModel = 'none' | 'copilot-utility-small' | 'gpt-5.6-luna'; /** * Which backend transcribes dictation audio: @@ -603,12 +601,11 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo private _getLlmCleanupModel(): Exclude { const configuredModel = this._configurationService.getValue(LLM_CLEANUP_MODEL_SETTING); - if (configuredModel === LLM_CLEANUP_NANO_MODEL_ID || configuredModel === LLM_CLEANUP_LUNA_MODEL_ID || configuredModel === LLM_CLEANUP_MODEL_SELECTOR.id) { + if (configuredModel === LLM_CLEANUP_LUNA_MODEL_ID || configuredModel === LLM_CLEANUP_MODEL_SELECTOR.id) { return configuredModel; } const experimentDefault = this._configurationService.inspect(LLM_CLEANUP_MODEL_SETTING).defaultValue; switch (experimentDefault) { - case LLM_CLEANUP_NANO_MODEL_ID: case LLM_CLEANUP_LUNA_MODEL_ID: return experimentDefault; default: @@ -1377,11 +1374,10 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo }, LLM_CLEANUP_TIMEOUT_MS); try { const cleanupModel = this._getLlmCleanupModel(); - const modelSelector = cleanupModel === LLM_CLEANUP_NANO_MODEL_ID - ? LLM_CLEANUP_NANO_MODEL_SELECTOR - : cleanupModel === LLM_CLEANUP_LUNA_MODEL_ID - ? LLM_CLEANUP_LUNA_MODEL_SELECTOR - : LLM_CLEANUP_MODEL_SELECTOR; + const modelSelector = cleanupModel === LLM_CLEANUP_LUNA_MODEL_ID + ? LLM_CLEANUP_LUNA_MODEL_SELECTOR + : LLM_CLEANUP_MODEL_SELECTOR; + this._logService.info(`[chat-stt] selecting language model cleanup model (vendor=${modelSelector.vendor}, id=${modelSelector.id})`); let models = await raceCancellation( this._languageModelsService.selectLanguageModels(modelSelector), cts.token, @@ -1409,7 +1405,7 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo this._logService.info(`[chat-stt] skipped language model cleanup (reason=noModel, phase=${phase}, elapsedMs=${Date.now() - cleanupStartMs}); using raw transcript`); return undefined; } - this._logService.trace(`[chat-stt] language model cleanup selected model (elapsedMs=${Date.now() - cleanupStartMs}, modelCount=${models.length})`); + this._logService.info(`[chat-stt] selected language model cleanup model (id=${selectedCleanupModel}, elapsedMs=${Date.now() - cleanupStartMs}, modelCount=${models.length})`); phase = 'loadInstructions'; const dictationInstructions = await raceCancellation( @@ -1433,7 +1429,7 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo this._sessionCleanupModel = selectedCleanupModel; phase = 'startRequest'; this._logService.trace(`[chat-stt] language model cleanup sending request (elapsedMs=${Date.now() - cleanupStartMs})`); - const requestOptions = selectedCleanupModel === LLM_CLEANUP_NANO_MODEL_ID || selectedCleanupModel === LLM_CLEANUP_LUNA_MODEL_ID + const requestOptions = selectedCleanupModel === LLM_CLEANUP_LUNA_MODEL_ID ? { configuration: { reasoningEffort: 'none' } } : {}; const response = await raceCancellation( @@ -1495,7 +1491,7 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo this._logService.warn(`[chat-stt] language model cleanup returned refusal-like output (rawChars=${text.length}, cleanedChars=${cleaned.length}); using raw transcript`); return undefined; } - this._logService.info(`[chat-stt] applied language model cleanup (rawChars=${text.length}, cleanedChars=${cleaned.length}, elapsedMs=${Date.now() - cleanupStartMs}, firstTextMs=${firstTextMs ?? -1})`); + this._logService.info(`[chat-stt] applied language model cleanup (model=${selectedCleanupModel}, rawChars=${text.length}, cleanedChars=${cleaned.length}, elapsedMs=${Date.now() - cleanupStartMs}, firstTextMs=${firstTextMs ?? -1})`); return cleaned; } catch (err) { const reason = timedOut ? 'timeout' : cts.token.isCancellationRequested ? 'cancelled' : 'error'; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatPlanReviewPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatPlanReviewPart.ts index 6da7d942b7b26..ef804ad9d3135 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatPlanReviewPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatPlanReviewPart.ts @@ -57,6 +57,7 @@ export class ChatPlanReviewPart extends Disposable implements IChatContentPart { private readonly _buttonStore = this._register(new DisposableStore()); private _submitButton: Button | undefined; + private _approveButton: IButton | undefined; private _renderedSubmitInlineCount = -1; private readonly _messageContentDisposables = this._register(new MutableDisposable()); private readonly _planChangeListeners = this._register(new DisposableStore()); @@ -392,7 +393,7 @@ export class ChatPlanReviewPart extends Disposable implements IChatContentPart { } // Update the cached Submit button rather than re-rendering the // whole button row on every keystroke. - this.updateSubmitButtonState(); + this.updateFeedbackActionButtonState(); })); // Enter submits feedback; Shift+Enter inserts a newline. Only wired @@ -538,9 +539,7 @@ export class ChatPlanReviewPart extends Disposable implements IChatContentPart { } this.renderCommentsList(); - if (this._isFeedbackMode) { - this.updateSubmitButtonState(); - } + this.updateFeedbackActionButtonState(); this._messageScrollable.scanDomNode(); this._onDidChangeHeight.fire(); } @@ -564,11 +563,12 @@ export class ChatPlanReviewPart extends Disposable implements IChatContentPart { const includeReject = options?.includeReject ?? true; this._buttonStore.clear(); this._submitButton = undefined; + this._approveButton = undefined; this._renderedSubmitInlineCount = -1; dom.clearNode(container); - // In feedback mode, show Submit + Reject. Submit's label includes - // the count of pending inline comments. + // In feedback mode, keep approval available while there is no pending + // feedback. Submit's label includes the count of inline comments. if (this._isFeedbackMode) { const inlineCount = this.getInlineFeedbackItems().length; const submitButton = new Button(container, { ...defaultButtonStyles, supportIcons: true }); @@ -578,18 +578,10 @@ export class ChatPlanReviewPart extends Disposable implements IChatContentPart { this._renderedSubmitInlineCount = inlineCount; this._buttonStore.add(submitButton); this._buttonStore.add(submitButton.onDidClick(() => void this.submitFeedback())); - - if (includeReject) { - const rejectButton = new Button(container, { ...defaultButtonStyles, secondary: true }); - rejectButton.label = localize('chat.planReview.reject', 'Reject'); - this._buttonStore.add(rejectButton); - this._buttonStore.add(rejectButton.onDidClick(() => this.submitRejection())); - } - return; } - // Approve button first (blue). Uses ButtonWithDropdown when there are - // extra actions; otherwise a plain Button. + // Uses ButtonWithDropdown when there are extra approval actions; + // otherwise uses a plain button. const primary = this._selectedAction; const moreActions = this.review.actions.filter(a => a !== primary); @@ -598,6 +590,7 @@ export class ChatPlanReviewPart extends Disposable implements IChatContentPart { approveButton = new ButtonWithDropdown(container, { ...defaultButtonStyles, supportIcons: true, + secondary: this._isFeedbackMode, contextMenuProvider: this._contextMenuService, addPrimaryActionToDropdown: false, actions: moreActions.map(action => { @@ -616,18 +609,18 @@ export class ChatPlanReviewPart extends Disposable implements IChatContentPart { }) as (Action | Separator)[], }); } else { - approveButton = new Button(container, { ...defaultButtonStyles, supportIcons: true }); + approveButton = new Button(container, { ...defaultButtonStyles, secondary: this._isFeedbackMode, supportIcons: true }); } this._buttonStore.add(approveButton); + this._approveButton = approveButton; approveButton.label = primary.label; if (primary.description) { approveButton.element.title = primary.description; } + approveButton.enabled = !this.review.planUri || !this.canSubmitFeedback(); this._buttonStore.add(approveButton.onDidClick(() => this.submitApproval(primary))); - // Reject button (grey secondary) immediately after the approve button - // so the primary Approve / Reject pair stays grouped together — - // omitted in the collapsed title bar (parity with + // Reject is omitted in the collapsed title bar (parity with // chatToolConfirmationCarouselPart which only surfaces the primary // action when collapsed). if (includeReject) { @@ -653,16 +646,19 @@ export class ChatPlanReviewPart extends Disposable implements IChatContentPart { } /** - * Update the cached Submit button's enabled state and label without + * Update the cached feedback-mode buttons without * destroying the button row. Cheap enough to run on every keystroke. */ - private updateSubmitButtonState(): void { - if (!this._submitButton || !this._isFeedbackMode) { - return; + private updateFeedbackActionButtonState(): void { + const canSubmitFeedback = this.canSubmitFeedback(); + if (this._submitButton) { + this._submitButton.enabled = canSubmitFeedback; + } + if (this._approveButton) { + this._approveButton.enabled = !this.review.planUri || !canSubmitFeedback; } - this._submitButton.enabled = this.canSubmitFeedback(); const inlineCount = this.getInlineFeedbackItems().length; - if (inlineCount !== this._renderedSubmitInlineCount) { + if (this._submitButton && inlineCount !== this._renderedSubmitInlineCount) { this._submitButton.label = this.computeSubmitLabel(inlineCount); this._renderedSubmitInlineCount = inlineCount; } @@ -984,6 +980,7 @@ export class ChatPlanReviewPart extends Disposable implements IChatContentPart { this.domNode.classList.add('chat-plan-review-used'); this._buttonStore.clear(); this._submitButton = undefined; + this._approveButton = undefined; this._renderedSubmitInlineCount = -1; // Hide the editor contribution even if the plan file is still open. this._planReviewRegistration.clear(); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentOpenChat.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentOpenChat.ts index 75a12af6f9481..a47b2c138b6e6 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentOpenChat.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentOpenChat.ts @@ -21,6 +21,7 @@ import { IAccessibilityService } from '../../../../../../platform/accessibility/ import { IActionViewItemService } from '../../../../../../platform/actions/browser/actionViewItemService.js'; import { Action2, MenuId, MenuItemAction, registerAction2 } from '../../../../../../platform/actions/common/actions.js'; import { parseChatUri } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { IHoverService } from '../../../../../../platform/hover/browser/hover.js'; import { IInstantiationService, ServicesAccessor } from '../../../../../../platform/instantiation/common/instantiation.js'; import { IMarkdownRendererService } from '../../../../../../platform/markdown/browser/markdownRenderer.js'; @@ -30,7 +31,7 @@ import { ACTIVE_GROUP } from '../../../../../services/editor/common/editorServic import { IWorkbenchEnvironmentService } from '../../../../../services/environment/common/environmentService.js'; import { formatElapsedTime } from '../../../common/chatProgressFormatting.js'; import { formatCopilotCreditsLabel } from '../../../common/chatService/chatService.js'; -import { CHAT_OPEN_AGENT_HOST_CHAT_COMMAND_ID, CHAT_SUBAGENT_RESOURCE_QUERY_PARAM } from '../../../common/constants.js'; +import { CHAT_OPEN_AGENT_HOST_CHAT_COMMAND_ID, CHAT_SUBAGENT_RESOURCE_QUERY_PARAM, ChatConfiguration } from '../../../common/constants.js'; import { AUTO_RAW_MODEL_ID, ILanguageModelsService } from '../../../common/languageModels.js'; import { IChatWidgetService } from '../../chat.js'; import { getChatMarkdownRenderOptions } from '../chatContentMarkdownRenderer.js'; @@ -256,6 +257,7 @@ export class OpenSubagentChatActionViewItem extends BaseActionViewItem { @INotificationService notificationService: INotificationService, @ILanguageModelsService private readonly languageModelsService: ILanguageModelsService, @IHoverService private readonly hoverService: IHoverService, + @IConfigurationService private readonly configurationService: IConfigurationService, ) { super(context, openInEditor ? createEditorOpenSubagentAction(action, chatWidgetService, notificationService) : createOpenSubagentAction(action), options); this._sourceAction = action; @@ -268,6 +270,12 @@ export class OpenSubagentChatActionViewItem extends BaseActionViewItem { this._finishToolTransition(); } })); + this._register(this.configurationService.onDidChangeConfiguration(event => { + if (event.affectsConfiguration(ChatConfiguration.SubagentsShowCreditUsage)) { + this._updateCredits(); + this.updateTooltip(); + } + })); } override render(container: HTMLElement): void { @@ -423,14 +431,23 @@ export class OpenSubagentChatActionViewItem extends BaseActionViewItem { private _setCredits(credits: number | undefined): void { // Zero-cost subagents report 0 rather than nothing, so normalize both to hidden. - const show = typeof credits === 'number' && credits > 0; - this._reportedCredits = show ? credits : undefined; + this._reportedCredits = typeof credits === 'number' && credits > 0 ? credits : undefined; + this._updateCredits(); + } + + private _updateCredits(): void { + const credits = this._reportedCredits; + const show = credits !== undefined && this._showCreditUsage; if (this._creditsElement) { this._creditsElement.textContent = show ? formatCopilotCreditsLabel(credits) : ''; this._creditsElement.classList.toggle('hidden', !show); } } + private get _showCreditUsage(): boolean { + return this.configurationService.getValue(ChatConfiguration.SubagentsShowCreditUsage) === true; + } + private _setAgentType(agentType: string | undefined): void { this._reportedAgentType = agentType; if (this._agentTypeElement) { @@ -662,7 +679,7 @@ export class OpenSubagentChatActionViewItem extends BaseActionViewItem { if (this._reportedModelName) { details.push(localize('chat.subagent.modelTooltip', "Model: {0}", this._reportedModelName)); } - if (this._reportedCredits !== undefined) { + if (this._reportedCredits !== undefined && this._showCreditUsage) { details.push(formatCopilotCreditsLabel(this._reportedCredits)); } if (this._displayedToolAccessibleLabel && this._displayedActivityIsTool) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatCodeBlockPill.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatCodeBlockPill.css index 05ffffdfe1a7d..58479754549fa 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatCodeBlockPill.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatCodeBlockPill.css @@ -104,6 +104,9 @@ } .icon::before { + --file-icon-mask-position: center; + --file-icon-mask-size: contain; + display: inline-block; line-height: 100%; overflow: hidden; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatPetWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatPetWidget.ts index f4de2f54a5bea..0cbd09404ade0 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatPetWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatPetWidget.ts @@ -515,8 +515,8 @@ export function getChatPetBaseState(hasActiveRequest: boolean, needsInput: boole return 'idle'; } -export function shouldReserveChatPetSpace(enabled: boolean, activeHost: boolean): boolean { - return enabled && activeHost; +export function shouldReserveChatPetSpace(enabled: boolean, visible: boolean): boolean { + return enabled && visible; } export function isChatPetVisible(enabled: boolean, windowActive = true): boolean { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index ade34c6c292cc..ffe86aad33d89 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -434,8 +434,8 @@ export class ChatWidget extends Disposable implements IChatWidget { private hasActiveRequest: IContextKey; private agentInInput: IContextKey; - private _visible = false; - get visible() { return this._visible; } + private readonly _visible = observableValue(this, false); + get visible() { return this._visible.get(); } private _inputVisible = true; private _readOnly = false; @@ -1132,7 +1132,7 @@ export class ChatWidget extends Disposable implements IChatWidget { const inputContainer = this.inputPart.inputContainerElement; const petHost = this.inputPart.element; const inputHasContent = observableFromEvent(this, this.inputEditor.onDidChangeModelContent, () => this.inputEditor.getValue().length > 0); - const registration = this._register(this.chatPetWidgetService.register(this, { + this._register(this.chatPetWidgetService.register(this, { parent: petHost, dragBounds: inputContainer ?? petHost, movementBounds: petMovementBounds ?? parent, @@ -1142,7 +1142,7 @@ export class ChatWidget extends Disposable implements IChatWidget { getPlatformTop: petCenterX => this.inputPart.getChatPetPlatformTop(petCenterX), onDidChangePlatform: this.inputPart.onDidChangeChatPetHorizontalPlatforms, }, preferredPetHost)); - const petSpaceReserved = derived(this, reader => shouldReserveChatPetSpace(this.chatPetService.enabled.read(reader), registration.active.read(reader))); + const petSpaceReserved = derived(this, reader => shouldReserveChatPetSpace(this.chatPetService.enabled.read(reader), this._visible.read(reader))); this._register(autorun(reader => this.container.classList.toggle('chat-pet-enabled', petSpaceReserved.read(reader)))); } @@ -1423,7 +1423,7 @@ export class ChatWidget extends Disposable implements IChatWidget { } private onDidChangeItems(skipDynamicLayout?: boolean) { - if (this._visible || !this.viewModel) { + if (this._visible.get() || !this.viewModel) { const items = this.viewModel?.getItems() ?? []; if (items.length > 0) { @@ -2065,8 +2065,8 @@ export class ChatWidget extends Disposable implements IChatWidget { } setVisible(visible: boolean): void { - const wasVisible = this._visible; - this._visible = visible; + const wasVisible = this._visible.get(); + this._visible.set(visible, undefined); this.visibleChangeCount++; this.listWidget.setVisible(visible); this.input.setVisible(visible); @@ -2076,7 +2076,7 @@ export class ChatWidget extends Disposable implements IChatWidget { this.visibilityTimeoutDisposable.value = disposableTimeout(() => { // Progressive rendering paused while hidden, so start it up again. // Do it after a timeout because the container is not visible yet (it should be but offsetHeight returns 0 here) - if (this._visible) { + if (this._visible.get()) { this.onDidChangeItems(true); } }, 0); 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 471aebdc9428a..aaac9757e0a8f 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -91,7 +91,7 @@ import { AccessibilityCommandId } from '../../../../accessibility/common/accessi import { getSimpleCodeEditorWidgetOptions, getSimpleEditorOptions, setupSimpleEditorSelectionStyling } from '../../../../codeEditor/browser/simpleEditorOptions.js'; import { IChatViewTitleActionContext } from '../../../common/actions/chatActions.js'; import { ChatContextKeys } from '../../../common/actions/chatContextKeys.js'; -import { ChatRequestVariableSet, getImageAttachmentLimit, IChatRequestVariableEntry, isPastedTextArtifact, isAgentHostCompletionVariableEntry, isBrowserViewVariableEntry, isElementVariableEntry, isExplicitFileOrImageVariableEntry, isImageVariableEntry, isNotebookOutputVariableEntry, isPasteVariableEntry, isPromptFileVariableEntry, isPromptTextVariableEntry, isSCMHistoryItemChangeRangeVariableEntry, isSCMHistoryItemChangeVariableEntry, isSCMHistoryItemVariableEntry, isStringVariableEntry, OmittedState } from '../../../common/attachments/chatVariableEntries.js'; +import { ChatRequestVariableSet, getImageAttachmentLimit, IChatRequestVariableEntry, isPastedTextArtifact, isAgentHostCompletionVariableEntry, isBrowserViewVariableEntry, isElementVariableEntry, isExplicitFileOrImageVariableEntry, isImageVariableEntry, isNotebookOutputVariableEntry, isPasteVariableEntry, isPromptFileVariableEntry, isPromptTextVariableEntry, isSCMHistoryItemChangeRangeVariableEntry, isSCMHistoryItemChangeVariableEntry, isSCMHistoryItemVariableEntry, OmittedState } from '../../../common/attachments/chatVariableEntries.js'; import { ChatMode, getModeNameForTelemetry, IChatMode, IChatModes, IChatModeService } from '../../../common/chatModes.js'; import { IChatFollowup, IChatPlanReview, IChatQuestionCarousel, IChatService, IChatToolInvocation } from '../../../common/chatService/chatService.js'; import { IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, IChatSessionsService, isAgentHostTarget, isIChatSessionFileChange2, localChatSessionType, SessionType } from '../../../common/chatSessionsService.js'; @@ -129,7 +129,7 @@ import { ChatAttachmentModel } from '../../attachments/chatAttachmentModel.js'; import { IChatAttachmentWidgetRegistry } from '../../attachments/chatAttachmentWidgetRegistry.js'; import { DefaultChatAttachmentWidget, ElementChatAttachmentWidget, FileAttachmentWidget, ImageAttachmentWidget, BrowserViewAttachmentWidget, NotebookCellOutputChatAttachmentWidget, PasteAttachmentWidget, PromptFileAttachmentWidget, PromptTextAttachmentWidget, SCMHistoryItemAttachmentWidget, SCMHistoryItemChangeAttachmentWidget, SCMHistoryItemChangeRangeAttachmentWidget, TerminalCommandAttachmentWidget, ToolSetOrToolItemAttachmentWidget } from '../../attachments/chatAttachmentWidgets.js'; import { ChatImplicitContexts } from '../../attachments/chatImplicitContext.js'; -import { ImplicitContextAttachmentWidget } from '../../attachments/implicitContextAttachment.js'; +import { ImplicitContextAttachmentWidget, isImplicitContextAlreadyAttached } from '../../attachments/implicitContextAttachment.js'; import { IChatWidget, IChatWidgetService, IChatWidgetViewModelChangeEvent, ISessionTypePickerDelegate, isIChatResourceViewContext, isIChatViewViewContext, IWorkspacePickerDelegate } from '../../chat.js'; import { ChatEditingShowChangesAction, ViewPreviousEditsAction } from '../../chatEditing/chatEditingActions.js'; import { resizeImage } from '../../chatImageUtils.js'; @@ -4101,20 +4101,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge : this._implicitContext?.values.some(v => v.enabled || v.isSelection) ?? false; if (this._implicitContext && hasVisibleImplicitContext) { const isAttachmentAlreadyAttached = (targetUri: URI | undefined, targetRange: IRange | undefined, targetHandle: number | undefined): boolean => { - return this._attachmentModel.attachments.some(a => { - const aUri = URI.isUri(a.value) ? a.value : isLocation(a.value) ? a.value.uri : undefined; - const aRange = isLocation(a.value) ? a.value.range : undefined; - if (targetHandle !== undefined && isStringVariableEntry(a) && a.handle === targetHandle) { - return true; - } - if (targetUri && aUri && isEqual(targetUri, aUri)) { - if (targetRange && aRange) { - return Range.equalsRange(targetRange, aRange); - } - return !targetRange && !aRange; - } - return false; - }); + return isImplicitContextAlreadyAttached(this._attachmentModel.attachments, targetUri, targetRange, targetHandle); }; const implicitContextWidget = this.instantiationService.createInstance( ImplicitContextAttachmentWidget, diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPickerActionItem.ts index cbbf97b58e92c..c83d6322a3ce8 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPickerActionItem.ts @@ -108,4 +108,25 @@ export abstract class ChatInputPickerActionViewItem extends ActionWidgetDropdown this.renderLabel(this.element); } } + + override setFocusable(_focusable: boolean): void { + // Chat input pickers are distinct Tab stops, not only roving toolbar items. + this._updateTabIndex(); + } + + override blur(): void { + super.blur(); + this._updateTabIndex(); + } + + protected override updateEnabled(): void { + super.updateEnabled(); + this._updateTabIndex(); + } + + private _updateTabIndex(): void { + if (this.element) { + this.element.tabIndex = this.isEnabled() ? 0 : -1; + } + } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatModelConfigurationStore.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelConfigurationStore.ts index a3415200fb1f6..0e212fa7d26fb 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatModelConfigurationStore.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelConfigurationStore.ts @@ -11,7 +11,7 @@ import { equals } from '../../../../../../base/common/objects.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js'; import { createModelConfigurationActions, ILanguageModelsService } from '../../../common/languageModels.js'; import { computeStoredConfiguration, extractSchemaDefaults, filterConfigurationToSchema, resolveModelConfiguration } from './chatModelConfigurationLogic.js'; -import { IModelConfigurationAccess } from './modelPicker/modelPickerActionItem.js'; +import { IModelConfigurationAccess } from './modelPicker/modelPickerModelConfig.js'; /** * Per-editor store for model configuration (e.g. context size, thinking effort). diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/media/modelPicker.css b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/media/modelPicker.css index ff9dc8e50ef6a..0f92c8c2a1174 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/media/modelPicker.css +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/media/modelPicker.css @@ -60,8 +60,10 @@ color: var(--vscode-foreground); } +/* Sized by its content: growing would pad the chip with empty space beside a short + * model name. It still shrinks, which is what ellipsises a long one. */ .chat-input-picker-item .action-label.model-picker-split .model-picker-name { - flex: 1 1 auto; + flex: 0 1 auto; min-width: 0; overflow: hidden; } @@ -71,7 +73,7 @@ } .chat-input-picker-item .action-label.model-picker-split .model-picker-name .chat-input-picker-label { - flex: 1 1 auto; + flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; @@ -105,6 +107,13 @@ justify-content: center; } +.chat-input-picker-item .action-label.model-picker-split .model-picker-config-summary { + flex: 0 0 auto; + margin-left: var(--vscode-spacing-size60); + color: var(--vscode-descriptionForeground); + white-space: nowrap; +} + .chat-input-picker-item .action-label.model-picker-split .model-picker-config { flex-shrink: 0; } @@ -403,3 +412,669 @@ .monaco-workbench .chat-model-hover-configurable-buttons > .monaco-button .codicon[class*='codicon-'] { font-size: var(--vscode-codiconFontSize-compact); } + +.action-widget.chat-model-picker-widget { + padding: var(--vscode-spacing-size80); +} + +.action-widget .tabbed-action-list-tabbar.chat-model-picker-tabbar { + align-items: center; + gap: var(--vscode-spacing-size20); + padding: var(--vscode-spacing-sizeNone) var(--vscode-spacing-sizeNone) var(--vscode-spacing-size60); + border-bottom: var(--vscode-strokeThickness) solid var(--vscode-menu-separatorBackground, var(--vscode-editorWidget-border)); +} + +.action-widget .chat-model-picker-tabbar .tabbed-action-list-tabstrip { + display: flex; + flex: 0 1 auto; + align-items: center; + min-width: 0; + gap: var(--vscode-spacing-size20); +} + +.action-widget.search-mode .chat-model-picker-tabbar .tabbed-action-list-tabstrip { + display: none; +} + +.action-widget .chat-model-picker-tabbar .tabbed-action-list-filter-slot { + display: contents; +} + +.action-widget.search-mode .chat-model-picker-tabbar .action-list-filter { + display: flex; + flex: 1; + align-items: center; + min-width: 0; + height: var(--vscode-spacing-size280); + padding: var(--vscode-spacing-sizeNone); + border: none; +} + +.action-widget.search-mode .chat-model-picker-tabbar .action-list-filter-row { + flex: 1; + align-self: center; + min-width: 0; + padding: var(--vscode-spacing-sizeNone) var(--vscode-spacing-size60); +} + +.action-widget.search-mode .chat-model-picker-tabbar .action-list-filter-input, +.action-widget.search-mode .chat-model-picker-tabbar .action-list-filter-input:focus { + height: var(--vscode-spacing-size240); + padding: var(--vscode-spacing-sizeNone); + border-color: transparent; + border-radius: var(--vscode-cornerRadius-small); + background: transparent; + box-shadow: none; +} + +/* The field replaces the tab strip and has no box of its own, so the global input focus + * chrome is dropped. Focus still has to be visible, so it is drawn as a ring instead. */ +.action-widget.search-mode .chat-model-picker-tabbar .action-list-filter-row input.action-list-filter-input:focus, +.action-widget.search-mode .chat-model-picker-tabbar .action-list-filter-row input.action-list-filter-input:focus-visible { + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: var(--vscode-spacing-size20); +} + +.action-widget .chat-model-picker-tabbar .monaco-custom-radio { + gap: var(--vscode-spacing-size20); + width: auto; +} + +.action-widget .chat-model-picker-tabbar .monaco-custom-radio > .monaco-button, +.action-widget .chat-model-picker-tabbar .tabbed-action-list-tabbar-action { + position: relative; + box-sizing: border-box; + flex: 0 0 auto; + width: var(--vscode-spacing-size280); + height: var(--vscode-spacing-size280); + min-width: var(--vscode-spacing-size280); + padding: var(--vscode-spacing-sizeNone); + border: 0; + border-radius: var(--vscode-cornerRadius-small); + color: var(--vscode-descriptionForeground); + background: transparent; +} + +.action-widget .chat-model-picker-tabbar .monaco-custom-radio > .monaco-button > .codicon { + flex: 0 0 auto; + margin: var(--vscode-spacing-sizeNone); +} + +/* Let the active tab label yield space first, and clip the label rather than its underline. */ +.action-widget .chat-model-picker-tabbar .monaco-custom-radio > .monaco-button.active { + flex: 0 1 auto; + width: auto; + gap: var(--vscode-spacing-size40); + padding: var(--vscode-spacing-sizeNone) var(--vscode-spacing-size60); +} + +.action-widget .chat-model-picker-tabbar .monaco-custom-radio > .monaco-button.active > span:not(.codicon) { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.action-widget .chat-model-picker-tabbar .monaco-custom-radio > .monaco-button:hover:not(.active), +.action-widget .chat-model-picker-tabbar .tabbed-action-list-tabbar-action:hover { + color: var(--vscode-foreground); + background: var(--vscode-toolbar-hoverBackground); +} + +.action-widget .chat-model-picker-tabbar .monaco-custom-radio > .monaco-button.active, +.action-widget .chat-model-picker-tabbar .monaco-custom-radio > .monaco-button.active:hover, +.action-widget.search-mode .chat-model-picker-tabbar .tabbed-action-list-tabbar-action.checked { + color: var(--vscode-foreground); + background: transparent; +} + +.action-widget .chat-model-picker-tabbar .monaco-custom-radio > .monaco-button.active::after, +.action-widget.search-mode .chat-model-picker-tabbar .tabbed-action-list-tabbar-action.checked::after { + content: ""; + position: absolute; + right: var(--vscode-spacing-size40); + bottom: calc(-1 * var(--vscode-spacing-size60) - var(--vscode-strokeThickness)); + left: var(--vscode-spacing-size40); + height: var(--vscode-spacing-size20); + border-radius: var(--vscode-cornerRadius-circle); + background: var(--vscode-textLink-foreground); +} + +.action-widget .chat-model-picker-tabbar .monaco-custom-radio > .monaco-button:focus-visible, +.action-widget .chat-model-picker-tabbar .tabbed-action-list-tabbar-action:focus-visible { + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: calc(-1 * var(--vscode-strokeThickness)); +} + +.action-widget .chat-model-picker-tabbar .monaco-custom-radio > .monaco-button .codicon, +.action-widget .chat-model-picker-tabbar .tabbed-action-list-tabbar-action .codicon { + font-size: var(--vscode-codiconFontSize); +} + +.action-widget.auto-enabled .chat-model-picker-tabbar .monaco-custom-radio > .monaco-button, +.action-widget.auto-enabled .chat-model-picker-tabbed .monaco-list-row.action, +.action-widget.auto-enabled .chat-model-picker-tabbed .monaco-list-row.separator { + opacity: 0.6; +} + +.action-widget.auto-enabled .chat-model-picker-tabbed .monaco-list-row.action:hover, +.action-widget.auto-enabled .chat-model-picker-tabbed .monaco-list-row.action.focused, +.action-widget.auto-enabled .chat-model-picker-tabbar .monaco-custom-radio > .monaco-button:hover, +.action-widget.auto-enabled .chat-model-picker-tabbar .monaco-custom-radio > .monaco-button.active { + opacity: 1; +} + +.action-widget .chat-model-picker-tabbed, +.action-widget .chat-model-picker-welcome { + padding: var(--vscode-spacing-size60) var(--vscode-spacing-sizeNone) var(--vscode-spacing-sizeNone); + background: transparent; +} + +.action-widget .chat-model-picker-tabbed .monaco-list-row { + box-sizing: border-box; + width: calc(100% - var(--vscode-spacing-size80)); + margin-left: var(--vscode-spacing-size40); + padding: var(--vscode-spacing-sizeNone) var(--vscode-spacing-size60) var(--vscode-spacing-sizeNone) var(--vscode-spacing-sizeNone); + border-radius: var(--vscode-cornerRadius-small); +} + +.action-widget .chat-model-picker-tabbed .monaco-list-row.action { + gap: 0; +} + +.action-widget .chat-model-picker-tabbed .monaco-list-row.action > .codicon:first-child { + display: flex; + flex: 0 0 var(--vscode-spacing-size200); + align-items: center; + justify-content: center; + width: var(--vscode-spacing-size200); + height: 100%; + font-size: var(--vscode-codiconFontSize-compact); + line-height: var(--vscode-codiconFontSize-compact); +} + +/* Override the shared separator rule so section headings have no rule line. */ +.action-widget .chat-model-picker-tabbed .monaco-scrollable-element .monaco-list-rows .monaco-list-row.separator, +.action-widget .chat-model-picker-tabbed .monaco-list-row.group-header { + box-sizing: border-box; + width: calc(100% - var(--vscode-spacing-size80)); + margin: var(--vscode-spacing-sizeNone) var(--vscode-spacing-sizeNone) var(--vscode-spacing-sizeNone) var(--vscode-spacing-size40); + padding: var(--vscode-spacing-size60) var(--vscode-spacing-size60) var(--vscode-spacing-sizeNone) var(--vscode-spacing-sizeNone); + border: none; + border-radius: var(--vscode-cornerRadius-small); + color: var(--vscode-descriptionForeground); + font-size: var(--vscode-fontSize-label2); + font-weight: var(--vscode-fontWeight-semiBold); + background: transparent; +} + +.action-widget .chat-model-picker-tabbed .monaco-list-row.group-header { + padding-left: var(--vscode-spacing-sizeNone); + padding-top: var(--vscode-spacing-size40); + color: var(--vscode-foreground); +} + +.action-widget .chat-model-picker-tabbed .monaco-list-row.separator > span:not(:empty) { + padding-left: var(--vscode-spacing-sizeNone); +} + +.action-widget .chat-model-picker-tabbed .monaco-scrollable-element .monaco-list-rows .monaco-list-row.separator:not(.has-label) { + display: flex; + align-items: center; + padding-top: var(--vscode-spacing-sizeNone); +} + +.action-widget .chat-model-picker-tabbed .monaco-scrollable-element .monaco-list-rows .monaco-list-row.separator:not(.has-label)::after { + content: ''; + flex: 1 1 auto; + height: var(--vscode-strokeThickness); + background: var(--vscode-menu-separatorBackground, var(--vscode-editorWidget-border)); +} + +.action-widget .chat-model-picker-tabbed .monaco-list-row.chat-model-picker-section-toggle { + color: var(--vscode-descriptionForeground); + font-size: var(--vscode-fontSize-label2); + font-weight: var(--vscode-fontWeight-semiBold); +} + +.action-widget .chat-model-picker-tabbed .monaco-list-row.chat-model-picker-section-toggle > .codicon:first-child { + font-size: var(--vscode-codiconFontSize-compact); +} + +.action-widget .chat-model-picker-tabbed .monaco-list-row.action.chat-model-picker-section-toggle .title { + flex: 0 1 auto; +} + +.action-widget .chat-model-picker-tabbed .monaco-list-row.action.chat-model-picker-section-toggle .action-item-badge { + margin-left: var(--vscode-spacing-size60); + margin-right: auto; + padding: var(--vscode-spacing-sizeNone) var(--vscode-spacing-size60); + background: color-mix(in srgb, var(--vscode-foreground) 10%, transparent); +} + +.action-widget .chat-model-picker-tabbed .monaco-list-row.action.checked { + color: var(--vscode-foreground); + background: color-mix(in srgb, var(--vscode-button-background) 18%, var(--vscode-menu-background)); +} + +.action-widget .chat-model-picker-tabbed .monaco-list-row.action.checked .title { + font-weight: var(--vscode-fontWeight-semiBold); +} + +.action-widget .chat-model-picker-tabbed .monaco-list-row.action.checked > .codicon:first-child { + color: var(--vscode-button-background); +} + +.action-widget .chat-model-picker-tabbed .monaco-list-row .action-list-item-toolbar .actions-container { + gap: var(--vscode-spacing-size20); +} + +.action-widget .chat-model-picker-tabbed .monaco-list-row .action-list-item-toolbar .action-item .action-label { + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + width: 20px; + height: 20px; + padding: var(--vscode-spacing-sizeNone); + border-radius: var(--vscode-cornerRadius-small); + color: var(--vscode-descriptionForeground); + background: transparent; +} + +.action-widget .chat-model-picker-tabbed .monaco-list-row .action-list-item-toolbar .action-item .action-label.codicon { + font-size: var(--vscode-codiconFontSize-compact); + line-height: var(--vscode-codiconFontSize-compact); +} + +.action-widget .chat-model-picker-tabbed .monaco-list-row .action-list-item-toolbar .action-item .action-label:hover { + color: var(--vscode-foreground); + background: var(--vscode-toolbar-hoverBackground); +} + +.action-widget .chat-model-picker-tabbed .monaco-list-row.action .action-list-submenu-indicator.has-submenu { + visibility: hidden; + color: var(--vscode-descriptionForeground); + font-size: var(--vscode-codiconFontSize-compact); +} + +.action-widget .chat-model-picker-tabbed .monaco-list-row.action:hover .action-list-submenu-indicator.has-submenu, +.action-widget .chat-model-picker-tabbed .monaco-list-row.action.focused .action-list-submenu-indicator.has-submenu { + visibility: visible; +} + +.action-widget .chat-model-picker-tabbed .monaco-list-row.action .action-item-badge { + flex: 0 0 auto; + max-width: 96px; + margin-left: auto; + padding: var(--vscode-spacing-sizeNone); + overflow: hidden; + border-radius: var(--vscode-cornerRadius-circle); + color: var(--vscode-descriptionForeground); + background: transparent; + font-size: var(--vscode-fontSize-label2); + line-height: var(--vscode-spacing-size160); + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Align the smaller badge text by baseline; keep icons and chevrons centered. */ +.action-widget .chat-model-picker-tabbed .monaco-list-row.action > .title, +.action-widget .chat-model-picker-tabbed .monaco-list-row.action > .action-item-badge { + align-self: baseline; +} + +.action-widget .chat-model-picker-tabbed .monaco-list-row.chat-model-picker-unavailable .description { + flex: 0 0 auto; + margin-left: auto; + padding: var(--vscode-spacing-sizeNone) var(--vscode-spacing-size60); + border-radius: var(--vscode-cornerRadius-circle); + color: var(--vscode-descriptionForeground); + background: color-mix(in srgb, var(--vscode-foreground) 10%, transparent); + font-size: var(--vscode-fontSize-label2); + line-height: var(--vscode-spacing-size160); + white-space: nowrap; +} + +.action-widget .chat-model-picker-tabbed .monaco-list-row.chat-model-picker-unavailable.has-link .description { + padding: var(--vscode-spacing-sizeNone); + background: transparent; +} + +.action-widget .chat-model-picker-tabbed .monaco-list-row.chat-model-picker-unavailable .description a, +.action-widget .chat-model-picker-tabbed .monaco-list-row.chat-model-picker-unavailable .description a:visited { + display: inline-block; + padding: var(--vscode-spacing-sizeNone) var(--vscode-spacing-size60); + border-radius: var(--vscode-cornerRadius-circle); + color: var(--vscode-textLink-foreground); + background: color-mix(in srgb, var(--vscode-textLink-foreground) 14%, transparent); + text-decoration: none; +} + +.action-widget .chat-model-picker-tabbed .monaco-list-row.chat-model-picker-unavailable .description a:hover, +.action-widget .chat-model-picker-tabbed .monaco-list-row.chat-model-picker-unavailable .description a:active { + color: var(--vscode-textLink-activeForeground); + background: color-mix(in srgb, var(--vscode-textLink-activeForeground) 22%, transparent); +} + +.action-widget .chat-model-picker-tabbed .monaco-list-row.chat-model-picker-unavailable .description a:focus-visible { + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: calc(-1 * var(--vscode-strokeThickness)); +} + +.action-widget .chat-model-picker-tabbed .monaco-list-row.chat-model-picker-badge-promo .action-item-badge { + padding: var(--vscode-spacing-sizeNone) var(--vscode-spacing-size60); + color: var(--vscode-charts-green, var(--vscode-textLink-foreground)); + background: color-mix(in srgb, var(--vscode-charts-green, var(--vscode-textLink-foreground)) 14%, transparent); +} + +.action-widget .chat-model-picker-tabbed .monaco-list-row.chat-model-picker-badge-warning .action-item-badge { + padding: var(--vscode-spacing-sizeNone) var(--vscode-spacing-size60); + color: var(--vscode-editorWarning-foreground); + background: color-mix(in srgb, var(--vscode-editorWarning-foreground) 14%, transparent); +} + +.chat-model-picker-auto-row { + display: flex; + flex-direction: column; + padding: var(--vscode-spacing-size60) var(--vscode-spacing-size80) var(--vscode-spacing-sizeNone); + border-top: var(--vscode-strokeThickness) solid var(--vscode-menu-separatorBackground, var(--vscode-editorWidget-border)); +} + +.chat-model-picker-auto-main { + display: flex; + align-items: center; + gap: var(--vscode-spacing-size60); + min-height: var(--vscode-spacing-size240); + cursor: pointer; +} + +.chat-model-picker-auto-label { + flex: 0 0 auto; + font-weight: var(--vscode-fontWeight-semiBold); +} + +/* Pinned right on its own line, so revealing the tiers below cannot move it. */ +.chat-model-picker-auto-row .monaco-switch { + margin-left: auto; +} + +.chat-model-picker-auto-tiers:not(:empty) { + margin-bottom: var(--vscode-spacing-size20); +} + +.chat-model-picker-auto-description { + margin-bottom: var(--vscode-spacing-size20); + color: var(--vscode-descriptionForeground); + font-size: var(--vscode-fontSize-label2); +} + +.chat-model-picker-auto-description.hidden { + display: none; +} + +.monaco-reduce-motion .chat-model-picker-auto-control { + transition: none; +} + +.chat-model-picker-welcome { + display: flex; + flex-direction: column; + align-items: stretch; + justify-content: center; + gap: var(--vscode-spacing-size240); + padding: var(--vscode-spacing-size320) var(--vscode-spacing-size160); + /* Holds the popup near its list height so switching destinations doesn't jump. */ + min-height: 200px; + text-align: center; +} + +.chat-model-picker-welcome-provider { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--vscode-spacing-size80); +} + +.chat-model-picker-welcome-icon.codicon { + font-size: 32px; + color: var(--vscode-descriptionForeground); +} + +.chat-model-picker-welcome-title { + font-size: var(--vscode-fontSize-body1); + font-weight: var(--vscode-fontWeight-semiBold); + color: var(--vscode-descriptionForeground); +} + +.chat-model-picker-welcome-message { + color: var(--vscode-descriptionForeground); + font-size: var(--vscode-fontSize-label2); +} + +.chat-model-picker-welcome .monaco-button { + width: auto; + padding: var(--vscode-spacing-size40) var(--vscode-spacing-size240); +} + +.chat-model-card { + display: flex; + flex-direction: column; + gap: var(--vscode-spacing-size100); + padding: var(--vscode-spacing-size100); + min-width: 220px; + max-width: 300px; + font-size: var(--vscode-fontSize-label1); +} + +.action-list-submenu-panel.chat-model-card-panel { + border-radius: var(--vscode-cornerRadius-large); + background: var(--vscode-menu-background); + box-shadow: var(--vscode-shadow-lg); + overflow: hidden; +} + +.chat-model-card.action-list-submenu-hover-header { + padding: var(--vscode-spacing-size120); + max-width: 300px; +} + +.chat-model-card-header { + display: flex; + align-items: center; + gap: var(--vscode-spacing-size80); +} + +.chat-model-card-name { + flex: 0 1 auto; + min-width: 0; +} + +.chat-model-card-name { + font-weight: var(--vscode-fontWeight-semiBold); + font-size: var(--vscode-fontSize-heading3); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Pinning sits with the model's name rather than on its row: a control that only + * appears on hover makes the list twitch as the pointer crosses it. */ +.chat-model-card-pin { + display: flex; + flex: 0 0 auto; + align-items: center; + justify-content: center; + width: var(--vscode-spacing-size240); + height: var(--vscode-spacing-size240); + padding: var(--vscode-spacing-sizeNone); + border: 0; + border-radius: var(--vscode-cornerRadius-small); + color: var(--vscode-descriptionForeground); + background: transparent; + cursor: pointer; +} + +.chat-model-card-pin:hover { + color: var(--vscode-foreground); + background: var(--vscode-toolbar-hoverBackground); +} + +.chat-model-card-pin.checked { + color: var(--vscode-textLink-foreground); +} + +.chat-model-card-pin:focus-visible { + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: calc(-1 * var(--vscode-strokeThickness)); +} + +.chat-model-card-pin .codicon { + font-size: var(--vscode-codiconFontSize-compact); +} + +.chat-model-card-badge { + flex: 0 0 auto; + margin-left: auto; + padding: var(--vscode-spacing-sizeNone) var(--vscode-spacing-size60); + border-radius: var(--vscode-cornerRadius-circle); + color: var(--vscode-descriptionForeground); + background: color-mix(in srgb, var(--vscode-foreground) 10%, transparent); + font-size: var(--vscode-fontSize-label2); + line-height: var(--vscode-spacing-size160); + white-space: nowrap; +} + +.chat-model-card-badge.high-cost { + color: var(--vscode-editorWarning-foreground); + background: color-mix(in srgb, var(--vscode-editorWarning-foreground) 14%, transparent); +} + +.chat-model-card-section { + display: flex; + flex-direction: column; + gap: var(--vscode-spacing-size40); +} + +.chat-model-card-section-heading { + display: flex; + align-items: center; + gap: var(--vscode-spacing-size80); +} + +.chat-model-card-section-title { + flex: 1 1 auto; + font-weight: var(--vscode-fontWeight-semiBold); +} + +.chat-model-card-section-value { + color: var(--vscode-descriptionForeground); +} + +.chat-model-card-options { + display: flex; + flex-direction: column; + gap: var(--vscode-spacing-size20); +} + +.chat-model-card-option { + display: flex; + align-items: center; + gap: var(--vscode-spacing-size60); + padding: var(--vscode-spacing-size40) var(--vscode-spacing-size80); + border-radius: var(--vscode-cornerRadius-small); + cursor: pointer; +} + +.chat-model-card-option:hover { + background-color: var(--vscode-list-hoverBackground); +} + +.chat-model-card-option.checked { + background-color: color-mix(in srgb, var(--vscode-button-background) 18%, var(--vscode-menu-background)); + font-weight: var(--vscode-fontWeight-semiBold); +} + +.chat-model-card-option.checked .chat-model-card-option-check { + color: var(--vscode-button-background); +} + +.chat-model-card-option:focus-visible { + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: calc(-1 * var(--vscode-strokeThickness)); +} + +.chat-model-card-option-check { + flex: 0 0 auto; + width: var(--vscode-spacing-size160); + font-size: var(--vscode-codiconFontSize-compact); +} + +.chat-model-card-option-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chat-model-card-pricing-toggle { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--vscode-spacing-size40); + width: 100%; + padding: var(--vscode-spacing-sizeNone); + border: none; + color: inherit; + background: transparent; + font: inherit; + text-align: left; + cursor: pointer; +} + +.chat-model-card-pricing-chevron { + flex: 0 0 auto; + font-size: var(--vscode-codiconFontSize-compact); + color: var(--vscode-descriptionForeground); +} + +.chat-model-card-pricing-toggle:focus-visible { + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: var(--vscode-spacing-size20); + border-radius: var(--vscode-cornerRadius-small); +} + +.chat-model-card-pricing-body { + display: flex; + flex-direction: column; + gap: var(--vscode-spacing-size20); + margin-top: var(--vscode-spacing-size40); +} + +.chat-model-card-pricing-caption { + color: var(--vscode-descriptionForeground); + font-size: var(--vscode-fontSize-label2); +} + +.chat-model-card-pricing-row { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--vscode-spacing-size80); +} + +.chat-model-card-pricing-label { + color: var(--vscode-descriptionForeground); +} + +.chat-model-card-pricing-value { + font-weight: var(--vscode-fontWeight-semiBold); + font-variant-numeric: tabular-nums; +} + +.chat-model-card-description { + color: var(--vscode-descriptionForeground); +} + +.chat-model-card-description p { + margin: 0; +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerActionItem.ts index 6f39c59144fab..1e467590233a6 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerActionItem.ts @@ -9,8 +9,6 @@ import { getBaseLayerHoverDelegate } from '../../../../../../../base/browser/ui/ import { getDefaultHoverDelegate } from '../../../../../../../base/browser/ui/hover/hoverDelegateFactory.js'; import { BaseActionViewItem } from '../../../../../../../base/browser/ui/actionbar/actionViewItems.js'; import { IAction } from '../../../../../../../base/common/actions.js'; -import { IStringDictionary } from '../../../../../../../base/common/collections.js'; -import { Event } from '../../../../../../../base/common/event.js'; import { MutableDisposable } from '../../../../../../../base/common/lifecycle.js'; import { autorun, IObservable } from '../../../../../../../base/common/observable.js'; import { localize } from '../../../../../../../nls.js'; @@ -20,26 +18,9 @@ import { IKeybindingService } from '../../../../../../../platform/keybinding/com import { getLanguageModelDisplayNameWithSubscriptionSource } from '../../../../common/languageModelSourcePresentation.js'; import { ILanguageModelChatMetadataAndIdentifier } from '../../../../common/languageModels.js'; import { IChatInputPickerOptions } from '../chatInputPickerActionItem.js'; +import { IModelConfigurationAccess } from './modelPickerModelConfig.js'; import { ModelPickerWidget } from './modelPickerWidget.js'; -/** - * Read/write access to a model's configuration (e.g. context size, thinking - * effort). Implemented either by the global {@link ILanguageModelsService} or by - * a per-editor override layer so that one editor's changes do not sync to other - * already-open editors. Structurally satisfied by `ILanguageModelsService`. - */ -export interface IModelConfigurationAccess { - getModelConfiguration(modelId: string): IStringDictionary | undefined; - setModelConfiguration(modelId: string, values: IStringDictionary): Promise; - getModelConfigurationActions(modelId: string): IAction[]; - /** - * Fires when this access layer's configuration changes (e.g. user picks a - * new context size). Implementations that always read the global value can - * omit this and rely on `ILanguageModelsService.onDidChangeLanguageModels`. - */ - readonly onDidChange?: Event; -} - export interface IModelPickerPresentationOptions { readonly useGroupedModelPicker: boolean; readonly showManageModelsAction: boolean; diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerAutoRow.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerAutoRow.ts new file mode 100644 index 0000000000000..6ab3cf565313a --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerAutoRow.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 * as dom from '../../../../../../../base/browser/dom.js'; +import { Radio } from '../../../../../../../base/browser/ui/radio/radio.js'; +import { Switch } from '../../../../../../../base/browser/ui/toggle/switch.js'; +import { DisposableStore } from '../../../../../../../base/common/lifecycle.js'; +import { localize } from '../../../../../../../nls.js'; +import { ILanguageModelChatMetadataAndIdentifier } from '../../../../common/languageModels.js'; +import { getModelConfigProperty, getModelConfigValueLabel, IModelConfigurationAccess, MODEL_CONFIG_GROUP_EFFORT } from './modelPickerModelConfig.js'; + +export interface IAutoRowOptions { + readonly autoModel: ILanguageModelChatMetadataAndIdentifier; + readonly configurationAccess: IModelConfigurationAccess; + readonly isEnabled: () => boolean; + readonly onToggle: (enabled: boolean) => void; +} + +/** + * The Auto row pinned below the model list: a switch that turns automatic model + * selection on, and the tiers it routes by. + * + * The switch keeps one position whether or not the tiers are showing, so turning Auto + * on does not move the control the user just aimed at. + */ +export class ModelPickerAutoRow extends DisposableStore { + + readonly element = dom.$('.chat-model-picker-auto-row'); + + private readonly _renderDisposables = this.add(new DisposableStore()); + private readonly _toggle: Switch; + private readonly _tierContainer: HTMLElement; + private readonly _description: HTMLElement; + private _tierControl: Radio | undefined; + + constructor(private readonly _options: IAutoRowOptions) { + super(); + + const main = dom.append(this.element, dom.$('.chat-model-picker-auto-main')); + dom.append(main, dom.$('.chat-model-picker-auto-label', undefined, _options.autoModel.metadata.name)); + + this._toggle = this.add(new Switch({ + ariaLabel: localize('chat.modelPicker.autoToggle', "Choose a model automatically"), + checked: _options.isEnabled(), + })); + main.appendChild(this._toggle.domNode); + this.add(this._toggle.onChange(checked => _options.onToggle(checked))); + + // The label and the gap beside it flip the switch too, the way a row carrying a + // standalone toggle does. The switch stops its own clicks from reaching here. + this.add(dom.addDisposableListener(main, dom.EventType.CLICK, () => { + if (this._toggle.disabled) { + return; + } + this._toggle.checked = !this._toggle.checked; + _options.onToggle(this._toggle.checked); + })); + // Pressing the strip must not move focus out of the list, which would blur the + // popup and dismiss it before the click lands. + this.add(dom.addDisposableGenericMouseDownListener(main, e => e.preventDefault())); + + this._tierContainer = dom.append(this.element, dom.$('.chat-model-picker-auto-tiers')); + this._description = dom.append(this.element, dom.$('.chat-model-picker-auto-description')); + // The description is inert text, so pressing it must not dismiss the popup either. + // The tiers are left alone: their buttons take focus of their own accord. + this.add(dom.addDisposableGenericMouseDownListener(this._description, e => e.preventDefault())); + this.render(); + } + + /** Re-reads the selection and tier so the row matches the current state. */ + render(): void { + const enabled = this._options.isEnabled(); + this.element.classList.toggle('enabled', enabled); + this._toggle.checked = enabled; + + const tier = getModelConfigProperty(this._options.autoModel, this._options.configurationAccess, MODEL_CONFIG_GROUP_EFFORT); + const values = tier?.schema.enum ?? []; + dom.clearNode(this._tierContainer); + this._renderDisposables.clear(); + this._tierControl = undefined; + + if (enabled && tier && values.length > 1) { + const control = this._renderDisposables.add(new Radio({ + ariaLabel: tier.schema.title ?? localize('chat.modelPicker.autoTier', "Optimize for"), + className: 'segmented', + arrowKeyBehavior: 'focus', + items: values.map((value, index) => ({ + text: getModelConfigValueLabel(tier.schema, value), + tooltip: tier.schema.enumDescriptions?.[index], + isActive: value === tier.value, + })), + })); + this._renderDisposables.add(control.onDidSelect(async index => { + await this._options.configurationAccess.setModelConfiguration(this._options.autoModel.identifier, { [tier.key]: values[index] }); + this.render(); + // Rebuilt, so focus has to land on the control that replaced this one. + this._tierControl?.focusActiveItem(); + })); + this._tierContainer.appendChild(control.domNode); + this._tierControl = control; + } + + const selectedIndex = values.indexOf(tier?.value); + const tierDescription = enabled && selectedIndex >= 0 ? tier?.schema.enumDescriptions?.[selectedIndex] : undefined; + // Auto's own detail stays put; the tier description joins it rather than replacing it. + const detail = this._options.autoModel.metadata.detail; + const parts = [detail, tierDescription].filter(part => !!part); + this._description.textContent = parts.join(' · '); + this._description.classList.toggle('hidden', parts.length === 0); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerBadges.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerBadges.ts new file mode 100644 index 0000000000000..4ddc25631b966 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerBadges.ts @@ -0,0 +1,63 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier } from '../../../../common/languageModels.js'; +import { getModelConfigSummary, IModelConfigurationAccess } from './modelPickerModelConfig.js'; + +/** Color treatment for a model-row badge. */ +export const enum ModelBadgeTone { + /** Plain text, no fill. The default for descriptive labels like a provider name. */ + Neutral = 'neutral', + /** A quiet tint, for a state the user chose, e.g. the current configuration. */ + Selected = 'selected', + /** Warm, for an offer the user gains from. */ + Promo = 'promo', + /** Warning, for a model going away or carrying a caveat. */ + Warning = 'warning', +} + +export interface IModelBadge { + readonly text: string; + readonly tone: ModelBadgeTone; +} + +/** Warning categories that mean the model itself is going away. */ +const DEPRECATION_WARNING_CODES: ReadonlySet = new Set(['model_pending_deprecation', 'model_deprecated']); + +export interface IModelBadgeContext { + readonly configurationAccess: IModelConfigurationAccess; + /** The provider a model came from, when the list does not already group by it. */ + readonly providerLabel?: string; +} + +/** + * The single badge a model row shows. A row has one badge slot, so the states are + * ranked by how much the user needs to know before picking. + */ +export function getModelBadge( + model: ILanguageModelChatMetadataAndIdentifier, + context: IModelBadgeContext, +): IModelBadge | undefined { + if (isDeprecated(model)) { + return { text: localize('chat.modelPicker.badge.deprecated', "Retiring"), tone: ModelBadgeTone.Warning }; + } + const promo = ILanguageModelChatMetadata.hasPromoDiscount(model.metadata) ? model.metadata.promo : undefined; + if (promo) { + return { text: localize('chat.modelPicker.badge.promo', "{0}% off", promo.discountPercent), tone: ModelBadgeTone.Promo }; + } + // Any model the user tuned says so, not just the selected one, so a row that will + // behave differently from its defaults is recognisable before it is picked. + const summary = getModelConfigSummary(model, context.configurationAccess); + if (summary) { + return { text: summary, tone: ModelBadgeTone.Selected }; + } + return context.providerLabel ? { text: context.providerLabel, tone: ModelBadgeTone.Neutral } : undefined; +} + +/** Whether the model is retiring, which its provider reports as a warning category. */ +export function isDeprecated(model: ILanguageModelChatMetadataAndIdentifier): boolean { + return Object.keys(model.metadata.warningText ?? {}).some(code => DEPRECATION_WARNING_CODES.has(code)); +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerCard.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerCard.ts new file mode 100644 index 0000000000000..0438c74edf0b6 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerCard.ts @@ -0,0 +1,320 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../../../../base/browser/dom.js'; +import { Radio } from '../../../../../../../base/browser/ui/radio/radio.js'; +import { Codicon } from '../../../../../../../base/common/codicons.js'; +import { Event } from '../../../../../../../base/common/event.js'; +import { DisposableStore } from '../../../../../../../base/common/lifecycle.js'; +import { formatTokenCount } from '../../../../../../../base/common/numbers.js'; +import { ThemeIcon } from '../../../../../../../base/common/themables.js'; +import { localize } from '../../../../../../../nls.js'; +import { IOpenerService } from '../../../../../../../platform/opener/common/opener.js'; +import { ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier } from '../../../../common/languageModels.js'; +import { formatModelCost, getCreditsPerMillionTokensLabel, getMaxContextLabel, getModelContextWindowTotal, getModelCostMetrics, renderModelDescription } from './modelPickerDetails.js'; +import { createMessageBanner } from './modelPickerHover.js'; +import { getModelConfigProperty, getModelConfigValueLabel, IModelConfigProperty, IModelConfigurationAccess, isExtendedContext, MODEL_CONFIG_GROUP_CONTEXT, MODEL_CONFIG_GROUP_EFFORT } from './modelPickerModelConfig.js'; +import { getCategoryLabel, getPriceCategoryLabel, isAutoModel, isHighCostCategory, isMultiplierPricing } from './modelPickerPresentation.js'; +import { IModelSpeedVariants } from './modelPickerVariants.js'; + +/** + * Whether the pricing breakdown is open, shared by every card. Most people never need + * the numbers, and the ones who do should not have to open them on each model. + */ +export interface IPricingDisclosure { + isExpanded(): boolean; + setExpanded(expanded: boolean): void; + /** Fires when the state changes, so cards already built stay in step. */ + readonly onDidChange: Event; +} + +export interface IModelCardOptions { + readonly model: ILanguageModelChatMetadataAndIdentifier; + readonly configurationAccess: IModelConfigurationAccess; + /** Whether the account is billed by credits, which is when cost numbers are shown. */ + readonly isUBB: boolean; + readonly openerService: IOpenerService; + /** Called after a configuration value changes so the caller can report it and refresh its own label. */ + readonly onDidChangeConfiguration?: (group: string, key: string, fromValue: unknown, toValue: unknown) => void; + /** Whether the model is pinned, when pinning is offered here. */ + readonly isPinned?: boolean; + readonly onTogglePin?: (pinned: boolean) => void; + readonly pricingDisclosure?: IPricingDisclosure; + /** The faster twin of this model, when the provider offers one. */ + readonly speedVariants?: IModelSpeedVariants; + /** Called with the twin the user picked, which becomes the selected model. */ + readonly onSelectVariant?: (model: ILanguageModelChatMetadataAndIdentifier) => void; +} + +/** + * The detail card shown beside a model row: what the model costs, how hard it + * thinks, and how much context it gets. Configuration changes are written + * straight through and the card re-renders itself in place. + */ +export class ModelCard extends DisposableStore { + + readonly element = dom.$('.chat-model-card'); + + private readonly _contentDisposables = this.add(new DisposableStore()); + /** The pricing disclosure's button, rebuilt with the rest of the card on each render. */ + private _pricingToggle: HTMLElement | undefined; + + constructor(private readonly _options: IModelCardOptions) { + super(); + if (_options.pricingDisclosure) { + // Opening the breakdown on one model opens it on the rest, so cards built + // earlier are re-rendered rather than left showing the old state. + this.add(_options.pricingDisclosure.onDidChange(() => this._render())); + } + this._render(); + } + + private _configProperty(group: string): IModelConfigProperty | undefined { + return getModelConfigProperty(this._options.model, this._options.configurationAccess, group); + } + + private async _setValue(group: string, key: string, value: unknown): Promise { + const previous = this._configProperty(group)?.value; + await this._options.configurationAccess.setModelConfiguration(this._options.model.identifier, { [key]: value }); + this._render(); + this._options.onDidChangeConfiguration?.(group, key, previous, value); + } + + private _render(): void { + this._contentDisposables.clear(); + dom.clearNode(this.element); + this._pricingToggle = undefined; + + const { model, isUBB, openerService } = this._options; + const metadata = model.metadata; + const isAuto = isAutoModel(model); + + this._renderHeader(); + + if (!isAuto) { + for (const message of Object.values(metadata.warningText ?? {})) { + this.element.appendChild(createMessageBanner(message, 'chat-model-hover-warning-text', Codicon.warningCompact, this._contentDisposables, openerService)); + } + for (const message of Object.values(metadata.infoText ?? {})) { + this.element.appendChild(createMessageBanner(message, 'chat-model-hover-info-text', Codicon.info, this._contentDisposables, openerService)); + } + } + const promo = !isAuto && ILanguageModelChatMetadata.hasPromoDiscount(metadata) ? metadata.promo : undefined; + if (promo) { + const endsAtLabel = ILanguageModelChatMetadata.getPromoEndsAtLabel(promo.endsAt); + const message = endsAtLabel ? `${promo.message} ${endsAtLabel}` : promo.message; + this.element.appendChild(createMessageBanner(message, 'chat-model-hover-promo-text', Codicon.info, this._contentDisposables, openerService)); + } + + const effort = this._configProperty(MODEL_CONFIG_GROUP_EFFORT); + const context = this._configProperty(MODEL_CONFIG_GROUP_CONTEXT); + + if (effort) { + this._renderEffortSection(effort, isAuto); + } + if (context) { + this._renderContextSection(context); + } else if (!isAuto) { + this._renderContextWindow(metadata); + } + // After the settings every model has, so those keep one position whether or not + // this model happens to have a faster twin. + if (!isAuto) { + this._renderSpeedSection(); + } + if (!isAuto && isUBB) { + this._renderCost(context); + } else if (!isAuto && metadata.pricing && isMultiplierPricing(model)) { + this._renderSection(localize('models.cost', "Cost: {0}", metadata.pricing)); + } + if (!this.element.firstChild && metadata.tooltip) { + this._renderDescription(metadata.tooltip); + } + } + + private _renderHeader(): void { + const metadata = this._options.model.metadata; + const isAuto = isAutoModel(this._options.model); + const header = dom.append(this.element, dom.$('.chat-model-card-header')); + dom.append(header, dom.$('.chat-model-card-name', undefined, metadata.name)); + + const badgeLabel = isAuto + ? metadata.detail + : getPriceCategoryLabel(metadata.priceCategory) ?? getCategoryLabel(metadata.category); + if (badgeLabel) { + const badge = dom.append(header, dom.$('span.chat-model-card-badge', undefined, badgeLabel)); + badge.classList.toggle('high-cost', !isAuto && isHighCostCategory(metadata.priceCategory)); + } + + // Pinning lives here rather than on the row: a control that only exists on hover + // makes every row twitch as the pointer crosses the list. + if (this._options.onTogglePin) { + const pinned = !!this._options.isPinned; + const label = pinned + ? localize('chat.modelPicker.unpin', "Unpin Model") + : localize('chat.modelPicker.pin', "Pin Model"); + const button = dom.append(header, dom.$('button.chat-model-card-pin')); + button.type = 'button'; + button.classList.toggle('checked', pinned); + button.setAttribute('aria-pressed', String(pinned)); + button.ariaLabel = label; + button.title = label; + dom.append(button, dom.$(`span${ThemeIcon.asCSSSelector(pinned ? Codicon.pinned : Codicon.pin)}`)); + this._contentDisposables.add(dom.addDisposableListener(button, dom.EventType.CLICK, e => { + dom.EventHelper.stop(e, true); + this._options.onTogglePin?.(!pinned); + })); + } + } + + private _renderDescription(tooltip: string): void { + const element = renderModelDescription(tooltip, this._options.openerService, this._contentDisposables); + element.classList.add('chat-model-card-description'); + this.element.appendChild(element); + } + + private _renderSection(title: string): HTMLElement { + const section = dom.append(this.element, dom.$('.chat-model-card-section')); + const heading = dom.append(section, dom.$('.chat-model-card-section-heading')); + dom.append(heading, dom.$('.chat-model-card-section-title', undefined, title)); + return section; + } + + private _renderEffortSection(effort: IModelConfigProperty, isAuto: boolean): void { + this._renderChoiceSection(effort, MODEL_CONFIG_GROUP_EFFORT, effort.schema.title ?? (isAuto + ? localize('models.routingProfile', "Routing Profile") + : localize('chat.effort.header', "Thinking Effort"))); + } + + /** + * The context windows the model can be given. Rendered like every other setting + * whether the producer offers two or five: a switch would read as off/on, but + * neither window is "off", and it would hide the one being chosen between. + */ + private _renderContextSection(context: IModelConfigProperty): void { + this._renderChoiceSection(context, MODEL_CONFIG_GROUP_CONTEXT, context.schema.title ?? localize('chat.context.header', "Context")); + } + + /** + * One setting: its name and the choices. The value is not described above the + * control, since these are ordered scales whose labels already say what they mean. + */ + private _renderChoiceSection(property: IModelConfigProperty, group: string, title: string): void { + const values = property.schema.enum ?? []; + const section = this._renderSection(title); + const control = this._contentDisposables.add(new Radio({ + ariaLabel: title, + className: 'segmented', + // Selecting closes the picker, so arrows must be able to travel past an option. + arrowKeyBehavior: 'focus', + items: values.map((value, index) => ({ + text: getModelConfigValueLabel(property.schema, value), + tooltip: property.schema.enumDescriptions?.[index], + isActive: value === property.value, + })), + })); + this._contentDisposables.add(control.onDidSelect(index => void this._setValue(group, property.key, values[index]))); + section.appendChild(control.domNode); + } + + /** + * The two speeds the provider offers the same model at. Picking one selects that + * model, since the twins are separate models with their own prices. + */ + private _renderSpeedSection(): void { + const variants = this._options.speedVariants; + if (!variants) { + return; + } + const choices = [ + { label: localize('models.speed.standard', "Standard"), model: variants.standard }, + { label: localize('models.speed.fast', "Fast"), model: variants.fast }, + ]; + const title = localize('models.speed', "Speed"); + const section = this._renderSection(title); + const control = this._contentDisposables.add(new Radio({ + ariaLabel: title, + className: 'segmented', + arrowKeyBehavior: 'focus', + items: choices.map(choice => ({ + text: choice.label, + isActive: choice.model.identifier === this._options.model.identifier, + })), + })); + this._contentDisposables.add(control.onDidSelect(index => { + const next = choices[index].model; + if (next.identifier !== this._options.model.identifier) { + this._options.onSelectVariant?.(next); + } + })); + section.appendChild(control.domNode); + } + + private _renderContextWindow(metadata: ILanguageModelChatMetadata): void { + const total = getModelContextWindowTotal(metadata); + if (!total) { + return; + } + const section = dom.append(this.element, dom.$('.chat-model-card-section')); + const heading = dom.append(section, dom.$('.chat-model-card-section-heading')); + dom.append(heading, dom.$('.chat-model-card-section-title', undefined, getMaxContextLabel())); + dom.append(heading, dom.$('.chat-model-card-section-value', undefined, formatTokenCount(total))); + } + + private _renderCost(context: IModelConfigProperty | undefined): void { + const metadata = this._options.model.metadata; + const metrics = getModelCostMetrics(metadata); + if (!metrics.length) { + if (metadata.pricing) { + this._renderSection(localize('models.cost', "Cost: {0}", metadata.pricing)); + } + return; + } + + const useExtended = !!context && isExtendedContext(context); + const disclosure = this._options.pricingDisclosure; + const expanded = disclosure ? disclosure.isExpanded() : true; + const section = dom.append(this.element, dom.$('.chat-model-card-section.chat-model-card-pricing')); + const bodyId = `chat-model-card-pricing-${this._options.model.identifier.replace(/[^\w-]/g, '-')}`; + + // Folded away by default: the numbers only matter to the people who go looking + // for them, and they are the last thing most people need to read. + if (disclosure) { + const title = localize('models.pricingDetails', "Pricing details"); + const toggle = dom.append(section, dom.$('button.chat-model-card-pricing-toggle')); + toggle.type = 'button'; + toggle.setAttribute('aria-expanded', String(expanded)); + toggle.setAttribute('aria-controls', bodyId); + dom.append(toggle, dom.$('span.chat-model-card-section-title', undefined, title)); + dom.append(toggle, dom.$(`span.chat-model-card-pricing-chevron${ThemeIcon.asCSSSelector(expanded ? Codicon.chevronDown : Codicon.chevronRight)}`)); + this._pricingToggle = toggle; + this._contentDisposables.add(dom.addDisposableListener(toggle, dom.EventType.CLICK, e => { + dom.EventHelper.stop(e, true); + const hadFocus = dom.isActiveElement(toggle); + disclosure.setExpanded(!expanded); + // The click rebuilt this card, so focus has to land on the button that + // replaced the one that was pressed. + if (hadFocus) { + this._pricingToggle?.focus(); + } + })); + } + if (!expanded) { + return; + } + + const body = dom.append(section, dom.$('.chat-model-card-pricing-body')); + body.id = bodyId; + // The unit is stated once, so each row can be read as a plain name and number. + dom.append(body, dom.$('.chat-model-card-pricing-caption', undefined, getCreditsPerMillionTokensLabel())); + for (const metric of metrics) { + const cost = useExtended ? metric.extended ?? metric.standard : metric.standard; + const row = dom.append(body, dom.$('.chat-model-card-pricing-row')); + dom.append(row, dom.$('span.chat-model-card-pricing-label', undefined, metric.label)); + dom.append(row, dom.$('span.chat-model-card-pricing-value', undefined, formatModelCost(cost))); + } + } + +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerConfiguration.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerConfiguration.ts index e281b1da78018..4f044c20ca2df 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerConfiguration.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerConfiguration.ts @@ -12,40 +12,10 @@ import { ActionListItemKind, IActionListHeaderLink, IActionListItem } from '../. import { IActionWidgetService } from '../../../../../../../platform/actionWidget/browser/actionWidget.js'; import { IActionWidgetDropdownAction } from '../../../../../../../platform/actionWidget/browser/actionWidgetDropdown.js'; import { ITelemetryService } from '../../../../../../../platform/telemetry/common/telemetry.js'; -import { TelemetryTrustedValue } from '../../../../../../../platform/telemetry/common/telemetryUtils.js'; import { ILanguageModelChatMetadataAndIdentifier } from '../../../../common/languageModels.js'; import { withChatInputPickerMotion } from '../chatInputPickerActionItem.js'; -import { IModelConfigurationAccess } from './modelPickerActionItem.js'; - -type ChatThinkingEffortChangeClassification = { - owner: 'lramos15'; - comment: 'Reporting when a model configuration value (e.g. thinking effort, or the Auto routing tier) is changed'; - model: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The model the configuration was changed for' }; - property: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The first-party configuration property that was changed (reasoningEffort, or tier for the Auto model); "unknown" for third-party providers, which choose their own keys' }; - fromValue: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The previous value of the configuration property' }; - toValue: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The new value of the configuration property' }; -}; - -type ChatThinkingEffortChangeEvent = { - model: string | TelemetryTrustedValue; - property: string; - fromValue: string; - toValue: string; -}; - -type ChatContextSizeChangeClassification = { - owner: 'lramos15'; - comment: 'Reporting when the context window size is changed'; - model: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The model the context size was changed for' }; - fromValue: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The previous context size value' }; - toValue: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The new context size value' }; -}; - -type ChatContextSizeChangeEvent = { - model: string | TelemetryTrustedValue; - fromValue: string; - toValue: string; -}; +import { getModelConfigProperty, IModelConfigurationAccess, MODEL_CONFIG_GROUP_CONTEXT, MODEL_CONFIG_GROUP_EFFORT } from './modelPickerModelConfig.js'; +import { logModelConfigurationChange } from './modelPickerTelemetry.js'; export interface IModelPickerConfigurationHost { readonly getSelectedModel: () => ILanguageModelChatMetadataAndIdentifier | undefined; @@ -66,8 +36,8 @@ export class ModelPickerConfiguration { renderButton(button: HTMLElement, compact: boolean, noModelsAvailable: boolean): void { const model = this._host.getSelectedModel(); - const effortConfig = this._getConfigProperty('navigation'); - const tokensConfig = this._getConfigProperty('tokens'); + const effortConfig = this._getConfigProperty(MODEL_CONFIG_GROUP_EFFORT); + const tokensConfig = this._getConfigProperty(MODEL_CONFIG_GROUP_CONTEXT); if (compact || !model || noModelsAvailable || (!effortConfig && !tokensConfig)) { button.style.display = 'none'; return; @@ -168,23 +138,7 @@ export class ModelPickerConfiguration { } private _getConfigProperty(group: string) { - const model = this._host.getSelectedModel(); - if (!model) { - return undefined; - } - const schema = model.metadata.configurationSchema; - if (!schema?.properties) { - return undefined; - } - const configurationAccess = this._host.getConfigurationAccess(); - const currentConfig = configurationAccess.getModelConfiguration(model.identifier) ?? {}; - for (const [key, propSchema] of Object.entries(schema.properties)) { - if (propSchema.group !== group || !propSchema.enum?.length) { - continue; - } - return { key, value: currentConfig[key] ?? propSchema.default, schema: propSchema }; - } - return undefined; + return getModelConfigProperty(this._host.getSelectedModel(), this._host.getConfigurationAccess(), group); } private _buildItems(): IActionListItem[] { @@ -201,7 +155,6 @@ export class ModelPickerConfiguration { group: string, fallbackHeaderLabel: string, formatValueLabel: (value: unknown, enumLabel: string | undefined) => string, - logChange: (value: unknown, previousValue: string, key: string) => void, ): void => { const config = this._getConfigProperty(group); if (!config) { @@ -229,7 +182,7 @@ export class ModelPickerConfiguration { tooltip: enumDescription ?? '', label: displayLabel, run: () => { - logChange(value, previousValue, config.key); + logModelConfigurationChange(this._telemetryService, model, group, config.key, previousValue, value); return configurationAccess.setModelConfiguration(modelIdentifier, { [config.key]: value }); } }, @@ -246,27 +199,14 @@ export class ModelPickerConfiguration { }; appendConfigSection( - 'navigation', + MODEL_CONFIG_GROUP_EFFORT, localize('chat.effort.header', "Thinking Effort"), (value, enumLabel) => enumLabel ?? String(value), - (value, previousValue, key) => this._telemetryService.publicLog2('chat.thinkingEffortChange', { - model: model.metadata.vendor === 'copilot' ? new TelemetryTrustedValue(modelIdentifier) : 'unknown', - // Third-party providers choose their own property keys, so only - // first-party ones are reported as a controlled vocabulary. - property: model.metadata.vendor === 'copilot' ? key : 'unknown', - fromValue: previousValue, - toValue: String(value), - }), ); appendConfigSection( - 'tokens', + MODEL_CONFIG_GROUP_CONTEXT, localize('chat.tokens.header', "Context Size"), (value, enumLabel) => enumLabel ?? formatTokenCount(Number(value)), - (value, previousValue) => this._telemetryService.publicLog2('chat.contextSizeChange', { - model: model.metadata.vendor === 'copilot' ? new TelemetryTrustedValue(modelIdentifier) : 'unknown', - fromValue: previousValue, - toValue: String(value), - }), ); return items; diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerDetails.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerDetails.ts new file mode 100644 index 0000000000000..8f98d9a79d996 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerDetails.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 { renderMarkdown } from '../../../../../../../base/browser/markdownRenderer.js'; +import { MarkdownString } from '../../../../../../../base/common/htmlContent.js'; +import { DisposableStore } from '../../../../../../../base/common/lifecycle.js'; +import { localize } from '../../../../../../../nls.js'; +import { IOpenerService } from '../../../../../../../platform/opener/common/opener.js'; +import { ILanguageModelChatMetadata } from '../../../../common/languageModels.js'; + +/** One cost metric, with the value for each context tier. */ +export interface IModelCostMetric { + readonly label: string; + readonly standard: number | null | undefined; + readonly extended: number | null | undefined; +} + +/** The cost metrics a model reports, in the order they are shown. */ +export function getModelCostMetrics(metadata: ILanguageModelChatMetadata): IModelCostMetric[] { + return [ + { label: localize('models.inputCostLabel', "Input"), standard: metadata.inputCost, extended: metadata.longContextInputCost }, + { label: localize('models.outputCostLabel', "Output"), standard: metadata.outputCost, extended: metadata.longContextOutputCost }, + { label: localize('models.cacheCostLabel', "Cache Read"), standard: metadata.cacheCost, extended: metadata.longContextCacheCost }, + { label: localize('models.cacheWriteCostLabel', "Cache Write"), standard: metadata.cacheWriteCost, extended: metadata.longContextCacheWriteCost }, + ].filter(metric => metric.standard !== undefined || metric.extended !== undefined); +} + +export function formatModelCost(cost: number | null | undefined): string { + return typeof cost === 'number' ? String(cost) : localize('models.cost.unknown', "Unknown"); +} + +/** The unit the cost numbers are given in, stated once above them. */ +export function getCreditsPerMillionTokensLabel(): string { + return localize('models.creditsPerMillionTokens', "Credits per 1M tokens"); +} + +/** The context window a model offers, or 0 when it reports none. */ +export function getModelContextWindowTotal(metadata: ILanguageModelChatMetadata): number { + return (metadata.maxInputTokens ?? 0) + (metadata.maxOutputTokens ?? 0); +} + +export function getMaxContextLabel(): string { + return localize('models.contextSize', "Max context"); +} + +/** Renders a model's description markdown. The caller places and classes the element. */ +export function renderModelDescription(tooltip: string, openerService: IOpenerService, store: DisposableStore): HTMLElement { + const rendered = store.add(renderMarkdown(new MarkdownString(tooltip, { supportThemeIcons: true }), { + actionHandler: link => { void openerService.open(link, { allowCommands: false, fromUserGesture: true }); }, + })); + return rendered.element; +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerHover.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerHover.ts index e093c4228e7cf..8135977feb783 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerHover.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerHover.ts @@ -18,9 +18,11 @@ import { localize } from '../../../../../../../nls.js'; import { IOpenerService } from '../../../../../../../platform/opener/common/opener.js'; import { defaultButtonStyles } from '../../../../../../../platform/theme/browser/defaultStyles.js'; import { ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier } from '../../../../common/languageModels.js'; -import { getPriceCategoryLabel, isAutoModel, isMultiplierPricing } from './modelPickerPresentation.js'; +import { formatModelCost, getCreditsPerMillionTokensLabel, getMaxContextLabel, getModelContextWindowTotal, getModelCostMetrics, renderModelDescription } from './modelPickerDetails.js'; +import { MODEL_CONFIG_GROUP_CONTEXT, MODEL_CONFIG_GROUP_EFFORT } from './modelPickerModelConfig.js'; +import { getCategoryLabel, getPriceCategoryLabel, isAutoModel, isHighCostCategory, isMultiplierPricing } from './modelPickerPresentation.js'; -const SUPPORTED_CONFIG_GROUPS: readonly string[] = ['navigation', 'tokens']; +const SUPPORTED_CONFIG_GROUPS: readonly string[] = [MODEL_CONFIG_GROUP_EFFORT, MODEL_CONFIG_GROUP_CONTEXT]; export interface IModelPickerHoverContent { readonly element: HTMLElement; @@ -84,15 +86,10 @@ export function getModelHoverContent( let costInfoRendered = false; let costTableRendered = false; if (!isAuto && isUBB) { - const metrics: { label: string; def: number | null | undefined; long: number | null | undefined }[] = [ - { label: localize('models.inputCostLabel', "Input"), def: model.metadata.inputCost, long: model.metadata.longContextInputCost }, - { label: localize('models.outputCostLabel', "Output"), def: model.metadata.outputCost, long: model.metadata.longContextOutputCost }, - { label: localize('models.cacheCostLabel', "Cache Read"), def: model.metadata.cacheCost, long: model.metadata.longContextCacheCost }, - { label: localize('models.cacheWriteCostLabel', "Cache Write"), def: model.metadata.cacheWriteCost, long: model.metadata.longContextCacheWriteCost }, - ].filter(metric => metric.def !== undefined || metric.long !== undefined); + const metrics = getModelCostMetrics(model.metadata); if (metrics.length > 0) { - const hasLongContext = metrics.some(metric => metric.long !== undefined); + const hasLongContext = metrics.some(metric => metric.extended !== undefined); const table = dom.$('.chat-model-hover-cost-table'); if (hasLongContext) { container.classList.add('has-long-context'); @@ -105,13 +102,12 @@ export function getModelHoverContent( return; } row.appendChild(dom.$('span.chat-model-hover-cost-value', undefined, - dom.$('span.chat-model-hover-cost-number', undefined, - typeof cost === 'number' ? String(cost) : localize('models.cost.unknown', "Unknown")), + dom.$('span.chat-model-hover-cost-number', undefined, formatModelCost(cost)), )); }; const headerRow = dom.$('.chat-model-hover-cost-row.header'); - headerRow.appendChild(dom.$('span.chat-model-hover-cost-heading', undefined, localize('models.creditsPerMillionTokens', "Credits Per 1M Tokens"))); + headerRow.appendChild(dom.$('span.chat-model-hover-cost-heading', undefined, getCreditsPerMillionTokensLabel())); if (hasLongContext) { headerRow.appendChild(dom.$('span.chat-model-hover-cost-value.subheader', undefined, localize('models.defaultContext', "Default"))); headerRow.appendChild(dom.$('span.chat-model-hover-cost-value.subheader', undefined, localize('models.longContext', "Long Context"))); @@ -125,9 +121,9 @@ export function getModelHoverContent( const labelCell = dom.$('.chat-model-hover-cost-label'); labelCell.appendChild(dom.$('span.chat-model-hover-cost-label-text', undefined, metric.label)); row.appendChild(labelCell); - appendValueCell(row, metric.def); + appendValueCell(row, metric.standard); if (hasLongContext) { - appendValueCell(row, metric.long); + appendValueCell(row, metric.extended); } table.appendChild(row); } @@ -145,18 +141,15 @@ export function getModelHoverContent( } if (!costInfoRendered && model.metadata.tooltip) { - const descriptionMd = new MarkdownString(model.metadata.tooltip, { supportThemeIcons: true }); - const rendered = disposables.add(renderMarkdown(descriptionMd, { - actionHandler: link => { void openerService.open(link, { allowCommands: false, fromUserGesture: true }); }, - })); - rendered.element.classList.add('chat-model-hover-description'); - container.appendChild(rendered.element); + const element = renderModelDescription(model.metadata.tooltip, openerService, disposables); + element.classList.add('chat-model-hover-description'); + container.appendChild(element); } if (!isAuto && !costTableRendered && (model.metadata.maxInputTokens || model.metadata.maxOutputTokens)) { - const totalTokens = (model.metadata.maxInputTokens ?? 0) + (model.metadata.maxOutputTokens ?? 0); + const totalTokens = getModelContextWindowTotal(model.metadata); const contextSection = dom.$('.chat-model-hover-context'); - contextSection.appendChild(dom.$('.chat-model-hover-context-label', undefined, localize('models.contextSize', "Max context"))); + contextSection.appendChild(dom.$('.chat-model-hover-context-label', undefined, getMaxContextLabel())); contextSection.appendChild(dom.$('.chat-model-hover-context-value', undefined, formatTokenCount(totalTokens))); container.appendChild(contextSection); } @@ -169,7 +162,7 @@ export function getModelHoverContent( for (const propSchema of Object.values(model.metadata.configurationSchema.properties)) { if (propSchema.enum && propSchema.enum.length >= 2 && propSchema.group && SUPPORTED_CONFIG_GROUPS.includes(propSchema.group) && !seenGroups.has(propSchema.group)) { // Auto's navigation option is its routing tier; the menu keeps the producer's "Optimize for…" title. - const label = isAuto && propSchema.group === 'navigation' ? localize('models.routingProfile', "Routing Profile") : propSchema.title ?? propSchema.description; + const label = isAuto && propSchema.group === MODEL_CONFIG_GROUP_EFFORT ? localize('models.routingProfile', "Routing Profile") : propSchema.title ?? propSchema.description; if (label) { seenGroups.add(propSchema.group); configButtons.push({ group: propSchema.group, label }); @@ -201,7 +194,7 @@ export function getModelHoverContent( * Builds one bordered message banner (an icon plus a rendered markdown message) * for the warning, info and promo notices shown at the top of the hover. */ -function createMessageBanner(message: string, className: string, icon: ThemeIcon, disposables: DisposableStore, openerService: IOpenerService): HTMLElement { +export function createMessageBanner(message: string, className: string, icon: ThemeIcon, disposables: DisposableStore, openerService: IOpenerService): HTMLElement { const banner = dom.$(`.${className}`); banner.appendChild(renderIcon(icon)); const markdown = new MarkdownString(message, { isTrusted: false, supportThemeIcons: true }); @@ -217,25 +210,3 @@ function appendCostSection(container: HTMLElement, pricing: string): void { costSection.appendChild(dom.$('span', undefined, localize('models.cost', "Cost: {0}", pricing))); container.appendChild(costSection); } - -function isHighCostCategory(priceCategory: string | undefined): boolean { - return priceCategory === 'high' || priceCategory === 'very_high'; -} - -function getCategoryLabel(category: string | undefined): string | undefined { - switch (category) { - case undefined: - case '': - return undefined; - case 'lightweight': - return localize('chat.category.lightweight', "Lightweight"); - case 'versatile': - return localize('chat.category.versatile', "Versatile"); - case 'powerful': - return localize('chat.category.powerful', "Powerful"); - default: - return typeof category === 'string' - ? category.charAt(0).toUpperCase() + category.slice(1) - : undefined; - } -} diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerItemPrimitives.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerItemPrimitives.ts index ab53effbe9136..50b2fb8ae5608 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerItemPrimitives.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerItemPrimitives.ts @@ -253,7 +253,7 @@ export function createUnavailableModelItem( group: { title: '', icon: ThemeIcon.fromId(Codicon.blank.id) }, disabled: true, hideIcon: false, - className: 'chat-model-picker-unavailable', + className: typeof description === 'string' ? 'chat-model-picker-unavailable' : 'chat-model-picker-unavailable has-link', section, hover: { content: hoverContent }, }; diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerItems.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerItems.ts index 90b19f830988c..6de39b879c8fd 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerItems.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerItems.ts @@ -42,6 +42,10 @@ export function getModelPickerControlModels( for (const [id, entry] of Object.entries(tier)) { if (entry.featured && availableModelIds.has(id)) { controlModels[id] = { ...entry, exists: true }; + } else if (entry.demoted && !controlModels[id]) { + // A demotion holds whoever is signed in, so it is not filtered away with + // the curated list the way a recommendation is. + controlModels[id] = { ...entry, exists: false }; } } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerLineage.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerLineage.ts new file mode 100644 index 0000000000000..e19dfbae90fe2 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerLineage.ts @@ -0,0 +1,78 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ILanguageModelChatMetadataAndIdentifier } from '../../../../common/languageModels.js'; + +/** A version token in a model id, e.g. the `5.6` in `example-5.6-sol`. */ +const VERSION_TOKEN = /^v?(\d+(?:\.\d+)*)$/; + +/** Where a model sits in its product line. */ +export interface IModelLine { + /** The line the model belongs to, e.g. `example-sol` for `example-5.6-sol`. */ + readonly line: string; + /** The version within that line, most significant part first. */ + readonly version: readonly number[]; +} + +/** + * Splits a model id into its product line and version by taking out the one token + * that reads as a version, e.g. `example-5.6-sol` is `example-sol` at 5.6. An id + * with no version token is a line of its own. + */ +export function parseModelLine(id: string): IModelLine { + const rest: string[] = []; + let version: readonly number[] | undefined; + for (const token of id.split('-')) { + const match = !version ? VERSION_TOKEN.exec(token) : undefined; + if (match) { + version = match[1].split('.').map(Number); + } else { + rest.push(token); + } + } + return { line: rest.join('-'), version: version ?? [] }; +} + +/** + * Tokens marking an early-access build. These are held out of the shortlist rather + * than listed by name, and stay selectable further down. + */ +const EARLY_ACCESS_TOKENS: ReadonlySet = new Set(['eap', 'experimental']); + +/** Whether the id marks an early-access build, which never leads the list. */ +export function isEarlyAccessModel(id: string): boolean { + return id.split('-').some(token => EARLY_ACCESS_TOKENS.has(token)); +} + +/** Orders two versions, longer runs of equal parts counting as newer. */ +function compareVersions(left: readonly number[], right: readonly number[]): number { + for (let i = 0; i < Math.max(left.length, right.length); i++) { + const difference = (left[i] ?? 0) - (right[i] ?? 0); + if (difference !== 0) { + return difference; + } + } + return 0; +} + +/** + * The newest model of each product line, which is the shortlist the picker leads with. + * Deriving it means a new version surfaces itself without anyone editing a list. + * Grouped by vendor too, since two providers can ship the same line name. + */ +export function latestOfEachLine( + models: readonly ILanguageModelChatMetadataAndIdentifier[], +): ILanguageModelChatMetadataAndIdentifier[] { + const newest = new Map(); + for (const model of models) { + const { line, version } = parseModelLine(model.metadata.id); + const key = `${model.metadata.vendor}/${line}`; + const current = newest.get(key); + if (!current || compareVersions(version, current.version) > 0) { + newest.set(key, { model, version }); + } + } + return [...newest.values()].map(entry => entry.model); +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerModelConfig.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerModelConfig.ts new file mode 100644 index 0000000000000..038547158b806 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerModelConfig.ts @@ -0,0 +1,104 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IAction } from '../../../../../../../base/common/actions.js'; +import { IStringDictionary } from '../../../../../../../base/common/collections.js'; +import { Event } from '../../../../../../../base/common/event.js'; +import { formatTokenCount } from '../../../../../../../base/common/numbers.js'; +import { ILanguageModelChatMetadataAndIdentifier, ILanguageModelConfigurationSchema } from '../../../../common/languageModels.js'; + +/** + * Read/write access to a model's configuration (e.g. context size, thinking + * effort). Implemented either by the global `ILanguageModelsService` or by + * a per-editor override layer so that one editor's changes do not sync to other + * already-open editors. Structurally satisfied by `ILanguageModelsService`. + */ +export interface IModelConfigurationAccess { + getModelConfiguration(modelId: string): IStringDictionary | undefined; + setModelConfiguration(modelId: string, values: IStringDictionary): Promise; + getModelConfigurationActions(modelId: string): IAction[]; + /** + * Fires when this access layer's configuration changes (e.g. user picks a + * new context size). Implementations that always read the global value can + * omit this and rely on `ILanguageModelsService.onDidChangeLanguageModels`. + */ + readonly onDidChange?: Event; +} + +/** The thinking effort group, or the routing tier for the Auto model. */ +export const MODEL_CONFIG_GROUP_EFFORT = 'navigation'; +/** The context window group: how much context the model is given. */ +export const MODEL_CONFIG_GROUP_CONTEXT = 'tokens'; + +export type IModelConfigPropertySchema = NonNullable[string]; + +/** One configurable property of a model, with the value currently in effect. */ +export interface IModelConfigProperty { + readonly key: string; + readonly value: unknown; + readonly schema: IModelConfigPropertySchema; +} + +/** + * The first property of a model's configuration schema belonging to `group` that + * offers a choice, with the user's value or the schema default. + */ +export function getModelConfigProperty( + model: ILanguageModelChatMetadataAndIdentifier | undefined, + configurationAccess: IModelConfigurationAccess, + group: string, +): IModelConfigProperty | undefined { + const properties = model?.metadata.configurationSchema?.properties; + if (!properties) { + return undefined; + } + const currentConfig = configurationAccess.getModelConfiguration(model.identifier) ?? {}; + for (const [key, schema] of Object.entries(properties)) { + if (schema.group !== group || !schema.enum?.length) { + continue; + } + return { key, value: currentConfig[key] ?? schema.default, schema }; + } + return undefined; +} + +/** The label an enum value is shown with, falling back to a formatted raw value. */ +export function getModelConfigValueLabel(schema: IModelConfigPropertySchema, value: unknown): string { + const index = schema.enum?.indexOf(value) ?? -1; + const label = index >= 0 ? schema.enumItemLabels?.[index] : undefined; + return label ?? (typeof value === 'number' ? formatTokenCount(value) : String(value)); +} + +/** + * Whether the context property is set to its largest value. Producers order the + * context enum from smallest window to largest, so the last entry is the + * extended one. + */ +export function isExtendedContext(property: IModelConfigProperty): boolean { + const values = property.schema.enum ?? []; + return values.length > 1 && property.value === values[values.length - 1]; +} + +/** + * A short read-out of the model settings the user changed, e.g. "Extra high · 1M". + * + * Only values that differ from the model's own defaults are named: a model left alone + * has nothing to report, so the read-out marks the models that were deliberately tuned + * rather than restating a default on every row. + */ +export function getModelConfigSummary( + model: ILanguageModelChatMetadataAndIdentifier | undefined, + configurationAccess: IModelConfigurationAccess, +): string | undefined { + const parts: string[] = []; + for (const group of [MODEL_CONFIG_GROUP_EFFORT, MODEL_CONFIG_GROUP_CONTEXT]) { + const property = getModelConfigProperty(model, configurationAccess, group); + if (!property || property.value === undefined || property.value === property.schema.default) { + continue; + } + parts.push(getModelConfigValueLabel(property.schema, property.value)); + } + return parts.length ? parts.join(' \u00b7 ') : undefined; +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerPresentation.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerPresentation.ts index f17ea326c9b2f..621e638015c51 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerPresentation.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerPresentation.ts @@ -34,6 +34,28 @@ export function getPriceCategoryLabel(priceCategory: string | undefined): string } } +export function isHighCostCategory(priceCategory: string | undefined): boolean { + return priceCategory === 'high' || priceCategory === 'very_high'; +} + +export function getCategoryLabel(category: string | undefined): string | undefined { + switch (category) { + case undefined: + case '': + return undefined; + case 'lightweight': + return localize('chat.category.lightweight', "Lightweight"); + case 'versatile': + return localize('chat.category.versatile', "Versatile"); + case 'powerful': + return localize('chat.category.powerful', "Powerful"); + default: + return typeof category === 'string' + ? category.charAt(0).toUpperCase() + category.slice(1) + : undefined; + } +} + export const enum ModelPickerUnavailableReason { Restricted = 'restricted', SetupRequired = 'setupRequired', diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerTabbedWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerTabbedWidget.ts new file mode 100644 index 0000000000000..6a9d012a3e899 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerTabbedWidget.ts @@ -0,0 +1,453 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IStringDictionary } from '../../../../../../../base/common/collections.js'; +import { Codicon } from '../../../../../../../base/common/codicons.js'; +import { Emitter } from '../../../../../../../base/common/event.js'; +import { Disposable, DisposableStore, IDisposable, MutableDisposable } from '../../../../../../../base/common/lifecycle.js'; +import { ThemeIcon } from '../../../../../../../base/common/themables.js'; +import { localize } from '../../../../../../../nls.js'; +import { ActionListItemKind, IActionListHeaderLink, IActionListItem } from '../../../../../../../platform/actionWidget/browser/actionList.js'; +import { IActionWidgetDropdownAction } from '../../../../../../../platform/actionWidget/browser/actionWidgetDropdown.js'; +import { ITabBarAction, ITabDescriptor, TabbedActionListWidget } from '../../../../../../../platform/actionWidget/browser/tabbedActionListWidget.js'; +import { IInstantiationService } from '../../../../../../../platform/instantiation/common/instantiation.js'; +import { IOpenerService } from '../../../../../../../platform/opener/common/opener.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../../../../platform/storage/common/storage.js'; +import { StateType } from '../../../../../../../platform/update/common/update.js'; +import { URI } from '../../../../../../../base/common/uri.js'; +import { IChatEntitlementService } from '../../../../../../services/chat/common/chatEntitlementService.js'; +import { ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService, IModelControlEntry } from '../../../../common/languageModels.js'; +import { withChatInputPickerMotion } from '../chatInputPickerActionItem.js'; +import { IModelConfigurationAccess } from './modelPickerModelConfig.js'; +import { ModelPickerAutoRow } from './modelPickerAutoRow.js'; +import { IPricingDisclosure, ModelCard } from './modelPickerCard.js'; +import { buildSpeedVariants, collapseSpeedVariants, IModelSpeedVariants } from './modelPickerVariants.js'; +import { getModelBadge } from './modelPickerBadges.js'; +import { createModelAction, createUnavailableModelItem, getUnavailableReason } from './modelPickerItemPrimitives.js'; +import { getModelPickerAccessibilityProvider } from './modelPickerItems.js'; +import { isAutoModel } from './modelPickerPresentation.js'; +import { buildModelPickerDestinations, buildModelPickerSections, getModelProviderLabel, hasPromotedModels, IModelPickerDestination, IModelPickerProviderPlaceholder, IModelPickerSections, IModelPickerUnavailableEntry, MODEL_PICKER_BUILT_IN_DESTINATION } from './modelPickerTabs.js'; +import { ModelPickerWelcome } from './modelPickerWelcome.js'; + +/** The collapsible section holding models that are neither pinned, recommended nor recent. */ +const OTHER_MODELS_SECTION = 'other'; +const PICKER_WIDTH = 320; +const PRICING_EXPANDED_STORAGE_KEY = 'chat.modelPicker.pricingExpanded'; + +/** Everything the picker needs for one showing, gathered by the owning widget. */ +export interface ITabbedModelPickerContext { + readonly models: readonly ILanguageModelChatMetadataAndIdentifier[]; + readonly selectedModelId: string | undefined; + readonly recentModelIds: readonly string[]; + readonly pinnedModelIds: readonly string[]; + readonly controlModels: IStringDictionary; + readonly configurationAccess: IModelConfigurationAccess; + /** Whether the account is billed by credits, which is when cost numbers are shown. */ + readonly isUBB: boolean; + readonly showManageModels: boolean; + /** + * What it takes to unlock a curated model the user cannot select yet, used to + * offer the upgrade, admin or update path instead of simply omitting the model. + */ + readonly unavailableContext: { + readonly show: boolean; + readonly currentVSCodeVersion: string; + readonly manageSettingsUrl: string | undefined; + readonly updateStateType: StateType; + }; + /** Reports a click on an upgrade or contact-admin link in an unavailable model row. */ + readonly onUnavailableLinkClick: (uri: URI) => void; + /** Providers the user can add models from but has none from yet, e.g. one that needs signing in. */ + readonly providerPlaceholders: readonly IModelPickerProviderPlaceholder[]; + readonly onSelect: (model: ILanguageModelChatMetadataAndIdentifier) => void; + readonly onTogglePin: ((modelIdentifier: string, pinned: boolean) => void) | undefined; + readonly onManageModels: () => void; + /** Reports a configuration change made from a model's detail card. */ + readonly onConfigurationChanged: (model: ILanguageModelChatMetadataAndIdentifier, group: string, key: string, fromValue: unknown, toValue: unknown) => void; + /** Warning banner shown when switching options mid-session would reset the prompt cache. */ + readonly cacheBreakHint: { readonly text: string; readonly link: IActionListHeaderLink | undefined; readonly dismiss: () => void } | undefined; +} + +/** + * A provider-tabbed model picker with a detail card beside the hovered model and an + * Auto row pinned below. With only the built-in provider there is no tab bar. + */ +export class TabbedModelPicker extends Disposable { + + private readonly _onDidHide = this._register(new Emitter()); + readonly onDidHide = this._onDidHide.event; + + private readonly _widget: TabbedActionListWidget; + private readonly _cards = this._register(new DisposableStore()); + private readonly _autoRow = this._register(new MutableDisposable()); + private readonly _onDidChangePricingDisclosure = this._register(new Emitter()); + /** Shared by every card, and remembered, so the breakdown is opened once rather than per model. */ + private readonly _pricingDisclosure: IPricingDisclosure = { + isExpanded: () => this._storageService.getBoolean(PRICING_EXPANDED_STORAGE_KEY, StorageScope.APPLICATION, false), + setExpanded: expanded => { + this._storageService.store(PRICING_EXPANDED_STORAGE_KEY, expanded, StorageScope.APPLICATION, StorageTarget.USER); + this._onDidChangePricingDisclosure.fire(); + }, + onDidChange: this._onDidChangePricingDisclosure.event, + }; + + private _context: ITabbedModelPickerContext | undefined; + private _anchor: HTMLElement | undefined; + private _activeDestination: string | undefined; + private _searchVisible = false; + private _speedVariants: ReadonlyMap = new Map(); + /** The model to fall back to when Auto is switched off. */ + private _lastExplicitModelId: string | undefined; + + get isVisible(): boolean { + return this._widget.isVisible; + } + + constructor( + @IInstantiationService instantiationService: IInstantiationService, + @IChatEntitlementService private readonly _entitlementService: IChatEntitlementService, + @ILanguageModelsService private readonly _languageModelsService: ILanguageModelsService, + @IOpenerService private readonly _openerService: IOpenerService, + @IStorageService private readonly _storageService: IStorageService, + ) { + super(); + this._widget = this._register(instantiationService.createInstance(TabbedActionListWidget)); + this._register(this._widget.onDidChangeTab(id => { this._activeDestination = id; })); + this._register(this._widget.onDidHide(() => this._onDidHide.fire())); + } + + hide(): void { + this._widget.hide(); + } + + show(anchor: HTMLElement, context: ITabbedModelPickerContext): void { + this._anchor = anchor; + this._context = context; + if (context.selectedModelId && !this._isAutoSelected(context)) { + this._lastExplicitModelId = context.selectedModelId; + } + this._showCurrent(); + } + + private _showCurrent(): void { + const context = this._context; + const anchor = this._anchor; + if (!context || !anchor) { + return; + } + + this._speedVariants = buildSpeedVariants(context.models); + const listModels = collapseSpeedVariants(context.models, this._speedVariants, context.selectedModelId); + const destinations = buildModelPickerDestinations(listModels, this._languageModelsService, context.providerPlaceholders); + if (!destinations.length) { + return; + } + if (!this._activeDestination || !destinations.some(destination => destination.id === this._activeDestination)) { + this._activeDestination = this._destinationForSelectedModel(destinations, context) ?? destinations[0].id; + } + + const autoModel = context.models.find(isAutoModel); + this._widget.show({ + user: 'ChatTabbedModelPicker', + anchor, + tabs: destinations.map((destination): ITabDescriptor => ({ id: destination.id, label: destination.label, icon: destination.icon, tooltip: destination.label })), + initialTab: this._activeDestination, + tabBarActions: this._buildTabBarActions(context), + tabBarClassName: 'chat-model-picker-tabbar', + // Recomputed on every render so a tab switch reflects the current Auto state. + widgetClassNames: () => [ + 'chat-model-picker-widget', + ...(this._isAutoSelected(this._context ?? context) ? ['auto-enabled'] : []), + ...(this._searchVisible ? ['search-mode'] : []), + ], + tabLabels: 'active', + filterInTabBar: true, + width: PICKER_WIDTH, + createActionList: activeTab => { + this._cards.clear(); + const current = this._context ?? context; + const destination = destinations.find(candidate => candidate.id === activeTab) ?? destinations[0]; + const sections = this._buildSections(destination, current); + // Search spans every destination at once, so each model names its provider. + const items = this._searchVisible + ? destinations.flatMap(candidate => this._buildSearchItems(candidate, current)) + : this._buildItems(destination, sections, current); + return { + items, + listOptions: withChatInputPickerMotion({ + className: 'chat-model-picker-dropdown chat-model-picker-tabbed', + showFilter: this._searchVisible, + filterPlaceholder: localize('chat.modelPicker.search', "Search models"), + focusFilterOnOpen: this._searchVisible, + headerText: current.cacheBreakHint?.text, + headerIcon: current.cacheBreakHint ? Codicon.info : undefined, + headerLink: current.cacheBreakHint?.link, + headerDismiss: current.cacheBreakHint?.dismiss, + // A tab with nothing promoted would open on an empty list, so leave it expanded. + collapsedByDefault: hasPromotedModels(sections) ? new Set([OTHER_MODELS_SECTION]) : undefined, + linkHandler: uri => current.onUnavailableLinkClick(uri), + maxWidth: PICKER_WIDTH, + hideDefaultKeybindingTooltip: true, + // Rows lose their chevron while Auto is on, so hold the gutter open. + reserveSubmenuSpace: 'always', + }), + }; + }, + renderEmpty: (container, activeTab) => { + const destination = destinations.find(candidate => candidate.id === activeTab); + if (!destination?.placeholders.length) { + return undefined; + } + const welcome = new ModelPickerWelcome(destination); + container.appendChild(welcome.element); + return welcome; + }, + renderFooter: autoModel ? container => this._renderAutoRow(container, autoModel, context) : undefined, + delegate: { + onSelect: action => { + void action.run(); + this._widget.hide(); + }, + onHide: () => { }, + }, + accessibilityProvider: getModelPickerAccessibilityProvider(), + }); + } + + private _isAutoSelected(context: ITabbedModelPickerContext): boolean { + const selected = context.models.find(model => model.identifier === context.selectedModelId); + return !!selected && isAutoModel(selected); + } + + private _destinationForSelectedModel(destinations: readonly IModelPickerDestination[], context: ITabbedModelPickerContext): string | undefined { + return destinations.find(destination => destination.models.some(model => model.identifier === context.selectedModelId))?.id; + } + + private _buildSections(destination: IModelPickerDestination, context: ITabbedModelPickerContext): IModelPickerSections { + const isBuiltIn = destination.id === MODEL_PICKER_BUILT_IN_DESTINATION; + return buildModelPickerSections({ + models: destination.models, + selectedModelId: context.selectedModelId, + recentModelIds: context.recentModelIds, + pinnedModelIds: context.pinnedModelIds, + controlModels: context.controlModels, + // Only the built-in provider curates a shortlist. A provider the user added + // gets a tab of its own, which is already the whole of what it offers. + showSuggested: isBuiltIn, + // Only the built-in provider has a curated catalogue to compare against. + showUnavailable: isBuiltIn && context.unavailableContext.show, + currentVSCodeVersion: context.unavailableContext.currentVSCodeVersion, + }); + } + + private _buildTabBarActions(context: ITabbedModelPickerContext): ITabBarAction[] { + const actions: ITabBarAction[] = []; + // Hidden while searching, when the filter takes the tab strip's place. + if (context.showManageModels && !this._searchVisible) { + actions.push({ + id: 'addProvider', + icon: Codicon.add, + tooltip: localize('chat.modelPicker.addProvider', "Add Models..."), + run: () => { + this._widget.hide(); + context.onManageModels(); + }, + }); + } + actions.push({ + id: 'search', + icon: Codicon.search, + tooltip: localize('chat.modelPicker.searchToggle', "Search Models"), + alignEnd: true, + checked: this._searchVisible, + run: () => { + this._searchVisible = !this._searchVisible; + this._showCurrent(); + }, + }); + return actions; + } + + private _buildItems(destination: IModelPickerDestination, sections: IModelPickerSections, context: ITabbedModelPickerContext): IActionListItem[] { + // A plan that grants only Auto still lists the models it could unlock, so the + // welcome body is reserved for having genuinely nothing to say. + if (!destination.models.length && !sections.unavailable.length) { + return []; + } + const items: IActionListItem[] = []; + const appendSection = ( + label: string | undefined, + models: readonly ILanguageModelChatMetadataAndIdentifier[], + unavailable: readonly IModelPickerUnavailableEntry[] = [], + ) => { + if (!models.length && !unavailable.length) { + return; + } + // An unlabelled run still needs a rule when something precedes it. + if (label || items.length) { + items.push({ kind: ActionListItemKind.Separator, label }); + } + for (const model of models) { + items.push(this._createModelItem(model, context, undefined)); + } + // Listed after the models that can be picked, so the section leads with what works. + for (const { id, entry, needsUpdate } of unavailable) { + const { unavailableContext } = context; + const reason = needsUpdate ? 'update' : getUnavailableReason(entry, this._entitlementService, unavailableContext.currentVSCodeVersion); + items.push(createUnavailableModelItem( + id, + entry, + reason, + unavailableContext.manageSettingsUrl, + unavailableContext.updateStateType, + this._entitlementService, + )); + } + }; + + appendSection(localize('chat.modelPicker.pinned', "Pinned"), sections.pinned); + // The shortlist is the default state, so it goes unlabelled. + appendSection(undefined, sections.suggested, sections.unavailable); + + if (sections.other.length) { + const collapsible = hasPromotedModels(sections); + const section = collapsible ? OTHER_MODELS_SECTION : undefined; + if (collapsible) { + const label = localize('chat.modelPicker.otherModels', "Other Models"); + const count = sections.other.length; + items.push({ + item: { id: 'otherModels', enabled: true, checked: false, class: undefined, tooltip: label, label, run: () => { } }, + kind: ActionListItemKind.Action, + label, + badge: String(count), + ariaDescription: localize('chat.modelPicker.otherModelsCount', "{0} more models", count), + group: { title: '', icon: Codicon.chevronDown }, + hideIcon: false, + section: OTHER_MODELS_SECTION, + isSectionToggle: true, + className: 'chat-model-picker-section-toggle', + }); + } + for (const model of sections.other) { + items.push(this._createModelItem(model, context, section)); + } + } + return items; + } + + /** + * Every model in one destination as flat rows, for searching. Sections would only + * get in the way of a result list, but each row still names its provider. + */ + private _buildSearchItems(destination: IModelPickerDestination, context: ITabbedModelPickerContext): IActionListItem[] { + return destination.models + .slice() + .sort((left, right) => left.metadata.name.localeCompare(right.metadata.name)) + .map(model => this._createModelItem(model, context, undefined, getModelProviderLabel(model, this._languageModelsService))); + } + + private _createModelItem( + model: ILanguageModelChatMetadataAndIdentifier, + context: ITabbedModelPickerContext, + section?: string, + providerLabel?: string, + ): IActionListItem { + const { action, ariaDescription } = createModelAction(model, context.selectedModelId, context.onSelect, section, true); + const badge = getModelBadge(model, { configurationAccess: context.configurationAccess, providerLabel }); + // While Auto is choosing, a model's settings do not apply, so the card that edits + // them stays shut. The row is still selectable, which is what turns Auto off. + const autoEnabled = this._isAutoSelected(context); + // Built when the panel first opens: only one card is ever visible, so building + // one per row would render the whole list's worth of markdown and controls. + const createCard = () => this._cards.add(new ModelCard({ + model, + configurationAccess: context.configurationAccess, + isUBB: context.isUBB, + openerService: this._openerService, + isPinned: context.pinnedModelIds.includes(model.identifier), + pricingDisclosure: this._pricingDisclosure, + speedVariants: this._speedVariants.get(model.identifier), + onSelectVariant: next => { + context.onSelect(next); + this._widget.hide(); + }, + onTogglePin: context.onTogglePin + ? pinned => { + context.onTogglePin?.(model.identifier, pinned); + // Closes like every other action in the card, so the card is not left + // open over a list that has since reordered itself. + this._widget.hide(); + } + : undefined, + onDidChangeConfiguration: (group, key, fromValue, toValue) => { + context.onConfigurationChanged(model, group, key, fromValue, toValue); + // Configuring a model is a choice of it: the settings only take effect on the + // model they belong to, so tuning one and leaving another selected would + // discard the change the user just made. + if (model.identifier !== context.selectedModelId) { + context.onSelect(model); + } + this._widget.hide(); + }, + })).element; + return { + item: action, + kind: ActionListItemKind.Action, + label: action.label, + description: badge ? undefined : action.description, + badge: badge?.text, + ariaDescription, + group: { title: '', icon: action.icon ?? ThemeIcon.fromId(action.checked ? Codicon.check.id : Codicon.blank.id) }, + hideIcon: false, + section, + className: badge ? `chat-model-picker-badge-${badge.tone}` : undefined, + hover: autoEnabled ? undefined : { content: createCard, expandable: true, panelClassName: 'chat-model-card-panel' }, + tooltip: action.tooltip, + }; + } + + private _renderAutoRow(container: HTMLElement, autoModel: ILanguageModelChatMetadataAndIdentifier, context: ITabbedModelPickerContext): IDisposable { + const row = new ModelPickerAutoRow({ + autoModel, + configurationAccess: context.configurationAccess, + isEnabled: () => this._isAutoSelected(this._context ?? context), + onToggle: enabled => this._toggleAuto(enabled, autoModel), + }); + this._autoRow.value = row; + container.appendChild(row.element); + return row; + } + + private _toggleAuto(enabled: boolean, autoModel: ILanguageModelChatMetadataAndIdentifier): void { + const context = this._context; + if (!context) { + return; + } + const next = enabled ? autoModel : this._fallbackModel(context); + if (!next) { + // Auto is the only model, so there is nothing to switch back to. + this._autoRow.value?.render(); + return; + } + context.onSelect(next); + this._context = { ...context, selectedModelId: next.identifier }; + // Updated in place rather than re-shown: rebuilding the popup would move focus + // off the switch the user just clicked, and can dismiss it outright. + this._widget.refreshActiveList(); + this._autoRow.value?.render(); + } + + /** The model to select when Auto is switched off: the last explicit pick, else the most recent one. */ + private _fallbackModel(context: ITabbedModelPickerContext): ILanguageModelChatMetadataAndIdentifier | undefined { + const candidates = [this._lastExplicitModelId, ...context.recentModelIds, ...context.pinnedModelIds]; + for (const id of candidates) { + const model = context.models.find(candidate => candidate.identifier === id); + if (model && !isAutoModel(model)) { + return model; + } + } + return context.models.find(model => !isAutoModel(model)); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerTabs.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerTabs.ts new file mode 100644 index 0000000000000..f333a75ac8667 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerTabs.ts @@ -0,0 +1,309 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IStringDictionary } from '../../../../../../../base/common/collections.js'; +import { ThemeIcon } from '../../../../../../../base/common/themables.js'; +import { isDefined } from '../../../../../../../base/common/types.js'; +import { localize } from '../../../../../../../nls.js'; +import { COPILOT_VENDOR_ID, ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService, IModelControlEntry } from '../../../../common/languageModels.js'; +import { buildModelToProviderGroupMap, getProviderGroupForModel, getProviderGroupKey, isVersionAtLeast } from './modelPickerItemPrimitives.js'; +import { isDeprecated } from './modelPickerBadges.js'; +import { isEarlyAccessModel, latestOfEachLine } from './modelPickerLineage.js'; +import { getProviderIconForIdentity } from './modelProviderIcons.js'; +import { isAutoModel } from './modelPickerPresentation.js'; + +/** The built-in provider's models. */ +export const MODEL_PICKER_BUILT_IN_DESTINATION = 'builtIn'; +/** Prefix for the destination each provider the user added gets. */ +const PROVIDER_DESTINATION_PREFIX = 'provider:'; + +/** A provider the user can add models from but has none from yet, e.g. one that needs signing in. */ +export interface IModelPickerProviderPlaceholder { + readonly vendor: string; + readonly label: string; + /** Why there are no models, shown under the provider name. */ + readonly message: string; + /** Optional call to action, e.g. "Sign in". */ + readonly action?: { readonly label: string; readonly run: () => void }; +} + +/** + * One tab of the picker: the built-in provider, or one the user added. Each names a + * single provider, so a tab's models never need to say where they came from. + */ +export interface IModelPickerDestination { + readonly id: string; + /** Names the destination in the tab tooltip and as the list heading. */ + readonly label: string; + readonly icon: ThemeIcon; + readonly models: readonly ILanguageModelChatMetadataAndIdentifier[]; + /** Providers with nothing to list yet, shown as a welcome body when there are no models. */ + readonly placeholders: readonly IModelPickerProviderPlaceholder[]; +} + +/** + * A model the picker names but the user cannot select yet: their plan does not + * include it, their administrator disabled it, or this build is too old. + */ +export interface IModelPickerUnavailableEntry { + readonly id: string; + readonly entry: IModelControlEntry; + /** The model exists for this account but needs a newer VS Code. */ + readonly needsUpdate: boolean; +} + +/** The rows of one destination, in the order they are shown. */ +export interface IModelPickerSections { + /** The models the user marked, shown under their own heading. */ + readonly pinned: readonly ILanguageModelChatMetadataAndIdentifier[]; + /** The shortlist the picker leads with, shown unlabelled as the body of the list. */ + readonly suggested: readonly ILanguageModelChatMetadataAndIdentifier[]; + /** Everything not promoted above, which folds away when there is a shortlist. */ + readonly other: readonly ILanguageModelChatMetadataAndIdentifier[]; + /** Curated models the user cannot select yet, shown alongside the recommended ones. */ + readonly unavailable: readonly IModelPickerUnavailableEntry[]; +} + +/** + * Vendor ids that are the built-in provider under another name. Its models reach the + * picker from the extension, from the CLI harness, and as agent-host copies, and each + * of those names a different vendor. + */ +const BUILT_IN_GROUP_IDS: ReadonlySet = new Set([COPILOT_VENDOR_ID, 'copilotcli']); + +/** + * Whether the user brought this model themselves rather than getting it from the + * built-in provider. + * + * This follows the provider group, the same thing the picker names a model's source by, + * rather than the BYOK flags: a host that forwards the built-in provider's models sets + * those flags on every model it relays, which would file the whole catalogue under the + * user's own models. + */ +export function isUserProvidedModel( + model: ILanguageModelChatMetadataAndIdentifier, + languageModelsService: ILanguageModelsService, +): boolean { + const groupId = model.metadata.modelGroup?.id ?? model.metadata.vendor; + if (BUILT_IN_GROUP_IDS.has(groupId)) { + return false; + } + return groupId !== languageModelsService.getVendors().find(vendor => vendor.isDefault)?.vendor; +} + +/** The provider a model came from, as shown in group headings. */ +export function getModelProviderLabel( + model: ILanguageModelChatMetadataAndIdentifier, + languageModelsService: ILanguageModelsService, + modelToGroup = buildModelToProviderGroupMap(languageModelsService), +): string { + return getProviderGroupForModel(model, modelToGroup, languageModelsService).groupName; +} + +/** + * Splits models into one destination per provider: the built-in one first, then each + * provider the user added, by name. Auto is left out because it has its own row, and + * empty providers are dropped so the common case yields no tab bar. + */ +export function buildModelPickerDestinations( + models: readonly ILanguageModelChatMetadataAndIdentifier[], + languageModelsService: ILanguageModelsService, + placeholders: readonly IModelPickerProviderPlaceholder[] = [], +): IModelPickerDestination[] { + const builtInModels: ILanguageModelChatMetadataAndIdentifier[] = []; + const userModels: ILanguageModelChatMetadataAndIdentifier[] = []; + for (const model of models) { + if (isAutoModel(model)) { + continue; + } + (isUserProvidedModel(model, languageModelsService) ? userModels : builtInModels).push(model); + } + + const builtInVendor = languageModelsService.getVendors().find(vendor => vendor.isDefault); + const builtInVendorId = builtInVendor?.vendor ?? COPILOT_VENDOR_ID; + const builtInLabel = builtInVendor?.displayName ?? localize('chat.modelPicker.builtInProvider', "GitHub Copilot"); + const builtInPlaceholders = placeholders.filter(placeholder => placeholder.vendor === builtInVendorId); + const userPlaceholders = placeholders.filter(placeholder => placeholder.vendor !== builtInVendorId); + + // The built-in destination stands even with nothing to list: a plan that only grants + // Auto still needs somewhere to show it, and its curated models still need to name + // the upgrade that would unlock them. + const hasAutoModel = models.some(isAutoModel); + const destinations: IModelPickerDestination[] = []; + if (builtInModels.length || builtInPlaceholders.length || hasAutoModel) { + destinations.push({ + id: MODEL_PICKER_BUILT_IN_DESTINATION, + label: builtInLabel, + icon: getProviderIconForIdentity(`${builtInLabel} ${builtInVendorId}`), + models: builtInModels, + placeholders: builtInPlaceholders, + }); + } + + const modelToGroup = buildModelToProviderGroupMap(languageModelsService); + // Keyed by provider identity rather than by display name, so two providers that + // happen to share a name each keep their own tab instead of being merged. + const byProvider = new Map(); + const providerEntry = (key: string, label: string) => { + let entry = byProvider.get(key); + if (!entry) { + entry = { label, models: [], placeholders: [] }; + byProvider.set(key, entry); + } + return entry; + }; + for (const model of userModels) { + const { vendor, groupName } = getProviderGroupForModel(model, modelToGroup, languageModelsService); + providerEntry(getProviderGroupKey(vendor, groupName), groupName).models.push(model); + } + // A provider still signing in has no models yet, but it has earned its tab. + for (const placeholder of userPlaceholders) { + providerEntry(getProviderGroupKey(placeholder.vendor, placeholder.label), placeholder.label).placeholders.push(placeholder); + } + // Ordered by the name the user reads, with the key breaking ties so that providers + // sharing a name keep a stable order. + const sortedProviders = [...byProvider].sort(([leftKey, left], [rightKey, right]) => + left.label.localeCompare(right.label) || leftKey.localeCompare(rightKey)); + for (const [key, entry] of sortedProviders) { + destinations.push({ + id: `${PROVIDER_DESTINATION_PREFIX}${key}`, + label: entry.label, + icon: getProviderIconForIdentity(entry.label), + models: entry.models, + placeholders: entry.placeholders, + }); + } + return destinations; +} + +export interface IModelPickerSectionsOptions { + readonly models: readonly ILanguageModelChatMetadataAndIdentifier[]; + readonly selectedModelId: string | undefined; + readonly recentModelIds: readonly string[]; + readonly pinnedModelIds: readonly string[]; + readonly controlModels: IStringDictionary; + /** Whether the destination has a curated shortlist to lead with. Only the built-in provider curates one. */ + readonly showSuggested: boolean; + /** Whether to name curated models the user cannot select yet. Off by default. */ + readonly showUnavailable?: boolean; + /** This build's version, used to spot models gated behind a newer VS Code. */ + readonly currentVSCodeVersion?: string; +} + +/** + * Splits a destination's models into favourites, the shortlist to lead with, and the + * rest. Each model appears once, and the selected model is never folded into the rest. + */ +export function buildModelPickerSections(options: IModelPickerSectionsOptions): IModelPickerSections { + // A model this build is too old to run is kept out of every selectable section and + // surfaced only as the update it needs. + const unavailable = buildUnavailableEntries(options); + const gated = new Set(unavailable.filter(entry => entry.needsUpdate).map(entry => entry.id)); + const selectable = gated.size === 0 + ? options.models + : options.models.filter(model => !gated.has(model.metadata.id) && !gated.has(model.identifier)); + + const byIdentifier = new Map(selectable.map(model => [model.identifier, model])); + const byMetadataId = new Map(selectable.map(model => [model.metadata.id, model])); + const placed = new Set(); + const take = (id: string | undefined): ILanguageModelChatMetadataAndIdentifier | undefined => { + const model = id ? byIdentifier.get(id) ?? byMetadataId.get(id) : undefined; + if (!model || placed.has(model.identifier)) { + return undefined; + } + placed.add(model.identifier); + return model; + }; + + const pinned = options.pinnedModelIds.map(take).filter(isDefined); + + const suggested: ILanguageModelChatMetadataAndIdentifier[] = []; + if (options.showSuggested) { + for (const model of options.models) { + if (!model.metadata.promo) { + continue; + } + // Resolved through `take`, which draws only from the selectable models: an + // offer on a model this build is too old to run is surfaced as the update + // it needs rather than as a row that cannot be picked. + const promoted = take(model.identifier); + if (promoted) { + suggested.push(promoted); + } + } + // The newest model of each line leads. A line replaced by a different line rather + // than by a newer version of itself is marked demoted instead. + for (const model of latestOfEachLine(selectable)) { + if (isEarlyAccessModel(model.metadata.id) || options.controlModels[model.metadata.id]?.demoted) { + continue; + } + const latest = take(model.identifier); + if (latest) { + suggested.push(latest); + } + } + // The model in use is never folded away, however the catalogue rates it. + const selected = take(options.selectedModelId); + if (selected) { + suggested.push(selected); + } + } + + const byName = (left: ILanguageModelChatMetadataAndIdentifier, right: ILanguageModelChatMetadataAndIdentifier) => + left.metadata.name.localeCompare(right.metadata.name); + // A time-limited offer leads the shortlist. + const byPromoThenName = (left: ILanguageModelChatMetadataAndIdentifier, right: ILanguageModelChatMetadataAndIdentifier) => + (hasPromo(right) ? 1 : 0) - (hasPromo(left) ? 1 : 0) || byName(left, right); + // A retiring model stays pickable but sinks to the end. + const byRetiringThenName = (left: ILanguageModelChatMetadataAndIdentifier, right: ILanguageModelChatMetadataAndIdentifier) => + (isDeprecated(left) ? 1 : 0) - (isDeprecated(right) ? 1 : 0) || byName(left, right); + const rest = selectable.filter(model => !placed.has(model.identifier)).sort(byRetiringThenName); + + return { + pinned: pinned.sort(byName), + suggested: suggested.sort(byPromoThenName), + other: rest, + unavailable, + }; +} + +/** Whether the model carries an offer worth leading with. */ +function hasPromo(model: ILanguageModelChatMetadataAndIdentifier): boolean { + return ILanguageModelChatMetadata.hasPromoDiscount(model.metadata); +} + +/** + * Curated models with no usable entry here, because the account has no access or this + * build is too old. Named so the path to unlocking them stays visible. + */ +function buildUnavailableEntries(options: IModelPickerSectionsOptions): IModelPickerUnavailableEntry[] { + if (!options.showUnavailable) { + return []; + } + const present = new Set(options.models.flatMap(model => [model.identifier, model.metadata.id])); + const entries: IModelPickerUnavailableEntry[] = []; + for (const [id, entry] of Object.entries(options.controlModels)) { + if (!entry.featured) { + continue; + } + const outOfDate = isOutOfDate(entry, options.currentVSCodeVersion); + // A model that is here but gated needs an update; one that is missing entirely + // needs whatever its account is short of. + if (present.has(id) ? outOfDate : !entry.exists) { + entries.push({ id, entry, needsUpdate: outOfDate }); + } + } + return entries.sort((left, right) => left.entry.label.localeCompare(right.entry.label)); +} + +/** Whether the entry names a minimum VS Code version this build does not meet. */ +function isOutOfDate(entry: IModelControlEntry, currentVSCodeVersion: string | undefined): boolean { + return !!entry.minVSCodeVersion && !!currentVSCodeVersion && !isVersionAtLeast(currentVSCodeVersion, entry.minVSCodeVersion); +} + +/** Whether the destination leads with a shortlist that the rest can fold away behind. */ +export function hasPromotedModels(sections: IModelPickerSections): boolean { + return sections.suggested.length > 0; +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerTelemetry.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerTelemetry.ts new file mode 100644 index 0000000000000..0d9ec2a16aba5 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerTelemetry.ts @@ -0,0 +1,73 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ITelemetryService } from '../../../../../../../platform/telemetry/common/telemetry.js'; +import { TelemetryTrustedValue } from '../../../../../../../platform/telemetry/common/telemetryUtils.js'; +import { COPILOT_VENDOR_ID, ILanguageModelChatMetadataAndIdentifier } from '../../../../common/languageModels.js'; +import { MODEL_CONFIG_GROUP_CONTEXT, MODEL_CONFIG_GROUP_EFFORT } from './modelPickerModelConfig.js'; + +type ChatThinkingEffortChangeClassification = { + owner: 'lramos15'; + comment: 'Reporting when a model configuration value (e.g. thinking effort, or the Auto routing tier) is changed'; + model: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The model the configuration was changed for' }; + property: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The first-party configuration property that was changed (reasoningEffort, or tier for the Auto model); "unknown" for third-party providers, which choose their own keys' }; + fromValue: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The previous value of the configuration property' }; + toValue: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The new value of the configuration property' }; +}; + +type ChatThinkingEffortChangeEvent = { + model: string | TelemetryTrustedValue; + property: string; + fromValue: string; + toValue: string; +}; + +type ChatContextSizeChangeClassification = { + owner: 'lramos15'; + comment: 'Reporting when the context window size is changed'; + model: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The model the context size was changed for' }; + fromValue: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The previous context size value' }; + toValue: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The new context size value' }; +}; + +type ChatContextSizeChangeEvent = { + model: string | TelemetryTrustedValue; + fromValue: string; + toValue: string; +}; + +/** + * Reports a model configuration change. Shared by both model pickers so the same + * change reports identically wherever the user makes it. + */ +export function logModelConfigurationChange( + telemetryService: ITelemetryService, + model: ILanguageModelChatMetadataAndIdentifier, + group: string, + key: string, + fromValue: unknown, + toValue: unknown, +): void { + // Third-party providers choose their own property keys and model ids, so only + // first-party ones are reported as a controlled vocabulary. + const isFirstParty = model.metadata.vendor === COPILOT_VENDOR_ID; + const modelValue = isFirstParty ? new TelemetryTrustedValue(model.identifier) : 'unknown'; + if (group === MODEL_CONFIG_GROUP_CONTEXT) { + telemetryService.publicLog2('chat.contextSizeChange', { + model: modelValue, + fromValue: String(fromValue ?? ''), + toValue: String(toValue), + }); + return; + } + if (group === MODEL_CONFIG_GROUP_EFFORT) { + telemetryService.publicLog2('chat.thinkingEffortChange', { + model: modelValue, + property: isFirstParty ? key : 'unknown', + fromValue: String(fromValue ?? ''), + toValue: String(toValue), + }); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerVariants.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerVariants.ts new file mode 100644 index 0000000000000..1d44de78400f3 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerVariants.ts @@ -0,0 +1,73 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ILanguageModelChatMetadataAndIdentifier } from '../../../../common/languageModels.js'; + +/** + * The suffix the provider adds to a model id to name its faster twin, e.g. + * `example-2.5` and `example-2.5-fast`. + */ +const FAST_VARIANT_SUFFIX = '-fast'; + +/** A model and the faster twin of it that the provider also offers. */ +export interface IModelSpeedVariants { + readonly standard: ILanguageModelChatMetadataAndIdentifier; + readonly fast: ILanguageModelChatMetadataAndIdentifier; +} + +/** + * Pairs each model with its faster twin, keyed by both of their identifiers. Reads + * model ids rather than display names, since only the id is the provider's own + * identifier. A stopgap until models describe the relationship themselves. + */ +export function buildSpeedVariants( + models: readonly ILanguageModelChatMetadataAndIdentifier[], +): ReadonlyMap { + // Keyed by vendor as well, so two providers offering the same model id are not paired. + const key = (model: ILanguageModelChatMetadataAndIdentifier, id: string) => `${model.metadata.vendor}/${id}`; + const byModelId = new Map(); + for (const model of models) { + byModelId.set(key(model, model.metadata.id), model); + } + + const pairs = new Map(); + for (const model of models) { + const id = model.metadata.id; + if (!id.endsWith(FAST_VARIANT_SUFFIX)) { + continue; + } + const standard = byModelId.get(key(model, id.slice(0, -FAST_VARIANT_SUFFIX.length))); + if (!standard) { + continue; + } + const pair: IModelSpeedVariants = { standard, fast: model }; + pairs.set(standard.identifier, pair); + pairs.set(model.identifier, pair); + } + return pairs; +} + +/** + * Drops the twin that is not in use, so a pair takes one row rather than two. The + * selected twin is the one kept, since a picker that hides the current choice cannot + * be read as showing it. + */ +export function collapseSpeedVariants( + models: readonly ILanguageModelChatMetadataAndIdentifier[], + variants: ReadonlyMap, + selectedModelId: string | undefined, +): ILanguageModelChatMetadataAndIdentifier[] { + if (!variants.size) { + return [...models]; + } + return models.filter(model => { + const pair = variants.get(model.identifier); + if (!pair) { + return true; + } + const inUse = selectedModelId === pair.fast.identifier ? pair.fast : pair.standard; + return model.identifier === inUse.identifier; + }); +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerWelcome.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerWelcome.ts new file mode 100644 index 0000000000000..c362d1dd79257 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerWelcome.ts @@ -0,0 +1,39 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../../../../base/browser/dom.js'; +import { Button } from '../../../../../../../base/browser/ui/button/button.js'; +import { DisposableStore } from '../../../../../../../base/common/lifecycle.js'; +import { ThemeIcon } from '../../../../../../../base/common/themables.js'; +import { defaultButtonStyles } from '../../../../../../../platform/theme/browser/defaultStyles.js'; +import { getProviderIconForIdentity } from './modelProviderIcons.js'; +import { IModelPickerDestination } from './modelPickerTabs.js'; + +/** + * The body shown when a destination has no models: each waiting provider's icon + * and name, why it is empty, and the action that fills it, e.g. signing in. + */ +export class ModelPickerWelcome extends DisposableStore { + + readonly element = dom.$('.chat-model-picker-welcome'); + + constructor(destination: IModelPickerDestination) { + super(); + for (const placeholder of destination.placeholders) { + const icon = destination.placeholders.length === 1 + ? destination.icon + : getProviderIconForIdentity(`${placeholder.label} ${placeholder.vendor}`); + const entry = dom.append(this.element, dom.$('.chat-model-picker-welcome-provider')); + dom.append(entry, dom.$(`span.chat-model-picker-welcome-icon${ThemeIcon.asCSSSelector(icon)}`)); + dom.append(entry, dom.$('.chat-model-picker-welcome-title', undefined, placeholder.label)); + dom.append(entry, dom.$('.chat-model-picker-welcome-message', undefined, placeholder.message)); + if (placeholder.action) { + const button = this.add(new Button(entry, { ...defaultButtonStyles, title: placeholder.action.label })); + button.label = placeholder.action.label; + this.add(button.onDidClick(() => placeholder.action?.run())); + } + } + } +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerWidget.ts index b44caef1f00c0..54a0d3c0b4c17 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerWidget.ts @@ -30,7 +30,7 @@ import { IProductService } from '../../../../../../../platform/product/common/pr import { ITelemetryService } from '../../../../../../../platform/telemetry/common/telemetry.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../../../platform/storage/common/storage.js'; import { TelemetryTrustedValue } from '../../../../../../../platform/telemetry/common/telemetryUtils.js'; -import { IModelControlEntry, ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from '../../../../common/languageModels.js'; +import { COPILOT_VENDOR_ID, getLanguageModelProviderDisplayName, IModelControlEntry, ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from '../../../../common/languageModels.js'; import { getLanguageModelDisplayNameWithSubscriptionSource } from '../../../../common/languageModelSourcePresentation.js'; import { IChatEntitlementService } from '../../../../../../services/chat/common/chatEntitlementService.js'; import { IModelPickerDelegate } from './modelPickerActionItem.js'; @@ -45,9 +45,17 @@ import { withChatInputPickerMotion } from '../chatInputPickerActionItem.js'; import { buildModelPickerItems, createManageModelsAction, getModelPickerAccessibilityProvider, getModelPickerControlModels, ModelPickerSection, shouldShowManageModelsAction } from './modelPickerItems.js'; import { ModelPickerConfiguration } from './modelPickerConfiguration.js'; import { getCompactModelPickerIcon } from './modelProviderIcons.js'; +import { ITabbedModelPickerContext, TabbedModelPicker } from './modelPickerTabbedWidget.js'; +import { getModelConfigSummary } from './modelPickerModelConfig.js'; +import { logModelConfigurationChange } from './modelPickerTelemetry.js'; +import { IModelPickerProviderPlaceholder } from './modelPickerTabs.js'; import { getModelPickerUnavailableReason, isAutoModel, ModelPickerUnavailableReason, modelPickerRequiresSetup, shouldShowCacheBreakHint as computeShouldShowCacheBreakHint } from './modelPickerPresentation.js'; const CACHE_BREAK_HINT_DISMISSED_STORAGE_KEY = 'chat.cacheBreakHintDismissed'; + +/** Opt-in setting for the tabbed model picker, which replaces the flat dropdown and the separate configuration button. */ +export const TABBED_MODEL_PICKER_SETTING_ID = 'chat.experimentalModelPicker'; + const MODEL_PICKER_MINIMUM_LABEL_WIDTH = 60; const MODEL_PICKER_NAME_CHROME_WIDTH = 30; const MODEL_PICKER_MINIMUM_NAME_WIDTH = MODEL_PICKER_MINIMUM_LABEL_WIDTH + MODEL_PICKER_NAME_CHROME_WIDTH; @@ -124,6 +132,8 @@ export class ModelPickerWidget extends Disposable { private _configButton: HTMLElement | undefined; private _minimumWidth = MODEL_PICKER_MINIMUM_NAME_WIDTH; private readonly _configuration: ModelPickerConfiguration; + private readonly _tabbedPicker = this._register(new MutableDisposable()); + private readonly _tabbedPickerHideListener = this._register(new MutableDisposable()); get selectedModel(): ILanguageModelChatMetadataAndIdentifier | undefined { return this._selectedModel; @@ -165,10 +175,10 @@ export class ModelPickerWidget extends Disposable { @IWorkspaceTrustRequestService private readonly _workspaceTrustRequestService: IWorkspaceTrustRequestService, @IStorageService private readonly _storageService: IStorageService, @IConfigurationService private readonly _configurationService: IConfigurationService, - @IInstantiationService instantiationService: IInstantiationService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, ) { super(); - this._configuration = instantiationService.createInstance(ModelPickerConfiguration, { + this._configuration = this._instantiationService.createInstance(ModelPickerConfiguration, { getSelectedModel: () => this._selectedModel, getConfigurationAccess: () => this._delegate.modelConfiguration ?? this._languageModelsService, isDisabled: () => !!this._domNode?.classList.contains('disabled'), @@ -228,6 +238,12 @@ export class ModelPickerWidget extends Disposable { this._renderLabel(); })); } + + this._register(this._configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(TABBED_MODEL_PICKER_SETTING_ID)) { + this._renderLabel(); + } + })); } setCompact(compact: IObservable): void { @@ -437,13 +453,53 @@ export class ModelPickerWidget extends Disposable { }); } + /** Whether the user opted into the tabbed picker, which folds model configuration into the list. */ + isTabbedPickerEnabled(): boolean { + return this._configurationService.getValue(TABBED_MODEL_PICKER_SETTING_ID) === true; + } + + /** + * Providers that need a welcome body instead of a list. Only the built-in provider + * qualifies today, when it still needs sign-in. Providers the user has not set up are + * reached through "Add Models" rather than given a tab. + */ + private _providerPlaceholders(): IModelPickerProviderPlaceholder[] { + if (!this.isSetupRequired()) { + return []; + } + return [{ + vendor: COPILOT_VENDOR_ID, + label: getLanguageModelProviderDisplayName(this._languageModelsService, COPILOT_VENDOR_ID), + message: localize('chat.modelPicker.signInMessage', "Sign in to see available models."), + action: { label: localize('chat.modelPicker.signIn', "Sign in"), run: () => this._requestSetup() }, + }]; + } + + private _showTabbedPicker(anchor: HTMLElement, context: ITabbedModelPickerContext): void { + const picker = this._tabbedPicker.value ?? (this._tabbedPicker.value = this._instantiationService.createInstance(TabbedModelPicker)); + const previouslyFocusedElement = dom.getActiveElement(); + this._tabbedPickerHideListener.value = picker.onDidHide(() => { + this._tabbedPickerHideListener.clear(); + this._nameButton?.setAttribute('aria-expanded', 'false'); + if (dom.isHTMLElement(previouslyFocusedElement)) { + previouslyFocusedElement.focus(); + } + }); + this._nameButton?.setAttribute('aria-expanded', 'true'); + picker.show(anchor, context); + } + show(anchor?: HTMLElement): void { const anchorElement = anchor ?? this._domNode; if (!anchorElement || this._domNode?.classList.contains('disabled')) { return; } if (this._nameButton?.getAttribute('aria-expanded') === 'true') { - this._actionWidgetService.hide(true); + if (this._tabbedPicker.value?.isVisible) { + this._tabbedPicker.value.hide(); + } else { + this._actionWidgetService.hide(true); + } return; } @@ -490,6 +546,48 @@ export class ModelPickerWidget extends Disposable { this.show(anchorElement); }; + const onLinkClick = (uri: URI) => { + if (uri.scheme === 'command' && uri.path === 'workbench.action.chat.upgradePlan') { + logModelPickerInteraction('premiumModelUpgradePlanClicked'); + } else if (manageSettingsUrl && this._uriIdentityService.extUri.isEqual(uri, URI.parse(manageSettingsUrl))) { + logModelPickerInteraction('disabledModelContactAdminClicked'); + } + void this._openerService.open(uri, { allowCommands: true }); + }; + + const placeholders = this._providerPlaceholders(); + if (this.isTabbedPickerEnabled() && !this.isRestrictedMode() && (models.length > 0 || placeholders.length > 0)) { + const showCacheBreakHint = this.shouldShowCacheBreakHint(/* excludeAutoModel */ true); + this._showTabbedPicker(anchorElement, { + models, + selectedModelId: this._selectedModel?.identifier, + recentModelIds: this._languageModelsService.getRecentlyUsedModelIds().filter(id => !this._languageModelsService.isModelHidden(id)), + pinnedModelIds: this._languageModelsService.getPinnedModelIds().filter(id => !this._languageModelsService.isModelHidden(id)), + controlModels: controlModelsForTier, + configurationAccess: this._delegate.modelConfiguration ?? this._languageModelsService, + isUBB: !!this._entitlementService.quotas.usageBasedBilling, + showManageModels: !!manageModelsAction, + providerPlaceholders: placeholders, + unavailableContext: { + show: presentation.showUnavailableFeatured, + currentVSCodeVersion: this._productService.version, + manageSettingsUrl, + updateStateType: this._updateService.state.type, + }, + onUnavailableLinkClick: onLinkClick, + onSelect, + onTogglePin, + onManageModels: () => manageModelsAction?.run(), + onConfigurationChanged: (model, group, key, fromValue, toValue) => logModelConfigurationChange(this._telemetryService, model, group, key, fromValue, toValue), + cacheBreakHint: showCacheBreakHint ? { + text: localize('chat.modelPicker.cacheBreakHint', "Switching models mid-session resets the prompt cache and may increase cost."), + link: this.getCacheBreakLearnMoreLink(), + dismiss: () => this.dismissCacheBreakHint(), + } : undefined, + }); + return; + } + const items = buildModelPickerItems({ models, selectedModelId: this._selectedModel?.identifier, @@ -551,14 +649,7 @@ export class ModelPickerWidget extends Disposable { logModelPickerInteraction(collapsed ? 'otherModelsCollapsed' : 'otherModelsExpanded'); } }, - linkHandler: (uri: URI) => { - if (uri.scheme === 'command' && uri.path === 'workbench.action.chat.upgradePlan') { - logModelPickerInteraction('premiumModelUpgradePlanClicked'); - } else if (manageSettingsUrl && this._uriIdentityService.extUri.isEqual(uri, URI.parse(manageSettingsUrl))) { - logModelPickerInteraction('disabledModelContactAdminClicked'); - } - void this._openerService.open(uri, { allowCommands: true }); - }, + linkHandler: onLinkClick, minWidth: 200, }); const previouslyFocusedElement = dom.getActiveElement(); @@ -643,7 +734,15 @@ export class ModelPickerWidget extends Disposable { : genericNoModels ? localize('chat.modelPicker.noModels', "No models available") : (name ?? localize('chat.modelPicker.auto', "Auto")); + // The tabbed picker has no separate configuration button, so the chip reads out + // what the model was tuned to. + const configSummary = this.isTabbedPickerEnabled() && !unavailable && !minimal + ? getModelConfigSummary(this._selectedModel, this._delegate.modelConfiguration ?? this._languageModelsService) + : undefined; const showModelLabel = !compact || !modelIcon || noModelsAvailable; + // Fixed rather than measured: this runs from a resize-driven autorun, so reading + // the rendered width here would dirty layout from inside the ResizeObserver + // callback and never settle. const showingAuto = !unavailable && !activating && !genericNoModels && (!this._selectedModel || isAutoModel(this._selectedModel)); const nameMinimumWidth = compact && !showModelLabel ? MODEL_PICKER_COMPACT_NAME_WIDTH @@ -654,13 +753,20 @@ export class ModelPickerWidget extends Disposable { if (showModelLabel) { nameChildren.push(dom.$('span.chat-input-picker-label', undefined, modelLabel)); } + if (configSummary && showModelLabel) { + nameChildren.push(dom.$('span.model-picker-config-summary', undefined, configSummary)); + } if (this._badgeIcon) { nameChildren.push(this._badgeIcon); } dom.reset(this._nameButton, ...nameChildren); if (this._configButton) { - this._configuration.renderButton(this._configButton, minimal, noModelsAvailable); + if (this.isTabbedPickerEnabled()) { + this._configButton.style.display = 'none'; + } else { + this._configuration.renderButton(this._configButton, minimal, noModelsAvailable); + } } const configVisible = !!this._configButton && this._configButton.style.display !== 'none'; this._domNode.classList.toggle('icon-only', !showModelLabel && !configVisible); @@ -671,7 +777,9 @@ export class ModelPickerWidget extends Disposable { ? localize('chat.modelPicker.ariaLabelRestricted', "Models, unavailable while in Restricted mode") : setupRequired ? localize('chat.modelPicker.ariaLabelSetupRequired', "Models, sign in to use Copilot") - : localize('chat.modelPicker.ariaLabel', "Models, {0}", modelLabel); + : configSummary + ? localize('chat.modelPicker.ariaLabelConfigured', "Models, {0}, {1}", modelLabel, configSummary) + : localize('chat.modelPicker.ariaLabel', "Models, {0}", modelLabel); this._domNode.ariaLabel = ariaLabel; this._nameButton.ariaLabel = ariaLabel; this._updateMinimumWidth(nameMinimumWidth); diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelProviderIcons.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelProviderIcons.ts index b252cbfd854db..481a2615a87c9 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelProviderIcons.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelProviderIcons.ts @@ -20,39 +20,53 @@ const xAIModelProviderIcon = registerIcon('chat-model-provider-xai', Codicon.xai const genericModelProviderIcon = registerIcon('chat-model-provider-generic', Codicon.sparkle, localize('chatModelProviderGenericIcon', "Icon for other model providers.")); const genericModelProviderCompactIcon = registerIcon('chat-model-provider-generic-compact', Codicon.sparkleCompact, localize('chatModelProviderGenericCompactIcon', "Compact icon for other model providers.")); -export function getModelProviderIcon(model: ILanguageModelChatMetadataAndIdentifier): ThemeIcon { - const identity = `${model.metadata.vendor} ${model.metadata.family} ${model.metadata.id} ${model.metadata.name}`.toLowerCase(); - if (identity.includes('grok') || identity.includes('xai')) { +/** + * The provider icon matching a free-form identity string (vendor, family, model + * or provider name). Falls back to a generic icon when nothing matches. + */ +export function getProviderIconForIdentity(identity: string, copilotIdentity: string = identity): ThemeIcon { + const normalized = identity.toLowerCase(); + if (normalized.includes('grok') || normalized.includes('xai')) { return xAIModelProviderIcon; } - if (model.metadata.isBYOK) { - return genericModelProviderIcon; - } - if (isAutoLanguageModel(model)) { - return copilotModelProviderIcon; - } - if (identity.includes('claude') || identity.includes('anthropic')) { + if (normalized.includes('claude') || normalized.includes('anthropic')) { return claudeModelProviderIcon; } - if (identity.includes('gemini') || identity.includes('google')) { + if (normalized.includes('gemini') || normalized.includes('google')) { return geminiModelProviderIcon; } - if (identity.includes('kimi') || identity.includes('moonshot')) { + if (normalized.includes('kimi') || normalized.includes('moonshot')) { return kimiModelProviderIcon; } - if (identity.includes('microsoft') || /\bmai\b/.test(identity)) { + if (normalized.includes('microsoft') || /\bmai\b/.test(normalized)) { return microsoftModelProviderIcon; } - if (identity.includes('openai') || identity.includes('gpt') || identity.includes('codex') || /\bo[134]\b/.test(identity)) { + if (normalized.includes('openai') || normalized.includes('chatgpt') || normalized.includes('gpt') || normalized.includes('codex') || /\bo[134]\b/.test(normalized)) { return openAIModelProviderIcon; } - const modelIdentity = `${model.metadata.id} ${model.metadata.name}`.toLowerCase(); - if (modelIdentity.includes('copilot')) { + // Checked last, so a more specific match always wins. + if (copilotIdentity.toLowerCase().includes('copilot')) { return copilotModelProviderIcon; } return genericModelProviderIcon; } +export function getModelProviderIcon(model: ILanguageModelChatMetadataAndIdentifier): ThemeIcon { + const identity = `${model.metadata.vendor} ${model.metadata.family} ${model.metadata.id} ${model.metadata.name}`; + if (/grok|xai/i.test(identity)) { + return xAIModelProviderIcon; + } + if (model.metadata.isBYOK) { + return genericModelProviderIcon; + } + if (isAutoLanguageModel(model)) { + return copilotModelProviderIcon; + } + // The Copilot fallback reads the model's own name only: the vendor is `copilot` for + // every first-party model, so including it would brand the whole catalogue. + return getProviderIconForIdentity(identity, `${model.metadata.id} ${model.metadata.name}`); +} + export function getModelPickerIcon(model: ILanguageModelChatMetadataAndIdentifier): ThemeIcon { return model.metadata.statusIcon ?? getModelProviderIcon(model); } diff --git a/src/vs/workbench/contrib/chat/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index c76aa5f6816c3..99371d79af9ec 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -92,7 +92,9 @@ export enum ChatConfiguration { SessionStateIndicatorEnabled = 'chat.experimental.sessionStateIndicator.enabled', SubagentToolCustomAgents = 'chat.customAgentInSubagent.enabled', SubagentsAllowInvocationsFromSubagents = 'chat.subagents.allowInvocationsFromSubagents', + SubagentsDefaultToAuto = 'chat.subagents.defaultToAuto', SubagentsUseRichRendering = 'chat.subagents.useRichRendering', + SubagentsShowCreditUsage = 'chat.subagents.showCreditUsage', ShowCodeBlockProgressAnimation = 'chat.agent.codeBlockProgress', RestoreLastPanelSession = 'chat.restoreLastPanelSession', ExitAfterDelegation = 'chat.exitAfterDelegation', diff --git a/src/vs/workbench/contrib/chat/common/languageModels.ts b/src/vs/workbench/contrib/chat/common/languageModels.ts index 47beba3ef9419..b675ac35e4374 100644 --- a/src/vs/workbench/contrib/chat/common/languageModels.ts +++ b/src/vs/workbench/contrib/chat/common/languageModels.ts @@ -760,6 +760,12 @@ export function getLanguageModelDisplayNameWithProvider(model: ILanguageModelCha export interface IModelControlEntry { readonly label: string; readonly featured?: boolean; + /** + * Keeps the model out of the shortlist the picker leads with, even when it is the + * newest of its line. For a line that has been replaced by another rather than by a + * newer version of itself, which no rule can work out on its own. + */ + readonly demoted?: boolean; readonly minVSCodeVersion?: string; readonly exists: boolean; } @@ -2495,7 +2501,7 @@ export class LanguageModelsService implements ILanguageModelsService { if (!entry || !isObject(entry)) { continue; } - free[entry.id] = { label: entry.label, featured: entry.featured, exists: this._modelCache.has(`copilot/${entry.id}`) }; + free[entry.id] = { label: entry.label, featured: entry.featured, demoted: entry.demoted, exists: this._modelCache.has(`copilot/${entry.id}`) }; } } @@ -2505,7 +2511,7 @@ export class LanguageModelsService implements ILanguageModelsService { if (!entry || !isObject(entry)) { continue; } - paid[entry.id] = { label: entry.label, featured: entry.featured, minVSCodeVersion: entry.minVSCodeVersion, exists: this._modelCache.has(`copilot/${entry.id}`) }; + paid[entry.id] = { label: entry.label, featured: entry.featured, demoted: entry.demoted, minVSCodeVersion: entry.minVSCodeVersion, exists: this._modelCache.has(`copilot/${entry.id}`) }; } } diff --git a/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts b/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts index 7447e071477f7..14c305be1f4c5 100644 --- a/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts +++ b/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts @@ -9,6 +9,7 @@ import { Emitter, type Event } from '../../../../../../base/common/event.js'; import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; import { IJSONSchema, IJSONSchemaMap } from '../../../../../../base/common/jsonSchema.js'; import { Disposable, DisposableStore } from '../../../../../../base/common/lifecycle.js'; +import { isEqual } from '../../../../../../base/common/resources.js'; import type { URI } from '../../../../../../base/common/uri.js'; import { ThemeIcon } from '../../../../../../base/common/themables.js'; import { generateUuid } from '../../../../../../base/common/uuid.js'; @@ -17,11 +18,12 @@ import { IConfigurationService } from '../../../../../../platform/configuration/ import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../../../platform/log/common/log.js'; import { IProductService } from '../../../../../../platform/product/common/productService.js'; +import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; import { ChatRequestVariableSet } from '../../attachments/chatVariableEntries.js'; import { isByokModel } from '../../chatSelectedModel.js'; import { IChatProgress, IChatService } from '../../chatService/chatService.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../../constants.js'; -import { COPILOT_VENDOR_ID, ILanguageModelChatMetadata, ILanguageModelsService } from '../../languageModels.js'; +import { AUTO_RAW_MODEL_ID, COPILOT_VENDOR_ID, ILanguageModelChatMetadata, ILanguageModelsService } from '../../languageModels.js'; import type { ChatModel, IChatRequestModeInstructions } from '../../model/chatModel.js'; import { getChatSessionType } from '../../model/chatUri.js'; import { IChatAgentRequest, IChatAgentResult, IChatAgentService } from '../../participants/chatAgents.js'; @@ -65,6 +67,24 @@ export interface IRunSubagentToolInputParams { export const RUN_SUBAGENT_MAX_NESTING_DEPTH = 5; +type SubagentModelSelectionSource = 'explicitModel' | 'agentModel' | 'autoDefault' | 'mainModel'; + +interface IResolvedSubagentModel { + readonly modeModelId: string | undefined; + readonly resolvedModelName: string | undefined; + readonly selectionSource: SubagentModelSelectionSource; +} + +type SubagentModelSelectionEvent = { + selectionSource: SubagentModelSelectionSource; +}; + +type SubagentModelSelectionClassification = { + selectionSource: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The source that selected the model for an invoked subagent. One of explicitModel, agentModel, autoDefault, or mainModel.' }; + owner: 'bhavyaus'; + comment: 'Tracks how the model for an invoked subagent was selected without collecting model or agent names.'; +}; + export class RunSubagentTool extends Disposable implements IToolImpl { static readonly Id = 'runSubagent'; @@ -73,11 +93,13 @@ export class RunSubagentTool extends Disposable implements IToolImpl { readonly onDidUpdateToolData: Event = this._onDidUpdateToolData.event; /** Hack to port data between prepare/invoke */ - private readonly _resolvedModels = new Map(); + private readonly _resolvedModels = new Map(); /** Tracks the current subagent nesting depth per session to detect and limit recursion. */ private readonly _sessionDepth = new Map(); + private _autoModelResolution: Promise | undefined; + constructor( @IChatAgentService private readonly chatAgentService: IChatAgentService, @IChatService private readonly chatService: IChatService, @@ -88,8 +110,10 @@ export class RunSubagentTool extends Disposable implements IToolImpl { @IPromptsService private readonly promptsService: IPromptsService, @IInstantiationService private readonly instantiationService: IInstantiationService, @IProductService private readonly productService: IProductService, + @ITelemetryService private readonly telemetryService: ITelemetryService, ) { super(); + this._register(this.languageModelsService.onDidChangeLanguageModels(() => this._autoModelResolution = undefined)); } getToolData(): IToolData { @@ -165,6 +189,7 @@ export class RunSubagentTool extends Disposable implements IToolImpl { let modeInstructions: IChatRequestModeInstructions | undefined; let subagent: ICustomAgent | undefined; let resolvedModelName: string | undefined; + let modelSelectionSource: SubagentModelSelectionSource = 'mainModel'; const currentModeInstructions = request.modeInfo?.modeInstructions; const subAgentName = this.normalizeRequestedAgentName(args.agentName); @@ -180,11 +205,13 @@ export class RunSubagentTool extends Disposable implements IToolImpl { this._resolvedModels.delete(invocation.callId); modeModelId = cached.modeModelId; resolvedModelName = cached.resolvedModelName; + modelSelectionSource = cached.selectionSource; } else { // Fallback: resolve the model here if prepare didn't cache it - const resolved = this.resolveSubagentModel(subagent, invocation.modelId, args.model); + const resolved = await this.resolveSubagentModel(subagent, invocation.modelId, args.model); modeModelId = resolved.modeModelId; resolvedModelName = resolved.resolvedModelName; + modelSelectionSource = resolved.selectionSource; } // Use mode-specific tools if available @@ -223,10 +250,12 @@ export class RunSubagentTool extends Disposable implements IToolImpl { this._resolvedModels.delete(invocation.callId); modeModelId = cached.modeModelId; resolvedModelName = cached.resolvedModelName; + modelSelectionSource = cached.selectionSource; } else { - const resolved = this.resolveSubagentModel(undefined, invocation.modelId, args.model); + const resolved = await this.resolveSubagentModel(undefined, invocation.modelId, args.model, currentModeInstructions); modeModelId = resolved.modeModelId; resolvedModelName = resolved.resolvedModelName; + modelSelectionSource = resolved.selectionSource; } } @@ -357,6 +386,9 @@ export class RunSubagentTool extends Disposable implements IToolImpl { })); // Invoke the agent, tracking nesting depth for recursion detection + this.telemetryService.publicLog2('chat.subagentModelSelection', { + selectionSource: modelSelectionSource, + }); this._sessionDepth.set(sessionKey, currentDepth + 1); let result: IChatAgentResult | undefined; try { @@ -456,12 +488,7 @@ export class RunSubagentTool extends Disposable implements IToolImpl { private getAvailableModelsInfo(mainModelId: string | undefined): string { const models = this.languageModelsService.getLanguageModelIds() .map(id => ({ id, metadata: this.languageModelsService.lookupLanguageModel(id) })) - .filter((m): m is { id: string; metadata: ILanguageModelChatMetadata } => - !!m.metadata - && ILanguageModelChatMetadata.suitableForAgentMode(m.metadata) - && m.metadata.isUserSelectable !== false - && !m.metadata.targetChatSessionType - ); + .filter((m): m is { id: string; metadata: ILanguageModelChatMetadata } => !!m.metadata && this.isSelectableForAgentMode(m.metadata)); if (models.length === 0) { return 'No models available.'; @@ -496,11 +523,16 @@ export class RunSubagentTool extends Disposable implements IToolImpl { * Resolves the model to be used by a subagent. * @param explicitModelQualifiedName Optional explicit model specified by the caller. * If provided and not found or not allowed, throws an error with available models. + * @param currentModeInstructions The current agent inherited when no subagent is requested. + * Its configured model keeps precedence over the Auto default. * @throws Error if the requested model is not found or exceeds the main model's cost tier. */ - private resolveSubagentModel(subagent: ICustomAgent | undefined, mainModelId: string | undefined, explicitModelQualifiedName?: string): { modeModelId: string | undefined; resolvedModelName: string | undefined } { + private async resolveSubagentModel(subagent: ICustomAgent | undefined, mainModelId: string | undefined, explicitModelQualifiedName?: string, currentModeInstructions?: IChatRequestModeInstructions): Promise { let modeModelId = mainModelId; let explicitModelResolved = false; + let usesAutoDefault = false; + let selectionSource: SubagentModelSelectionSource = 'mainModel'; + const mainModelMetadata = mainModelId ? this.languageModelsService.lookupLanguageModel(mainModelId) : undefined; // Explicit model parameter takes highest priority if (explicitModelQualifiedName) { @@ -508,6 +540,7 @@ export class RunSubagentTool extends Disposable implements IToolImpl { if (lm?.identifier) { modeModelId = lm.identifier; explicitModelResolved = true; + selectionSource = 'explicitModel'; } else { // Model not found - throw error with available models throw new Error(`Requested model '${explicitModelQualifiedName}' not found. ${this.getAvailableModelsInfo(mainModelId)}`); @@ -520,7 +553,6 @@ export class RunSubagentTool extends Disposable implements IToolImpl { // When the main model is BYOK (flagged via `metadata.isBYOK`), skip Copilot/CAPI fallback models // for built-in agents (e.g. Explore), whose model list is a curated convenience fallback. A // user-authored agent's model list is a deliberate choice and is always honored as-is. - const mainModelMetadata = mainModelId ? this.languageModelsService.lookupLanguageModel(mainModelId) : undefined; const mainModelIsByok = !!mainModelMetadata && isByokModel(mainModelMetadata); const skipCopilotFallbacks = mainModelIsByok && isBuiltinAgent(subagent.source, subagent.uri, this.productService); // Find the actual model identifier from the qualified name(s) @@ -531,14 +563,30 @@ export class RunSubagentTool extends Disposable implements IToolImpl { continue; } modeModelId = lmByQualifiedName.identifier; + selectionSource = 'agentModel'; break; } } } } + if ( + !explicitModelResolved + && !subagent?.model?.length + && (!mainModelId || (!!mainModelMetadata && !isByokModel(mainModelMetadata))) + && this.configurationService.getValue(ChatConfiguration.SubagentsDefaultToAuto) === true + && !(await this.inheritedAgentHasModel(subagent, currentModeInstructions)) + ) { + const autoModelId = await this.resolveAutoModelId(); + if (autoModelId) { + modeModelId = autoModelId; + usesAutoDefault = true; + selectionSource = 'autoDefault'; + } + } + // Check multiplier constraint - throw error if requested model exceeds main model's cost tier - if (modeModelId) { + if (modeModelId && !usesAutoDefault) { const check = this.checkMultiplierConstraint(modeModelId, mainModelId); if (check.exceeds) { const modelMetadata = this.languageModelsService.lookupLanguageModel(modeModelId); @@ -547,7 +595,51 @@ export class RunSubagentTool extends Disposable implements IToolImpl { } const resolvedModelMetadata = modeModelId ? this.languageModelsService.lookupLanguageModel(modeModelId) : undefined; - return { modeModelId, resolvedModelName: resolvedModelMetadata?.name }; + return { modeModelId, resolvedModelName: resolvedModelMetadata?.name, selectionSource }; + } + + private async inheritedAgentHasModel(subagent: ICustomAgent | undefined, currentModeInstructions: IChatRequestModeInstructions | undefined): Promise { + if (subagent || !currentModeInstructions) { + return false; + } + const { uri, name } = currentModeInstructions; + const agents = await this.promptsService.getCustomAgents(CancellationToken.None); + const currentAgent = agents.find(agent => uri ? isEqual(agent.uri, uri) : agent.name === name && agent.enabled); + return !!currentAgent?.model?.length; + } + + private isSelectableForAgentMode(metadata: ILanguageModelChatMetadata): boolean { + return ILanguageModelChatMetadata.suitableForAgentMode(metadata) + && metadata.isUserSelectable !== false + && !metadata.targetChatSessionType; + } + + private findEligibleAutoModelId(modelIds: readonly string[]): string | undefined { + return modelIds.find(modelId => { + const metadata = this.languageModelsService.lookupLanguageModel(modelId); + return metadata?.vendor === COPILOT_VENDOR_ID + && metadata.id === AUTO_RAW_MODEL_ID + && this.isSelectableForAgentMode(metadata); + }); + } + + private resolveAutoModelId(): Promise { + const cachedModelId = this.findEligibleAutoModelId(this.languageModelsService.getLanguageModelIds()); + if (cachedModelId || this.languageModelsService.hasResolvedVendor(COPILOT_VENDOR_ID)) { + return Promise.resolve(cachedModelId); + } + this._autoModelResolution ??= this.activateAutoModel(); + return this._autoModelResolution; + } + + private async activateAutoModel(): Promise { + try { + const modelIds = await this.languageModelsService.selectLanguageModels({ vendor: COPILOT_VENDOR_ID, id: AUTO_RAW_MODEL_ID }); + return this.findEligibleAutoModelId(modelIds); + } catch (error) { + this.logService.warn('RunSubagentTool: Failed to resolve the Auto model, keeping the main model', error); + return undefined; + } } async prepareToolInvocation(context: IToolInvocationPreparationContext, _token: CancellationToken): Promise { @@ -561,7 +653,7 @@ export class RunSubagentTool extends Disposable implements IToolImpl { const subagent = requestedAgentName ? await this.getSubAgentByName(requestedAgentName) : undefined; // Resolve the model early and cache it for invoke() - const resolved = this.resolveSubagentModel(subagent, context.modelId, args.model); + const resolved = await this.resolveSubagentModel(subagent, context.modelId, args.model, currentModeInstructions); this._resolvedModels.set(context.toolCallId, resolved); return { diff --git a/src/vs/workbench/contrib/chat/electron-browser/agentSessions/agentSessionsActions.ts b/src/vs/workbench/contrib/chat/electron-browser/agentSessions/agentSessionsActions.ts index 5b455ab42cd75..d3c7f201ffdae 100644 --- a/src/vs/workbench/contrib/chat/electron-browser/agentSessions/agentSessionsActions.ts +++ b/src/vs/workbench/contrib/chat/electron-browser/agentSessions/agentSessionsActions.ts @@ -47,8 +47,10 @@ const OPEN_WORKSPACE_IN_AGENTS_WINDOW_TITLE_BAR_COMMAND_ID = 'workbench.action.c async function openCurrentWorkspaceInAgentsWindow(accessor: ServicesAccessor, source: AgentsWindowOpenSource): Promise { const nativeHostService = accessor.get(INativeHostService); const workspaceContextService = accessor.get(IWorkspaceContextService); - const folderUri = workspaceContextService.getWorkspace().folders[0]?.uri; - await nativeHostService.openAgentsWindow({ folderUri: folderUri?.scheme === Schemas.file ? folderUri : undefined, source }); + await nativeHostService.openAgentsWindow({ + folderUri: workspaceContextService.getWorkspace().folders[0]?.uri, + source, + }); } function isOpenChatSessionInAgentsWindowOptions(value: unknown): value is { readonly agentsWindowOpenSource: AgentsWindowOpenSource } { diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatInputPicker.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatInputPicker.test.ts index f490dd533715f..1fb72e679a358 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatInputPicker.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatInputPicker.test.ts @@ -4,6 +4,26 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { Emitter, Event } from '../../../../../../base/common/event.js'; +import { constObservable, observableFromEvent, observableValue } from '../../../../../../base/common/observable.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { mock } from '../../../../../../base/test/common/mock.js'; +import { IActionWidgetService } from '../../../../../../platform/actionWidget/browser/actionWidget.js'; +import { IActionListDelegate, IActionListItem, IActionListItemInlineToggle } from '../../../../../../platform/actionWidget/browser/actionList.js'; +import { IAgentHostEnablementService } from '../../../../../../platform/agentHost/common/agentHostEnablementService.js'; +import { AgentHostSdkSandboxEnabledSettingId, AgentHostSdkSandboxWindowsEnabledSettingId, IAgentHostService } from '../../../../../../platform/agentHost/common/agentService.js'; +import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { IDialogService } from '../../../../../../platform/dialogs/common/dialogs.js'; +import { IHoverService } from '../../../../../../platform/hover/browser/hover.js'; +import { IOpenerService } from '../../../../../../platform/opener/common/opener.js'; +import { COPILOT_SANDBOX_ALLOW_BYPASS_KEY, IManagedSettingsService } from '../../../../../../platform/policy/common/copilotManagedSettings.js'; +import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; +import { IAgentHostNewSessionFolderService } from '../../../browser/agentSessions/agentHost/agentHostNewSessionFolderService.js'; +import { IAgentHostSessionWorkingDirectoryResolver } from '../../../browser/agentSessions/agentHost/agentHostSessionWorkingDirectoryResolver.js'; +import { IAgentHostUntitledProvisionalSessionService } from '../../../browser/agentSessions/agentHost/agentHostUntitledProvisionalSessionService.js'; +import { IChatWidget } from '../../../browser/chat.js'; +import { IChatViewModel } from '../../../common/model/chatViewModel.js'; +import { TestStorageService } from '../../../../../test/common/workbenchTestServices.js'; import * as dom from '../../../../../../base/browser/dom.js'; import { toDisposable } from '../../../../../../base/common/lifecycle.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; @@ -13,15 +33,154 @@ import { ClaudeSessionConfigKey } from '../../../../../../platform/agentHost/com import { SessionConfigKey } from '../../../../../../platform/agentHost/common/sessionConfigKeys.js'; import { CodexSessionConfigKey } from '../../../../../../platform/agentHost/common/codexSessionConfigKeys.js'; import type { SessionConfigPropertySchema } from '../../../../../../platform/agentHost/common/state/protocol/commands.js'; -import { getAgentHostSandboxSettingId, getConfigPickerAccessibleTriggerLabel, getConfigPickerItemHover, getConfigPickerListOptions, getConfigPickerTriggerHover, getConfigPickerTriggerLabel, resolveConfigChipValue } from '../../../browser/agentSessions/agentHost/agentHostChatInputPicker.js'; -import { AgentHostSdkSandboxEnabledSettingId, AgentHostSdkSandboxWindowsEnabledSettingId } from '../../../../../../platform/agentHost/common/agentService.js'; -import { AgentSandboxSettingId } from '../../../../../../platform/sandbox/common/settings.js'; +import { AgentHostChatInputPicker, getAgentHostSandboxSettingId, getConfigPickerAccessibleTriggerLabel, getConfigPickerItemHover, getConfigPickerListOptions, getConfigPickerTriggerHover, getConfigPickerTriggerLabel, resolveConfigChipValue } from '../../../browser/agentSessions/agentHost/agentHostChatInputPicker.js'; +import { AgentSandboxEnabledValue, AgentSandboxSettingId } from '../../../../../../platform/sandbox/common/settings.js'; import { SessionType } from '../../../common/chatSessionsService.js'; import { getAgentHostPickerProperty, OpenAgentHostAutoApprovePickerAction, OpenAgentHostCodexApprovalsPickerAction, OpenAgentHostModePickerAction, OpenAgentHostPermissionModePickerAction } from '../../../browser/agentSessions/agentHost/agentHostChatInputPicker.contribution.js'; import { isAutoApproveValuePolicyRestricted, isPermissionLevelVisible, normalizeSessionConfigValue } from '../../../common/agentHostConfigPolicy.js'; -import { ChatPermissionLevel } from '../../../common/constants.js'; +import { ChatConfiguration, ChatPermissionLevel } from '../../../common/constants.js'; import '../../../browser/agentSessions/agentHost/media/agentHostChatInputPicker.css'; +suite('AgentHostChatInputPicker - sandbox toggle', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('editability follows managed bypass policy', async () => { + const sandboxSettingId = getAgentHostSandboxSettingId(SessionType.AgentHostCopilot, false)!; + const writes: unknown[] = []; + const configurationService = new class extends TestConfigurationService { + override async updateValue(key: string, value: unknown): Promise { + writes.push({ key, value }); + } + }(); + store.add(configurationService.onDidChangeConfigurationEmitter); + await configurationService.setUserConfiguration(ChatConfiguration.PermissionsSandboxToggleEnabled, true); + const managedSandboxEnforced = observableValue('managedSandboxEnforced', false); + let allowBypass: boolean | undefined; + const managedSettingsChanged = store.add(new Emitter()); + const managedSettingsService: IManagedSettingsService = { + _serviceBrand: undefined, + onDidChangeManagedSettings: managedSettingsChanged.event, + getManagedSettingValue: key => key === COPILOT_SANDBOX_ALLOW_BYPASS_KEY ? allowBypass : undefined, + }; + const enablementService: IAgentHostEnablementService = { + _serviceBrand: undefined, + enabled: constObservable(true), + managedSandboxEnforced, + managedSandboxAllowsBypass: observableFromEvent(managedSettingsService, managedSettingsChanged.event, () => allowBypass === true), + }; + const visibleStates: Pick[] = []; + let onHide: (() => void) | undefined; + const recordVisibleState = (items: readonly IActionListItem[]) => { + const toggle = items.find(item => item.standaloneToggle)?.standaloneToggle; + assert.ok(toggle); + visibleStates.push({ disabled: toggle.disabled, title: toggle.title }); + }; + const actionWidgetService = new class extends mock() { + override readonly isVisible = false; + override show(_user: string, _supportsPreview: boolean, items: readonly IActionListItem[], delegate: IActionListDelegate): void { + onHide = delegate.onHide; + recordVisibleState(items); + } + override updateItems(items: readonly IActionListItem[]): void { + recordVisibleState(items); + } + }(); + const widget = new class extends mock() { + override readonly onDidChangeViewModel = Event.None; + override viewModel: IChatViewModel | undefined; + }(); + const picker = store.add(new AgentHostChatInputPicker( + widget, + SessionConfigKey.AutoApprove, + new class extends mock() { }(), + actionWidgetService, + new class extends mock() { }(), + new class extends mock() { }(), + new class extends mock() { }(), + new class extends mock() { }(), + new class extends mock() { + override readonly onDidChange = Event.None; + }(), + configurationService, + new class extends mock() { }(), + new class extends mock() { }(), + store.add(new TestStorageService()), + enablementService, + )); + widget.viewModel = new class extends mock() { + override readonly sessionResource = URI.from({ scheme: SessionType.AgentHostCopilot, path: '/test-session' }); + }(); + + for (const managed of [false, true]) { + managedSandboxEnforced.set(managed, undefined); + for (const bypass of [undefined, false, true]) { + allowBypass = bypass; + for (const configured of [AgentSandboxEnabledValue.Off, AgentSandboxEnabledValue.On]) { + await configurationService.setUserConfiguration(sandboxSettingId, configured); + const toggle = picker['_getSandboxStandaloneToggle']()!; + writes.length = 0; + toggle.onChange(false); + toggle.onChange(true); + const disabled = managed && bypass !== true; + assert.deepStrictEqual({ checked: toggle.checked, disabled: toggle.disabled, title: toggle.title, writes }, { + checked: managed || configured === AgentSandboxEnabledValue.On, + disabled, + title: managed + ? disabled ? 'Sandboxing is required by your organization' : 'Sandboxing is enabled by your organization, but you may disable it' + : 'Run terminal commands inside a sandbox that restricts file system and network access', + writes: disabled ? [] : [ + { key: sandboxSettingId, value: AgentSandboxEnabledValue.Off }, + { key: sandboxSettingId, value: AgentSandboxEnabledValue.On }, + ], + }); + } + } + } + + const toggle = picker['_getSandboxStandaloneToggle']()!; + allowBypass = false; + writes.length = 0; + toggle.onChange(false); + assert.deepStrictEqual({ writes, disabled: picker['_getSandboxStandaloneToggle']()!.disabled }, { writes: [], disabled: true }); + allowBypass = true; + assert.strictEqual(picker['_getSandboxStandaloneToggle']()!.disabled, false); + + picker['_initialResolved'] = { + sessionResource: widget.viewModel.sessionResource, + result: { + values: { [SessionConfigKey.AutoApprove]: 'default' }, + schema: { + type: 'object', + properties: { + [SessionConfigKey.AutoApprove]: { type: 'string', title: 'Permissions', enum: ['default', 'autoApprove'], default: 'default' }, + }, + }, + }, + }; + allowBypass = false; + await picker['_showPicker'](document.createElement('div')); + allowBypass = true; + managedSettingsChanged.fire(); + managedSettingsChanged.fire(); + managedSandboxEnforced.set(false, undefined); + managedSandboxEnforced.set(true, undefined); + allowBypass = false; + managedSettingsChanged.fire(); + assert.ok(onHide); + onHide(); + allowBypass = true; + managedSettingsChanged.fire(); + assert.deepStrictEqual(visibleStates, [ + { disabled: true, title: 'Sandboxing is required by your organization' }, + { disabled: true, title: 'Sandboxing is required by your organization' }, + { disabled: false, title: 'Sandboxing is enabled by your organization, but you may disable it' }, + { disabled: false, title: 'Run terminal commands inside a sandbox that restricts file system and network access' }, + { disabled: false, title: 'Sandboxing is enabled by your organization, but you may disable it' }, + { disabled: true, title: 'Sandboxing is required by your organization' }, + ]); + }); +}); + suite('AgentHostChatInputPicker - compact layout', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts index e191bf2d8ec5e..f7af02ff470c6 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts @@ -15,7 +15,7 @@ import { toAgentMessageDelegationMeta } from '../../../../../../platform/agentHo import { AgentSystemNotificationKind, AgentSystemNotificationSeverity, toAgentSystemNotificationMeta } from '../../../../../../platform/agentHost/common/meta/agentSystemNotificationMeta.js'; import { McpAuthRequiredReason } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { createAgentHostResourceUriMapper, fromAgentHostUri, toAgentHostContentUri } from '../../../../../../platform/agentHost/common/agentHostUri.js'; -import { buildSubagentChatUri, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, createErrorResponsePart, MessageAttachmentKind, MessageKind, ToolCallContributorKind, ToolCallRiskAssessmentKind, ToolCallRiskAssessmentStatus, ToolCallStatus, ToolCallConfirmationReason, ToolResultContentType, TurnState, ResponsePartKind, readUsageInfoMeta, withMessageHiddenFromTranscript, withMessageRequestHiddenFromTranscript, type ActiveTurn, type ICompletedToolCall, type ToolCallPendingConfirmationState, type ToolCallRunningState, type Turn, type ToolCallResponsePart, ToolCallCancellationReason, type Message, type ToolResultContent } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { buildSubagentChatUri, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, createErrorResponsePart, MessageAttachmentKind, MessageKind, ToolCallContributorKind, ToolCallRiskAssessmentKind, ToolCallRiskAssessmentStatus, ToolCallStatus, ToolCallConfirmationReason, ToolResultContentType, TurnState, ResponsePartKind, readUsageInfoMeta, withMessageHiddenFromTranscript, withMessageRequestHiddenFromTranscript, withMessageSystemInitiatedLabel, type ActiveTurn, type ICompletedToolCall, type ToolCallPendingConfirmationState, type ToolCallRunningState, type Turn, type ToolCallResponsePart, ToolCallCancellationReason, type Message, type ToolResultContent } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { ChatTranscriptContextAttachmentDisplayKind, IChatRequestTranscriptContextVariableEntry, toChatTranscriptContextAttachmentMeta } from '../../../common/attachments/chatVariableEntries.js'; import { ChatRequestOriginKind } from '../../../common/chatRequestOrigin.js'; import { IChatToolInvocation, IChatToolInvocationSerialized, ToolConfirmKind, type IChatMarkdownContent, type IChatTerminalToolInvocationData, type IChatThinkingPart, type IChatToolInputInvocationData, type IChatUsage } from '../../../common/chatService/chatService.js'; @@ -317,15 +317,23 @@ suite('stateToProgressAdapter', () => { test('system-initiated turn preserves compact request label', () => { const turn = createTurn({ - message: message('`sleep 6` completed', MessageKind.SystemNotification), + message: withMessageSystemInitiatedLabel( + message('Continue the original task in the new workspace.', MessageKind.SystemNotification), + 'Workspace Set', + ), }); const history = turnsToHistory(URI.file('/'), [turn], 'participant-1'); - assert.strictEqual(history[0].type, 'request'); - if (history[0].type !== 'request') { return; } - assert.strictEqual(history[0].isSystemInitiated, true); - assert.strictEqual(history[0].prompt, '`sleep 6` completed'); - assert.strictEqual(history[0].systemInitiatedLabel, undefined); + assert.deepStrictEqual(history[0], { + id: turn.id, + type: 'request', + prompt: 'Continue the original task in the new workspace.', + participant: 'participant-1', + modelId: undefined, + variableData: undefined, + isSystemInitiated: true, + systemInitiatedLabel: 'Workspace Set', + }); }); test('hidden turn remains hidden when restored from protocol history', () => { @@ -1306,6 +1314,31 @@ suite('stateToProgressAdapter', () => { assert.strictEqual(invocation.source, ToolDataSource.Internal); }); + test('set_workspace confirmation hides implementation input', () => { + const invocation = toolCallStateToInvocation({ + toolCallId: 'tc-set-workspace', + toolName: 'set_workspace', + displayName: 'Set Workspace', + invocationMessage: 'Continue this session in /workspace/app and make changes directly in that folder?', + status: ToolCallStatus.PendingConfirmation, + confirmationTitle: 'Continue in app?', + toolInput: '{"workspaceFolder":"/workspace/app","isolation":false}', + }); + const state = invocation.state.get(); + + assert.deepStrictEqual({ + confirmationMessages: state.type === IChatToolInvocation.StateKind.WaitingForConfirmation ? state.confirmationMessages : undefined, + toolSpecificData: invocation.toolSpecificData, + }, { + confirmationMessages: { + title: 'Continue in app?', + message: 'Continue this session in /workspace/app and make changes directly in that folder?', + approvalReason: undefined, + }, + toolSpecificData: undefined, + }); + }); + test('renders ask-user tools as waiting progress that hides after completion', () => { const toolNames = ['ask_user', 'AskUserQuestion', 'request_user_input']; const live = toolNames.map(toolName => { 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 f2dd156e90446..d4ee34ecdf480 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 @@ -13,7 +13,8 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/ import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js'; -import { AICustomizationListWidget, getAlwaysVisibleCustomizationGroupKeys, getTargetedCreateActionLabel, usesCustomizationCardLayout } from '../../../browser/aiCustomization/aiCustomizationListWidget.js'; +import { AICustomizationListWidget, getAlwaysVisibleCustomizationGroupKeys, getCollapsedCustomizationGroupKey, getCustomizationItemAriaLabel, getTargetedCreateActionLabel, usesCustomizationCardLayout } from '../../../browser/aiCustomization/aiCustomizationListWidget.js'; +import { IAICustomizationListItem } from '../../../browser/aiCustomization/aiCustomizationItemSource.js'; import { IAICustomizationItemsModel } from '../../../browser/aiCustomization/aiCustomizationItemsModel.js'; import { extractExtensionIdFromPath, getCustomizationSecondaryText, truncateToFirstLine } from '../../../browser/aiCustomization/aiCustomizationListWidgetUtils.js'; import { AICustomizationManagementSection, IAICustomizationWorkspaceService } from '../../../common/aiCustomizationWorkspaceService.js'; @@ -25,7 +26,7 @@ import { IPromptsService, PromptsStorage } from '../../../common/promptSyntax/se import { PromptsType } from '../../../common/promptSyntax/promptTypes.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; import { ResourceSet } from '../../../../../../base/common/map.js'; -import { createCustomizationCardPrimaryAction, CustomizationCardListController } from '../../../browser/aiCustomization/customizationCardList.js'; +import { createCustomizationCardPrimaryAction, CustomizationCardListController, layoutVirtualizedSectionList, layoutVirtualizedSections, renderVirtualizedSectionLoadingPlaceholder, setVirtualizedRowActionsTabbable, setupCollapsibleSection } from '../../../browser/aiCustomization/customizationCardList.js'; suite('aiCustomizationListWidget', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -74,6 +75,283 @@ suite('aiCustomizationListWidget', () => { ]); }); + test('collapsible sections are expanded by default and only the disclosure toggles them', () => { + const disposables = new DisposableStore(); + const heading = document.createElement('div'); + const content = document.createElement('div'); + const headerAction = document.createElement('button'); + heading.appendChild(headerAction); + const changes: boolean[] = []; + const toggle = setupCollapsibleSection(heading, content, 'Workspace', disposables, false, collapsed => changes.push(collapsed)); + + try { + headerAction.click(); + const initiallyExpanded = { + expanded: toggle.getAttribute('aria-expanded'), + controlsContent: toggle.getAttribute('aria-controls') === content.id, + hidden: content.hidden, + display: content.style.display, + changes: [...changes], + }; + toggle.click(); + const collapsed = { + expanded: toggle.getAttribute('aria-expanded'), + label: toggle.getAttribute('aria-label'), + hidden: content.hidden, + display: content.style.display, + changes: [...changes], + }; + toggle.click(); + + assert.deepStrictEqual({ + initiallyExpanded, + collapsed, + expandedAgain: { + expanded: toggle.getAttribute('aria-expanded'), + label: toggle.getAttribute('aria-label'), + hidden: content.hidden, + display: content.style.display, + changes, + }, + }, { + initiallyExpanded: { + expanded: 'true', + controlsContent: true, + hidden: false, + display: '', + changes: [], + }, + collapsed: { + expanded: 'false', + label: 'Expand Workspace', + hidden: true, + display: 'none', + changes: [true], + }, + expandedAgain: { + expanded: 'true', + label: 'Collapse Workspace', + hidden: false, + display: '', + changes: [true, false], + }, + }); + } finally { + disposables.dispose(); + } + }); + + test('collapsible sections create disclosures outside auxiliary document realms', () => { + const disposables = new DisposableStore(); + const auxiliaryDocument = document.implementation.createHTMLDocument(); + const heading = auxiliaryDocument.createElement('div'); + const content = auxiliaryDocument.createElement('div'); + Object.defineProperty(auxiliaryDocument, 'createElement', { + configurable: true, + value: () => { + throw new Error('Auxiliary documents must not create workbench controls'); + }, + }); + + try { + const toggle = setupCollapsibleSection(heading, content, 'Workspace', disposables, false, () => { }); + assert.deepStrictEqual({ + ownerDocument: toggle.ownerDocument === auxiliaryDocument, + parent: toggle.parentElement === heading, + expanded: toggle.getAttribute('aria-expanded'), + }, { + ownerDocument: true, + parent: true, + expanded: 'true', + }); + } finally { + disposables.dispose(); + } + }); + + test('collapsed groups are scoped to their customization page', () => { + assert.deepStrictEqual({ + agents: getCollapsedCustomizationGroupKey(AICustomizationManagementSection.Agents, PromptsStorage.local), + skills: getCollapsedCustomizationGroupKey(AICustomizationManagementSection.Skills, PromptsStorage.local), + }, { + agents: 'agents:local', + skills: 'skills:local', + }); + }); + + test('virtualized customization labels include item status', () => { + const item: IAICustomizationListItem = { + id: 'prompt', + uri: URI.file('Q:\\workspace\\.github\\prompts\\review.prompt.md'), + name: 'review', + displayName: 'Review', + filename: 'review.prompt.md', + description: 'Review the current changes', + source: PromptsStorage.local, + promptType: PromptsType.prompt, + disabled: false, + status: 'degraded', + }; + + assert.strictEqual(getCustomizationItemAriaLabel(item), 'Review. Review the current changes. Needs attention'); + }); + + test('virtualized row actions use a focused-row tab stop and skip disabled controls', () => { + const actions = document.createElement('div'); + const action = document.createElement('a'); + action.setAttribute('role', 'button'); + const toggle = document.createElement('div'); + toggle.setAttribute('role', 'switch'); + const disabledAction = document.createElement('a'); + disabledAction.setAttribute('role', 'button'); + disabledAction.setAttribute('aria-disabled', 'true'); + actions.append(action, toggle, disabledAction); + + setVirtualizedRowActionsTabbable(actions, true); + const focused = [action.tabIndex, toggle.tabIndex, disabledAction.tabIndex]; + setVirtualizedRowActionsTabbable(actions, false); + + assert.deepStrictEqual({ + focused, + unfocused: [action.tabIndex, toggle.tabIndex, disabledAction.tabIndex], + }, { + focused: [0, 0, -1], + unfocused: [-1, -1, -1], + }); + }); + + test('virtualized section height is redistributed when a sibling collapses', () => { + const root = document.createElement('div'); + const createSection = () => { + const section = document.createElement('section'); + const list = document.createElement('div'); + section.appendChild(list); + root.appendChild(section); + Object.defineProperty(list, 'offsetHeight', { configurable: true, get: () => list.hidden ? 0 : Number.parseFloat(list.style.height) || 100 }); + Object.defineProperty(section, 'offsetHeight', { configurable: true, get: () => 40 + list.offsetHeight }); + return list; + }; + const first = createSection(); + const second = createSection(); + Object.defineProperty(root, 'clientHeight', { configurable: true, value: 300 }); + + const expanded = layoutVirtualizedSections(root, [ + { container: first, contentHeight: 300, minimumHeight: 44 }, + { container: second, contentHeight: 300, minimumHeight: 44 }, + ]); + first.hidden = true; + const redistributed = layoutVirtualizedSections(root, [ + { container: first, contentHeight: 300, minimumHeight: 44 }, + { container: second, contentHeight: 300, minimumHeight: 44 }, + ]); + + assert.deepStrictEqual({ expanded, redistributed }, { + expanded: [110, 110], + redistributed: [0, 220], + }); + }); + + test('virtualized sections keep one complete row when the initial height is constrained', () => { + const root = document.createElement('div'); + const sections = Array.from({ length: 3 }, () => { + const section = document.createElement('section'); + const list = document.createElement('div'); + section.appendChild(list); + root.appendChild(section); + Object.defineProperty(list, 'offsetHeight', { configurable: true, get: () => Number.parseFloat(list.style.height) || 0 }); + Object.defineProperty(section, 'offsetHeight', { configurable: true, get: () => 40 + list.offsetHeight }); + return list; + }); + Object.defineProperty(root, 'clientHeight', { configurable: true, value: 180 }); + + const constrained = layoutVirtualizedSections(root, sections.map(container => ({ + container, + contentHeight: 300, + minimumHeight: 44, + }))); + + assert.deepStrictEqual({ + constrained, + overflow: root.style.overflow, + reservesPageScrollbarLane: root.classList.contains('virtualized-section-layout-overflow'), + }, { + constrained: [44, 44, 44], + overflow: 'visible', + reservesPageScrollbarLane: true, + }); + }); + + test('virtualized sections distribute remaining height after reserving complete rows', () => { + const root = document.createElement('div'); + const sections = [352, 88, 44, 220].map(contentHeight => { + const section = document.createElement('section'); + const list = document.createElement('div'); + section.appendChild(list); + root.appendChild(section); + Object.defineProperty(list, 'offsetHeight', { configurable: true, get: () => Number.parseFloat(list.style.height) || 44 }); + Object.defineProperty(section, 'offsetHeight', { configurable: true, get: () => 40 + list.offsetHeight }); + return { container: list, contentHeight, minimumHeight: 44 }; + }); + Object.defineProperty(root, 'clientHeight', { configurable: true, value: 349 }); + + const heights = layoutVirtualizedSections(root, sections); + + assert.deepStrictEqual({ + heights, + overflow: root.style.overflow, + reservesPageScrollbarLane: root.classList.contains('virtualized-section-layout-overflow'), + }, { + heights: [48, 48, 44, 48], + overflow: '', + reservesPageScrollbarLane: false, + }); + }); + + test('loading placeholders and replacement lists keep a stable row height and scroll position', () => { + const container = document.createElement('div'); + const placeholder = renderVirtualizedSectionLoadingPlaceholder(container, 'Loading customizations...', 44); + const list = { + scrollTop: 88, + layout: (height: number) => { + assert.strictEqual(height, 44); + list.scrollTop = 0; + }, + }; + + layoutVirtualizedSectionList(list, container, 44); + + assert.deepStrictEqual({ + placeholderHeight: placeholder.style.height, + containerHeight: container.style.height, + scrollTop: list.scrollTop, + }, { + placeholderHeight: '44px', + containerHeight: '44px', + scrollTop: 88, + }); + }); + + test('collapsed virtualized lists retain their scroll position', () => { + const container = document.createElement('div'); + let layoutCount = 0; + const list = { + scrollTop: 88, + layout: () => layoutCount++, + }; + + layoutVirtualizedSectionList(list, container, 0); + + assert.deepStrictEqual({ + containerHeight: container.style.height, + scrollTop: list.scrollTop, + layoutCount, + }, { + containerHeight: '0px', + scrollTop: 88, + layoutCount: 0, + }); + }); + test('card lists use roving focus and expose focused-row actions', async () => { const disposables = new DisposableStore(); const list = document.createElement('div'); @@ -402,5 +680,101 @@ suite('aiCustomizationListWidget', () => { assert.strictEqual(widget.element.querySelector('.list-container')!.style.height, '830px'); }); + + test('instruction rows use an overflow menu without loaded status or targeting badges', async () => { + const items = observableValue('test', [{ + id: 'instruction', + uri: URI.file('Q:\\workspace\\.github\\instructions\\typescript.instructions.md'), + name: 'TypeScript', + filename: 'typescript.instructions.md', + description: 'TypeScript instructions', + source: PromptsStorage.local, + promptType: PromptsType.instructions, + disabled: false, + badge: '*.ts', + status: 'loaded', + }]); + instaService.stub(IAICustomizationItemsModel, { + getItems: () => items, + getCount: () => observableValue('test', 1), + getPluginCount: () => observableValue('test', 0), + whenSectionLoaded: async () => { }, + getActiveItemSource: () => ({ onDidAICustomizationItemsChange: Event.None, fetchProviderItems: async () => [], fetchAICustomizationItems: async () => [], fetchSourceFolders: async () => [], sessionResource: URI.parse('test:///session'), dispose() { } }), + }); + const widget = disposables.add(instaService.createInstance(AICustomizationListWidget)); + document.body.appendChild(widget.element); + disposables.add(toDisposable(() => widget.element.remove())); + setLayoutHeights(widget, 500); + + await widget.setSection(AICustomizationManagementSection.Instructions); + widget.layout(800, 500); + + const row = widget.element.querySelector('.ai-customization-list-item'); + assert.deepStrictEqual({ + badgeDisplay: row?.querySelector('.item-badge')?.style.display, + statusDisplay: row?.querySelector('.item-status-icon')?.style.display, + hasOverflowAction: !!row?.querySelector('.item-right .codicon-ellipsis'), + sectionExpanded: widget.element.querySelector('.customization-section-toggle')?.getAttribute('aria-expanded'), + }, { + badgeDisplay: 'none', + statusDisplay: 'none', + hasOverflowAction: true, + sectionExpanded: 'true', + }); + }); + + test('async section rerenders discard disposed virtual lists before redistributing height', async () => { + const items = observableValue('test', []); + let completeLoading!: () => void; + const loading = new Promise(resolve => completeLoading = resolve); + instaService.stub(IAICustomizationItemsModel, { + getItems: () => items, + getCount: () => observableValue('test', 0), + getPluginCount: () => observableValue('test', 0), + whenSectionLoaded: () => loading, + getActiveItemSource: () => ({ onDidAICustomizationItemsChange: Event.None, fetchProviderItems: async () => [], fetchAICustomizationItems: async () => [], fetchSourceFolders: async () => [], sessionResource: URI.parse('test:///session'), dispose() { } }), + }); + const widget = disposables.add(instaService.createInstance(AICustomizationListWidget)); + document.body.appendChild(widget.element); + disposables.add(toDisposable(() => widget.element.remove())); + setLayoutHeights(widget, 500); + + const setSection = widget.setSection(AICustomizationManagementSection.Skills); + items.set([ + ...Array.from({ length: 4 }, (_, index): IAICustomizationListItem => ({ + id: `workspace-${index}`, + uri: URI.file(`Q:\\workspace\\.github\\skills\\workspace-${index}\\SKILL.md`), + name: `Workspace ${index}`, + filename: 'SKILL.md', + source: PromptsStorage.local, + promptType: PromptsType.skill, + disabled: false, + })), + ...Array.from({ length: 2 }, (_, index): IAICustomizationListItem => ({ + id: `user-${index}`, + uri: URI.file(`Q:\\user\\skills\\user-${index}\\SKILL.md`), + name: `User ${index}`, + filename: 'SKILL.md', + source: PromptsStorage.user, + promptType: PromptsType.skill, + disabled: false, + })), + ], undefined); + completeLoading(); + await setSection; + + const content = widget.element.querySelector('.distributed-section-layout')!; + Object.defineProperty(content, 'clientHeight', { configurable: true, value: 399 }); + widget.layout(500, 800); + + const sectionHeights = Array.from(content.querySelectorAll('.virtualized-section-list'), section => section.style.height); + assert.deepStrictEqual({ + sectionCount: sectionHeights.length, + hasExpandedSection: sectionHeights.some(height => Number.parseInt(height) > 44), + }, { + sectionCount: 2, + hasExpandedSection: true, + }); + }); }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationManagementEditor.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationManagementEditor.test.ts index b76432c5b56f5..69a0cea3b82bc 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationManagementEditor.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationManagementEditor.test.ts @@ -14,6 +14,7 @@ import { IHoverService } from '../../../../../../platform/hover/browser/hover.js import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { AGENT_BUILTIN_CUSTOMIZATION_SCHEME } from '../../../../../../platform/agentHost/common/agentHostCustomizationUri.js'; import { toAgentHostUri } from '../../../../../../platform/agentHost/common/agentHostUri.js'; +import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { URI } from '../../../../../../base/common/uri.js'; import { AICustomizationManagementEditor, isCurrentPluginContributionNavigation } from '../../../browser/aiCustomization/aiCustomizationManagementEditor.js'; import { ChatConfiguration } from '../../../common/constants.js'; @@ -26,6 +27,7 @@ import { CustomizationMigrationCategoryId } from '../../../browser/aiCustomizati import type { ICustomizationSourceFolder } from '../../../common/customizationHarnessService.js'; import type { ICustomizationMigrationCategorySummary } from '../../../browser/aiCustomization/aiCustomizationWelcomePage.js'; import { AICustomizationManagementEditorInput } from '../../../browser/aiCustomization/aiCustomizationManagementEditorInput.js'; +import { workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js'; suite('aiCustomizationManagementEditor', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); @@ -72,10 +74,12 @@ suite('aiCustomizationManagementEditor', () => { viewMode: 'list' | 'migration' | 'editor' | 'mcpDetail' | 'pluginDetail' | 'toolsDetail'; dimension: undefined; hoverService: IHoverService; + instantiationService: IInstantiationService; configurationService: IConfigurationService; editorDisposables: DisposableStore; harnessService: { activeSessionResource: ISettableObservable }; migrationListContainer: HTMLElement | undefined; + migrationSectionLists: readonly unknown[]; migrationMigrateButton: { enabled: boolean; label: string } | undefined; migrationTitleElement: HTMLElement | undefined; migrationDescriptionElement: HTMLElement | undefined; @@ -152,8 +156,10 @@ suite('aiCustomizationManagementEditor', () => { update() { }, }), } as unknown as IHoverService; + editor.instantiationService = workbenchInstantiationService({}, editor.editorPreviewDisposables); editor.configurationService = configurationService ?? createConfigurationServiceStub(); editor.migrationListContainer = undefined; + editor.migrationSectionLists = []; editor.migrationMigrateButton = undefined; editor.migrationTitleElement = undefined; editor.migrationDescriptionElement = undefined; @@ -605,6 +611,51 @@ suite('aiCustomizationManagementEditor', () => { } }); + test('virtualized migration rows keep checkbox selection and keyboard traversal aligned', () => { + const editor = createTestEditor(undefined, createConfigurationServiceStub({ + [ChatConfiguration.ChatCustomizationsPromptMigrationEnabled]: true, + })); + const promptFiles = Array.from({ length: 6 }, (_, index): MigratableConfiguration => ({ + uri: URI.file(`/workspace/.github/prompts/workspace-${index}.prompt.md`), + name: `workspace-${index}.prompt.md`, + storage: PromptsStorage.local, + type: PromptsType.prompt, + source: PromptFileSource.GitHubWorkspace, + })); + editor.customizationsByMigrationCategory = new Map([[CustomizationMigrationCategoryId.PromptFiles, promptFiles]]); + editor.activeMigrationCategoryId = CustomizationMigrationCategoryId.PromptFiles; + editor.migrationListContainer = document.createElement('div'); + Object.defineProperty(editor.migrationListContainer, 'clientHeight', { configurable: true, value: 500 }); + editor.migrationMigrateButton = { enabled: false, label: '' }; + document.body.appendChild(editor.migrationListContainer); + + try { + editor.renderCustomizationMigrationPage(); + const firstRow = editor.migrationListContainer.querySelector('.monaco-list-row[data-index="0"]'); + firstRow?.click(); + const lastVisibleMoreButton = editor.migrationListContainer.querySelector('.monaco-list-row[data-index="4"] .prompt-migration-more-action'); + const tabEvent = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }); + Object.defineProperty(tabEvent, 'keyCode', { get: () => 9 }); + lastVisibleMoreButton?.dispatchEvent(tabEvent); + + assert.deepStrictEqual({ + firstRowSelected: firstRow?.classList.contains('selected'), + firstRowAriaSelected: firstRow?.getAttribute('aria-selected') === 'true', + focusedRowIndex: document.activeElement?.closest('.monaco-list-row')?.getAttribute('data-index'), + focusedControlIsCheckbox: document.activeElement?.classList.contains('monaco-checkbox'), + }, { + firstRowSelected: false, + firstRowAriaSelected: false, + focusedRowIndex: '5', + focusedControlIsCheckbox: true, + }); + } finally { + editor.migrationListContainer.remove(); + editor.migrationPageDisposables.dispose(); + editor.editorPreviewDisposables.dispose(); + } + }); + test('group migration selection retains keyboard focus', () => { const editor = createTestEditor(undefined, createConfigurationServiceStub({ [ChatConfiguration.ChatCustomizationsPromptMigrationEnabled]: true, @@ -631,6 +682,7 @@ suite('aiCustomizationManagementEditor', () => { editor.setCustomizationSelectedForMigration(promptFile, true); } editor.migrationListContainer = document.createElement('div'); + Object.defineProperty(editor.migrationListContainer, 'clientHeight', { configurable: true, value: 500 }); editor.migrationMigrateButton = { enabled: false, label: '' }; document.body.appendChild(editor.migrationListContainer); @@ -782,6 +834,7 @@ suite('aiCustomizationManagementEditor', () => { editor.setCustomizationSelectedForMigration(promptFile, true); } editor.migrationListContainer = document.createElement('div'); + Object.defineProperty(editor.migrationListContainer, 'clientHeight', { configurable: true, value: 500 }); editor.migrationTitleElement = document.createElement('h2'); editor.migrationDescriptionElement = document.createElement('p'); editor.migrationLinkElement = document.createElement('a'); diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/mcpListWidget.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/mcpListWidget.test.ts index 7d19ecffcb23c..26c8943a589b9 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/mcpListWidget.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/mcpListWidget.test.ts @@ -893,6 +893,7 @@ suite('mcpListWidget', () => { } as unknown as ICustomizationHarnessService; const renderer = new McpServerItemRenderer( async () => { }, + () => { }, { isSessionsWindow: true } as IAICustomizationWorkspaceService, { plugins: observableValue('plugins', []) } as unknown as IAgentPluginService, { setupManagedHover: () => Disposable.None } as unknown as IHoverService, @@ -925,6 +926,13 @@ suite('mcpListWidget', () => { const button = ctx.actionNode(); assert.ok(button, 'expected an action for an erroring server'); + assert.deepStrictEqual({ + text: ctx.templateData.statusBadge.textContent, + className: ctx.templateData.statusBadge.className, + }, { + text: 'Error', + className: 'plugin-list-item-status mcp-runtime-status-badge error', + }); // What the autorun does in production while a server sits in error. for (let i = 0; i < 10; i++) { diff --git a/src/vs/workbench/contrib/chat/test/browser/attachments/implicitContextAttachment.test.ts b/src/vs/workbench/contrib/chat/test/browser/attachments/implicitContextAttachment.test.ts new file mode 100644 index 0000000000000..de4f33bcdf004 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/attachments/implicitContextAttachment.test.ts @@ -0,0 +1,36 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { URI } from '../../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { isImplicitContextAlreadyAttached } from '../../../browser/attachments/implicitContextAttachment.js'; +import { IChatRequestStringVariableEntry } from '../../../common/attachments/chatVariableEntries.js'; + +suite('ImplicitContextAttachment', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('matches refreshed string context by resource URI', () => { + const uri = URI.parse('pr://github/microsoft/vscode/195'); + const attachment: IChatRequestStringVariableEntry = { + kind: 'string', + id: 'vscode.implicit.string', + name: '#195 Support for adding, editing, and deleting comments', + value: 'pull request context', + uri, + handle: 1, + }; + + assert.deepStrictEqual({ + refreshedHandle: isImplicitContextAlreadyAttached([attachment], uri, undefined, 2), + differentResource: isImplicitContextAlreadyAttached([attachment], URI.parse('pr://github/microsoft/vscode/196'), undefined, 2), + activeResource: isImplicitContextAlreadyAttached([attachment], uri, undefined, undefined), + }, { + refreshedHandle: true, + differentResource: false, + activeResource: false, + }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/chatSpeechToTextService.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatSpeechToTextService.test.ts index d2ad83916cca2..378451647cfb9 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatSpeechToTextService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatSpeechToTextService.test.ts @@ -742,23 +742,17 @@ suite('ChatSpeechToTextService', () => { }; await createService()._cleanupWithLanguageModel('control transcript', CancellationToken.None); - await createService('auto', 'gpt-5.4-nano')._cleanupWithLanguageModel('Nano experiment transcript', CancellationToken.None); await createService('auto', 'gpt-5.6-luna')._cleanupWithLanguageModel('Luna experiment transcript', CancellationToken.None); await createService('auto', 'unexpected-model')._cleanupWithLanguageModel('unknown experiment transcript', CancellationToken.None); - await createService('gpt-5.4-nano')._cleanupWithLanguageModel('configured Nano transcript', CancellationToken.None); await createService('gpt-5.6-luna')._cleanupWithLanguageModel('configured Luna transcript', CancellationToken.None); await createService('copilot-utility-small', 'gpt-5.6-luna')._cleanupWithLanguageModel('configured utility transcript', CancellationToken.None); assert.deepStrictEqual(selectors, [ { vendor: 'copilot', id: 'copilot-utility-small' }, - { vendor: 'copilot', id: 'copilot-dictation-cleanup-nano' }, + { vendor: 'copilot', id: 'gpt-5.6-luna' }, { vendor: 'copilot', id: 'copilot-utility-small' }, - { vendor: 'copilot', id: 'copilot-dictation-cleanup-luna' }, { vendor: 'copilot', id: 'copilot-utility-small' }, - { vendor: 'copilot', id: 'copilot-utility-small' }, - { vendor: 'copilot', id: 'copilot-dictation-cleanup-nano' }, - { vendor: 'copilot', id: 'copilot-utility-small' }, - { vendor: 'copilot', id: 'copilot-dictation-cleanup-luna' }, + { vendor: 'copilot', id: 'gpt-5.6-luna' }, { vendor: 'copilot', id: 'copilot-utility-small' }, { vendor: 'copilot', id: 'copilot-utility-small' }, ]); @@ -795,7 +789,6 @@ suite('ChatSpeechToTextService', () => { return service; }; - await createService('gpt-5.4-nano')._cleanupWithLanguageModel('Nano transcript', CancellationToken.None); await createService('gpt-5.6-luna')._cleanupWithLanguageModel('Luna transcript', CancellationToken.None); const fallbackService = createService('gpt-5.6-luna'); let selectionCall = 0; @@ -803,7 +796,6 @@ suite('ChatSpeechToTextService', () => { await fallbackService._cleanupWithLanguageModel('utility fallback transcript', CancellationToken.None); assert.deepStrictEqual(requestConfigurations, [ - { reasoningEffort: 'none' }, { reasoningEffort: 'none' }, undefined, ]); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatPlanReviewPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatPlanReviewPart.test.ts index 57975e53ea1de..98aee100c2795 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatPlanReviewPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatPlanReviewPart.test.ts @@ -26,6 +26,7 @@ import { ITextFileContent, ITextFileService } from '../../../../../../services/t import { DeferredPromise } from '../../../../../../../base/common/async.js'; import { AgentEditorCommentsBridge, IAgentEditorComment, IAgentEditorCommentsBridge } from '../../../../../../services/agentEditorComments/common/agentEditorComments.js'; import { Emitter, Event as VSCodeEvent } from '../../../../../../../base/common/event.js'; +import { IContextMenuService } from '../../../../../../../platform/contextview/browser/contextView.js'; function createMockReview(overrides?: Partial): IChatPlanReview { return { @@ -87,6 +88,7 @@ suite('ChatPlanReviewPart', () => { let lastTextFileService: ITextFileService | undefined; let lastModelService: IModelService | undefined; let lastCommentsBridge: AgentEditorCommentsBridge | undefined; + let lastContextMenuService: IContextMenuService | undefined; let fileChangesEmitter: Emitter | undefined; function createWidget(review: IChatPlanReview, dialogService?: TestDialogService, onSubmit?: () => void): ChatPlanReviewPart { @@ -101,6 +103,7 @@ suite('ChatPlanReviewPart', () => { lastTextFileService = instantiationService.get(ITextFileService); lastModelService = instantiationService.get(IModelService); lastCommentsBridge = commentsBridge; + lastContextMenuService = instantiationService.get(IContextMenuService); if (fileChangesEmitter) { sinon.stub(instantiationService.get(IFileService), 'createWatcher').returns({ onDidChange: fileChangesEmitter.event, @@ -133,6 +136,7 @@ suite('ChatPlanReviewPart', () => { lastTextFileService = undefined; lastModelService = undefined; lastCommentsBridge = undefined; + lastContextMenuService = undefined; fileChangesEmitter = undefined; sinon.restore(); }); @@ -358,9 +362,15 @@ suite('ChatPlanReviewPart', () => { }); suite('Feedback mode', () => { - test('clicking Review button opens the plan editor and shows Submit Feedback button', async () => { - createWidget(createMockReviewWithPlan()); + test('clicking Review button opens the plan editor and preserves the approval dropdown', async () => { + createWidget(createMockReviewWithPlan({ + actions: [ + { id: 'interactive', label: 'Implement Plan', default: true }, + { id: 'autopilot', label: 'Implement with Autopilot' }, + ], + })); const openEditorSpy = sinon.spy(lastEditorService!, 'openEditor'); + const showContextMenuStub = sinon.stub(lastContextMenuService!, 'showContextMenu'); const reviewButton = getReviewButton(widget)!; reviewButton.click(); @@ -375,11 +385,23 @@ suite('ChatPlanReviewPart', () => { const feedbackSection = getFeedbackSection(widget); assert.notStrictEqual(feedbackSection.style.display, 'none', 'feedback section should be visible'); - // Footer should have Submit Feedback + Reject (no approve, no Provide Feedback). + // Footer should keep approval available when no feedback has been entered. const buttons = getFooterButtons(widget); assert.ok(buttons.some(b => b.textContent?.includes('Submit Feedback')), 'should have Submit Feedback button'); assert.ok(buttons.some(b => b.textContent?.includes('Reject')), 'should still have Reject button'); - assert.ok(!buttons.some(b => b.textContent?.includes('Autopilot')), 'approve button should be hidden'); + const dropdown = widget.domNode.querySelector('.chat-plan-review-footer .monaco-button-dropdown'); + assert.ok(dropdown, 'approval dropdown should remain visible'); + assert.ok(!dropdown.classList.contains('disabled'), 'approval dropdown should remain enabled without feedback'); + + (dropdown.querySelector('.monaco-dropdown-button') as HTMLElement).click(); + assert.strictEqual(showContextMenuStub.calledOnce, true, 'approval dropdown should open its menu'); + const menuDelegate = showContextMenuStub.firstCall.args[0]; + assert.ok(menuDelegate.getActions); + const autopilotAction = menuDelegate.getActions().find(action => action.id === 'Implement with Autopilot'); + assert.ok(autopilotAction, 'non-default approval action should be available'); + await autopilotAction.run(); + await tick(); + assert.deepStrictEqual(lastSubmitResult, { action: 'Implement with Autopilot', actionId: 'autopilot', rejected: false }); }); test('reject button remains visible in feedback mode', async () => { @@ -403,11 +425,11 @@ suite('ChatPlanReviewPart', () => { const feedbackSection = getFeedbackSection(widget); assert.notStrictEqual(feedbackSection.style.display, 'none', 'feedback section should be visible'); - // Footer should have Submit Feedback + Reject (no approve, no Provide Feedback). + // Footer should have Submit Feedback + Approve + Reject. const buttons = getFooterButtons(widget); assert.ok(buttons.some(b => b.textContent?.includes('Submit Feedback')), 'should have Submit Feedback button'); assert.ok(buttons.some(b => b.textContent?.includes('Reject')), 'should still have Reject button'); - assert.ok(!buttons.some(b => b.textContent?.includes('Autopilot')), 'approve button should be hidden'); + assert.ok(buttons.some(b => b.textContent?.includes('Autopilot')), 'approve button should remain visible'); }); test('reject button remains visible in feedback mode', async () => { @@ -431,11 +453,11 @@ suite('ChatPlanReviewPart', () => { const feedbackSection = getFeedbackSection(widget); assert.notStrictEqual(feedbackSection.style.display, 'none', 'feedback section should be visible'); - // Footer should have Submit Feedback + Reject (no approve, no Provide Feedback). + // Footer should have Submit Feedback + Approve + Reject. const buttons = getFooterButtons(widget); assert.ok(buttons.some(b => b.textContent?.includes('Submit Feedback')), 'should have Submit Feedback button'); assert.ok(buttons.some(b => b.textContent?.includes('Reject')), 'should still have Reject button'); - assert.ok(!buttons.some(b => b.textContent?.includes('Autopilot')), 'approve button should be hidden'); + assert.ok(buttons.some(b => b.textContent?.includes('Autopilot')), 'approve button should remain visible'); }); test('reject button remains visible in feedback mode', async () => { @@ -515,6 +537,66 @@ suite('ChatPlanReviewPart', () => { assert.ok(submitButton); assert.ok(submitButton!.classList.contains('disabled'), 'Submit Feedback should be disabled when nothing to submit'); }); + + test('approval is disabled while feedback is pending and restored when feedback is cleared', async () => { + createWidget(createMockReviewWithPlan()); + + getReviewButton(widget)!.click(); + await tick(); + + const textarea = widget.domNode.querySelector('.chat-plan-review-feedback-textarea') as HTMLTextAreaElement; + const approveButton = getFooterButtons(widget).find(b => b.textContent?.includes('Autopilot'))!; + const submitButton = getFooterButtons(widget).find(b => b.textContent?.includes('Submit Feedback'))!; + + textarea.value = 'Please change the plan'; + textarea.dispatchEvent(new Event('input')); + + assert.deepStrictEqual({ + approveDisabled: approveButton.classList.contains('disabled'), + submitDisabled: submitButton.classList.contains('disabled'), + }, { + approveDisabled: true, + submitDisabled: false, + }); + + textarea.value = ''; + textarea.dispatchEvent(new Event('input')); + + assert.deepStrictEqual({ + approveDisabled: approveButton.classList.contains('disabled'), + submitDisabled: submitButton.classList.contains('disabled'), + }, { + approveDisabled: false, + submitDisabled: true, + }); + }); + + test('closing feedback mode keeps approval disabled while feedback is pending', async () => { + createWidget(createMockReviewWithPlan()); + + getReviewButton(widget)!.click(); + await tick(); + + const textarea = widget.domNode.querySelector('.chat-plan-review-feedback-textarea') as HTMLTextAreaElement; + textarea.value = 'Please change the plan'; + textarea.dispatchEvent(new Event('input')); + + (widget.domNode.querySelector('.chat-plan-review-feedback-close') as HTMLElement).click(); + await tick(); + + const approveButton = getFooterButtons(widget).find(b => b.textContent?.includes('Autopilot'))!; + approveButton.click(); + await tick(); + assert.deepStrictEqual({ + feedbackHidden: getFeedbackSection(widget).style.display, + approveDisabled: approveButton.classList.contains('disabled'), + submitResult: lastSubmitResult, + }, { + feedbackHidden: 'none', + approveDisabled: true, + submitResult: undefined, + }); + }); }); suite('Inline comments list', () => { @@ -888,7 +970,7 @@ suite('ChatPlanReviewPart', () => { collapseButton.click(); const footerButtons = getFooterButtons(widget); assert.ok(footerButtons.some(b => b.textContent?.includes('Submit Feedback')), 'submit feedback button should remain after expand'); - assert.ok(!footerButtons.some(b => b.textContent?.includes('Autopilot')), 'approve should still be hidden in feedback mode'); + assert.ok(footerButtons.some(b => b.textContent?.includes('Autopilot')), 'approve should remain available in feedback mode'); }); test('a comment added while collapsed is reflected in the inline action', async () => { diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSubagentContentPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSubagentContentPart.test.ts index 5a11d77076abf..fa887531a5b21 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSubagentContentPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSubagentContentPart.test.ts @@ -30,7 +30,7 @@ import { IRenderedMarkdown, MarkdownRenderOptions } from '../../../../../../../b import { IMarkdownString, isMarkdownString } from '../../../../../../../base/common/htmlContent.js'; import { EditorPool, DiffEditorPool } from '../../../../browser/widget/chatContentParts/chatContentCodePools.js'; import { IHoverService } from '../../../../../../../platform/hover/browser/hover.js'; -import { IConfigurationService } from '../../../../../../../platform/configuration/common/configuration.js'; +import { ConfigurationTarget, IConfigurationChangeEvent, IConfigurationService } from '../../../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../../../platform/configuration/test/common/testConfigurationService.js'; import { AccessibilityWorkbenchSettingId } from '../../../../../accessibility/browser/accessibilityConfiguration.js'; import { URI } from '../../../../../../../base/common/uri.js'; @@ -55,6 +55,12 @@ class TestOpenChatActionViewItem extends ActionViewItem { } } +class TestOpenSubagentChatActionViewItem extends OpenSubagentChatActionViewItem { + get tooltip(): string | undefined { + return this.getTooltip(); + } +} + class TestActionViewItemService implements IActionViewItemService { declare _serviceBrand: undefined; private readonly _onDidChange = new Emitter(); @@ -340,6 +346,7 @@ suite('ChatSubagentContentPart', () => { )); instantiationService.stub(IMenuService, menuService); (instantiationService.get(IConfigurationService) as TestConfigurationService).setUserConfiguration(ChatConfiguration.SubagentsUseRichRendering, true); + (instantiationService.get(IConfigurationService) as TestConfigurationService).setUserConfiguration(ChatConfiguration.SubagentsShowCreditUsage, true); // Mock list pool and editor pool mockListPool = {} as CollapsibleListPool; @@ -524,6 +531,79 @@ suite('ChatSubagentContentPart', () => { }); }); + test('should update displayed and accessible credit usage when the setting changes', () => { + const configService = instantiationService.get(IConfigurationService) as TestConfigurationService; + const setShowCreditUsage = (value: boolean) => { + configService.setUserConfiguration(ChatConfiguration.SubagentsShowCreditUsage, value); + configService.onDidChangeConfigurationEmitter.fire({ + source: ConfigurationTarget.USER, + affectedKeys: new Set([ChatConfiguration.SubagentsShowCreditUsage]), + change: { keys: [ChatConfiguration.SubagentsShowCreditUsage], overrides: [] }, + affectsConfiguration: key => key === ChatConfiguration.SubagentsShowCreditUsage, + } satisfies IConfigurationChangeEvent); + }; + const action = store.add(new Action('openSubagent', 'Open Subagent')); + const viewItem = store.add(instantiationService.createInstance( + TestOpenSubagentChatActionViewItem, + { + chatResource: 'ahp-chat://subagent/Y29waWxvdGNsaTovc2Vzc2lvbg/tool-call', + parentSessionResource: 'agent-host-copilotcli:/session', + startedAt: 1_000, + duration: 65_000, + credits: 2.5, + }, + action, + {}, + false, + )); + const container = mainWindow.document.createElement('div'); + viewItem.render(container); + const credits = container.querySelector('.chat-subagent-pill-credits'); + const before = { + text: credits?.textContent, + hidden: credits?.classList.contains('hidden'), + tooltip: viewItem.tooltip, + ariaLabel: container.getAttribute('aria-label'), + }; + + setShowCreditUsage(false); + const hidden = { + text: credits?.textContent, + hidden: credits?.classList.contains('hidden'), + tooltip: viewItem.tooltip, + ariaLabel: container.getAttribute('aria-label'), + }; + + setShowCreditUsage(true); + const restored = { + text: credits?.textContent, + hidden: credits?.classList.contains('hidden'), + tooltip: viewItem.tooltip, + ariaLabel: container.getAttribute('aria-label'), + }; + + assert.deepStrictEqual({ before, hidden, restored }, { + before: { + text: '2.5 credits', + hidden: false, + tooltip: 'Open Subagent\n2.5 credits', + ariaLabel: 'Open Subagent. Worked for 1m 5s. 2.5 credits', + }, + hidden: { + text: '', + hidden: true, + tooltip: 'Open Subagent', + ariaLabel: 'Open Subagent. Worked for 1m 5s', + }, + restored: { + text: '2.5 credits', + hidden: false, + tooltip: 'Open Subagent\n2.5 credits', + ariaLabel: 'Open Subagent. Worked for 1m 5s. 2.5 credits', + }, + }); + }); + test('should render the specialized subagent type before the title', () => { const action = store.add(new Action('openSubagent', 'Open Subagent')); const viewItem = store.add(instantiationService.createInstance( diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatPetWidget.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatPetWidget.test.ts index d4aeffd7a2f7c..d738a35d560d7 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatPetWidget.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatPetWidget.test.ts @@ -616,7 +616,7 @@ suite('ChatPetWidget', () => { assert.strictEqual(CHAT_PET_CONFIRMATION_ATTENTION_DURATION, 2_000); }); - test('shows the window pet only in the active VS Code window and reserves only its active host', () => { + test('shows the window pet only in the active VS Code window and reserves space in every visible chat', () => { assert.deepStrictEqual({ visible: [ isChatPetVisible(false, false), diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatInputPickerActionItem.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatInputPickerActionItem.test.ts new file mode 100644 index 0000000000000..dd12488cda6c2 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatInputPickerActionItem.test.ts @@ -0,0 +1,68 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { IAction } from '../../../../../../../base/common/actions.js'; +import { constObservable } from '../../../../../../../base/common/observable.js'; +import { mock } from '../../../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; +import { IActionWidgetService } from '../../../../../../../platform/actionWidget/browser/actionWidget.js'; +import { IContextKeyService } from '../../../../../../../platform/contextkey/common/contextkey.js'; +import { IKeybindingService } from '../../../../../../../platform/keybinding/common/keybinding.js'; +import { ITelemetryService } from '../../../../../../../platform/telemetry/common/telemetry.js'; +import { ChatInputPickerActionViewItem } from '../../../../browser/widget/input/chatInputPickerActionItem.js'; + +const action: IAction = { + id: 'test.chatInputPicker', + label: 'Agent', + tooltip: '', + class: undefined, + enabled: true, + run: async () => { }, +}; + +class TestChatInputPickerActionViewItem extends ChatInputPickerActionViewItem { + constructor() { + super( + action, + { actions: [] }, + { compact: constObservable(false) }, + new class extends mock() { }, + new class extends mock() { }, + new class extends mock() { }, + new class extends mock() { }, + ); + } + + setElement(element: HTMLElement): void { + this.element = element; + } +} + +suite('ChatInputPickerActionViewItem', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + test('keeps the picker in the Tab order when it is not the leading toolbar item', () => { + const item = disposables.add(new TestChatInputPickerActionViewItem()); + const element = document.createElement('a'); + item.setElement(element); + + item.setFocusable(false); + const afterToolbarUpdate = element.tabIndex; + item.focus(); + const afterFocus = element.tabIndex; + item.blur(); + + assert.deepStrictEqual({ + afterToolbarUpdate, + afterFocus, + afterBlur: element.tabIndex, + }, { + afterToolbarUpdate: 0, + afterFocus: 0, + afterBlur: 0, + }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelPickerAutoRow.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelPickerAutoRow.test.ts new file mode 100644 index 0000000000000..ba4d94751e6be --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelPickerAutoRow.test.ts @@ -0,0 +1,110 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { IStringDictionary } from '../../../../../../../../base/common/collections.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../../base/test/common/utils.js'; +import { ModelPickerAutoRow } from '../../../../../browser/widget/input/modelPicker/modelPickerAutoRow.js'; +import { IModelConfigurationAccess } from '../../../../../browser/widget/input/modelPicker/modelPickerModelConfig.js'; +import { ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier } from '../../../../../common/languageModels.js'; + +function createAutoModel(): ILanguageModelChatMetadataAndIdentifier { + return { + identifier: 'copilot/auto', + metadata: { + id: 'auto', + name: 'Auto', + vendor: 'copilot', + version: '1.0', + family: 'auto', + maxInputTokens: 128000, + maxOutputTokens: 4096, + isDefaultForLocation: {}, + } as ILanguageModelChatMetadata, + }; +} + +function createConfigurationAccess(): IModelConfigurationAccess { + const values: IStringDictionary> = {}; + return { + getModelConfiguration: modelId => values[modelId], + setModelConfiguration: async (modelId, next) => { values[modelId] = { ...values[modelId], ...next }; }, + getModelConfigurationActions: () => [], + }; +} + +suite('ModelPickerAutoRow', () => { + + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + function createRow(initiallyEnabled: boolean) { + const toggles: boolean[] = []; + let enabled = initiallyEnabled; + const row = disposables.add(new ModelPickerAutoRow({ + autoModel: createAutoModel(), + configurationAccess: createConfigurationAccess(), + isEnabled: () => enabled, + onToggle: next => { + enabled = next; + toggles.push(next); + }, + })); + const element = row.element; + return { + toggles, + main: element.querySelector('.chat-model-picker-auto-main') as HTMLElement, + label: element.querySelector('.chat-model-picker-auto-label') as HTMLElement, + description: element.querySelector('.chat-model-picker-auto-description') as HTMLElement, + toggle: element.querySelector('.monaco-switch') as HTMLElement, + }; + } + + test('clicking the label toggles Auto on', () => { + const { toggles, label, toggle } = createRow(false); + + label.click(); + + assert.deepStrictEqual({ toggles, checked: toggle.getAttribute('aria-checked') }, { toggles: [true], checked: 'true' }); + }); + + test('clicking the label toggles Auto back off', () => { + const { toggles, label, toggle } = createRow(true); + + label.click(); + + assert.deepStrictEqual({ toggles, checked: toggle.getAttribute('aria-checked') }, { toggles: [false], checked: 'false' }); + }); + + test('clicking the switch itself reports one change, not two', () => { + const { toggles, toggle } = createRow(false); + + toggle.click(); + + assert.deepStrictEqual({ toggles, checked: toggle.getAttribute('aria-checked') }, { toggles: [true], checked: 'true' }); + }); + + test('clicking the strip beside the label toggles Auto', () => { + const { toggles, main, toggle } = createRow(false); + + main.click(); + + assert.deepStrictEqual({ toggles, checked: toggle.getAttribute('aria-checked') }, { toggles: [true], checked: 'true' }); + }); + + // The row sits in the popup's footer, which is dismissed when focus leaves it. + // Pressing inert parts of the row must not move focus, or the popup closes first. + test('pressing the strip and the description does not move focus', () => { + const { main, description } = createRow(false); + const defaultPrevented = (target: HTMLElement) => { + const event = new MouseEvent('mousedown', { bubbles: true, cancelable: true }); + target.dispatchEvent(event); + return event.defaultPrevented; + }; + + assert.deepStrictEqual( + { strip: defaultPrevented(main), description: defaultPrevented(description) }, + { strip: true, description: true }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelPickerConfiguration.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelPickerConfiguration.test.ts index 2c224b08edd5f..22d43b834651c 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelPickerConfiguration.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelPickerConfiguration.test.ts @@ -11,7 +11,7 @@ import { IActionWidgetService } from '../../../../../../../../platform/actionWid import { IActionWidgetDropdownAction } from '../../../../../../../../platform/actionWidget/browser/actionWidgetDropdown.js'; import { ITelemetryService } from '../../../../../../../../platform/telemetry/common/telemetry.js'; import { ModelPickerConfiguration } from '../../../../../browser/widget/input/modelPicker/modelPickerConfiguration.js'; -import { IModelConfigurationAccess } from '../../../../../browser/widget/input/modelPicker/modelPickerActionItem.js'; +import { IModelConfigurationAccess } from '../../../../../browser/widget/input/modelPicker/modelPickerModelConfig.js'; import { ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier } from '../../../../../common/languageModels.js'; /** diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelPickerTabs.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelPickerTabs.test.ts new file mode 100644 index 0000000000000..1e2b343b962ff --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelPickerTabs.test.ts @@ -0,0 +1,660 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { IStringDictionary } from '../../../../../../../../base/common/collections.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../../base/test/common/utils.js'; +import { IModelConfigurationAccess, getModelConfigProperty, getModelConfigSummary, isExtendedContext, MODEL_CONFIG_GROUP_CONTEXT } from '../../../../../browser/widget/input/modelPicker/modelPickerModelConfig.js'; +import { getModelBadge } from '../../../../../browser/widget/input/modelPicker/modelPickerBadges.js'; +import { latestOfEachLine, parseModelLine } from '../../../../../browser/widget/input/modelPicker/modelPickerLineage.js'; +import { buildSpeedVariants, collapseSpeedVariants } from '../../../../../browser/widget/input/modelPicker/modelPickerVariants.js'; +import { buildModelPickerDestinations, buildModelPickerSections, hasPromotedModels, IModelPickerProviderPlaceholder } from '../../../../../browser/widget/input/modelPicker/modelPickerTabs.js'; +import { getProviderGroupKey } from '../../../../../browser/widget/input/modelPicker/modelPickerItemPrimitives.js'; +import { ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService, IModelControlEntry } from '../../../../../common/languageModels.js'; + +interface IFixtureModelOptions { + readonly vendor?: string; + readonly isBYOK?: boolean; + readonly byokModelIdentifier?: string; + readonly modelGroupId?: string; +} + +function createModel(id: string, name: string, options: IFixtureModelOptions = {}): ILanguageModelChatMetadataAndIdentifier { + const vendor = options.vendor ?? 'copilot'; + return { + identifier: `${vendor}/${id}`, + metadata: { + id, + name, + vendor, + version: '1.0', + family: id, + isBYOK: options.isBYOK, + byokModelIdentifier: options.byokModelIdentifier, + modelGroup: options.modelGroupId ? { id: options.modelGroupId } : undefined, + maxInputTokens: 128000, + maxOutputTokens: 4096, + isDefaultForLocation: {}, + } as ILanguageModelChatMetadata, + }; +} + +function createConfigurableModel(): ILanguageModelChatMetadataAndIdentifier { + const model = createModel('gpt-5-5', 'GPT-5.5'); + return { + ...model, + metadata: { + ...model.metadata, + configurationSchema: { + properties: { + reasoningEffort: { + type: 'string', + group: 'navigation', + enum: ['low', 'medium', 'xhigh'], + enumItemLabels: ['Low', 'Medium', 'Extra high'], + default: 'medium', + }, + contextSize: { + type: 'number', + group: 'tokens', + enum: [264000, 1000000], + enumItemLabels: ['264K', '1M'], + default: 264000, + }, + }, + }, + }, + }; +} + +function createLanguageModelsService(): ILanguageModelsService { + return { + getVendors: () => [ + { vendor: 'copilot', displayName: 'GitHub Copilot', isDefault: true }, + { vendor: 'ollama', displayName: 'Ollama', isDefault: false }, + { vendor: 'openai', displayName: 'OpenAI', isDefault: false }, + ], + getLanguageModelGroups: () => [], + } as unknown as ILanguageModelsService; +} + +function createConfigurationAccess(values: IStringDictionary = {}): IModelConfigurationAccess { + return { + getModelConfiguration: () => values, + setModelConfiguration: async () => { }, + getModelConfigurationActions: () => [], + }; +} + +suite('Model picker destinations', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + const service = createLanguageModelsService(); + const auto = createModel('auto', 'Auto'); + const gpt = createModel('gpt-5-5', 'GPT-5.5'); + const claude = createModel('claude-sonnet-5', 'Claude Sonnet 5'); + const gemini = createModel('gemini-3-1-pro', 'Gemini 3.1 Pro'); + const llama = createModel('llama-3', 'Llama 3', { vendor: 'ollama', isBYOK: true }); + const mistral = createModel('mistral', 'Mistral Large', { vendor: 'openai', isBYOK: true }); + + const summarize = (models: readonly ILanguageModelChatMetadataAndIdentifier[], placeholders: readonly IModelPickerProviderPlaceholder[] = []) => + buildModelPickerDestinations(models, service, placeholders) + .map(destination => ({ id: destination.id, label: destination.label, models: destination.models.map(model => model.metadata.name) })); + + /** The destination id a provider gets, keyed by identity rather than by display name. */ + const providerId = (vendor: string, label: string) => `provider:${getProviderGroupKey(vendor, label)}`; + + test('a host that relays the built-in provider keeps its models built in', () => { + // An agent host registers a vendor of its own and republishes the built-in + // provider's catalogue under it, so neither the vendor nor the BYOK flags say + // where a model actually came from. Only the provider group does. + const relayed = createModel('gpt-5-5', 'GPT-5.5', { + vendor: 'agent-host-copilotcli', + isBYOK: true, + byokModelIdentifier: 'copilot/gpt-5-5', + modelGroupId: 'copilot', + }); + const relayedNative = createModel('cli-model', 'CLI Model', { + vendor: 'agent-host-copilotcli', + isBYOK: true, + modelGroupId: 'copilotcli', + }); + const relayedByok = createModel('llama-3', 'Llama 3', { + vendor: 'agent-host-copilotcli', + isBYOK: true, + modelGroupId: 'ollama', + }); + assert.deepStrictEqual( + summarize([relayed, relayedNative, relayedByok]), + [ + { id: 'builtIn', label: 'GitHub Copilot', models: ['GPT-5.5', 'CLI Model'] }, + // The relayed model carries the host's vendor, so that is the identity it + // is keyed by; only the group supplies the name the user reads. + { id: providerId('agent-host-copilotcli', 'Ollama'), label: 'Ollama', models: ['Llama 3'] }, + ], + ); + }); + + test('a plan that grants only Auto still opens on the built-in destination', () => { + // Auto has its own row rather than a place in the list, so without this the + // picker would have no destination at all and open on nothing. + assert.deepStrictEqual( + summarize([auto]), + [{ id: 'builtIn', label: 'GitHub Copilot', models: [] }], + ); + }); + + test('models from only the built-in provider yield a single destination', () => { + assert.deepStrictEqual( + summarize([auto, gpt, claude]), + [{ id: 'builtIn', label: 'GitHub Copilot', models: ['GPT-5.5', 'Claude Sonnet 5'] }], + ); + }); + + test('a provider the user added gets its own destination', () => { + assert.deepStrictEqual( + summarize([gpt, llama]), + [ + { id: 'builtIn', label: 'GitHub Copilot', models: ['GPT-5.5'] }, + { id: providerId('ollama', 'Ollama'), label: 'Ollama', models: ['Llama 3'] }, + ], + ); + }); + + test('every provider the user added gets a destination, in name order', () => { + assert.deepStrictEqual( + summarize([gpt, mistral, llama]), + [ + { id: 'builtIn', label: 'GitHub Copilot', models: ['GPT-5.5'] }, + { id: providerId('ollama', 'Ollama'), label: 'Ollama', models: ['Llama 3'] }, + { id: providerId('openai', 'OpenAI'), label: 'OpenAI', models: ['Mistral Large'] }, + ], + ); + }); + + test('two providers sharing a display name each keep their own tab', () => { + // Nothing stops two providers from presenting the same name, and merging them + // would file one provider's models under the other. + const sameName = createModel('local-1', 'Local One', { vendor: 'ollama', isBYOK: true }); + const alsoSameName = createModel('local-2', 'Local Two', { vendor: 'openai', isBYOK: true }); + const service = { + getVendors: () => [ + { vendor: 'copilot', displayName: 'GitHub Copilot', isDefault: true }, + { vendor: 'ollama', displayName: 'Local Models', isDefault: false }, + { vendor: 'openai', displayName: 'Local Models', isDefault: false }, + ], + getLanguageModelGroups: () => [], + } as unknown as ILanguageModelsService; + + assert.deepStrictEqual( + buildModelPickerDestinations([sameName, alsoSameName], service) + .map(destination => ({ id: destination.id, label: destination.label, models: destination.models.map(model => model.metadata.name) })), + [ + { id: providerId('ollama', 'Local Models'), label: 'Local Models', models: ['Local One'] }, + { id: providerId('openai', 'Local Models'), label: 'Local Models', models: ['Local Two'] }, + ], + ); + }); + + test('a provider waiting on sign-in gets its own destination with no models', () => { + assert.deepStrictEqual( + summarize([gpt], [{ vendor: 'ollama', label: 'Ollama', message: 'Sign in to see available models.' }]), + [ + { id: 'builtIn', label: 'GitHub Copilot', models: ['GPT-5.5'] }, + { id: providerId('ollama', 'Ollama'), label: 'Ollama', models: [] }, + ], + ); + }); + + test('the built-in provider waiting on sign-in still gets its destination', () => { + assert.deepStrictEqual( + summarize([], [{ vendor: 'copilot', label: 'GitHub Copilot', message: 'Sign in to see available models.' }]), + [{ id: 'builtIn', label: 'GitHub Copilot', models: [] }], + ); + }); + + test('sections place each model once, with the selected model in the shortlist', () => { + const controlModels: IStringDictionary = { + 'gemini-3-1-pro': { label: 'Gemini 3.1 Pro', featured: true, exists: true }, + }; + const sections = buildModelPickerSections({ + models: [gpt, claude, gemini], + selectedModelId: 'copilot/gpt-5-5', + recentModelIds: ['copilot/gemini-3-1-pro'], + pinnedModelIds: ['copilot/claude-sonnet-5'], + controlModels, + showSuggested: true, + }); + assert.deepStrictEqual( + { + pinned: sections.pinned.map(model => model.metadata.name), + suggested: sections.suggested.map(model => model.metadata.name), + other: sections.other, + }, + { + pinned: ['Claude Sonnet 5'], + // The selected model rides along with the curated ones, so it is never folded away. + suggested: ['Gemini 3.1 Pro', 'GPT-5.5'], + other: [], + }, + ); + }); + + test('a model superseded within its line falls through to the rest', () => { + const sections = buildModelPickerSections({ + models: [createModel('example-5.5', 'Example 5.5'), createModel('example-5.6', 'Example 5.6'), claude], + selectedModelId: undefined, + recentModelIds: [], + pinnedModelIds: [], + controlModels: {}, + showSuggested: true, + }); + assert.deepStrictEqual( + { + suggested: sections.suggested.map(model => model.metadata.name), + other: sections.other.map(model => model.metadata.name), + promoted: hasPromotedModels(sections), + }, + { suggested: ['Claude Sonnet 5', 'Example 5.6'], other: ['Example 5.5'], promoted: true }, + ); + }); + + test('a retiring model sinks below the models that are staying', () => { + // Alphabetically "Alpha" leads, so only the retirement can move it last. + const base = createModel('alpha', 'Alpha'); + const alpha = { ...base, metadata: { ...base.metadata, warningText: { model_pending_deprecation: 'Retiring soon.' } } }; + const sections = buildModelPickerSections({ + models: [alpha, claude, gemini], + selectedModelId: undefined, + recentModelIds: [], + pinnedModelIds: [], + controlModels: { + 'alpha': { label: 'Alpha', exists: true, demoted: true }, + 'claude-sonnet-5': { label: 'Claude Sonnet 5', exists: true, demoted: true }, + 'gemini-3-1-pro': { label: 'Gemini 3.1 Pro', exists: true, demoted: true }, + }, + showSuggested: true, + }); + assert.deepStrictEqual( + sections.other.map(model => model.metadata.name), + ['Claude Sonnet 5', 'Gemini 3.1 Pro', 'Alpha'], + ); + }); + + test('the built-in destination is the only one that recommends models', () => { + const sections = buildModelPickerSections({ + models: [llama], + selectedModelId: undefined, + recentModelIds: [], + pinnedModelIds: [], + controlModels: { 'llama-3': { label: 'Llama 3', featured: true, exists: true } }, + showSuggested: false, + }); + assert.deepStrictEqual( + { suggested: sections.suggested.length, other: sections.other.map(model => model.metadata.name) }, + { suggested: 0, other: ['Llama 3'] }, + ); + }); + + test('a destination with no shortlist puts every model in the list', () => { + const sections = buildModelPickerSections({ + models: [llama, mistral], + selectedModelId: 'ollama/llama-3', + recentModelIds: ['openai/mistral'], + pinnedModelIds: [], + controlModels: {}, + showSuggested: false, + }); + assert.deepStrictEqual( + { + suggested: sections.suggested.length, + other: sections.other.map(model => model.metadata.name), + }, + { suggested: 0, other: ['Llama 3', 'Mistral Large'] }, + ); + }); + + test('a model id splits into the line it belongs to and its version', () => { + // The id shapes a provider can use: version last, version in the middle, a + // multi-word line, and no version at all. + assert.deepStrictEqual( + ['example-5.5', 'example-5.6-sol', 'example-1.1-lite', 'example-opus-5', 'example-prime'] + .map(id => ({ id, ...parseModelLine(id) })), + [ + { id: 'example-5.5', line: 'example', version: [5, 5] }, + { id: 'example-5.6-sol', line: 'example-sol', version: [5, 6] }, + { id: 'example-1.1-lite', line: 'example-lite', version: [1, 1] }, + { id: 'example-opus-5', line: 'example-opus', version: [5] }, + // No version token, so it stands as its own line rather than being buried. + { id: 'example-prime', line: 'example-prime', version: [] }, + ], + ); + }); + + test('the shortlist is the newest of each line, so a launch needs no list edit', () => { + const line = (id: string, vendor = 'copilot') => createModel(id, id, { vendor }); + const shortlist = (models: readonly ILanguageModelChatMetadataAndIdentifier[]) => + latestOfEachLine(models).map(model => model.metadata.id).sort(); + + const catalogue = [line('example-5.6-sol'), line('example-opus-4.8'), line('example-opus-5')]; + assert.deepStrictEqual( + { + today: shortlist(catalogue), + // A newer version of a line replaces it; a line of its own joins the shortlist. + afterLaunch: shortlist([...catalogue, line('example-5.7-sol'), line('example-6-vega')]), + // `example-5.5` is the `example` line and `example-5.6-sol` is the + // `example-sol` line, so neither supersedes the other. Replacing a line + // takes a name, not a rule. + acrossLines: shortlist([line('example-5.5'), line('example-5.6-sol')]), + // Two providers can ship the same line name without displacing each other. + perVendor: shortlist([line('example-5.4-mini'), line('example-5-mini', 'azure')]), + }, + { + today: ['example-5.6-sol', 'example-opus-5'], + afterLaunch: ['example-5.7-sol', 'example-6-vega', 'example-opus-5'], + acrossLines: ['example-5.5', 'example-5.6-sol'], + perVendor: ['example-5-mini', 'example-5.4-mini'], + }, + ); + }); + + test('a line replaced by a different line is demoted by name, and promos still lead', () => { + const sol = createModel('example-5.6-sol', 'Example 5.6 Sol'); + const codex = createModel('example-5.3-codex', 'Example 5.3 Codex'); + const promoCodex = { ...codex, metadata: { ...codex.metadata, promo: { id: 'p', discountPercent: 25, message: 'Save now.' } } }; + const sections = (codexModel: ILanguageModelChatMetadataAndIdentifier) => buildModelPickerSections({ + models: [sol, codexModel], + selectedModelId: undefined, + recentModelIds: [], + pinnedModelIds: [], + // Codex is the newest of its own line, so only a name can move it down. + controlModels: { 'example-5.3-codex': { label: 'Example 5.3 Codex', exists: true, demoted: true } }, + showSuggested: true, + }); + assert.deepStrictEqual( + { + demoted: { + suggested: sections(codex).suggested.map(model => model.metadata.id), + other: sections(codex).other.map(model => model.metadata.id), + }, + // An offer outranks the demotion: it is time-limited and worth seeing. + withPromo: sections(promoCodex).suggested.map(model => model.metadata.id), + }, + { + demoted: { suggested: ['example-5.6-sol'], other: ['example-5.3-codex'] }, + withPromo: ['example-5.3-codex', 'example-5.6-sol'], + }, + ); + }); + + test('demoting every model leaves the list open rather than empty', () => { + // A demotion is honoured whether or not anything replaced the model, so a config + // that names them all is possible. The fold has nothing to hide behind then, and + // the rest is shown instead of the picker opening on nothing. + const sections = buildModelPickerSections({ + models: [gpt, claude], + selectedModelId: undefined, + recentModelIds: [], + pinnedModelIds: [], + controlModels: { + 'gpt-5-5': { label: 'GPT-5.5', exists: true, demoted: true }, + 'claude-sonnet-5': { label: 'Claude Sonnet 5', exists: true, demoted: true }, + }, + showSuggested: true, + }); + assert.deepStrictEqual( + { + suggested: sections.suggested.length, + other: sections.other.map(model => model.metadata.name), + // False, so the caller leaves the rest expanded instead of folding it away. + folds: hasPromotedModels(sections), + }, + { suggested: 0, other: ['Claude Sonnet 5', 'GPT-5.5'], folds: false }, + ); + }); + + test('an early-access build stays out of the shortlist without being named', () => { + const sections = buildModelPickerSections({ + models: [gpt, createModel('example-3-eap', 'Example 3 EAP'), createModel('example-4-experimental', 'Example 4')], + selectedModelId: undefined, + recentModelIds: [], + pinnedModelIds: [], + // No entry for either: the id says enough, so a new one needs no config. + controlModels: {}, + showSuggested: true, + }); + assert.deepStrictEqual( + { + suggested: sections.suggested.map(model => model.metadata.id), + // Held back from the shortlist, not hidden: still selectable further down. + other: sections.other.map(model => model.metadata.id), + }, + { + suggested: ['gpt-5-5'], + other: ['example-3-eap', 'example-4-experimental'], + }, + ); + }); + + test('a demotion names one model, so a newer one in that line surfaces again', () => { + // Deliberate: a demotion says "not this model", not "not this line". A line that + // comes back is worth seeing, which is the whole point of failing upward. The + // cost is that suppressing a variant has to be repeated when it is re-released. + const shortlist = (models: readonly ILanguageModelChatMetadataAndIdentifier[]) => buildModelPickerSections({ + models, + selectedModelId: undefined, + recentModelIds: [], + pinnedModelIds: [], + controlModels: { + 'example-5.5': { label: 'Example 5.5', exists: true, demoted: true }, + 'example-1-lite-picker': { label: 'Example 1 Lite', exists: true, demoted: true }, + }, + showSuggested: true, + }).suggested.map(model => model.metadata.id).sort(); + + const retired = createModel('example-5.5', 'Example 5.5'); + const variant = createModel('example-1-lite-picker', 'Example 1 Lite'); + assert.deepStrictEqual( + { + demoted: shortlist([retired, variant]), + // A flagship on the line that was retired, and a re-release of the variant. + succeeded: shortlist([retired, variant, createModel('example-6', 'Example 6'), createModel('example-2-lite-picker', 'Example 2 Lite')]), + }, + { demoted: [], succeeded: ['example-2-lite-picker', 'example-6'] }, + ); + }); + + test('a model is paired with the faster twin the provider names by id', () => { + // The ids and names the provider actually uses for the pair. + const standard = createModel('example-2.5', 'Example 2.5'); + const fast = createModel('example-2.5-fast', 'Example 2.5 (fast mode)'); + // Ends in the suffix but has no twin, so it is a model in its own right. + const orphan = createModel('some-model-fast', 'Some Model (fast mode)'); + const variants = buildSpeedVariants([gpt, standard, fast, orphan]); + + assert.deepStrictEqual( + { + fromStandard: variants.get(standard.identifier)?.fast.metadata.id, + fromFast: variants.get(fast.identifier)?.standard.metadata.id, + unpaired: [gpt, orphan].map(model => variants.has(model.identifier)), + }, + { fromStandard: 'example-2.5-fast', fromFast: 'example-2.5', unpaired: [false, false] }, + ); + }); + + test('a pair takes one row, showing whichever twin is in use', () => { + const standard = createModel('example-2.5', 'Example 2.5'); + const fast = createModel('example-2.5-fast', 'Example 2.5 (fast mode)'); + const models = [gpt, standard, fast]; + const variants = buildSpeedVariants(models); + const names = (selected: string | undefined) => + collapseSpeedVariants(models, variants, selected).map(model => model.metadata.name); + + assert.deepStrictEqual( + { + neither: names(undefined), + standardSelected: names(standard.identifier), + fastSelected: names(fast.identifier), + }, + { + neither: ['GPT-5.5', 'Example 2.5'], + standardSelected: ['GPT-5.5', 'Example 2.5'], + // The twin in use is never hidden, however the pair is collapsed. + fastSelected: ['GPT-5.5', 'Example 2.5 (fast mode)'], + }, + ); + }); + + test('badges rank a retiring model over an offer over the settings a model was tuned to', () => { + const retiring = { ...gpt, metadata: { ...gpt.metadata, warningText: { model_pending_deprecation: 'Retiring soon.' } } }; + const promo = { ...claude, metadata: { ...claude.metadata, promo: { id: 'p', discountPercent: 25, message: 'Save now.' } } }; + const tuned = createConfigurableModel(); + const badge = (model: ILanguageModelChatMetadataAndIdentifier, values: IStringDictionary = {}, providerLabel?: string) => + getModelBadge(model, { configurationAccess: createConfigurationAccess(values), providerLabel }); + + assert.deepStrictEqual( + { + retiring: badge(retiring), + promo: badge(promo), + tuned: badge(tuned, { reasoningEffort: 'xhigh', contextSize: 1000000 }), + // Left at its defaults, so there is nothing to report. + untouched: badge(tuned), + provider: badge(gpt, {}, 'Ollama'), + plain: badge(gpt), + }, + { + retiring: { text: 'Retiring', tone: 'warning' }, + promo: { text: '25% off', tone: 'promo' }, + tuned: { text: 'Extra high \u00b7 1M', tone: 'selected' }, + untouched: undefined, + provider: { text: 'Ollama', tone: 'neutral' }, + plain: undefined, + }, + ); + }); + + test('curated models the account cannot reach are named so their unlock path shows', () => { + const controlModels: IStringDictionary = { + 'gpt-5-5': { label: 'GPT-5.5', featured: true, exists: true }, + 'example-opus-5': { label: 'Claude Opus 5', featured: true, exists: false }, + 'gpt-6': { label: 'GPT-6', featured: true, exists: false, minVSCodeVersion: '99.0.0' }, + 'hidden': { label: 'Not Featured', featured: false, exists: false }, + }; + const sections = buildModelPickerSections({ + models: [gpt], + selectedModelId: undefined, + recentModelIds: [], + pinnedModelIds: [], + controlModels, + showSuggested: true, + showUnavailable: true, + currentVSCodeVersion: '1.100.0', + }); + assert.deepStrictEqual( + { + suggested: sections.suggested.map(model => model.metadata.name), + unavailable: sections.unavailable.map(entry => ({ id: entry.id, needsUpdate: entry.needsUpdate })), + }, + { + suggested: ['GPT-5.5'], + unavailable: [{ id: 'example-opus-5', needsUpdate: false }, { id: 'gpt-6', needsUpdate: true }], + }, + ); + }); + + test('an offer on a model this build is too old to run does not break the shortlist', () => { + // The promoted model is gated, so it is not among the selectable models. Reaching + // for it regardless used to put a hole in the shortlist and crash the sort. + const base = createModel('gpt-6', 'GPT-6'); + const gatedPromo = { ...base, metadata: { ...base.metadata, promo: { id: 'p', discountPercent: 25, message: 'Save now.' } } }; + const sections = buildModelPickerSections({ + models: [gatedPromo, gpt], + selectedModelId: undefined, + recentModelIds: [], + pinnedModelIds: [], + controlModels: { 'gpt-6': { label: 'GPT-6', featured: true, exists: true, minVSCodeVersion: '99.0.0' } }, + showSuggested: true, + showUnavailable: true, + currentVSCodeVersion: '1.100.0', + }); + assert.deepStrictEqual( + { + suggested: sections.suggested.map(model => model.metadata.name), + other: sections.other.map(model => model.metadata.name), + unavailable: sections.unavailable.map(entry => ({ id: entry.id, needsUpdate: entry.needsUpdate })), + }, + { suggested: ['GPT-5.5'], other: [], unavailable: [{ id: 'gpt-6', needsUpdate: true }] }, + ); + }); + + test('a model this build is too old to run is never offered as selectable', () => { + const controlModels: IStringDictionary = { + 'gpt-5-5': { label: 'GPT-5.5', featured: true, exists: true, minVSCodeVersion: '99.0.0' }, + }; + // Favourited and selected both name it, so it would reappear if any section kept it. + const sections = buildModelPickerSections({ + models: [gpt, claude], + selectedModelId: 'copilot/gpt-5-5', + recentModelIds: ['copilot/gpt-5-5'], + pinnedModelIds: ['copilot/gpt-5-5'], + controlModels: { ...controlModels, 'claude-sonnet-5': { label: 'Claude Sonnet 5', exists: true, demoted: true } }, + showSuggested: true, + showUnavailable: true, + currentVSCodeVersion: '1.100.0', + }); + assert.deepStrictEqual( + { + pinned: sections.pinned.map(model => model.metadata.name), + suggested: sections.suggested.map(model => model.metadata.name), + other: sections.other.map(model => model.metadata.name), + unavailable: sections.unavailable.map(entry => ({ id: entry.id, needsUpdate: entry.needsUpdate })), + }, + { + pinned: [], + suggested: [], + other: ['Claude Sonnet 5'], + unavailable: [{ id: 'gpt-5-5', needsUpdate: true }], + }, + ); + }); + + test('surfaces that cannot act on locked models do not advertise them', () => { + const sections = buildModelPickerSections({ + models: [gpt], + selectedModelId: undefined, + recentModelIds: [], + pinnedModelIds: [], + controlModels: { 'example-opus-5': { label: 'Claude Opus 5', featured: true, exists: false } }, + showSuggested: true, + }); + assert.deepStrictEqual(sections.unavailable, []); + }); + + test('context is extended only at the largest configured window', () => { + const model = createConfigurableModel(); + const read = (values: IStringDictionary) => { + const property = getModelConfigProperty(model, createConfigurationAccess(values), MODEL_CONFIG_GROUP_CONTEXT)!; + return isExtendedContext(property); + }; + assert.deepStrictEqual( + { default: read({}), standard: read({ contextSize: 264000 }), extended: read({ contextSize: 1000000 }) }, + { default: false, standard: false, extended: true }, + ); + }); + + test('the configuration summary names only what was changed from the defaults', () => { + const model = createConfigurableModel(); + assert.deepStrictEqual( + { + defaults: getModelConfigSummary(model, createConfigurationAccess()), + bothChanged: getModelConfigSummary(model, createConfigurationAccess({ reasoningEffort: 'xhigh', contextSize: 1000000 })), + oneChanged: getModelConfigSummary(model, createConfigurationAccess({ contextSize: 1000000 })), + noSchema: getModelConfigSummary(gpt, createConfigurationAccess()), + }, + { defaults: undefined, bothChanged: 'Extra high · 1M', oneChanged: '1M', noSchema: undefined }, + ); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/sessionTargetPickerActionItem.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/sessionTargetPickerActionItem.test.ts index 4558d92cc9d27..1af5ff3f83393 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/input/sessionTargetPickerActionItem.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/sessionTargetPickerActionItem.test.ts @@ -38,8 +38,9 @@ function createCodexItem(type: AgentSessionProviders.Codex | AgentSessionProvide }; } -function getMarkdownValue(value: string | IMarkdownString | HTMLElement | undefined): string | undefined { - return typeof value === 'string' ? value : value instanceof HTMLElement ? value.textContent ?? undefined : value?.value; +function getMarkdownValue(value: string | IMarkdownString | HTMLElement | (() => HTMLElement) | undefined): string | undefined { + const resolved = typeof value === 'function' ? value() : value; + return typeof resolved === 'string' ? resolved : resolved instanceof HTMLElement ? resolved.textContent ?? undefined : resolved?.value; } interface IAvailabilityInputs { diff --git a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/editor/chatEditorInput.test.ts b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/editor/chatEditorInput.test.ts index 781939a566e21..8244c409f3b63 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/editor/chatEditorInput.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/editor/chatEditorInput.test.ts @@ -73,7 +73,7 @@ suite('ChatEditorInput', () => { {} as IStorageService, new NullLogService(), new TestContextService(), - { _serviceBrand: undefined, enabled: constObservable(false), managedSandboxEnforced: constObservable(false) }, + { _serviceBrand: undefined, enabled: constObservable(false), managedSandboxEnforced: constObservable(false), managedSandboxAllowsBypass: constObservable(false) }, { ambientConnection: undefined } as unknown as IAgentHostConnectionsService, NullTelemetryService, { withProgress: (_options: unknown, task: (progress: unknown) => unknown) => task({ report() { } }) } as unknown as IProgressService, @@ -134,7 +134,7 @@ suite('ChatEditorInput', () => { {} as IStorageService, new NullLogService(), new TestContextService(), - { _serviceBrand: undefined, enabled: constObservable(false), managedSandboxEnforced: constObservable(false) }, + { _serviceBrand: undefined, enabled: constObservable(false), managedSandboxEnforced: constObservable(false), managedSandboxAllowsBypass: constObservable(false) }, { ambientConnection: undefined } as unknown as IAgentHostConnectionsService, NullTelemetryService, { withProgress: (_options: unknown, task: (progress: unknown) => unknown) => task({ report() { } }) } as unknown as IProgressService, @@ -189,7 +189,7 @@ suite('ChatEditorInput', () => { {} as IStorageService, new NullLogService(), new TestContextService(), - { _serviceBrand: undefined, enabled: constObservable(true), managedSandboxEnforced: constObservable(false) }, + { _serviceBrand: undefined, enabled: constObservable(true), managedSandboxEnforced: constObservable(false), managedSandboxAllowsBypass: constObservable(false) }, { ambientConnection: undefined } as unknown as IAgentHostConnectionsService, NullTelemetryService, { withProgress: (_options: unknown, task: (progress: unknown) => unknown) => task({ report() { } }) } as unknown as IProgressService, @@ -235,7 +235,7 @@ suite('ChatEditorInput', () => { {} as IStorageService, new NullLogService(), new TestContextService(), - { _serviceBrand: undefined, enabled: constObservable(true), managedSandboxEnforced: constObservable(false) }, + { _serviceBrand: undefined, enabled: constObservable(true), managedSandboxEnforced: constObservable(false), managedSandboxAllowsBypass: constObservable(false) }, { ambientConnection: undefined } as unknown as IAgentHostConnectionsService, NullTelemetryService, { withProgress: (_options: unknown, task: (progress: unknown) => unknown) => task({ report() { } }) } as unknown as IProgressService, @@ -293,7 +293,7 @@ suite('ChatEditorInput', () => { {} as IStorageService, new NullLogService(), new TestContextService(), - { _serviceBrand: undefined, enabled: constObservable(false), managedSandboxEnforced: constObservable(false) }, + { _serviceBrand: undefined, enabled: constObservable(false), managedSandboxEnforced: constObservable(false), managedSandboxAllowsBypass: constObservable(false) }, { ambientConnection: undefined } as unknown as IAgentHostConnectionsService, NullTelemetryService, { withProgress: (_options: unknown, task: (progress: unknown) => unknown) => task({ report() { } }) } as unknown as IProgressService, @@ -329,7 +329,7 @@ suite('ChatEditorInput', () => { }]); const storageService = store.add(new TestStorageService()); const workspaceContextService = new TestContextService(); - const agentHostEnablementService = { _serviceBrand: undefined, enabled: constObservable(true), managedSandboxEnforced: constObservable(false) } satisfies IAgentHostEnablementService; + const agentHostEnablementService = { _serviceBrand: undefined, enabled: constObservable(true), managedSandboxEnforced: constObservable(false), managedSandboxAllowsBypass: constObservable(false) } satisfies IAgentHostEnablementService; instantiationService.stub(IChatService, {}); instantiationService.stub(IDialogService, {}); @@ -384,7 +384,7 @@ suite('ChatEditorInput', () => { instantiationService.set(IStorageService, store.add(new TestStorageService())); instantiationService.set(ILogService, new NullLogService()); instantiationService.set(IWorkspaceContextService, new TestContextService()); - instantiationService.set(IAgentHostEnablementService, { _serviceBrand: undefined, enabled: constObservable(agentHostEnabled), managedSandboxEnforced: constObservable(false) }); + instantiationService.set(IAgentHostEnablementService, { _serviceBrand: undefined, enabled: constObservable(agentHostEnabled), managedSandboxEnforced: constObservable(false), managedSandboxAllowsBypass: constObservable(false) }); return store.add(instantiationService.createInstance(ChatEditorInput, resource, {})); } diff --git a/src/vs/workbench/contrib/chat/test/common/constants.test.ts b/src/vs/workbench/contrib/chat/test/common/constants.test.ts index 785c7854ff559..d091d94ce31f9 100644 --- a/src/vs/workbench/contrib/chat/test/common/constants.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/constants.test.ts @@ -59,7 +59,7 @@ suite('ChatConfiguration defaults', () => { accessor.set(IChatSessionsService, chatSessionsService); accessor.set(IStorageService, storageService); accessor.set(IWorkspaceContextService, new TestContextService(workspace)); - accessor.set(IAgentHostEnablementService, { _serviceBrand: undefined, enabled: constObservable(agentHostEnabled), managedSandboxEnforced: constObservable(false) }); + accessor.set(IAgentHostEnablementService, { _serviceBrand: undefined, enabled: constObservable(agentHostEnabled), managedSandboxEnforced: constObservable(false), managedSandboxAllowsBypass: constObservable(false) }); return { sessionType: getDefaultNewChatSessionTypeAndReason(accessor, options).sessionType }; } @@ -76,7 +76,7 @@ suite('ChatConfiguration defaults', () => { accessor.set(IChatSessionsService, chatSessionsService); accessor.set(IStorageService, storageService); accessor.set(IWorkspaceContextService, new TestContextService(workspace)); - accessor.set(IAgentHostEnablementService, { _serviceBrand: undefined, enabled: constObservable(agentHostEnabled), managedSandboxEnforced: constObservable(false) }); + accessor.set(IAgentHostEnablementService, { _serviceBrand: undefined, enabled: constObservable(agentHostEnabled), managedSandboxEnforced: constObservable(false), managedSandboxAllowsBypass: constObservable(false) }); return getDefaultNewChatSessionTypeAndReason(accessor, options); } diff --git a/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/runSubagentTool.test.ts b/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/runSubagentTool.test.ts index 63f77bf4783a9..461d1f2f0eabf 100644 --- a/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/runSubagentTool.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/runSubagentTool.test.ts @@ -5,6 +5,7 @@ import assert from 'assert'; import { CancellationToken } from '../../../../../../../base/common/cancellation.js'; +import { Event } from '../../../../../../../base/common/event.js'; import { URI } from '../../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; import { NullLogService } from '../../../../../../../platform/log/common/log.js'; @@ -13,9 +14,11 @@ import { RUN_SUBAGENT_MAX_NESTING_DEPTH, RunSubagentTool } from '../../../../com import { MockLanguageModelToolsService } from '../mockLanguageModelToolsService.js'; import { IChatAgentHistoryEntry, IChatAgentRequest, IChatAgentResult, IChatAgentService, UserSelectedTools } from '../../../../common/participants/chatAgents.js'; import { IChatProgress, IChatService } from '../../../../common/chatService/chatService.js'; -import { COPILOT_VENDOR_ID, ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from '../../../../common/languageModels.js'; +import { AUTO_RAW_MODEL_ID, COPILOT_VENDOR_ID, ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from '../../../../common/languageModels.js'; import { IInstantiationService } from '../../../../../../../platform/instantiation/common/instantiation.js'; import { IProductService } from '../../../../../../../platform/product/common/productService.js'; +import { ITelemetryService } from '../../../../../../../platform/telemetry/common/telemetry.js'; +import { NullTelemetryService, NullTelemetryServiceShape } from '../../../../../../../platform/telemetry/common/telemetryUtils.js'; import { ICustomAgent, PromptsStorage } from '../../../../common/promptSyntax/service/promptsService.js'; import { Target } from '../../../../common/promptSyntax/promptTypes.js'; import { MockPromptsService } from '../../promptSyntax/service/mockPromptsService.js'; @@ -24,9 +27,178 @@ import { IToolInvocation, ToolProgress } from '../../../../common/tools/language import { IChatModel, IChatRequestModeInstructions } from '../../../../common/model/chatModel.js'; import { ChatConfiguration } from '../../../../common/constants.js'; +class TestTelemetryService extends NullTelemetryServiceShape { + readonly events: { readonly name: string; readonly data: unknown }[] = []; + + override publicLog2(eventName?: string, data?: unknown): void { + if (eventName) { + this.events.push({ name: eventName, data }); + } + } +} + suite('RunSubagentTool', () => { const testDisposables = ensureNoDisposablesAreLeakedInTestSuite(); + function createMetadata(name: string, multiplierNumeric?: number, vendor: string = 'TestVendor'): ILanguageModelChatMetadata { + return { + extension: new ExtensionIdentifier('test.extension'), + name, + id: name.toLowerCase().replace(/\s+/g, '-'), + vendor, + version: '1.0', + family: 'test', + maxInputTokens: 128000, + maxOutputTokens: 8192, + isDefaultForLocation: {}, + multiplierNumeric, + capabilities: { toolCalling: true }, + isBYOK: vendor !== COPILOT_VENDOR_ID, + }; + } + + function createAutoMetadata(multiplierNumeric?: number, overrides: Partial = {}): ILanguageModelChatMetadata { + return { + ...createMetadata('Auto', multiplierNumeric, COPILOT_VENDOR_ID), + id: AUTO_RAW_MODEL_ID, + ...overrides, + }; + } + + function createAgent(name: string, modelQualifiedNames?: string[]): ICustomAgent { + const id = `file:///test/${name}.md`; + return { + uri: URI.parse(id), + id, + name, + description: `Agent ${name}`, + tools: ['tool1'], + model: modelQualifiedNames, + agentInstructions: { content: 'test', toolReferences: [] }, + source: { storage: PromptsStorage.local }, + target: Target.Undefined, + visibility: { userInvocable: true, agentInvocable: true }, + enabled: true + }; + } + + function createLanguageModelsServiceMock(models = new Map(), opts: { + qualifiedNameMap?: Map; + selectedModels?: Map; + copilotVendorResolved?: boolean; + onSelectLanguageModels?: () => void; + } = {}): ILanguageModelsService { + const service: Partial = { + onDidChangeLanguageModels: Event.None, + getLanguageModelIds: () => Array.from(models.keys()), + lookupLanguageModel: modelId => models.get(modelId), + lookupLanguageModelByQualifiedName: qualifiedName => opts.qualifiedNameMap?.get(qualifiedName), + hasResolvedVendor: vendor => { + assert.strictEqual(vendor, COPILOT_VENDOR_ID); + return opts.copilotVendorResolved ?? false; + }, + selectLanguageModels: async selector => { + opts.onSelectLanguageModels?.(); + assert.deepStrictEqual(selector, { vendor: COPILOT_VENDOR_ID, id: AUTO_RAW_MODEL_ID }); + for (const [modelId, metadata] of opts.selectedModels ?? []) { + models.set(modelId, metadata); + } + return Array.from(models) + .filter(([, metadata]) => metadata.vendor === selector.vendor && metadata.id === selector.id) + .map(([modelId]) => modelId); + }, + getModelConfiguration: () => undefined, + }; + return service as ILanguageModelsService; + } + + let callIdCounter = 0; + function createInvokableTool(opts: { + allowInvocationsFromSubagents: boolean; + capturedRequests: IChatAgentRequest[]; + currentModeInstructions?: IChatRequestModeInstructions; + customAgents?: ICustomAgent[]; + defaultToAuto?: boolean; + models?: Map; + selectedModels?: Map; + qualifiedNameMap?: Map; + copilotVendorResolved?: boolean; + onSelectLanguageModels?: () => void; + telemetryService?: ITelemetryService; + }) { + const mockToolsService = testDisposables.add(new MockLanguageModelToolsService()); + const configService = new TestConfigurationService({ + [ChatConfiguration.SubagentsAllowInvocationsFromSubagents]: opts.allowInvocationsFromSubagents, + [ChatConfiguration.SubagentsDefaultToAuto]: opts.defaultToAuto ?? false, + }); + const promptsService = new MockPromptsService(); + if (opts.customAgents) { + promptsService.setCustomModes(opts.customAgents); + } + + const mockChatAgentService: Pick = { + getDefaultAgent() { + return { id: 'default-agent' } as IChatAgentService extends { getDefaultAgent(...args: infer _A): infer R } ? NonNullable : never; + }, + async invokeAgent(_id: string, request: IChatAgentRequest, _progress: (parts: IChatProgress[]) => void, _history: IChatAgentHistoryEntry[], _token: CancellationToken): Promise { + opts.capturedRequests.push(request); + return {}; + }, + }; + + const mockChatService: Pick = { + getSession() { + return { + getRequests: () => [{ + id: 'req-1', + modeInfo: opts.currentModeInstructions ? { + kind: undefined, + isBuiltin: false, + modeInstructions: opts.currentModeInstructions, + telemetryModeId: 'custom', + applyCodeBlockSuggestionId: undefined, + } : undefined + }], + acceptResponseProgress: () => { }, + } as unknown as IChatModel; + }, + }; + + const mockInstantiationService: Pick = { + createInstance(..._args: never[]): { collect: () => Promise } { + return { collect: async () => { } }; + }, + }; + const tool = testDisposables.add(new RunSubagentTool( + mockChatAgentService as IChatAgentService, + mockChatService as IChatService, + mockToolsService, + createLanguageModelsServiceMock(opts.models, opts), + new NullLogService(), + configService, + promptsService, + mockInstantiationService as IInstantiationService, + {} as IProductService, + opts.telemetryService ?? NullTelemetryService, + )); + + return { tool, mockChatAgentService }; + } + + function createInvocation(sessionUri: URI, userSelectedTools?: UserSelectedTools, modelId?: string): IToolInvocation { + return { + callId: `call-${++callIdCounter}`, + toolId: 'runSubagent', + parameters: { prompt: 'do something', description: 'test' }, + context: { sessionResource: sessionUri }, + modelId, + userSelectedTools: userSelectedTools ?? { runSubagent: true }, + } as IToolInvocation; + } + + const countTokens = async () => 0; + const noProgress: ToolProgress = { report() { } }; + suite('resultText trimming', () => { test('trims leading empty codeblocks (```\\n```) from result', () => { // This tests the regex: /^\n*```\n+```\n*/g @@ -70,12 +242,13 @@ suite('RunSubagentTool', () => { {} as IChatAgentService, {} as IChatService, mockToolsService, - {} as ILanguageModelsService, + createLanguageModelsServiceMock(), new NullLogService(), new TestConfigurationService(), promptsService, {} as IInstantiationService, {} as IProductService, + NullTelemetryService, )); const result = await tool.prepareToolInvocation( @@ -113,12 +286,13 @@ suite('RunSubagentTool', () => { {} as IChatAgentService, {} as IChatService, mockToolsService, - {} as ILanguageModelsService, + createLanguageModelsServiceMock(), new NullLogService(), new TestConfigurationService(), promptsService, {} as IInstantiationService, {} as IProductService, + NullTelemetryService, )); return tool; } @@ -155,12 +329,13 @@ suite('RunSubagentTool', () => { {} as IChatAgentService, {} as IChatService, mockToolsService, - {} as ILanguageModelsService, + createLanguageModelsServiceMock(), new NullLogService(), new TestConfigurationService(), promptsService, {} as IInstantiationService, {} as IProductService, + NullTelemetryService, )); const toolData = tool.getToolData(); @@ -242,27 +417,14 @@ suite('RunSubagentTool', () => { const BUILTIN_CHAT_EXTENSION_ID = 'github.copilot-chat'; const builtinProductService = { defaultChatAgent: { chatExtensionId: BUILTIN_CHAT_EXTENSION_ID } } as IProductService; - function createMetadata(name: string, multiplierNumeric?: number, vendor: string = 'TestVendor'): ILanguageModelChatMetadata { - return { - extension: new ExtensionIdentifier('test.extension'), - name, - id: name.toLowerCase().replace(/\s+/g, '-'), - vendor, - version: '1.0', - family: 'test', - maxInputTokens: 128000, - maxOutputTokens: 8192, - isDefaultForLocation: {}, - multiplierNumeric, - capabilities: { toolCalling: true }, - isBYOK: vendor !== COPILOT_VENDOR_ID, - }; - } - function createTool(opts: { models: Map; + selectedModels?: Map; qualifiedNameMap?: Map; customAgents?: ICustomAgent[]; + defaultToAuto?: boolean; + copilotVendorResolved?: boolean; + onSelectLanguageModels?: () => void; }) { const mockToolsService = testDisposables.add(new MockLanguageModelToolsService()); const promptsService = new MockPromptsService(); @@ -270,50 +432,24 @@ suite('RunSubagentTool', () => { promptsService.setCustomModes(opts.customAgents); } - const mockLanguageModelsService: Partial = { - getLanguageModelIds() { - return Array.from(opts.models.keys()); - }, - lookupLanguageModel(modelId: string) { - return opts.models.get(modelId); - }, - lookupLanguageModelByQualifiedName(qualifiedName: string) { - return opts.qualifiedNameMap?.get(qualifiedName); - }, - }; - const tool = testDisposables.add(new RunSubagentTool( {} as IChatAgentService, {} as IChatService, mockToolsService, - mockLanguageModelsService as ILanguageModelsService, + createLanguageModelsServiceMock(opts.models, opts), new NullLogService(), - new TestConfigurationService(), + new TestConfigurationService({ + [ChatConfiguration.SubagentsDefaultToAuto]: opts.defaultToAuto ?? false, + }), promptsService, {} as IInstantiationService, builtinProductService, + NullTelemetryService, )); return tool; } - function createAgent(name: string, modelQualifiedNames?: string[]): ICustomAgent { - const id = `file:///test/${name}.md`; - return { - uri: URI.parse(id), - id, - name, - description: `Agent ${name}`, - tools: ['tool1'], - model: modelQualifiedNames, - agentInstructions: { content: 'test', toolReferences: [] }, - source: { storage: PromptsStorage.local }, - target: Target.Undefined, - visibility: { userInvocable: true, agentInvocable: true }, - enabled: true - }; - } - // A built-in (extension-shipped) agent such as Explore, whose model list is a curated fallback list. function createBuiltinAgent(name: string, modelQualifiedNames?: string[]): ICustomAgent { return { @@ -354,18 +490,20 @@ suite('RunSubagentTool', () => { }); test('uses subagent model when it has equal multiplier', async () => { - const mainMeta = createMetadata('GPT-4o', 1); + const mainMeta = createMetadata('GPT-4o', 1, COPILOT_VENDOR_ID); const sameCostMeta = createMetadata('Claude Sonnet', 1); + const autoMeta = createAutoMetadata(); const models = new Map([ ['main-model-id', mainMeta], ['same-cost-model-id', sameCostMeta], + ['copilot-auto-model-id', autoMeta], ]); const qualifiedNameMap = new Map([ ['Claude Sonnet (TestVendor)', { metadata: sameCostMeta, identifier: 'same-cost-model-id' }], ]); const agent = createAgent('SameCostAgent', ['Claude Sonnet (TestVendor)']); - const tool = createTool({ models, qualifiedNameMap, customAgents: [agent] }); + const tool = createTool({ models, qualifiedNameMap, customAgents: [agent], defaultToAuto: true }); const result = await tool.prepareToolInvocation({ parameters: { prompt: 'test', description: 'test task', agentName: 'SameCostAgent' }, @@ -526,6 +664,257 @@ suite('RunSubagentTool', () => { }); }); + test('resolves and uses Auto from a cold provider when enabled and no subagent is specified', async () => { + const mainMeta = createMetadata('GPT-4o', 1, COPILOT_VENDOR_ID); + const autoMeta = createAutoMetadata(); + let selectCalls = 0; + const tool = createTool({ + models: new Map([['main-model-id', mainMeta]]), + selectedModels: new Map([['copilot-auto-model-id', autoMeta]]), + defaultToAuto: true, + onSelectLanguageModels: () => selectCalls++, + }); + + const result = await tool.prepareToolInvocation({ + parameters: { prompt: 'test', description: 'test task' }, + toolCallId: 'auto-call-1', + modelId: 'main-model-id', + chatSessionResource: URI.parse('test://session'), + }, CancellationToken.None); + + assert.ok(result); + assert.deepStrictEqual({ + modelName: result.toolSpecificData?.kind === 'subagent' ? result.toolSpecificData.modelName : undefined, + selectCalls, + }, { + modelName: 'Auto', + selectCalls: 1, + }); + }); + + test('uses Auto when enabled and subagent has no model configured', async () => { + const mainMeta = createMetadata('GPT-4o', 1, COPILOT_VENDOR_ID); + const autoMeta = createAutoMetadata(); + const agent = createAgent('NoModelAgent', undefined); + const tool = createTool({ + models: new Map([ + ['main-model-id', mainMeta], + ['copilot-auto-model-id', autoMeta], + ]), + customAgents: [agent], + defaultToAuto: true, + }); + + const result = await tool.prepareToolInvocation({ + parameters: { prompt: 'test', description: 'test task', agentName: 'NoModelAgent' }, + toolCallId: 'auto-call-2', + modelId: 'main-model-id', + chatSessionResource: URI.parse('test://session'), + }, CancellationToken.None); + + assert.ok(result); + assert.strictEqual(result.toolSpecificData?.kind === 'subagent' ? result.toolSpecificData.modelName : undefined, 'Auto'); + }); + + test('falls back to main model when Auto is unavailable after provider resolution', async () => { + const mainMeta = createMetadata('GPT-4o', 1, COPILOT_VENDOR_ID); + let selectCalls = 0; + const tool = createTool({ + models: new Map([['main-model-id', mainMeta]]), + defaultToAuto: true, + copilotVendorResolved: true, + onSelectLanguageModels: () => selectCalls++, + }); + + const result = await tool.prepareToolInvocation({ + parameters: { prompt: 'test', description: 'test task' }, + toolCallId: 'auto-call-3', + modelId: 'main-model-id', + chatSessionResource: URI.parse('test://session'), + }, CancellationToken.None); + + assert.ok(result); + assert.deepStrictEqual({ + modelName: result.toolSpecificData?.kind === 'subagent' ? result.toolSpecificData.modelName : undefined, + selectCalls, + }, { + modelName: 'GPT-4o', + selectCalls: 0, + }); + }); + + test('falls back to main model when cached Auto is ineligible', async () => { + const mainMeta = createMetadata('GPT-4o', 1, COPILOT_VENDOR_ID); + const ineligibleAutoModels = [ + createAutoMetadata(undefined, { capabilities: { toolCalling: false } }), + createAutoMetadata(undefined, { isUserSelectable: false }), + createAutoMetadata(undefined, { targetChatSessionType: 'other-session' }), + ]; + + for (const [index, autoMeta] of ineligibleAutoModels.entries()) { + let selectCalls = 0; + const tool = createTool({ + models: new Map([ + ['main-model-id', mainMeta], + [`copilot-auto-model-id-${index}`, autoMeta], + ]), + defaultToAuto: true, + copilotVendorResolved: true, + onSelectLanguageModels: () => selectCalls++, + }); + + const result = await tool.prepareToolInvocation({ + parameters: { prompt: 'test', description: 'test task' }, + toolCallId: `ineligible-auto-call-${index}`, + modelId: 'main-model-id', + chatSessionResource: URI.parse('test://session'), + }, CancellationToken.None); + + assert.ok(result); + assert.deepStrictEqual({ + modelName: result.toolSpecificData?.kind === 'subagent' ? result.toolSpecificData.modelName : undefined, + selectCalls, + }, { + modelName: 'GPT-4o', + selectCalls: 0, + }); + } + }); + + test('keeps main model and does not retry when Auto resolution fails', async () => { + const mainMeta = createMetadata('GPT-4o', 1, COPILOT_VENDOR_ID); + let selectCalls = 0; + const tool = createTool({ + models: new Map([['main-model-id', mainMeta]]), + defaultToAuto: true, + onSelectLanguageModels: () => { + selectCalls++; + throw new Error('activation failed'); + }, + }); + + const modelNames: (string | undefined)[] = []; + for (const toolCallId of ['failed-auto-1', 'failed-auto-2']) { + const result = await tool.prepareToolInvocation({ + parameters: { prompt: 'test', description: 'test task' }, + toolCallId, + modelId: 'main-model-id', + chatSessionResource: URI.parse('test://session'), + }, CancellationToken.None); + modelNames.push(result?.toolSpecificData?.kind === 'subagent' ? result.toolSpecificData.modelName : undefined); + } + + assert.deepStrictEqual({ modelNames, selectCalls }, { modelNames: ['GPT-4o', 'GPT-4o'], selectCalls: 1 }); + }); + + test('keeps main model when its metadata is unknown', async () => { + let selectCalls = 0; + const tool = createTool({ + models: new Map([['copilot-auto-model-id', createAutoMetadata()]]), + defaultToAuto: true, + copilotVendorResolved: true, + onSelectLanguageModels: () => selectCalls++, + }); + + const result = await tool.prepareToolInvocation({ + parameters: { prompt: 'test', description: 'test task' }, + toolCallId: 'unknown-main-model', + modelId: 'unknown-main-model-id', + chatSessionResource: URI.parse('test://session'), + }, CancellationToken.None); + + assert.deepStrictEqual({ + modelName: result?.toolSpecificData?.kind === 'subagent' ? result.toolSpecificData.modelName : undefined, + selectCalls, + }, { + modelName: undefined, + selectCalls: 0, + }); + }); + + test('keeps BYOK main model without resolving Copilot Auto', async () => { + const byokMain = createMetadata('Claude Sonnet BYOK', undefined, 'anthropic'); + const autoMeta = createAutoMetadata(); + + for (const agent of [undefined, createAgent('NoModelAgent', undefined)]) { + let selectCalls = 0; + const tool = createTool({ + models: new Map([['main-byok-id', byokMain]]), + selectedModels: new Map([['copilot-auto-model-id', autoMeta]]), + customAgents: agent ? [agent] : undefined, + defaultToAuto: true, + onSelectLanguageModels: () => selectCalls++, + }); + + const result = await tool.prepareToolInvocation({ + parameters: { prompt: 'test', description: 'test task', agentName: agent?.name }, + toolCallId: `byok-auto-call-${agent?.name ?? 'unnamed'}`, + modelId: 'main-byok-id', + chatSessionResource: URI.parse('test://session'), + }, CancellationToken.None); + + assert.ok(result); + assert.deepStrictEqual({ + modelName: result.toolSpecificData?.kind === 'subagent' ? result.toolSpecificData.modelName : undefined, + selectCalls, + }, { + modelName: 'Claude Sonnet BYOK', + selectCalls: 0, + }); + } + }); + + test('reuses warm Auto without provider refresh and exempts it from fixed multiplier constraint', async () => { + const mainMeta = createMetadata('GPT-4o', 1, COPILOT_VENDOR_ID); + const autoMeta = createAutoMetadata(50); + let selectCalls = 0; + const tool = createTool({ + models: new Map([ + ['main-model-id', mainMeta], + ['copilot-auto-model-id', autoMeta], + ]), + defaultToAuto: true, + copilotVendorResolved: true, + onSelectLanguageModels: () => selectCalls++, + }); + + for (const toolCallId of ['warm-auto-1', 'warm-auto-2']) { + const result = await tool.prepareToolInvocation({ + parameters: { prompt: 'test', description: 'test task' }, + toolCallId, + modelId: 'main-model-id', + chatSessionResource: URI.parse('test://session'), + }, CancellationToken.None); + assert.ok(result); + assert.strictEqual(result.toolSpecificData?.kind === 'subagent' ? result.toolSpecificData.modelName : undefined, 'Auto'); + } + assert.strictEqual(selectCalls, 0); + }); + + test('keeps main model when configured subagent model is unavailable', async () => { + const mainMeta = createMetadata('GPT-4o', 1, COPILOT_VENDOR_ID); + const autoMeta = createAutoMetadata(); + const unavailableAgent = createAgent('UnavailableAgent', ['Missing Model (TestVendor)']); + const tool = createTool({ + models: new Map([ + ['main-model-id', mainMeta], + ['copilot-auto-model-id', autoMeta], + ]), + customAgents: [unavailableAgent], + defaultToAuto: true, + }); + + const result = await tool.prepareToolInvocation({ + parameters: { prompt: 'test', description: 'test task', agentName: 'UnavailableAgent' }, + toolCallId: 'unavailable-agent-model', + modelId: 'main-model-id', + chatSessionResource: URI.parse('test://session'), + }, CancellationToken.None); + + assert.ok(result); + assert.strictEqual(result.toolSpecificData?.kind === 'subagent' ? result.toolSpecificData.modelName : undefined, 'GPT-4o'); + }); + test('skips Copilot fallback models when main model is BYOK and inherits the main model', async () => { const mainMeta = createMetadata('Claude Sonnet BYOK', undefined, 'anthropic'); const copilotFallback = createMetadata('Copilot Haiku', undefined, COPILOT_VENDOR_ID); @@ -687,26 +1076,11 @@ suite('RunSubagentTool', () => { }); suite('explicit model parameter', () => { - function createMetadata(name: string, multiplierNumeric?: number): ILanguageModelChatMetadata { - return { - extension: new ExtensionIdentifier('test.extension'), - name, - id: name.toLowerCase().replace(/\s+/g, '-'), - vendor: 'TestVendor', - version: '1.0', - family: 'test', - maxInputTokens: 128000, - maxOutputTokens: 8192, - isDefaultForLocation: {}, - multiplierNumeric, - capabilities: { toolCalling: true }, - }; - } - function createTool(opts: { models: Map; qualifiedNameMap?: Map; customAgents?: ICustomAgent[]; + defaultToAuto?: boolean; }) { const mockToolsService = testDisposables.add(new MockLanguageModelToolsService()); const promptsService = new MockPromptsService(); @@ -714,50 +1088,24 @@ suite('RunSubagentTool', () => { promptsService.setCustomModes(opts.customAgents); } - const mockLanguageModelsService: Partial = { - getLanguageModelIds() { - return Array.from(opts.models.keys()); - }, - lookupLanguageModel(modelId: string) { - return opts.models.get(modelId); - }, - lookupLanguageModelByQualifiedName(qualifiedName: string) { - return opts.qualifiedNameMap?.get(qualifiedName); - }, - }; - const tool = testDisposables.add(new RunSubagentTool( {} as IChatAgentService, {} as IChatService, mockToolsService, - mockLanguageModelsService as ILanguageModelsService, + createLanguageModelsServiceMock(opts.models, opts), new NullLogService(), - new TestConfigurationService(), + new TestConfigurationService({ + [ChatConfiguration.SubagentsDefaultToAuto]: opts.defaultToAuto ?? false, + }), promptsService, {} as IInstantiationService, {} as IProductService, + NullTelemetryService, )); return tool; } - function createAgent(name: string, modelQualifiedNames?: string[]): ICustomAgent { - const id = `file:///test/${name}.md`; - return { - id, - uri: URI.parse(id), - name, - description: `Agent ${name}`, - tools: ['tool1'], - model: modelQualifiedNames, - agentInstructions: { content: 'test', toolReferences: [] }, - source: { storage: PromptsStorage.local }, - target: Target.Undefined, - visibility: { userInvocable: true, agentInvocable: true }, - enabled: true - }; - } - test('model property is included in tool schema without enum', () => { const models = new Map([ ['model-1', createMetadata('GPT-4o')], @@ -774,7 +1122,7 @@ suite('RunSubagentTool', () => { }); test('resolves explicit model parameter without agentName', async () => { - const mainMeta = createMetadata('GPT-4o', 1); + const mainMeta = createMetadata('GPT-4o', 1, COPILOT_VENDOR_ID); const explicitMeta = createMetadata('Claude Sonnet', 1); const models = new Map([ ['main-model-id', mainMeta], @@ -784,7 +1132,7 @@ suite('RunSubagentTool', () => { ['Claude Sonnet (TestVendor)', { metadata: explicitMeta, identifier: 'explicit-model-id' }], ]); - const tool = createTool({ models, qualifiedNameMap }); + const tool = createTool({ models, qualifiedNameMap, defaultToAuto: true }); const result = await tool.prepareToolInvocation({ parameters: { prompt: 'test', description: 'test task', model: 'Claude Sonnet (TestVendor)' }, @@ -981,12 +1329,13 @@ suite('RunSubagentTool', () => { mockChatAgentService as IChatAgentService, mockChatService as IChatService, mockToolsService, - {} as ILanguageModelsService, + createLanguageModelsServiceMock(), new NullLogService(), new TestConfigurationService(), promptsService, mockInstantiationService as IInstantiationService, {} as IProductService, + NullTelemetryService, )); } @@ -1064,84 +1413,6 @@ suite('RunSubagentTool', () => { }); suite('nested subagent depth tracking', () => { - /** - * Creates a RunSubagentTool with mocked services suitable for invoke() testing. - * The returned `capturedRequests` array collects every IChatAgentRequest passed to invokeAgent. - */ - let callIdCounter = 0; - function createInvokableTool(opts: { - allowInvocationsFromSubagents: boolean; - capturedRequests: IChatAgentRequest[]; - currentModeInstructions?: IChatRequestModeInstructions; - }) { - const mockToolsService = testDisposables.add(new MockLanguageModelToolsService()); - const configService = new TestConfigurationService({ - [ChatConfiguration.SubagentsAllowInvocationsFromSubagents]: opts.allowInvocationsFromSubagents, - }); - const promptsService = new MockPromptsService(); - - const mockChatAgentService: Pick = { - getDefaultAgent() { - return { id: 'default-agent' } as IChatAgentService extends { getDefaultAgent(...args: infer _A): infer R } ? NonNullable : never; - }, - async invokeAgent(_id: string, request: IChatAgentRequest, _progress: (parts: IChatProgress[]) => void, _history: IChatAgentHistoryEntry[], _token: CancellationToken): Promise { - opts.capturedRequests.push(request); - return {}; - }, - }; - - const mockChatService: Pick = { - getSession() { - return { - getRequests: () => [{ - id: 'req-1', - modeInfo: opts.currentModeInstructions ? { - kind: undefined, - isBuiltin: false, - modeInstructions: opts.currentModeInstructions, - telemetryModeId: 'custom', - applyCodeBlockSuggestionId: undefined, - } : undefined - }], - acceptResponseProgress: () => { }, - } as unknown as IChatModel; - }, - }; - - const mockInstantiationService: Pick = { - createInstance(..._args: never[]): { collect: () => Promise } { - return { collect: async () => { } }; - }, - }; - - const tool = testDisposables.add(new RunSubagentTool( - mockChatAgentService as IChatAgentService, - mockChatService as IChatService, - mockToolsService, - {} as ILanguageModelsService, - new NullLogService(), - configService, - promptsService, - mockInstantiationService as IInstantiationService, - {} as IProductService, - )); - - return { tool, mockChatAgentService }; - } - - function createInvocation(sessionUri: URI, userSelectedTools?: UserSelectedTools): IToolInvocation { - return { - callId: `call-${++callIdCounter}`, - toolId: 'runSubagent', - parameters: { prompt: 'do something', description: 'test' }, - context: { sessionResource: sessionUri }, - userSelectedTools: userSelectedTools ?? { runSubagent: true }, - } as IToolInvocation; - } - - const countTokens = async () => 0; - const noProgress: ToolProgress = { report() { } }; - test('disables runSubagent tool when nesting is disabled', async () => { const capturedRequests: IChatAgentRequest[] = []; const { tool } = createInvokableTool({ allowInvocationsFromSubagents: false, capturedRequests }); @@ -1225,6 +1496,186 @@ suite('RunSubagentTool', () => { }); }); + suite('default to Auto model', () => { + test('passes prepared Auto model to participant without resolving it again', async () => { + const capturedRequests: IChatAgentRequest[] = []; + const mainMeta = createMetadata('GPT-4o', 1, COPILOT_VENDOR_ID); + const autoMeta = createAutoMetadata(); + let selectCalls = 0; + const telemetryService = new TestTelemetryService(); + const { tool } = createInvokableTool({ + allowInvocationsFromSubagents: false, + capturedRequests, + defaultToAuto: true, + models: new Map([['main-model-id', mainMeta]]), + selectedModels: new Map([['copilot-auto-model-id', autoMeta]]), + onSelectLanguageModels: () => selectCalls++, + telemetryService, + }); + const sessionUri = URI.parse('test://session/prepared-auto'); + const invocation = createInvocation(sessionUri, undefined, 'main-model-id'); + + await tool.prepareToolInvocation({ + parameters: invocation.parameters, + toolCallId: invocation.callId, + modelId: invocation.modelId, + chatSessionResource: sessionUri, + }, CancellationToken.None); + assert.deepStrictEqual(telemetryService.events, []); + await tool.invoke(invocation, countTokens, noProgress, CancellationToken.None); + + assert.deepStrictEqual({ + userSelectedModelId: capturedRequests[0].userSelectedModelId, + selectCalls, + telemetryEvents: telemetryService.events, + }, { + userSelectedModelId: 'copilot-auto-model-id', + selectCalls: 1, + telemetryEvents: [{ + name: 'chat.subagentModelSelection', + data: { selectionSource: 'autoDefault' }, + }], + }); + }); + + test('resolves and passes Auto to participant when preparation was skipped', async () => { + const capturedRequests: IChatAgentRequest[] = []; + const mainMeta = createMetadata('GPT-4o', 1, COPILOT_VENDOR_ID); + const autoMeta = createAutoMetadata(); + let selectCalls = 0; + const telemetryService = new TestTelemetryService(); + const { tool } = createInvokableTool({ + allowInvocationsFromSubagents: false, + capturedRequests, + defaultToAuto: true, + models: new Map([['main-model-id', mainMeta]]), + selectedModels: new Map([['copilot-auto-model-id', autoMeta]]), + onSelectLanguageModels: () => selectCalls++, + telemetryService, + }); + + await tool.invoke( + createInvocation(URI.parse('test://session/direct-auto'), undefined, 'main-model-id'), + countTokens, + noProgress, + CancellationToken.None, + ); + + assert.deepStrictEqual({ + userSelectedModelId: capturedRequests[0].userSelectedModelId, + selectCalls, + telemetryEvents: telemetryService.events, + }, { + userSelectedModelId: 'copilot-auto-model-id', + selectCalls: 1, + telemetryEvents: [{ + name: 'chat.subagentModelSelection', + data: { selectionSource: 'autoDefault' }, + }], + }); + }); + + test('keeps main model when the inherited current agent configures a model', async () => { + const capturedRequests: IChatAgentRequest[] = []; + const mainMeta = createMetadata('GPT-4o', 1, COPILOT_VENDOR_ID); + const autoMeta = createAutoMetadata(); + const currentAgent = createAgent('CurrentAgent', ['Claude Sonnet (TestVendor)']); + let selectCalls = 0; + const telemetryService = new TestTelemetryService(); + const { tool } = createInvokableTool({ + allowInvocationsFromSubagents: false, + capturedRequests, + currentModeInstructions: { uri: currentAgent.uri, name: currentAgent.name, content: 'test', toolReferences: [] }, + customAgents: [currentAgent], + defaultToAuto: true, + models: new Map([['main-model-id', mainMeta]]), + selectedModels: new Map([['copilot-auto-model-id', autoMeta]]), + onSelectLanguageModels: () => selectCalls++, + telemetryService, + }); + const sessionUri = URI.parse('test://session/inherited-agent-model'); + const invocation = createInvocation(sessionUri, undefined, 'main-model-id'); + + await tool.prepareToolInvocation({ + parameters: invocation.parameters, + toolCallId: invocation.callId, + modelId: invocation.modelId, + chatSessionResource: sessionUri, + }, CancellationToken.None); + await tool.invoke(invocation, countTokens, noProgress, CancellationToken.None); + + assert.deepStrictEqual({ + subAgentName: capturedRequests[0].subAgentName, + userSelectedModelId: capturedRequests[0].userSelectedModelId, + selectCalls, + telemetryEvents: telemetryService.events, + }, { + subAgentName: 'CurrentAgent', + userSelectedModelId: 'main-model-id', + selectCalls: 0, + telemetryEvents: [{ + name: 'chat.subagentModelSelection', + data: { selectionSource: 'mainModel' }, + }], + }); + }); + + test('reports explicit and configured agent model selection sources', async () => { + const mainMeta = createMetadata('GPT-4o', 1, COPILOT_VENDOR_ID); + const selectedMeta = createMetadata('Claude Sonnet', 1); + const qualifiedName = 'Claude Sonnet (TestVendor)'; + const qualifiedNameMap = new Map([ + [qualifiedName, { metadata: selectedMeta, identifier: 'selected-model-id' }], + ]); + const configuredAgent = { ...createAgent('ConfiguredAgent', [qualifiedName]), tools: undefined }; + const selections: unknown[] = []; + + for (const testCase of [ + { name: 'explicit', parameters: { prompt: 'do something', description: 'test', model: qualifiedName } }, + { name: 'agent', parameters: { prompt: 'do something', description: 'test', agentName: 'ConfiguredAgent' } }, + ]) { + const telemetryService = new TestTelemetryService(); + const capturedRequests: IChatAgentRequest[] = []; + const { tool } = createInvokableTool({ + allowInvocationsFromSubagents: false, + capturedRequests, + customAgents: [configuredAgent], + defaultToAuto: true, + models: new Map([ + ['main-model-id', mainMeta], + ['selected-model-id', selectedMeta], + ['copilot-auto-model-id', createAutoMetadata()], + ]), + qualifiedNameMap, + copilotVendorResolved: true, + telemetryService, + }); + const invocation = createInvocation(URI.parse(`test://session/${testCase.name}`), undefined, 'main-model-id'); + invocation.parameters = testCase.parameters; + + const result = await tool.invoke(invocation, countTokens, noProgress, CancellationToken.None); + if (capturedRequests.length !== 1) { + throw new Error(`${testCase.name}: ${JSON.stringify(result)}`); + } + selections.push({ + selectedModelId: capturedRequests[0].userSelectedModelId, + telemetry: telemetryService.events[0], + }); + } + + assert.deepStrictEqual(selections, [ + { + selectedModelId: 'selected-model-id', + telemetry: { name: 'chat.subagentModelSelection', data: { selectionSource: 'explicitModel' } }, + }, + { + selectedModelId: 'selected-model-id', + telemetry: { name: 'chat.subagentModelSelection', data: { selectionSource: 'agentModel' } }, + }, + ]); + }); + }); + suite('subagent credits', () => { let creditsCallIdCounter = 0; @@ -1273,12 +1724,13 @@ suite('RunSubagentTool', () => { mockChatAgentService as IChatAgentService, mockChatService as IChatService, mockToolsService, - {} as ILanguageModelsService, + createLanguageModelsServiceMock(), new NullLogService(), configService, promptsService, mockInstantiationService as IInstantiationService, {} as IProductService, + NullTelemetryService, )); return { tool, parentCredits }; } diff --git a/src/vs/workbench/contrib/chat/test/electron-browser/agentSessionsActions.test.ts b/src/vs/workbench/contrib/chat/test/electron-browser/agentSessionsActions.test.ts new file mode 100644 index 0000000000000..ffaea1eb0df2f --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/electron-browser/agentSessionsActions.test.ts @@ -0,0 +1,66 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { encodeHex, VSBuffer } from '../../../../../base/common/buffer.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { Schemas } from '../../../../../base/common/network.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { upcastPartial } from '../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { INativeHostService, IOpenAgentsWindowOptions } from '../../../../../platform/native/common/native.js'; +import { AgentsWindowOpenSource } from '../../../../../platform/window/common/window.js'; +import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; +import { OpenWorkspaceInAgentsWindowAction } from '../../electron-browser/agentSessions/agentSessionsActions.js'; + +suite('OpenWorkspaceInAgentsWindowAction', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + test('opens the Agents Window with the local folder and Dev Container preference', async () => { + const store = disposables.add(new DisposableStore()); + const instantiationService = store.add(new TestInstantiationService()); + let workspaceFolderUri = URI.file('/workspace'); + const calls: IOpenAgentsWindowOptions[] = []; + instantiationService.stub(IWorkspaceContextService, upcastPartial({ + getWorkspace: () => ({ + id: 'workspace', + folders: [{ + uri: workspaceFolderUri, + name: 'workspace', + index: 0, + toResource: relativePath => URI.joinPath(workspaceFolderUri, relativePath), + }], + }), + })); + instantiationService.stub(INativeHostService, upcastPartial({ + openAgentsWindow: async options => { calls.push(options ?? {}); }, + })); + + await instantiationService.invokeFunction(accessor => new OpenWorkspaceInAgentsWindowAction().run(accessor, { + source: AgentsWindowOpenSource.TitleBar, + })); + const hostFolderUri = URI.file('/host/workspace'); + workspaceFolderUri = URI.from({ + scheme: Schemas.vscodeRemote, + authority: `dev-container+${encodeHex(VSBuffer.fromString(hostFolderUri.fsPath))}`, + path: '/workspaces/project', + }); + await instantiationService.invokeFunction(accessor => new OpenWorkspaceInAgentsWindowAction().run(accessor, { + source: AgentsWindowOpenSource.ChatTitleBar, + })); + + assert.deepStrictEqual(calls.map(call => ({ + folderUri: URI.revive(call.folderUri)?.toString(), + source: call.source, + })), [{ + folderUri: URI.file('/workspace').toString(), + source: AgentsWindowOpenSource.TitleBar, + }, { + folderUri: workspaceFolderUri.toString(), + source: AgentsWindowOpenSource.ChatTitleBar, + }]); + }); +}); diff --git a/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts b/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts index 56405fc019dcb..b06c588bd0ecd 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts @@ -371,6 +371,27 @@ Registry.as(ConfigurationExtensions.Configuration) default: 'github', scope: ConfigurationScope.APPLICATION, included: false, + policy: { + name: 'ExtensionGalleryAuthProvider', + category: PolicyCategory.Extensions, + minimumVersion: '1.137', + localization: { + description: { + key: 'extensions.gallery.authProvider', + value: localize('extensions.gallery.authProvider', "Configure the authentication provider for the Extensions Marketplace"), + }, + enumDescriptions: [ + { + key: 'extensions.gallery.authProvider.github', + value: localize('extensions.gallery.authProvider.github', "Authenticate to the Extensions Marketplace using GitHub."), + }, + { + key: 'extensions.gallery.authProvider.microsoft', + value: localize('extensions.gallery.authProvider.microsoft', "Authenticate to the Extensions Marketplace using a Microsoft (Entra ID) account."), + }, + ] + } + }, }, 'extensions.supportNodeGlobalNavigator': { type: 'boolean', diff --git a/src/vs/workbench/contrib/github/browser/githubLinkPresentation.contribution.ts b/src/vs/workbench/contrib/github/browser/githubLinkPresentation.contribution.ts index dbec4ea9161f0..044b3552f12c9 100644 --- a/src/vs/workbench/contrib/github/browser/githubLinkPresentation.contribution.ts +++ b/src/vs/workbench/contrib/github/browser/githubLinkPresentation.contribution.ts @@ -15,6 +15,7 @@ import { FragmentState, PullRequestCheck, PullRequestCore, PullRequestRef, PullR import { GitHubRequestError } from '../../../../platform/github/common/githubTransport.js'; import { GitHubAccountHandle, GitHubRequestErrorKind } from '../../../../platform/github/common/githubTypes.js'; import { ILogService } from '../../../../platform/log/common/log.js'; +import { INotificationService, NeverShowAgainScope, Severity } from '../../../../platform/notification/common/notification.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../common/contributions.js'; const githubRepositoryProviderId = 'workbench.github.repositoryLinkPresentation'; @@ -31,6 +32,7 @@ export class GitHubLinkPresentationContribution extends Disposable implements IW static readonly ID = 'workbench.contrib.githubLinkPresentations'; private readonly _registrations = this._register(new MutableDisposable()); + private readonly _authenticationNotification = this._register(new MutableDisposable()); private readonly _provider: GitHubLinkPresentationProvider; constructor( @@ -38,13 +40,46 @@ export class GitHubLinkPresentationContribution extends Disposable implements IW @ILinkPresentationService private readonly _linkPresentationService: ILinkPresentationService, @IDefaultAccountService private readonly _defaultAccountService: IDefaultAccountService, @ILogService logService: ILogService, + @INotificationService private readonly _notificationService: INotificationService, ) { super(); - this._provider = this._register(new GitHubLinkPresentationProvider(gitHubService, logService)); - this._register(_defaultAccountService.onDidChangeDefaultAccount(() => this._registerProviders())); + this._provider = this._register(new GitHubLinkPresentationProvider(gitHubService, logService, () => this._showAuthenticationRequiredNotification())); + this._register(_defaultAccountService.onDidChangeDefaultAccount(() => { + this._authenticationNotification.clear(); + this._registerProviders(); + })); this._registerProviders(); } + private _showAuthenticationRequiredNotification(): void { + if (this._authenticationNotification.value) { + return; + } + + const handleDisposables = new DisposableStore(); + const handle = this._notificationService.prompt( + Severity.Info, + localize('github.authenticationRequired', "Sign in to GitHub to load pull request status and other GitHub link details."), + [{ + label: localize('github.authenticationRequired.signIn', "Sign In"), + run: async () => { + await this._defaultAccountService.signIn({ additionalScopes: ['repo'] }); + }, + }], + { + sticky: true, + neverShowAgain: { + id: 'github.linkPresentation.authenticationRequired', + isSecondary: true, + scope: NeverShowAgainScope.PROFILE, + }, + }, + ); + handleDisposables.add(handle.onDidClose(() => this._authenticationNotification.clear())); + handleDisposables.add({ dispose: () => handle.close() }); + this._authenticationNotification.value = handleDisposables; + } + private _registerProviders(): void { this._registrations.clear(); const authority = URI.parse(this._defaultAccountService.resolveGitHubUrl('')).authority; @@ -82,6 +117,7 @@ class GitHubLinkPresentationProvider extends Disposable implements ILinkPresenta constructor( private readonly _gitHubService: IGitHubService, private readonly _logService: ILogService, + private readonly _onAuthenticationRequired: () => void, ) { super(); this._hydrator = this._register(new GitHubLinkPresentationHydrator(_gitHubService, _logService)); @@ -92,7 +128,7 @@ class GitHubLinkPresentationProvider extends Disposable implements ILinkPresenta if (!target) { throw new Error(`Unsupported GitHub link presentation resource: ${resource.toString(true)}`); } - return new GitHubLinkPresentationWatcher(target, this._gitHubService, this._hydrator, this._logService); + return new GitHubLinkPresentationWatcher(target, this._gitHubService, this._hydrator, this._logService, this._onAuthenticationRequired); } } @@ -189,6 +225,7 @@ class GitHubLinkPresentationWatcher extends Disposable implements ILinkPresentat private readonly _gitHubService: IGitHubService, private readonly _hydrator: GitHubLinkPresentationHydrator, private readonly _logService: ILogService, + private readonly _onAuthenticationRequired: () => void, ) { super(); this._register(_gitHubService.credentials.onDidInvalidate(() => this._initialize())); @@ -223,19 +260,19 @@ class GitHubLinkPresentationWatcher extends Disposable implements ILinkPresentat owner: target.owner, repo: target.repo, }, { priority: 'visible' })); - store.add(autorun(reader => this._presentation.set( - repositoryPresentation(target, subscription.resource.state.read(reader)), - undefined, - ))); + store.add(autorun(reader => { + const state = subscription.resource.state.read(reader); + this._setPresentation(repositoryPresentation(target, state), state.status === 'error' ? state.error : undefined); + })); break; } case 'issue': { const ref: GitHubIssueRef = { ...account, owner: target.owner, repo: target.repo, number: target.number }; const subscription = store.add(this._gitHubService.query.subscribeIssue(ref, { priority: 'visible' })); - store.add(autorun(reader => this._presentation.set( - issuePresentation(target, subscription.resource.state.read(reader)), - undefined, - ))); + store.add(autorun(reader => { + const state = subscription.resource.state.read(reader); + this._setPresentation(issuePresentation(target, state), state.status === 'error' ? state.error : undefined); + })); break; } case 'pullRequest': { @@ -245,10 +282,10 @@ class GitHubLinkPresentationWatcher extends Disposable implements ILinkPresentat core: true, checks: { includeOptional: true }, })); - store.add(autorun(reader => this._presentation.set( - pullRequestPresentation(target, subscription.resource.snapshot.read(reader)), - undefined, - ))); + store.add(autorun(reader => { + const snapshot = subscription.resource.snapshot.read(reader); + this._setPresentation(pullRequestPresentation(target, snapshot), snapshot.core.status === 'error' ? snapshot.core.error : undefined); + })); break; } } @@ -257,8 +294,22 @@ class GitHubLinkPresentationWatcher extends Disposable implements ILinkPresentat return; } this._logService.trace(`[GitHubLinkPresentation] Failed to resolve ${formatTarget(this._target)}`, error); - this._presentation.set(failurePresentation(this._target.kind, error instanceof GitHubRequestError ? error.kind : undefined), undefined); + const errorKind = error instanceof GitHubRequestError ? error.kind : undefined; + this._setPresentation(failurePresentation(this._target.kind, errorKind), { + kind: errorKind ?? 'unknown', + message: error instanceof Error ? error.message : String(error), + }); + } + } + + private _setPresentation(presentation: ILinkPresentation | undefined, error: { readonly kind: GitHubRequestErrorKind; readonly message: string } | undefined): void { + if (error) { + this._logService.warn(`[GitHubLinkPresentation] ${formatTarget(this._target)} failed with '${error.kind}': ${error.message}`); + } + if (error?.kind === 'authentication') { + this._onAuthenticationRequired(); } + this._presentation.set(presentation, undefined); } } diff --git a/src/vs/workbench/contrib/github/test/browser/githubLinkPresentation.test.ts b/src/vs/workbench/contrib/github/test/browser/githubLinkPresentation.test.ts index b28f6e4631d4f..5e42587cd183b 100644 --- a/src/vs/workbench/contrib/github/test/browser/githubLinkPresentation.test.ts +++ b/src/vs/workbench/contrib/github/test/browser/githubLinkPresentation.test.ts @@ -15,8 +15,10 @@ import { ILinkPresentationProvider, ILinkPresentationProviderRegistration, ILink import { IDefaultAccountService } from '../../../../../platform/defaultAccount/common/defaultAccount.js'; import { IGitHubService } from '../../../../../platform/github/common/githubService.js'; import { GitHubIssue, GitHubRepository } from '../../../../../platform/github/common/githubQueryService.js'; -import { FragmentState, PullRequestSnapshot } from '../../../../../platform/github/common/githubPullRequestService.js'; +import { FragmentState, PullRequestCore, PullRequestSnapshot } from '../../../../../platform/github/common/githubPullRequestService.js'; +import { GitHubRequestError } from '../../../../../platform/github/common/githubTransport.js'; import { NullLogService } from '../../../../../platform/log/common/log.js'; +import { INotificationService, NeverShowAgainScope, NoOpNotification, Severity } from '../../../../../platform/notification/common/notification.js'; import { GitHubLinkPresentationContribution } from '../../browser/githubLinkPresentation.contribution.js'; suite('GitHub link presentations', () => { @@ -35,6 +37,7 @@ suite('GitHub link presentations', () => { } }(), new NullLogService(), + new TestNotificationService(), )); const resources = [ @@ -92,6 +95,7 @@ suite('GitHub link presentations', () => { } }(), new NullLogService(), + new TestNotificationService(), )); const before = linkPresentationService.hasProvider(URI.parse('https://github.com/microsoft/vscode/issues/1')); @@ -108,8 +112,105 @@ suite('GitHub link presentations', () => { newAuthority: true, }); }); + + test('prompts once to sign in when authentication is required', async () => { + const linkPresentationService = new TestLinkPresentationService(); + const notificationService = new TestNotificationService(); + let signInOptions: Parameters[0]; + store.add(new GitHubLinkPresentationContribution( + createGitHubService( + () => { }, + async () => { throw new GitHubRequestError('GitHub authentication is required', 'authentication'); }, + ), + linkPresentationService, + new class extends mock() { + override readonly onDidChangeDefaultAccount = Event.None; + override resolveGitHubUrl(path: string): string { + return `https://github.com/${path}`; + } + override async signIn(options?: Parameters[0]): Promise { + signInOptions = options; + return null; + } + }(), + new NullLogService(), + notificationService, + )); + + store.add(linkPresentationService.createWatcher(URI.parse('https://github.com/microsoft/vscode/issues/7'))); + store.add(linkPresentationService.createWatcher(URI.parse('https://github.com/microsoft/vscode/pull/8'))); + await Promise.resolve(); + await notificationService.prompts[0].choices[0].run(); + + assert.deepStrictEqual({ + prompts: notificationService.prompts.map(prompt => ({ + severity: prompt.severity, + message: prompt.message, + labels: prompt.choices.map(choice => choice.label), + sticky: prompt.options?.sticky, + neverShowAgain: prompt.options?.neverShowAgain, + })), + signInOptions, + }, { + prompts: [{ + severity: Severity.Info, + message: 'Sign in to GitHub to load pull request status and other GitHub link details.', + labels: ['Sign In'], + sticky: true, + neverShowAgain: { + id: 'github.linkPresentation.authenticationRequired', + isSecondary: true, + scope: NeverShowAgainScope.PROFILE, + }, + }], + signInOptions: { additionalScopes: ['repo'] }, + }); + }); + + test('prompts to sign in when a pull request subscription reports an authentication error', async () => { + const linkPresentationService = new TestLinkPresentationService(); + const notificationService = new TestNotificationService(); + store.add(new GitHubLinkPresentationContribution( + createGitHubService( + () => { }, + undefined, + { status: 'error', complete: false, error: { message: 'GitHub authentication is required', kind: 'authentication' } }, + ), + linkPresentationService, + new class extends mock() { + override readonly onDidChangeDefaultAccount = Event.None; + override resolveGitHubUrl(path: string): string { + return `https://github.com/${path}`; + } + }(), + new NullLogService(), + notificationService, + )); + + store.add(linkPresentationService.createWatcher(URI.parse('https://github.com/microsoft/vscode/pull/8'))); + await Promise.resolve(); + + assert.deepStrictEqual(notificationService.prompts.map(prompt => prompt.message), [ + 'Sign in to GitHub to load pull request status and other GitHub link details.', + ]); + }); }); +class TestNotificationService extends mock() { + + readonly prompts: { + readonly severity: Severity; + readonly message: string; + readonly choices: Parameters[2]; + readonly options: Parameters[3]; + }[] = []; + + override prompt(...[severity, message, choices, options]: Parameters): NoOpNotification { + this.prompts.push({ severity, message, choices, options }); + return new NoOpNotification(); + } +} + class TestLinkPresentationService extends mock() { private readonly _providers: { readonly registration: ILinkPresentationProviderRegistration; readonly provider: ILinkPresentationProvider }[] = []; @@ -141,14 +242,23 @@ class TestLinkPresentationService extends mock() { } } -function createGitHubService(onHydrate: (resources: Parameters[0]) => void): IGitHubService { +function createGitHubService( + onHydrate: (resources: Parameters[0]) => void, + getCredential: IGitHubService['credentials']['getCredential'] = async () => ({ + account: { host: 'api.github.com', accountId: '1' }, + token: 'token', + generation: 1, + signal: new AbortController().signal, + }), + pullRequestCore?: FragmentState, +): IGitHubService { const ready = (value: T): FragmentState => ({ value, status: 'ready', complete: true }); const missing: FragmentState = { status: 'missing', complete: false }; const pullRequestSnapshot: PullRequestSnapshot = { ref: { host: 'api.github.com', accountId: '1', owner: 'microsoft', repo: 'vscode', number: 8 }, generation: 1, headGeneration: 1, - core: ready({ + core: pullRequestCore ?? ready({ repositoryNameWithOwner: 'microsoft/vscode', number: 8, title: 'Pull request title', @@ -189,12 +299,7 @@ function createGitHubService(onHydrate: (resources: Parameters() { override readonly credentials = { onDidInvalidate: Event.None, - getCredential: async () => ({ - account: { host: 'api.github.com', accountId: '1' }, - token: 'token', - generation: 1, - signal: new AbortController().signal, - }), + getCredential, resolveCredential: async () => { throw new Error('Not implemented'); }, handleRequestError: () => { }, }; diff --git a/src/vs/workbench/contrib/multiDiffEditor/browser/multiDiffEditor.ts b/src/vs/workbench/contrib/multiDiffEditor/browser/multiDiffEditor.ts index a82ed9620aba8..7e03fe3b97d46 100644 --- a/src/vs/workbench/contrib/multiDiffEditor/browser/multiDiffEditor.ts +++ b/src/vs/workbench/contrib/multiDiffEditor/browser/multiDiffEditor.ts @@ -7,6 +7,7 @@ import * as DOM from '../../../../base/browser/dom.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { Disposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { MultiDiffEditorWidget } from '../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.js'; +import { MultiDiffEditorVariant } from '../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorOptions.js'; import { MultiDiffEditorLogger } from '../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorLogging.js'; import { IResourceLabel, IWorkbenchUIElementFactory, MultiDiffEditorItemLabelKind } from '../../../../editor/browser/widget/multiDiffEditor/workbenchUIElementFactory.js'; import { ITextResourceConfigurationService } from '../../../../editor/common/services/textResourceConfiguration.js'; @@ -18,7 +19,6 @@ import { IStorageService } from '../../../../platform/storage/common/storage.js' import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { IThemeService } from '../../../../platform/theme/common/themeService.js'; import { ResourceLabel } from '../../../browser/labels.js'; -import { IsSessionsWindowContext } from '../../../common/contextkeys.js'; import { AbstractEditorWithViewState } from '../../../browser/parts/editor/editorWithViewState.js'; import { ICompositeControl } from '../../../common/composite.js'; import { IEditorOpenContext } from '../../../common/editor.js'; @@ -84,7 +84,7 @@ export class MultiDiffEditor extends AbstractEditorWithViewState { @@ -244,15 +244,10 @@ class MultiDiffEditorContentMenuOverlay extends Disposable { } class WorkbenchUIElementFactory implements IWorkbenchUIElementFactory { - readonly headerClickToCollapse: boolean; - constructor( @IInstantiationService private readonly _instantiationService: IInstantiationService, - @IContextKeyService contextKeyService: IContextKeyService, @IEditorService private readonly editorService: IEditorService, - ) { - this.headerClickToCollapse = IsSessionsWindowContext.getValue(contextKeyService) === true; - } + ) { } createResourceLabel(element: HTMLElement, _kind: MultiDiffEditorItemLabelKind): IResourceLabel { const label = this._instantiationService.createInstance(ResourceLabel, element, {}); diff --git a/src/vs/workbench/contrib/notebook/browser/diff/notebookMultiDiffEditor.ts b/src/vs/workbench/contrib/notebook/browser/diff/notebookMultiDiffEditor.ts index 87ee014e9f7c6..1a5cf832bc67c 100644 --- a/src/vs/workbench/contrib/notebook/browser/diff/notebookMultiDiffEditor.ts +++ b/src/vs/workbench/contrib/notebook/browser/diff/notebookMultiDiffEditor.ts @@ -28,6 +28,7 @@ import { NotebookOptions } from '../notebookOptions.js'; import { INotebookService } from '../../common/notebookService.js'; import { NotebookMultiDiffEditorInput, NotebookMultiDiffEditorWidgetInput } from './notebookMultiDiffEditorInput.js'; import { MultiDiffEditorWidget } from '../../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.js'; +import { MultiDiffEditorVariant } from '../../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorOptions.js'; import { ResourceLabel } from '../../../../browser/labels.js'; import { INotebookDocumentService } from '../../../../services/notebook/common/notebookDocumentService.js'; import { localize } from '../../../../../nls.js'; @@ -103,7 +104,7 @@ export class NotebookMultiTextDiffEditor extends EditorPane { MultiDiffEditorWidget, parent, this.instantiationService.createInstance(WorkbenchUIElementFactory), - undefined, + { variant: MultiDiffEditorVariant.Standard }, )); this._register(this._multiDiffEditorWidget.onDidChangeActiveControl(() => { diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts index 9f02970edf301..e014242645634 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts @@ -558,7 +558,7 @@ export const terminalChatAgentToolsConfiguration: IStringDictionary { }; workspaceContextService.setWorkspaceFolders([URI.file('/workspace-one')]); - // Setup default configuration + // Use an explicitly network-restricted sandbox for the restriction tests. configurationService.setUserConfiguration(AgentSandboxSettingId.AgentSandboxEnabled, AgentSandboxEnabledValue.On); + configurationService.setUserConfiguration(AgentSandboxSettingId.AgentSandboxAllowNetwork, false); configurationService.setUserConfiguration(AgentSandboxSettingId.AgentSandboxAllowUnsandboxedCommands, true); configurationService.setUserConfiguration(AgentSandboxSettingId.AgentSandboxRetryWithAllowNetworkRequests, true); configurationService.setUserConfiguration(AgentNetworkDomainSettingId.AllowedNetworkDomains, []); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/common/sandboxSettingsReader.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/common/sandboxSettingsReader.test.ts index 1b12a94d03ac8..72443ea5b1cd5 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/common/sandboxSettingsReader.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/common/sandboxSettingsReader.test.ts @@ -11,6 +11,7 @@ import { AgentNetworkDomainSettingId } from '../../../../../../platform/networkF import { AgentSandboxEnabledValue, AgentSandboxSettingId } from '../../../../../../platform/sandbox/common/settings.js'; import { AgentHostSandboxKey } from '../../../../../../platform/agentHost/common/sandboxConfigSchema.js'; import { readAgentHostSandboxValues, readSandboxSetting } from '../../common/sandboxSettingsReader.js'; +import { terminalChatAgentToolsConfiguration } from '../../common/terminalChatAgentToolsConfiguration.js'; suite('sandboxSettingsReader', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -25,6 +26,19 @@ suite('sandboxSettingsReader', () => { ); }); + test('forwards the network default and explicit network restrictions to the agent host', async () => { + const settingId = AgentSandboxSettingId.AgentSandboxAllowNetwork; + const cfg = new TestConfigurationService({ [settingId]: terminalChatAgentToolsConfiguration[settingId].default }); + const logService = new NullLogService(); + const values = [readAgentHostSandboxValues(cfg, logService)]; + await cfg.setUserConfiguration(settingId, false); + values.push(readAgentHostSandboxValues(cfg, logService)); + assert.deepStrictEqual(values, [ + { [AgentHostSandboxKey.AllowNetwork]: true }, + { [AgentHostSandboxKey.AllowNetwork]: false }, + ]); + }); + test('returns undefined when nothing is configured', () => { const cfg = new TestConfigurationService(); assert.strictEqual( diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/common/terminalChatAgentToolsConfiguration.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/common/terminalChatAgentToolsConfiguration.test.ts index 3bd6ca111cdef..28ec3958766d0 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/common/terminalChatAgentToolsConfiguration.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/common/terminalChatAgentToolsConfiguration.test.ts @@ -6,6 +6,8 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { ConfigurationModelParser } from '../../../../../../platform/configuration/common/configurationModels.js'; +import { DefaultConfiguration } from '../../../../../../platform/configuration/common/configurations.js'; +import { AgentSandboxSettingId } from '../../../../../../platform/sandbox/common/settings.js'; import { Extensions, IConfigurationNode, IConfigurationRegistry } from '../../../../../../platform/configuration/common/configurationRegistry.js'; import { NullLogService } from '../../../../../../platform/log/common/log.js'; import { Registry } from '../../../../../../platform/registry/common/platform.js'; @@ -13,7 +15,7 @@ import { WorkspaceConfigurationModelParser } from '../../../../../services/confi import { terminalChatAgentToolsConfiguration, TerminalChatAgentToolsSettingId } from '../../common/terminalChatAgentToolsConfiguration.js'; suite('Terminal chat agent tools configuration', () => { - ensureNoDisposablesAreLeakedInTestSuite(); + const store = ensureNoDisposablesAreLeakedInTestSuite(); const configurationRegistry = Registry.as(Extensions.Configuration); const configurationNode: IConfigurationNode = { id: 'terminalChatAgentToolsConfigurationTest', @@ -41,6 +43,19 @@ suite('Terminal chat agent tools configuration', () => { suiteTeardown(() => configurationRegistry.deregisterConfigurations([configurationNode])); + test('allows sandbox network access by default and preserves explicit overrides', async () => { + const logService = new NullLogService(); + const defaults = await store.add(new DefaultConfiguration(logService)).initialize(); + const settingId = AgentSandboxSettingId.AgentSandboxAllowNetwork; + const values = [defaults.getValue(settingId)]; + for (const value of [false, true]) { + const parser = new ConfigurationModelParser('sandboxNetworkSettings', logService); + parser.parse(JSON.stringify({ [settingId]: value })); + values.push(defaults.merge(parser.configurationModel).getValue(settingId)); + } + assert.deepStrictEqual(values, [true, false, true]); + }); + test('registers terminal safety settings as restricted', () => { assert.deepStrictEqual( restrictedSettingIds.map(id => terminalChatAgentToolsConfiguration[id].restricted), diff --git a/src/vs/workbench/services/accounts/browser/defaultAccount.ts b/src/vs/workbench/services/accounts/browser/defaultAccount.ts index 23f0dbb85c1ae..ed17979520225 100644 --- a/src/vs/workbench/services/accounts/browser/defaultAccount.ts +++ b/src/vs/workbench/services/accounts/browser/defaultAccount.ts @@ -1337,7 +1337,7 @@ export class DefaultAccountProvider extends Disposable implements IDefaultAccoun const adapted = adaptManagedSettings(data ?? {}, msg => this.logService.warn(msg)); // An empty response (`{}`) is a successful "no policy file present" signal. const managedSettingsCount = adapted.managedSettings ? Object.keys(adapted.managedSettings).length : 0; - if (managedSettingsCount === 0) { + if (managedSettingsCount === 0 && adapted.managedSettingsActive !== true) { this.logService.debug('[DefaultAccount] Managed settings fetched (empty response — no enterprise policy file present)'); } else { this.logService.info('[DefaultAccount] Managed settings applied'); diff --git a/src/vs/workbench/services/accounts/browser/managedSettings.ts b/src/vs/workbench/services/accounts/browser/managedSettings.ts index 4fc1370469e7d..b764f929f499d 100644 --- a/src/vs/workbench/services/accounts/browser/managedSettings.ts +++ b/src/vs/workbench/services/accounts/browser/managedSettings.ts @@ -7,7 +7,7 @@ import { IPolicyData } from '../../../../base/common/defaultAccount.js'; import { IProductConfiguration } from '../../../../base/common/product.js'; import { isString } from '../../../../base/common/types.js'; import { IManagedSettingsCompatibilityError, MANAGED_SETTINGS_UPDATE_REQUIRED_ERROR_CODE } from '../../../../platform/defaultAccount/common/defaultAccount.js'; -import { normalizeManagedSettings } from '../../../../platform/policy/common/copilotManagedSettings.js'; +import { hasRawManagedSettings, normalizeManagedSettings } from '../../../../platform/policy/common/copilotManagedSettings.js'; /** * Client identity VS Code reports to the managed settings service. It names this codebase's own @@ -40,6 +40,9 @@ export type IManagedMcpServerMatcher = export interface IManagedSettingsResponse { readonly permissions?: { readonly disableBypassPermissionsMode?: string; + readonly allow?: readonly string[]; + readonly ask?: readonly string[]; + readonly deny?: readonly string[]; /** * Legacy location for the default chat model. Retained for deployments authored against * the original schema; the top-level {@link IManagedSettingsResponse.model} wins when both @@ -144,5 +147,9 @@ export function parseManagedSettingsCompatibilityError(response: unknown): IMana * Exported for unit-testing the shape transformation independently of network I/O. */ export function adaptManagedSettings(response: IManagedSettingsResponse, onWarn?: (msg: string) => void): Partial { - return { managedSettings: normalizeManagedSettings(response as Record, onWarn) }; + const managedSettings = normalizeManagedSettings(response as Record, onWarn); + return { + managedSettings, + ...(Object.keys(managedSettings).length === 0 && hasRawManagedSettings(response) ? { managedSettingsActive: true } : {}), + }; } diff --git a/src/vs/workbench/services/accounts/test/browser/managedSettings.test.ts b/src/vs/workbench/services/accounts/test/browser/managedSettings.test.ts index 005ff029267d9..477b4f9426d30 100644 --- a/src/vs/workbench/services/accounts/test/browser/managedSettings.test.ts +++ b/src/vs/workbench/services/accounts/test/browser/managedSettings.test.ts @@ -46,6 +46,15 @@ suite('adaptManagedSettings', () => { }); }); + test('marks permission rules as active when they are retained only in the raw response', () => { + assert.deepStrictEqual(adaptManagedSettings({ + permissions: { deny: ['Shell'] }, + }), { + managedSettings: {}, + managedSettingsActive: true, + }); + }); + test('parses the stable compatibility error and optional versions', () => { assert.deepStrictEqual(parseManagedSettingsCompatibilityError({ error_code: 'client_update_required', @@ -281,7 +290,7 @@ suite('adaptManagedSettings', () => { } as IManagedSettingsResponse, msg => warnings.push(msg)); assert.deepStrictEqual( { result, warned: warnings.length, mentionsRepo: warnings.some(w => w.includes('requires "repo"')) }, - { result: { managedSettings: {} }, warned: 1, mentionsRepo: true } + { result: { managedSettings: {}, managedSettingsActive: true }, warned: 1, mentionsRepo: true } ); }); @@ -290,6 +299,7 @@ suite('adaptManagedSettings', () => { extraKnownMarketplaces: ['https://plugins.acme.com'] as unknown as IManagedSettingsResponse['extraKnownMarketplaces'], } as IManagedSettingsResponse), { managedSettings: {}, + managedSettingsActive: true, }); }); diff --git a/src/vs/workbench/services/agentHost/test/electron-browser/agentHostService.test.ts b/src/vs/workbench/services/agentHost/test/electron-browser/agentHostService.test.ts index ca11193240f02..be8a6ea7b9f8d 100644 --- a/src/vs/workbench/services/agentHost/test/electron-browser/agentHostService.test.ts +++ b/src/vs/workbench/services/agentHost/test/electron-browser/agentHostService.test.ts @@ -69,6 +69,7 @@ class TestAgentHostEnablementService extends Disposable implements IAgentHostEna private readonly _enabled; readonly enabled; readonly managedSandboxEnforced = constObservable(false); + readonly managedSandboxAllowsBypass = constObservable(false); constructor(enabled: boolean) { super(); diff --git a/src/vs/workbench/services/assignment/common/assignmentService.ts b/src/vs/workbench/services/assignment/common/assignmentService.ts index 0cd95c3b6938a..cc9ac957964df 100644 --- a/src/vs/workbench/services/assignment/common/assignmentService.ts +++ b/src/vs/workbench/services/assignment/common/assignmentService.ts @@ -8,6 +8,7 @@ import { createDecorator, IInstantiationService } from '../../../../platform/ins import type { IKeyValueStorage, IExperimentationTelemetry, IExperimentationFilterProvider, ExperimentationService as TASClient } from 'tas-client'; import { Memento } from '../../../common/memento.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; +import { TelemetryTrustedValue } from '../../../../platform/telemetry/common/telemetryUtils.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { ITelemetryData } from '../../../../base/common/actions.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; @@ -63,6 +64,20 @@ export function resolveScopedTreatment(read return scoped !== undefined ? scoped : read(name); } +/** + * Builds the telemetry payload for a tas-client feature query. The queried-feature name is marked + * trusted so the telemetry cleaner does not redact a `/vscode/`-scoped name as a `user-file-path`. + * + * Exported for testing. + */ +export function toExperimentTelemetryData(props: Map): ITelemetryData { + const data: ITelemetryData = {}; + for (const [key, value] of props.entries()) { + data[key] = key === 'ABExp.queriedFeature' ? new TelemetryTrustedValue(value) : value; + } + return data; +} + export interface IWorkbenchAssignmentService extends IAssignmentService { getCurrentExperiments(): Promise; addTelemetryAssignmentFilter(filter: IAssignmentFilter): void; @@ -135,10 +150,7 @@ class WorkbenchAssignmentServiceTelemetry extends Disposable implements IExperim } postEvent(eventName: string, props: Map): void { - const data: ITelemetryData = {}; - for (const [key, value] of props.entries()) { - data[key] = value; - } + const data = toExperimentTelemetryData(props); /* __GDPR__ "query-expfeature" : { diff --git a/src/vs/workbench/services/assignment/test/common/assignmentService.test.ts b/src/vs/workbench/services/assignment/test/common/assignmentService.test.ts index 18d92c7817d9d..1e8b48b8eb34a 100644 --- a/src/vs/workbench/services/assignment/test/common/assignmentService.test.ts +++ b/src/vs/workbench/services/assignment/test/common/assignmentService.test.ts @@ -5,7 +5,8 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { resolveScopedTreatment } from '../../common/assignmentService.js'; +import { cleanData } from '../../../../../platform/telemetry/common/telemetryUtils.js'; +import { resolveScopedTreatment, toExperimentTelemetryData } from '../../common/assignmentService.js'; suite('resolveScopedTreatment', () => { @@ -43,3 +44,18 @@ suite('resolveScopedTreatment', () => { assert.strictEqual(resolveScopedTreatment(read, BARE), false); }); }); + +suite('toExperimentTelemetryData', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('marks the queried feature name trusted so a /vscode/-scoped key survives telemetry cleaning', () => { + const scoped = '/vscode/config.chat.agentHost.copilot.multiTurnContextRouting.enabled'; + const data = toExperimentTelemetryData(new Map([['ABExp.queriedFeature', scoped]])); + + // The trusted feature name survives cleaning, whereas the same value left unmarked would be + // redacted by the file-path heuristic - guarding against a regression back to that behavior. + assert.strictEqual(cleanData(data, [])['ABExp.queriedFeature'], scoped); + assert.strictEqual(cleanData({ 'ABExp.queriedFeature': scoped }, [])['ABExp.queriedFeature'], ''); + }); +}); diff --git a/src/vs/workbench/services/configuration/browser/configurationService.ts b/src/vs/workbench/services/configuration/browser/configurationService.ts index 44728b84e4e93..6c6186a41a6e8 100644 --- a/src/vs/workbench/services/configuration/browser/configurationService.ts +++ b/src/vs/workbench/services/configuration/browser/configurationService.ts @@ -1438,15 +1438,17 @@ export class ConfigurationDefaultOverridesContribution extends Disposable implem } } const registeredDefault = this.registeredExperimentalDefaults.get(property); + if (registeredDefault && equals(registeredDefault.overrides[property], value)) { + // Already applied: the registry mutated `schema.default` to this value, so re-checking drops it. + continue; + } if (this.shouldOverride(value, schema)) { - if (!equals(registeredDefault?.overrides[property], value)) { - if (registeredDefault) { - removedDefaults.push(registeredDefault); - } - const nextDefault = { overrides: { [property]: value }, source: 'experiments' } satisfies IConfigurationDefaults; - this.registeredExperimentalDefaults.set(property, nextDefault); - addedDefaults.push(nextDefault); + if (registeredDefault) { + removedDefaults.push(registeredDefault); } + const nextDefault = { overrides: { [property]: value }, source: 'experiments' } satisfies IConfigurationDefaults; + this.registeredExperimentalDefaults.set(property, nextDefault); + addedDefaults.push(nextDefault); } else if (registeredDefault) { this.registeredExperimentalDefaults.delete(property); removedDefaults.push(registeredDefault); diff --git a/src/vs/workbench/services/configuration/test/browser/configurationService.test.ts b/src/vs/workbench/services/configuration/test/browser/configurationService.test.ts index 6cc104950ed4c..b8be93e1c4cea 100644 --- a/src/vs/workbench/services/configuration/test/browser/configurationService.test.ts +++ b/src/vs/workbench/services/configuration/test/browser/configurationService.test.ts @@ -61,6 +61,36 @@ suite('ConfigurationDefaultOverridesContribution', () => { ensureNoDisposablesAreLeakedInTestSuite(); const configurationRegistry = Registry.as(ConfigurationExtensions.Configuration); + + type TestContribution = { + processedExperimentalSettings: Set; + autoExperimentalSettings: Set; + pendingStartupExperimentalSettings: Set; + registeredExperimentalDefaults: Map; + configurationRegistry: IConfigurationRegistry; + workbenchAssignmentService: { + getTreatment(name: string): Promise; + }; + environmentService: { isSessionsWindow: boolean }; + processExperimentalSettings(properties: Iterable, autoRefetch: boolean): Promise; + }; + + // Builds the contribution without running its constructor so that `processExperimentalSettings` + // can be driven directly, resolving treatments from the given (mutable) record. + function createTestContribution(treatments: Record): TestContribution { + const contribution = Object.create(ConfigurationDefaultOverridesContribution.prototype) as TestContribution; + contribution.processedExperimentalSettings = new Set(); + contribution.autoExperimentalSettings = new Set(); + contribution.pendingStartupExperimentalSettings = new Set(); + contribution.registeredExperimentalDefaults = new Map(); + contribution.configurationRegistry = configurationRegistry; + contribution.workbenchAssignmentService = { + getTreatment: async (name: string) => treatments[name] as T | undefined, + }; + contribution.environmentService = { isSessionsWindow: false }; + return contribution; + } + const firstSetting = 'test.firstAutoExperimentalSetting'; const secondSetting = 'test.secondAutoExperimentalSetting'; const configuration: IConfigurationNode = { @@ -91,28 +121,7 @@ suite('ConfigurationDefaultOverridesContribution', () => { testFirstAutoExperimentalSetting: 'firstTreatment', testSecondAutoExperimentalSetting: 'secondTreatment', }; - type TestContribution = { - processedExperimentalSettings: Set; - autoExperimentalSettings: Set; - pendingStartupExperimentalSettings: Set; - registeredExperimentalDefaults: Map; - configurationRegistry: IConfigurationRegistry; - workbenchAssignmentService: { - getTreatment(name: string): Promise; - }; - environmentService: { isSessionsWindow: boolean }; - processExperimentalSettings(properties: Iterable, autoRefetch: boolean): Promise; - }; - const contribution = Object.create(ConfigurationDefaultOverridesContribution.prototype) as TestContribution; - contribution.processedExperimentalSettings = new Set(); - contribution.autoExperimentalSettings = new Set(); - contribution.pendingStartupExperimentalSettings = new Set(); - contribution.registeredExperimentalDefaults = new Map(); - contribution.configurationRegistry = configurationRegistry; - contribution.workbenchAssignmentService = { - getTreatment: async (name: string) => treatments[name] as T | undefined, - }; - contribution.environmentService = { isSessionsWindow: false }; + const contribution = createTestContribution(treatments); configurationRegistry.registerConfiguration(configuration); try { @@ -161,6 +170,89 @@ suite('ConfigurationDefaultOverridesContribution', () => { } }); + test('keeps an auto experiment override when it resolves again to the same value', async () => { + const treatments: Record = { + testFirstAutoExperimentalSetting: 'treatment', + testSecondAutoExperimentalSetting: undefined, + }; + const contribution = createTestContribution(treatments); + configurationRegistry.registerConfiguration(configuration); + + const readDefault = () => configurationRegistry.getConfigurationProperties()[firstSetting].default; + + try { + await contribution.processExperimentalSettings([firstSetting], false); + const afterInitial = readDefault(); + + // A refetch that resolves to the same value must not drop the override. + await contribution.processExperimentalSettings([firstSetting], true); + const afterRefetch = readDefault(); + + // Leaving the experiment removes the override again. + treatments.testFirstAutoExperimentalSetting = undefined; + await contribution.processExperimentalSettings([firstSetting], true); + + assert.deepStrictEqual({ afterInitial, afterRefetch, afterLeavingExperiment: readDefault() }, { + afterInitial: 'treatment', + afterRefetch: 'treatment', + afterLeavingExperiment: 'control', + }); + } finally { + if (contribution.registeredExperimentalDefaults.size) { + configurationRegistry.deregisterDefaultConfigurations([...contribution.registeredExperimentalDefaults.values()]); + } + configurationRegistry.deregisterConfigurations([configuration]); + } + }); + + test('applies an auto experiment value that matches the schema default over another default override', async () => { + const overriddenSetting = 'test.overriddenAutoExperimentalSetting'; + const overriddenConfiguration: IConfigurationNode = { + id: 'test.overriddenAutoExperimentalSettings', + type: 'object', + properties: { + [overriddenSetting]: { + type: 'string', + default: 'control', + experiment: { + mode: 'auto', + name: 'testOverriddenAutoExperimentalSetting' + } + } + } + }; + // A competing default override that has not opted out of experiment overrides. + const otherDefaults: IConfigurationDefaults = { overrides: { [overriddenSetting]: 'otherDefault' }, source: 'otherDefaults' }; + const treatments: Record = { + testOverriddenAutoExperimentalSetting: 'control', + }; + const contribution = createTestContribution(treatments); + configurationRegistry.registerConfiguration(overriddenConfiguration); + configurationRegistry.registerDefaultConfigurations([otherDefaults]); + + const readDefault = () => configurationRegistry.getConfigurationProperties()[overriddenSetting].default; + + try { + const beforeExperiment = readDefault(); + await contribution.processExperimentalSettings([overriddenSetting], false); + const afterInitial = readDefault(); + + await contribution.processExperimentalSettings([overriddenSetting], true); + + assert.deepStrictEqual({ beforeExperiment, afterInitial, afterRefetch: readDefault() }, { + beforeExperiment: 'otherDefault', + afterInitial: 'control', + afterRefetch: 'control', + }); + } finally { + if (contribution.registeredExperimentalDefaults.size) { + configurationRegistry.deregisterDefaultConfigurations([...contribution.registeredExperimentalDefaults.values()]); + } + configurationRegistry.deregisterDefaultConfigurations([otherDefaults]); + configurationRegistry.deregisterConfigurations([overriddenConfiguration]); + } + }); + test('defers a startup experiment until its value first resolves, then latches it', async () => { const startupSetting = 'test.startupExperimentalSetting'; const startupConfiguration: IConfigurationNode = { @@ -182,28 +274,7 @@ suite('ConfigurationDefaultOverridesContribution', () => { const treatments: Record = { testStartupExperimentalSetting: undefined, }; - type TestContribution = { - processedExperimentalSettings: Set; - autoExperimentalSettings: Set; - pendingStartupExperimentalSettings: Set; - registeredExperimentalDefaults: Map; - configurationRegistry: IConfigurationRegistry; - workbenchAssignmentService: { - getTreatment(name: string): Promise; - }; - environmentService: { isSessionsWindow: boolean }; - processExperimentalSettings(properties: Iterable, autoRefetch: boolean): Promise; - }; - const contribution = Object.create(ConfigurationDefaultOverridesContribution.prototype) as TestContribution; - contribution.processedExperimentalSettings = new Set(); - contribution.autoExperimentalSettings = new Set(); - contribution.pendingStartupExperimentalSettings = new Set(); - contribution.registeredExperimentalDefaults = new Map(); - contribution.configurationRegistry = configurationRegistry; - contribution.workbenchAssignmentService = { - getTreatment: async (name: string) => treatments[name] as T | undefined, - }; - contribution.environmentService = { isSessionsWindow: false }; + const contribution = createTestContribution(treatments); configurationRegistry.registerConfiguration(startupConfiguration); const readDefault = () => configurationRegistry.getConfigurationProperties()[startupSetting].default; @@ -259,28 +330,7 @@ suite('ConfigurationDefaultOverridesContribution', () => { const treatments: Record = { testResolvedStartupExperimentalSetting: 'treatment', }; - type TestContribution = { - processedExperimentalSettings: Set; - autoExperimentalSettings: Set; - pendingStartupExperimentalSettings: Set; - registeredExperimentalDefaults: Map; - configurationRegistry: IConfigurationRegistry; - workbenchAssignmentService: { - getTreatment(name: string): Promise; - }; - environmentService: { isSessionsWindow: boolean }; - processExperimentalSettings(properties: Iterable, autoRefetch: boolean): Promise; - }; - const contribution = Object.create(ConfigurationDefaultOverridesContribution.prototype) as TestContribution; - contribution.processedExperimentalSettings = new Set(); - contribution.autoExperimentalSettings = new Set(); - contribution.pendingStartupExperimentalSettings = new Set(); - contribution.registeredExperimentalDefaults = new Map(); - contribution.configurationRegistry = configurationRegistry; - contribution.workbenchAssignmentService = { - getTreatment: async (name: string) => treatments[name] as T | undefined, - }; - contribution.environmentService = { isSessionsWindow: false }; + const contribution = createTestContribution(treatments); configurationRegistry.registerConfiguration(startupConfiguration); const readDefault = () => configurationRegistry.getConfigurationProperties()[startupSetting].default; diff --git a/src/vs/workbench/services/environment/electron-browser/environmentService.ts b/src/vs/workbench/services/environment/electron-browser/environmentService.ts index 57b97aed04060..b4ffe6831bb64 100644 --- a/src/vs/workbench/services/environment/electron-browser/environmentService.ts +++ b/src/vs/workbench/services/environment/electron-browser/environmentService.ts @@ -14,7 +14,7 @@ import { URI } from '../../../../base/common/uri.js'; import { Schemas } from '../../../../base/common/network.js'; import { IProductService } from '../../../../platform/product/common/productService.js'; import { joinPath } from '../../../../base/common/resources.js'; -import { VSBuffer } from '../../../../base/common/buffer.js'; +import { decodeBase64, VSBuffer } from '../../../../base/common/buffer.js'; export const INativeWorkbenchEnvironmentService = refineServiceDecorator(IEnvironmentService); @@ -155,7 +155,10 @@ export class NativeWorkbenchEnvironmentService extends AbstractNativeEnvironment get isSessionsWindow(): boolean { return !!this.configuration.isSessionsWindow; } @memoize - get sessionTitle(): string | undefined { return this.configuration['session-title']; } + get sessionTitle(): string | undefined { + const encodedSessionTitle = this.configuration['session-title-base64']; + return encodedSessionTitle ? decodeBase64(encodedSessionTitle).toString() : undefined; + } constructor( private readonly configuration: INativeWindowConfiguration, diff --git a/src/vs/workbench/services/github/browser/githubService.ts b/src/vs/workbench/services/github/browser/githubService.ts index 67bef5bbb7131..bb73495acf536 100644 --- a/src/vs/workbench/services/github/browser/githubService.ts +++ b/src/vs/workbench/services/github/browser/githubService.ts @@ -35,13 +35,14 @@ class WorkbenchGitHubEndpointProvider implements IGitHubEndpointProvider { } } -class WorkbenchGitHubTokenProvider implements IGitHubTokenProvider { +export class WorkbenchGitHubTokenProvider implements IGitHubTokenProvider { readonly onDidChangeToken: Event; constructor( private readonly _authenticationService: IAuthenticationService, private readonly _defaultAccountService: IDefaultAccountService, + private readonly _logService: ILogService, ) { this.onDidChangeToken = Event.any( Event.map(Event.filter( @@ -60,19 +61,33 @@ class WorkbenchGitHubTokenProvider implements IGitHubTokenProvider { ? sessions.find(session => session.id === defaultAccount.sessionId) : undefined; if (defaultAccount && !defaultSession) { + this._logService.warn(`[WorkbenchGitHubTokenProvider] Default account session was not found for provider '${provider.id}' among ${sessions.length} session(s)`); return undefined; } - if (defaultSession?.scopes.includes('repo')) { - return defaultSession.accessToken; + const repositorySession = sessions.find(session => + session.scopes.includes('repo') + && (!defaultSession || session.account.id === defaultSession.account.id) + ); + if (repositorySession) { + this._logService.trace(`[WorkbenchGitHubTokenProvider] Reusing a repository-capable session for provider '${provider.id}' with scopes [${repositorySession.scopes.join(', ')}]`); + return repositorySession.accessToken; } const repositorySessions = await this._authenticationService.getSessions(provider.id, ['repo'], { createIfNone: true, ...(defaultSession ? { account: defaultSession.account } : {}), }, true); - return repositorySessions.find(session => !defaultSession || session.account.id === defaultSession.account.id)?.accessToken; + const resolvedSession = repositorySessions.find(session => !defaultSession || session.account.id === defaultSession.account.id); + if (!resolvedSession) { + this._logService.warn(`[WorkbenchGitHubTokenProvider] No repository-capable session resolved for provider '${provider.id}'; initial session scopes: ${formatSessionScopes(sessions)}; repository query scopes: ${formatSessionScopes(repositorySessions)}`); + } + return resolvedSession?.accessToken; } } +function formatSessionScopes(sessions: readonly { readonly scopes: readonly string[] }[]): string { + return sessions.length ? sessions.map(session => `[${session.scopes.join(', ')}]`).join(', ') : 'none'; +} + export class WorkbenchGitHubService extends GitHubService { constructor( @@ -82,7 +97,7 @@ export class WorkbenchGitHubService extends GitHubService { ) { super({ endpoint: new WorkbenchGitHubEndpointProvider(defaultAccountService), - tokenProvider: new WorkbenchGitHubTokenProvider(authenticationService, defaultAccountService), + tokenProvider: new WorkbenchGitHubTokenProvider(authenticationService, defaultAccountService, logService), }, logService); } } diff --git a/src/vs/workbench/services/github/test/browser/githubService.test.ts b/src/vs/workbench/services/github/test/browser/githubService.test.ts new file mode 100644 index 0000000000000..eef2eeb8def6b --- /dev/null +++ b/src/vs/workbench/services/github/test/browser/githubService.test.ts @@ -0,0 +1,55 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { Event } from '../../../../../base/common/event.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { IDefaultAccountService } from '../../../../../platform/defaultAccount/common/defaultAccount.js'; +import { NullLogService } from '../../../../../platform/log/common/log.js'; +import { AuthenticationSession, IAuthenticationService } from '../../../authentication/common/authentication.js'; +import { WorkbenchGitHubTokenProvider } from '../../browser/githubService.js'; + +suite('Workbench GitHub service', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('reuses a repo-capable session with additional scopes', async () => { + const sessions: AuthenticationSession[] = [{ + id: 'session', + accessToken: 'token', + account: { id: 'account', label: 'Account' }, + scopes: ['repo', 'user:email'], + }]; + const requestedScopes: (readonly string[] | undefined)[] = []; + const tokenProvider = new WorkbenchGitHubTokenProvider( + new class extends mock() { + override readonly onDidChangeSessions = Event.None; + override async getSessions(_id: string, scopes?: readonly string[]): Promise { + requestedScopes.push(scopes); + return sessions; + } + }(), + new class extends mock() { + override readonly onDidChangeDefaultAccount = Event.None; + override readonly currentDefaultAccount = null; + override getDefaultAccountAuthenticationProvider() { + return { id: 'github', name: 'GitHub', enterprise: false }; + } + override async getDefaultAccount() { + return null; + } + }(), + new NullLogService(), + ); + + assert.deepStrictEqual({ + token: await tokenProvider.getToken(), + requestedScopes, + }, { + token: 'token', + requestedScopes: [[]], + }); + }); +}); diff --git a/src/vs/workbench/services/policies/common/accountPolicyService.ts b/src/vs/workbench/services/policies/common/accountPolicyService.ts index e377d53d0af81..5ed01ef080076 100644 --- a/src/vs/workbench/services/policies/common/accountPolicyService.ts +++ b/src/vs/workbench/services/policies/common/accountPolicyService.ts @@ -12,7 +12,7 @@ import { localize } from '../../../../nls.js'; import { RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../platform/log/common/log.js'; -import { INativeManagedSettingsService, IFileManagedSettingsService, IManagedSettingsPick, IManagedSettingsService, ManagedSettingsChannel, collectManagedSettingsDefinitions, hasManagedSettingsDefinitions, projectManagedSettings, pickManagedSettings } from '../../../../platform/policy/common/copilotManagedSettings.js'; +import { INativeManagedSettingsService, IFileManagedSettingsService, IManagedSettingsPick, IManagedSettingsService, MANAGED_SETTINGS_CHANNELS, ManagedSettingsChannel, collectManagedSettingsDefinitions, hasManagedSettingsDefinitions, hasRawManagedSettings, projectManagedSettings, pickManagedSettings } from '../../../../platform/policy/common/copilotManagedSettings.js'; import { IManagedSettingsFreshness, isManagedSettingsFreshnessBlocking } from '../../../../platform/policy/common/managedSettingsFreshness.js'; import { AbstractPolicyService, getRestrictedPolicyValue, IPolicyService, PolicyDefinition, PolicyValue, PolicyValueSource } from '../../../../platform/policy/common/policy.js'; import { IDefaultAccountService } from '../../../../platform/defaultAccount/common/defaultAccount.js'; @@ -67,6 +67,7 @@ export interface IAccountPolicyGateService { interface IResolvedPolicyData { readonly policyData: IPolicyData; readonly managedSettingResolutions: IManagedSettingsPick['resolutions']; + readonly activeManagedSettingsSources: readonly ManagedSettingsChannel[]; } export class AccountPolicyService extends AbstractPolicyService implements IPolicyService, IAccountPolicyGateService, IManagedSettingsService { @@ -128,7 +129,7 @@ export class AccountPolicyService extends AbstractPolicyService implements IPoli })); } if (this.fileManagedSettingsService) { - this._register(this.fileManagedSettingsService.onDidChangeManagedSettings(() => { + this._register(this.fileManagedSettingsService.onDidChangeRawManagedSettings(() => { this._updatePolicyDefinitions(this.policyDefinitions); })); } @@ -186,7 +187,7 @@ export class AccountPolicyService extends AbstractPolicyService implements IPoli return undefined; } - const { policyData, managedSettingResolutions } = resolvedPolicyData; + const { policyData, managedSettingResolutions, activeManagedSettingsSources } = resolvedPolicyData; const value = valueProvider(policyData); if (value === undefined) { return undefined; @@ -231,13 +232,9 @@ export class AccountPolicyService extends AbstractPolicyService implements IPoli // declared key, so probe for that too and attribute it to the governing channels. if (source === PolicyValueSource.Account && policyData.managedSettingsActive === true && valueProvider({ ...policyData, managedSettingsActive: false }) !== value) { - const channels = new Set(); - for (const resolution of managedSettingResolutions.values()) { - channels.add(resolution.source); - } - if (channels.size > 0) { - source = channels.size === 1 - ? policyValueSourceForManagedSettingsChannel(Array.from(channels)[0]) + if (activeManagedSettingsSources.length > 0) { + source = activeManagedSettingsSources.length === 1 + ? policyValueSourceForManagedSettingsChannel(activeManagedSettingsSources[0]) : PolicyValueSource.MixedManagedSettings; } } @@ -263,11 +260,19 @@ export class AccountPolicyService extends AbstractPolicyService implements IPoli // channel is still filled in by a lower one. A key locked by a higher channel cannot be // overwritten. See `.github/skills/policy-and-managed-settings/github-managed-settings.md` for the rationale. const pick = pickManagedSettings(nativeManagedSettings, accountPolicyData?.managedSettings, fileManagedSettings); + const activeSources = new Set(pick.activeSources); + if (accountPolicyData?.managedSettingsActive === true) { + activeSources.add('server'); + } + if (hasRawManagedSettings(this.fileManagedSettingsService?.rawManagedSettings)) { + activeSources.add('file'); + } + const activeManagedSettingsSources = MANAGED_SETTINGS_CHANNELS.filter(source => activeSources.has(source)); if (!equals(this._managedSettings, pick.values)) { this._managedSettings = pick.values; this._onDidChangeManagedSettings.fire(); } - if (!accountPolicyData && pick.activeSources.length === 0) { + if (!accountPolicyData && activeManagedSettingsSources.length === 0) { return undefined; } @@ -282,9 +287,10 @@ export class AccountPolicyService extends AbstractPolicyService implements IPoli policyData: { ...accountPolicyData, managedSettings: managedSettingsData, - managedSettingsActive: pick.activeSources.length > 0, + managedSettingsActive: activeManagedSettingsSources.length > 0, }, managedSettingResolutions: pick.resolutions, + activeManagedSettingsSources, }; } diff --git a/src/vs/workbench/services/policies/test/browser/accountPolicyService.test.ts b/src/vs/workbench/services/policies/test/browser/accountPolicyService.test.ts index b11c95a9f4e6e..6717ba9e6f21c 100644 --- a/src/vs/workbench/services/policies/test/browser/accountPolicyService.test.ts +++ b/src/vs/workbench/services/policies/test/browser/accountPolicyService.test.ts @@ -6,13 +6,14 @@ import assert from 'assert'; import { IDefaultAccount, IDefaultAccountAuthenticationProvider, IPolicyData } from '../../../../../base/common/defaultAccount.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; import { ManagedSettingsData, PolicyCategory } from '../../../../../base/common/policy.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { Extensions, IConfigurationNode, IConfigurationRegistry } from '../../../../../platform/configuration/common/configurationRegistry.js'; import { DefaultConfiguration, PolicyConfiguration } from '../../../../../platform/configuration/common/configurations.js'; import { IDefaultAccountProvider, IDefaultAccountService, MANAGED_SETTINGS_FRESHNESS_NOT_REQUIRED } from '../../../../../platform/defaultAccount/common/defaultAccount.js'; import { NullLogService } from '../../../../../platform/log/common/log.js'; -import { COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY, COPILOT_ENABLED_PLUGINS_KEY, COPILOT_SANDBOX_ENABLED_KEY, INativeManagedSettingsService, IFileManagedSettingsService, thirdPartyAgentEnabledValue } from '../../../../../platform/policy/common/copilotManagedSettings.js'; +import { COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY, COPILOT_ENABLED_PLUGINS_KEY, COPILOT_SANDBOX_ENABLED_KEY, INativeManagedSettingsService, IFileManagedSettingsService, RawManagedSettingsData, managedSettingsDisabledValue } from '../../../../../platform/policy/common/copilotManagedSettings.js'; import { IManagedSettingsFreshness, ManagedSettingsFreshnessFailure, ManagedSettingsFreshnessState } from '../../../../../platform/policy/common/managedSettingsFreshness.js'; import { AbstractPolicyService, IPolicyService, PolicyDefinition, PolicyValue, PolicyValueSource } from '../../../../../platform/policy/common/policy.js'; import { Registry } from '../../../../../platform/registry/common/platform.js'; @@ -198,9 +199,7 @@ suite('AccountPolicyService', () => { category: PolicyCategory.Extensions, minimumVersion: '1.0.0', localization: { description: { key: '', value: '' } }, - // Mirrors the third-party harness policies: keys off the presence of managed - // settings, so it deliberately declares no `managedSettings`. - value: thirdPartyAgentEnabledValue, + value: managedSettingsDisabledValue, } } } @@ -412,7 +411,7 @@ suite('AccountPolicyService', () => { // All three channels provide the same key with different values. // Server says 'enable', MDM says 'disable', File says 'file-value'. // Native MDM should win. - const fileManagedSettingsService = new FakeFileManagedSettingsService({ [COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY]: 'file-value', [COPILOT_SANDBOX_ENABLED_KEY]: true }); + const fileManagedSettingsService = disposables.add(new FakeFileManagedSettingsService({ [COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY]: 'file-value', [COPILOT_SANDBOX_ENABLED_KEY]: true })); const nativeManagedSettingsService = disposables.add(new FakeNativeManagedSettingsService({ [COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY]: 'disable', [COPILOT_SANDBOX_ENABLED_KEY]: false })); policyService = disposables.add(new AccountPolicyService(logService, defaultAccountService, undefined, nativeManagedSettingsService, fileManagedSettingsService)); const defaultConfiguration = disposables.add(new DefaultConfiguration(new NullLogService())); @@ -445,7 +444,7 @@ suite('AccountPolicyService', () => { test('managed settings: file-based settings apply when server and MDM are empty', async () => { // Only the file channel provides a value — it should be used. - const fileManagedSettingsService = new FakeFileManagedSettingsService({ [COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY]: 'disable' }); + const fileManagedSettingsService = disposables.add(new FakeFileManagedSettingsService({ [COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY]: 'disable' })); const nativeManagedSettingsService = disposables.add(new FakeNativeManagedSettingsService({})); policyService = disposables.add(new AccountPolicyService(logService, defaultAccountService, undefined, nativeManagedSettingsService, fileManagedSettingsService)); const defaultConfiguration = disposables.add(new DefaultConfiguration(new NullLogService())); @@ -467,7 +466,7 @@ suite('AccountPolicyService', () => { // key. Neither overrides the other, so BOTH reach policy evaluation: setting F resolves from // native MDM and setting G resolves from the file. This is the per-key fill-down behavior. const enabledPluginsJson = '{"assign-issue@skills":true}'; - const fileManagedSettingsService = new FakeFileManagedSettingsService({ [COPILOT_ENABLED_PLUGINS_KEY]: enabledPluginsJson }); + const fileManagedSettingsService = disposables.add(new FakeFileManagedSettingsService({ [COPILOT_ENABLED_PLUGINS_KEY]: enabledPluginsJson })); const nativeManagedSettingsService = disposables.add(new FakeNativeManagedSettingsService({ [COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY]: 'disable' })); policyService = disposables.add(new AccountPolicyService(logService, defaultAccountService, undefined, nativeManagedSettingsService, fileManagedSettingsService)); const defaultConfiguration = disposables.add(new DefaultConfiguration(new NullLogService())); @@ -494,7 +493,7 @@ suite('AccountPolicyService', () => { test('managed settings: attributes policies caused by multiple channels as mixed', async () => { const enabledPluginsJson = '{"assign-issue@skills":true}'; - const fileManagedSettingsService = new FakeFileManagedSettingsService({ [COPILOT_ENABLED_PLUGINS_KEY]: enabledPluginsJson }); + const fileManagedSettingsService = disposables.add(new FakeFileManagedSettingsService({ [COPILOT_ENABLED_PLUGINS_KEY]: enabledPluginsJson })); const nativeManagedSettingsService = disposables.add(new FakeNativeManagedSettingsService({ [COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY]: 'disable' })); policyService = disposables.add(new AccountPolicyService(logService, defaultAccountService, undefined, nativeManagedSettingsService, fileManagedSettingsService)); const defaultConfiguration = disposables.add(new DefaultConfiguration(new NullLogService())); @@ -514,9 +513,8 @@ suite('AccountPolicyService', () => { }); }); - test('managed settings: their mere presence disables the third-party harnesses', async () => { - // A runtime-owned key VS Code never declares still counts as governance. - const fileManagedSettingsService = new FakeFileManagedSettingsService({ 'permissions.deny': '["Bash"]' }); + test('managed settings: raw permission rules disable presence-gated settings', async () => { + const fileManagedSettingsService = disposables.add(new FakeFileManagedSettingsService({}, { permissions: { deny: ['Shell'] } })); policyService = disposables.add(new AccountPolicyService(logService, defaultAccountService, undefined, undefined, fileManagedSettingsService)); const defaultConfiguration = disposables.add(new DefaultConfiguration(new NullLogService())); await defaultConfiguration.initialize(); @@ -537,7 +535,68 @@ suite('AccountPolicyService', () => { }); }); - test('managed settings: an ungoverned account leaves the third-party harnesses alone', async () => { + test('managed settings: removing raw permission rules releases presence-gated settings', async () => { + const fileManagedSettingsService = disposables.add(new FakeFileManagedSettingsService({}, { permissions: { deny: ['Shell'] } })); + policyService = disposables.add(new AccountPolicyService(logService, defaultAccountService, undefined, undefined, fileManagedSettingsService)); + const defaultConfiguration = disposables.add(new DefaultConfiguration(new NullLogService())); + await defaultConfiguration.initialize(); + policyConfiguration = disposables.add(new PolicyConfiguration(defaultConfiguration, policyService, new NullLogService())); + + defaultAccountService.setDefaultAccountProvider(new DefaultAccountProvider(BASE_DEFAULT_ACCOUNT, {})); + await defaultAccountService.refresh(); + await policyConfiguration.initialize(); + + const change = Event.toPromise(policyService.onDidChange); + fileManagedSettingsService.setManagedSettings({}, {}); + assert.deepStrictEqual({ + changed: await change, + setting: policyConfiguration.configurationModel.getValue('setting.J'), + value: policyService.getPolicyValue('PolicySettingJ'), + }, { + changed: ['PolicySettingJ'], + setting: undefined, + value: undefined, + }); + }); + + test('managed settings: sandbox settings disable presence-gated settings even when false', async () => { + defaultAccountService.setDefaultAccountProvider(new DefaultAccountProvider(BASE_DEFAULT_ACCOUNT, { + managedSettings: { [COPILOT_SANDBOX_ENABLED_KEY]: false }, + })); + await defaultAccountService.refresh(); + await policyConfiguration.initialize(); + + assert.deepStrictEqual({ + setting: policyConfiguration.configurationModel.getValue('setting.J'), + value: policyService.getPolicyValue('PolicySettingJ'), + source: policyService.getPolicyValueSource('PolicySettingJ'), + }, { + setting: false, + value: false, + source: PolicyValueSource.ServerManagedSettings, + }); + }); + + test('managed settings: raw server settings disable presence-gated settings', async () => { + defaultAccountService.setDefaultAccountProvider(new DefaultAccountProvider(BASE_DEFAULT_ACCOUNT, { + managedSettings: {}, + managedSettingsActive: true, + })); + await defaultAccountService.refresh(); + await policyConfiguration.initialize(); + + assert.deepStrictEqual({ + setting: policyConfiguration.configurationModel.getValue('setting.J'), + value: policyService.getPolicyValue('PolicySettingJ'), + source: policyService.getPolicyValueSource('PolicySettingJ'), + }, { + setting: false, + value: false, + source: PolicyValueSource.ServerManagedSettings, + }); + }); + + test('managed settings: an ungoverned account leaves presence-gated settings alone', async () => { defaultAccountService.setDefaultAccountProvider(new DefaultAccountProvider(BASE_DEFAULT_ACCOUNT, { chat_preview_features_enabled: true })); await defaultAccountService.refresh(); await policyConfiguration.initialize(); @@ -669,16 +728,26 @@ suite('AccountPolicyService', () => { } } - class FakeFileManagedSettingsService implements IFileManagedSettingsService { + class FakeFileManagedSettingsService extends Disposable implements IFileManagedSettingsService { readonly _serviceBrand: undefined; - readonly rawManagedSettings = {}; - readonly onDidChangeRawManagedSettings = Event.None; - private readonly _onDidChangeManagedSettings = new Emitter(); - readonly onDidChangeManagedSettings = this._onDidChangeManagedSettings.event; - - constructor(public managedSettings: ManagedSettingsData = {}) { } + private readonly _onDidChangeRawManagedSettings = this._register(new Emitter()); + readonly onDidChangeRawManagedSettings = this._onDidChangeRawManagedSettings.event; + readonly onDidChangeManagedSettings = Event.None; + + constructor( + public managedSettings: ManagedSettingsData = {}, + public rawManagedSettings: RawManagedSettingsData = managedSettings, + ) { + super(); + } async initialize(): Promise { return this.managedSettings; } + + setManagedSettings(managedSettings: ManagedSettingsData, rawManagedSettings: RawManagedSettingsData = managedSettings): void { + this.managedSettings = managedSettings; + this.rawManagedSettings = rawManagedSettings; + this._onDidChangeRawManagedSettings.fire(this.rawManagedSettings); + } } async function setupGate(opts: { diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/tabbedModelPicker.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/chat/tabbedModelPicker.fixture.ts new file mode 100644 index 0000000000000..d1b4ce572b843 --- /dev/null +++ b/src/vs/workbench/test/browser/componentFixtures/chat/tabbedModelPicker.fixture.ts @@ -0,0 +1,503 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IStringDictionary } from '../../../../../base/common/collections.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { InMemoryStorageService, IStorageService } from '../../../../../platform/storage/common/storage.js'; +import { upcastPartial } from '../../../../../base/test/common/mock.js'; +import { localize } from '../../../../../nls.js'; +import { autoModeTiers, defaultAutoModeTier, getAutoModeTierDescription, getAutoModeTierLabel } from '../../../../../platform/agentHost/common/autoModeTiers.js'; +import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; +import { IContextViewDelegate, IContextViewService } from '../../../../../platform/contextview/browser/contextView.js'; +import { ILayoutService } from '../../../../../platform/layout/browser/layoutService.js'; +import { NullOpenerService } from '../../../../../platform/opener/test/common/nullOpenerService.js'; +import { StateType } from '../../../../../platform/update/common/update.js'; +import { ChatEntitlement, IChatEntitlementService } from '../../../../services/chat/common/chatEntitlementService.js'; +import { ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, ILanguageModelProviderDescriptor, ILanguageModelsService, IModelControlEntry } from '../../../../contrib/chat/common/languageModels.js'; +import { IModelConfigurationAccess } from '../../../../contrib/chat/browser/widget/input/modelPicker/modelPickerModelConfig.js'; +import { ModelPickerAutoRow } from '../../../../contrib/chat/browser/widget/input/modelPicker/modelPickerAutoRow.js'; +import { IPricingDisclosure, ModelCard } from '../../../../contrib/chat/browser/widget/input/modelPicker/modelPickerCard.js'; +import { ITabbedModelPickerContext, TabbedModelPicker } from '../../../../contrib/chat/browser/widget/input/modelPicker/modelPickerTabbedWidget.js'; +import { IModelPickerProviderPlaceholder } from '../../../../contrib/chat/browser/widget/input/modelPicker/modelPickerTabs.js'; +import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from '../fixtureUtils.js'; + +import '../../../../contrib/chat/browser/widget/media/chat.css'; +import '../../../../contrib/chat/browser/widget/input/modelPicker/media/modelPicker.css'; + +const EXTENSION = new ExtensionIdentifier('fixture.models'); + +interface IFixtureModelOptions { + readonly vendor?: string; + readonly category?: string; + readonly priceCategory?: string; + readonly detail?: string; + readonly effortValues?: readonly string[]; + readonly effortLabels?: readonly string[]; + readonly effortDescriptions?: readonly string[]; + readonly effortTitle?: string; + readonly effortDefault?: string; + readonly contextLabels?: readonly string[]; + readonly costs?: boolean; + readonly isBYOK?: boolean; + readonly promoDiscount?: number; + readonly retiring?: boolean; + readonly byokModelIdentifier?: string; + readonly modelGroupId?: string; +} + +function createModel(id: string, name: string, options: IFixtureModelOptions = {}): ILanguageModelChatMetadataAndIdentifier { + const vendor = options.vendor ?? 'copilot'; + const properties: NonNullable['properties'] = {}; + if (options.effortValues) { + properties.reasoningEffort = { + type: 'string', + title: options.effortTitle, + group: 'navigation', + enum: [...options.effortValues], + enumItemLabels: options.effortLabels ? [...options.effortLabels] : undefined, + enumDescriptions: options.effortDescriptions + ? [...options.effortDescriptions] + : ['Fastest, least thorough', 'Balanced reasoning and speed', 'Slowest, most thorough'], + default: options.effortDefault ?? options.effortValues[1], + }; + } + if (options.contextLabels) { + properties.contextSize = { + type: 'number', + group: 'tokens', + enum: [264000, 1000000], + enumItemLabels: [...options.contextLabels], + // Real providers describe the standard window as "Default", which says nothing + // the segment does not already say. + enumDescriptions: ['Default', 'Extended context window'], + default: 264000, + }; + } + return { + identifier: `${vendor}/${id}`, + metadata: upcastPartial({ + extension: EXTENSION, + id, + name, + vendor, + version: '1.0', + family: id, + detail: options.detail, + category: options.category, + priceCategory: options.priceCategory, + isBYOK: options.isBYOK, + byokModelIdentifier: options.byokModelIdentifier, + modelGroup: options.modelGroupId ? { id: options.modelGroupId } : undefined, + promo: options.promoDiscount ? { id: 'promo', discountPercent: options.promoDiscount, message: 'Discounted for a limited time.' } : undefined, + warningText: options.retiring ? { model_pending_deprecation: 'This model is retiring soon.' } : undefined, + maxInputTokens: 264000, + maxOutputTokens: 64000, + isDefaultForLocation: {}, + inputCost: options.costs ? 40 : undefined, + outputCost: options.costs ? 200 : undefined, + cacheCost: options.costs ? 4 : undefined, + cacheWriteCost: options.costs ? 50 : undefined, + longContextInputCost: options.costs ? 80 : undefined, + longContextOutputCost: options.costs ? 400 : undefined, + longContextCacheCost: options.costs ? 8 : undefined, + longContextCacheWriteCost: options.costs ? 100 : undefined, + configurationSchema: Object.keys(properties).length ? { properties } : undefined, + }), + }; +} + +// Built from the runtime's own routing profiles so the fixture cannot drift from the +// values the Auto model actually offers. +const AUTO_MODEL = createModel('auto', 'Auto', { + detail: '10% off', + effortTitle: localize('copilot.modelAutoTier.title', "Optimize for"), + effortValues: [...autoModeTiers], + effortLabels: autoModeTiers.map(getAutoModeTierLabel), + effortDescriptions: autoModeTiers.map(tier => getAutoModeTierDescription(tier) ?? ''), + effortDefault: defaultAutoModeTier, +}); + +const COPILOT_MODELS = [ + createModel('gemini-3-1-pro', 'Gemini 3.1 Pro', { + category: 'powerful', priceCategory: 'medium', costs: true, + effortValues: ['minimal', 'medium', 'high'], effortLabels: ['Minimal', 'Medium', 'High'], + contextLabels: ['264K', '1M'], + }), + createModel('gpt-5-5', 'GPT-5.5', { + category: 'powerful', priceCategory: 'high', costs: true, + effortValues: ['low', 'medium', 'xhigh'], effortLabels: ['Low', 'Medium', 'Extra high'], + contextLabels: ['264K', '1M'], + }), + createModel('claude-sonnet-5', 'Claude Sonnet 5', { category: 'powerful', priceCategory: 'medium', costs: true }), + createModel('gpt-5-3-codex', 'GPT-5.3-Codex', { category: 'versatile', priceCategory: 'low' }), + createModel('gemini-3-5-flash', 'Gemini 3.5 Flash', { category: 'lightweight', priceCategory: 'low' }), +]; + +/** Copilot models plus the states a row can advertise: an offer and a retirement. */ +const COPILOT_NOTICE_MODELS = [ + ...COPILOT_MODELS, + createModel('gpt-5-1', 'GPT-5.1', { category: 'versatile', priceCategory: 'low', promoDiscount: 25 }), + createModel('gpt-4-turbo', 'GPT-4 Turbo', { category: 'versatile', priceCategory: 'medium', retiring: true }), +]; + +/** The full effort ladder a real model can publish, where the labels vary in width. */ +const MANY_EFFORT_MODEL = createModel('gpt-5-6-terra', 'GPT-5.6 Terra', { + category: 'powerful', priceCategory: 'medium', costs: true, + effortValues: ['none', 'low', 'medium', 'high', 'xhigh', 'max'], + effortLabels: ['None', 'Low', 'Medium', 'High', 'Extra High', 'Max'], + contextLabels: ['272K', '1M'], +}); + +const OLLAMA_MODELS = [ + createModel('llama-3-70b', 'Llama 3 70B', { vendor: 'ollama', isBYOK: true }), + createModel('mistral-large', 'Mistral Large', { vendor: 'ollama', isBYOK: true }), +]; + +const OPENAI_MODELS = [ + createModel('gpt-4o', 'GPT-4o', { vendor: 'openai', isBYOK: true }), +]; + +const ANTHROPIC_MODELS = [ + createModel('example-opus-4', 'Example Opus 4', { vendor: 'anthropic', isBYOK: true }), +]; + +const GOOGLE_MODELS = [ + createModel('gemini-2-flash', 'Gemini 2 Flash', { vendor: 'google', isBYOK: true }), +]; + +/** A model the provider also offers at a second speed, named by an id suffix. */ +const SPEED_VARIANT_MODELS = [ + createModel('example-2.5', 'Example 2.5', { category: 'powerful', priceCategory: 'high', costs: true }), + createModel('example-2.5-fast', 'Example 2.5 (fast mode)', { category: 'powerful', priceCategory: 'very_high', costs: true }), +]; + +const COPILOT_ONLY_MODELS = [AUTO_MODEL, ...COPILOT_MODELS]; + +/** How the Copilot agent host relays models: its own vendor, BYOK stamped on everything. */ +const RELAYED_MODELS = [ + createModel('auto', 'Auto', { vendor: 'agent-host-copilotcli', isBYOK: true, modelGroupId: 'copilot', detail: '10% off', effortValues: [...autoModeTiers], effortLabels: autoModeTiers.map(getAutoModeTierLabel), effortDefault: defaultAutoModeTier }), + createModel('gpt-5-5', 'GPT-5.5', { vendor: 'agent-host-copilotcli', isBYOK: true, byokModelIdentifier: 'copilot/gpt-5-5', modelGroupId: 'copilot', category: 'powerful', priceCategory: 'high' }), + createModel('claude-sonnet-5', 'Claude Sonnet 5', { vendor: 'agent-host-copilotcli', isBYOK: true, modelGroupId: 'copilot', category: 'powerful' }), + createModel('llama-3-70b', 'Llama 3 70B', { vendor: 'agent-host-copilotcli', isBYOK: true, modelGroupId: 'ollama' }), +]; +const ALL_MODELS = [...COPILOT_ONLY_MODELS, ...OLLAMA_MODELS]; + +const CONTROL_MODELS: IStringDictionary = { + 'gemini-3-1-pro': { label: 'Gemini 3.1 Pro', featured: true, exists: true }, + 'claude-sonnet-5': { label: 'Claude Sonnet 5', featured: true, exists: true }, +}; + +/** Adds curated models the account cannot reach, plus one gated behind a newer build. */ +const CONTROL_MODELS_WITH_LOCKED: IStringDictionary = { + ...CONTROL_MODELS, + 'example-locked-5': { label: 'Example Locked 5', featured: true, exists: false }, + 'gpt-6': { label: 'GPT-6', featured: true, exists: false, minVSCodeVersion: '99.0.0' }, +}; + +/** In-memory model configuration so the fixture's cards and tiers are interactive. */ +function createConfigurationAccess(): IModelConfigurationAccess { + const values = new Map>(); + return { + getModelConfiguration: modelId => values.get(modelId), + setModelConfiguration: async (modelId, next) => { values.set(modelId, { ...values.get(modelId), ...next }); }, + getModelConfigurationActions: () => [], + }; +} + +/** A disclosure backed by a plain flag, so a fixture can render either state. */ +function createPricingDisclosure(disposableStore: DisposableStore, expanded: boolean): IPricingDisclosure { + const emitter = disposableStore.add(new Emitter()); + let current = expanded; + return { + isExpanded: () => current, + setExpanded: next => { current = next; emitter.fire(); }, + onDidChange: emitter.event, + }; +} + +function createLanguageModelsService(): ILanguageModelsService { + // Only the fields the picker reads. The rest of the descriptor never comes up here. + const vendor = (vendor: string, displayName: string, isDefault: boolean) => + upcastPartial({ vendor, displayName, isDefault }); + return upcastPartial({ + getVendors: () => [ + vendor('copilot', 'GitHub Copilot', true), + vendor('ollama', 'Ollama', false), + vendor('openai', 'OpenAI', false), + ], + getLanguageModelGroups: () => [], + }); +} + +/** Renders the popup inline in the fixture container instead of a floating context view. */ +function createInlineContextViewService(container: HTMLElement, disposables: ComponentFixtureContext['disposableStore']): IContextViewService { + let activeHost: HTMLElement | undefined; + let activeRender: { dispose(): void } | undefined; + const hide = () => { + activeRender?.dispose(); + activeHost?.remove(); + activeRender = undefined; + activeHost = undefined; + }; + disposables.add({ dispose: hide }); + return upcastPartial({ + showContextView: (delegate: IContextViewDelegate) => { + hide(); + activeHost = document.createElement('div'); + container.appendChild(activeHost); + const rendered = delegate.render(activeHost); + activeRender = rendered ?? undefined; + return { close: hide }; + }, + hideContextView: hide, + getContextViewElement: () => container, + layout: () => { }, + anchorAlignment: 0, + _serviceBrand: undefined, + }); +} + +function setupContainer(container: HTMLElement, width: number): void { + container.classList.add('monaco-workbench'); + container.style.width = `${width}px`; + container.style.padding = '8px'; + container.style.backgroundColor = 'var(--vscode-editor-background)'; +} + +interface IPickerFixtureOptions { + readonly selectedModelId?: string; + readonly pinnedModelIds?: readonly string[]; + /** Opens the detail card for the model whose row label matches, as a chevron click would. */ + readonly openCardFor?: string; + /** Providers with no models, which show a welcome body instead of a list. */ + readonly providerPlaceholders?: readonly IModelPickerProviderPlaceholder[]; + /** Starts on this destination, matched against the tab label. */ + readonly initialTabLabel?: string; + /** Overrides the models offered, e.g. to show the picker without any added models. */ + readonly models?: readonly ILanguageModelChatMetadataAndIdentifier[]; + /** Opens search, which replaces the tab strip with the filter field. */ + readonly search?: boolean; + /** Expands the collapsed "Other Models" section. */ + readonly expandOther?: boolean; + /** Curated models the account cannot select, which offer an unlock path instead. */ + readonly controlModels?: IStringDictionary; + /** The plan the account is on, which decides whether locked models offer upgrade or admin. */ + readonly entitlement?: ChatEntitlement; + /** Settings to apply per model identifier, so rows can show what they were tuned to. */ + readonly configured?: IStringDictionary>; +} + +async function renderPicker(context: ComponentFixtureContext, options: IPickerFixtureOptions = {}): Promise { + const { container, disposableStore } = context; + setupContainer(container, options.openCardFor ? 700 : 360); + + const instantiationService = createEditorServices(disposableStore, { + colorTheme: context.theme, + additionalServices: registration => { + registerWorkbenchServices(registration); + registration.defineInstance(ILayoutService, upcastPartial({ + getContainer: () => container.ownerDocument.body, + mainContainer: container.ownerDocument.body, + activeContainer: container.ownerDocument.body, + onDidChangeActiveContainer: Event.None, + onDidAddContainer: Event.None, + onDidLayoutMainContainer: Event.None, + onDidLayoutActiveContainer: Event.None, + onDidLayoutContainer: Event.None, + })); + registration.defineInstance(IContextViewService, createInlineContextViewService(container, disposableStore)); + registration.defineInstance(ILanguageModelsService, createLanguageModelsService()); + registration.defineInstance(IChatEntitlementService, upcastPartial({ entitlement: options.entitlement ?? ChatEntitlement.Free })); + // The shared harness discards writes, so the picker cannot remember anything. + registration.defineInstance(IStorageService, disposableStore.add(new InMemoryStorageService())); + }, + }); + + // The picker opens upward from its chip, so its height comes from the space above the + // anchor. Stand the anchor where the chat input's chip sits, or the list measures flat. + const anchor = document.createElement('div'); + anchor.style.position = 'fixed'; + anchor.style.bottom = '8px'; + anchor.style.left = '8px'; + anchor.style.width = '120px'; + anchor.style.height = '22px'; + container.appendChild(anchor); + + const picker = disposableStore.add(instantiationService.createInstance(TabbedModelPicker)); + const configurationAccess = createConfigurationAccess(); + for (const [modelId, values] of Object.entries(options.configured ?? {})) { + void configurationAccess.setModelConfiguration(modelId, values); + } + const pickerContext: ITabbedModelPickerContext = { + models: options.models ?? ALL_MODELS, + selectedModelId: options.selectedModelId ?? 'copilot/gpt-5-5', + recentModelIds: ['copilot/gpt-5-3-codex', 'copilot/gemini-3-5-flash'], + pinnedModelIds: options.pinnedModelIds ?? ['copilot/claude-sonnet-5'], + controlModels: options.controlModels ?? CONTROL_MODELS, + configurationAccess, + isUBB: true, + showManageModels: true, + onSelect: () => { }, + onTogglePin: () => { }, + onManageModels: () => { }, + onConfigurationChanged: () => { }, + unavailableContext: { + show: true, + currentVSCodeVersion: '1.100.0', + manageSettingsUrl: 'https://github.com/settings/copilot', + updateStateType: StateType.Idle, + }, + onUnavailableLinkClick: () => { }, + providerPlaceholders: options.providerPlaceholders ?? [], + cacheBreakHint: undefined, + }; + picker.show(anchor, pickerContext); + + if (options.expandOther) { + [...container.querySelectorAll('.monaco-list-row.action')] + .find(row => row.textContent?.includes('Other Models'))?.click(); + await new Promise(resolve => setTimeout(resolve, 50)); + } + + if (options.search) { + container.querySelector('.tabbed-action-list-tabbar-action[data-id="search"]')?.click(); + await new Promise(resolve => setTimeout(resolve, 50)); + } + + if (options.initialTabLabel) { + const tab = [...container.querySelectorAll('.chat-model-picker-tabbar .monaco-button')] + .find(candidate => candidate.ariaLabel === options.initialTabLabel); + tab?.click(); + await new Promise(resolve => setTimeout(resolve, 50)); + } + + if (options.openCardFor) { + const row = [...container.querySelectorAll('.monaco-list-row.action')] + .find(candidate => candidate.textContent?.includes(options.openCardFor!)); + row?.querySelector('.action-list-submenu-indicator.has-submenu')?.click(); + await new Promise(resolve => setTimeout(resolve, 50)); + } +} + +function renderCard(context: ComponentFixtureContext, model: ILanguageModelChatMetadataAndIdentifier, extendedContext: boolean, pricingExpanded = false): void { + const { container, disposableStore } = context; + setupContainer(container, 320); + + const configurationAccess = createConfigurationAccess(); + if (extendedContext) { + void configurationAccess.setModelConfiguration(model.identifier, { contextSize: 1000000 }); + } + + const card = disposableStore.add(new ModelCard({ + model, + configurationAccess, + isUBB: true, + openerService: NullOpenerService, + pricingDisclosure: createPricingDisclosure(disposableStore, pricingExpanded), + })); + + const wrapper = document.createElement('div'); + wrapper.classList.add('action-widget'); + wrapper.appendChild(card.element); + container.appendChild(wrapper); +} + +function renderAutoRow(context: ComponentFixtureContext, enabled: boolean): void { + const { container, disposableStore } = context; + setupContainer(container, 320); + + const row = disposableStore.add(new ModelPickerAutoRow({ + autoModel: AUTO_MODEL, + configurationAccess: createConfigurationAccess(), + isEnabled: () => enabled, + onToggle: () => { }, + })); + + const wrapper = document.createElement('div'); + wrapper.classList.add('action-widget'); + wrapper.appendChild(row.element); + container.appendChild(wrapper); +} + +export default defineThemedFixtureGroup({ path: 'chat/input/tabbedModelPicker' }, { + Picker: defineComponentFixture({ render: context => renderPicker(context, { models: COPILOT_ONLY_MODELS }) }), + PickerWithAddedModels: defineComponentFixture({ render: context => renderPicker(context) }), + PickerAddedModelsTab: defineComponentFixture({ + render: context => renderPicker(context, { initialTabLabel: 'Ollama' }), + }), + PickerWithManyProviders: defineComponentFixture({ + render: context => renderPicker(context, { + models: [...ALL_MODELS, ...OPENAI_MODELS, ...ANTHROPIC_MODELS, ...GOOGLE_MODELS], + initialTabLabel: 'Ollama', + }), + }), + PickerAddedModelsPinned: defineComponentFixture({ + render: context => renderPicker(context, { + models: [...ALL_MODELS, ...OPENAI_MODELS], + pinnedModelIds: ['ollama/llama-3-70b', 'openai/gpt-4o'], + initialTabLabel: 'Ollama', + }), + }), + PickerWithAutoSelected: defineComponentFixture({ render: context => renderPicker(context, { models: COPILOT_ONLY_MODELS, selectedModelId: 'copilot/auto' }) }), + PickerAutoOnlyPlan: defineComponentFixture({ + render: context => renderPicker(context, { + models: [AUTO_MODEL], + selectedModelId: 'copilot/auto', + controlModels: CONTROL_MODELS_WITH_LOCKED, + }), + }), + PickerRelayedByHost: defineComponentFixture({ + render: context => renderPicker(context, { models: RELAYED_MODELS, selectedModelId: 'agent-host-copilotcli/gpt-5-5' }), + }), + PickerLockedModels: defineComponentFixture({ + render: context => renderPicker(context, { models: COPILOT_ONLY_MODELS, controlModels: CONTROL_MODELS_WITH_LOCKED }), + }), + PickerLockedModelsBusiness: defineComponentFixture({ + render: context => renderPicker(context, { models: COPILOT_ONLY_MODELS, controlModels: CONTROL_MODELS_WITH_LOCKED, entitlement: ChatEntitlement.Business }), + }), + PickerBadges: defineComponentFixture({ + render: context => renderPicker(context, { models: [AUTO_MODEL, ...COPILOT_NOTICE_MODELS], expandOther: true }), + }), + PickerConfiguredModels: defineComponentFixture({ + render: context => renderPicker(context, { + models: COPILOT_ONLY_MODELS, + configured: { + 'copilot/gpt-5-5': { reasoningEffort: 'xhigh', contextSize: 1000000 }, + 'copilot/gemini-3-1-pro': { reasoningEffort: 'high' }, + }, + }), + }), + PickerSearch: defineComponentFixture({ render: context => renderPicker(context, { search: true }) }), + PickerWithCard: defineComponentFixture({ render: context => renderPicker(context, { models: COPILOT_ONLY_MODELS, openCardFor: 'GPT-5.5' }) }), + PickerWelcome: defineComponentFixture({ + render: context => renderPicker(context, { + models: [], + providerPlaceholders: [{ vendor: 'copilot', label: 'GitHub Copilot', message: 'Sign in to see available models.', action: { label: 'Sign in', run: () => { } } }], + }), + }), + CardStandardContext: defineComponentFixture({ render: context => renderCard(context, COPILOT_MODELS[0], false) }), + CardPricingExpanded: defineComponentFixture({ render: context => renderCard(context, COPILOT_MODELS[0], false, true) }), + PickerSpeedVariants: defineComponentFixture({ + render: context => renderPicker(context, { + models: [...COPILOT_ONLY_MODELS, ...SPEED_VARIANT_MODELS], + expandOther: true, + openCardFor: 'Example 2.5', + }), + }), + /** The faster twin selected outright, as `chat.defaultModel` set to its id does. */ + PickerSpeedVariantsFastSelected: defineComponentFixture({ + render: context => renderPicker(context, { + models: [...COPILOT_ONLY_MODELS, ...SPEED_VARIANT_MODELS], + selectedModelId: 'copilot/example-2.5-fast', + openCardFor: 'Example 2.5 (fast mode)', + }), + }), + CardExtendedContext: defineComponentFixture({ render: context => renderCard(context, COPILOT_MODELS[0], true) }), + CardManyEffortValues: defineComponentFixture({ render: context => renderCard(context, MANY_EFFORT_MODEL, false) }), + CardWithoutConfiguration: defineComponentFixture({ render: context => renderCard(context, COPILOT_MODELS[2], false) }), + AutoRowOff: defineComponentFixture({ render: context => renderAutoRow(context, false) }), + AutoRowOn: defineComponentFixture({ render: context => renderAutoRow(context, true) }), +}); diff --git a/src/vs/workbench/test/browser/componentFixtures/editor/multiDiffEditorFixtureUtils.ts b/src/vs/workbench/test/browser/componentFixtures/editor/multiDiffEditorFixtureUtils.ts index f059d86a8b626..f490c02930bcf 100644 --- a/src/vs/workbench/test/browser/componentFixtures/editor/multiDiffEditorFixtureUtils.ts +++ b/src/vs/workbench/test/browser/componentFixtures/editor/multiDiffEditorFixtureUtils.ts @@ -10,6 +10,7 @@ import { mock } from '../../../../../base/test/common/mock.js'; import { RefCounted } from '../../../../../editor/browser/widget/diffEditor/utils.js'; import { IDiffProviderFactoryService } from '../../../../../editor/browser/widget/diffEditor/diffProviderFactoryService.js'; import { MultiDiffEditorWidget } from '../../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.js'; +import { MultiDiffEditorVariant } from '../../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorOptions.js'; import { DiffItemSource, IDocumentDiffItem } from '../../../../../editor/browser/widget/multiDiffEditor/model.js'; import { IResourceLabel as IMultiDiffResourceLabel, IWorkbenchUIElementFactory } from '../../../../../editor/browser/widget/multiDiffEditor/workbenchUIElementFactory.js'; import { IDiffEditorOptions } from '../../../../../editor/common/config/editorOptions.js'; @@ -128,7 +129,7 @@ export function createMultiDiffEditorFixtureWidget(instantiationService: IInstan MultiDiffEditorWidget, container, uiFactory, - diffEditorOptions, + { variant: MultiDiffEditorVariant.Compact, diffEditorOptions }, ); } 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 7ff2424d595c7..e03d6dc93764a 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts @@ -1159,6 +1159,10 @@ async function renderEditor(ctx: ComponentFixtureContext, options: IRenderEditor } } + if (options.migrationCategory) { + editor.showCustomizationMigrationPage(options.migrationCategory); + } + if (options.migrationPartialSelection) { let firstMigrationCheckbox: HTMLElement | null = null; for (let attempt = 0; attempt < 20 && !firstMigrationCheckbox; attempt++) { @@ -1177,10 +1181,6 @@ async function renderEditor(ctx: ComponentFixtureContext, options: IRenderEditor await new Promise(resolve => setTimeout(resolve, 1400)); } - if (options.migrationCategory) { - editor.showCustomizationMigrationPage(options.migrationCategory); - } - if (options.openFirstItem) { const visibleContent = [...ctx.container.querySelectorAll('.prompts-content-container, .mcp-content-container, .plugin-content-container')] .find(node => node instanceof HTMLElement && node.style.display !== 'none') as HTMLElement | undefined; @@ -1867,8 +1867,8 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { isSessionsWindow: true, selectedSection: AICustomizationManagementSection.McpServers, activeSessionMcpServers, - mcpSearchQuery: 'Remote Browser', openFirstItem: true, + openItemLabel: 'Remote Browser', }), }), diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsList.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsList.fixture.ts index b6e02d641eb3a..a47526872711a 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsList.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsList.fixture.ts @@ -7,8 +7,10 @@ import { Codicon } from '../../../../../base/common/codicons.js'; import * as DOM from '../../../../../base/browser/dom.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { IMarkdownString, MarkdownString } from '../../../../../base/common/htmlContent.js'; +import { KeyCode, KeyMod } from '../../../../../base/common/keyCodes.js'; import { Disposable, IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; import { constObservable, IObservable, observableValue } from '../../../../../base/common/observable.js'; +import { OS } from '../../../../../base/common/platform.js'; import { ExtUri } from '../../../../../base/common/resources.js'; import { ThemeIcon, themeColorFromId } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; @@ -17,8 +19,11 @@ import { IActionViewItemFactory, IActionViewItemService } from '../../../../../p import { IListService, ListService } from '../../../../../platform/list/browser/listService.js'; import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; -import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { ConfigurationTarget, IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js'; +import { createUSLayoutResolvedKeybinding } from '../../../../../platform/keybinding/test/common/keybindingsTestUtils.js'; +import { MockKeybindingService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js'; import { IMenu, IMenuService, MenuId, MenuItemAction } from '../../../../../platform/actions/common/actions.js'; import { EditorMarkdownCodeBlockRenderer } from '../../../../../editor/browser/widget/markdownRenderer/browser/editorMarkdownCodeBlockRenderer.js'; import { IMarkdownRendererService, MarkdownRendererService } from '../../../../../platform/markdown/browser/markdownRenderer.js'; @@ -52,7 +57,7 @@ import { AUTOMATIONS_NEW_BADGE_STYLE_SETTING, type AutomationsNewBadgeStyle } fr // eslint-disable-next-line local/code-import-patterns import { renderSessionsHeader } from '../../../../../sessions/contrib/sessions/browser/views/sessionsView.js'; // eslint-disable-next-line local/code-import-patterns -import { NewSessionActionViewItemContribution } from '../../../../../sessions/contrib/sessions/browser/sessionsActions.js'; +import { NEW_SESSION_BUTTON_STYLE_SETTING, NEW_SESSION_BUTTON_STYLE_TREATMENT, NewSessionActionViewItemContribution, type NewSessionButtonStyle } from '../../../../../sessions/contrib/sessions/browser/sessionsActions.js'; // eslint-disable-next-line local/code-import-patterns import { NEW_SESSION_ACTION_ID } from '../../../../../sessions/contrib/chat/common/constants.js'; // eslint-disable-next-line local/code-import-patterns @@ -219,17 +224,27 @@ interface IRenderOptions { readonly showAutomations?: boolean; readonly automationRunStatus?: IAutomationRun['status']; readonly automationBadgeStyle?: AutomationsNewBadgeStyle; + readonly newSessionButtonStyle?: NewSessionButtonStyle; + readonly newSessionButtonTreatment?: NewSessionButtonStyle; readonly showFocusedToolbar?: boolean; } async function renderSessionsList(ctx: ComponentFixtureContext, options: IRenderOptions): Promise { const { container, disposableStore } = ctx; + const expectedNewSessionButtonStyle = options.newSessionButtonStyle ?? options.newSessionButtonTreatment; + const showHeader = options.showAutomations || expectedNewSessionButtonStyle !== undefined; const approvals = new Map(); const sessions = options.sessions.map(spec => createSession(spec, approvals)); const approvalModel = createApprovalModel(approvals); const groups = options.groups ?? []; const automationRuns = observableValue(disposableStore, []); const actionViewItemService = disposableStore.add(new FixtureActionViewItemService()); + const newSessionKeybinding = expectedNewSessionButtonStyle + ? createUSLayoutResolvedKeybinding(KeyMod.CtrlCmd | KeyCode.KeyN, OS) + : undefined; + if (expectedNewSessionButtonStyle && !newSessionKeybinding) { + throw new Error('Expected the New Session keybinding to resolve.'); + } const membership = new Map(); for (const spec of options.sessions) { if (spec.group) { @@ -262,6 +277,17 @@ async function renderSessionsList(ctx: ComponentFixtureContext, options: IRender }()); } reg.define(IListService, ListService); + if (newSessionKeybinding) { + reg.defineInstance(IKeybindingService, new class extends MockKeybindingService { + override lookupKeybinding(commandId: string) { + return commandId === NEW_SESSION_ACTION_ID ? newSessionKeybinding : undefined; + } + + override lookupKeybindings(commandId: string) { + return commandId === NEW_SESSION_ACTION_ID ? [newSessionKeybinding] : []; + } + }()); + } reg.define(IMarkdownRendererService, MarkdownRendererService); reg.defineInstance(IAgentHostConnectionsService, new class extends mock() { }()); reg.defineInstance(IChatService, new class extends mock() { @@ -334,7 +360,9 @@ async function renderSessionsList(ctx: ComponentFixtureContext, options: IRender }()); reg.defineInstance(IWorkbenchAssignmentService, new class extends mock() { override readonly onDidRefetchAssignments = Event.None; - override async getTreatment(): Promise { return undefined; } + override async getTreatment(name: string): Promise { + return name === NEW_SESSION_BUTTON_STYLE_TREATMENT ? options.newSessionButtonTreatment as T | undefined : undefined; + } }()); reg.defineInstance(IUriIdentityService, new class extends mock() { override readonly extUri = new ExtUri(() => true); @@ -344,7 +372,7 @@ async function renderSessionsList(ctx: ComponentFixtureContext, options: IRender }()); }, }); - if (options.showAutomations) { + if (showHeader) { const contextKeyService = instantiationService.get(IContextKeyService); const newSessionAction = new MenuItemAction( { id: NEW_SESSION_ACTION_ID, title: 'New Session' }, @@ -394,21 +422,21 @@ async function renderSessionsList(ctx: ComponentFixtureContext, options: IRender } let listParent = container; - if (options.showAutomations) { + if (showHeader) { container.classList.add('agent-sessions-viewpane', 'agent-sessions-section'); const content = DOM.append(container, DOM.$('.agent-sessions-content')); disposableStore.add(instantiationService.createInstance(NewSessionActionViewItemContribution)); renderSessionsHeader(content, false, instantiationService, instantiationService.get(IContextKeyService), disposableStore).toolbar?.refresh(); listParent = content; } - const listHost = DOM.append(listParent, DOM.$(options.showAutomations ? '.agent-sessions-control-container' : 'div')); + const listHost = DOM.append(listParent, DOM.$(showHeader ? '.agent-sessions-control-container' : 'div')); const list = disposableStore.add(instantiationService.createInstance(SessionsList, listHost, { grouping: () => options.grouping ?? SessionsGrouping.Workspace, sorting: () => SessionsSorting.Created, onSessionOpen: () => { }, approvalModel, })); - list.layout(options.phone ? 260 : options.showAutomations ? 180 : 220, width); + list.layout(options.phone ? 260 : showHeader ? 180 : 220, width); if (options.automationRunStatus) { automationRuns.set([{ @@ -421,13 +449,29 @@ async function renderSessionsList(ctx: ComponentFixtureContext, options: IRender }], undefined); } await Promise.resolve(); - if (options.showAutomations && !container.querySelector('.agent-sessions-compact-new-button')) { + if (options.newSessionButtonStyle) { + const configurationService = instantiationService.get(IConfigurationService) as TestConfigurationService; + await configurationService.setUserConfiguration(NEW_SESSION_BUTTON_STYLE_SETTING, options.newSessionButtonStyle); + configurationService.onDidChangeConfigurationEmitter.fire({ + source: ConfigurationTarget.USER, + affectedKeys: new Set([NEW_SESSION_BUTTON_STYLE_SETTING]), + change: { keys: [NEW_SESSION_BUTTON_STYLE_SETTING], overrides: [] }, + affectsConfiguration: configuration => configuration === NEW_SESSION_BUTTON_STYLE_SETTING, + }); + } + if (showHeader && !container.querySelector('.agent-sessions-compact-new-button')) { const menu = instantiationService.get(IMenuService).createMenu(Menus.SidebarSessionsHeader, instantiationService.get(IContextKeyService)); const actionCount = menu.getActions().flatMap(([, actions]) => actions).length; menu.dispose(); const hasProvider = !!instantiationService.get(IActionViewItemService).lookUp(Menus.SidebarSessionsHeader, NEW_SESSION_ACTION_ID); throw new Error(`Expected the production New Session action; found ${actionCount} menu action(s), provider=${hasProvider}.`); } + if (expectedNewSessionButtonStyle === 'lightweight' && !container.querySelector('.agent-sessions-compact-new-button.lightweight:not(.lightweight-keybinding-background)')) { + throw new Error('Expected the rendered New Session action to react to the lightweight style setting.'); + } + if (expectedNewSessionButtonStyle === 'lightweightWithKeybindingBackground' && !container.querySelector('.agent-sessions-compact-new-button.lightweight.lightweight-keybinding-background')) { + throw new Error('Expected the rendered New Session action to react to the lightweight keybinding-background style setting.'); + } if (options.showFocusedToolbar) { return Promise.resolve().then(() => { @@ -529,6 +573,24 @@ export default defineThemedFixtureGroup({ path: 'sessions/' }, { showAutomations: true, }), }), + SessionsList_LightweightNewButton: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: true }, + additionalThemes: ['darkHighContrast'], + expectedVisualDescriptions: ['The Sessions header has an outlined New button whose keyboard shortcut is plain inline text without a nested keycap or chip background. The shortcut uses a quieter type role than New and compact platform-native chord notation.'], + render: ctx => renderSessionsList(ctx, { + sessions: [], + newSessionButtonStyle: 'lightweight', + }), + }), + SessionsList_LightweightNewButtonWithKeybindingBackground: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: true }, + additionalThemes: ['darkHighContrast'], + expectedVisualDescriptions: ['The Sessions header has an outlined New button whose keyboard shortcut uses a quieter type role than New and sits on a subtle grouped keybinding background.'], + render: ctx => renderSessionsList(ctx, { + sessions: [], + newSessionButtonTreatment: 'lightweightWithKeybindingBackground', + }), + }), SessionsList_AutomationsNewBadge_Accent: defineComponentFixture({ labels: { kind: 'screenshot', blocksCi: true }, additionalThemes: ['darkHighContrast'], diff --git a/test/componentFixtures/blocks-ci-screenshots.md b/test/componentFixtures/blocks-ci-screenshots.md index acea33e6313ea..8c1a3a07c6605 100644 --- a/test/componentFixtures/blocks-ci-screenshots.md +++ b/test/componentFixtures/blocks-ci-screenshots.md @@ -7,16 +7,16 @@ ![screenshot](https://hediet-screenshots.azurewebsites.net/images/c13f82b9dbe63bd2450bb28f48d66704613bfa02a36b47c6a05a3c9b4d0b81ea) #### chat/aiCustomizations/aiCustomizationManagementEditor/AgentsTab/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/80f87f875ed4c2e57f538d498dfae808fb0fb0599d966dace61b135c467ff794) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/28afa7579bb0d20cb18d370984ef36f74671b4d62dbf00ef5195e3f39817d202) #### chat/aiCustomizations/aiCustomizationManagementEditor/AgentsTab/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/9bd3ed05110dbe9c193acf371e2af656926d8f223faba768a8acd7f009bd7485) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/896f225ad9c8c34648d178723be3c09be772331fc369e2053bfdeda50561fdfe) #### chat/aiCustomizations/aiCustomizationManagementEditor/AgentsTabNarrow/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/80d131368e6930bc9135e9d47f57dbcf121588dab5718cfda4b40c8543a3e620) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/8478954d28888f188b20cf5b1eb3a919c091316085c3b81106480075cf92d00c) #### chat/aiCustomizations/aiCustomizationManagementEditor/AgentsTabNarrow/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/08e0a7af4d6954232fd0de9008b3c0f4936d31b4a286446f44011f463fe23b41) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/006b83d675a7f58ae5523b9b518f93b34b63aeb48ff774c9cf93129214ff41ac) #### chat/aiCustomizations/aiCustomizationManagementEditor/EmbeddedMcpDetailUninstalled/Dark ![screenshot](https://hediet-screenshots.azurewebsites.net/images/a5c4c329b3df33c748b8cd46ba7585490b58b2d42fa71c25a44a7f327b344b1b) @@ -25,76 +25,76 @@ ![screenshot](https://hediet-screenshots.azurewebsites.net/images/b150bed8ecf034a2ac61bc46081007101d109ed98bc176824e045965dfff94da) #### chat/aiCustomizations/aiCustomizationManagementEditor/HooksEmptyWorkspace/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/584a93e3dbed1d8f7ffcff61cb6d9617fc60e09beed1f2f07ed759cf1359d960) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/e325f9d62356e7dcbe47d010d40da5a4b2e0e14bab822b1d1af7c809e90ab783) #### chat/aiCustomizations/aiCustomizationManagementEditor/HooksEmptyWorkspace/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/cd215308df295015347d508181e37f594152b90c4d29ae8d93c6b76fe3820ad7) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/3cc3dc7395a62e03c87a7474db2382cff6ea7e7240e206e3b076b89c53458bb4) #### chat/aiCustomizations/aiCustomizationManagementEditor/McpServerDetailNarrow/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/381816517a26c12e647f06d53e86100e1c67445bb76ea60ee3dcd981ffeaa41d) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/dcbe6f881e9d1d94691bc87ecd800253c237f73e30341b7f88f8afbf588a282d) #### chat/aiCustomizations/aiCustomizationManagementEditor/McpServerDetailNarrow/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/b8c214bd7418237091cedd35cb8e5abb117e9b0741c8c108000939306595e0ec) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/cfb82a5399bf015b29c48bdb7f7f4224d13a6bfa4ea0ae9cb8efa67948c2f4ad) #### chat/aiCustomizations/aiCustomizationManagementEditor/McpServersTab/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/f0544aedf46f4e4f082b31fbf9572639656ce6e149a2bacc6114da6b751b6faa) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/64a9ba55fca8dbd08bf3ce5118f1b78fedc3d97231b6c92700542b720ea1d23c) #### chat/aiCustomizations/aiCustomizationManagementEditor/McpServersTab/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/109b64f5e81c0da1bd6e00a1bcbb94edb3a3099c95a49bc9578605b055dd5175) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/92d2e1fa82290461764a4d0ba075994e42e969f6994d2d45dd1727ad36b6280f) #### chat/aiCustomizations/aiCustomizationManagementEditor/PluginCatalogHome/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/4e881a199c6371613745f5298984d63de42aac4d0eaabb3c4811c9bcda00205a) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/e3eee532373366a533ac4374808d799b1a2cfdc7db83ad479ae38eac2102e670) #### chat/aiCustomizations/aiCustomizationManagementEditor/PluginCatalogHome/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/aca2145ec8c31a48c033dd291119b5c74d4782728215013939d18e21b3889a83) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/1cb59613169dce3604a71ffc9b9a7460de175e78d323436e6427299730ee0a76) #### chat/aiCustomizations/aiCustomizationManagementEditor/PluginCatalogHomeNarrow/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/df5b59c82075a7531e8ceffd16f7098626863f731bd099970ecda88efa3d9a40) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/9b4ea6512c19a7999a18d857ff15140d13fbfea69d32d63b0aa871739d4c5af5) #### chat/aiCustomizations/aiCustomizationManagementEditor/PluginCatalogHomeNarrow/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/8895e63f1e5e0a369181cf60128aa984e258592ff85b6b87e57d0c604360888a) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/13d81ef8758630efebb534de263c87ec72f24fea419305539c3e9449b08dd838) #### chat/aiCustomizations/aiCustomizationManagementEditor/PluginCatalogSearch/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/d39e63162436cf09ff71cd6427d6c808ad23889f00bf73240e089d887fba374e) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/1c73380dc80120ef5cf9a9ad9998701074fd71101eba681d9c3c53bb1d14f450) #### chat/aiCustomizations/aiCustomizationManagementEditor/PluginCatalogSearch/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/94ed07ad627037f1c98d1e9767449991e6f4ebac3c25d3dc265c03cb5cb930fc) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/32e4715053eeba6b457cf67e2069ab08c9d1428e088c4203e0de8a4c92b96b36) #### chat/aiCustomizations/aiCustomizationManagementEditor/PluginDetail/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/f9c703b94481f8bd41d4154fe26df01d2a502eae7b1a917ed406fe1f72ac34df) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/62537479c1fd43a5592ff159693c958b4d740da4d52066bc2731172d656ec0e5) #### chat/aiCustomizations/aiCustomizationManagementEditor/PluginDetail/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/d0b6575b59c8658eb961d0f022cf4943bfc9477b9e1bbf65f9de5a08ab773149) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/a17dbb65cd39f1dfd3528afeab8ab67b57b69926a440b0b74dab7e4d2ec52ac1) #### chat/aiCustomizations/aiCustomizationManagementEditor/PluginsTab/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/f9c703b94481f8bd41d4154fe26df01d2a502eae7b1a917ed406fe1f72ac34df) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/62537479c1fd43a5592ff159693c958b4d740da4d52066bc2731172d656ec0e5) #### chat/aiCustomizations/aiCustomizationManagementEditor/PluginsTab/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/d0b6575b59c8658eb961d0f022cf4943bfc9477b9e1bbf65f9de5a08ab773149) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/a17dbb65cd39f1dfd3528afeab8ab67b57b69926a440b0b74dab7e4d2ec52ac1) #### chat/aiCustomizations/aiCustomizationManagementEditor/PluginsTabNarrow/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/0de50692f3dc96a1396e2a820e6cd45a5be7f6625e7b49565089b8e929927c0e) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/cd5ba64dc1d4335dee027f901961b54fba261082989985ae52ae9e95b05ade37) #### chat/aiCustomizations/aiCustomizationManagementEditor/PluginsTabNarrow/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/4b3c83e0b3b4f3e116e9f38ffe48ad7bf5a9b9b4656e233c8394196eae9133bc) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/74e7da25f15ef10984935651a1b898d512675cdd18c5d6485b2fe028c3503d18) #### chat/aiCustomizations/aiCustomizationManagementEditor/PromptMigration/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/13f0fd52bed5191a9fafe05be00a7757e72a92f05162a2f323a31663b9ddc83c) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/ff29022d8f4218b9bab6b727e2dc102313349ee3741b7e09c91f1d3699819045) #### chat/aiCustomizations/aiCustomizationManagementEditor/PromptMigration/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/45a9ae5a6c6ce5f5d9184ae55d124e13f6625c805b72a6d52b17035545ab7278) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/df1b9208d7da611a7eb80cfe78e6ec67993c84263f895dbdefdba15f054fdab4) #### chat/aiCustomizations/aiCustomizationManagementEditor/ToolsTab/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/afe68d55d548d3234536e2e69147f8c48de5dac0e1eb09b1ff656337c370d664) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/fb6cf3c234695edd7425429cd80f8831a099ebf31627eff7a6da34fd1a39e978) #### chat/aiCustomizations/aiCustomizationManagementEditor/ToolsTab/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/4f4bf2ba517c50d456a557fb27ff423cce77e24adf626754af2cb055dd931761) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/61ae20aec4f4fb94a34c7c030b90d20f7dc214413a335aed4d91260cf97e8937) #### chat/aiCustomizations/aiCustomizationManagementEditor/UserDataMigration/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/5483d9af839d6e44f95c61701cb3e03bd578e6ba1465f4724d9231654d775549) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/da9c970c065f09bcc641277cc354827e578a912fbea692e41312e4d0a310291a) #### chat/aiCustomizations/aiCustomizationManagementEditor/UserDataMigration/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/7203d034fad4d4439b6f70ac49ed02f95d764a090f8067a06bb6bae7d4bd1107) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/37be569b9695d50b0c1ca515bef743bd743cf5009502a3cbf85ba86a841c5ba5) #### chat/aiCustomizations/aiCustomizationManagementEditor/WelcomePage/Dark ![screenshot](https://hediet-screenshots.azurewebsites.net/images/483f8579ecd4518666859e405e43c08aec24e1a857a05f242517c1916ab21b9a) @@ -211,46 +211,46 @@ ![screenshot](https://hediet-screenshots.azurewebsites.net/images/0fe9bb7434b473795187b0545ccaed6aa77af4b7e9f90890dc8885c7f31ddebb) #### sessions/chat/newWidget/newChatWidget/NewSessionAttachedContext/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/19b1ebb2ae6b03f3500181ac7d35fe20ccf4f0a3bcac413211f00f3db200a69b) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/2d61e65235b95e91d8db031868df3ca20a0be29ef4bc63527919d44d8b089949) #### sessions/chat/newWidget/newChatWidget/NewSessionAttachedContext/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/c2903c1bfd9364ff2dd3c6531a47a0b5c4ad11892c21c6db3357eb986c3ae568) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/6a1477a35797beb694904a063b139294c6df3984cebe968a15ee1bf3da0d0706) #### sessions/chat/newWidget/newChatWidget/NewSessionAutoModel/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/828f33b8392e74ac892e8339d6b5401e5e17b9aeb424863f0c8ca0ae5982a30c) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/b3d1e747d5fc68a1a0214e42302eb93f5fd1b0c05919839edc960c9a426a2935) #### sessions/chat/newWidget/newChatWidget/NewSessionAutoModel/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/704712c8157cbb1e8b05602c92f65941f869a455400a7057057fa03330424c03) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/8e7ca1d9cf566e287afef9ccf116c60b1cce43b70924ab2dcfb2ce571d23ac48) #### sessions/chat/newWidget/newChatWidget/NewSessionCompactAutoModel/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/08859e75e7f3a262ed2428c7b676881578fc8f62301f4f3ca63d79c21a1e6dd0) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/89de02c5250d1dfabbf7ce94fca5a7c87e480d3a7b67c376eb3afe38e7168443) #### sessions/chat/newWidget/newChatWidget/NewSessionCompactAutoModel/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/e896298aaf3e160015d98455ae350254c4acb40d32117b29d8fd8284923f0f44) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/3ee80de3c6e3555e9bf2dec75a00acc06a5656a4bf3864b95b7ac7a16a5fe499) #### sessions/chat/newWidget/newChatWidget/NewSessionGitHubContextPicker/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/e1d5b98be6a615e7e30e3f08e1b1e80219743fbe0b478ff68a0e7b08e234cf56) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/6646486035136d6960d0e8547e01e572fdb9a2bd61652f75710e29003e9764ed) #### sessions/chat/newWidget/newChatWidget/NewSessionGitHubContextPicker/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/e6b7c571945d3c311ab89529478340018496b9d4c75e13095b672c913cee52ee) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/070c122c652f70e4e01f4565b47ff936af668eee09a5928f59c15200c3029e2f) #### sessions/chat/newWidget/newChatWidget/NewSessionPhoneAttachedContext/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/92dddf0febbf9a2e7b9d1a33940fefc624ba319f7bb7bd4d911743c535908392) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/5019d89a75c5d2ed11af6ae8b5dc10c55e0d068380b77b9caff25b6610c1fc98) #### sessions/chat/newWidget/newChatWidget/NewSessionPhoneAttachedContext/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/4ad50b05198491cea5a200931e6070113ac5238ecf6a31185e4d13e6f9dacdfc) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/4807e11e1a7be9450a27705b9fc1c171d709a547d45edb0bbf2e513c16a9e4b1) #### sessions/chat/newWidget/newChatWidget/NewSessionRemoteWorkspace/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/34408877e1ac8af8668377237c835da520bfc4a1301ebd55f6dab32084f88042) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/690f680cfabfb2547ed86052c31788d7e49de198568de881c48c4929855762cc) #### sessions/chat/newWidget/newChatWidget/NewSessionRemoteWorkspace/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/ef4a895e36499772c251ee00d72d765d084b5082430e430c99398b7a12761c7a) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/269504f69602705f579c0c977052d04c390c067595f6552841123a11c6275106) #### sessions/chat/newWidget/newChatWidget/NewSessionWorkspacePicker/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/c6c33dcf1ba01a1cf19d97289c2699e2e0571aac0e220b6c2d71282a3dd4cf3a) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/65edfaa8f1cc8246ca1b9ee2e35ba2efc3267db79ec9e419f9924362418c5027) #### sessions/chat/newWidget/newChatWidget/NewSessionWorkspacePicker/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/695c9069e5791b7d24b24c1f9db4561755ff5694ad38370a7706426b6d38210a) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/058cf4ff1b32a3e30c1550713c4fde7410339cd5fe896ce3fd840b45653b5d41) #### sessions/sessionsList/SessionsList_AutomationsNewBadge_Accent/Dark ![screenshot](https://hediet-screenshots.azurewebsites.net/images/75cb51ad8b1f6ecb8c3891c2ee1d260a45d293c502bef1c33ce0df915c543aa0) @@ -297,6 +297,24 @@ #### sessions/sessionsList/SessionsList_AutomationsNewBadge/Light ![screenshot](https://hediet-screenshots.azurewebsites.net/images/0154b8cd8302a62959c0a067e02aadc24d27a54d85891dd19fb9e9b4d99362f2) +#### sessions/sessionsList/SessionsList_LightweightNewButton/Dark +![screenshot](https://hediet-screenshots.azurewebsites.net/images/d8f12a4790516fe09f20ccff0012b2422bac95e48c7f3360af36c254b31b57dc) + +#### sessions/sessionsList/SessionsList_LightweightNewButton/DarkHighContrast +![screenshot](https://hediet-screenshots.azurewebsites.net/images/50c9d63b886aa15e5aab690fca127c675b135ac76c12a36afa65cc40200e985b) + +#### sessions/sessionsList/SessionsList_LightweightNewButton/Light +![screenshot](https://hediet-screenshots.azurewebsites.net/images/27750194694cd6b4941e11c7752732c04f934269141b93ad9bbd6131217ace45) + +#### sessions/sessionsList/SessionsList_LightweightNewButtonWithKeybindingBackground/Dark +![screenshot](https://hediet-screenshots.azurewebsites.net/images/9d8a57cfaff4101c600dd6bb8464d48973ecd0751b4470664ec7fb2d6553b008) + +#### sessions/sessionsList/SessionsList_LightweightNewButtonWithKeybindingBackground/DarkHighContrast +![screenshot](https://hediet-screenshots.azurewebsites.net/images/4ada330a003107db5221c84f4662aff80f1f4b541877e80420c0cf22918bf5b2) + +#### sessions/sessionsList/SessionsList_LightweightNewButtonWithKeybindingBackground/Light +![screenshot](https://hediet-screenshots.azurewebsites.net/images/54f5cf2d72ea720ff9c6f07388c686f91f7541abff62ef516b321588260da824) + #### sessions/sessionsList/SessionsList_NarrowHoverToolbar/Dark ![screenshot](https://hediet-screenshots.azurewebsites.net/images/8af0c707c9c8e321ac7c8fd792b3c242a0d394cdaf68c3fe1c61804095395030) diff --git a/test/smoke/src/areas/chat/chatSandbox.test.ts b/test/smoke/src/areas/chat/chatSandbox.test.ts index ba89fd95d8885..054b345e56f0c 100644 --- a/test/smoke/src/areas/chat/chatSandbox.test.ts +++ b/test/smoke/src/areas/chat/chatSandbox.test.ts @@ -168,8 +168,7 @@ export function setup(logger: Logger): void { // focusable for the additional settings written by nested suites. ['editor.wordWrap', '"on"'], ['chat.agent.sandbox.enabled', '"on"'], - // Leave allowNetwork at its default (false), and prevent a failed probe - // from being retried with relaxed network access or outside the sandbox. + // Exercise the default network policy, without retrying failed probes outside its restrictions. ['chat.agent.sandbox.retryWithAllowNetworkRequests', 'false'], ['chat.agent.sandbox.allowUnsandboxedCommands', 'false'], ]); @@ -273,59 +272,50 @@ export function setup(logger: Logger): void { } }); - /* - * Input: Ask chat to run an HTTP request to the local mock server with allowNetwork disabled. - * Expected result: The sandbox blocks the request and its output contains a network error such as - * `ECONNREFUSED`, `EPERM`, `EACCES`, `ENETUNREACH`, `EHOSTUNREACH`, `ENETDOWN`, or `EAI_AGAIN`. - */ - it('blocks terminal network access by default', async function () { + it('allows terminal network access by default', async function () { const app = this.app as Application; try { const requestsBefore = mockServer.requestCount(); - await app.workbench.chat.sendMessage(`Run the terminal network sandbox probe [scenario:${NETWORK_SCENARIO_ID}]`); + await app.workbench.chat.sendMessage(`Run the allowed terminal network sandbox probe [scenario:${NETWORK_ALLOWED_SCENARIO_ID}]`); - const responseText = await app.workbench.chat.waitForResponseText(NETWORK_BLOCKED_PATTERN, CHAT_RESPONSE_TIMEOUT); - logger.log(`[Chat Sandbox/network] response: ${responseText}`); - assert.ok(mockServer.requestCount() > requestsBefore, 'expected the mock LLM server to receive the network sandbox scenario'); - assert.match( - responseText, - NETWORK_BLOCKED_PATTERN, - 'expected the sandbox to block the terminal command from reaching the local mock server' - ); + const responseText = await app.workbench.chat.waitForResponseText(networkAllowedReply, CHAT_RESPONSE_TIMEOUT); + logger.log(`[Chat Sandbox/network allowed] response: ${responseText}`); + assert.ok(mockServer.requestCount() > requestsBefore, 'expected the mock LLM server to receive the allowed network sandbox scenario'); + assert.ok(responseText.includes(networkAllowedReply), 'expected the default network policy to permit the sandboxed terminal command to reach the local mock server'); } catch (error) { - logger.log(`[Chat Sandbox/network] FAILURE: ${error instanceof Error ? error.stack ?? error.message : String(error)}`); - await dumpFailureDiagnostics(app, logger, `Chat Sandbox (${process.platform}) network`); + logger.log(`[Chat Sandbox/network allowed] FAILURE: ${error instanceof Error ? error.stack ?? error.message : String(error)}`); + await dumpFailureDiagnostics(app, logger, `Chat Sandbox (${process.platform}) network allowed`); throw error; } }); - /* - * Input: Enable allowNetwork and ask chat to run an HTTP request to the local mock server. - * Expected result: The sandbox permits the request and its output contains `${networkAllowedReply}`. - */ - describe('with terminal network access enabled', function () { + describe('with terminal network access disabled', function () { before(async function () { const app = this.app as Application; await updateUserSettingsWhileChatIsOpen(app, [ - ['chat.agent.sandbox.allowNetwork', 'true'], + ['chat.agent.sandbox.allowNetwork', 'false'], ]); }); - it('allows terminal network access', async function () { + it('blocks terminal network access', async function () { const app = this.app as Application; try { const requestsBefore = mockServer.requestCount(); - await app.workbench.chat.sendMessage(`Run the allowed terminal network sandbox probe [scenario:${NETWORK_ALLOWED_SCENARIO_ID}]`); - - const responseText = await app.workbench.chat.waitForResponseText(networkAllowedReply, CHAT_RESPONSE_TIMEOUT); - logger.log(`[Chat Sandbox/network allowed] response: ${responseText}`); - assert.ok(mockServer.requestCount() > requestsBefore, 'expected the mock LLM server to receive the allowed network sandbox scenario'); - assert.ok(responseText.includes(networkAllowedReply), 'expected allowNetwork to permit the sandboxed terminal command to reach the local mock server'); + await app.workbench.chat.sendMessage(`Run the terminal network sandbox probe [scenario:${NETWORK_SCENARIO_ID}]`); + + const responseText = await app.workbench.chat.waitForResponseText(NETWORK_BLOCKED_PATTERN, CHAT_RESPONSE_TIMEOUT); + logger.log(`[Chat Sandbox/network] response: ${responseText}`); + assert.ok(mockServer.requestCount() > requestsBefore, 'expected the mock LLM server to receive the network sandbox scenario'); + assert.match( + responseText, + NETWORK_BLOCKED_PATTERN, + 'expected the sandbox to block the terminal command from reaching the local mock server' + ); } catch (error) { - logger.log(`[Chat Sandbox/network allowed] FAILURE: ${error instanceof Error ? error.stack ?? error.message : String(error)}`); - await dumpFailureDiagnostics(app, logger, `Chat Sandbox (${process.platform}) network allowed`); + logger.log(`[Chat Sandbox/network] FAILURE: ${error instanceof Error ? error.stack ?? error.message : String(error)}`); + await dumpFailureDiagnostics(app, logger, `Chat Sandbox (${process.platform}) network`); throw error; } });