From d1e297855e130d6003f7d9fd06edd7d681860e5d Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 23 Oct 2026 00:12:08 -0500 Subject: [PATCH] =?UTF-8?q?Reconstruct=20batch=20096/124:=202026-09-11=20?= =?UTF-8?q?=E2=86=92=202026-10-09=20(chat,=20sessions,=20agenthost,=20mcp)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Squashed reconstruction of 50 upstream commits (999533efbc0..e23b74e3aa5). 210 files changed, 8544 insertions(+), 1168 deletions(-) chat: - Use custom text editor selections as implicit chat context - Coalesce custom-editor context refreshes across chat widgets - Fix customization migration accessibility regressions (#340592) - Fix prompt-to-skill description YAML serialization (#340590) - Simplify customization migration confirmation dialogs (#340753) - Use warning foreground for customization migration actions (#340759) - Explain prompt-to-skill migration consequences (#340593) - chat: correlate session metrics with Agent Host ownership (#340087) - chat: Respect strict plugin marketplace allowlists (#340769) - Remove auto toggle (#340789) - chat: allow leaving global Allow All mode (#340780) sessions: - sessions: show external session application badges - sessions: configure external application badge placement - sessions: harden external application badges - sessions: refine multi-session grid spacing - sessions: fix panel gap regressions - sessions: preserve connected tab frame - sessions: preserve grid panel minimum sizes - sessions: configure external session application badges (#340487) - sessions: fix automation dialog frosted glass styling - sessions: Ignore empty sandbox repository references (#340768) agenthost: - enable experiments for chat.agentHost.experimental.mcpToolRouting (#340730) - agentHost: snapshot the bundled managed settings schema (#340658) - agentHost: allow owned plan artifacts over Mission Control (#340647) - agentHost: honor BYOK utility model settings in the Copilot agent host - agentHost: fail closed for BYOK utility routes while the renderer is disconnected - agentHost: pin the SDK BYOK selection id contract with tests - agentHost: don't require Copilot models in the BYOK selection id integration test - agentHost: honor BYOK utility model settings in the Copilot agent host (#340747) misc: - Refine Agents window multi-session panel spacing (#340709) - Include rich Markdown selections in chat context (#340717) - Integrate smart paste and configurable Markdown block highlighting (#340728) - Approve reviewed PR state - Disable Step Out action when the active thread has only one stack frame (#336125) mcp: - mcp: address configuration form review feedback - mcp: merge configuration form rows per entry and keep focus on refresh - mcp: address configuration form review feedback (#340636) debug: - Improve step out button behavior - Allow step out only when the active thread has multiple stack frames - debug: keep call stack updates out of the ViewModel dependency graph - debug: decouple ViewModel from IDebugModel (#340770) extensions/markdown-language-features: - Preserve plain-text paste when linking selected Markdown text - Make rich Markdown active-block highlighting configurable markdown: - markdown: Bump editor for Copy button clipboard fix - markdown: Bump editor for Copy button clipboard fix (#340756) extension api: - Add viewColumn to external uri opener (#340716) actionwidget: - Fix tabbed model picker position in inline chat (#340731) fix: - fix: reject with proper Error on audio error event (fixes #339115) (#339121) agents window: - Agents window: fix New automation dialog styling (#340749) editor: - Updating setting description for editor.forceFullwidthCharacterWidth (#340742) remotetunnel: - Add telemetry correlation headers to dev tunnel service requests (#340787) Co-Authored-By: Claude Opus 5.5 --- .../lib/stylelint/vscode-known-variables.json | 2 + cli/CONTRIBUTING.md | 15 + cli/Cargo.lock | 2 +- cli/Cargo.toml | 2 +- cli/src/tunnels/dev_tunnels.rs | 123 +- .../CONTRIBUTING.md | 11 + .../markdown-editor-src/editor.ts | 10 +- .../imagePasteController.ts | 6 +- .../markdown-editor-src/markdownEditor.css | 5 +- .../package-lock.json | 33 +- .../markdown-language-features/package.json | 8 +- .../package.nls.json | 1 + .../src/preview/markdownEditorProtocol.ts | 3 + .../src/preview/markdownEditorProvider.ts | 20 +- .../src/preview/webviewInitialState.ts | 4 +- .../src/test/markdownEditorBridge.test.ts | 1 + .../src/test/markdownEditorProvider.test.ts | 1 + .../src/test/markdownEditorRpc.test.ts | 16 + package-lock.json | 43 +- package.json | 6 +- .../editor/browser/services/openerService.ts | 3 +- src/vs/editor/common/config/editorOptions.ts | 2 +- src/vs/editor/common/textModelEditSource.ts | 4 + .../browser/services/openerService.test.ts | 30 +- .../accessibility/browser/accessibleView.ts | 1 + .../browser/accessibilitySignalService.ts | 9 +- .../actionWidget/browser/actionList.ts | 18 +- .../browser/tabbedActionListWidget.css | 15 - .../browser/tabbedActionListWidget.ts | 72 +- .../browser/tabbedActionListWidget.test.ts | 19 + src/vs/platform/agentHost/common/agent.ts | 3 + .../agentHost/common/agentHostByokLm.ts | 14 + .../agentHost/common/agentHostSchema.ts | 35 + .../agentHostStarter.config.contribution.ts | 1 + .../platform/agentHost/common/agentService.ts | 2 + .../agentHost/common/fileEditAttribution.ts | 2 + .../agentHost/node/agentHostBootstrap.ts | 10 +- .../agentHostPullRequestOperationHandler.ts | 46 +- .../agentHost/node/agentHostServices.ts | 2 + .../node/agentHostSessionTitleController.ts | 35 +- .../agentHost/node/agentHostTurnStarter.ts | 2 +- .../node/agentHostUtilityModelService.ts | 287 ++++ .../platform/agentHost/node/agentService.ts | 4 + .../agentHost/node/copilot/copilotAgent.ts | 10 + .../agentHost/node/protocolServerHandler.ts | 54 +- .../shared/agentEditAttributionService.ts | 9 +- .../node/shared/copilotApiService.ts | 4 +- .../agentHost/node/tunnelAgentHostService.ts | 15 +- .../test/common/agentHostByokLm.test.ts | 44 + .../test/common/fileEditAttribution.test.ts | 34 +- ...gs_schema_matches_the_snapshot.schema.json | 1158 +++++++++++++++++ .../node/agentEditAttributionService.test.ts | 12 +- ...entHostPullRequestOperationHandler.test.ts | 12 +- .../test/node/agentHostServices.test.ts | 18 + .../agentHostSessionTitleController.test.ts | 63 +- .../node/agentHostUtilityModelService.test.ts | 232 ++++ .../agentHost/test/node/agentService.test.ts | 6 +- .../test/node/agentServiceTestUtils.ts | 13 +- .../agentHost/test/node/copilotAgent.test.ts | 8 + ...pilotSdkManagedSettings.integrationTest.ts | 56 + .../agentHost/test/node/e2e/README.md | 18 + .../test/node/protocolServerHandler.test.ts | 199 ++- .../test/node/providerIntegration/README.md | 6 + ...copilotByokSelectionIds.integrationTest.ts | 108 ++ .../test/node/tunnelAgentHostService.test.ts | 43 +- src/vs/platform/editor/common/editor.ts | 17 + .../mcp/common/mcpResourceScannerService.ts | 63 +- .../common/mcpResourceScannerService.test.ts | 59 + src/vs/platform/opener/common/opener.ts | 4 +- .../common/tunnelServiceHeaders.ts | 49 + .../remoteTunnel/node/remoteTunnelService.ts | 7 +- .../node/tunnelProcessCoordinator.ts | 23 +- .../test/common/tunnelServiceHeaders.test.ts | 49 + .../test/node/remoteTunnelService.test.ts | 16 +- .../node/tunnelProcessCoordinator.test.ts | 51 +- .../telemetry/common/editTelemetry.ts | 5 +- src/vs/sessions/browser/media/workbench.css | 35 +- .../sessions/browser/parts/agentsPartCard.ts | 55 +- .../browser/parts/auxiliaryBarPart.ts | 5 +- .../browser/parts/desktopEditorPart.ts | 22 + src/vs/sessions/browser/parts/editorPart.ts | 4 +- src/vs/sessions/browser/parts/gridGap.ts | 88 ++ .../browser/parts/media/chatCompositeBar.css | 17 +- .../browser/parts/media/chatGroupsView.css | 1 + .../browser/parts/media/editorPart.css | 6 + .../browser/parts/media/sessionsPart.css | 9 + src/vs/sessions/browser/parts/panelPart.ts | 8 +- .../browser/parts/sessionGridLayout.ts | 133 +- src/vs/sessions/browser/parts/sessionsPart.ts | 13 +- src/vs/sessions/common/sessionConfig.ts | 10 + .../automations/browser/automationDialog.ts | 11 +- .../browser/automationDialogService.ts | 5 +- .../browser/media/automationDialog.css | 44 +- .../test/browser/automationDialog.test.ts | 30 + .../chat/test/browser/sessionsGrid.fixture.ts | 322 ++++- .../browser/sessionsWorkbenchFixtureUtils.ts | 45 +- .../test/browser/frostedGlass.test.ts | 10 +- .../agentHostPermissionPickerDelegate.ts | 3 +- .../agentHostPermissionPickerDelegate.test.ts | 21 +- .../browser/browserTunnelAgentHostService.ts | 17 +- .../browser/devTunnelsWebLoader.ts | 1 + .../webTunnelAgentHostService.contribution.ts | 3 + .../browserTunnelAgentHostService.test.ts | 11 +- .../sessions/browser/media/sessionsList.css | 48 +- .../sessions/browser/sessions.contribution.ts | 26 +- .../sessions/browser/views/sessionsList.ts | 113 +- .../browser/sessions.contribution.test.ts | 36 +- .../test/browser/sessionsList.fixture.ts | 90 +- .../test/browser/sessionsList.test.ts | 96 +- .../test/browser/sessionsListFixtureUtils.ts | 6 + .../test/browser/agentsPartCard.test.ts | 255 +++- .../test/browser/chatCompositeBar.test.ts | 9 +- .../test/browser/chatGroupsView.test.ts | 169 +++ .../test/browser/parts/panelPart.test.ts | 62 +- .../test/browser/sessionGridLayout.test.ts | 225 +++- .../test/browser/sessionViewTestUtils.ts | 8 +- .../test/browser/sessionsPart.test.ts | 121 +- .../sessions/test/browser/workbench.test.ts | 94 +- .../api/browser/mainThreadUriOpeners.ts | 6 +- .../workbench/api/common/extHost.protocol.ts | 4 +- src/vs/workbench/api/common/extHostTypes.ts | 15 +- .../workbench/api/common/extHostUriOpener.ts | 6 +- .../api/test/browser/extHostUriOpener.test.ts | 82 ++ .../browser/accessibilityConfiguration.ts | 5 + .../browser/actions/chatAccessibilityHelp.ts | 6 +- .../browser/actions/chatCodeblockActions.ts | 12 +- .../chat/browser/actions/chatPluginActions.ts | 11 +- .../browser/actions/codeBlockOperations.ts | 6 +- .../agentHost/agentHostChatInputPicker.ts | 5 +- .../aiCustomizationManagementEditor.ts | 73 +- .../aiCustomization/customizationMigration.ts | 33 +- .../customizationMigrationAccessibility.ts | 4 +- .../customizationMigrationCategories.ts | 12 +- .../customizationMigrationDashboard.ts | 33 +- .../media/aiCustomizationManagement.css | 14 +- .../aiCustomizationWelcomePromptLaunchers.css | 2 +- .../media/customizationMigrationDashboard.css | 6 + .../attachments/chatImplicitContext.ts | 59 +- .../chat/browser/chat.shared.contribution.ts | 6 +- .../chatEditingDeletedFileEntry.ts | 3 + .../chatEditingModifiedDocumentEntry.ts | 3 + .../chatEditingModifiedFileEntry.ts | 5 + .../chatEditingModifiedNotebookEntry.ts | 4 +- .../remoteAgentHost/cloudSandboxApiService.ts | 5 +- .../chatContentParts/chatPlanReviewPart.ts | 28 +- .../browser/widget/input/chatInputPart.ts | 11 + .../input/modelPicker/media/modelPicker.css | 37 +- .../modelPicker/modelPickerActionItem.ts | 1 + .../input/modelPicker/modelPickerCard.ts | 79 +- .../modelPicker/modelPickerConfiguration.ts | 9 +- .../modelPicker/modelPickerPresentation.ts | 2 +- .../modelPicker/modelPickerTabbedWidget.ts | 277 ++-- .../input/modelPicker/modelPickerTelemetry.ts | 35 +- .../input/modelPicker/modelPickerWidget.ts | 17 +- .../chat/common/agentHostConfigPolicy.ts | 7 + .../chat/common/chatService/chatService.ts | 2 + .../common/chatService/chatServiceImpl.ts | 26 +- .../chatService/chatServiceTelemetry.ts | 26 +- .../chatAccessibilityHelp.test.ts | 29 +- .../actions/chatCodeblockActions.test.ts | 6 + .../browser/actions/chatPluginActions.test.ts | 100 +- .../agentHostChatContribution.test.ts | 25 + .../agentHostChatInputPicker.test.ts | 20 + .../aiCustomizationManagementEditor.test.ts | 236 +++- ...mizationWelcomePagePromptLaunchers.test.ts | 5 + .../customizationMigration.test.ts | 146 ++- .../customizationMigrationDashboard.test.ts | 41 +- .../embeddedMcpServerDetail.test.ts | 144 +- .../attachments/chatImplicitContext.test.ts | 312 +++++ .../chatUserInteractionTelemetry.test.ts | 63 +- .../cloudSandboxApiService.test.ts | 74 +- .../chatPlanReviewPart.test.ts | 107 +- .../input/chatModelConfigurationStore.test.ts | 20 + .../input/modelPicker/modelPickerCard.test.ts | 43 + .../modelPickerConfiguration.test.ts | 6 +- .../modelPickerTabbedWidget.test.ts | 411 +++--- .../modelPicker/modelPickerTelemetry.test.ts | 98 +- .../common/chatService/chatService.test.ts | 96 ++ .../customEditor/browser/customEditorInput.ts | 16 +- .../contrib/debug/browser/callStackView.ts | 16 +- .../debug/browser/debug.contribution.ts | 8 +- .../contrib/debug/browser/debugCommands.ts | 4 +- .../contrib/debug/browser/debugService.ts | 1 + .../contrib/debug/browser/debugToolBar.ts | 4 +- .../workbench/contrib/debug/common/debug.ts | 19 + .../contrib/debug/common/debugModel.ts | 10 +- .../contrib/debug/common/debugViewModel.ts | 9 +- .../debug/test/browser/callStack.test.ts | 99 +- .../debug/test/browser/debugViewModel.test.ts | 76 +- .../debug/test/common/debugModel.test.ts | 40 +- .../telemetry/agentHostEditMarkerService.ts | 2 + .../aiEditTelemetry/aiEditTelemetryService.ts | 6 +- .../aiEditTelemetryServiceImpl.ts | 11 +- .../telemetry/editSourceTrackingImpl.ts | 5 +- .../agentHostEditMarkerService.test.ts | 15 + .../browser/editSourceTrackingImpl.test.ts | 42 + .../test/browser/editTelemetry.test.ts | 9 + .../common/externalUriOpenerService.ts | 7 +- .../common/externalUriOpenerService.test.ts | 10 +- .../inlineChatEditReviewSession.test.ts | 45 +- .../contrib/mcp/browser/mcp.contribution.ts | 3 + .../mcp/browser/mcpServerConfigurationForm.ts | 271 +++- ...mcpServerConfigurationFormAccessibility.ts | 47 + .../mcp/common/mcpServerConfigurationForm.ts | 45 +- .../common/mcpServerConfigurationForm.test.ts | 26 +- .../chat/tabbedModelPicker.fixture.ts | 28 +- ...aiCustomizationManagementEditor.fixture.ts | 165 ++- .../test/browser/workbenchTestServices.ts | 2 + .../vscode.proposed.externalUriOpener.d.ts | 6 + .../tests/screenshotStability.spec.ts | 26 +- 210 files changed, 8544 insertions(+), 1168 deletions(-) create mode 100644 src/vs/platform/agentHost/node/agentHostUtilityModelService.ts create mode 100644 src/vs/platform/agentHost/test/common/agentHostByokLm.test.ts create mode 100644 src/vs/platform/agentHost/test/node/__snapshots__/Copilot_SDK_-_managed_settings_bundled_managedSettings_schema_matches_the_snapshot.schema.json create mode 100644 src/vs/platform/agentHost/test/node/agentHostUtilityModelService.test.ts create mode 100644 src/vs/platform/agentHost/test/node/copilotSdkManagedSettings.integrationTest.ts create mode 100644 src/vs/platform/agentHost/test/node/providerIntegration/copilotByokSelectionIds.integrationTest.ts create mode 100644 src/vs/platform/remoteTunnel/common/tunnelServiceHeaders.ts create mode 100644 src/vs/platform/remoteTunnel/test/common/tunnelServiceHeaders.test.ts create mode 100644 src/vs/sessions/browser/parts/gridGap.ts create mode 100644 src/vs/workbench/api/test/browser/extHostUriOpener.test.ts create mode 100644 src/vs/workbench/contrib/chat/test/browser/attachments/chatImplicitContext.test.ts create mode 100644 src/vs/workbench/contrib/mcp/browser/mcpServerConfigurationFormAccessibility.ts diff --git a/build/lib/stylelint/vscode-known-variables.json b/build/lib/stylelint/vscode-known-variables.json index be2fdcb486f587..db5045d15043a2 100644 --- a/build/lib/stylelint/vscode-known-variables.json +++ b/build/lib/stylelint/vscode-known-variables.json @@ -1002,6 +1002,8 @@ "--agents-floating-panel-gap", "--agents-part-corner-radius", "--agents-part-frame-border-width", + "--agents-session-frame-inset", + "--agents-side-pane-frame-inset", "--agents-window-corner-radius", "--session-chat-base-height", "--editor-font-size", diff --git a/cli/CONTRIBUTING.md b/cli/CONTRIBUTING.md index d6b3696bcac33f..220827f056d3a4 100644 --- a/cli/CONTRIBUTING.md +++ b/cli/CONTRIBUTING.md @@ -31,3 +31,18 @@ an interactive prompt. Without `--tunnel`, `code agent` and `code agent host` still start or reuse a local agent host supervisor. + +## Tunnel request correlation + +Management requests and relay WebSocket handshakes carry the +`X-Tunnels-VSCode-Session-Id`, `X-Tunnels-VSCode-Client-Operation-Id`, and +`X-Tunnels-VSCode-Client-Request-Id` headers. Each request has a fresh request ID. +VS Code passes its telemetry session and operation IDs through +`VSCODE_TUNNEL_SESSION_ID` and `VSCODE_TUNNEL_OPERATION_ID` when launching the CLI. +These IDs are preserved verbatim; telemetry session IDs are not necessarily UUIDs. +Standalone commands use a process-local session ID and a new operation ID for each +management client. Correlation IDs are sent in service headers, not written to CLI logs. +Local CLI status, login, and service-management invocations retain correlation IDs but +do not emit host-operation telemetry. +Attaching to an already-running tunnel does not replace that process's correlation IDs; +its service requests retain the IDs of the original host process. diff --git a/cli/Cargo.lock b/cli/Cargo.lock index 6891fd4823017c..1adb1908c98dd9 100644 --- a/cli/Cargo.lock +++ b/cli/Cargo.lock @@ -3344,7 +3344,7 @@ dependencies = [ [[package]] name = "tunnels" version = "0.1.0" -source = "git+https://github.com/microsoft/dev-tunnels?rev=64048c1409ff56cb958b879de7ea069ec71edc8b#64048c1409ff56cb958b879de7ea069ec71edc8b" +source = "git+https://github.com/microsoft/dev-tunnels?rev=b73b0eb774b86a5b519c300f2f41c9072a66f663#b73b0eb774b86a5b519c300f2f41c9072a66f663" dependencies = [ "async-trait", "futures-util", diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 48c1fcb28694f2..f1b3e46118b9ac 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -33,7 +33,7 @@ jiff = { version = "0.2", default-features = false, features = ["std", "serde"] http = "1" gethostname = "0.4.3" libc = "0.2.144" -tunnels = { git = "https://github.com/microsoft/dev-tunnels", rev = "64048c1409ff56cb958b879de7ea069ec71edc8b", default-features = false, features = ["connections"] } +tunnels = { git = "https://github.com/microsoft/dev-tunnels", rev = "b73b0eb774b86a5b519c300f2f41c9072a66f663", default-features = false, features = ["connections"] } keyring = { version = "2.0.3", default-features = false, features = ["linux-secret-service-rt-tokio-crypto-openssl", "platform-windows", "platform-macos", "linux-keyutils"] } dialoguer = "0.10.4" hyper = { version = "1", features = ["server", "http1", "client"] } diff --git a/cli/src/tunnels/dev_tunnels.rs b/cli/src/tunnels/dev_tunnels.rs index 6c039124266fbc..645f5a395535d1 100644 --- a/cli/src/tunnels/dev_tunnels.rs +++ b/cli/src/tunnels/dev_tunnels.rs @@ -14,6 +14,7 @@ use crate::util::errors::{ use crate::util::input::prompt_placeholder; use futures::future::BoxFuture; use futures::{FutureExt, TryFutureExt}; +use http::header::{HeaderName, HeaderValue}; use http::StatusCode; use rand::prelude::IteratorRandom; use regex::Regex; @@ -30,12 +31,73 @@ use tunnels::contracts::{ TUNNEL_ACCESS_SCOPES_CONNECT, TUNNEL_PROTOCOL_AUTO, }; use tunnels::management::{ - new_tunnel_management, HttpError, TunnelLocator, TunnelManagementClient, TunnelRequestOptions, - NO_REQUEST_OPTIONS, + new_tunnel_management, HttpError, TunnelClientBuilder, TunnelLocator, TunnelManagementClient, + TunnelRequestOptions, NO_REQUEST_OPTIONS, }; static TUNNEL_COUNT_LIMIT_NAME: &str = "TunnelsPerUserPerLocation"; static TUNNEL_PORT_PROTOCOL_CONFLICT_DETAIL: &str = "The tunnel port protocol cannot be changed."; +static TUNNEL_SESSION_ID: LazyLock = LazyLock::new(uuid::Uuid::new_v4); + +fn tunnel_correlation_id( + log: &log::Logger, + name: &str, + value: Result, + fallback: uuid::Uuid, +) -> HeaderValue { + let fallback = HeaderValue::from_str(&fallback.to_string()).unwrap(); + match value { + Ok(value) if !value.is_empty() => match HeaderValue::from_str(&value) { + Ok(id) => id, + Err(error) => { + warning!(log, "Invalid {}: {}", name, error); + fallback + } + }, + Ok(_) => { + warning!(log, "Invalid {}: empty correlation ID", name); + fallback + } + Err(std::env::VarError::NotPresent) => fallback, + Err(error) => { + warning!(log, "Invalid {}: {}", name, error); + fallback + } + } +} + +fn tunnel_management_client(log: &log::Logger) -> TunnelClientBuilder { + let session_id = tunnel_correlation_id( + log, + "VSCODE_TUNNEL_SESSION_ID", + std::env::var("VSCODE_TUNNEL_SESSION_ID"), + *TUNNEL_SESSION_ID, + ); + let operation_id = tunnel_correlation_id( + log, + "VSCODE_TUNNEL_OPERATION_ID", + std::env::var("VSCODE_TUNNEL_OPERATION_ID"), + uuid::Uuid::new_v4(), + ); + let mut client = new_tunnel_management(&TUNNEL_SERVICE_USER_AGENT); + client.additional_headers(vec![ + ( + HeaderName::from_static("x-tunnels-vscode-session-id"), + session_id, + ), + ( + HeaderName::from_static("x-tunnels-vscode-client-operation-id"), + operation_id, + ), + ]); + client.additional_headers_provider(|| { + vec![( + HeaderName::from_static("x-tunnels-vscode-client-request-id"), + HeaderValue::from_str(&uuid::Uuid::new_v4().to_string()).unwrap(), + )] + }); + client +} #[allow(dead_code)] mod tunnel_flags { @@ -329,7 +391,7 @@ impl DevTunnels { auth: auth::Auth, paths: &LauncherPaths, ) -> DevTunnels { - let mut client = new_tunnel_management(&TUNNEL_SERVICE_USER_AGENT); + let mut client = tunnel_management_client(log); client.authorization_provider(auth.clone()); DevTunnels { @@ -347,7 +409,7 @@ impl DevTunnels { auth: auth::Auth, paths: &LauncherPaths, ) -> DevTunnels { - let mut client = new_tunnel_management(&TUNNEL_SERVICE_USER_AGENT); + let mut client = tunnel_management_client(log); client.authorization_provider(auth.clone()); DevTunnels { @@ -1328,6 +1390,59 @@ fn tunnel_has_host_connection(tunnel: &Tunnel) -> bool { mod test { use super::*; + #[test] + fn test_tunnel_correlation_preserves_telemetry_session_id() { + let session_id = "7c86e247-3438-4f26-959d-061b3299f4de1791567000000"; + assert_eq!( + tunnel_correlation_id( + &log::Logger::test(), + "VSCODE_TUNNEL_SESSION_ID", + Ok(session_id.to_string()), + uuid::Uuid::new_v4(), + ), + HeaderValue::from_static(session_id), + ); + } + + #[test] + fn test_tunnel_correlation_rejects_invalid_header_values() { + let fallback = uuid::Uuid::new_v4(); + for value in ["", "session\r\ninjected-header: value"] { + assert_eq!( + tunnel_correlation_id( + &log::Logger::test(), + "VSCODE_TUNNEL_SESSION_ID", + Ok(value.to_string()), + fallback, + ), + HeaderValue::from_str(&fallback.to_string()).unwrap(), + ); + } + } + + #[test] + fn test_tunnel_request_correlation() { + let client: TunnelManagementClient = tunnel_management_client(&log::Logger::test()).into(); + let first = client.request_headers(); + let second = client.request_headers(); + assert_eq!( + first + .iter() + .map(|(name, _)| name.as_str()) + .collect::>(), + vec![ + "x-tunnels-vscode-session-id", + "x-tunnels-vscode-client-operation-id", + "x-tunnels-vscode-client-request-id", + ] + ); + for (_, value) in &first { + assert!(uuid::Uuid::parse_str(value.to_str().unwrap()).is_ok()); + } + assert_eq!(&first[..2], &second[..2]); + assert_ne!(first[2].1, second[2].1); + } + #[test] fn test_clean_hostname_for_tunnel() { assert_eq!( diff --git a/extensions/markdown-language-features/CONTRIBUTING.md b/extensions/markdown-language-features/CONTRIBUTING.md index d6393223ef0671..1b62674b50af1f 100644 --- a/extensions/markdown-language-features/CONTRIBUTING.md +++ b/extensions/markdown-language-features/CONTRIBUTING.md @@ -80,12 +80,23 @@ Build outputs are written to `out/` (desktop), `dist/` (web), and `notebook-out/ Markdown editor commands and their default keybindings are defined in `vscode-packages/vscode-team-tools/packages/markdown-editor/src/editorCommands.ts`. Do not manually edit entries marked with `"$generated": true` in this extension's `package.json` or their titles in `package.nls.json`: `npm run build-markdown-editor` and `npm run watch-markdown-editor` regenerate them while preserving manual entries. Run `npm run check-markdown-editor-package-json` to verify that the checked-in manifests are current without modifying them. +### Active block highlighting + +`markdown.editor.highlightActiveBlock` controls the rich editor's active-block +background and glow, and defaults to enabled. Resource-specific changes are sent +to the renderer without reloading the document or changing source-marker visibility. +The webview reserves symmetric scrollbar gutters so the document remains centered. + ### Outline and breadcrumbs The rich editor opts into `customTextEditorNavigation`. The workbench reuses the document's symbol providers for Outline and breadcrumbs; the extension reports each panel's source selection and routes reveal and view-state requests through the Markdown editor RPC bridge. Offset mapping uses the renderer's synchronized text, including when its line endings differ from the document. Keep navigation independent of editing mode. Breadcrumb previews must preserve focus and selection, canceled previews must not override newer navigation, and opening a symbol to the side must retain the custom editor type. Navigation regression coverage lives in `src/test/markdownEditorNavigation.test.ts` and `src/test/markdownEditorBridge.test.ts`, with workbench coverage in the custom-editor navigation and document-symbol Outline suites. +Chat also consumes this per-panel navigation selection as implicit context, including in the Agents window. It attaches a range in the underlying Markdown document, not rendered HTML, and respects the existing implicit-context settings and ignored-file checks. A collapsed or cleared selection falls back to the document URI. No separate Markdown chat bridge is needed. Workbench regression coverage lives in `src/vs/workbench/contrib/chat/test/browser/attachments/chatImplicitContext.test.ts`. + +Selection, document, and webview changes share a 500 ms debounce before refreshing chat context, matching code-editor behavior. Navigation availability changes refresh immediately, and registering a chat widget refreshes existing widgets as well so a superseded asynchronous refresh cannot leave them stale. + ### Running tests You can run the VS Code extension tests by running the `Markdown Extension Tests` target in VS Code. This will run the tests under `./src/test` diff --git a/extensions/markdown-language-features/markdown-editor-src/editor.ts b/extensions/markdown-language-features/markdown-editor-src/editor.ts index 689233ef2069b4..ba6e461568bacd 100644 --- a/extensions/markdown-language-features/markdown-editor-src/editor.ts +++ b/extensions/markdown-language-features/markdown-editor-src/editor.ts @@ -47,6 +47,7 @@ interface InitialState { /** Identifies the authoritative text baseline against which local edits are computed. */ readonly editEpoch: number; readonly readonly: boolean; + readonly highlightActiveBlock: boolean; readonly richLinksEnabled: boolean; readonly linkPresentationRules: readonly { id: string; source: string; flags: string; kind: LinkPresentationKind }[]; } @@ -271,11 +272,14 @@ class Editor extends Disposable { highlightThemeChanged: () => { this.#syntaxHighlighter.themeChanged(); }, + configurationChanged: ({ highlightActiveBlock }) => { + this.#view?.highlightActiveBlock.set(highlightActiveBlock, undefined); + }, richLinkPresentations: ({ presentations }) => { this.#linkPresentationProvider?.updatePresentations(presentations); }, })); - this.#createView(host, initialState.content); + this.#createView(host, initialState.content, initialState.highlightActiveBlock); this.#send('ready', this.#host.ready({ documentVersion: initialState.documentVersion, editEpoch: this.#editEpoch, @@ -355,7 +359,7 @@ class Editor extends Disposable { this.#connection.close(); } - #createView(host: HTMLElement, content: string): void { + #createView(host: HTMLElement, content: string, highlightActiveBlock: boolean): void { const model = this.model; const scriptNonce = document.querySelector('meta[name="vscode-markdown-editor-script-nonce"]')?.content; const iframeBootstrapUrl = new URL(location.href); @@ -387,6 +391,7 @@ class Editor extends Disposable { const view = this._register(new EditorView(model, { classNames: ['md-theme-vscode-default'], + highlightActiveBlock, presentation: model.readonlyMode.get() ? 'reading' : 'editing', syntaxHighlighter: this.#syntaxHighlighter, linkPresentationProvider: this.#linkPresentationProvider, @@ -703,6 +708,7 @@ function isInitialState(value: unknown): value is InitialState { && Number.isInteger(candidate.editEpoch) && candidate.editEpoch >= 0 && typeof candidate.readonly === 'boolean' + && typeof candidate.highlightActiveBlock === 'boolean' && typeof candidate.richLinksEnabled === 'boolean' && Array.isArray(candidate.linkPresentationRules); } diff --git a/extensions/markdown-language-features/markdown-editor-src/imagePasteController.ts b/extensions/markdown-language-features/markdown-editor-src/imagePasteController.ts index f2f245fa6a8f97..9cad5a79864e1d 100644 --- a/extensions/markdown-language-features/markdown-editor-src/imagePasteController.ts +++ b/extensions/markdown-language-features/markdown-editor-src/imagePasteController.ts @@ -45,7 +45,7 @@ export class ImagePasteController extends Disposable implements IClipboardStrate throw new Error('The clipboard formats are not available to this editor. Try copying the image itself instead of its file.'); } return { text, images }; - })); + }, plainText)); this.#status.className = 'md-language-status'; this.#status.setAttribute('role', 'status'); this.#status.hidden = true; @@ -78,7 +78,7 @@ export class ImagePasteController extends Disposable implements IClipboardStrate }; } - async #paste(context: IClipboardContext, read: () => Promise<{ readonly images: readonly File[]; readonly text: string }>): Promise { + async #paste(context: IClipboardContext, read: () => Promise<{ readonly images: readonly File[]; readonly text: string }>, plainText = false): Promise { const request = ++this.#request; const source = this.editor.sourceText.get(); const selection = this.editor.selection.get(); @@ -108,7 +108,7 @@ export class ImagePasteController extends Disposable implements IClipboardStrate } this.#status.hidden = true; if (!payload.length) { - if (text) { context.insertText(text); } + if (text) { context.insertText(text, plainText); } return; } this.#status.textContent = 'Pasting image...'; diff --git a/extensions/markdown-language-features/markdown-editor-src/markdownEditor.css b/extensions/markdown-language-features/markdown-editor-src/markdownEditor.css index b5ea146cdf09e3..be096fa452eb7d 100644 --- a/extensions/markdown-language-features/markdown-editor-src/markdownEditor.css +++ b/extensions/markdown-language-features/markdown-editor-src/markdownEditor.css @@ -18,7 +18,10 @@ body { #editor { height: 100%; width: 100%; - overflow: scroll; + overflow: auto; + scrollbar-gutter: stable both-edges; + scrollbar-width: thin; + scrollbar-color: var(--vscode-scrollbarSlider-background) transparent; } .md-comment-widget { diff --git a/extensions/markdown-language-features/package-lock.json b/extensions/markdown-language-features/package-lock.json index 0db43083d777d0..85a5addf2ee610 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-114", + "@vscode/markdown-editor": "0.0.2-119", "@vscode/observables": "^0.1.1-0", "dompurify": "^3.4.10", "highlight.js": "^11.8.0", @@ -672,9 +672,9 @@ "integrity": "sha512-ukOMWnCg1tCvT7WnDfsUKQOFDQGsyR5tNgRpwmqi+5/vzU3ghdDXzvIM4IOPdSb3OeSsBNvmSL8nxIVOqi2WXA==" }, "node_modules/@vscode/markdown-editor": { - "version": "0.0.2-114", - "resolved": "https://registry.npmjs.org/@vscode/markdown-editor/-/markdown-editor-0.0.2-114.tgz", - "integrity": "sha512-gnktzReJIybeYYOA8mYYdXEN0CHfWylCNBeqkgV/k06hwSPibe8eaB2UJ/w35NpNMVXgWKZzknZgVQoVIWiVkg==", + "version": "0.0.2-119", + "resolved": "https://registry.npmjs.org/@vscode/markdown-editor/-/markdown-editor-0.0.2-119.tgz", + "integrity": "sha512-5inGrhay4L+Ljy+lrnlRxWmX2OaD5HfwRChO0XyYYoyNujHRqDlb7JJ63eXrjefkAy1ncYlVMbA0e000rvt37Q==", "license": "MIT", "dependencies": { "@vscode/codicons": "0.0.46-36", @@ -689,7 +689,8 @@ "micromark-extension-gfm-strikethrough": "^2.1.0", "micromark-extension-gfm-table": "^2.1.1", "micromark-extension-gfm-task-list-item": "^2.1.0", - "micromark-extension-math": "^3.1.0" + "micromark-extension-math": "^3.1.0", + "micromark-util-decode-string": "^2.0.1" } }, "node_modules/@vscode/markdown-editor/node_modules/entities": { @@ -2127,6 +2128,28 @@ "micromark-util-symbol": "^2.0.0" } }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, "node_modules/micromark-util-encode": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", diff --git a/extensions/markdown-language-features/package.json b/extensions/markdown-language-features/package.json index 9a933946e5f35b..2ee1199373634c 100644 --- a/extensions/markdown-language-features/package.json +++ b/extensions/markdown-language-features/package.json @@ -1471,6 +1471,12 @@ "onExP" ] }, + "markdown.editor.highlightActiveBlock": { + "type": "boolean", + "default": true, + "description": "%configuration.markdown.editor.highlightActiveBlock%", + "scope": "resource" + }, "markdown.editor.quickSuggestions": { "type": "boolean", "default": false, @@ -1985,7 +1991,7 @@ }, "dependencies": { "@vscode/extension-telemetry": "^0.9.8", - "@vscode/markdown-editor": "0.0.2-114", + "@vscode/markdown-editor": "0.0.2-119", "@vscode/observables": "^0.1.1-0", "dompurify": "^3.4.10", "highlight.js": "^11.8.0", diff --git a/extensions/markdown-language-features/package.nls.json b/extensions/markdown-language-features/package.nls.json index 242e45996504ea..5509452fe409b3 100644 --- a/extensions/markdown-language-features/package.nls.json +++ b/extensions/markdown-language-features/package.nls.json @@ -5,6 +5,7 @@ "markdown.editor.triggerSuggest": "Trigger Suggest", "configuration.preview": "Preview", "configuration.editor": "Language Features", + "configuration.markdown.editor.highlightActiveBlock": "Controls whether the active block in the rich Markdown editor is highlighted. Does not affect source markers or editing behavior.", "configuration.markdown.experimental.richLinks.enabled": "Controls whether supported links in the Markdown editor are rendered as rich links with live metadata. Enabling this may make authenticated requests to services such as GitHub.", "configuration.markdown.editor.quickSuggestions": "Controls whether typing automatically requests suggestions in the rich Markdown editor. Also requires `#editor.quickSuggestions#` to be enabled for Markdown. Does not affect manually invoking Trigger Suggest or suggestions in the Markdown code editor.", "configuration.validation": "Validation", diff --git a/extensions/markdown-language-features/src/preview/markdownEditorProtocol.ts b/extensions/markdown-language-features/src/preview/markdownEditorProtocol.ts index 385ad623e3cfab..b143c35f6cdc0b 100644 --- a/extensions/markdown-language-features/src/preview/markdownEditorProtocol.ts +++ b/extensions/markdown-language-features/src/preview/markdownEditorProtocol.ts @@ -8,6 +8,7 @@ import * as z from 'zod/mini'; const offset = z.int().check(z.nonnegative()); const range = z.object({ start: offset, endExclusive: offset }); +const editorConfiguration = z.object({ highlightActiveBlock: z.boolean() }); const selection = z.object({ anchor: offset, active: offset }); export const navigationState = z.object({ editEpoch: offset, @@ -118,6 +119,7 @@ export const markdownEditorHost = defineInterface({ id: 'markdown.editor.host' } }); export const markdownEditorRenderer = defineInterface({ id: 'markdown.editor.renderer' }, { + configurationChanged: notificationType(editorConfiguration), diagnosticsChanged: requestType(z.object({}), z.void()), update: requestType(z.object({ content: z.string(), editEpoch: offset }), z.void()), codeBlockEditorProviders: notificationType(z.object({ codeBlockEditorProviders: z.readonly(z.array(codeBlockEditorProvider)) })), @@ -146,6 +148,7 @@ export const markdownEditorRenderer = defineInterface({ id: 'markdown.editor.ren export type MarkdownEditorHost = InterfaceClient; export type MarkdownEditorRenderer = InterfaceClient; +export type MarkdownEditorConfiguration = z.infer; export type CodeBlockEditorProviderDefinition = z.infer; export type ResolvedCodeBlockEditor = z.infer; export type HighlightResult = z.infer; diff --git a/extensions/markdown-language-features/src/preview/markdownEditorProvider.ts b/extensions/markdown-language-features/src/preview/markdownEditorProvider.ts index 8c687957d95243..30e207b5bf6b64 100644 --- a/extensions/markdown-language-features/src/preview/markdownEditorProvider.ts +++ b/extensions/markdown-language-features/src/preview/markdownEditorProvider.ts @@ -21,7 +21,7 @@ import type { } from '../markdownExtensions'; import { generateUuid } from '../util/uuid'; import { MarkdownEditorRichLinkController } from './markdownEditorRichLinks'; -import { markdownEditorHost, markdownEditorRenderer, type CodeBlockEditorProviderDefinition, type ResolvedCodeBlockEditor } from './markdownEditorProtocol'; +import { markdownEditorHost, markdownEditorRenderer, type CodeBlockEditorProviderDefinition, type MarkdownEditorConfiguration, type ResolvedCodeBlockEditor } from './markdownEditorProtocol'; import { createMarkdownEditorRpcConnection, MarkdownEditorRpcTransport } from './markdownEditorRpc'; import { MarkdownEditorRename } from './markdownEditorRename'; import { MarkdownEditorLanguageFeatures } from './markdownEditorLanguageFeatures'; @@ -445,6 +445,9 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT this.#logger, params => editorWebview.renderer.richLinkPresentations(params), ); + const postConfiguration = (): void => { + editorWebview.report('Update configuration', editorWebview.renderer.configurationChanged(this.#getEditorConfiguration(document.uri))); + }; const postCodeBlockEditorProviders = (): void => { if (webviewReady && codeBlockEditorProviders) { editorWebview.renderer.codeBlockEditorProviders({ codeBlockEditorProviders }); @@ -505,6 +508,7 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT } signal.throwIfAborted(); editorWebview.publishReady(); + postConfiguration(); postCodeBlockEditorProviders(); diagnosticsChanged(); }, @@ -705,10 +709,13 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT const onDidRenameFiles = vscode.workspace.onDidRenameFiles(event => invalidateResourceCache( event.files.flatMap(file => [file.oldUri, file.newUri]))); const onDidChangeViewState = webviewPanel.onDidChangeViewState(() => this.#updateEditorFocusContext()); - const onDidChangeRichLinksConfiguration = vscode.workspace.onDidChangeConfiguration(event => { + const onDidChangeConfiguration = vscode.workspace.onDidChangeConfiguration(event => { if (event.affectsConfiguration('markdown.experimental.richLinks.enabled', document.uri)) { reloadWebview(); } + if (event.affectsConfiguration('markdown.editor.highlightActiveBlock', document.uri) && editorWebview.ready) { + postConfiguration(); + } }); const onDidChangeLinkPresentationRules = vscode.window.onDidChangeLinkPresentationRules(() => { reloadWebview(); @@ -743,7 +750,7 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT onDidDeleteFiles.dispose(); onDidRenameFiles.dispose(); onDidChangeViewState.dispose(); - onDidChangeRichLinksConfiguration.dispose(); + onDidChangeConfiguration.dispose(); onDidChangeLinkPresentationRules.dispose(); richLinks.dispose(); }); @@ -1120,6 +1127,7 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT const baseUri = webview.asWebviewUri(document.uri); const nonce = getNonce(); const initialState = encodeWebviewInitialState({ + ...this.#getEditorConfiguration(document.uri), content: document.getText(), documentVersion: document.version, editEpoch, @@ -1155,6 +1163,12 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT `; } + + #getEditorConfiguration(resource: vscode.Uri): MarkdownEditorConfiguration { + return { + highlightActiveBlock: vscode.workspace.getConfiguration('markdown.editor', resource).get('highlightActiveBlock', true), + }; + } } function codeBlockEditorDefinitionsEqual( diff --git a/extensions/markdown-language-features/src/preview/webviewInitialState.ts b/extensions/markdown-language-features/src/preview/webviewInitialState.ts index 39c5594a1f84e4..ad66d2ad30840b 100644 --- a/extensions/markdown-language-features/src/preview/webviewInitialState.ts +++ b/extensions/markdown-language-features/src/preview/webviewInitialState.ts @@ -3,7 +3,9 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -export interface MarkdownEditorInitialState { +import type { MarkdownEditorConfiguration } from './markdownEditorProtocol'; + +export interface MarkdownEditorInitialState extends MarkdownEditorConfiguration { readonly content: string; readonly documentVersion: number; /** Identifies the authoritative text baseline shared by the host and webview. */ diff --git a/extensions/markdown-language-features/src/test/markdownEditorBridge.test.ts b/extensions/markdown-language-features/src/test/markdownEditorBridge.test.ts index 2ef5bfc40cad83..9cb378681fb5d8 100644 --- a/extensions/markdown-language-features/src/test/markdownEditorBridge.test.ts +++ b/extensions/markdown-language-features/src/test/markdownEditorBridge.test.ts @@ -52,6 +52,7 @@ suite('Markdown editor bridge', () => { restoreNavigationState: () => { }, command: () => { }, highlightThemeChanged: () => { }, + configurationChanged: () => { }, richLinkPresentations: () => { }, }); const host = renderer.get(markdownEditorHost); diff --git a/extensions/markdown-language-features/src/test/markdownEditorProvider.test.ts b/extensions/markdown-language-features/src/test/markdownEditorProvider.test.ts index 75711f4177c4d0..988fc0f155b7bb 100644 --- a/extensions/markdown-language-features/src/test/markdownEditorProvider.test.ts +++ b/extensions/markdown-language-features/src/test/markdownEditorProvider.test.ts @@ -240,6 +240,7 @@ suite('Markdown editor initial state', () => { editEpoch: 3, readonly: true, richLinksEnabled: true, + highlightActiveBlock: false, linkPresentationRules: [], }; const encoded = encodeWebviewInitialState(state); diff --git a/extensions/markdown-language-features/src/test/markdownEditorRpc.test.ts b/extensions/markdown-language-features/src/test/markdownEditorRpc.test.ts index 1b5009ad67bd26..24d67b59d6e116 100644 --- a/extensions/markdown-language-features/src/test/markdownEditorRpc.test.ts +++ b/extensions/markdown-language-features/src/test/markdownEditorRpc.test.ts @@ -56,6 +56,7 @@ suite('Markdown editor RPC', () => { }); host.get(markdownEditorRenderer); renderer.register(markdownEditorRenderer, { + configurationChanged: () => { }, diagnosticsChanged: () => { }, update: () => { }, codeBlockEditorProviders: () => { }, @@ -119,6 +120,21 @@ suite('Markdown editor RPC', () => { assert.deepStrictEqual(rendererInbox.messages, [], 'even failed notifications have no response'); }); + test('updates highlight configuration without reloading or acknowledging the renderer', async () => { + const seen: boolean[] = []; + const { host, rendererInbox, hostInbox, errors } = pair({}, { + configurationChanged: ({ highlightActiveBlock }) => { seen.push(highlightActiveBlock); }, + }); + const renderer = host.get(markdownEditorRenderer); + renderer.configurationChanged({ highlightActiveBlock: false }); + renderer.configurationChanged({ highlightActiveBlock: true }); + await host.channel.sendNotification('markdown.editor.renderer::configurationChanged', { highlightActiveBlock: 'false' }); + assert.deepStrictEqual(seen, [false, true]); + assert.strictEqual(rendererInbox.messages.length, 3); + assert.deepStrictEqual(hostInbox.messages, []); + assert.deepStrictEqual(errors, []); + }); + test('reports notification delivery failures and sends after close without unhandled rejections', async () => { const inbox = new Inbox(); const errors: unknown[] = []; diff --git a/package-lock.json b/package-lock.json index e3ac3babd978b1..5299353e945c27 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,9 +16,9 @@ "@hpke/core": "^1.9.0", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", - "@microsoft/dev-tunnels-connections": "^1.3.41", - "@microsoft/dev-tunnels-contracts": "^1.3.41", - "@microsoft/dev-tunnels-management": "^1.3.41", + "@microsoft/dev-tunnels-connections": "^1.3.61", + "@microsoft/dev-tunnels-contracts": "^1.3.61", + "@microsoft/dev-tunnels-management": "^1.3.61", "@microsoft/dev-tunnels-ssh": "^3.12.22", "@microsoft/dev-tunnels-ssh-tcp": "^3.12.22", "@parcel/watcher": "^2.5.6", @@ -2205,13 +2205,13 @@ "integrity": "sha512-PoHEgsnmcqruLNHZ/amACqdJ6YYQpED0KSRe6J7gIJTtpZC1FfFU9b1fmDKDKtFoUSrPzEh1qzO3kmRZP0betg==" }, "node_modules/@microsoft/dev-tunnels-connections": { - "version": "1.3.60", - "resolved": "https://registry.npmjs.org/@microsoft/dev-tunnels-connections/-/dev-tunnels-connections-1.3.60.tgz", - "integrity": "sha512-UrFueOBz856tJix0b68weAkea65cLaG5IbDlQtTet163TcdJwTGqgdAv/kDcMn3xAnSB51FJh4Wk+50cs9SBEw==", + "version": "1.3.61", + "resolved": "https://registry.npmjs.org/@microsoft/dev-tunnels-connections/-/dev-tunnels-connections-1.3.61.tgz", + "integrity": "sha512-Y1/K9IciafblRMS+XDY8uXoA/12bAxF/40Fiw2hd4QF1fi0Jlze4s0UARP/bHppJ4cqD5tqiV97StRJ+xnGvjw==", "license": "MIT", "dependencies": { - "@microsoft/dev-tunnels-contracts": "1.3.60", - "@microsoft/dev-tunnels-management": "1.3.60", + "@microsoft/dev-tunnels-contracts": "1.3.61", + "@microsoft/dev-tunnels-management": "1.3.61", "await-semaphore": "^0.1.3", "buffer": "^5.2.1", "debug": "^4.1.1", @@ -2235,9 +2235,9 @@ } }, "node_modules/@microsoft/dev-tunnels-contracts": { - "version": "1.3.60", - "resolved": "https://registry.npmjs.org/@microsoft/dev-tunnels-contracts/-/dev-tunnels-contracts-1.3.60.tgz", - "integrity": "sha512-ElmrEzJHk1NrmFq7IAIH19hbZXBeTsLzh6NGf8AkFPeJCL2M8lYh6y3WiaxJKIVJOF/Qct/Avx4I24FFCKWBWw==", + "version": "1.3.61", + "resolved": "https://registry.npmjs.org/@microsoft/dev-tunnels-contracts/-/dev-tunnels-contracts-1.3.61.tgz", + "integrity": "sha512-WAqWIl9TG9jrZQnWdPsWHoG8m4C/E4xkDqiiRPilvdr+W0oNVZwnxteio0KDcysPSFOk/Iyu0KpMakUkvFdeDA==", "license": "MIT", "dependencies": { "buffer": "^5.2.1", @@ -2255,12 +2255,12 @@ } }, "node_modules/@microsoft/dev-tunnels-management": { - "version": "1.3.60", - "resolved": "https://registry.npmjs.org/@microsoft/dev-tunnels-management/-/dev-tunnels-management-1.3.60.tgz", - "integrity": "sha512-KDX72VyTB1Nf0fNFD53EqkJe3wmsFyvq1QmVlSsZP4UKWTOPcXZmA2jBuRTE/9JC1B50tgy7fLTpwR7aMtFuwQ==", + "version": "1.3.61", + "resolved": "https://registry.npmjs.org/@microsoft/dev-tunnels-management/-/dev-tunnels-management-1.3.61.tgz", + "integrity": "sha512-Yy3f1SBxsUo4fnTiUIFEmvKoWqP4a8Wqbnvr5oec/R+KdZdINQJHVWEUIYoWg0NHbX074om16O3iamldWnBV4g==", "license": "MIT", "dependencies": { - "@microsoft/dev-tunnels-contracts": "1.3.60", + "@microsoft/dev-tunnels-contracts": "1.3.61", "axios": "^1.8.4", "buffer": "^5.2.1", "debug": "^4.1.1", @@ -6176,7 +6176,8 @@ "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k= sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" }, "node_modules/atob": { "version": "2.1.2", @@ -7379,6 +7380,7 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" }, @@ -7389,7 +7391,8 @@ "node_modules/combined-stream/node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk= sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", "engines": { "node": ">=0.4.0" } @@ -10066,9 +10069,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", - "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.1.tgz", + "integrity": "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==", "funding": [ { "type": "individual", diff --git a/package.json b/package.json index 72bca2f2669f84..12e8a7fcfa704f 100644 --- a/package.json +++ b/package.json @@ -106,9 +106,9 @@ "@hpke/core": "^1.9.0", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", - "@microsoft/dev-tunnels-connections": "^1.3.41", - "@microsoft/dev-tunnels-contracts": "^1.3.41", - "@microsoft/dev-tunnels-management": "^1.3.41", + "@microsoft/dev-tunnels-connections": "^1.3.61", + "@microsoft/dev-tunnels-contracts": "^1.3.61", + "@microsoft/dev-tunnels-management": "^1.3.61", "@microsoft/dev-tunnels-ssh": "^3.12.22", "@microsoft/dev-tunnels-ssh-tcp": "^3.12.22", "@parcel/watcher": "^2.5.6", diff --git a/src/vs/editor/browser/services/openerService.ts b/src/vs/editor/browser/services/openerService.ts index 6672d9647b851c..1eb3d5c960a369 100644 --- a/src/vs/editor/browser/services/openerService.ts +++ b/src/vs/editor/browser/services/openerService.ts @@ -15,7 +15,7 @@ import { normalizePath } from '../../../base/common/resources.js'; import { URI } from '../../../base/common/uri.js'; import { ICodeEditorService } from './codeEditorService.js'; import { ICommandService } from '../../../platform/commands/common/commands.js'; -import { EditorOpenSource } from '../../../platform/editor/common/editor.js'; +import { EditorOpenSource, ViewColumn } from '../../../platform/editor/common/editor.js'; import { defaultExternalUriOpenerId, extractSelection, IExternalOpener, IExternalUriResolver, IOpener, IOpenerService, IResolvedExternalUri, IValidator, OpenOptions, ResolveExternalUriOptions } from '../../../platform/opener/common/opener.js'; interface IExternalUriOpenTarget { @@ -276,6 +276,7 @@ export class OpenerService implements IOpenerService { const didOpen = await opener.openExternal(target.href, { sourceUri: target.sourceUri, preferredOpenerId, + viewColumn: options?.openToSide ? ViewColumn.Beside : undefined, }, CancellationToken.None); if (didOpen) { return true; diff --git a/src/vs/editor/common/config/editorOptions.ts b/src/vs/editor/common/config/editorOptions.ts index 3129b83b6672e6..c89cdb4bee21fa 100644 --- a/src/vs/editor/common/config/editorOptions.ts +++ b/src/vs/editor/common/config/editorOptions.ts @@ -6436,7 +6436,7 @@ export const EditorOptions = { { enumDescriptions: [ nls.localize('fullwidthCharacterWidth.font', "Render full-width characters using the width defined by the font."), - nls.localize('fullwidthCharacterWidth.twoCells', "Render full-width characters centered in exactly two character cells. Only applies to monospace fonts. Does not apply to multi codepoint full-width characters or to GPU rendering. This has a performance impact on line rendering."), + nls.localize('fullwidthCharacterWidth.twoCells', "Render full-width characters centered in exactly two character cells. Only applies to monospace fonts when font ligatures are disabled. Does not apply to multi codepoint full-width characters or to GPU rendering. This has a performance impact on line rendering."), ], description: nls.localize('fullwidthCharacterWidth', "Controls the width used to render full-width characters.") } diff --git a/src/vs/editor/common/textModelEditSource.ts b/src/vs/editor/common/textModelEditSource.ts index 8c7ee38c576c50..b0d2f332788467 100644 --- a/src/vs/editor/common/textModelEditSource.ts +++ b/src/vs/editor/common/textModelEditSource.ts @@ -111,6 +111,7 @@ export const EditSources = { codeBlockSuggestionId: EditSuggestionId | undefined; origin?: 'agentHost'; harness?: string; + agentSessionId?: string; chatSessionId?: string; }) { return createEditSource({ @@ -123,6 +124,7 @@ export const EditSources = { $origin: data.origin, $$languageId: data.languageId, $$sessionId: data.sessionId, + ...(data.agentSessionId !== undefined ? { $$agentSessionId: data.agentSessionId } : {}), ...(data.chatSessionId !== undefined ? { $$chatSessionId: data.chatSessionId } : {}), $$requestId: data.requestId, $$mode: data.mode, @@ -134,6 +136,7 @@ export const EditSources = { modelId: string | undefined; autoTier?: string; sessionId: string; + agentSessionId?: string; chatSessionId?: string; requestId: string; harness: string; @@ -142,6 +145,7 @@ export const EditSources = { modelId: data.modelId, autoTier: data.autoTier, sessionId: data.sessionId, + agentSessionId: data.agentSessionId, chatSessionId: data.chatSessionId, requestId: data.requestId, languageId: '', diff --git a/src/vs/editor/test/browser/services/openerService.test.ts b/src/vs/editor/test/browser/services/openerService.test.ts index f41628496d5dec..897a8fc34122bc 100644 --- a/src/vs/editor/test/browser/services/openerService.test.ts +++ b/src/vs/editor/test/browser/services/openerService.test.ts @@ -10,10 +10,10 @@ import { OpenerService } from '../../../browser/services/openerService.js'; import { TestCodeEditorService } from '../editorTestServices.js'; import { CommandsRegistry, ICommandService } from '../../../../platform/commands/common/commands.js'; import { NullCommandService } from '../../../../platform/commands/test/common/nullCommandService.js'; -import { ITextEditorOptions } from '../../../../platform/editor/common/editor.js'; +import { ITextEditorOptions, ViewColumn } from '../../../../platform/editor/common/editor.js'; import { matchesScheme, matchesSomeScheme } from '../../../../base/common/network.js'; import { TestThemeService } from '../../../../platform/theme/test/common/testThemeService.js'; -import { defaultExternalUriOpenerId } from '../../../../platform/opener/common/opener.js'; +import { defaultExternalUriOpenerId, IExternalOpener } from '../../../../platform/opener/common/opener.js'; suite('OpenerService', function () { const themeService = new TestThemeService(); @@ -197,6 +197,32 @@ suite('OpenerService', function () { }); }); + test('external URI opener contexts normalize placement without forwarding openToSide', async () => { + const openerService = new OpenerService(editorService, commandService); + const sourceUri = URI.parse('https://source.example.com'); + const contexts: Parameters[1][] = []; + store.add(openerService.registerExternalOpener({ + async openExternal(_href, context) { + contexts.push(context); + return true; + } + })); + + for (const options of [ + { openToSide: true }, + { openToSide: false }, + {} + ]) { + await openerService.open(sourceUri, { ...options, allowContributedOpeners: true }); + } + + assert.deepStrictEqual(contexts, [ + { sourceUri, preferredOpenerId: undefined, viewColumn: ViewColumn.Beside }, + { sourceUri, preferredOpenerId: undefined, viewColumn: undefined }, + { sourceUri, preferredOpenerId: undefined, viewColumn: undefined } + ]); + }); + test('external URI fallback validates the resolved URI', async function () { const openerService = new OpenerService(editorService, commandService); const sourceUri = URI.parse('https://source.example.com'); diff --git a/src/vs/platform/accessibility/browser/accessibleView.ts b/src/vs/platform/accessibility/browser/accessibleView.ts index df1307dca578e9..fd5c9d7d4ed547 100644 --- a/src/vs/platform/accessibility/browser/accessibleView.ts +++ b/src/vs/platform/accessibility/browser/accessibleView.ts @@ -22,6 +22,7 @@ export const enum AccessibleViewProviderId { PanelChat = 'panelChat', CustomizationMigrations = 'customizationMigrations', CustomizationDiscovery = 'customizationDiscovery', + McpServerConfiguration = 'mcpServerConfiguration', ChatTerminalOutput = 'chatTerminalOutput', ChatBackgroundShellOutput = 'chatBackgroundShellOutput', ChatThinking = 'chatThinking', diff --git a/src/vs/platform/accessibilitySignal/browser/accessibilitySignalService.ts b/src/vs/platform/accessibilitySignal/browser/accessibilitySignalService.ts index 3576d7b9ba90a8..51b5647bb90754 100644 --- a/src/vs/platform/accessibilitySignal/browser/accessibilitySignalService.ts +++ b/src/vs/platform/accessibilitySignal/browser/accessibilitySignalService.ts @@ -294,9 +294,12 @@ function doPlayAudio(url: string, volume: number, disposables: DisposableStore): disposables.add(addDisposableListener(audio, 'ended', () => { resolve(audio); })); - disposables.add(addDisposableListener(audio, 'error', (e) => { - // When the error event fires, ended might not be called - reject(e.error); + disposables.add(addDisposableListener(audio, 'error', () => { + // When the error event fires, ended might not be called. + // The media `error` event is a plain Event without an `error` + // property; the actual failure is described by `audio.error`. + const mediaError = audio.error; + reject(new Error(mediaError ? `Failed to play audio (code ${mediaError.code}): ${mediaError.message}` : 'Failed to play audio')); })); audio.play().catch(e => { // When play fails, the error event is not fired. diff --git a/src/vs/platform/actionWidget/browser/actionList.ts b/src/vs/platform/actionWidget/browser/actionList.ts index abf7ff1e8f88ae..18b94da4932738 100644 --- a/src/vs/platform/actionWidget/browser/actionList.ts +++ b/src/vs/platform/actionWidget/browser/actionList.ts @@ -895,6 +895,14 @@ export interface IActionListOptions { readonly preferredAnchorPosition?: AnchorPosition; /** Uses the available height instead of the usual fractional viewport cap. */ readonly useFullHeight?: boolean; + /** + * Measures everything the containing popup shows besides the list's rows, including the + * list's own filter, header and footer and anything the popup adds around them, such as + * tabs. Placement then fits the whole popup beside the anchor, while the viewport cap + * still applies to the list and its own chrome. Defaults to the list's own chrome plus + * the action widget's padding and border. + */ + readonly getPopupChromeHeight?: (listElement: HTMLElement) => number; } /** @@ -3387,6 +3395,7 @@ export class ActionList extends Disposable { private readonly _anchorPosition: AnchorPosition | undefined; private readonly _preferredAnchorPosition: AnchorPosition | undefined; private readonly _useFullHeight: boolean; + private readonly _getPopupChromeHeight: ((listElement: HTMLElement) => number) | undefined; private readonly _widgetClassName: string | undefined; get domNode(): HTMLElement { @@ -3448,6 +3457,7 @@ export class ActionList extends Disposable { this._anchorPosition = options?.anchorPosition; this._preferredAnchorPosition = options?.preferredAnchorPosition; this._useFullHeight = options?.useFullHeight ?? false; + this._getPopupChromeHeight = options?.getPopupChromeHeight; this._widgetClassName = options?.widgetClassName; this._widget = this._register(instantiationService.createInstance( @@ -3563,6 +3573,10 @@ export class ActionList extends Disposable { const footerHeight = this._widget.footerContainer ? 32 : 0; const headerHeight = this._widget.headerContainer && !this._widget.headerContainer.hidden ? this._widget.headerContainer.offsetHeight || 36 : 0; const chromeHeight = filterHeight + footerHeight + headerHeight; + // Space the popup takes up around the list and its own chrome, e.g. its padding, border + // or tabs. It counts toward fitting beside the anchor but not toward the viewport cap. + const popupChromeHeight = this._getPopupChromeHeight?.(this.domNode); + const outerChromeHeight = popupChromeHeight === undefined ? this.computeActionWidgetVerticalChromeHeight() : Math.max(0, popupChromeHeight - chromeHeight); const targetWindow = dom.getWindow(this.domNode); let availableHeight; @@ -3583,7 +3597,7 @@ export class ActionList extends Disposable { } else if (this._preferredAnchorPosition !== undefined) { const preferAbove = this._preferredAnchorPosition === AnchorPosition.ABOVE; const viewportMaxHeight = this._useFullHeight ? viewportHeight : Math.floor(viewportHeight * 0.6); - const desiredHeight = Math.min(chromeHeight + fullHeight, viewportMaxHeight) + this.computeActionWidgetVerticalChromeHeight(); + const desiredHeight = Math.min(chromeHeight + fullHeight, viewportMaxHeight) + outerChromeHeight; // Reflect above preferences so both directions use the same flip policy. const placement = layoutAlongAxis(viewportHeight - bottomGap, desiredHeight, { offset: preferAbove ? viewportHeight - bottomGap - anchorTopInViewport - anchorRect.height : anchorTopInViewport, @@ -3597,7 +3611,7 @@ export class ActionList extends Disposable { this._showAbove = chromeHeight + fullHeight > spaceBelow && spaceAbove > spaceBelow; } } - availableHeight = Math.max(0, (this._showAbove ? spaceAbove : spaceBelow) - this.computeActionWidgetVerticalChromeHeight()); + availableHeight = Math.max(0, (this._showAbove ? spaceAbove : spaceBelow) - outerChromeHeight); } else { const padding = 10; const windowHeight = this._layoutService.getContainer(targetWindow).clientHeight; diff --git a/src/vs/platform/actionWidget/browser/tabbedActionListWidget.css b/src/vs/platform/actionWidget/browser/tabbedActionListWidget.css index 7e79cf4cdd7294..f7f8d58c06eb3b 100644 --- a/src/vs/platform/actionWidget/browser/tabbedActionListWidget.css +++ b/src/vs/platform/actionWidget/browser/tabbedActionListWidget.css @@ -50,21 +50,6 @@ white-space: nowrap; } -.action-widget .tabbed-action-list-tab { - display: flex; - align-items: center; - min-width: 0; -} - -.action-widget .tabbed-action-list-tab-toggle:not([hidden]) { - display: flex; - align-items: center; - gap: var(--vscode-spacing-size40); - padding: 0 var(--vscode-spacing-size60); - font-size: var(--vscode-fontSize-label2); - color: var(--vscode-descriptionForeground); -} - .action-widget .tabbed-action-list-tabbar .tabbed-action-list-tabbar-action { display: flex; align-items: center; diff --git a/src/vs/platform/actionWidget/browser/tabbedActionListWidget.ts b/src/vs/platform/actionWidget/browser/tabbedActionListWidget.ts index 41cfc100566aaf..6f2c9610fa541a 100644 --- a/src/vs/platform/actionWidget/browser/tabbedActionListWidget.ts +++ b/src/vs/platform/actionWidget/browser/tabbedActionListWidget.ts @@ -9,7 +9,6 @@ import { ActionBar } from '../../../base/browser/ui/actionbar/actionbar.js'; import { Button } from '../../../base/browser/ui/button/button.js'; import { IListAccessibilityProvider } from '../../../base/browser/ui/list/listWidget.js'; import { Radio } from '../../../base/browser/ui/radio/radio.js'; -import { Switch } from '../../../base/browser/ui/toggle/switch.js'; import { DomScrollableElement } from '../../../base/browser/ui/scrollbar/scrollableElement.js'; import { toAction } from '../../../base/common/actions.js'; import { Codicon } from '../../../base/common/codicons.js'; @@ -82,11 +81,6 @@ function readTabBox(element: HTMLElement): ITabBox { export interface ITabbedActionListBuildResult { readonly items: readonly IActionListItem[]; readonly listOptions?: IActionListOptions; - /** - * For a sizing build, other layouts the sizing tab can show at rest, such as a - * mode it can be switched into. The popup is sized to the tallest of them. - */ - readonly alternateSizingItems?: readonly (readonly IActionListItem[])[]; } /** @@ -104,13 +98,6 @@ export interface ITabDescriptor { readonly tooltip?: string; /** Optional leading icon rendered before the label. */ readonly icon?: ThemeIcon; - /** A separate mode switch beside the active tab's button. */ - readonly toggle?: { - readonly label: string; - readonly ariaLabel: string; - readonly getState: () => { readonly checked: boolean; readonly enabled: boolean; readonly description?: string } | undefined; - readonly onChange: (checked: boolean) => void; - }; } /** @@ -237,6 +224,8 @@ export class TabbedActionListWidget extends Disposable { private _fixedListHeight: number | undefined; private _fixedPopupHeight: number | undefined; private _hasMeasuredSizingTab = false; + /** Side of the anchor the popup opened on, kept across tab swaps so it cannot jump sides. */ + private _anchorPosition: AnchorPosition | undefined; get isVisible(): boolean { return !!this._activePopup.value; @@ -271,6 +260,7 @@ export class TabbedActionListWidget extends Disposable { this._fixedListHeight = undefined; this._fixedPopupHeight = undefined; this._hasMeasuredSizingTab = false; + this._anchorPosition = undefined; } let activeTab = options.initialTab; @@ -372,38 +362,6 @@ export class TabbedActionListWidget extends Disposable { } })); - const activeIndex = options.tabs.findIndex(tab => tab.id === activeTab); - const toggleOptions = options.tabs[activeIndex]?.toggle; - let updateTabToggle: (() => void) | undefined; - if (toggleOptions) { - const button = radio.optionElements[activeIndex]; - const group = dom.$('.tabbed-action-list-tab'); - button.before(group); - group.appendChild(button); - const toggleContainer = dom.append(group, dom.$('.tabbed-action-list-tab-toggle')); - dom.append(toggleContainer, dom.$('span', undefined, toggleOptions.label)); - const toggle = renderDisposables.add(new Switch({ ariaLabel: toggleOptions.ariaLabel })); - toggleContainer.appendChild(toggle.domNode); - updateTabToggle = () => { - const state = toggleOptions.getState(); - toggleContainer.hidden = !state; - group.classList.toggle('has-toggle', !!state); - toggle.checked = !!state?.checked; - toggle.disabled = !state?.enabled; - toggle.setAriaLabel(toggleOptions.ariaLabel, state?.description ?? toggleOptions.ariaLabel); - if (state?.description) { - toggle.domNode.setAttribute('aria-description', state.description); - } else { - toggle.domNode.removeAttribute('aria-description'); - } - }; - updateTabToggle(); - renderDisposables.add(toggle.onChange(checked => { - toggleOptions.onChange(checked); - updateTabToggle?.(); - })); - } - for (const tabAction of options.tabBarActions ?? []) { const container = tabAction.alignEnd ? tabBar : tabStrip; const button = dom.append(container, dom.$('button.tabbed-action-list-tabbar-action')); @@ -434,6 +392,9 @@ export class TabbedActionListWidget extends Disposable { const { items, listOptions } = options.createActionList(activeTab); const emptyBody = items.length === 0 ? this._renderEmptyBody(bodyContent, options, activeTab, renderDisposables) : undefined; + const getExpandedPopupHeight = () => widget.offsetHeight - body.offsetHeight + bodyContent.offsetHeight; + // Padding belongs to the popup's chrome, not the list's content height. + const getChromeHeight = (listElement: HTMLElement) => getExpandedPopupHeight() - (emptyBody ? emptyBody.offsetHeight : dom.getContentHeight(listElement)); const list = renderDisposables.add(this._instantiationService.createInstance( ActionList, options.user, @@ -441,12 +402,16 @@ export class TabbedActionListWidget extends Disposable { items, options.delegate, options.accessibilityProvider, - listOptions, + { + ...listOptions, + anchorPosition: this._anchorPosition ?? listOptions?.anchorPosition, + // The tabs are part of the popup, so they have to fit beside the anchor too. + getPopupChromeHeight: getChromeHeight, + }, options.anchor, )); listRef = list; - const measureSizing = (sizing: ITabbedActionListBuildResult) => Math.max(...[sizing.items, ...sizing.alternateSizingItems ?? []] - .map(sizingItems => list.computeHeightForItems(sizingItems, sizing.listOptions?.collapsedByDefault, sizing.listOptions))) || undefined; + const measureSizing = (sizing: ITabbedActionListBuildResult) => list.computeHeightForItems(sizing.items, sizing.listOptions?.collapsedByDefault, sizing.listOptions) || undefined; this._focusItemAction = (itemId, actionId) => !body.inert && list.focusItemAction(itemId, actionId); this._focusItem = itemId => { if (body.inert) { @@ -473,7 +438,6 @@ export class TabbedActionListWidget extends Disposable { list.setHoverEnabled(false); } applyWidgetClassNames(); - updateTabToggle?.(); const sizing = sizingTab !== undefined ? options.createActionList(sizingTab, true) : undefined; @@ -545,18 +509,13 @@ export class TabbedActionListWidget extends Disposable { this._hasMeasuredSizingTab = true; } - const getExpandedPopupHeight = () => widget.offsetHeight - body.offsetHeight + bodyContent.offsetHeight; const layout = () => { if (this._detailsVisible) { return; } - const listBody = emptyBody ?? list.domNode; - // Padding belongs to the popup's chrome, not the fixed list content height. - const listHeight = emptyBody ? listBody.offsetHeight : dom.getContentHeight(listBody); - const chromeHeight = getExpandedPopupHeight() - listHeight; const contentHeight = this._fixedPopupHeight === undefined ? this._fixedListHeight - : Math.max(0, this._fixedPopupHeight - chromeHeight); + : Math.max(0, this._fixedPopupHeight - getChromeHeight(list.domNode)); const width = list.layout(0, contentHeight); widget.style.width = `${options.width ?? width}px`; if (emptyBody && this._fixedListHeight !== undefined) { @@ -567,6 +526,7 @@ export class TabbedActionListWidget extends Disposable { } }; layout(); + this._anchorPosition ??= list.anchorPosition; const bodyAnimation = renderDisposables.add(new MutableDisposable()); const finishBodyAnimation = () => { @@ -774,7 +734,7 @@ export class TabbedActionListWidget extends Disposable { // 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, .tabbed-action-list-details, .action-list-item-toolbar, .tabbed-action-list-tab-toggle'); + const onOwnControls = !!target?.closest('.tabbed-action-list-empty, .action-list-submenu-panel, .tabbed-action-list-details, .action-list-item-toolbar'); const listNavigation = !onTabBar && !onFooter && !onOwnControls; if (e.keyCode === KeyCode.Escape) { diff --git a/src/vs/platform/actionWidget/test/browser/tabbedActionListWidget.test.ts b/src/vs/platform/actionWidget/test/browser/tabbedActionListWidget.test.ts index b23a20dcdb532f..889c07d4fb6de7 100644 --- a/src/vs/platform/actionWidget/test/browser/tabbedActionListWidget.test.ts +++ b/src/vs/platform/actionWidget/test/browser/tabbedActionListWidget.test.ts @@ -526,6 +526,25 @@ suite('TabbedActionListWidget', () => { }); }); + test('keeps its rows when the page leaves no room beside the anchor', () => { + const { widget, contextView } = createWidget(disposables); + // A long page, like the one a full test run leaves behind, pushes the anchor's free space out of view. + const filler = document.createElement('div'); + filler.style.height = '5000px'; + document.body.prepend(filler); + disposables.add({ dispose: () => filler.remove() }); + widget.show({ + user: 'test', + anchor: document.body, + tabs: [{ id: 'Models' }], + initialTab: 'Models', + createActionList: () => ({ items: [action('first'), action('second')], listOptions: { showFilter: true } }), + delegate: { onSelect: () => { }, onHide: () => { } }, + }); + const rows = contextView.getContextViewElement().querySelectorAll('.monaco-list-row .title'); + assert.deepStrictEqual(Array.from(rows, row => row.textContent), ['first', 'second']); + }); + test('details scroll independently and retain a fixed Back button', async () => { const { widget, contextView } = createWidget(disposables); const anchor = document.createElement('button'); diff --git a/src/vs/platform/agentHost/common/agent.ts b/src/vs/platform/agentHost/common/agent.ts index 0c88b8d6111696..5a6121d8346b9d 100644 --- a/src/vs/platform/agentHost/common/agent.ts +++ b/src/vs/platform/agentHost/common/agent.ts @@ -1687,6 +1687,9 @@ export interface IAgent { /** Return the provider-owned state file for a session, when one exists. */ getSessionStateFile?(session: URI, chat?: URI): Promise; + /** Return the exact provider-owned plan file, resolving the chat's native backing identity. */ + getSessionPlanFile?(session: URI, chat: URI): URI | undefined; + /** Add provider-owned diagnostics to an Agent Host debug-log staging directory. */ collectDebugLogs?(session: URI | undefined, outputDirectory: URI, chat?: URI): Promise; diff --git a/src/vs/platform/agentHost/common/agentHostByokLm.ts b/src/vs/platform/agentHost/common/agentHostByokLm.ts index a7b7448d7f1d02..4d0b2f5cf6be09 100644 --- a/src/vs/platform/agentHost/common/agentHostByokLm.ts +++ b/src/vs/platform/agentHost/common/agentHostByokLm.ts @@ -192,6 +192,20 @@ export function getByokLmAgentModelId(model: IByokLmModelInfo): string { return `${model.vendor}/${getByokLmSelectionModelId(model)}`; } +/** + * Whether an agent model id has the BYOK shape produced by + * {@link getByokLmAgentModelId} (`vendor/[group/]id`). Copilot API model ids + * are single segments, so this identifies a retained BYOK selection even after + * the model has left the live catalog (for example, the renderer disconnected). + * + * This relies on the Copilot SDK listing BYOK models under the provider-qualified + * selection id `provider/id` and CAPI models under bare ids. That contract is + * pinned against the bundled runtime by `copilotByokSelectionIds.integrationTest.ts`. + */ +export function isByokLmAgentModelId(modelId: string): boolean { + return modelId.includes('/'); +} + /** Resolves BYOK enablement and trace context from synchronized root configuration. */ export function resolveByokLmEnablement(rootConfigValue: boolean | undefined): { readonly enabled: boolean; readonly trace: string } { const enabled = rootConfigValue !== false; diff --git a/src/vs/platform/agentHost/common/agentHostSchema.ts b/src/vs/platform/agentHost/common/agentHostSchema.ts index 0233eb963dcd1d..612213d6e6789a 100644 --- a/src/vs/platform/agentHost/common/agentHostSchema.ts +++ b/src/vs/platform/agentHost/common/agentHostSchema.ts @@ -454,6 +454,23 @@ export const AgentHostByokModelsEnabledConfigKey = 'byokModelsEnabled'; /** Shared default for the root config and mirrored workbench setting. */ export const AgentHostByokModelsEnabledDefault = true; +/** + * Root config key mirrored from `chat.utilitySmallModel`: a `${vendor}/${id}` + * model that overrides the default model for host utility flows such as title + * and pull request generation. Empty uses the default behavior. + */ +export const AgentHostUtilitySmallModelConfigKey = 'utilitySmallModel'; + +/** + * Root config key mirrored from `chat.byokUtilityModelDefault`: the default + * utility model when the chat's selected main agent model is BYOK. + */ +export const AgentHostByokUtilityModelDefaultConfigKey = 'byokUtilityModelDefault'; + +/** Values of {@link AgentHostByokUtilityModelDefaultConfigKey}; keep in sync with `BYOKUtilityModelDefault` in the chat contribution. */ +export const AgentHostByokUtilityModelDefaults = ['none', 'mainAgent', 'copilot'] as const; +export type AgentHostByokUtilityModelDefault = typeof AgentHostByokUtilityModelDefaults[number]; + /** * Root config key forwarded from the renderer carrying the experiment-aware * value of `chat.agentHost.codexAgent.enabled`. The host registers the Codex @@ -859,6 +876,24 @@ export const platformRootSchema = createSchema({ description: localize('agentHost.config.byokModelsEnabled.description', "Whether extension-provided BYOK models are enabled."), default: AgentHostByokModelsEnabledDefault, }), + [AgentHostUtilitySmallModelConfigKey]: schemaProperty({ + type: 'string', + title: localize('agentHost.config.utilitySmallModel.title', "Utility Model"), + description: localize('agentHost.config.utilitySmallModel.description', "The `vendor/id` of a BYOK model that overrides the default model for utility flows such as title and pull request generation. Empty uses the default behavior."), + default: '', + }), + [AgentHostByokUtilityModelDefaultConfigKey]: schemaProperty({ + type: 'string', + title: localize('agentHost.config.byokUtilityModelDefault.title', "BYOK Utility Model Default"), + description: localize('agentHost.config.byokUtilityModelDefault.description', "The default model for utility flows when the chat's selected main agent model is a BYOK model. A configured utility model takes precedence."), + enum: [...AgentHostByokUtilityModelDefaults], + enumDescriptions: [ + localize('agentHost.config.byokUtilityModelDefault.none', "Do not use a default utility model."), + localize('agentHost.config.byokUtilityModelDefault.mainAgent', "Use the selected BYOK main agent model."), + localize('agentHost.config.byokUtilityModelDefault.copilot', "Use the default GitHub Copilot utility model."), + ], + default: 'copilot', + }), [AgentHostCodexEnabledConfigKey]: schemaProperty({ type: 'boolean', title: localize('agentHost.config.codexAgentEnabled.title', "Codex Agent"), diff --git a/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts b/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts index 55520d55581581..9281d6cc21ffda 100644 --- a/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts +++ b/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts @@ -274,6 +274,7 @@ configurationRegistry.registerConfiguration({ description: nls.localize('chat.agentHost.experimental.mcpToolRouting', "When enabled, agent-host sessions use cached MCP tool metadata to route requests and only prompt for MCP authentication when a server is relevant. Newly created sessions pick up changes to this setting."), default: false, tags: ['experimental', 'advanced'], + experiment: { mode: 'auto' }, agentHost: { key: AgentHostMcpToolRoutingEnabledConfigKey }, }, [AgentHostCopilotMultiRootEnabledSettingId]: { diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index 77c13c87425893..18254b79174407 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -1036,6 +1036,8 @@ export interface IAgentService { diagnosticsFetch(url: string): Promise; getSessionStateFile?(session: URI, chat?: URI): Promise; + /** Resolve the owning provider's exact plan artifact without granting access to its directory. */ + getSessionPlanFile?(session: URI, chat: URI): URI | undefined; collectDebugLogs?(session: URI | undefined, kind: AgentHostDebugLogsArtifactKind, chat?: URI): Promise; diff --git a/src/vs/platform/agentHost/common/fileEditAttribution.ts b/src/vs/platform/agentHost/common/fileEditAttribution.ts index 076b471901dc97..250da5e8f6f60d 100644 --- a/src/vs/platform/agentHost/common/fileEditAttribution.ts +++ b/src/vs/platform/agentHost/common/fileEditAttribution.ts @@ -26,6 +26,7 @@ export interface IFileEditAttributionSource { readonly modelId?: string; readonly autoTier?: string; readonly conversationId: string; + readonly agentSessionId?: string; readonly chatSessionId?: string; readonly requestId: string; readonly harness: string; @@ -177,6 +178,7 @@ function isFileEditAttributionSource(source: unknown): source is IFileEditAttrib return (candidate.modelId === undefined || typeof candidate.modelId === 'string') && (candidate.autoTier === undefined || typeof candidate.autoTier === 'string') && typeof candidate.conversationId === 'string' && + (candidate.agentSessionId === undefined || typeof candidate.agentSessionId === 'string') && (candidate.chatSessionId === undefined || typeof candidate.chatSessionId === 'string') && typeof candidate.requestId === 'string' && typeof candidate.harness === 'string'; diff --git a/src/vs/platform/agentHost/node/agentHostBootstrap.ts b/src/vs/platform/agentHost/node/agentHostBootstrap.ts index a5fdbbf9df14af..5821647091e0a0 100644 --- a/src/vs/platform/agentHost/node/agentHostBootstrap.ts +++ b/src/vs/platform/agentHost/node/agentHostBootstrap.ts @@ -40,7 +40,7 @@ import { AgentHostClientConnectionService, IAgentHostClientConnectionService } f import { AgentHostSessionTitleController, IAgentHostSessionTitleController } from './agentHostSessionTitleController.js'; import { AgentHostLocalTurns, IAgentHostLocalTurns } from './agentHostLocalTurns.js'; import { IAgentHostGitHubService } from './agentHostGitHubService.js'; -import { ICopilotApiService } from './shared/copilotApiService.js'; +import { IAgentHostUtilityModelService } from './agentHostUtilityModelService.js'; import { AgentHostStartupMarks, IAgentHostStartupPerformance } from './agentHostStartupPerformance.js'; export interface ICreateAgentHostRuntimeOptions { @@ -172,24 +172,20 @@ export async function createAgentHostRuntime(options: ICreateAgentHostRuntimeOpt }); instantiationService = new InstantiationService(services, /*strict*/ true); const gitHubService = instantiationService.invokeFunction(accessor => accessor.get(IAgentHostGitHubService)); - const copilotApiService = instantiationService.invokeFunction(accessor => accessor.get(ICopilotApiService)); + const utilityModelService = instantiationService.invokeFunction(accessor => accessor.get(IAgentHostUtilityModelService)); services.set(IAgentHostSessionTitleController, infrastructure.add(instantiationService.createInstance(AgentHostSessionTitleController, foundation.stateManager, { sessionDataService, queueCatalogSync: (session, metadataOverrides) => foundation.callbackAdapter.value.queueCatalogSync(session, metadataOverrides), persistMetadata: (resource, values) => foundation.callbackAdapter.value.persistMetadata(resource, values), readNormalizedChat: (session, chat) => foundation.callbackAdapter.value.readNormalizedChat(session, chat), persistSurfacedSessionTitle: (session, title) => foundation.callbackAdapter.value.persistSurfacedSessionTitle(session, title), - getGitHubCopilotToken: () => { - const resource = foundation.gitHubEndpointService.getCopilotResource(); - return foundation.authenticationService.getAuthToken({ resource: resource.resource, scopes: resource.scopes_supported }); - }, getGitHubToken: () => { const resource = foundation.gitHubEndpointService.getRepoResource(); return foundation.authenticationService.getAuthToken({ resource: resource.resource, scopes: resource.scopes_supported }); }, getGitHubHost: () => foundation.gitHubEndpointService.getEnterpriseHost() ?? 'github.com', gitHubService, - copilotApiService, + utilityModelService, getInitialTitleGenerationStrategy: () => { const configurationService = foundation.configurationService; return resolveTitleGenerationStrategy( diff --git a/src/vs/platform/agentHost/node/agentHostPullRequestOperationHandler.ts b/src/vs/platform/agentHost/node/agentHostPullRequestOperationHandler.ts index df21125eb8a954..6d97b8f49d96f9 100644 --- a/src/vs/platform/agentHost/node/agentHostPullRequestOperationHandler.ts +++ b/src/vs/platform/agentHost/node/agentHostPullRequestOperationHandler.ts @@ -23,7 +23,8 @@ import { IGitHubClient } from '../../github/common/githubService.js'; import { GitHubRequestTimeoutError } from '../../github/common/githubTypes.js'; import { IAgentHostGitHubService } from './agentHostGitHubService.js'; import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from '../common/state/protocol/channels-changeset/commands.js'; -import { ICopilotApiService, type ICopilotUtilityChatMessage } from './shared/copilotApiService.js'; +import { type ICopilotUtilityChatMessage } from './shared/copilotApiService.js'; +import { AgentHostUtilityModelUnavailableError, AgentHostUtilityModelUnavailableReason, IAgentHostUtilityModelService, type IAgentHostUtilityModelContext } from './agentHostUtilityModelService.js'; import { buildConversationContext } from '../common/agentHostConversationContext.js'; import { IAgentBranchNameGenerator } from './shared/agentBranchNameGenerator.js'; import { SessionConfigKey } from '../common/sessionConfigKeys.js'; @@ -106,7 +107,7 @@ export class AgentHostPullRequestOperationHandler implements IChangesetOperation @IAgentHostGitService private readonly _gitService: IAgentHostGitService, @IAgentHostGitHubService private readonly _gitHubService: IAgentHostGitHubService, @IAgentHostGitHubEndpointService private readonly _gitHubEndpointService: IAgentHostGitHubEndpointService, - @ICopilotApiService private readonly _copilotApiService: ICopilotApiService, + @IAgentHostUtilityModelService private readonly _utilityModelService: IAgentHostUtilityModelService, @IAgentBranchNameGenerator private readonly _branchNameGenerator: IAgentBranchNameGenerator, @IAgentConfigurationService private readonly _configurationService: IAgentConfigurationService, @ILogService private readonly _logService: ILogService, @@ -120,7 +121,7 @@ export class AgentHostPullRequestOperationHandler implements IChangesetOperation async prepare(params: InvokeChangesetOperationParams, token: CancellationToken): Promise { return this._withAbortSignal(token, async (signal, client) => { const expectedContext = readPullRequestValidationMeta(params); - const { sessionUri, sourceUri, ownerUri, conversationState, workingDirectory, gitHubState, branchName, baseBranchName, preparationContext } = await this._resolveContext(params, token, expectedContext); + const { sessionUri, sourceUri, ownerUri, conversationState, conversationChat, workingDirectory, gitHubState, branchName, baseBranchName, preparationContext } = await this._resolveContext(params, token, expectedContext); if (expectedContext) { return {}; } @@ -139,7 +140,7 @@ export class AgentHostPullRequestOperationHandler implements IChangesetOperation let description = ''; let generationError: string | undefined; try { - ({ title, description } = await this._generateTitleAndDescription(conversationState, workingDirectory, branchName, baseBranchName, branchChanges, signal, token)); + ({ title, description } = await this._generateTitleAndDescription(conversationState, { session: sessionUri, chat: conversationChat }, workingDirectory, branchName, baseBranchName, branchChanges, signal, token)); } catch (err) { this._throwIfCancelled(token); generationError = this._reportGenerationError(err); @@ -399,7 +400,7 @@ export class AgentHostPullRequestOperationHandler implements IChangesetOperation let generated: { title: string; description: string } | undefined; if (!submitted) { try { - generated = await this._generateTitleAndDescription(conversationState, workingDirectory, branchName, baseBranchName, branchChanges, signal, token); + generated = await this._generateTitleAndDescription(conversationState, { session: sessionUri, chat: conversationChat }, workingDirectory, branchName, baseBranchName, branchChanges, signal, token); } catch (err) { this._throwIfCancelled(token); this._reportGenerationError(err); @@ -630,6 +631,7 @@ export class AgentHostPullRequestOperationHandler implements IChangesetOperation /** Generates from bounded conversation and file context; callers decide how to surface failures. */ private async _generateTitleAndDescription( sessionState: ISessionWithDefaultChat, + modelContext: IAgentHostUtilityModelContext, workingDirectory: URI, branchName: string, base: string, @@ -637,24 +639,23 @@ export class AgentHostPullRequestOperationHandler implements IChangesetOperation signal: AbortSignal, token: CancellationToken, ): Promise<{ title: string; description: string }> { - const copilotResource = this._gitHubEndpointService.getCopilotResource(); - const authToken = this._authenticationService.getAuthToken({ - resource: copilotResource.resource, - scopes: copilotResource.scopes_supported, - }); - if (!authToken) { - throw new Error(localize('agentHost.changeset.pr.generationAuthRequired', "Sign in to Copilot to generate a pull request title and description, or enter them manually.")); - } - const conversation = buildConversationContext(sessionState.turns, { maxChars: MAX_PR_CONVERSATION_CONTEXT_CHARS }); const changeSummary = this._summarizeDiffsForPrompt(branchChanges, workingDirectory); if (!conversation && !changeSummary) { throw new Error(localize('agentHost.changeset.pr.generationNoContext', "There is no conversation or change context to generate a pull request title and description.")); } - const raw = await this._copilotApiService.utilityChatCompletion(authToken, { - messages: this._buildTitleAndDescriptionPrompt(branchName, base, conversation, changeSummary), - }, { signal }); + let raw: string; + try { + raw = await this._utilityModelService.chatCompletion(modelContext, { + messages: this._buildTitleAndDescriptionPrompt(branchName, base, conversation, changeSummary), + }, { signal }); + } catch (err) { + if (err instanceof AgentHostUtilityModelUnavailableError) { + throw new Error(this._utilityModelUnavailableMessage(err.reason)); + } + throw err; + } this._throwIfCancelled(token); const generated = this._parseTitleAndDescription(raw); if (!generated) { @@ -663,6 +664,17 @@ export class AgentHostPullRequestOperationHandler implements IChangesetOperation return generated; } + private _utilityModelUnavailableMessage(reason: AgentHostUtilityModelUnavailableReason): string { + switch (reason) { + case AgentHostUtilityModelUnavailableReason.CopilotSignInRequired: + return localize('agentHost.changeset.pr.generationAuthRequired', "Sign in to Copilot to generate a pull request title and description, or enter them manually."); + case AgentHostUtilityModelUnavailableReason.NotConfigured: + return localize('agentHost.changeset.pr.generationUtilityModelNotConfigured', "No utility model is configured for the selected BYOK model. Configure {0} or {1} to generate a pull request title and description, or enter them manually.", 'chat.utilitySmallModel', 'chat.byokUtilityModelDefault'); + case AgentHostUtilityModelUnavailableReason.ByokModelUnavailable: + return localize('agentHost.changeset.pr.generationByokModelUnavailable', "The BYOK model used to generate a pull request title and description is unavailable. Enter them manually."); + } + } + private _reportGenerationError(error: unknown): string { const message = error instanceof Error ? error.message : String(error); this._logService.warn(`[AgentHostPullRequestOperationHandler] Failed to generate PR title and description: ${message}`); diff --git a/src/vs/platform/agentHost/node/agentHostServices.ts b/src/vs/platform/agentHost/node/agentHostServices.ts index decfbf3cbfc616..1a56f7a061466e 100644 --- a/src/vs/platform/agentHost/node/agentHostServices.ts +++ b/src/vs/platform/agentHost/node/agentHostServices.ts @@ -25,6 +25,7 @@ import { IAgentHostGitStateService } from '../common/agentHostGitStateService.js import { IAgentHostReviewService } from '../common/agentHostReviewService.js'; import { IAgentHostSubscriptionService } from '../common/agentHostSubscriptionService.js'; import { CopilotApiService, ICopilotApiService } from './shared/copilotApiService.js'; +import { AgentHostUtilityModelService, IAgentHostUtilityModelService } from './agentHostUtilityModelService.js'; import { AgentHostFileMonitorService, IAgentHostFileMonitorService } from './agentHostFileMonitorService.js'; import { AgentHostGitService } from './agentHostGitService.js'; import { AgentPluginManager } from './agentPluginManager.js'; @@ -145,6 +146,7 @@ export function registerAgentHostHostServices(services: ServiceCollection, input services.set(IClaudeProxyService, new SyncDescriptor(ClaudeProxyService)); services.set(ICodexProxyService, new SyncDescriptor(CodexProxyService, [undefined])); services.set(IAgentHostOTelService, new SyncDescriptor(AgentHostOTelService, [{ fetchFn: inputs.fetchFn }])); + services.set(IAgentHostUtilityModelService, new SyncDescriptor(AgentHostUtilityModelService, [inputs.byok.kind === 'renderer'])); services.set( IByokLmProxyService, inputs.byok.kind === 'renderer' ? new SyncDescriptor(ByokLmProxyService) : new NullByokLmProxyService(), diff --git a/src/vs/platform/agentHost/node/agentHostSessionTitleController.ts b/src/vs/platform/agentHost/node/agentHostSessionTitleController.ts index 63486612f3d088..d099576bce0474 100644 --- a/src/vs/platform/agentHost/node/agentHostSessionTitleController.ts +++ b/src/vs/platform/agentHost/node/agentHostSessionTitleController.ts @@ -13,13 +13,14 @@ import { AgentHostTitleGenerationStrategies, type AgentHostTitleGenerationStrate import { ISessionDataService } from '../common/sessionDataService.js'; import { SessionServerToolName } from '../common/serverToolNames.js'; import { ActionType } from '../common/state/sessionActions.js'; -import { buildDefaultChatUri, isAhpChatChannel, isDefaultChatUri, TurnState, type Turn, type URI as ProtocolURI } from '../common/state/sessionState.js'; +import { buildDefaultChatUri, isAhpChatChannel, isDefaultChatUri, TurnState, type ModelSelection, type Turn, type URI as ProtocolURI } from '../common/state/sessionState.js'; import { buildConversationContext, renderResponseMarkdown, truncateMiddle } from '../common/agentHostConversationContext.js'; import { AgentHostStateManager } from './agentHostStateManager.js'; import type { IAgentHostPeerChatPersistenceService } from './agentHostPeerChatStore.js'; import type { GitHubIssueOrPullRequest } from '../../github/common/githubQueryService.js'; import type { IAgentHostGitHubService } from './agentHostGitHubService.js'; -import { ICopilotApiService, type ICopilotUtilityChatMessage } from './shared/copilotApiService.js'; +import { type ICopilotUtilityChatMessage } from './shared/copilotApiService.js'; +import { AgentHostUtilityModelUnavailableError, type IAgentHostUtilityModelContext, type IAgentHostUtilityModelService } from './agentHostUtilityModelService.js'; import { AGENT_HOST_TITLE_SOURCE_AGENT, AGENT_HOST_TITLE_SOURCE_AUTO, AGENT_HOST_TITLE_SOURCE_USER, customChatTitleMetadataKey, customChatTitleSourceMetadataKey, persistSessionMetadata, SESSION_CUSTOM_TITLE_KEY, SESSION_CUSTOM_TITLE_SOURCE_KEY } from './shared/persistSessionMetadata.js'; const MAX_TITLE_LENGTH = 200; @@ -98,6 +99,8 @@ interface ITitlePromptContext { readonly gitHubReferenceSource?: string; /** The title in place already, offered to the model as the incumbent. */ readonly currentTitle?: string; + /** The session and chat whose selected model determines the utility model. */ + readonly modelContext: IAgentHostUtilityModelContext; } export interface IAgentHostSessionTitleControllerOptions { @@ -106,12 +109,11 @@ export interface IAgentHostSessionTitleControllerOptions { readonly readNormalizedChat?: IAgentHostPeerChatPersistenceService['readNormalizedChat']; readonly queueCatalogSync?: (session: ProtocolURI, metadataOverrides: Readonly>) => void; readonly persistSurfacedSessionTitle?: (session: ProtocolURI, title: string) => Promise; - readonly getGitHubCopilotToken?: () => string | undefined; readonly getGitHubToken?: () => string | undefined; readonly getGitHubHost?: () => string | undefined; readonly gitHubContextRequestTimeout?: number; readonly gitHubService?: IAgentHostGitHubService; - readonly copilotApiService?: ICopilotApiService; + readonly utilityModelService?: IAgentHostUtilityModelService; readonly getInitialTitleGenerationStrategy?: () => AutomaticTitleGenerationStrategy; } @@ -122,7 +124,7 @@ export interface IAgentHostSessionTitleController { readonly _serviceBrand: undefined; getAutomaticTitleGenerationStrategy(channel?: ProtocolURI): AutomaticTitleGenerationStrategy; restoreTitleGenerationStrategy(channel: ProtocolURI, chatChannel?: ProtocolURI): Promise; - seedTitleFromFirstMessage(channel: ProtocolURI, userPrompt: string, chatChannel?: ProtocolURI): void; + seedTitleFromFirstMessage(channel: ProtocolURI, userPrompt: string, chatChannel?: ProtocolURI, model?: ModelSelection): void; seedProvisionalTitle(channel: ProtocolURI, suggestedTitle: string, chatChannel?: ProtocolURI): void; refineTitleFromFirstTurn(channel: ProtocolURI, chatChannel?: ProtocolURI, successful?: boolean): void; generateForkedTitle(channel: ProtocolURI, chatChannel: ProtocolURI | undefined, turns: readonly Turn[], fallbackTitle: string, sourceTitle?: string): void; @@ -174,7 +176,7 @@ export class AgentHostSessionTitleController extends Disposable implements IAgen super(); } - seedTitleFromFirstMessage(channel: ProtocolURI, userPrompt: string, chatChannel?: ProtocolURI): void { + seedTitleFromFirstMessage(channel: ProtocolURI, userPrompt: string, chatChannel?: ProtocolURI, model?: ModelSelection): void { if (this._isEphemeralSession(channel)) { return; } @@ -208,7 +210,7 @@ export class AgentHostSessionTitleController extends Disposable implements IAgen } this._generateTitleSoon( key, - { content: userPrompt, isConversation: false, gitHubReferenceSource: userPrompt }, + { content: userPrompt, isConversation: false, gitHubReferenceSource: userPrompt, modelContext: { session: channel, chat: chatChannel, model } }, fallbackTitle, title => this._applySeedTitle(channel, independentChat, title), () => this._currentSeedTitle(channel, independentChat) === this._lastAppliedTitle.get(key), @@ -409,7 +411,7 @@ export class AgentHostSessionTitleController extends Disposable implements IAgen } this._generateTitleSoon( key, - { content: context, isConversation: true, gitHubReferenceSource: turn.message.text, currentTitle: lastApplied }, + { content: context, isConversation: true, gitHubReferenceSource: turn.message.text, currentTitle: lastApplied, modelContext: { session: channel, chat: chatChannel } }, lastApplied, title => this._applySeedTitle(channel, independentChat, title), () => { @@ -453,7 +455,7 @@ export class AgentHostSessionTitleController extends Disposable implements IAgen const apply = (title: string) => this._applyTitle(key, title, t => this._stateManager.updateChatTitle(channel, key, t)); this._generateTitleSoon( key, - { content: context, isConversation: true }, + { content: context, isConversation: true, modelContext: { session: channel, chat: chatChannel } }, fallbackTitle, apply, () => this._stateManager.getChatState(key)?.title === this._lastAppliedTitle.get(key), @@ -470,7 +472,7 @@ export class AgentHostSessionTitleController extends Disposable implements IAgen })); this._generateTitleSoon( channel, - { content: context, isConversation: true }, + { content: context, isConversation: true, modelContext: { session: channel, chat: chatChannel } }, fallbackTitle, apply, () => this._stateManager.getSessionState(channel)?.title === this._lastAppliedTitle.get(channel), @@ -499,7 +501,7 @@ export class AgentHostSessionTitleController extends Disposable implements IAgen } await this._startTitleGeneration( session, - { content: userPrompt, isConversation: false, gitHubReferenceSource: userPrompt }, + { content: userPrompt, isConversation: false, gitHubReferenceSource: userPrompt, modelContext: { session } }, '', title => this._applyExternalSessionTitle(session, title), () => true, @@ -705,9 +707,8 @@ export class AgentHostSessionTitleController extends Disposable implements IAgen return undefined; } - const githubToken = this._options.getGitHubCopilotToken?.(); - const copilotApiService = this._options.copilotApiService; - if (!githubToken || !copilotApiService) { + const utilityModelService = this._options.utilityModelService; + if (!utilityModelService) { return undefined; } @@ -720,7 +721,7 @@ export class AgentHostSessionTitleController extends Disposable implements IAgen if (token.isCancellationRequested) { return undefined; } - const rawTitle = await copilotApiService.utilityChatCompletion(githubToken, { + const rawTitle = await utilityModelService.chatCompletion(prompt.modelContext, { messages: this._buildTitlePrompt(titlePromptContent, prompt), maxTokens: MAX_TITLE_TOKENS, }, { @@ -731,6 +732,10 @@ export class AgentHostSessionTitleController extends Disposable implements IAgen if (token.isCancellationRequested) { return undefined; } + if (err instanceof AgentHostUtilityModelUnavailableError) { + this._logService.trace(`[AgentHostSessionTitleController] Skipping title generation: ${err.message}`); + return undefined; + } this._logService.warn('[AgentHostSessionTitleController] Failed to generate session title', err); return undefined; } finally { diff --git a/src/vs/platform/agentHost/node/agentHostTurnStarter.ts b/src/vs/platform/agentHost/node/agentHostTurnStarter.ts index 0f1af0d02a480b..5b99271eefc9de 100644 --- a/src/vs/platform/agentHost/node/agentHostTurnStarter.ts +++ b/src/vs/platform/agentHost/node/agentHostTurnStarter.ts @@ -95,7 +95,7 @@ export function startTurn(accessor: ServicesAccessor, request: ITurnStartRequest if (!state) { logService.info(`[AgentHostTurnStarter] Turn started for session not in state manager: ${request.chat}, turnId=${request.turnId} - status/summary updates may be dropped unless the session is restored`); } - titleController.seedTitleFromFirstMessage(request.session, request.message.text, request.chat); + titleController.seedTitleFromFirstMessage(request.session, request.message.text, request.chat, request.message.model); const agent = providerService.getProviderForSession(request.session); if (!agent) { diff --git a/src/vs/platform/agentHost/node/agentHostUtilityModelService.ts b/src/vs/platform/agentHost/node/agentHostUtilityModelService.ts new file mode 100644 index 00000000000000..9e733e06a55ace --- /dev/null +++ b/src/vs/platform/agentHost/node/agentHostUtilityModelService.ts @@ -0,0 +1,287 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationError } from '../../../base/common/errors.js'; +import { URI } from '../../../base/common/uri.js'; +import { createDecorator } from '../../instantiation/common/instantiation.js'; +import { ILogService } from '../../log/common/log.js'; +import { COPILOT_CLI_AGENT_PROVIDER_ID } from '../common/agent.js'; +import { getByokLmAgentModelId, getByokLmSelectionModelId, IByokLmChatRequest, IByokLmModelInfo, isByokLmAgentModelId, resolveByokLmEnablement } from '../common/agentHostByokLm.js'; +import { AgentHostByokModelsEnabledConfigKey, AgentHostByokUtilityModelDefault, AgentHostByokUtilityModelDefaultConfigKey, AgentHostUtilitySmallModelConfigKey, platformRootSchema } from '../common/agentHostSchema.js'; +import { readAgentModelByokIdentifier } from '../common/agentModelByokMeta.js'; +import { buildDefaultChatUri, isAhpChatChannel, isDefaultChatUri, type ModelSelection, type URI as ProtocolURI } from '../common/state/sessionState.js'; +import { createAgentChatContext } from './agentChatContext.js'; +import { IAgentConfigurationService } from './agentConfigurationService.js'; +import { IAgentHostAuthenticationService } from './agentHostAuthenticationService.js'; +import { IAgentHostGitHubEndpointService } from './agentHostGitHubEndpointService.js'; +import { IAgentHostProviderService } from './agentHostProviderService.js'; +import { AgentHostStateManager, IAgentHostStateManager } from './agentHostStateManager.js'; +import { IByokLmBridgeRegistry } from './byokLmBridgeRegistry.js'; +import { ICopilotApiService, ICopilotApiServiceRequestOptions, ICopilotUtilityChatCompletionRequest, UTILITY_DEFAULT_TEMPERATURE, UTILITY_DEFAULT_TOP_P } from './shared/copilotApiService.js'; + +/** LM API vendor of Copilot models; an override naming it is not a BYOK model. */ +const COPILOT_VENDOR = 'copilot'; + +/** The session, and optionally the chat, whose selected main agent model a utility request serves. */ +export interface IAgentHostUtilityModelContext { + readonly session: ProtocolURI; + /** Defaults to the session's default chat. */ + readonly chat?: ProtocolURI; + /** + * The model selected for the turn being served, when the caller runs before + * the provider has applied it (for example, titling a turn at admission). + */ + readonly model?: ModelSelection; +} + +export const enum AgentHostUtilityModelUnavailableReason { + /** The Copilot utility model is selected but no Copilot token is available. */ + CopilotSignInRequired = 'copilotSignInRequired', + /** The main agent model is BYOK and the BYOK default is `none`. */ + NotConfigured = 'notConfigured', + /** + * A BYOK model is selected (as the main agent model or the utility model + * override) but cannot be reached, for example because the renderer that + * serves BYOK models is disconnected. + */ + ByokModelUnavailable = 'byokModelUnavailable', +} + +/** Thrown when no utility model can serve a request; callers map {@link reason} to user-facing text. */ +export class AgentHostUtilityModelUnavailableError extends Error { + constructor(readonly reason: AgentHostUtilityModelUnavailableReason) { + super(`No utility model is available: ${reason}`); + this.name = 'AgentHostUtilityModelUnavailableError'; + } +} + +export const IAgentHostUtilityModelService = createDecorator('agentHostUtilityModelService'); + +/** + * Runs the host's small utility completions (titles, pull request text) on the + * model the user configured, matching the Copilot Chat extension's + * `copilot-utility-small` resolution: + * + * 1. A resolvable BYOK `utilitySmallModel` override. + * 2. When the chat's selected main agent model is BYOK, `byokUtilityModelDefault`: + * the main agent model, no model, or the Copilot default. + * 3. The Copilot default utility model. + * + * Only an agent host with a renderer BYOK bridge (the local utility-process + * host) honors steps 1 and 2. Remote hosts always use the Copilot default and + * ignore the utility model root config, even when a client writes it. + * + * BYOK selections fail closed: while no renderer serves BYOK models, a BYOK + * override or BYOK main agent model makes the request fail instead of + * falling back to Copilot, even when `byokUtilityModelDefault` is `copilot`. + * The user chose to keep that chat's content on their own model, and the + * Copilot route must not be reached only because the bridge is unavailable. + */ +export interface IAgentHostUtilityModelService { + readonly _serviceBrand: undefined; + + /** + * Returns the assistant text for `request`. + * + * @throws {@link AgentHostUtilityModelUnavailableError} when no utility model is available. + */ + chatCompletion(context: IAgentHostUtilityModelContext, request: ICopilotUtilityChatCompletionRequest, options?: ICopilotApiServiceRequestOptions): Promise; +} + +type UtilityModelRoute = + | { readonly kind: 'byok'; readonly vendor: string; readonly modelId: string } + | { readonly kind: 'copilot'; readonly githubToken: string }; + +export class AgentHostUtilityModelService implements IAgentHostUtilityModelService { + + declare readonly _serviceBrand: undefined; + + constructor( + private readonly _byokUtilityModelsSupported: boolean, + @IAgentConfigurationService private readonly _configurationService: IAgentConfigurationService, + @ICopilotApiService private readonly _copilotApiService: ICopilotApiService, + @IByokLmBridgeRegistry private readonly _byokBridgeRegistry: IByokLmBridgeRegistry, + @IAgentHostProviderService private readonly _providerService: IAgentHostProviderService, + @IAgentHostStateManager private readonly _stateManager: AgentHostStateManager, + @IAgentHostAuthenticationService private readonly _authenticationService: IAgentHostAuthenticationService, + @IAgentHostGitHubEndpointService private readonly _gitHubEndpointService: IAgentHostGitHubEndpointService, + @ILogService private readonly _logService: ILogService, + ) { } + + async chatCompletion(context: IAgentHostUtilityModelContext, request: ICopilotUtilityChatCompletionRequest, options?: ICopilotApiServiceRequestOptions): Promise { + const route = this._resolveRoute(context); + if (route.kind === 'copilot') { + return this._copilotApiService.utilityChatCompletion(route.githubToken, request, options); + } + return this._byokChatCompletion(route.vendor, route.modelId, request, options?.signal); + } + + private _resolveRoute(context: IAgentHostUtilityModelContext): UtilityModelRoute { + return this._byokUtilityModelsSupported + ? this._resolveByokRoute(context) ?? this._resolveCopilotRoute() + : this._resolveCopilotRoute(); + } + + /** Resolves a BYOK route, or `undefined` to use the Copilot default. */ + private _resolveByokRoute(context: IAgentHostUtilityModelContext): UtilityModelRoute | undefined { + const byokModels = this._byokModels(); + const rendererConnected = this._byokBridgeRegistry.getServingConnection() !== undefined; + const override = this._resolveOverride(byokModels, rendererConnected); + if (override) { + return override; + } + + const mainAgentModel = this._resolveByokMainAgentModel(context); + if (mainAgentModel) { + const byokDefault = this._byokUtilityModelDefault(); + if (byokDefault === 'none') { + throw new AgentHostUtilityModelUnavailableError(AgentHostUtilityModelUnavailableReason.NotConfigured); + } + if (!rendererConnected) { + this._logService.trace(`[AgentHostUtilityModelService] BYOK main agent model '${mainAgentModel}' is selected but no renderer serves BYOK models; not falling back to Copilot.`); + throw new AgentHostUtilityModelUnavailableError(AgentHostUtilityModelUnavailableReason.ByokModelUnavailable); + } + switch (byokDefault) { + case 'mainAgent': { + const model = byokModels?.find(m => getByokLmAgentModelId(m) === mainAgentModel); + if (!model) { + throw new AgentHostUtilityModelUnavailableError(AgentHostUtilityModelUnavailableReason.ByokModelUnavailable); + } + return { kind: 'byok', vendor: model.vendor, modelId: getByokLmSelectionModelId(model) }; + } + case 'copilot': + break; + } + } + return undefined; + } + + private _resolveCopilotRoute(): UtilityModelRoute { + const resource = this._gitHubEndpointService.getCopilotResource(); + const githubToken = this._authenticationService.getAuthToken({ resource: resource.resource, scopes: resource.scopes_supported }); + if (!githubToken) { + throw new AgentHostUtilityModelUnavailableError(AgentHostUtilityModelUnavailableReason.CopilotSignInRequired); + } + return { kind: 'copilot', githubToken }; + } + + /** The renderer's BYOK models, or `undefined` when BYOK is disabled for the agent host. */ + private _byokModels(): readonly IByokLmModelInfo[] | undefined { + const { enabled } = resolveByokLmEnablement(this._configurationService.getRootValue(platformRootSchema, AgentHostByokModelsEnabledConfigKey)); + return enabled ? this._byokBridgeRegistry.getModels() : undefined; + } + + /** + * Resolves a `${vendor}/${id}` override to exactly one BYOK model. Like the + * extension, an override that is unresolvable while the renderer serves BYOK + * models, or ambiguous, falls back to the default behavior. Copilot overrides + * also fall back: the host only uses the Copilot default utility model. A + * BYOK override while no renderer serves BYOK models fails closed. + */ + private _resolveOverride(byokModels: readonly IByokLmModelInfo[] | undefined, rendererConnected: boolean): UtilityModelRoute | undefined { + const raw = this._configurationService.getRootValue(platformRootSchema, AgentHostUtilitySmallModelConfigKey); + if (!raw) { + return undefined; + } + const slashIndex = raw.indexOf('/'); + if (slashIndex <= 0 || slashIndex >= raw.length - 1) { + this._logService.warn(`[AgentHostUtilityModelService] Ignoring malformed utility model override '${raw}' (expected '\${vendor}/\${id}').`); + return undefined; + } + const vendor = raw.substring(0, slashIndex); + const id = raw.substring(slashIndex + 1); + if (vendor === COPILOT_VENDOR || !byokModels) { + return undefined; + } + if (!rendererConnected) { + this._logService.trace(`[AgentHostUtilityModelService] Utility model override '${raw}' is a BYOK model but no renderer serves BYOK models; not falling back to Copilot.`); + throw new AgentHostUtilityModelUnavailableError(AgentHostUtilityModelUnavailableReason.ByokModelUnavailable); + } + const matches = byokModels.filter(m => m.vendor === vendor && m.id === id); + if (matches.length !== 1) { + this._logService.trace(`[AgentHostUtilityModelService] Utility model override '${raw}' matched ${matches.length} BYOK models; using the default behavior.`); + return undefined; + } + return { kind: 'byok', vendor, modelId: getByokLmSelectionModelId(matches[0]) }; + } + + /** The agent model id of the chat's selected main agent model when it is a BYOK model. */ + private _resolveByokMainAgentModel(context: IAgentHostUtilityModelContext): string | undefined { + const agent = this._providerService.getProviderForSession(context.session); + if (!agent) { + return undefined; + } + const chat = context.chat && isAhpChatChannel(context.chat) ? context.chat : buildDefaultChatUri(context.session); + const state = this._stateManager.getChatState(chat) ?? (isDefaultChatUri(chat) ? this._stateManager.getSessionState(context.session) : undefined); + // Prefer the model of the turn being served: the provider applies a turn's + // model after admission, and a chat that is not resident has no selection. + const selection = context.model + ?? state?.activeTurn?.message.model + ?? agent.chats.getModel?.(URI.parse(chat), createAgentChatContext(this._stateManager, context.session, chat)) + ?? state?.turns.at(-1)?.message.model; + if (!selection) { + return undefined; + } + const model = agent.models.get().find(m => m.id === selection.id); + if (model) { + return readAgentModelByokIdentifier(model) !== undefined ? model.id : undefined; + } + // The Copilot agent drops BYOK models from its catalog while no renderer + // serves them, but the chat keeps its selection; recognize it by its id. + return agent.id === COPILOT_CLI_AGENT_PROVIDER_ID && isByokLmAgentModelId(selection.id) ? selection.id : undefined; + } + + private _byokUtilityModelDefault(): AgentHostByokUtilityModelDefault { + return this._configurationService.getRootValue(platformRootSchema, AgentHostByokUtilityModelDefaultConfigKey) ?? 'copilot'; + } + + private async _byokChatCompletion(vendor: string, modelId: string, request: ICopilotUtilityChatCompletionRequest, signal: AbortSignal | undefined): Promise { + // Same keys as the Responses translation, and the same defaults as the Copilot route. + const modelOptions: Record = { + temperature: request.temperature ?? UTILITY_DEFAULT_TEMPERATURE, + top_p: UTILITY_DEFAULT_TOP_P, + ...(request.maxTokens !== undefined ? { max_tokens: request.maxTokens } : {}), + }; + const connection = this._byokBridgeRegistry.getServingConnection(); + if (!connection) { + throw new AgentHostUtilityModelUnavailableError(AgentHostUtilityModelUnavailableReason.ByokModelUnavailable); + } + const bridgeRequest: IByokLmChatRequest = { + vendor, + modelId, + input: request.messages.map(message => ({ + type: 'message', + role: message.role, + content: [{ type: 'text', text: message.content }], + })), + modelOptions, + }; + this._logService.debug(`[AgentHostUtilityModelService] BYOK utility request: ${vendor}/${modelId}`); + const result = await raceAbort(connection.chat(bridgeRequest), signal); + if (result.error) { + throw new Error(`BYOK utility request failed for ${vendor}/${modelId}: ${result.error}`); + } + const text = result.output.flatMap(item => item.type === 'message' ? item.content.map(part => part.text) : []).join(''); + if (!text) { + throw new Error(`BYOK utility request for ${vendor}/${modelId} returned no text content`); + } + return text; + } +} + +/** The bridge has no cancellation channel, so stop waiting when `signal` aborts. */ +function raceAbort(promise: Promise, signal: AbortSignal | undefined): Promise { + if (!signal) { + return promise; + } + if (signal.aborted) { + return Promise.reject(new CancellationError()); + } + return new Promise((resolve, reject) => { + const onAbort = () => reject(new CancellationError()); + signal.addEventListener('abort', onAbort, { once: true }); + promise.then(resolve, reject).finally(() => signal.removeEventListener('abort', onAbort)); + }); +} diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index c364cfe819d357..e643f100470358 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -10671,6 +10671,10 @@ export class AgentService extends Disposable implements IAgentService { return this._providerService.getProviderForSession(session)?.getSessionStateFile?.(session, chat); } + getSessionPlanFile(session: URI, chat: URI): URI | undefined { + return this._providerService.getProviderForSession(session)?.getSessionPlanFile?.(session, chat); + } + async collectDebugLogs(session: URI | undefined, kind: AgentHostDebugLogsArtifactKind, chat?: URI): Promise { if (!this._debugLogsCollector) { throw new Error('Agent Host debug log collection is unavailable'); diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 790a2b3549f01f..cf60a76ece2ea7 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -3206,6 +3206,16 @@ export class CopilotAgent extends Disposable implements IAgent { return await this._fileService.exists(resource) ? resource : undefined; } + getSessionPlanFile(session: URI, chat: URI): URI | undefined { + const sdkConversationId = !isDefaultChatUri(chat) + ? this._findChatByUri(chat)?.sessionId ?? this._chatBackings.get(chat.toString())?.sdkSessionId + : this._sdkConversationId(session); + if (!sdkConversationId || sdkConversationId === '.' || sdkConversationId === '..' || /[/\\:]/.test(sdkConversationId)) { + return undefined; + } + return URI.file(join(getCopilotHomePath(this._environmentService.userHome.fsPath, process.env), 'session-state', sdkConversationId, 'plan.md')); + } + async *readChatSessionEvents(chat: URI, context: IAgentChatContext, token: CancellationToken, afterEventId?: string, onDidReadEventId?: (id: string) => void): AsyncIterable { const resource = await this.getSessionStateFile(context.configurationResource, chat); if (resource) { diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts index d8eb04344ffeec..0c545ff9450b01 100644 --- a/src/vs/platform/agentHost/node/protocolServerHandler.ts +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -9,7 +9,7 @@ import { lstat, realpath, stat } from 'fs/promises'; import { parseSessionDbUri } from '../common/sessionDbUri.js'; import { parsePendingEditContentUri } from '../common/pendingEditContentUri.js'; import { parseGitBlobUri } from './gitDiffContent.js'; -import { dirname, relative, isAbsolute, resolve, sep } from '../../../base/common/path.js'; +import { basename, dirname, relative, isAbsolute, resolve, sep } from '../../../base/common/path.js'; import { Schemas } from '../../../base/common/network.js'; import { encodeBase64 } from '../../../base/common/buffer.js'; import { Emitter, Event } from '../../../base/common/event.js'; @@ -92,6 +92,8 @@ import { toErrorMessage } from '../../../base/common/errorMessage.js'; import type { AuthRequiredErrorData } from '../common/state/protocol/common/errors.js'; import type { MissionControlProjects } from './missionControl/missionControlProjects.js'; import { fromMissionControlConfigValues, toMissionControlConfigValues, toMissionControlSessionConfig } from './missionControl/missionControlSessionConfig.js'; +import { extUriBiasedIgnorePathCase } from '../../../base/common/resources.js'; +import { isChatInputRequestWithPlanReview } from '../common/agentHostPlanReview.js'; /** Default capacity of the server-side action replay buffer. */ const REPLAY_BUFFER_CAPACITY = 1000; @@ -2324,7 +2326,7 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien for (const field of fields) { const value = isParamsObject(params) ? params[field] ?? (field === 'cwd' ? this._config.defaultDirectory : undefined) : undefined; const readOnly = ['resourceRead', 'resourceList', 'resourceResolve', 'createResourceWatch'].includes(method) || (method === 'resourceCopy' && field === 'source') || (method === 'resourceRequest' && isParamsObject(params) && params.write !== true); - if (typeof value !== 'string' || !await this._isGrantedRelayResource(value, readOnly)) { + if (typeof value !== 'string' || (!await this._isGrantedRelayResource(value, readOnly) && !await this._isPublishedPlanResource(value, method, readOnly))) { throw new ProtocolError(AhpErrorCodes.PermissionDenied, 'Resource is outside the host workspace grants'); } if (field === 'cwd') { @@ -2343,6 +2345,51 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien } } + private async _isPublishedPlanResource(resource: string, method: string, readOnly: boolean): Promise { + if (!['resourceRead', 'resourceResolve', 'createResourceWatch', 'resourceWrite', 'resourceRequest'].includes(method)) { + return false; + } + const uri = URI.parse(resource, true); + if (uri.scheme !== Schemas.file || uri.authority || uri.query || uri.fragment) { + return false; + } + for (const session of this._stateManager.getExposedSessionKeys()) { + for (const chat of this._stateManager.getSessionState(session)?.chats ?? []) { + const state = this._stateManager.getChatState(chat.resource); + const turns = state ? [...state.turns, ...(state.activeTurn ? [state.activeTurn] : [])] : []; + const published = turns.some(turn => turn.responseParts.some(part => + part.kind === ResponsePartKind.InputRequest + && isChatInputRequestWithPlanReview(part.request) + && part.request.planReview?.planUri !== undefined + && (readOnly || (turn === state?.activeTurn && part.response === undefined && part.request.planReview.canProvideFeedback)) + && extUriBiasedIgnorePathCase.isEqual(uri, URI.parse(part.request.planReview.planUri)) + )); + if (!published) { + continue; + } + const owned = this._agentService.getSessionPlanFile?.(URI.parse(session), URI.parse(chat.resource)); + if (!owned || !extUriBiasedIgnorePathCase.isEqual(uri, owned)) { + continue; + } + // Neither the session directory nor its plan may redirect to a different artifact. + try { + const directory = dirname(owned.fsPath); + const expectedDirectory = resolve(await realpath(dirname(directory)), basename(directory)); + return relative(expectedDirectory, await realpath(directory)) === '' + && relative(resolve(expectedDirectory, basename(owned.fsPath)), await realpath(owned.fsPath)) === '' + && relative(resolve(expectedDirectory, basename(owned.fsPath)), await realpath(uri.fsPath)) === '' + && (await stat(uri.fsPath)).isFile(); + } catch (error) { + if (isParamsObject(error) && (error.code === 'ENOENT' || error.code === 'ENOTDIR')) { + return false; + } + throw error; + } + } + } + return false; + } + private async _isGrantedRelayResource(resource: string, readOnly: boolean): Promise { const uri = URI.parse(resource, true); if (uri.scheme !== Schemas.file) { @@ -2376,6 +2423,9 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien } return true; } + if (uri.authority && !this._config.relayResourceRoots?.(readOnly).some(root => URI.file(root).authority.toLowerCase() === uri.authority.toLowerCase())) { + return false; + } let ancestor = uri.fsPath; let canonical: string; while (true) { diff --git a/src/vs/platform/agentHost/node/shared/agentEditAttributionService.ts b/src/vs/platform/agentHost/node/shared/agentEditAttributionService.ts index 6f62d7147cf06d..69f8c160706a74 100644 --- a/src/vs/platform/agentHost/node/shared/agentEditAttributionService.ts +++ b/src/vs/platform/agentHost/node/shared/agentEditAttributionService.ts @@ -55,6 +55,7 @@ interface ISourceStatistics { readonly modelId: string | undefined; readonly autoTier: string | undefined; readonly conversationId: string; + readonly agentSessionId: string; readonly chatSessionId: string | undefined; readonly requestId: string; readonly harness: string; @@ -315,8 +316,10 @@ export class AgentEditAttributionService extends Disposable implements IAgentEdi const groupSourceKey = `source:Chat.applyEdits${modelSegment}-$harness:${provider}-$origin:agentHost`; const conversationId = AgentSession.id(edit.sessionUri); const chatUri = edit.chatUri ?? (isAhpChatChannel(edit.sessionUri) ? edit.sessionUri : undefined); + const attributionSession = chatUri ?? edit.sessionUri; + const agentSessionId = AgentSession.id(isAhpChatChannel(attributionSession) ? parseRequiredSessionUriFromChatUri(attributionSession) : attributionSession); const chatSessionId = chatUri === undefined ? undefined : getTelemetryChatSessionId(chatUri); - const toTrackingKey = (key: string) => chatSessionId === undefined ? key : JSON.stringify([key, conversationId, chatSessionId]); + const toTrackingKey = (key: string) => JSON.stringify([key, agentSessionId, conversationId, chatSessionId]); const trackingKey = toTrackingKey(sourceKey); let source = resource.sources.get(trackingKey); if (!source) { @@ -328,6 +331,7 @@ export class AgentEditAttributionService extends Disposable implements IAgentEdi modelId: edit.modelId, autoTier: edit.autoTier, conversationId, + agentSessionId, chatSessionId, requestId: edit.turnId, harness: provider, @@ -347,6 +351,7 @@ export class AgentEditAttributionService extends Disposable implements IAgentEdi modelId: edit.modelId, ...(edit.autoTier !== undefined ? { autoTier: edit.autoTier } : {}), conversationId, + agentSessionId, ...(chatSessionId !== undefined ? { chatSessionId } : {}), requestId: edit.turnId, harness: provider, @@ -811,6 +816,7 @@ export class AgentEditAttributionService extends Disposable implements IAgentEdi languageId: prepared.languageId, statsUuid: prepared.statsUuid, conversationId: source.conversationId, + agentSessionId: source.agentSessionId, ...(source.chatSessionId !== undefined ? { chatSessionId: source.chatSessionId } : {}), requestId: source.requestId, origin: 'agentHost', @@ -834,6 +840,7 @@ export class AgentEditAttributionService extends Disposable implements IAgentEdi languageId: data.languageId ?? '', statsUuid: data.statsUuid, conversationId: data.conversationId, + agentSessionId: data.agentSessionId, ...(data.chatSessionId !== undefined ? { chatSessionId: data.chatSessionId } : {}), requestId: data.requestId, origin: data.origin, diff --git a/src/vs/platform/agentHost/node/shared/copilotApiService.ts b/src/vs/platform/agentHost/node/shared/copilotApiService.ts index a00224796a06b1..ae2260aa24fdc5 100644 --- a/src/vs/platform/agentHost/node/shared/copilotApiService.ts +++ b/src/vs/platform/agentHost/node/shared/copilotApiService.ts @@ -215,13 +215,13 @@ const UTILITY_DEFAULT_MODEL_FAMILY = 'gpt-4o-mini'; * Default `temperature` for utility chat completions. Matches the Copilot * Chat extension's default `IConversationOptions.temperature`. */ -const UTILITY_DEFAULT_TEMPERATURE = 0.1; +export const UTILITY_DEFAULT_TEMPERATURE = 0.1; /** * Default `top_p` for utility chat completions. Matches the Copilot Chat * extension's default `IConversationOptions.topP`. */ -const UTILITY_DEFAULT_TOP_P = 1; +export const UTILITY_DEFAULT_TOP_P = 1; /** * `OpenAI-Intent` value for utility chat completions. Matches the extension diff --git a/src/vs/platform/agentHost/node/tunnelAgentHostService.ts b/src/vs/platform/agentHost/node/tunnelAgentHostService.ts index 364d943f1c0859..56432ba82ca596 100644 --- a/src/vs/platform/agentHost/node/tunnelAgentHostService.ts +++ b/src/vs/platform/agentHost/node/tunnelAgentHostService.ts @@ -9,6 +9,8 @@ import type WebSocket from 'ws'; import { Emitter, Event } from '../../../base/common/event.js'; import { Disposable, toDisposable } from '../../../base/common/lifecycle.js'; import { ILogService } from '../../log/common/log.js'; +import { ITelemetryService } from '../../telemetry/common/telemetry.js'; +import { createTunnelServiceCorrelation, tunnelServiceHeaders } from '../../remoteTunnel/common/tunnelServiceHeaders.js'; import { PendingGatewaySelection, TunnelAgentHostConnector, @@ -185,10 +187,11 @@ export class TunnelAgentHostMainService extends Disposable implements ITunnelAge constructor( @ILogService private readonly _logService: ILogService, + @ITelemetryService private readonly _telemetryService: ITelemetryService, ) { super(); this._connector = this._register(new TunnelAgentHostConnector( - new NodeTunnelRelayClientFactory((token, authProvider) => this._createManagementClient(token, authProvider)), + new NodeTunnelRelayClientFactory((token, authProvider) => this._createManagementClient(token, authProvider, 'connect')), new NodeTunnelSocketFactory(), this._logService, )); @@ -198,7 +201,7 @@ export class TunnelAgentHostMainService extends Disposable implements ITunnelAge } async listTunnels(token: string, authProvider: 'github' | 'microsoft', additionalTunnelNames?: string[]): Promise { - const client = await this._createManagementClient(token, authProvider); + const client = await this._createManagementClient(token, authProvider, 'list'); const results: ITunnelInfo[] = []; const seen = new Set(); @@ -248,7 +251,7 @@ export class TunnelAgentHostMainService extends Disposable implements ITunnelAge } async deleteTunnel(token: string, authProvider: 'github' | 'microsoft', tunnelId: string, clusterId: string): Promise { - const client = await this._createManagementClient(token, authProvider); + const client = await this._createManagementClient(token, authProvider, 'delete'); this._logService.info(`${LOG_PREFIX} Deleting tunnel ${tunnelId} in cluster ${clusterId}...`); await client.deleteTunnel({ tunnelId, clusterId }); this._connector.closeTunnelConnections(tunnelId, 'deleting'); @@ -279,14 +282,16 @@ export class TunnelAgentHostMainService extends Disposable implements ITunnelAge return this._connector.disconnect(connectionId); } - private async _createManagementClient(token: string, authProvider: 'github' | 'microsoft'): Promise { + private async _createManagementClient(token: string, authProvider: 'github' | 'microsoft', operation: 'list' | 'connect' | 'delete'): Promise { const management = await import('@microsoft/dev-tunnels-management'); const authHeader = authProvider === 'github' ? `github ${token}` : `Bearer ${token}`; - return new management.TunnelManagementHttpClient( + const client = new management.TunnelManagementHttpClient( 'vscode-sessions', management.ManagementApiVersions.Version20230927preview, async () => authHeader, ); + client.additionalRequestHeaders = tunnelServiceHeaders(createTunnelServiceCorrelation(this._telemetryService, operation)); + return client; } } diff --git a/src/vs/platform/agentHost/test/common/agentHostByokLm.test.ts b/src/vs/platform/agentHost/test/common/agentHostByokLm.test.ts new file mode 100644 index 00000000000000..529c8a9ef1b60a --- /dev/null +++ b/src/vs/platform/agentHost/test/common/agentHostByokLm.test.ts @@ -0,0 +1,44 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { getByokLmAgentModelId, getByokLmSelectionModelId, isByokLmAgentModelId, type IByokLmModelInfo } from '../../common/agentHostByokLm.js'; + +suite('agentHostByokLm model ids', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + // The runtime-facing half of this contract (the SDK lists and selects BYOK + // models as `provider/id`, and CAPI ids are bare) is pinned against the + // bundled runtime by copilotByokSelectionIds.integrationTest.ts. + test('recognizes every agent model id produced for a BYOK model, and no Copilot model id', () => { + const byokModels: IByokLmModelInfo[] = [ + { vendor: 'acme', id: 'test-model' }, + { vendor: 'acme', id: 'test-model', modelIdentifier: 'acme/test-model' }, + { vendor: 'azure', id: 'gpt-5', modelIdentifier: 'azure/work/gpt-5' }, + { vendor: 'gemini', id: 'models/gemini-flash', modelIdentifier: 'gemini/Google/models/gemini-flash' }, + { vendor: 'openrouter', id: 'anthropic/claude', modelIdentifier: 'openrouter/anthropic/claude' }, + ]; + const copilotModelIds = ['auto', 'hydrafusion', 'claude-sonnet-4.5', 'gpt-4o-mini', 'gpt-5.6-luna', 'mai-code-1.1-flash']; + + assert.deepStrictEqual({ + byok: byokModels.map(model => { + const id = getByokLmAgentModelId(model); + return { id, isByok: isByokLmAgentModelId(id), sdkSelectionId: id === `${model.vendor}/${getByokLmSelectionModelId(model)}` }; + }), + copilot: copilotModelIds.filter(isByokLmAgentModelId), + }, { + byok: [ + { id: 'acme/test-model', isByok: true, sdkSelectionId: true }, + { id: 'acme/test-model', isByok: true, sdkSelectionId: true }, + { id: 'azure/work/gpt-5', isByok: true, sdkSelectionId: true }, + { id: 'gemini/Google/models/gemini-flash', isByok: true, sdkSelectionId: true }, + { id: 'openrouter/anthropic/claude', isByok: true, sdkSelectionId: true }, + ], + copilot: [], + }); + }); +}); diff --git a/src/vs/platform/agentHost/test/common/fileEditAttribution.test.ts b/src/vs/platform/agentHost/test/common/fileEditAttribution.test.ts index 23948a73bd1222..35e7d3ad56ec40 100644 --- a/src/vs/platform/agentHost/test/common/fileEditAttribution.test.ts +++ b/src/vs/platform/agentHost/test/common/fileEditAttribution.test.ts @@ -55,21 +55,23 @@ suite('File Edit Attribution', () => { }); }); - test('accepts optional chat identity but rejects malformed chat metadata', () => { - const results = [undefined, 'hashed-chat-id', 42].map(chatSessionId => { - const content = { - type: ToolResultContentType.FileEdit, - [FILE_EDIT_ATTRIBUTION_PROPERTY]: { - version: 1, - editId: 'edit-1', - sequence: 1, - beforeDigest: 'before', - afterDigest: 'after', - source: { conversationId: 'session-1', chatSessionId, requestId: 'turn-1', harness: 'copilotcli' }, - }, - } as const; - return !!getFileEditAttributionMarker(content); + for (const key of ['chatSessionId', 'agentSessionId']) { + test(`accepts optional ${key} but rejects malformed metadata`, () => { + const results = [undefined, 'session-id', 42].map(value => { + const content = { + type: ToolResultContentType.FileEdit, + [FILE_EDIT_ATTRIBUTION_PROPERTY]: { + version: 1, + editId: 'edit-1', + sequence: 1, + beforeDigest: 'before', + afterDigest: 'after', + source: { conversationId: 'session-1', [key]: value, requestId: 'turn-1', harness: 'copilotcli' }, + }, + } as const; + return !!getFileEditAttributionMarker(content); + }); + assert.deepStrictEqual(results, [true, true, false]); }); - assert.deepStrictEqual(results, [true, true, false]); - }); + } }); diff --git a/src/vs/platform/agentHost/test/node/__snapshots__/Copilot_SDK_-_managed_settings_bundled_managedSettings_schema_matches_the_snapshot.schema.json b/src/vs/platform/agentHost/test/node/__snapshots__/Copilot_SDK_-_managed_settings_bundled_managedSettings_schema_matches_the_snapshot.schema.json new file mode 100644 index 00000000000000..3d3fdecb33fe3a --- /dev/null +++ b/src/vs/platform/agentHost/test/node/__snapshots__/Copilot_SDK_-_managed_settings_bundled_managedSettings_schema_matches_the_snapshot.schema.json @@ -0,0 +1,1158 @@ +{ + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/schemas/copilot/managed-settings/v0", + "title": "Managed Settings for Copilot Clients", + "description": "Schema for managed settings delivered via MDM (plist/registry), file-based (managed-settings.json), or server-managed (/copilot_internal/managed_settings). All keys are optional — an empty document or a document with only a subset of keys is valid.", + "type": "object", + "additionalProperties": false, + "properties": { + "allowedMcpServers": { + "description": "Managed allowlist of MCP servers users may load, aligned with Claude Code semantics. Each entry identifies servers by exactly one of serverUrl (URL with '*' wildcards), serverCommand (exact command+args), or serverName (literal label). Governs only non-default servers: trusted first-party servers (e.g. the built-in GitHub MCP server) are always exempt. Unset means all (non-default) servers are allowed; an empty array admits none of them; a populated list admits only matching ones. For remote (HTTP/SSE) servers the match must be a serverUrl entry (serverName counts only when no serverUrl entries exist); for stdio servers the match must be a serverCommand entry (serverName counts only when no serverCommand entries exist). Denied entries always win over allowed entries.", + "type": "array", + "items": { + "$ref": "#/$defs/mcpAllowMatcher" + }, + "x-composition": { + "strategy": "allowlist-intersection" + } + }, + "deniedMcpServers": { + "description": "Managed denylist of MCP servers that must never load, aligned with Claude Code semantics. Each entry identifies servers by exactly one of serverUrl (URL with '*' wildcards), serverCommand (exact command+args), or serverName (literal label). A matching non-default server is blocked regardless of the allowlist. Trusted first-party servers (e.g. the built-in GitHub MCP server) are exempt and cannot be blocked by a deny entry. Unset or an empty array blocks nothing.", + "type": "array", + "items": { + "$ref": "#/$defs/mcpDenyMatcher" + }, + "x-composition": { + "strategy": "deny-wins" + } + }, + "strictPluginOnlyCustomization": { + "description": "Customization lockdown baseline. When set, prevents standalone user and workspace/project customizations from the named surfaces; those customizations may then originate only from plugins or managed settings. It does NOT mean managed-plugin-only — eligible plugin-provided customizations are still permitted (use `allowManagedMcpServersOnly` / `allowManagedHooksOnly` for the stricter managed-only overlays). `true` locks all four surfaces (skills, agents, hooks, MCP servers); an array locks only the named surfaces. Unset preserves existing behavior.", + "oneOf": [ + { + "description": "true locks all four surfaces (skills, agents, hooks, MCP servers); false preserves existing behavior.", + "type": "boolean" + }, + { + "description": "Locks only the named customization surfaces.", + "type": "array", + "items": { + "type": "string", + "enum": [ + "skills", + "agents", + "hooks", + "mcp" + ] + }, + "uniqueItems": true + } + ], + "x-composition": { + "strategy": "most-restrictive-wins", + "description": "Union of locked surfaces across managed sources: a surface locked by any higher-priority managed source stays locked; the boolean true form is equivalent to locking every surface." + } + }, + "allowManagedMcpServersOnly": { + "description": "Stricter overlay on top of `strictPluginOnlyCustomization`: when true, only the managed `allowedMcpServers` allowlist (plus MCP servers provided by managed/force-enabled plugins and any fixed managed-MCP deployment) governs which MCP servers are eligible. Lower-precedence user and workspace/project allowlist entries cannot broaden it; `deniedMcpServers` retains its restrictive deny-wins merge. Trusted first-party servers (e.g. the built-in GitHub MCP server) remain exempt. Unset or false preserves existing behavior.", + "type": "boolean", + "x-composition": { + "strategy": "most-restrictive-wins", + "description": "Sticky-true: a lower-priority source cannot undo allowManagedMcpServersOnly:true set by a higher-priority managed source." + } + }, + "allowManagedHooksOnly": { + "description": "Stricter overlay on top of `strictPluginOnlyCustomization`: when true, only enterprise-managed hooks and hooks supplied by plugins force-enabled through managed `enabledPlugins` may load. User, workspace/project, and otherwise user-enabled plugin hooks are blocked. This does not silently grant additional tool or network access; normal managed permission and sandbox policies still apply. Unset or false preserves existing behavior.", + "type": "boolean", + "x-composition": { + "strategy": "most-restrictive-wins", + "description": "Sticky-true: a lower-priority source cannot undo allowManagedHooksOnly:true set by a higher-priority managed source." + } + }, + "features": { + "description": "Open map of managed product feature availability. A false value blocks the named feature; true or absent does not force-enable it.", + "type": "object", + "additionalProperties": { + "type": "boolean" + } + }, + "model": { + "description": "Default model identifier for new conversations, including 'auto' or a concrete model ID. Users may override this startup default. Hosts should match it against the account's available models.", + "type": "string", + "x-composition": { + "strategy": "highest-layer-wins" + } + }, + "effortLevel": { + "description": "Reasoning effort for the managed concrete model. A bare string locks the value; use {\"overridable\":\"\"} to provide a default users and repositories may override. Invalid values are ignored without rejecting the rest of the managed document.", + "x-mdm-delivery": false, + "oneOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "object", + "properties": { + "overridable": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "overridable" + ], + "additionalProperties": false + } + ], + "x-composition": { + "allowOverridable": true, + "strategy": "enum-order-wins", + "collapseStrategy": "enum-order-permissive-wins", + "description": "Shared settings-engine annotations, not an independent-options execution contract. This runtime keeps model, effortLevel, and contextTier coupled: options come only from the channel supplying the winning concrete model. Use managedSettings.compose for the runtime's effective result." + } + }, + "contextTier": { + "description": "Context tier for the managed concrete model. A bare string locks the value; use {\"overridable\":\"\"} to provide a default users and repositories may override. Invalid values are ignored without rejecting the rest of the managed document.", + "x-mdm-delivery": false, + "oneOf": [ + { + "type": "string", + "enum": [ + "default", + "long_context" + ] + }, + { + "type": "object", + "properties": { + "overridable": { + "type": "string", + "enum": [ + "default", + "long_context" + ] + } + }, + "required": [ + "overridable" + ], + "additionalProperties": false + } + ], + "x-composition": { + "allowOverridable": true, + "strategy": "enum-order-wins", + "collapseStrategy": "enum-order-permissive-wins", + "description": "Shared settings-engine annotations, not an independent-options execution contract. This runtime keeps model, effortLevel, and contextTier coupled: options come only from the channel supplying the winning concrete model. Use managedSettings.compose for the runtime's effective result." + } + }, + "autoTier": { + "description": "Default Auto routing preference for new conversations when the selected model is auto. A bare string strictly locks the tier and overrides user and repository settings. Use {\"overridable\":\"\"} to provide an organization default that users and repositories may override.", + "oneOf": [ + { + "type": "string", + "minLength": 1, + "pattern": "^[^\\s\\u0000-\\u001F\\u007F-\\u009F]+$" + }, + { + "type": "object", + "properties": { + "overridable": { + "type": "string", + "minLength": 1, + "pattern": "^[^\\s\\u0000-\\u001F\\u007F-\\u009F]+$" + } + }, + "required": [ + "overridable" + ], + "additionalProperties": false + } + ], + "x-composition": { + "allowOverridable": true, + "strategy": "enum-order-wins", + "collapseStrategy": "enum-order-permissive-wins", + "description": "Shared settings-engine annotations are descriptive, not a complete specification of this runtime's composition rules. Use managedSettings.compose for the runtime's effective result." + } + }, + "permissions": { + "description": "Controls that govern permissions mode behavior.", + "type": "object", + "additionalProperties": false, + "properties": { + "model": { + "description": "Legacy location of the model default in file and server policy. Accepted as a fallback when top-level model is absent, then canonicalized to the top-level key. Not supported through native plist/registry delivery.", + "x-mdm-delivery": false, + "type": "string", + "deprecated": true + }, + "disableBypassPermissionsMode": { + "description": "Controls bypass-permissions ('allow-all' / 'yolo') escalation. 'disable' blocks combined bypass mode and individual tool, path, and URL allow-all flags across CLI and runtime APIs. Advisory auto-approval remains available because normal prompt paths stay active. Deliberately unconstrained: a client must not reject the document over a value it does not recognize, so a value a newer client or the server understands never fails the whole policy. Because the key is present only to restrict something, a client that cannot interpret the value must fail closed and enforce the most restrictive mode it knows.", + "type": "string", + "x-composition": { + "strategy": "most-restrictive-wins", + "restrictiveness": [ + "disable" + ], + "description": "The composed value is the most restrictive one any managed source supplies, ranked by 'restrictiveness' (most restrictive first). An absent key imposes no restriction. Any value outside that list ranks above every listed value and is enforced as the first entry, so an unrecognized string can never relax a restriction another source contributed." + } + }, + "disableAssistedPermissionsMode": { + "description": "When true, prevents Assisted Permissions from being activated; an active or requested Assisted mode falls back to Manual Approval. Assisted Permissions uses probabilistic LLM review to reduce prompts and is not a security boundary. Managed permission rules, sandboxing, least-privilege credentials, and egress controls remain the hard boundaries. Unset or false imposes no restriction.", + "type": "boolean", + "x-composition": { + "strategy": "most-restrictive-wins", + "description": "Sticky-true: a lower-priority managed source cannot undo disableAssistedPermissionsMode:true set by another source." + } + }, + "defaultMode": { + "description": "Starting permission mode for new sessions: 'manual' (Manual Approval), 'assisted' (Assisted Permissions), or 'allow-all'. It replaces the user's defaultPermissionMode setting and organization targeting, but an explicit choice at startup (a command-line flag or a host request) still wins, and users can change the mode during the session. Resumed sessions keep their mode. A default blocked by disableAssistedPermissionsMode or disableBypassPermissionsMode, or Assisted when it is unavailable, starts in Manual Approval. Unrecognized values are treated as 'manual'.", + "type": "string", + "x-composition": { + "strategy": "most-restrictive-wins", + "restrictiveness": [ + "manual", + "assisted", + "allow-all" + ], + "description": "The composed value is the most restrictive one any managed source supplies, ranked by 'restrictiveness' (most restrictive first)." + } + }, + "deny": { + "description": "Permission rules that block matching operations. Deny takes precedence over ask and allow and is unioned across managed sources.", + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "x-composition": { + "strategy": "deny-wins" + } + }, + "ask": { + "description": "Permission rules that require human approval. Ask takes precedence over allow and is unioned across managed sources.", + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "x-composition": { + "strategy": "deny-wins" + } + }, + "allow": { + "description": "Permission rules that allow matching operations without prompting. Every managed source that declares an allow list must admit the operation.", + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "x-composition": { + "strategy": "allowlist-intersection" + } + }, + "limitTo": { + "description": "Closed-world boundary for the rule families it names. Only Domain(...) rules are supported: any agent network request to a domain outside the boundary is denied without prompting, and user rules, saved approvals, and allow-all modes cannot widen it. It does not restrict shell, read, or write operations by themselves, but detected URLs in shell requests are Domain requests and must satisfy the boundary. An empty list denies every Domain request. An invalid list or any invalid entry is reported as a configuration error and enforced as an empty list, without discarding other valid permission rules; effective settings report the enforced empty list. Deny takes precedence over limitTo, and ask and allow only refine requests inside the boundary. Every managed source that declares a limitTo list must admit the request.", + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "x-composition": { + "strategy": "allowlist-intersection" + } + } + } + }, + "remoteControl": { + "description": "Controls whether Copilot CLI sessions running on this device can be controlled from other devices. 'enabled' permits control, 'disabled' blocks control, and 'requireSSO' permits control only when the client is SSO-authorized for at least one configured GitHub.com organization or authenticated to at least one configured GitHub Enterprise Cloud domain.", + "type": "object", + "additionalProperties": false, + "required": [ + "mode" + ], + "properties": { + "mode": { + "description": "Policy for controlling sessions on this device from other devices.", + "type": "string", + "enum": [ + "enabled", + "disabled", + "requireSSO" + ], + "x-composition": { + "strategy": "enum-order-wins" + } + }, + "githubDotComOrganizations": { + "description": "GitHub.com organization logins. When mode is 'requireSSO', the client must be SSO-authorized for at least one organization in this list.", + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + }, + "githubEnterpriseCloudDomains": { + "description": "GitHub Enterprise Cloud with data residency domains. Accepted formats are the tenant's URL (for example, 'https://octocorp.ghe.com'), bare host ('octocorp.ghe.com'), or tenant slug ('octocorp'). When mode is 'requireSSO', authentication to any domain in this list satisfies the policy.", + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + } + }, + "allOf": [ + { + "if": { + "properties": { + "mode": { + "const": "requireSSO" + } + }, + "required": [ + "mode" + ] + }, + "then": { + "anyOf": [ + { + "required": [ + "githubDotComOrganizations" + ] + }, + { + "required": [ + "githubEnterpriseCloudDomains" + ] + } + ] + } + } + ], + "x-composition": { + "strategy": "enum-order-wins" + } + }, + "enabledPlugins": { + "description": "Declarative plugin allow/deny list. Keys use the format PLUGIN-NAME@MARKETPLACE-NAME. true = must be enabled, false = must be disabled.", + "type": "object", + "additionalProperties": { + "type": "boolean" + }, + "x-composition": { + "strategy": "deny-wins" + } + }, + "extraKnownMarketplaces": { + "description": "Managed additional marketplace sources beyond built-ins. Each entry defines how to resolve a marketplace name to a source.", + "type": "object", + "additionalProperties": { + "type": "object", + "required": [ + "source" + ], + "properties": { + "autoUpdate": { + "description": "When true, this marketplace's installed plugins are refreshed to the latest catalog version at the start of a Copilot CLI session (interactive or `-p`); SDK/server sessions do not auto-update. Honored from the user's own settings or from managed (MDM/server) settings — a repository-level opt-in is ignored. On a same-name collision the precedence is: a built-in first-party marketplace wins, then managed (which replaces the whole same-named user entry, so a managed entry without \"autoUpdate\": true removes the user's opt-in), then user.", + "type": "boolean" + }, + "source": { + "description": "Source descriptor for this marketplace.", + "type": "object", + "required": [ + "source" + ], + "properties": { + "source": { + "description": "Source type for the marketplace.", + "type": "string", + "enum": [ + "github", + "git", + "directory" + ] + } + }, + "oneOf": [ + { + "properties": { + "source": { + "const": "github" + }, + "repo": { + "description": "Repository NWO (owner/repo) containing the marketplace plugins.", + "type": "string", + "pattern": "^[^/:]+/[^/:]+$" + }, + "ref": { + "description": "Optional branch, tag, or SHA to pin the marketplace source to.", + "type": "string" + }, + "path": { + "description": "Optional subdirectory within the repository containing plugins.", + "type": "string" + } + }, + "required": [ + "source", + "repo" + ], + "additionalProperties": false + }, + { + "properties": { + "source": { + "const": "git" + }, + "url": { + "description": "Git URL for the marketplace repository.", + "type": "string", + "format": "uri" + }, + "ref": { + "description": "Optional branch, tag, or SHA to pin the marketplace source to.", + "type": "string" + }, + "path": { + "description": "Optional subdirectory within the repository containing plugins.", + "type": "string" + } + }, + "required": [ + "source", + "url" + ], + "additionalProperties": false + }, + { + "properties": { + "source": { + "const": "directory" + }, + "path": { + "description": "Directory containing the marketplace source.", + "type": "string" + } + }, + "required": [ + "source", + "path" + ], + "additionalProperties": false + } + ] + } + }, + "additionalProperties": false + }, + "x-composition": { + "strategy": "allowlist-intersection" + } + }, + "strictKnownMarketplaces": { + "description": "Strict marketplace allowlist. Each entry must match a supported source descriptor (github, git, url, npm, file, directory, hostPattern, or pathPattern). Empty array means complete lockdown.", + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "required": [ + "source", + "repo" + ], + "properties": { + "source": { + "const": "github" + }, + "repo": { + "description": "Repository NWO (owner/repo) containing the marketplace plugins.", + "type": "string", + "pattern": "^[^/:]+/[^/:]+$" + }, + "ref": { + "description": "Optional branch, tag, or SHA to pin the marketplace source to.", + "type": "string" + }, + "path": { + "description": "Optional subdirectory within the repository containing plugins.", + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "source", + "url" + ], + "properties": { + "source": { + "const": "git" + }, + "url": { + "description": "Git URL for the marketplace repository.", + "type": "string", + "format": "uri" + }, + "ref": { + "description": "Optional branch, tag, or SHA to pin the marketplace source to.", + "type": "string" + }, + "path": { + "description": "Optional subdirectory within the repository containing plugins.", + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "source", + "url" + ], + "properties": { + "source": { + "const": "url" + }, + "url": { + "description": "HTTP(S) URL for the marketplace catalog.", + "type": "string", + "format": "uri" + }, + "headers": { + "description": "Optional HTTP headers for fetching the catalog.", + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "source", + "package" + ], + "properties": { + "source": { + "const": "npm" + }, + "package": { + "description": "NPM package name for the marketplace catalog.", + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "source", + "path" + ], + "properties": { + "source": { + "const": "file" + }, + "path": { + "description": "Filesystem path to the marketplace catalog.", + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "source", + "path" + ], + "properties": { + "source": { + "const": "directory" + }, + "path": { + "description": "Directory containing the marketplace catalog.", + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "source", + "hostPattern" + ], + "properties": { + "source": { + "const": "hostPattern" + }, + "hostPattern": { + "description": "Regex used to match marketplace hosts.", + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "source", + "pathPattern" + ], + "properties": { + "source": { + "const": "pathPattern" + }, + "pathPattern": { + "description": "Regex used to match marketplace paths.", + "type": "string" + } + }, + "additionalProperties": false + } + ] + }, + "x-composition": { + "strategy": "allowlist-intersection" + } + }, + "forceRemoteSettingsRefresh": { + "description": "When true, requires a fresh server-managed settings fetch on startup even when a fresh persistent cache entry exists: the cached policy is never served, and — unlike the default path — it is not used as a fetch-failure fallback either. A failed startup fetch therefore fails closed on the unconfirmed policy (skipping both the 1h fast path and the 24h stale fallback) rather than reverting to a possibly-stale cached policy. The directive applies even when it lives in the cache — a cached server response carrying it forces the next startup to re-fetch. The device (MDM) value takes precedence over a cached server value, so an explicit MDM false overrides a server-side true. Settable via the server-managed policy or the device (MDM) layer. The policy helper cannot set this key. Note: like every typed MDM value, when delivered natively (plist/registry) the boolean must be supplied as a JSON-encoded string (the string \"true\" or \"false\"); a native plist boolean (CFBoolean) or registry DWORD is not accepted. File-based (managed-settings.json) and server-managed documents use a real JSON boolean.", + "type": "boolean" + }, + "policyHelper": { + "description": "Registers an executable that supplies the lowest-priority managed-settings layer. If device and server policy register different executables, the device registration takes priority.", + "type": "object", + "additionalProperties": false, + "required": [ + "path" + ], + "properties": { + "path": { + "description": "Executable registration in one of three forms: an absolute path naming a file, a home-relative path (`~/...` or `~\\...`) with a path after the separator, or a bare program name with no path separator. The registration form is the trust decision: an absolute path pins the exact binary, while a bare name defers to the binary resolved from the user's PATH. The value is used verbatim, so other relative paths, root-only paths, a trailing separator, surrounding whitespace, and any other `~` form (`~`, `~/`, `~bob`) are rejected.", + "type": "string", + "minLength": 1, + "allOf": [ + { + "$comment": "No surrounding whitespace, no trailing separator, and no bare dot component.", + "not": { + "pattern": "^\\s|\\s$|[/\\\\]$|^\\.{1,2}$" + } + }, + { + "$comment": "Absolute (POSIX, Windows drive, or Windows UNC), home-relative, or a bare name carrying no separator and not claiming the reserved `~` prefix.", + "anyOf": [ + { + "pattern": "^/.+" + }, + { + "pattern": "^[A-Za-z]:[/\\\\].+" + }, + { + "pattern": "^\\\\\\\\[^/\\\\]+[/\\\\].+" + }, + { + "pattern": "^~[/\\\\].+" + }, + { + "pattern": "^[^~/\\\\][^/\\\\]*$" + } + ] + } + ] + }, + "args": { + "description": "Arguments passed to the helper executable, in order. At the spawn boundary, the runtime passes each element to the program verbatim as a separate argument without asking a shell to parse another command. Use this to select a subcommand of a helper that serves several callers.", + "type": "array", + "items": { + "type": "string" + } + }, + "timeoutMs": { + "description": "Milliseconds the runtime waits for one helper run before it gives up. The helper runs while the session starts, so the value is capped to keep startup bounded.", + "type": "integer", + "minimum": 1, + "maximum": 30000, + "default": 5000 + } + } + }, + "forceLoginOrgs": { + "description": "Pins sign-in to an approved set of GitHub organizations. Each entry is a GitHub organization login (the URL slug, for example 'octocorp'); matching is case-insensitive. When set, Copilot only runs for an account that belongs to at least one listed organization — a personal account, or an account belonging only to some other enterprise, is refused with an actionable error instead of silently escaping enterprise-managed policy. Because it must be able to redirect a developer's *first* sign-in, deploy it via the device channel (MDM plist/registry or managed-settings.json); the server-managed channel only reaches accounts that already authenticated into the organization. The policy helper cannot set this key. The setting also blocks bring-your-own-key (BYOK / API-key) authentication outright, since such credentials carry no GitHub identity whose organization membership could be verified. An empty array means no pin, so the control can be turned off without deleting the key. Fails closed: if the value is present but unusable, or the managed policy that carries it cannot be read from a device that is known to be managed, no account is permitted to sign in until the value is fixed. Note: like every typed MDM value, when delivered natively (plist/registry) the array must be supplied as a JSON-encoded string (for example \"[\\\"octocorp\\\"]\"). File-based (managed-settings.json) and server-managed documents use a real JSON array.", + "type": "array", + "items": { + "type": "string", + "description": "A GitHub organization login: 1-39 characters of ASCII letters and digits, optionally separated by single hyphens. Surrounding whitespace is trimmed. URLs, 'org/team' slugs, and email addresses are rejected.", + "pattern": "^\\s*[A-Za-z0-9](?:-?[A-Za-z0-9]){0,38}\\s*$" + }, + "uniqueItems": true + }, + "policyHelperFailureMode": { + "description": "What happens when the registered policy helper does not produce a usable layer, because it failed to start, timed out, exited non-zero, exceeded the output limit, or emitted output that failed schema validation. Use \"ignore\" (the default) to resolve policy from the remaining channels and report the fault as an error. Use \"failClosed\" to treat the fault as making policy undetermined, which engages the sandbox fail-closed protection. If device and server policy set different values, the device value takes priority.", + "type": "string", + "enum": [ + "ignore", + "failClosed" + ], + "default": "ignore" + }, + "telemetry": { + "description": "Enterprise-mandated OpenTelemetry (OTel) configuration. Admin-only and non-overridable by users; consumed uniformly by Copilot clients. Controls where Copilot telemetry is exported and whether sensitive content is captured. Operates independently from GitHub's own audit streaming.", + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "description": "Enables OTel emission. When true, users cannot disable telemetry export via local settings or environment variables.", + "type": "boolean" + }, + "endpoint": { + "description": "OTLP collector endpoint. Equivalent to the OTEL_EXPORTER_OTLP_ENDPOINT environment variable; the managed value takes precedence over any user-provided value.", + "type": "string", + "format": "uri" + }, + "protocol": { + "description": "OTLP transport protocol used for all exported telemetry. Equivalent to the OTEL_EXPORTER_OTLP_PROTOCOL environment variable; the managed value takes precedence over any user-provided value. Recognized values are \"http/json\" and \"http/protobuf\" (\"grpc\" is accepted for forward compatibility but is not implemented in this version and falls back to the default protocol).", + "type": "string" + }, + "headers": { + "description": "Auth and routing headers sent to the OTLP collector. Equivalent to OTEL_EXPORTER_OTLP_HEADERS. Values are literal strings (no secret interpolation in this version) and are never surfaced in logs or status output. Requires endpoint in the same telemetry block so managed headers are never sent to a destination resolved from user-controlled environment variables. The endpoint must use HTTP or HTTPS. HTTP transmits every header value in plaintext and produces a runtime warning; authentication credentials, including bearer tokens, are confidential and must use HTTPS. Use HTTP only for non-confidential routing metadata on a trusted network.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "resourceAttributes": { + "description": "Extra OTel resource attributes (e.g. cost center / team routing). Equivalent to OTEL_RESOURCE_ATTRIBUTES.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "capture": { + "description": "Fine-grained controls for sensitive OpenTelemetry attributes. Every field defaults to false. Explicit fields override the legacy captureContent shorthand for the same data class.", + "type": "object", + "additionalProperties": false, + "properties": { + "identity": { + "description": "Emits identity attributes for enterprise audit attribution: the authenticated provider account as user.name, the local operating-system account as process.user.name, and the local machine as host.name. Does not affect enduser.pseudo.id.", + "type": "boolean", + "default": false + }, + "prompts": { + "description": "Captures user, system, and developer prompt content and associated input context, including supported attachments.", + "type": "boolean", + "default": false + }, + "responses": { + "description": "Captures assistant response and supported reasoning content.", + "type": "boolean", + "default": false + }, + "toolArguments": { + "description": "Captures raw tool arguments, tool descriptions and schemas, and sensitive tool or MCP identifiers.", + "type": "boolean", + "default": false + }, + "toolOutput": { + "description": "Captures tool result content and execution error detail.", + "type": "boolean", + "default": false + }, + "policyDetail": { + "description": "Captures raw policy details such as sandbox filesystem paths. Bounded policy posture, counts, outcomes, and classifications remain available when false.", + "type": "boolean", + "default": false + } + } + }, + "captureContent": { + "description": "Legacy shorthand for prompts, responses, toolArguments, toolOutput, and policyDetail. Does not enable identity. Explicit capture fields override this value for their class. Defaults to false.", + "type": "boolean" + }, + "lockCaptureContent": { + "description": "When true, prevents local settings from enabling a legacy content class unless that class or captureContent is explicitly set in this managed telemetry block. Does not govern identity.", + "type": "boolean" + }, + "serviceName": { + "description": "Overrides the OTel service.name resource attribute. Equivalent to OTEL_SERVICE_NAME.", + "type": "string" + } + }, + "x-composition": { + "strategy": "highest-priority-managed-block-wins", + "description": "The highest-priority managed channel that defines telemetry supplies the complete managed telemetry block; weaker managed telemetry blocks do not merge into it. Explicit managed fields are authoritative over local or environment configuration. Omitted fields fall back according to the client resolver, including legacy captureContent and lockCaptureContent compatibility." + } + }, + "sandbox": { + "description": "Enterprise-mandated sandbox floor. Admin-set values compose with the user's own sandbox configuration in the safe (more-restrictive) direction and cannot be loosened by the user. Only non-deprecated keys are part of the managed surface. The restrictiveness merge itself is applied by the client at resolve time (see #11374); recognizing the block here is behavior-neutral.", + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "description": "Force sandboxing on by default. When a managed source sets true, ordinary user configuration, startup flags, and sandbox settings cannot disable the sandbox. If the effective policy also allows bypass, the user may explicitly disable it for the current session from an active sandbox-bypass permission prompt.", + "type": "boolean", + "x-composition": { + "strategy": "force-on-wins" + } + }, + "failIfUnavailable": { + "description": "When true and the composed managed policy also sets sandbox.enabled to true, the managed sandbox becomes mandatory. Copilot must block model and tool execution if the policy cannot be validated, compiled, or enforced by a usable sandbox backend, and the user cannot disable it. This control does not enable sandboxing by itself; true from any managed source wins.", + "type": "boolean", + "x-composition": { + "strategy": "force-on-wins" + } + }, + "allowBypass": { + "description": "Whether tools may offer the user a per-operation sandbox bypass and whether the user may explicitly disable the sandbox for the current session from an active bypass permission prompt. Managed false wins: if policy forbids bypass, the user cannot re-enable either escape hatch.", + "type": "boolean", + "x-composition": { + "strategy": "deny-wins" + } + }, + "addCurrentWorkingDirectory": { + "description": "Whether the current working directory is auto-added to the sandbox readwrite paths. Managed false wins.", + "type": "boolean", + "x-composition": { + "strategy": "deny-wins" + } + }, + "sandboxMcpServers": { + "description": "Whether MCP servers are spawned inside the sandbox. Routing flag: true (servers sandboxed) is the more-restrictive value, so a managed true wins (force-on) and a user false cannot loosen it.", + "type": "boolean", + "x-composition": { + "strategy": "force-on-wins" + } + }, + "sandboxLspServers": { + "description": "Whether LSP servers are spawned inside the sandbox. Routing flag: true (servers sandboxed) is the more-restrictive value, so a managed true wins (force-on) and a user false cannot loosen it.", + "type": "boolean", + "x-composition": { + "strategy": "force-on-wins" + } + }, + "auth": { + "description": "Credential-injection capabilities applied while the sandbox is enabled.", + "type": "object", + "additionalProperties": false, + "properties": { + "git": { + "description": "Whether a git token is injected so git works inside the sandbox. Credential-injection capability; managed false wins: policy can force it off but never force it on.", + "type": "boolean", + "x-composition": { + "strategy": "deny-wins" + } + }, + "gh": { + "description": "Whether a gh token is injected so gh works inside the sandbox. Credential-injection capability; managed false wins: policy can force it off but never force it on.", + "type": "boolean", + "x-composition": { + "strategy": "deny-wins" + } + } + } + }, + "allowDevToolAccess": { + "description": "Whether the sandbox automatically grants access to developer-tool files needed by builds, including the tool directories discovered on PATH and in toolchain environment variables (GOROOT, CARGO_HOME, JAVA_HOME, and similar), the user-profile tool directories, plus caches, registries, toolchains, and registry/proxy configuration in default home locations. Delivering false disables all of them, so a developer-tool path must then be granted explicitly through sandbox.userPolicy.filesystem. This governs only the developer-tool grants: the working directory (sandbox.addCurrentWorkingDirectory), temporary storage, session log paths, and the platform's system locations — which on macOS include the /usr/local and /opt/homebrew prefixes — are granted by their own rules and are unaffected. Managed false wins: policy can disable these grants but never force them on", + "type": "boolean", + "x-composition": { + "strategy": "deny-wins" + } + }, + "learningMode": { + "description": "Windows only. The denial-capture mode sandboxed shell commands start in when the sandbox is enabled: \"deny\" records and denies blocked access (the default), and \"allow\" records and allows filesystem and process access (permissive learning mode) while the network policy still enforces. With \"allow\", a command that is still refused offers only the full sandbox bypass, and only when sandbox.allowBypass permits it. \"deny\" is the stricter value and wins when managed sources disagree. Deliver \"allow\" through native MDM (the Windows registry policy hive, for example through Intune); an \"allow\" in managed-settings.json is ignored. Ignored on hosts without denial capture", + "type": "string", + "enum": [ + "deny", + "allow" + ], + "x-composition": { + "strategy": "enum-order-wins", + "description": "deny wins when managed sources disagree." + } + }, + "userPolicy": { + "description": "Fine-grained sandbox policy (filesystem, network, seatbelt). Deprecated sub-keys (userPolicy.experimental) are excluded from the managed surface.", + "type": "object", + "additionalProperties": false, + "properties": { + "filesystem": { + "description": "Filesystem access policy. Grant lists shrink under policy (intersection); the deny list grows (union).", + "type": "object", + "additionalProperties": false, + "properties": { + "readwritePaths": { + "description": "Paths granted read-write access. Managed policy can only shrink the effective grant (intersection).", + "type": "array", + "items": { + "type": "string" + }, + "x-composition": { + "strategy": "allowlist-intersection" + } + }, + "readonlyPaths": { + "description": "Paths granted read-only access. Managed policy can only shrink the effective grant (intersection).", + "type": "array", + "items": { + "type": "string" + }, + "x-composition": { + "strategy": "allowlist-intersection" + } + }, + "deniedPaths": { + "description": "Paths explicitly denied. Managed policy can only add denials (union).", + "type": "array", + "items": { + "type": "string" + }, + "x-composition": { + "strategy": "denylist-union" + } + } + } + }, + "network": { + "description": "Network access policy. Managed false wins for each toggle. Non-empty host allowlists intersect; denylists combine and take precedence. A managed proxy URL overrides the user's URL while credentials remain user-managed.", + "type": "object", + "additionalProperties": false, + "properties": { + "allowOutbound": { + "description": "Whether outbound network access is permitted. Managed false wins.", + "type": "boolean", + "x-composition": { + "strategy": "deny-wins" + } + }, + "allowLocalNetwork": { + "description": "Whether local-network access is permitted. Managed false wins.", + "type": "boolean", + "x-composition": { + "strategy": "deny-wins" + } + }, + "allowedHosts": { + "description": "Host allowlist for the built-in local proxy. Non-empty lists intersect with other configured allowlists; absent or empty lists add no restriction. Supports exact hosts, IP addresses, and *.example.com for strict subdomains.", + "type": "array", + "items": { + "$ref": "#/$defs/hostPattern" + }, + "x-composition": { + "strategy": "allowlist-intersection" + } + }, + "blockedHosts": { + "description": "Host denylist for the built-in local proxy. Combined with other denylists; deny takes precedence over allow. A domain also denies all its subdomains. IP addresses match exactly; *.example.com matches strict subdomains, and * denies every host.", + "type": "array", + "items": { + "$ref": "#/$defs/hostPattern" + }, + "x-composition": { + "strategy": "denylist-union" + } + }, + "proxy": { + "description": "Enterprise-managed sandbox proxy endpoint. Only the URL is managed; username and password remain user-managed and are not admitted by this schema.", + "type": "object", + "additionalProperties": false, + "required": [ + "url" + ], + "properties": { + "url": { + "description": "Credential-free HTTP(S) proxy endpoint enforced over the user's configured proxy URL. The URL must contain only printable ASCII characters (percent-encode non-ASCII path data); the host must be an ASCII DNS hostname, dotted-decimal IPv4 address, or bracketed hexadecimal IPv6 address; and an explicit port must be between 1 and 65535. Device-managed settings take precedence when managed sources specify different URLs.", + "type": "string", + "minLength": 1, + "anyOf": [ + { + "pattern": "^[Hh][Tt][Tt][Pp][Ss]?://(?=[A-Za-z0-9.-]{1,253}(?::|[/?#]|$))(?=[A-Za-z0-9.-]*[A-Za-z-][A-Za-z0-9-]*\\.?(?::|[/?#]|$))[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*\\.?(?::(?!0{1,4}(?:[/?#]|$))(?:[0-9]{1,4}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]))?(?:[/?#][!-~]*)?$" + }, + { + "pattern": "^[Hh][Tt][Tt][Pp][Ss]?://(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3}(?::(?!0{1,4}(?:[/?#]|$))(?:[0-9]{1,4}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]))?(?:[/?#][!-~]*)?$" + }, + { + "pattern": "^[Hh][Tt][Tt][Pp][Ss]?://\\[(?:(?:[0-9A-Fa-f]{1,4}:){7}[0-9A-Fa-f]{1,4}|(?:[0-9A-Fa-f]{1,4}:){1,7}:|(?:[0-9A-Fa-f]{1,4}:){1,6}:[0-9A-Fa-f]{1,4}|(?:[0-9A-Fa-f]{1,4}:){1,5}(?::[0-9A-Fa-f]{1,4}){1,2}|(?:[0-9A-Fa-f]{1,4}:){1,4}(?::[0-9A-Fa-f]{1,4}){1,3}|(?:[0-9A-Fa-f]{1,4}:){1,3}(?::[0-9A-Fa-f]{1,4}){1,4}|(?:[0-9A-Fa-f]{1,4}:){1,2}(?::[0-9A-Fa-f]{1,4}){1,5}|[0-9A-Fa-f]{1,4}:(?:(?::[0-9A-Fa-f]{1,4}){1,6})|:(?:(?::[0-9A-Fa-f]{1,4}){1,7}|:))\\](?::(?!0{1,4}(?:[/?#]|$))(?:[0-9]{1,4}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]))?(?:[/?#][!-~]*)?$" + } + ], + "x-composition": { + "strategy": "managed-precedence" + } + } + } + } + } + }, + "seatbelt": { + "description": "macOS seatbelt policy.", + "type": "object", + "additionalProperties": false, + "properties": { + "keychainAccess": { + "description": "Whether keychain access is permitted from within the sandbox. Managed false wins.", + "type": "boolean", + "x-composition": { + "strategy": "deny-wins" + } + } + } + } + } + } + }, + "x-composition": { + "strategy": "restrictiveness-merge", + "description": "The managed sandbox is a floor: each field composes with the user's value in the safe direction (force-on for enabled and the MCP/LSP routing flags, deny-wins for the remaining boolean toggles including allowDevToolAccess, intersection for the readwrite/readonly grant lists, union for the deny list). The proxy URL is a directional managed override: it replaces the user's URL while preserving user-managed credentials, and device-managed settings win conflicts between managed sources. Deprecated keys (sandbox.config, sandbox.userPolicy.experimental) are excluded. The runtime applies this per-field merge to compute the effective sandbox policy." + } + } + }, + "$defs": { + "hostPattern": { + "description": "A host rule in standard ASCII DNS, dotted IPv4, or hexadecimal IPv6 notation. Domains may have a leading *. wildcard; * matches every host. No URL, port, userinfo, path, or IPv6 zone identifier is permitted.", + "type": "string", + "minLength": 1, + "not": { + "pattern": "[\\s/@%?#\\\\]" + }, + "anyOf": [ + { + "const": "*" + }, + { + "pattern": "^(?:\\*\\.)?(?!(?:.*\\.)?(?:[0-9]+|0[xX][0-9A-Fa-f]*)\\.*$)[A-Za-z0-9_-]+(?:\\.[A-Za-z0-9_-]+)*\\.*$" + }, + { + "pattern": "^(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3}\\.*$" + }, + { + "allOf": [ + { + "pattern": "^(?:[^\\[\\]]*|\\[[^\\[\\]]*\\])$" + }, + { + "pattern": "^\\[?(?:(?:[0-9A-Fa-f]{1,4}:){7}[0-9A-Fa-f]{1,4}|(?:[0-9A-Fa-f]{1,4}:){1,7}:|(?:[0-9A-Fa-f]{1,4}:){1,6}:[0-9A-Fa-f]{1,4}|(?:[0-9A-Fa-f]{1,4}:){1,5}(?::[0-9A-Fa-f]{1,4}){1,2}|(?:[0-9A-Fa-f]{1,4}:){1,4}(?::[0-9A-Fa-f]{1,4}){1,3}|(?:[0-9A-Fa-f]{1,4}:){1,3}(?::[0-9A-Fa-f]{1,4}){1,4}|(?:[0-9A-Fa-f]{1,4}:){1,2}(?::[0-9A-Fa-f]{1,4}){1,5}|[0-9A-Fa-f]{1,4}:(?:(?::[0-9A-Fa-f]{1,4}){1,6})|:(?:(?::[0-9A-Fa-f]{1,4}){1,7}|:))\\]?$" + } + ] + } + ] + }, + "mcpDenyMatcher": { + "description": "A single MCP denylist entry. Exactly one of serverUrl, serverCommand, or serverName must be present.", + "type": "object", + "oneOf": [ + { + "type": "object", + "required": [ + "serverUrl" + ], + "properties": { + "serverUrl": { + "description": "Matches a remote (HTTP/SSE) server by URL. Supports '*' wildcards anywhere; the scheme and host are matched case-insensitively, the path case-sensitively. A pattern with no path matches any path. ${VAR} references expand before matching.", + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "serverCommand" + ], + "properties": { + "serverCommand": { + "description": "Matches a stdio server by its exact command and arguments (every element, in order). ${VAR} references expand before matching.", + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1 + } + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "serverName" + ], + "properties": { + "serverName": { + "description": "Matches a server by its assigned label (exact, no wildcards). Any non-empty string is accepted in a denylist, so connectors can be blocked by display name.", + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + } + ] + }, + "mcpAllowMatcher": { + "description": "A single MCP allowlist entry. Exactly one of serverUrl, serverCommand, or serverName must be present. Identical to a denylist entry except serverName is restricted to a safe charset so it cannot smuggle a wildcard past the allowlist.", + "type": "object", + "oneOf": [ + { + "type": "object", + "required": [ + "serverUrl" + ], + "properties": { + "serverUrl": { + "description": "Matches a remote (HTTP/SSE) server by URL. Supports '*' wildcards anywhere; the scheme and host are matched case-insensitively, the path case-sensitively. A pattern with no path matches any path. ${VAR} references expand before matching.", + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "serverCommand" + ], + "properties": { + "serverCommand": { + "description": "Matches a stdio server by its exact command and arguments (every element, in order). ${VAR} references expand before matching.", + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1 + } + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "serverName" + ], + "properties": { + "serverName": { + "description": "Matches a server by its assigned label (exact, no wildcards). Restricted to letters, numbers, hyphens, and underscores.", + "type": "string", + "pattern": "^[A-Za-z0-9_-]+$" + } + }, + "additionalProperties": false + } + ] + } + } + }, + "runtimeVersion": "1.0.95-0" +} diff --git a/src/vs/platform/agentHost/test/node/agentEditAttributionService.test.ts b/src/vs/platform/agentHost/test/node/agentEditAttributionService.test.ts index ae30950b513fcb..db9a51c9ac5b7d 100644 --- a/src/vs/platform/agentHost/test/node/agentEditAttributionService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentEditAttributionService.test.ts @@ -118,6 +118,7 @@ suite('Agent Edit Attribution Service', () => { source: { modelId: 'model', conversationId: 'session-1', + agentSessionId: 'session-1', requestId: 'turn-1', harness: 'copilotcli', }, @@ -206,8 +207,8 @@ suite('Agent Edit Attribution Service', () => { first?.status !== 'skipped' ? first?.source : undefined, second?.status !== 'skipped' ? second?.source : undefined, ], [ - { modelId: 'model', conversationId: 'session-1', requestId: 'turn-1', harness: 'copilotcli' }, - { modelId: 'model', conversationId: 'session-1', requestId: 'turn-2', harness: 'copilotcli' }, + { modelId: 'model', conversationId: 'session-1', agentSessionId: 'session-1', requestId: 'turn-1', harness: 'copilotcli' }, + { modelId: 'model', conversationId: 'session-1', agentSessionId: 'session-1', requestId: 'turn-2', harness: 'copilotcli' }, ]); }); @@ -281,6 +282,7 @@ suite('Agent Edit Attribution Service', () => { const project = (data: Record) => ({ sourceKey: data.sourceKey, conversationId: data.conversationId, + agentSessionId: data.agentSessionId, chatSessionId: data.chatSessionId, hasChatSessionId: Object.hasOwn(data, 'chatSessionId'), requestId: data.requestId, @@ -291,6 +293,7 @@ suite('Agent Edit Attribution Service', () => { const expected = chats.map((chat, index) => ({ sourceKey: 'source:Chat.applyEdits-$modelId:model-$harness:copilotcli-$origin:agentHost', conversationId: AgentSession.id(sessionUris[index]), + agentSessionId: flushMode === 'legacyNoChat' ? `session-${index + 1}` : AgentSession.id(sessionUri), chatSessionId: chat === undefined ? undefined : getTelemetryChatSessionId(chat), hasChatSessionId: chat !== undefined, requestId: `turn-${index}`, @@ -298,9 +301,7 @@ suite('Agent Edit Attribution Service', () => { deltaModifiedCount: index + 2, totalModifiedCount: 9, })); - const expectedDetails = flushMode === 'legacyNoChat' - ? [{ ...expected[0], modifiedCount: 9, deltaModifiedCount: 9 }] - : expected; + const expectedDetails = expected; assert.deepStrictEqual({ distinctChats: expected[0].chatSessionId !== expected[1].chatSessionId, details: details.toSorted((a, b) => Number(a.modifiedCount) - Number(b.modifiedCount)).map(project), @@ -313,6 +314,7 @@ suite('Agent Edit Attribution Service', () => { markers: expected.map(data => ({ modelId: 'model', conversationId: data.conversationId, + agentSessionId: data.agentSessionId, ...(data.hasChatSessionId ? { chatSessionId: data.chatSessionId } : {}), requestId: data.requestId, harness: 'copilotcli', diff --git a/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts b/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts index 5883f44af7356d..c8a84cfb6ad926 100644 --- a/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts @@ -20,6 +20,9 @@ import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { buildChatUri, buildDefaultChatUri, withSessionGitHubState, withSessionGitState, type ISessionFileDiff, type ISessionGitState, MessageKind, ResponsePartKind, SessionStatus, TurnState, type Turn } from '../../common/state/sessionState.js'; import type { IAgentHostGitService, IBranch, IDefaultBranch, IPushOptions } from '../../common/agentHostGitService.js'; import { AgentHostPullRequestOperationHandler } from '../../node/agentHostPullRequestOperationHandler.js'; +import type { IAgentHostProviderService } from '../../node/agentHostProviderService.js'; +import { AgentHostUtilityModelService } from '../../node/agentHostUtilityModelService.js'; +import { NullByokLmBridgeRegistry } from '../../node/byokLmBridgeRegistry.js'; import { createTestGitHubEndpointService } from './testGitHubEndpointService.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; import { CreatedPullRequest, CreatePullRequestOptions, EnablePullRequestAutoMergeOptions } from '../../../github/common/githubPullRequestMutationService.js'; @@ -325,6 +328,13 @@ function setup(disposables: Pick, gitService: TestGitSer sessionConfigUpdates.push(patch); } }(); + const authenticationService = createAuthenticationService(options?.withCopilotToken); + const gitHubEndpointService = createTestGitHubEndpointService(); + const logService = options?.logService ?? new NullLogService(); + const providerService = new class extends mock() { + override getProviderForSession() { return undefined; } + }(); + const utilityModelService = new AgentHostUtilityModelService(true, configurationService, copilotApiService, new NullByokLmBridgeRegistry(), providerService, stateManager, authenticationService, gitHubEndpointService, logService); return { handler: new AgentHostPullRequestOperationHandler( options?.draft ?? false, @@ -346,7 +356,7 @@ function setup(disposables: Pick, gitService: TestGitSer createdConversationChats.push(event.conversationChat); createdBranches.push(event.branchName); }, - createAuthenticationService(options?.withCopilotToken), gitService, createTestGitHubService(gitHubClient), createTestGitHubEndpointService(), copilotApiService, branchNameGenerator, configurationService, options?.logService ?? new NullLogService(), stateManager), + authenticationService, gitService, createTestGitHubService(gitHubClient), gitHubEndpointService, utilityModelService, branchNameGenerator, configurationService, logService, stateManager), session, stateManager, createdEvents, diff --git a/src/vs/platform/agentHost/test/node/agentHostServices.test.ts b/src/vs/platform/agentHost/test/node/agentHostServices.test.ts index bc16045f665efb..217a0586240ed7 100644 --- a/src/vs/platform/agentHost/test/node/agentHostServices.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostServices.test.ts @@ -31,6 +31,7 @@ import { IAgentHostProxyResolver } from '../../node/agentHostProxyResolver.js'; import { IAgentHostPeerChatPersistenceService } from '../../node/agentHostPeerChatStore.js'; import { IAgentHostStateManager } from '../../node/agentHostStateManager.js'; import { NullByokLmBridgeRegistry, IByokLmBridgeRegistry } from '../../node/byokLmBridgeRegistry.js'; +import { IAgentHostUtilityModelService } from '../../node/agentHostUtilityModelService.js'; import { registerAgentHostCoreServices, registerAgentHostHostServices } from '../../node/agentHostServices.js'; import { IAgentHostWorktreeIsolation, NullAgentHostWorktreeIsolation } from '../../node/shared/worktreeIsolation.js'; @@ -252,6 +253,23 @@ suite('Agent Host service registrations', () => { assert.deepStrictEqual(descriptor.staticArguments, [{ fetchFn: globalThis.fetch }]); }); + test('enables BYOK utility model routing only for hosts with a renderer BYOK bridge', () => { + const staticArguments = (byok: Parameters[1]['byok']) => { + const services = new StrictServiceCollection(); + registerAgentHostHostServices(services, { userDataPath: URI.file('/user-data'), fetchFn: globalThis.fetch, byok }); + const descriptor = services.get(IAgentHostUtilityModelService); + assert.ok(descriptor instanceof SyncDescriptor); + return descriptor.staticArguments; + }; + assert.deepStrictEqual({ + local: staticArguments({ kind: 'renderer', bridgeRegistry: new NullByokLmBridgeRegistry() }), + remote: staticArguments({ kind: 'unavailable' }), + }, { + local: [true], + remote: [false], + }); + }); + test('descriptor-created services have one disposal owner', () => { const services = new StrictServiceCollection(); let disposeCount = 0; diff --git a/src/vs/platform/agentHost/test/node/agentHostSessionTitleController.test.ts b/src/vs/platform/agentHost/test/node/agentHostSessionTitleController.test.ts index 97f0c58d852415..9538b7f0f6855e 100644 --- a/src/vs/platform/agentHost/test/node/agentHostSessionTitleController.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostSessionTitleController.test.ts @@ -27,6 +27,7 @@ import { buildChatUri, buildDefaultChatUri, MessageKind, ResponsePartKind, Sessi import { GitHubIssueOrPullRequest, GitHubIssueRef } from '../../../github/common/githubQueryService.js'; import { IGitHubQuery } from '../../../github/common/githubQueryServiceImpl.js'; import { type ICopilotApiService, type ICopilotApiServiceRequestOptions, type ICopilotUtilityChatCompletionRequest } from '../../node/shared/copilotApiService.js'; +import { AgentHostUtilityModelUnavailableError, AgentHostUtilityModelUnavailableReason, type IAgentHostUtilityModelContext, type IAgentHostUtilityModelService } from '../../node/agentHostUtilityModelService.js'; import { AGENT_HOST_TITLE_SOURCE_AGENT, AGENT_HOST_TITLE_SOURCE_AUTO, AGENT_HOST_TITLE_SOURCE_USER, customChatTitleMetadataKey, customChatTitleSourceMetadataKey, SESSION_CUSTOM_TITLE_SOURCE_KEY } from '../../node/shared/persistSessionMetadata.js'; import { sessionServerToolDefinitions } from '../../node/shared/sessionServerTools.js'; import { createSessionDataService, TestSessionDatabase } from '../common/sessionTestHelpers.js'; @@ -63,6 +64,18 @@ class TestCopilotApiService implements ICopilotApiService { } } +function createUtilityModelService(copilotApiService: TestCopilotApiService, getGitHubCopilotToken: () => string | undefined): IAgentHostUtilityModelService { + return { + _serviceBrand: undefined, + chatCompletion: (_context, request, options) => { + const token = getGitHubCopilotToken(); + return token + ? copilotApiService.utilityChatCompletion(token, request, options) + : Promise.reject(new AgentHostUtilityModelUnavailableError(AgentHostUtilityModelUnavailableReason.CopilotSignInRequired)); + }, + }; +} + class TestGitHubQuery extends mock() { readonly calls: { owner: string; repo: string; number: number; accountId: string; signal: AbortSignal }[] = []; readonly responses = new Map(); @@ -157,12 +170,11 @@ suite('AgentHostSessionTitleController', () => { const controller = disposables.add(new AgentHostSessionTitleController(stateManager, { sessionDataService: createSessionDataService(db), queueCatalogSync: (session, metadataOverrides) => catalogSyncs.push({ session, metadataOverrides }), - getGitHubCopilotToken, getGitHubToken, getGitHubHost, gitHubContextRequestTimeout, gitHubService: createTestGitHubService(createTestGitHubClient({ query })), - copilotApiService, + utilityModelService: createUtilityModelService(copilotApiService, getGitHubCopilotToken), getInitialTitleGenerationStrategy: () => initialTitleGenerationStrategy, }, new NullLogService())); return { controller, stateManager, session, db, titleActions, catalogSyncs, copilotApiService, query }; @@ -276,8 +288,7 @@ suite('AgentHostSessionTitleController', () => { const restored = disposables.add(new AgentHostSessionTitleController(stateManager, { sessionDataService: createSessionDataService(db), getInitialTitleGenerationStrategy: () => 'utility', - copilotApiService, - getGitHubCopilotToken: () => 'gh-token', + utilityModelService: createUtilityModelService(copilotApiService, () => 'gh-token'), }, new NullLogService())); await restored.restoreTitleGenerationStrategy(session.toString(), buildDefaultChatUri(session)); const beforeRefinement = await restored.prepareInstructionForAgent(session.toString(), buildDefaultChatUri(session)); @@ -438,8 +449,7 @@ suite('AgentHostSessionTitleController', () => { tryOpenDatabase: resource => (resource.toString() === session.toString() ? sessionData : chatData).tryOpenDatabase(resource), }, getInitialTitleGenerationStrategy: () => 'deferred', - copilotApiService, - getGitHubCopilotToken: () => 'gh-token', + utilityModelService: createUtilityModelService(copilotApiService, () => 'gh-token'), }, new NullLogService())); const defaultChat = buildDefaultChatUri(session); const pendingTitle = new DeferredPromise(); @@ -640,8 +650,7 @@ suite('AgentHostSessionTitleController', () => { const restored = disposables.add(new AgentHostSessionTitleController(stateManager, { sessionDataService: createSessionDataService(db), getInitialTitleGenerationStrategy: () => 'utility', - copilotApiService, - getGitHubCopilotToken: () => 'gh-token', + utilityModelService: createUtilityModelService(copilotApiService, () => 'gh-token'), }, new NullLogService())); await restored.restoreTitleGenerationStrategy(session.toString(), buildDefaultChatUri(session)); assert.deepStrictEqual({ @@ -674,8 +683,7 @@ suite('AgentHostSessionTitleController', () => { const options = { sessionDataService: createSessionDataService(db), getInitialTitleGenerationStrategy: () => 'deferred' as const, - getGitHubCopilotToken: () => 'gh-token', - copilotApiService, + utilityModelService: createUtilityModelService(copilotApiService, () => 'gh-token'), persistMetadata: (resource: string, values: Readonly>) => store.persistMetadata(session, URI.parse(resource), values), readNormalizedChat: (owner: URI, resource: URI) => store.readNormalizedChat(owner, resource), }; @@ -717,8 +725,7 @@ suite('AgentHostSessionTitleController', () => { await db.setMetadata('deferredTitleSeed', rawSeed); const restored = disposables.add(new AgentHostSessionTitleController(stateManager, { sessionDataService: createSessionDataService(db), - copilotApiService, - getGitHubCopilotToken: () => 'gh-token', + utilityModelService: createUtilityModelService(copilotApiService, () => 'gh-token'), }, new NullLogService())); await restored.restoreTitleGenerationStrategy(session.toString(), buildDefaultChatUri(session)); stateManager.seedDefaultChatTurns(session.toString(), [firstTurn('Add dark mode', [textPart('Done')])]); @@ -737,8 +744,7 @@ suite('AgentHostSessionTitleController', () => { await db.setMetadata(SESSION_CUSTOM_TITLE_SOURCE_KEY, source); const restored = disposables.add(new AgentHostSessionTitleController(stateManager, { sessionDataService: createSessionDataService(db), - copilotApiService, - getGitHubCopilotToken: () => 'gh-token', + utilityModelService: createUtilityModelService(copilotApiService, () => 'gh-token'), }, new NullLogService())); await restored.restoreTitleGenerationStrategy(session.toString(), buildDefaultChatUri(session)); stateManager.seedDefaultChatTurns(session.toString(), [firstTurn('Add dark mode', [textPart('Done')])]); @@ -768,8 +774,7 @@ suite('AgentHostSessionTitleController', () => { const callsBeforeRestore = copilotApiService.utilityCalls.length; const restored = disposables.add(new AgentHostSessionTitleController(stateManager, { sessionDataService: createSessionDataService(db), - copilotApiService, - getGitHubCopilotToken: () => 'gh-token', + utilityModelService: createUtilityModelService(copilotApiService, () => 'gh-token'), }, new NullLogService())); await restored.restoreTitleGenerationStrategy(session.toString(), buildDefaultChatUri(session)); stateManager.seedDefaultChatTurns(session.toString(), [firstTurn('Add dark mode', [textPart('Done')])]); @@ -1134,10 +1139,9 @@ suite('AgentHostSessionTitleController', () => { const persistedTitles: string[] = []; const controller = disposables.add(new AgentHostSessionTitleController(stateManager, { sessionDataService: createSessionDataService(new TestSessionDatabase()), - getGitHubCopilotToken: () => 'copilot-token', getGitHubToken: () => 'test-token', gitHubService, - copilotApiService, + utilityModelService: createUtilityModelService(copilotApiService, () => 'copilot-token'), persistSurfacedSessionTitle: async session => { persistedTitles.push(session); }, }, new NullLogService())); const sessions = [URI.parse('agenthost-session://copilot/first'), URI.parse('agenthost-session://copilot/second')]; @@ -1542,6 +1546,29 @@ suite('AgentHostSessionTitleController', () => { return { kind: ResponsePartKind.ToolCall, toolCall }; } + test('passes the session, chat, and turn model to the utility model service', async () => { + const stateManager = disposables.add(new AgentHostStateManager(new NullLogService())); + const session = URI.parse('agenthost-session://copilot/utility-model-context'); + stateManager.createSession(createSummary(session)); + const contexts: IAgentHostUtilityModelContext[] = []; + const controller = disposables.add(new AgentHostSessionTitleController(stateManager, { + sessionDataService: createSessionDataService(new TestSessionDatabase()), + utilityModelService: { + _serviceBrand: undefined, + chatCompletion: async context => { + contexts.push(context); + return 'Generated title'; + }, + }, + getInitialTitleGenerationStrategy: () => 'utility', + }, new NullLogService())); + const chat = buildDefaultChatUri(session); + controller.seedTitleFromFirstMessage(session.toString(), 'Add dark mode', chat, { id: 'azure/work/gpt-5' }); + await waitForCondition(() => stateManager.getSessionState(session.toString())?.title === 'Generated title', 'title should be generated'); + + assert.deepStrictEqual(contexts, [{ session: session.toString(), chat, model: { id: 'azure/work/gpt-5' } }]); + }); + function firstTurn(text: string, responseParts: ResponsePart[]): Turn { return { id: 'turn-1', diff --git a/src/vs/platform/agentHost/test/node/agentHostUtilityModelService.test.ts b/src/vs/platform/agentHost/test/node/agentHostUtilityModelService.test.ts new file mode 100644 index 00000000000000..b1067c3c54ff0c --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentHostUtilityModelService.test.ts @@ -0,0 +1,232 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { Event } from '../../../../base/common/event.js'; +import { DisposableStore, Disposable } from '../../../../base/common/lifecycle.js'; +import { constObservable } from '../../../../base/common/observable.js'; +import { mock, upcastPartial } from '../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { NullLogService } from '../../../log/common/log.js'; +import { COPILOT_CLI_AGENT_PROVIDER_ID, GITHUB_COPILOT_PROTECTED_RESOURCE, type IAgent, type IAgentChats, type IAgentModelInfo } from '../../common/agent.js'; +import type { IByokLmBridgeConnection, IByokLmChatRequest, IByokLmModelInfo } from '../../common/agentHostByokLm.js'; +import { AgentHostByokModelsEnabledConfigKey, AgentHostByokUtilityModelDefaultConfigKey, AgentHostUtilitySmallModelConfigKey } from '../../common/agentHostSchema.js'; +import { createAgentModelByokMeta } from '../../common/agentModelByokMeta.js'; +import { MessageKind, SessionStatus, TurnState } from '../../common/state/sessionState.js'; +import type { IAgentConfigurationService } from '../../node/agentConfigurationService.js'; +import type { IAgentHostAuthenticationService } from '../../node/agentHostAuthenticationService.js'; +import type { IAgentHostProviderService } from '../../node/agentHostProviderService.js'; +import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; +import { AgentHostUtilityModelService, AgentHostUtilityModelUnavailableError } from '../../node/agentHostUtilityModelService.js'; +import type { IByokLmBridgeRegistry } from '../../node/byokLmBridgeRegistry.js'; +import type { ICopilotApiService } from '../../node/shared/copilotApiService.js'; +import { createTestGitHubEndpointService } from './testGitHubEndpointService.js'; + +const SESSION = 'agent:/session'; + +const BYOK_MODELS: readonly IByokLmModelInfo[] = [ + { vendor: 'ollama', id: 'llama3', modelIdentifier: 'ollama/llama3' }, + { vendor: 'azure', id: 'gpt-5', modelIdentifier: 'azure/work/gpt-5' }, +]; + +const AGENT_MODELS: readonly IAgentModelInfo[] = [ + { provider: 'copilot', id: 'claude-sonnet-4.5', name: 'Claude Sonnet 4.5', supportsVision: false }, + { provider: 'copilot', id: 'azure/work/gpt-5', name: 'GPT-5', supportsVision: false, _meta: createAgentModelByokMeta('azure/work/gpt-5') }, +]; + +interface IScenario { + readonly mainModel?: string; + readonly utilitySmallModel?: string; + readonly byokUtilityModelDefault?: string; + readonly byokModelsEnabled?: boolean; + readonly copilotToken?: boolean; + /** Whether the host has a renderer BYOK bridge; `false` models a remote agent host. */ + readonly byokUtilityModelsSupported?: boolean; + /** The model of the turn being served, passed by the caller. */ + readonly turnModel?: string; + /** The model recorded on the chat's last completed turn. */ + readonly lastTurnModel?: string; + /** + * Whether a renderer serves BYOK models. When `false`, the bridge has no + * serving connection and the Copilot agent's catalog drops BYOK models. + */ + readonly rendererConnected?: boolean; + /** The session's agent provider id; defaults to the Copilot CLI provider. */ + readonly agentId?: string; +} + +suite('AgentHostUtilityModelService', () => { + const disposables = new DisposableStore(); + teardown(() => disposables.clear()); + ensureNoDisposablesAreLeakedInTestSuite(); + + async function run(scenario: IScenario, includeInput = false): Promise { + const rootValues: Record = { + [AgentHostUtilitySmallModelConfigKey]: scenario.utilitySmallModel, + [AgentHostByokUtilityModelDefaultConfigKey]: scenario.byokUtilityModelDefault, + [AgentHostByokModelsEnabledConfigKey]: scenario.byokModelsEnabled, + }; + const configurationService = new class extends mock() { + override getRootValue(_schema: never, key: string) { + return rootValues[key] as never; + } + }(); + const copilotCalls: string[] = []; + const copilotApiService = upcastPartial({ + utilityChatCompletion: async token => { + copilotCalls.push(token); + return 'copilot text'; + }, + }); + const rendererConnected = scenario.rendererConnected ?? true; + const byokRequests: Pick[] = []; + const connection: IByokLmBridgeConnection = { + chat: async request => { + byokRequests.push({ vendor: request.vendor, modelId: request.modelId, input: request.input, modelOptions: request.modelOptions }); + return { output: [{ type: 'message', content: [{ type: 'text', text: 'byok ' }, { type: 'text', text: 'text' }] }] }; + }, + onDidChangeModels: Event.None, + }; + const bridgeRegistry = upcastPartial({ + getModels: () => rendererConnected ? BYOK_MODELS : [], + getServingConnection: () => rendererConnected ? connection : undefined, + onDidChangeModels: () => Disposable.None, + }); + const agent = upcastPartial({ + id: scenario.agentId ?? COPILOT_CLI_AGENT_PROVIDER_ID, + models: constObservable(rendererConnected ? AGENT_MODELS : AGENT_MODELS.filter(m => !m._meta)), + chats: upcastPartial({ + getModel: () => scenario.mainModel ? { id: scenario.mainModel } : undefined, + }), + }); + const providerService = new class extends mock() { + override getProviderForSession() { return agent; } + }(); + const authenticationService = upcastPartial({ + onDidChangeAuthToken: Event.None, + getAuthToken: resource => scenario.copilotToken !== false && resource.resource === GITHUB_COPILOT_PROTECTED_RESOURCE.resource ? 'copilot-token' : undefined, + }); + const stateManager = disposables.add(new AgentHostStateManager(new NullLogService())); + stateManager.createSession({ + resource: SESSION, + provider: 'copilot', + title: 'Session', + status: SessionStatus.Idle, + createdAt: new Date(1).toISOString(), + modifiedAt: new Date(1).toISOString(), + }); + if (scenario.lastTurnModel) { + stateManager.seedDefaultChatTurns(SESSION, [{ + id: 'turn-1', + message: { text: 'request', origin: { kind: MessageKind.User }, model: { id: scenario.lastTurnModel } }, + responseParts: [], + usage: undefined, + state: TurnState.Complete, + }]); + } + const service = new AgentHostUtilityModelService(scenario.byokUtilityModelsSupported ?? true, configurationService, copilotApiService, bridgeRegistry, providerService, stateManager, authenticationService, createTestGitHubEndpointService(), new NullLogService()); + + let result: string; + try { + result = await service.chatCompletion({ session: SESSION, model: scenario.turnModel ? { id: scenario.turnModel } : undefined }, { messages: [{ role: 'system', content: 'rules' }, { role: 'user', content: 'request' }], maxTokens: 32 }); + } catch (err) { + result = err instanceof AgentHostUtilityModelUnavailableError ? `unavailable: ${err.reason}` : `error: ${err}`; + } + return includeInput + ? { result, byokRequests } + : { result, copilotCalls, byokRequests: byokRequests.map(r => `${r.vendor}|${r.modelId}`) }; + } + + test('resolves the override, BYOK main agent default, and Copilot routes', async () => { + const scenarios: Record = { + copilotMainModel: { mainModel: 'claude-sonnet-4.5' }, + noSelectedModel: {}, + copilotSignedOut: { mainModel: 'claude-sonnet-4.5', copilotToken: false }, + byokOverride: { mainModel: 'claude-sonnet-4.5', utilitySmallModel: 'ollama/llama3', copilotToken: false }, + byokOverrideInGroup: { utilitySmallModel: 'azure/gpt-5' }, + unknownOverrideFallsBack: { utilitySmallModel: 'ollama/missing' }, + copilotOverrideFallsBack: { utilitySmallModel: 'copilot/gpt-4.1' }, + malformedOverrideFallsBack: { utilitySmallModel: 'llama3' }, + overrideWithByokDisabled: { utilitySmallModel: 'ollama/llama3', byokModelsEnabled: false }, + byokMainDefaultsToCopilot: { mainModel: 'azure/work/gpt-5' }, + byokMainSignedOut: { mainModel: 'azure/work/gpt-5', copilotToken: false }, + byokMainMainAgent: { mainModel: 'azure/work/gpt-5', byokUtilityModelDefault: 'mainAgent', copilotToken: false }, + byokMainNone: { mainModel: 'azure/work/gpt-5', byokUtilityModelDefault: 'none' }, + byokMainNoneWithOverride: { mainModel: 'azure/work/gpt-5', byokUtilityModelDefault: 'none', utilitySmallModel: 'ollama/llama3' }, + byokMainMainAgentWithByokDisabled: { mainModel: 'azure/work/gpt-5', byokUtilityModelDefault: 'mainAgent', byokModelsEnabled: false }, + copilotMainIgnoresByokDefault: { mainModel: 'claude-sonnet-4.5', byokUtilityModelDefault: 'none' }, + remoteIgnoresOverride: { utilitySmallModel: 'ollama/llama3', byokUtilityModelsSupported: false }, + remoteIgnoresByokDefault: { mainModel: 'azure/work/gpt-5', byokUtilityModelDefault: 'none', byokUtilityModelsSupported: false }, + turnModelPrecedesChatSelection: { mainModel: 'claude-sonnet-4.5', turnModel: 'azure/work/gpt-5', byokUtilityModelDefault: 'none' }, + nonResidentChatUsesLastTurnModel: { lastTurnModel: 'azure/work/gpt-5', byokUtilityModelDefault: 'none' }, + chatSelectionPrecedesLastTurnModel: { mainModel: 'claude-sonnet-4.5', lastTurnModel: 'azure/work/gpt-5', byokUtilityModelDefault: 'none' }, + disconnectedCopilotMainModel: { mainModel: 'claude-sonnet-4.5', rendererConnected: false }, + disconnectedByokMainCopilotDefault: { mainModel: 'azure/work/gpt-5', rendererConnected: false }, + disconnectedByokMainMainAgent: { mainModel: 'azure/work/gpt-5', byokUtilityModelDefault: 'mainAgent', rendererConnected: false }, + disconnectedByokMainNone: { mainModel: 'azure/work/gpt-5', byokUtilityModelDefault: 'none', rendererConnected: false }, + disconnectedRestoredByokTurn: { lastTurnModel: 'azure/work/gpt-5', rendererConnected: false }, + disconnectedByokOverride: { mainModel: 'claude-sonnet-4.5', utilitySmallModel: 'ollama/llama3', rendererConnected: false }, + disconnectedCopilotOverrideFallsBack: { utilitySmallModel: 'copilot/gpt-4.1', rendererConnected: false }, + disconnectedOverrideWithByokDisabled: { utilitySmallModel: 'ollama/llama3', byokModelsEnabled: false, rendererConnected: false }, + disconnectedOtherProviderSlashId: { mainModel: 'org/model', agentId: 'codex', rendererConnected: false }, + }; + const results: Record = {}; + for (const [name, scenario] of Object.entries(scenarios)) { + results[name] = await run(scenario); + } + + const copilot = { result: 'copilot text', copilotCalls: ['copilot-token'], byokRequests: [] }; + const signedOut = { result: 'unavailable: copilotSignInRequired', copilotCalls: [], byokRequests: [] }; + const byokUnavailable = { result: 'unavailable: byokModelUnavailable', copilotCalls: [], byokRequests: [] }; + assert.deepStrictEqual(results, { + copilotMainModel: copilot, + noSelectedModel: copilot, + copilotSignedOut: signedOut, + byokOverride: { result: 'byok text', copilotCalls: [], byokRequests: ['ollama|llama3'] }, + byokOverrideInGroup: { result: 'byok text', copilotCalls: [], byokRequests: ['azure|work/gpt-5'] }, + unknownOverrideFallsBack: copilot, + copilotOverrideFallsBack: copilot, + malformedOverrideFallsBack: copilot, + overrideWithByokDisabled: copilot, + byokMainDefaultsToCopilot: copilot, + byokMainSignedOut: signedOut, + byokMainMainAgent: { result: 'byok text', copilotCalls: [], byokRequests: ['azure|work/gpt-5'] }, + byokMainNone: { result: 'unavailable: notConfigured', copilotCalls: [], byokRequests: [] }, + byokMainNoneWithOverride: { result: 'byok text', copilotCalls: [], byokRequests: ['ollama|llama3'] }, + byokMainMainAgentWithByokDisabled: { result: 'unavailable: byokModelUnavailable', copilotCalls: [], byokRequests: [] }, + copilotMainIgnoresByokDefault: copilot, + remoteIgnoresOverride: copilot, + remoteIgnoresByokDefault: copilot, + turnModelPrecedesChatSelection: { result: 'unavailable: notConfigured', copilotCalls: [], byokRequests: [] }, + nonResidentChatUsesLastTurnModel: { result: 'unavailable: notConfigured', copilotCalls: [], byokRequests: [] }, + chatSelectionPrecedesLastTurnModel: copilot, + disconnectedCopilotMainModel: copilot, + disconnectedByokMainCopilotDefault: byokUnavailable, + disconnectedByokMainMainAgent: byokUnavailable, + disconnectedByokMainNone: { result: 'unavailable: notConfigured', copilotCalls: [], byokRequests: [] }, + disconnectedRestoredByokTurn: byokUnavailable, + disconnectedByokOverride: byokUnavailable, + disconnectedCopilotOverrideFallsBack: copilot, + disconnectedOverrideWithByokDisabled: copilot, + disconnectedOtherProviderSlashId: copilot, + }); + }); + + test('forwards the utility messages and inference options to the BYOK bridge', async () => { + const result = await run({ utilitySmallModel: 'ollama/llama3' }, true); + assert.deepStrictEqual(result, { + result: 'byok text', + byokRequests: [{ + vendor: 'ollama', + modelId: 'llama3', + input: [ + { type: 'message', role: 'system', content: [{ type: 'text', text: 'rules' }] }, + { type: 'message', role: 'user', content: [{ type: 'text', text: 'request' }] }, + ], + modelOptions: { temperature: 0.1, top_p: 1, max_tokens: 32 }, + }], + }); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 1a76ecb629db87..7b1901af4f587b 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -29439,8 +29439,8 @@ suite('AgentService (node dispatcher)', () => { }); test('restoreSession seeds the provider model into the default chat draft', async () => { - const sessionDb = disposables.add(await SessionDatabase.open(':memory:')); - const sessionDataService = createSessionDataService(sessionDb); + // Provider discovery and explicit restore can address different sessions concurrently. + const { service: sessionDataService, database } = createPerSessionDataService(); const localAgent = new MockAgent('codex'); const model = { id: '@provider=openai:gpt-5.6-sol' }; localAgent.sessionMetadataOverrides = { model } as typeof localAgent.sessionMetadataOverrides; @@ -29448,7 +29448,7 @@ suite('AgentService (node dispatcher)', () => { const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); registerTestAgentProvider(localService, localAgent); const { session } = await createAgentSession(localAgent); - await sessionDb.setChatDraft(URI.parse(buildDefaultChatUri(session)), { + await database(session).setChatDraft(URI.parse(buildDefaultChatUri(session)), { text: 'unsent text', origin: { kind: MessageKind.User }, model: { id: 'codex-model:vscode-proxy:gpt-5-mini', config: { thinkingLevel: 'medium' } }, diff --git a/src/vs/platform/agentHost/test/node/agentServiceTestUtils.ts b/src/vs/platform/agentHost/test/node/agentServiceTestUtils.ts index f7f1d6feac96ce..776f2713ba4b26 100644 --- a/src/vs/platform/agentHost/test/node/agentServiceTestUtils.ts +++ b/src/vs/platform/agentHost/test/node/agentServiceTestUtils.ts @@ -10,6 +10,7 @@ import { upcastPartial } from '../../../../base/test/common/mock.js'; import { INativeEnvironmentService } from '../../../environment/common/environment.js'; import { IFileService } from '../../../files/common/files.js'; import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js'; +import { SyncDescriptor } from '../../../instantiation/common/descriptors.js'; import { InstantiationService } from '../../../instantiation/common/instantiationService.js'; import { StrictServiceCollection } from '../../../instantiation/common/strictServiceCollection.js'; import { ILogService } from '../../../log/common/log.js'; @@ -44,6 +45,8 @@ import { AgentHostSessionTitleController, IAgentHostSessionTitleController, type import { AgentHostLocalTurns, IAgentHostLocalTurns } from '../../node/agentHostLocalTurns.js'; import { IAgentHostGitHubService } from '../../node/agentHostGitHubService.js'; import { IAgentHostWorktreeIsolation, NullAgentHostWorktreeIsolation } from '../../node/shared/worktreeIsolation.js'; +import { IByokLmBridgeRegistry, NullByokLmBridgeRegistry } from '../../node/byokLmBridgeRegistry.js'; +import { AgentHostUtilityModelService, IAgentHostUtilityModelService } from '../../node/agentHostUtilityModelService.js'; const compositions = new WeakMap(); const chatPersistenceServices = new WeakMap(); @@ -234,26 +237,24 @@ export function createTestAgentService( services.set(IAgentHostStartupPerformance, startupPerformance); } services.set(IAgentHostWorktreeIsolation, worktreeIsolation.service); + services.set(IByokLmBridgeRegistry, new NullByokLmBridgeRegistry()); + services.set(IAgentHostUtilityModelService, new SyncDescriptor(AgentHostUtilityModelService, [true])); const instantiationService = new InstantiationService(services, /*strict*/ true); const gitHubService = instantiationService.invokeFunction(accessor => accessor.get(IAgentHostGitHubService)); - const effectiveCopilotApiService = instantiationService.invokeFunction(accessor => accessor.get(ICopilotApiService)); + const utilityModelService = instantiationService.invokeFunction(accessor => accessor.get(IAgentHostUtilityModelService)); services.set(IAgentHostSessionTitleController, foundationDisposables.add(instantiationService.createInstance(AgentHostSessionTitleController, foundation.stateManager, { sessionDataService, queueCatalogSync: (session, metadataOverrides) => foundation.callbackAdapter.value.queueCatalogSync(session, metadataOverrides), persistMetadata: (resource, values) => foundation.callbackAdapter.value.persistMetadata(resource, values), readNormalizedChat: (session, chat) => foundation.callbackAdapter.value.readNormalizedChat(session, chat), persistSurfacedSessionTitle: (session, title) => foundation.callbackAdapter.value.persistSurfacedSessionTitle(session, title), - getGitHubCopilotToken: () => { - const resource = foundation.gitHubEndpointService.getCopilotResource(); - return foundation.authenticationService.getAuthToken({ resource: resource.resource, scopes: resource.scopes_supported }); - }, getGitHubToken: () => { const resource = foundation.gitHubEndpointService.getRepoResource(); return foundation.authenticationService.getAuthToken({ resource: resource.resource, scopes: resource.scopes_supported }); }, getGitHubHost: () => foundation.gitHubEndpointService.getEnterpriseHost() ?? 'github.com', gitHubService, - copilotApiService: effectiveCopilotApiService, + utilityModelService, getInitialTitleGenerationStrategy: () => initialTitleGenerationStrategy, }))); const localTurns = new AgentHostLocalTurns(sessionDataService, logService); diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 1207982143b937..a890208d36c974 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -1698,12 +1698,20 @@ suite('CopilotAgent', () => { defaultChat: (await agent.getSessionStateFile(session))?.toString(), explicitDefaultChat: (await agent.getSessionStateFile(session, URI.parse(buildDefaultChatUri(session))))?.toString(), peerChat: (await agent.getSessionStateFile(session, peerChat))?.toString(), + defaultPlan: agent.getSessionPlanFile(session, URI.parse(buildDefaultChatUri(session)))?.toString(), + peerPlan: agent.getSessionPlanFile(session, peerChat)?.toString(), + unknownPeerPlan: agent.getSessionPlanFile(session, URI.parse(buildChatUri(session, 'unknown')))?.toString(), }, { beforeCreate: undefined, defaultChat: 'file:///home/test/.copilot/session-state/sdk-conversation-id/events.jsonl', explicitDefaultChat: 'file:///home/test/.copilot/session-state/sdk-conversation-id/events.jsonl', peerChat: 'file:///home/test/.copilot/session-state/peer-sdk-conversation-id/events.jsonl', + defaultPlan: 'file:///home/test/.copilot/session-state/sdk-conversation-id/plan.md', + peerPlan: 'file:///home/test/.copilot/session-state/peer-sdk-conversation-id/plan.md', + unknownPeerPlan: undefined, }); + chatBackings(agent).set(peerChat.toString(), { sdkSessionId: '../unrelated-session' }); + assert.strictEqual(agent.getSessionPlanFile(session, peerChat), undefined); } finally { await disposeAgent(agent); } diff --git a/src/vs/platform/agentHost/test/node/copilotSdkManagedSettings.integrationTest.ts b/src/vs/platform/agentHost/test/node/copilotSdkManagedSettings.integrationTest.ts new file mode 100644 index 00000000000000..d389eb57daf73e --- /dev/null +++ b/src/vs/platform/agentHost/test/node/copilotSdkManagedSettings.integrationTest.ts @@ -0,0 +1,56 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { CopilotClient } from '@github/copilot-sdk'; +import { existsSync } from 'fs'; +import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises'; +import { tmpdir } from 'os'; +import { dirname, join } from '../../../../base/common/path.js'; +import { assertSnapshot } from '../../../../base/test/common/snapshot.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { createCopilotCliEnvironment } from '../../node/copilot/copilotCliEnvironment.js'; +import { AgentHostUpdateAhpSnapshotsEnvVar, snapshotPathForTest } from './e2e/harness/ahpSnapshot.js'; +import { createIsolatedProviderEnvironment } from './providerTestEnvironment.js'; + +suite('Copilot SDK - managed settings', function () { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('bundled managedSettings.schema matches the snapshot', async function () { + this.timeout(60_000); + const home = await mkdtemp(join(tmpdir(), 'copilot-managed-schema-')); + const client = new CopilotClient({ + mode: 'empty', + baseDirectory: home, + useLoggedInUser: false, + env: createCopilotCliEnvironment(createIsolatedProviderEnvironment(home, { + PATH: process.env.PATH, + SystemRoot: process.env.SystemRoot, + WINDIR: process.env.WINDIR, + COPILOT_TELEMETRY_ENABLED: 'false', + })), + }); + try { + await client.start(); + const result = await client.rpc.managedSettings.schema(); + assert.ok(result.schema && typeof result.schema === 'object' && !Array.isArray(result.schema)); + const content = JSON.stringify(result, null, '\t') + '\n'; + const snapshotPath = snapshotPathForTest(this.test!, 'schema', 'json'); + if (process.env[AgentHostUpdateAhpSnapshotsEnvVar] === '1') { + await mkdir(dirname(snapshotPath), { recursive: true }); + await writeFile(snapshotPath, content); + } else { + assert.ok(existsSync(snapshotPath), `Missing schema baseline. Generate it with ${AgentHostUpdateAhpSnapshotsEnvVar}=1 and review the result.`); + await assertSnapshot(content, { name: 'schema', extension: 'json' }); + } + } finally { + try { + await client.stop(); + } finally { + await rm(home, { recursive: true, force: true }); + } + } + }); +}); diff --git a/src/vs/platform/agentHost/test/node/e2e/README.md b/src/vs/platform/agentHost/test/node/e2e/README.md index 52fb1ecfb2425d..3defeb2c0f1f98 100644 --- a/src/vs/platform/agentHost/test/node/e2e/README.md +++ b/src/vs/platform/agentHost/test/node/e2e/README.md @@ -476,6 +476,24 @@ After recording, **review the diff** (paths normalized? no usernames, tokens, or --- +### Managed-settings schema snapshot + +[`../copilotSdkManagedSettings.integrationTest.ts`](../copilotSdkManagedSettings.integrationTest.ts) +snapshots the complete `managedSettings.schema()` response from the bundled SDK/runtime. +This is a sessionless SDK integration test, not an AHP scenario: it creates no chat, +calls no model, and starts no MCP servers. It uses an isolated home without login credentials. +The snapshot preserves the schema's descriptions, defaults, validation rules, and composition metadata. + +```bash +# Compare with the committed baseline. +./scripts/test-integration.sh --run src/vs/platform/agentHost/test/node/copilotSdkManagedSettings.integrationTest.ts + +# Regenerate after an intentional SDK/schema change, then review and rerun without the flag. +AGENT_HOST_UPDATE_AHP_SNAPSHOTS=1 ./scripts/test-integration.sh --run src/vs/platform/agentHost/test/node/copilotSdkManagedSettings.integrationTest.ts +``` + +A missing baseline fails unless the explicit update flag is set, matching the prompt snapshots. + ## Writing a new test Most tests are cross-provider and live in a focused module under `suites/`. A shared test receives `IAgentHostE2ETestContext` and registers its cases: diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index 940e5fc943560c..852babf07c08e6 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -8,7 +8,7 @@ import sinon from 'sinon'; import { generateKeyPairSync, sign, type JsonWebKey } from 'crypto'; import { EventEmitter } from 'events'; import sodium from 'libsodium-wrappers'; -import { mkdir, mkdtemp, rm, symlink, writeFile } from 'fs/promises'; +import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'fs/promises'; import { dirname, join } from '../../../../base/common/path.js'; import { NullAgentHostOTelService } from '../../common/otel/agentHostOTelService.js'; import { supportsAgentHostTiming } from '../../common/meta/agentHostTimingMeta.js'; @@ -34,14 +34,15 @@ import { ITelemetryService, TelemetryLevel } from '../../../telemetry/common/tel 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 { DevContainerConnectExtensionMethod, DevContainerDisconnectExtensionMethod, DevContainerIsDockerAvailableExtensionMethod, DevContainerOutputNotification, RemoveSessionArtifactExtensionMethod, RequestAgentHostMcpAuthenticationExtensionMethod, RequestAgentHostWorkspaceTrustExtensionMethod, StopBackgroundWorkExtensionMethod, supportsAgentHostArtifactRemoval, supportsAgentHostDevContainers, type IAgentHostMcpAuthenticationRequest } from '../../common/agentHostExtensionProtocol.js'; -import { ChatSourceKind, CompletionsParams, CompletionsResult, ContentEncoding, CreateTerminalParams, ListSessionsResult, ResourceReadResult, ResolveSessionConfigResult, SessionConfigCompletionsResult, ResourceMkdirParams, ResourceMkdirResult, ResourceResolveParams, ResourceResolveResult, ResourceCopyParams, ResourceCopyResult } from '../../common/state/protocol/commands.js'; +import { ChatSourceKind, CompletionsParams, CompletionsResult, ContentEncoding, CreateTerminalParams, ListSessionsResult, ResourceReadResult, ResolveSessionConfigResult, SessionConfigCompletionsResult, ResourceMkdirParams, ResourceMkdirResult, ResourceResolveParams, ResourceResolveResult, ResourceCopyParams, ResourceCopyResult, ResourceType } 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'; import { ActionType, type ActionEnvelope, type ChatAction, type ClientAnnotationsAction, type ClientAutomationAction, type ClientAutomationRunAction, type ClientChangesetAction, type IRootConfigChangedAction, type ProgressParams, type SessionAction, type TerminalAction } from '../../common/state/sessionActions.js'; import { PROTOCOL_VERSION } from '../../common/state/protocol/version/registry.js'; import { isJsonRpcNotification, isJsonRpcRequest, isJsonRpcResponse, JSON_RPC_INTERNAL_ERROR, JsonRpcErrorCodes, ProtocolError, AhpErrorCodes, AHP_UNSUPPORTED_PROTOCOL_VERSION, AHP_SESSION_NOT_FOUND, AHP_AUTH_REQUIRED, type AhpNotification, type InitializeResult, type ProtocolMessage, type JsonRpcResponse, type ReconnectResult, type ResourceListResult, type ResourceWriteParams, type ResourceWriteResult, type IStateSnapshot, type SubscribeResult } from '../../common/state/sessionProtocol.js'; import { ROOT_STATE_URI, AUTOMATION_CATALOG_URI, ChatInteractivity, ChatOriginKind, MessageKind, ResponsePartKind, SessionStatus, ChangesetStatus, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, buildChatUri, buildDefaultChatUri, buildSubagentChatUri, readSessionExternal, readSessionWorkspaceless, withSessionExternal, withSessionWorkspaceless, type AgentInfo, type ChangesetState, type ChatState, type RootState, type SessionState, type SessionSummary } from '../../common/state/sessionState.js'; -import { SessionInputRequestKind, TerminalClaimKind } from '../../common/state/protocol/state.js'; +import { ChatInputResponseKind, SessionInputRequestKind, TerminalClaimKind } from '../../common/state/protocol/state.js'; +import type { ChatInputRequestWithPlanReview } from '../../common/agentHostPlanReview.js'; import type { SessionAddedParams, SessionSummaryChangedParams } from '../../common/state/protocol/notifications.js'; import type { IProtocolServer, IProtocolTransport } from '../../common/state/sessionTransport.js'; import { ProtocolServerHandler } from '../../node/protocolServerHandler.js'; @@ -319,6 +320,7 @@ class MockAgentService implements IAgentService { this.getSessionStateFileCalls.push({ session: session.toString(), chat: chat?.toString() }); return URI.file('/state/sdk-session/events.jsonl'); } + getSessionPlanFile(_session: URI, _chat: URI): URI | undefined { return undefined; } async removeSessionArtifact(session: URI, artifactId: string): Promise { this.removeSessionArtifactCalls.push({ session: session.toString(), artifactId }); } @@ -3191,6 +3193,197 @@ suite('ProtocolServerHandler', () => { }); }); + test('MC plan access denies distinct case-variant requested files and session directories', async function () { + const path = await mkdtemp(join(process.cwd(), '.build', 'mission-control-plan-case-grants-')); + try { + const workspace = join(path, 'workspace'); + const owner = join(path, 'session-state', 'native-backing'); + const variantOwner = join(path, 'session-state', 'NATIVE-BACKING'); + await Promise.all([mkdir(workspace), mkdir(owner, { recursive: true })]); + const plan = URI.file(join(owner, 'plan.md')); + const variantPlan = URI.file(join(owner, 'PLAN.md')); + const variantDirectoryPlan = URI.file(join(variantOwner, 'plan.md')); + await writeFile(plan.fsPath, '# Owned plan'); + try { + await writeFile(variantPlan.fsPath, 'private case-variant file', { flag: 'wx' }); + await mkdir(variantOwner); + await writeFile(variantDirectoryPlan.fsPath, 'private case-variant directory'); + } catch (error) { + if (hasKey(error, { code: true }) && error.code === 'EEXIST') { + this.skip(); + } + throw error; + } + + stateManager.createSession(makeSessionSummary()); + const chat = buildDefaultChatUri(sessionUri); + agentService.getSessionPlanFile = () => plan; + agentService.resourceRead = async uri => ({ data: await readFile(uri.fsPath, 'utf8'), encoding: ContentEncoding.Utf8 }); + stateManager.dispatchServerAction(chat, { + type: ActionType.ChatTurnStarted, turnId: 'case-plan-turn', startedAt: '2026-10-08T20:00:00.000Z', + message: { text: 'Plan a change', origin: { kind: MessageKind.User } }, + }); + const inputRequest: ChatInputRequestWithPlanReview = { + id: 'case-plan-review', + planReview: { title: 'Review Plan', content: 'Summary', actions: [{ id: 'implement', label: 'Implement Plan' }], canProvideFeedback: true, answerQuestionId: 'choice', planUri: plan.toString() }, + }; + stateManager.dispatchServerAction(chat, { type: ActionType.ChatInputRequested, request: inputRequest }); + const relay = disposables.add(new MockProtocolServer()); + const scopedHandler = disposables.add(new ProtocolServerHandler( + agentService, stateManager, relay, { allowExtensionMethods: false, relayResourceRoots: () => [workspace] }, + disposables.add(new AgentHostFileSystemProvider()), logService, NullTelemetryService, + managedSettingsService, clientConnections, devContainerService, NullAgentHostOTelService, + )); + const transport = disposables.add(new MockProtocolTransport(AgentHostTransportKind.WebSocket, 'case-plan-lane', false)); + transport.relayAuthenticated = true; + relay.simulateConnection(transport); + transport.simulateMessage(request(1, 'initialize', { clientId: 'case-plan-lane', protocolVersions: [PROTOCOL_VERSION] })); + for (const [index, uri] of [plan, variantPlan, variantDirectoryPlan].entries()) { + transport.simulateMessage(request(index + 2, 'resourceRead', { uri: uri.toString() })); + } + await scopedHandler.whenIdle(); + assert.deepStrictEqual([2, 3, 4].map(id => { + const response = findResponse(transport.sent, id); + return response && hasKey(response, { result: true }) ? response.result + : response && hasKey(response, { error: true }) ? response.error.code : undefined; + }), [{ data: '# Owned plan', encoding: ContentEncoding.Utf8 }, AhpErrorCodes.PermissionDenied, AhpErrorCodes.PermissionDenied]); + } finally { + await rm(path, { recursive: true }); + } + }); + + test('MC plan access is bound to the published review, provider backing and pending write lifetime', async () => { + const path = await mkdtemp(join(process.cwd(), '.build', 'mission-control-plan-grants-')); + try { + const workspace = join(path, 'workspace'); + const storage = join(path, 'session-state'); + const owner = join(storage, 'native-backing'); + const other = join(storage, 'unrelated-session'); + await Promise.all([mkdir(workspace), mkdir(owner, { recursive: true }), mkdir(other, { recursive: true })]); + const plan = URI.file(join(owner, 'plan.md')); + const privateFile = URI.file(join(owner, 'events.jsonl')); + const otherPlan = URI.file(join(other, 'plan.md')); + await Promise.all([writeFile(plan.fsPath, '# Full plan'), writeFile(privateFile.fsPath, 'private'), writeFile(otherPlan.fsPath, 'unrelated')]); + stateManager.createSession({ ...makeSessionSummary(), workingDirectories: [URI.file(workspace).toString()] }); + const chat = buildChatUri(sessionUri, 'opaque-peer'); + stateManager.addChat(sessionUri, chat); + agentService.getSessionPlanFile = (session, resource) => session.toString() === sessionUri && resource.toString() === chat ? plan : undefined; + agentService.resourceRead = async uri => ({ data: await readFile(uri.fsPath, 'utf8'), encoding: ContentEncoding.Utf8 }); + agentService.resourceResolve = async params => ({ uri: params.uri, type: ResourceType.File }); + agentService.resourceWrite = async params => { + await writeFile(URI.parse(params.uri).fsPath, params.data); + return {}; + }; + const relay = disposables.add(new MockProtocolServer()); + const scopedHandler = disposables.add(new ProtocolServerHandler( + agentService, stateManager, relay, { allowExtensionMethods: false, relayResourceRoots: () => [workspace] }, + disposables.add(new AgentHostFileSystemProvider()), logService, NullTelemetryService, + managedSettingsService, clientConnections, devContainerService, NullAgentHostOTelService, + )); + const transport = disposables.add(new MockProtocolTransport(AgentHostTransportKind.WebSocket, 'plan-lane', false)); + transport.relayAuthenticated = true; + relay.simulateConnection(transport); + transport.simulateMessage(request(1, 'initialize', { clientId: 'plan-lane', protocolVersions: [PROTOCOL_VERSION] })); + transport.simulateMessage(request(2, 'resourceRead', { uri: plan.toString() })); + await scopedHandler.whenIdle(); + assert.deepStrictEqual(findResponse(transport.sent, 2), { + jsonrpc: '2.0', id: 2, error: { code: AhpErrorCodes.PermissionDenied, message: 'Resource is outside the host workspace grants' }, + }); + stateManager.dispatchServerAction(chat, { + type: ActionType.ChatTurnStarted, turnId: 'plan-turn', startedAt: '2026-10-06T19:00:00.000Z', + message: { text: 'Plan a change', origin: { kind: MessageKind.User } }, + }); + const publish = (id: string, uri: URI, canProvideFeedback = true) => { + const inputRequest: ChatInputRequestWithPlanReview = { + id, + planReview: { title: 'Review Plan', content: 'Summary', actions: [{ id: 'implement', label: 'Implement Plan' }], canProvideFeedback, answerQuestionId: 'choice', planUri: uri.toString() }, + }; + stateManager.dispatchServerAction(chat, { type: ActionType.ChatInputRequested, request: inputRequest }); + }; + publish('review', plan); + publish('forged-owner', otherPlan); + transport.simulateMessage(request(3, 'resourceRead', { uri: plan.toString() })); + await scopedHandler.whenIdle(); + assert.deepStrictEqual(findResponse(transport.sent, 3), { + jsonrpc: '2.0', id: 3, result: { data: '# Full plan', encoding: ContentEncoding.Utf8 }, + }); + const cases = [ + { method: 'resourceRead', uri: plan.toString(), allowed: true }, + { method: 'resourceResolve', uri: plan.toString(), allowed: true }, + { method: 'resourceWrite', uri: plan.toString(), allowed: true }, + { method: 'resourceRead', uri: privateFile.toString(), allowed: false }, + { method: 'resourceWrite', uri: privateFile.toString(), allowed: false }, + { method: 'resourceRead', uri: otherPlan.toString(), allowed: false }, + { method: 'resourceRead', uri: URI.file(owner).toString(), allowed: false }, + { method: 'resourceList', uri: URI.file(owner).toString(), allowed: false }, + { method: 'resourceList', uri: plan.toString(), allowed: false }, + { method: 'resourceDelete', uri: plan.toString(), allowed: false }, + { method: 'resourceMkdir', uri: plan.toString(), allowed: false }, + { method: 'resourceRead', uri: plan.with({ query: 'forged=1' }).toString(), allowed: false }, + { method: 'resourceRead', uri: plan.with({ fragment: 'forged' }).toString(), allowed: false }, + { method: 'resourceRead', uri: plan.with({ authority: 'unrelated-host' }).toString(), allowed: false }, + { method: 'resourceRead', uri: plan.with({ path: `${plan.path}/child` }).toString(), allowed: false }, + { method: 'resourceRead', uri: plan.with({ path: `${URI.file(owner).path}/../unrelated-session/plan.md` }).toString(), allowed: false }, + { method: 'resourceCopy', uri: plan.toString(), allowed: false }, + ]; + for (const [index, item] of cases.entries()) { + transport.simulateMessage(request(index + 10, item.method, { uri: item.uri, source: item.uri, destination: URI.file(join(workspace, 'copy.md')).toString(), data: '# Edited full plan', encoding: ContentEncoding.Utf8 })); + } + await scopedHandler.whenIdle(); + assert.deepStrictEqual(cases.map((item, index) => { + const result = findResponse(transport.sent, index + 10); + return { method: item.method, uri: item.uri, code: result && hasKey(result, { error: true }) ? result.error.code : 0 }; + }), cases.map(item => ({ method: item.method, uri: item.uri, code: item.allowed ? 0 : AhpErrorCodes.PermissionDenied }))); + assert.strictEqual(await readFile(plan.fsPath, 'utf8'), '# Edited full plan'); + stateManager.dispatchServerAction(chat, { type: ActionType.ChatInputCompleted, requestId: 'review', response: ChatInputResponseKind.Accept }); + transport.simulateMessage(request(40, 'resourceRead', { uri: plan.toString() })); + transport.simulateMessage(request(41, 'resourceWrite', { uri: plan.toString(), data: 'forbidden', encoding: ContentEncoding.Utf8 })); + await scopedHandler.whenIdle(); + assert.deepStrictEqual([40, 41].map(id => { + const response = findResponse(transport.sent, id); + return response && hasKey(response, { error: true }) ? response.error.code : 0; + }), [0, AhpErrorCodes.PermissionDenied]); + publish('rejected-review', plan); + stateManager.dispatchServerAction(chat, { type: ActionType.ChatInputCompleted, requestId: 'rejected-review', response: ChatInputResponseKind.Decline }); + transport.simulateMessage(request(44, 'resourceWrite', { uri: plan.toString(), data: 'forbidden', encoding: ContentEncoding.Utf8 })); + await scopedHandler.whenIdle(); + const rejectedWrite = findResponse(transport.sent, 44); + assert.strictEqual(rejectedWrite && hasKey(rejectedWrite, { error: true }) ? rejectedWrite.error.code : undefined, AhpErrorCodes.PermissionDenied); + publish('read-only-review', plan, false); + transport.simulateMessage(request(47, 'resourceWrite', { uri: plan.toString(), data: 'forbidden', encoding: ContentEncoding.Utf8 })); + await scopedHandler.whenIdle(); + const readOnlyWrite = findResponse(transport.sent, 47); + assert.strictEqual(readOnlyWrite && hasKey(readOnlyWrite, { error: true }) ? readOnlyWrite.error.code : undefined, AhpErrorCodes.PermissionDenied); + await rm(plan.fsPath); + await symlink(privateFile.fsPath, plan.fsPath, process.platform === 'win32' ? 'junction' : 'file'); + transport.simulateMessage(request(42, 'resourceRead', { uri: plan.toString() })); + await scopedHandler.whenIdle(); + const redirectedPlan = findResponse(transport.sent, 42); + assert.strictEqual(redirectedPlan && hasKey(redirectedPlan, { error: true }) ? redirectedPlan.error.code : undefined, AhpErrorCodes.PermissionDenied); + await rm(owner, { recursive: true }); + await symlink(other, owner, process.platform === 'win32' ? 'junction' : 'dir'); + transport.simulateMessage(request(43, 'resourceRead', { uri: plan.toString() })); + await scopedHandler.whenIdle(); + const redirectedDirectory = findResponse(transport.sent, 43); + assert.strictEqual(redirectedDirectory && hasKey(redirectedDirectory, { error: true }) ? redirectedDirectory.error.code : undefined, AhpErrorCodes.PermissionDenied); + await rm(owner); + await mkdir(plan.fsPath, { recursive: true }); + transport.simulateMessage(request(45, 'resourceResolve', { uri: plan.toString() })); + await scopedHandler.whenIdle(); + const planDirectory = findResponse(transport.sent, 45); + assert.strictEqual(planDirectory && hasKey(planDirectory, { error: true }) ? planDirectory.error.code : undefined, AhpErrorCodes.PermissionDenied); + await rm(plan.fsPath, { recursive: true }); + await writeFile(plan.fsPath, '# Full plan'); + stateManager.deleteSession(sessionUri); + transport.simulateMessage(request(46, 'resourceRead', { uri: plan.toString() })); + await scopedHandler.whenIdle(); + const retiredPlan = findResponse(transport.sent, 46); + assert.strictEqual(retiredPlan && hasKey(retiredPlan, { error: true }) ? retiredPlan.error.code : undefined, AhpErrorCodes.PermissionDenied); + } finally { + await rm(path, { recursive: true }); + } + }); + test('MC MCP app resources route to their owning provider without filesystem grants', async () => { const relay = disposables.add(new MockProtocolServer()); disposables.add(new ProtocolServerHandler( diff --git a/src/vs/platform/agentHost/test/node/providerIntegration/README.md b/src/vs/platform/agentHost/test/node/providerIntegration/README.md index eefe08245638c7..6ddbd887efcc34 100644 --- a/src/vs/platform/agentHost/test/node/providerIntegration/README.md +++ b/src/vs/platform/agentHost/test/node/providerIntegration/README.md @@ -20,6 +20,12 @@ Run one suite with: ./scripts/test-integration.sh --run src/vs/platform/agentHost/test/node/providerIntegration/copilotOtel.integrationTest.ts ``` +`copilotByokSelectionIds.integrationTest.ts` pins the SDK contract behind `getByokLmAgentModelId` and `isByokLmAgentModelId`: the bundled runtime lists and selects BYOK models registered through the production `synthesizeByokSessionConfig` under the agent host's `provider/[group/]id` ids, and that no Copilot model it lists has that shape. Signed out, the runtime lists Copilot models only when it can reach the Copilot API, so that half is checked opportunistically here and deterministically by `../../common/agentHostByokLm.test.ts`. The utility model service relies on that shape to keep a retained BYOK selection off the Copilot route while the renderer is disconnected, so an SDK change to either id format should fail here first. It needs no Copilot sign-in. Run it with: + +```bash +./scripts/test-integration.sh --run src/vs/platform/agentHost/test/node/providerIntegration/copilotByokSelectionIds.integrationTest.ts +``` + `copilotManagedPermissions.integrationTest.ts` exercises the default-on legacy bridge against the bundled runtime with an isolated synthetic BYOK model. It checks URL denials and terminal managed asks on create, cold resume, and removal, preserving diff --git a/src/vs/platform/agentHost/test/node/providerIntegration/copilotByokSelectionIds.integrationTest.ts b/src/vs/platform/agentHost/test/node/providerIntegration/copilotByokSelectionIds.integrationTest.ts new file mode 100644 index 00000000000000..0f60fce869f97d --- /dev/null +++ b/src/vs/platform/agentHost/test/node/providerIntegration/copilotByokSelectionIds.integrationTest.ts @@ -0,0 +1,108 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { mkdtemp, rm } from 'fs/promises'; +import { tmpdir } from 'os'; +import { CopilotClient } from '@github/copilot-sdk'; +import { Emitter } from '../../../../../base/common/event.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { NullLogService } from '../../../../log/common/log.js'; +import { getByokLmAgentModelId, isByokLmAgentModelId, type IByokLmModelInfo } from '../../../common/agentHostByokLm.js'; +import { ByokLmBridgeRegistry } from '../../../node/byokLmBridgeRegistry.js'; +import { ByokLmProxyService } from '../../../node/copilot/byokLmProxyService.js'; +import { createCopilotCliEnvironment } from '../../../node/copilot/copilotCliEnvironment.js'; +import { synthesizeByokSessionConfig } from '../../../node/copilot/copilotSessionLauncher.js'; +import { createIsolatedProviderEnvironment } from '../providerTestEnvironment.js'; + +/** + * Pins the SDK contract that `getByokLmAgentModelId` and `isByokLmAgentModelId` + * rely on: the runtime lists and selects a BYOK model under the + * provider-qualified id `provider/id`, and no Copilot (CAPI) model it lists + * takes that shape. The utility model service uses `isByokLmAgentModelId` to + * keep a retained BYOK selection off the Copilot route while no renderer serves + * BYOK models, so a runtime change to the BYOK id format must fail here. + */ +suite('Agent Host Provider Integration - Copilot BYOK selection ids', function () { + + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + const byokModels: IByokLmModelInfo[] = [ + { vendor: 'acme', id: 'test-model', modelIdentifier: 'acme/test-model' }, + // A configured provider group becomes part of the selection id. + { vendor: 'azure', id: 'gpt-5', modelIdentifier: 'azure/work/gpt-5' }, + // Provider-local ids may themselves contain slashes (e.g. Gemini's `models/...`). + { vendor: 'gemini', id: 'models/gemini-flash', modelIdentifier: 'gemini/Google/models/gemini-flash' }, + ]; + + test('the bundled runtime lists and selects BYOK models under the agent host model ids', async function () { + this.timeout(120_000); + + const sessionId = 'byok-selection-ids'; + const baseDirectory = await mkdtemp(`${tmpdir()}/byok-selection-ids-`); + const models = store.add(new Emitter()); + const registry = new ByokLmBridgeRegistry(); + const registration = registry.register('client', { chat: async () => ({ output: [] }), onDidChangeModels: models.event }); + models.fire(byokModels); + const proxy = new ByokLmProxyService(new NullLogService(), registry); + const handle = await proxy.start(); + const client = new CopilotClient({ + mode: 'empty', + baseDirectory, + useLoggedInUser: false, + logLevel: 'error', + env: createCopilotCliEnvironment(createIsolatedProviderEnvironment(baseDirectory)), + }); + let clientStarted = false; + let session: Awaited> | undefined; + + try { + const agentModelIds = byokModels.map(getByokLmAgentModelId); + // The same providers/models config the session launcher passes to the runtime. + const byok = await synthesizeByokSessionConfig(sessionId, registry, async () => handle, new NullLogService()); + await client.start(); + clientStarted = true; + session = await client.createSession({ sessionId, model: agentModelIds[0], availableTools: [], ...byok }); + + const listedIds = (await session.rpc.model.list()).list.map(entry => (entry as { id: string }).id); + const selectedIds: (string | undefined)[] = []; + for (const id of agentModelIds) { + await session.rpc.model.switchTo({ modelId: id }); + selectedIds.push((await session.rpc.model.getCurrent()).modelId); + } + + assert.deepStrictEqual({ + agentModelIds, + byokIdsListed: agentModelIds.every(id => listedIds.includes(id)), + // Also excludes any Copilot model the runtime lists. Whether it lists + // Copilot models while signed out depends on whether it can reach the + // Copilot API, so their presence is not asserted; the agentHostByokLm + // unit test pins bare Copilot ids deterministically. + listedIdsWithByokShape: listedIds.filter(isByokLmAgentModelId).sort(), + selectedIds, + }, { + agentModelIds: ['acme/test-model', 'azure/work/gpt-5', 'gemini/Google/models/gemini-flash'], + byokIdsListed: true, + listedIdsWithByokShape: [...agentModelIds].sort(), + selectedIds: agentModelIds, + }); + } finally { + try { + await session?.disconnect(); + } finally { + try { + if (clientStarted) { + await client.stop(); + } + } finally { + handle.dispose(); + registration.dispose(); + proxy.dispose(); + await rm(baseDirectory, { recursive: true, force: true }); + } + } + } + }); +}); diff --git a/src/vs/platform/agentHost/test/node/tunnelAgentHostService.test.ts b/src/vs/platform/agentHost/test/node/tunnelAgentHostService.test.ts index a409d81e53660c..1de201427cb07b 100644 --- a/src/vs/platform/agentHost/test/node/tunnelAgentHostService.test.ts +++ b/src/vs/platform/agentHost/test/node/tunnelAgentHostService.test.ts @@ -4,11 +4,14 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import sinon from 'sinon'; +import { TunnelManagementHttpClient } from '@microsoft/dev-tunnels-management'; import * as net from 'net'; import { DeferredPromise, timeout } from '../../../../base/common/async.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { toDisposable } from '../../../../base/common/lifecycle.js'; import { NullLogService } from '../../../log/common/log.js'; +import { NullTelemetryService } from '../../../telemetry/common/telemetryUtils.js'; import { runWithFakedTimers } from '../../../../base/test/common/timeTravelScheduler.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { isTunnelGatewaySelectionRejectedError, TUNNEL_GATEWAY_SELECTION_REJECTED_ERROR_NAME } from '../../common/tunnelAgentHost.js'; @@ -80,6 +83,34 @@ class FakeRelayClient implements ITunnelRelayClient { } } +suite('TunnelAgentHostService - correlation', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + teardown(() => sinon.restore()); + + test('uses the injected telemetry session and retains the operation across enumeration requests', async () => { + const requests: Record[] = []; + sinon.stub(TunnelManagementHttpClient.prototype, 'listTunnels').callsFake(async function captureHeaders(this: TunnelManagementHttpClient) { + requests.push({ ...this.additionalRequestHeaders }); + return []; + }); + const telemetry = sinon.spy(NullTelemetryService, 'publicLog2'); + const service = store.add(new TunnelAgentHostMainService(new NullLogService(), NullTelemetryService)); + await service.listTunnels('token', 'github', ['additional-tunnel']); + const operationId = requests[0]['X-Tunnels-VSCode-Client-Operation-Id']; + assert.deepStrictEqual({ + telemetry: telemetry.firstCall.args, + sessions: requests.map(request => request['X-Tunnels-VSCode-Session-Id']), + operations: requests.map(request => request['X-Tunnels-VSCode-Client-Operation-Id']), + uniqueRequests: new Set(requests.map(request => request['X-Tunnels-VSCode-Client-Request-Id'])).size, + }, { + telemetry: ['tunnelServiceOperation', { tunnelSessionId: NullTelemetryService.sessionId, operationId, operation: 'list' }], + sessions: [NullTelemetryService.sessionId, NullTelemetryService.sessionId], + operations: [operationId, operationId], + uniqueRequests: 2, + }); + }); +}); + suite('TunnelAgentHostService - withTimeout', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -143,7 +174,7 @@ suite('TunnelAgentHostService - gateway selection', () => { ensureNoDisposablesAreLeakedInTestSuite(); test('completeSelection sends the selection immediately, then resolves once the gateway acknowledges', async () => { - const service = new TunnelAgentHostMainService(new NullLogService()); + const service = new TunnelAgentHostMainService(new NullLogService(), NullTelemetryService); try { const { ws, pending } = createPending(); setPendingGatewaySelectionForTests(service, 'sel1', pending); @@ -182,7 +213,7 @@ suite('TunnelAgentHostService - gateway selection', () => { }); test('completeSelection throws for an unknown selection id', async () => { - const service = new TunnelAgentHostMainService(new NullLogService()); + const service = new TunnelAgentHostMainService(new NullLogService(), NullTelemetryService); try { await assert.rejects( () => service.completeSelection('does-not-exist', { instanceId: 'x' }), @@ -194,7 +225,7 @@ suite('TunnelAgentHostService - gateway selection', () => { }); test('completeSelection surfaces a gateway rejection and closes pending resources without switching targets', async () => { - const service = new TunnelAgentHostMainService(new NullLogService()); + const service = new TunnelAgentHostMainService(new NullLogService(), NullTelemetryService); try { const { ws, relayClient, pending } = createPending(); setPendingGatewaySelectionForTests(service, 'sel1', pending); @@ -222,7 +253,7 @@ suite('TunnelAgentHostService - gateway selection', () => { }); test('completeSelection reports a transport failure as a plain error, never as a gateway rejection', async () => { - const service = new TunnelAgentHostMainService(new NullLogService()); + const service = new TunnelAgentHostMainService(new NullLogService(), NullTelemetryService); try { const { ws, pending } = createPending(); setPendingGatewaySelectionForTests(service, 'sel1', pending); @@ -238,7 +269,7 @@ suite('TunnelAgentHostService - gateway selection', () => { }); test('cancelSelection disposes the pending socket and relay client, and is safe to call again or with an unknown id', async () => { - const service = new TunnelAgentHostMainService(new NullLogService()); + const service = new TunnelAgentHostMainService(new NullLogService(), NullTelemetryService); try { const { ws, relayClient, pending } = createPending(); setPendingGatewaySelectionForTests(service, 'sel1', pending); @@ -258,7 +289,7 @@ suite('TunnelAgentHostService - gateway selection', () => { }); test('completeSelection fails once the pending socket has already closed unexpectedly', async () => { - const service = new TunnelAgentHostMainService(new NullLogService()); + const service = new TunnelAgentHostMainService(new NullLogService(), NullTelemetryService); try { // Wire the unexpected-close callback the same way prepareSelection // does in production: remove the entry from the pending map. diff --git a/src/vs/platform/editor/common/editor.ts b/src/vs/platform/editor/common/editor.ts index d2589f4cb958d9..6ba4d0e685e0b3 100644 --- a/src/vs/platform/editor/common/editor.ts +++ b/src/vs/platform/editor/common/editor.ts @@ -184,6 +184,23 @@ export enum EditorOpenSource { USER } +/** + * An editor column or symbolic placement relative to the active editor. + */ +export enum ViewColumn { + Active = -1, + Beside = -2, + One = 1, + Two = 2, + Three = 3, + Four = 4, + Five = 5, + Six = 6, + Seven = 7, + Eight = 8, + Nine = 9 +} + export interface IEditorOptions { /** diff --git a/src/vs/platform/mcp/common/mcpResourceScannerService.ts b/src/vs/platform/mcp/common/mcpResourceScannerService.ts index 1e535a7b176858..81a0b39407aac6 100644 --- a/src/vs/platform/mcp/common/mcpResourceScannerService.ts +++ b/src/vs/platform/mcp/common/mcpResourceScannerService.ts @@ -13,7 +13,7 @@ import { applyEdits, setProperty } from '../../../base/common/jsonEdit.js'; import { Disposable } from '../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../base/common/map.js'; import { equals } from '../../../base/common/objects.js'; -import { Mutable } from '../../../base/common/types.js'; +import { isObject, Mutable } from '../../../base/common/types.js'; import { URI } from '../../../base/common/uri.js'; import { localize } from '../../../nls.js'; import { ConfigurationTarget, ConfigurationTargetToString } from '../../configuration/common/configuration.js'; @@ -143,7 +143,8 @@ export class McpResourceScannerService extends Disposable implements IMcpResourc if (error) { throw new Error(error); } - const edits = getChangedProperties(toCopilotMcpServerConfiguration(previous), toCopilotMcpServerConfiguration(config)); + const entry = toCopilotGlobalMcpServerEntry(config); + const edits = withServerTypeEdits(getChangedProperties(toCopilotGlobalMcpServerEntry(previous), entry), previous, config, Reflect.get(entry, 'type'), undefined); await this.withCopilotGlobalMcpServers(mcpResource, (content, servers) => { if (!Object.hasOwn(servers, name)) { throw notFound(); @@ -160,7 +161,7 @@ export class McpResourceScannerService extends Disposable implements IMcpResourc if (error) { throw new Error(error); } - const edits = getChangedProperties(previous, config); + const edits = withServerTypeEdits(getChangedProperties(previous, config), previous, config, config.type, config.type === McpServerType.REMOTE ? config.transport : undefined); await this.withWorkspaceRootMcpServers(mcpResource, (content, wrapped, servers) => { if (!Object.hasOwn(servers, name)) { throw notFound(); @@ -431,21 +432,69 @@ registerSingleton(IMcpResourceScannerService, McpResourceScannerService, Instant /** * The part of {@link config} that an update to {@link previous} has to validate: the changed - * properties plus `type`, `command` and `url`, which define the server. Unchanged properties - * stay as they are in the file, so values that a shared format does not support, such as `cwd` - * in `.mcp.json`, do not block edits to other properties. + * properties plus `type`, `command` and `url`, which define the server. Unchanged properties, and + * unchanged entries of changed `env`, `headers` and `args` values, stay as they are in the file, so + * values that a shared format does not support, such as `cwd` in `.mcp.json`, do not block edits + * to other properties. */ export function getEditedMcpServerConfiguration(previous: IMcpServerConfiguration, config: IMcpServerConfiguration): IMcpServerConfiguration { const changed = new Set(getChangedProperties(previous, config).map(([key]) => key)); const edited: Mutable = { ...config }; for (const key of Object.keys(edited)) { - if (key !== 'type' && key !== 'command' && key !== 'url' && !changed.has(key)) { + if (key === 'type' || key === 'command' || key === 'url') { + continue; + } + if (!changed.has(key)) { Reflect.deleteProperty(edited, key); + continue; + } + const before: unknown = Reflect.get(previous, key); + const after: unknown = Reflect.get(edited, key); + if (Array.isArray(before) && Array.isArray(after)) { + // Each existing item accounts for one unchanged item, so an added duplicate is still validated. + const unmatched = [...before]; + Reflect.set(edited, key, after.filter(item => { + const index = unmatched.findIndex(old => equals(old, item)); + if (index === -1) { + return true; + } + unmatched.splice(index, 1); + return false; + })); + } else if (isObject(before) && isObject(after)) { + Reflect.set(edited, key, Object.fromEntries(Object.entries(after).filter(([name, value]) => !equals(value, Reflect.get(before, name))))); } } return edited; } +/** + * A server entry in the Copilot CLI's configuration, as {@link IMcpResourceScannerService.updateMcpServer} + * writes it. Unlike {@link toCopilotMcpServerConfiguration}, which targets the SDK, it uses the + * canonical `stdio` type (`local` is a legacy alias) and keeps `env` values as they are instead of + * converting them to strings. + */ +function toCopilotGlobalMcpServerEntry(config: IMcpServerConfiguration): object { + const entry = toCopilotMcpServerConfiguration(config); + return config.type === McpServerType.LOCAL ? { ...entry, type: McpServerType.LOCAL, env: config.env } : entry; +} + +/** + * Adds explicit `type` and `transport` edits when an update changes the kind of server. The file + * may spell a transport in ways that normalize alike, such as `"type": "sse"` or `"type": "http"` + * with `"transport": "sse"`, so changing only the normalized properties could leave it unchanged. + */ +function withServerTypeEdits(edits: [string, unknown][], previous: IMcpServerConfiguration, config: IMcpServerConfiguration, type: unknown, transport: unknown): [string, unknown][] { + if (getServerKind(previous) === getServerKind(config)) { + return edits; + } + return [...edits.filter(([key]) => key !== 'type' && key !== 'transport'), ['type', type], ['transport', transport]]; +} + +function getServerKind(config: IMcpServerConfiguration): 'stdio' | 'http' | 'sse' { + return config.type === McpServerType.LOCAL ? 'stdio' : config.transport === 'sse' ? 'sse' : 'http'; +} + /** Properties whose values differ between two server configurations; removed properties map to `undefined`. */ function getChangedProperties(previous: object, next: object): [string, unknown][] { const before = previous as Record; diff --git a/src/vs/platform/mcp/test/common/mcpResourceScannerService.test.ts b/src/vs/platform/mcp/test/common/mcpResourceScannerService.test.ts index 0a4f29cf0fc9c5..93286ea552b3f4 100644 --- a/src/vs/platform/mcp/test/common/mcpResourceScannerService.test.ts +++ b/src/vs/platform/mcp/test/common/mcpResourceScannerService.test.ts @@ -144,6 +144,37 @@ suite('McpResourceScannerService', () => { }); }); + test('keeps number and null env values and writes the canonical stdio type', async () => { + await write('{"mcpServers":{"server":{"type":"local","command":"node","env":{"A":"1","PORT":3000,"UNSET":null}},"remote":{"type":"http","url":"https://example.com/mcp","tools":["*"]}}}'); + const servers = (await scanner.scanMcpServers(resource, undefined, format)).servers!; + await scanner.updateMcpServer('server', servers.server, { type: McpServerType.LOCAL, command: 'node', env: { A: '2', PORT: 3000, UNSET: null } }, resource, undefined, format); + await scanner.updateMcpServer('remote', servers.remote, { type: McpServerType.LOCAL, command: 'node' }, resource, undefined, format); + + assert.deepStrictEqual(JSON.parse(await read()).mcpServers, { + server: { type: 'local', command: 'node', env: { A: '2', PORT: 3000, UNSET: null } }, + remote: { type: 'stdio', tools: ['*'], command: 'node', args: [] }, + }); + }); + + test('switching from SSE to HTTP rewrites the type however the file spells SSE', async () => { + await write('{"mcpServers":{"typed":{"type":"sse","url":"https://example.com/a","tools":["*"]},"transport":{"type":"http","transport":"sse","url":"https://example.com/b"}}}'); + const servers = (await scanner.scanMcpServers(resource, undefined, format)).servers!; + for (const name of ['typed', 'transport']) { + const previous = servers[name]; + assert.ok(previous.type === McpServerType.REMOTE && previous.transport === 'sse'); + await scanner.updateMcpServer(name, previous, { ...previous, transport: undefined }, resource, undefined, format); + } + const reloaded = (await scanner.scanMcpServers(resource, undefined, format)).servers!; + + assert.deepStrictEqual({ + document: JSON.parse(await read()).mcpServers, + reloadedTransports: Object.values(reloaded).map(server => server.type === McpServerType.REMOTE ? server.transport : undefined), + }, { + document: { typed: { type: 'http', url: 'https://example.com/a', tools: ['*'] }, transport: { type: 'http', url: 'https://example.com/b' } }, + reloadedTransports: [undefined, undefined], + }); + }); + test('switching transport replaces type-specific properties only', async () => { await write('{"mcpServers":{"remote":{"type":"local","command":"node","args":["server.js"],"tools":["*"],"custom":1}}}'); const previous = (await scanner.scanMcpServers(resource, undefined, format)).servers!.remote; @@ -155,6 +186,34 @@ suite('McpResourceScannerService', () => { }); }); + test('switching a root server from SSE to HTTP rewrites the type however the file spells SSE', async () => { + await write('{ "mcpServers": { "typed": { "type": "sse", "url": "https://example.com/a", "custom": 1 }, "transport": { "type": "http", "transport": "sse", "url": "https://example.com/b" } } }'); + const servers = (await scanner.scanMcpServers(resource, target, format)).servers!; + for (const name of ['typed', 'transport']) { + const previous = servers[name]; + assert.ok(previous.type === McpServerType.REMOTE && previous.transport === 'sse'); + await scanner.updateMcpServer(name, previous, { ...previous, transport: undefined }, resource, target, format); + } + const reloaded = (await scanner.scanMcpServers(resource, target, format)).servers!; + + assert.deepStrictEqual({ + document: parse(await read()).mcpServers, + reloadedTransports: Object.values(reloaded).map(server => server.type === McpServerType.REMOTE ? server.transport : undefined), + }, { + document: { typed: { type: 'http', url: 'https://example.com/a', custom: 1 }, transport: { type: 'http', url: 'https://example.com/b' } }, + reloadedTransports: [undefined, undefined], + }); + }); + + test('unsupported entries already in a root server env do not block edits to other entries', async () => { + await write('{ "mcpServers": { "keep": { "command": "node", "env": { "UNSET": null, "PORT": "1" } } } }'); + const previous = { type: McpServerType.LOCAL, command: 'node', env: { UNSET: null, PORT: '1' } } as const; + await scanner.updateMcpServer('keep', previous, { ...previous, env: { UNSET: null, PORT: '2' } }, resource, target, format); + await assert.rejects(scanner.updateMcpServer('keep', previous, { ...previous, env: { UNSET: null, PORT: '1', OTHER: null } }, resource, target, format)); + + assert.deepStrictEqual(parse(await read()), { mcpServers: { keep: { command: 'node', env: { UNSET: null, PORT: '2' } } } }); + }); + test('unsupported properties already in a root server do not block edits to other properties', async () => { await write('{ "mcpServers": { "keep": { "command": "node", "cwd": "/work", "envFile": ".env" } } }'); const previous = { type: McpServerType.LOCAL, command: 'node', cwd: '/work', envFile: '.env' } as const; diff --git a/src/vs/platform/opener/common/opener.ts b/src/vs/platform/opener/common/opener.ts index 27908c18df45cc..3799e69b7587e2 100644 --- a/src/vs/platform/opener/common/opener.ts +++ b/src/vs/platform/opener/common/opener.ts @@ -6,7 +6,7 @@ import { CancellationToken } from '../../../base/common/cancellation.js'; import { IDisposable } from '../../../base/common/lifecycle.js'; import { URI } from '../../../base/common/uri.js'; -import { IEditorOptions, ITextEditorSelection } from '../../editor/common/editor.js'; +import { IEditorOptions, ITextEditorSelection, ViewColumn } from '../../editor/common/editor.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; export const IOpenerService = createDecorator('openerService'); @@ -65,7 +65,7 @@ export interface IOpener { } export interface IExternalOpener { - openExternal(href: string, ctx: { sourceUri: URI; preferredOpenerId?: string }, token: CancellationToken): Promise; + openExternal(href: string, ctx: { sourceUri: URI; preferredOpenerId?: string; viewColumn?: ViewColumn }, token: CancellationToken): Promise; dispose?(): void; } diff --git a/src/vs/platform/remoteTunnel/common/tunnelServiceHeaders.ts b/src/vs/platform/remoteTunnel/common/tunnelServiceHeaders.ts new file mode 100644 index 00000000000000..debba0c8978c30 --- /dev/null +++ b/src/vs/platform/remoteTunnel/common/tunnelServiceHeaders.ts @@ -0,0 +1,49 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { generateUuid } from '../../../base/common/uuid.js'; +import { ITelemetryService } from '../../telemetry/common/telemetry.js'; + +export interface ITunnelServiceCorrelation { + readonly sessionId: string; + readonly operationId: string; +} + +type TunnelServiceOperation = 'list' | 'connect' | 'delete' | 'host'; + +type TunnelServiceOperationEvent = { + tunnelSessionId: string; + operationId: string; + operation: TunnelServiceOperation; +}; + +type TunnelServiceOperationClassification = { + owner: 'connor4312'; + comment: 'Correlates a tunnel service operation with management and relay requests.'; + tunnelSessionId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The originating VS Code telemetry session ID.' }; + operationId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'A random ID shared by requests belonging to this tunnel operation.' }; + operation: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The kind of tunnel operation.' }; +}; + +export function createTunnelServiceCorrelation(telemetryService: ITelemetryService, operation: TunnelServiceOperation | undefined): ITunnelServiceCorrelation { + const correlation = { sessionId: telemetryService.sessionId, operationId: generateUuid() }; + if (operation !== undefined) { + telemetryService.publicLog2('tunnelServiceOperation', { + tunnelSessionId: correlation.sessionId, + operationId: correlation.operationId, + operation, + }); + } + return correlation; +} + +/** The SDK copies these headers separately for each management request and Node.js relay handshake. */ +export function tunnelServiceHeaders(correlation: ITunnelServiceCorrelation): Record { + return { + 'X-Tunnels-VSCode-Session-Id': correlation.sessionId, + 'X-Tunnels-VSCode-Client-Operation-Id': correlation.operationId, + get 'X-Tunnels-VSCode-Client-Request-Id'() { return generateUuid(); }, + }; +} diff --git a/src/vs/platform/remoteTunnel/node/remoteTunnelService.ts b/src/vs/platform/remoteTunnel/node/remoteTunnelService.ts index 53a88fbd770754..7391717cb62ab6 100644 --- a/src/vs/platform/remoteTunnel/node/remoteTunnelService.ts +++ b/src/vs/platform/remoteTunnel/node/remoteTunnelService.ts @@ -37,12 +37,16 @@ type RemoteTunnelConnectedClassification = { owner: 'aeschli'; comment: 'Reporting when a Remote Tunnel connection is established'; tunnelName: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The name of the connected tunnel' }; - isAttached: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the connection is attached to an existing tunnel process' }; + isAttached: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether the connection is attached to an existing tunnel process' }; + tunnelSessionId?: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The VS Code telemetry session ID supplied to the CLI.' }; + operationId?: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The operation ID supplied to the CLI for tunnel service requests.' }; }; type RemoteTunnelConnectedEvent = { tunnelName: string; isAttached: boolean; + tunnelSessionId?: string; + operationId?: string; }; const restartTunnelOnConfigurationChanges: readonly string[] = [ @@ -231,6 +235,7 @@ export class RemoteTunnelService extends Disposable implements IRemoteTunnelServ this.telemetryService.publicLog2('remoteTunnel.connected', { tunnelName: info.tunnelName, isAttached: info.isAttached, + ...(event.correlation ? { tunnelSessionId: event.correlation.sessionId, operationId: event.correlation.operationId } : {}), }); this.setTunnelStatus(TunnelStates.connected(info, this.tunnelProcessCoordinator.getStatus().serviceInstallFailed)); } else if (event.status.type === 'tokenError') { diff --git a/src/vs/platform/remoteTunnel/node/tunnelProcessCoordinator.ts b/src/vs/platform/remoteTunnel/node/tunnelProcessCoordinator.ts index 7eec16ff5d2113..00d0a3b2b12355 100644 --- a/src/vs/platform/remoteTunnel/node/tunnelProcessCoordinator.ts +++ b/src/vs/platform/remoteTunnel/node/tunnelProcessCoordinator.ts @@ -14,6 +14,8 @@ import { CONFIGURATION_KEY_HOST_NAME, CONFIGURATION_KEY_PREVENT_SLEEP, normalize import { parseTunnelMachineStatus, TunnelMachineStatus } from '../common/tunnelMachineStatus.js'; import { CodeTunnelCli, CodeTunnelCliOutput, ICodeTunnelCliRun } from './codeTunnelCliProcess.js'; import { hostname } from 'os'; +import { ITelemetryService } from '../../telemetry/common/telemetry.js'; +import { createTunnelServiceCorrelation, ITunnelServiceCorrelation } from '../common/tunnelServiceHeaders.js'; type TunnelCliFactory = (onLog: (message: string) => void) => CodeTunnelCli; @@ -59,6 +61,7 @@ export interface ITunnelProcessOutput { export interface ITunnelProcessMachineStatus { readonly mode: TunnelProcessMode; readonly status: TunnelMachineStatus; + readonly correlation: ITunnelServiceCorrelation | undefined; cancel(): void; } @@ -132,6 +135,7 @@ export class TunnelProcessCoordinator extends Disposable implements ITunnelProce @IConfigurationService private readonly configurationService: IConfigurationService, @INativeEnvironmentService private readonly environmentService: INativeEnvironmentService, @IProductService productService: IProductService, + @ITelemetryService private readonly telemetryService: ITelemetryService, ) { super(); this._tunnelCli = tunnelCliFactory?.(() => { }) ?? new CodeTunnelCli({ @@ -340,7 +344,11 @@ export class TunnelProcessCoordinator extends Disposable implements ITunnelProce } private _startTunnel(args: readonly string[], mode: TunnelProcessMode, generation: number): void { - const tunnelRun = this._tunnelCli.run('tunnel', args, (message, isError) => this._fireOutput(mode, message, isError, true, () => tunnelRun.result.cancel(), generation), { VSCODE_CLI_MACHINE_STATUS: '1' }); + const correlation = createTunnelServiceCorrelation(this.telemetryService, 'host'); + const tunnelRun = this._tunnelCli.run('tunnel', args, (message, isError) => this._fireOutput(mode, message, isError, true, () => tunnelRun.result.cancel(), generation, correlation), { + VSCODE_CLI_MACHINE_STATUS: '1', + ...this._correlationEnvironment(correlation), + }); this._currentProcess = tunnelRun; const onSettled = () => { if (this._currentProcess === tunnelRun) { @@ -355,10 +363,11 @@ export class TunnelProcessCoordinator extends Disposable implements ITunnelProce } private async _runTransient(logLabel: string, args: readonly string[], mode: TunnelProcessMode, generation: number, env?: Record, onOutput?: CodeTunnelCliOutput): Promise { + const correlation = createTunnelServiceCorrelation(this.telemetryService, undefined); const run = this._tunnelCli.run(logLabel, args, (message, isError) => { onOutput?.(message, isError); - this._fireOutput(mode, message, isError, false, () => run.result.cancel()); - }, env); + this._fireOutput(mode, message, isError, false, () => run.result.cancel(), generation, correlation); + }, { ...env, ...this._correlationEnvironment(correlation) }); this._currentProcess = run; try { return await run.result; @@ -371,6 +380,10 @@ export class TunnelProcessCoordinator extends Disposable implements ITunnelProce } } + private _correlationEnvironment(correlation: ITunnelServiceCorrelation): Record { + return { VSCODE_TUNNEL_SESSION_ID: correlation.sessionId, VSCODE_TUNNEL_OPERATION_ID: correlation.operationId }; + } + private async _stopCurrentProcess(): Promise { const run = this._currentProcess; if (!run) { @@ -383,7 +396,7 @@ export class TunnelProcessCoordinator extends Disposable implements ITunnelProce } } - private _fireOutput(mode: TunnelProcessMode, message: string, isError: boolean, isTunnelProcess: boolean, cancel: () => void, generation?: number): void { + private _fireOutput(mode: TunnelProcessMode, message: string, isError: boolean, isTunnelProcess: boolean, cancel: () => void, generation: number, correlation: ITunnelServiceCorrelation): void { this._onDidOutput.fire({ mode, message, isError }); if (!isError && isTunnelProcess && generation === this._generation) { const status = parseTunnelMachineStatus(message); @@ -391,7 +404,7 @@ export class TunnelProcessCoordinator extends Disposable implements ITunnelProce if (status.type === 'connected' && this._status.mode === mode) { this._setStatus({ ...this._status, tunnelId: status.tunnelId, connectionState: 'connected' }); } - this._onDidMachineStatus.fire({ mode, status, cancel }); + this._onDidMachineStatus.fire({ mode, status, cancel, correlation: status.type === 'connected' && status.isAttached ? undefined : correlation }); } } } diff --git a/src/vs/platform/remoteTunnel/test/common/tunnelServiceHeaders.test.ts b/src/vs/platform/remoteTunnel/test/common/tunnelServiceHeaders.test.ts new file mode 100644 index 00000000000000..103d599c7a8abb --- /dev/null +++ b/src/vs/platform/remoteTunnel/test/common/tunnelServiceHeaders.test.ts @@ -0,0 +1,49 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { isUUID } from '../../../../base/common/uuid.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { ITelemetryData } from '../../../telemetry/common/telemetry.js'; +import { NullTelemetryServiceShape } from '../../../telemetry/common/telemetryUtils.js'; +import { createTunnelServiceCorrelation, tunnelServiceHeaders } from '../../common/tunnelServiceHeaders.js'; + +suite('Tunnel service headers', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('correlates service headers with telemetry and refreshes the request ID on every copy', () => { + const events: { name: string | undefined; data: ITelemetryData | undefined }[] = []; + const telemetry = new class extends NullTelemetryServiceShape { + override publicLog2(name?: string, data?: ITelemetryData): void { + events.push({ name, data }); + } + }(); + const correlation = createTunnelServiceCorrelation(telemetry, 'connect'); + const headers = tunnelServiceHeaders(correlation); + const first = { ...headers }; + const second = { ...headers }; + const next = createTunnelServiceCorrelation(telemetry, 'list'); + assert.deepStrictEqual({ + events, + session: first['X-Tunnels-VSCode-Session-Id'], + operation: first['X-Tunnels-VSCode-Client-Operation-Id'], + stableOperation: first['X-Tunnels-VSCode-Client-Operation-Id'] === second['X-Tunnels-VSCode-Client-Operation-Id'], + freshRequests: first['X-Tunnels-VSCode-Client-Request-Id'] !== second['X-Tunnels-VSCode-Client-Request-Id'], + validRequestIds: [first, second].every(request => isUUID(request['X-Tunnels-VSCode-Client-Request-Id'])), + differentOperations: correlation.operationId !== next.operationId, + }, { + events: [ + { name: 'tunnelServiceOperation', data: { tunnelSessionId: correlation.sessionId, operationId: correlation.operationId, operation: 'connect' } }, + { name: 'tunnelServiceOperation', data: { tunnelSessionId: next.sessionId, operationId: next.operationId, operation: 'list' } }, + ], + session: telemetry.sessionId, + operation: correlation.operationId, + stableOperation: true, + freshRequests: true, + validRequestIds: true, + differentOperations: true, + }); + }); +}); diff --git a/src/vs/platform/remoteTunnel/test/node/remoteTunnelService.test.ts b/src/vs/platform/remoteTunnel/test/node/remoteTunnelService.test.ts index 090ae5a64f7f10..222533cfb05ae2 100644 --- a/src/vs/platform/remoteTunnel/test/node/remoteTunnelService.test.ts +++ b/src/vs/platform/remoteTunnel/test/node/remoteTunnelService.test.ts @@ -15,8 +15,10 @@ import { LogLevel, NullLoggerService } from '../../../log/common/log.js'; import { IProductService } from '../../../product/common/productService.js'; import { InMemoryStorageService } from '../../../storage/common/storage.js'; import { NullTelemetryService } from '../../../telemetry/common/telemetryUtils.js'; +import { ITelemetryService } from '../../../telemetry/common/telemetry.js'; import { ActiveTunnelMode, TunnelMode, TunnelStatus } from '../../common/remoteTunnel.js'; import { TunnelMachineStatus } from '../../common/tunnelMachineStatus.js'; +import { ITunnelServiceCorrelation } from '../../common/tunnelServiceHeaders.js'; import { RemoteTunnelService } from '../../node/remoteTunnelService.js'; import { ITunnelProcessCoordinator, ITunnelProcessMachineStatus, ITunnelProcessOutput, ITunnelProcessStatus, TunnelProcessConnectionState, TunnelProcessMode } from '../../node/tunnelProcessCoordinator.js'; import sinon from 'sinon'; @@ -62,8 +64,8 @@ class TestTunnelProcessCoordinator implements ITunnelProcessCoordinator { this._onDidChangeStatus.fire(this._status); } - fireMachineStatus(mode: TunnelProcessMode, status: TunnelMachineStatus, cancel = () => { }): void { - this._onDidMachineStatus.fire({ mode, status, cancel }); + fireMachineStatus(mode: TunnelProcessMode, status: TunnelMachineStatus, cancel = () => { }, correlation?: ITunnelServiceCorrelation): void { + this._onDidMachineStatus.fire({ mode, status, cancel, correlation }); } dispose(): void { @@ -80,7 +82,8 @@ suite('Remote tunnel', () => { const coordinator = new TestTunnelProcessCoordinator(); const loggerService = new NullLoggerService(); const storageService = new InMemoryStorageService(); - const publicLog2 = sinon.spy(NullTelemetryService, 'publicLog2'); + const telemetryService: ITelemetryService = NullTelemetryService; + const publicLog2 = sinon.spy(telemetryService, 'publicLog2'); const service = new RemoteTunnelService( NullTelemetryService, { tunnelApplicationName: 'code-tunnel' } as IProductService, @@ -106,7 +109,7 @@ suite('Remote tunnel', () => { let didCancel = false; coordinator.fireMachineStatus('remoteAccess', { type: 'connected', tunnelName: 'test_host', tunnelId: 'tunnel-id', isAttached: true, link: 'https://vscode.dev/tunnel/test_host', domain: 'vscode.dev' }); const linkedStatus = await service.getTunnelStatus(); - coordinator.fireMachineStatus('remoteAccess', { type: 'connected', tunnelName: 'test_host', isAttached: false }); + coordinator.fireMachineStatus('remoteAccess', { type: 'connected', tunnelName: 'test_host', isAttached: false }, undefined, { sessionId: 'telemetry-session', operationId: 'operation-id' }); const noLinkStatus = await service.getTunnelStatus(); coordinator.fireMachineStatus('remoteAccess', { type: 'tokenError', message: 'token expired' }, () => didCancel = true); const disconnectedStatus = await service.getTunnelStatus(); @@ -119,7 +122,12 @@ suite('Remote tunnel', () => { tokenFailures, didCancel, telemetryCallCount: publicLog2.callCount, + connectedTelemetry: publicLog2.getCalls().filter(call => call.args[0] === 'remoteTunnel.connected').map(call => call.args[1]), }, { + connectedTelemetry: [ + { tunnelName: 'test_host', isAttached: true }, + { tunnelName: 'test_host', isAttached: false, tunnelSessionId: 'telemetry-session', operationId: 'operation-id' }, + ], linkedStatus: { type: 'connected', info: { diff --git a/src/vs/platform/remoteTunnel/test/node/tunnelProcessCoordinator.test.ts b/src/vs/platform/remoteTunnel/test/node/tunnelProcessCoordinator.test.ts index 563d0c5facb9f3..b8f0d025ba7c31 100644 --- a/src/vs/platform/remoteTunnel/test/node/tunnelProcessCoordinator.test.ts +++ b/src/vs/platform/remoteTunnel/test/node/tunnelProcessCoordinator.test.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import sinon from 'sinon'; import { ChildProcess, SpawnOptions } from 'child_process'; import { EventEmitter } from 'events'; import { PassThrough } from 'stream'; @@ -13,8 +14,10 @@ import { INativeEnvironmentService } from '../../../environment/common/environme import { TestConfigurationService } from '../../../configuration/test/common/testConfigurationService.js'; import { ActiveTunnelMode, INACTIVE_TUNNEL_MODE } from '../../common/remoteTunnel.js'; import { CodeTunnelCli, CodeTunnelSpawn } from '../../node/codeTunnelCliProcess.js'; -import { resolveTunnelProcessMode, TunnelProcessCoordinator } from '../../node/tunnelProcessCoordinator.js'; +import { ITunnelProcessMachineStatus, resolveTunnelProcessMode, TunnelProcessCoordinator } from '../../node/tunnelProcessCoordinator.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { NullTelemetryService } from '../../../telemetry/common/telemetryUtils.js'; +import { ITelemetryService } from '../../../telemetry/common/telemetry.js'; interface TestChildProcess { readonly child: ChildProcess; @@ -88,6 +91,7 @@ function createCoordinator(exitOnKill = true, ordering?: string[], installExitCo new TestConfigurationService({ 'remote.tunnels.access.hostNameOverride': 'Test_Host' }), environmentService, { tunnelApplicationName: 'code-tunnel' } as IProductService, + NullTelemetryService, ); return { coordinator, processes }; } @@ -103,6 +107,51 @@ suite('TunnelProcessCoordinator', () => { ], ['none', 'remoteAccess', 'service']); }); + test('passes correlation to every CLI invocation but only logs operations for host launches', async () => { + const { coordinator, processes } = createCoordinator(); + const statuses: ITunnelProcessMachineStatus[] = []; + const listener = coordinator.onDidMachineStatus(status => statuses.push(status)); + const telemetryService: ITelemetryService = NullTelemetryService; + const telemetry = sinon.spy(telemetryService, 'publicLog2'); + try { + await coordinator.setRemoteAccess(activeMode(true), LogLevel.Info); + const tunnel = processes.find(process => process.args.includes('--parent-process-id'))!; + tunnel.stdout.write('__VSCODE_CLI_STATUS__{"type":"connected","tunnelName":"test_host","isAttached":false}\n'); + tunnel.stdout.write('__VSCODE_CLI_STATUS__{"type":"connected","tunnelName":"test_host","isAttached":true}\n'); + await coordinator.setRemoteAccess(INACTIVE_TUNNEL_MODE, LogLevel.Info); + assert.deepStrictEqual({ + sessionIds: [...new Set(processes.map(process => process.env?.VSCODE_TUNNEL_SESSION_ID))], + uniqueOperations: new Set(processes.map(process => process.env?.VSCODE_TUNNEL_OPERATION_ID)).size, + processCount: processes.length, + hasOperations: processes.every(process => !!process.env?.VSCODE_TUNNEL_OPERATION_ID), + correlation: statuses.map(status => status.correlation), + telemetry: telemetry.getCalls().map(call => call.args), + }, { + sessionIds: [NullTelemetryService.sessionId], + uniqueOperations: processes.length, + processCount: processes.length, + hasOperations: true, + correlation: [ + { sessionId: tunnel.env?.VSCODE_TUNNEL_SESSION_ID, operationId: tunnel.env?.VSCODE_TUNNEL_OPERATION_ID }, + undefined, + ], + telemetry: [['tunnelServiceOperation', { + tunnelSessionId: NullTelemetryService.sessionId, + operationId: tunnel.env?.VSCODE_TUNNEL_OPERATION_ID, + operation: 'host', + }]], + }); + } finally { + telemetry.restore(); + listener.dispose(); + for (const process of processes) { + process.emitExit(); + } + await new Promise(resolve => setImmediate(resolve)); + coordinator.dispose(); + } + }); + test('stops the tunnel instead of resuming a narrower mode when Remote Tunnel Access is disabled', async () => { const { coordinator, processes } = createCoordinator(); try { diff --git a/src/vs/platform/telemetry/common/editTelemetry.ts b/src/vs/platform/telemetry/common/editTelemetry.ts index 65de23de117591..ab683107456ccf 100644 --- a/src/vs/platform/telemetry/common/editTelemetry.ts +++ b/src/vs/platform/telemetry/common/editTelemetry.ts @@ -21,6 +21,8 @@ export interface IEditSourcesDetailsTelemetryData { languageId: string | undefined; statsUuid: string; conversationId: string | undefined; + /** Owning Agent Host session ID; unlike conversationId, never the chat's persistence-resource ID. */ + agentSessionId?: string; /** Hashed originating Agent Host chat ID, scoped under its session rather than globally; omitted when provenance is unknown. */ chatSessionId?: string; requestId: string | undefined; @@ -43,8 +45,9 @@ type EditSourcesDetailsTelemetryClassification = { modelId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The LLM id.' }; autoTier?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The canonical Auto tier (efficiency, balance, intelligence or fast), when known for a Copilot Auto edit.' }; languageId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The language id of the document.' }; - statsUuid: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The unique identifier of the editing window for which stats are reported. Agent Host source rows are further separated by conversationId and chatSessionId.' }; + statsUuid: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The unique identifier of the editing window for which stats are reported. Agent Host source rows are further separated by agentSessionId, conversationId and chatSessionId.' }; conversationId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The chat edit session identifier. Agent Host sources retain the existing session/resource identifier for compatibility.' }; + agentSessionId?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The owning Agent Host session identifier. Omitted for non-Agent Host sources and older markers without owner provenance.' }; chatSessionId?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The hashed originating Agent Host chat identifier, scoped under its session rather than globally and matching core Agent Host telemetry. Omitted when chat provenance is unknown.' }; requestId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The chat request identifier when the edit source comes from chat.' }; origin: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The process or subsystem that observed the edit source.' }; diff --git a/src/vs/sessions/browser/media/workbench.css b/src/vs/sessions/browser/media/workbench.css index c8dd5a7cbf987b..cde113cd120551 100644 --- a/src/vs/sessions/browser/media/workbench.css +++ b/src/vs/sessions/browser/media/workbench.css @@ -11,6 +11,8 @@ background-color: var(--vscode-agents-background); --agents-floating-panel-gap: var(--vscode-agents-layout-floatingPanelGap); --agents-part-corner-radius: var(--vscode-cornerRadius-large); + --agents-session-frame-inset: 0px; + --agents-side-pane-frame-inset: 0px; --model-hover-surface-background: var(--vscode-agentsPanel-background); --modern-ui-notifications-inline-inset: var(--vscode-spacing-size80); --modern-ui-notifications-block-end-inset: var(--vscode-spacing-size80); @@ -27,6 +29,14 @@ --agents-part-frame-border-width: var(--vscode-strokeThickness); } +.monaco-workbench.agent-sessions-workbench.modern-ui-tabs:not(.phone-layout):not(.modern-ui-compact):not(.nosessionspart) { + --agents-session-frame-inset: var(--vscode-strokeThickness); +} + +.monaco-workbench.agent-sessions-workbench.modern-ui-tabs.modern-ui-connected-editor-tabs.dock-detail-panel.agents-side-pane-multiple-tabs:not(.phone-layout):not(.modern-ui-compact):not(.noeditorpane) { + --agents-side-pane-frame-inset: var(--vscode-strokeThickness); +} + /* ---- Workbench Shell ---- */ .monaco-workbench.agent-sessions-workbench .part.sidebar { @@ -167,6 +177,11 @@ margin-right: 0; } +/* Compensate for the inset session frame and any inset side-pane frame. */ +.agent-sessions-workbench.modern-ui-tabs:not(.phone-layout):not(.modern-ui-compact):not(.noeditorpane) .part.sessionspart.agents-part-card { + margin-right: calc(var(--agents-floating-panel-gap) - var(--agents-session-frame-inset) - var(--agents-side-pane-frame-inset)); +} + .agent-sessions-workbench:not(.dock-detail-panel) .part.auxiliarybar { margin: 0 0 0 0; padding-left: var(--vscode-spacing-size60); @@ -194,7 +209,7 @@ } .agent-sessions-workbench .part.panel { - margin: var(--agents-floating-panel-gap) 0 0 0; + margin: calc(var(--agents-floating-panel-gap) - var(--agents-session-frame-inset)) 0 0 0; background: var(--part-background); border: 1px solid var(--part-border-color, transparent); border-radius: var(--agents-part-corner-radius); @@ -270,12 +285,12 @@ */ .agent-sessions-workbench:not(.noeditorpane) .monaco-sash.vertical.sessions-side-pane-divider { width: max(var(--vscode-sash-size), var(--agents-floating-panel-gap)); - transform: translateX(calc((var(--vscode-sash-size) - max(var(--vscode-sash-size), var(--agents-floating-panel-gap)) - var(--agents-floating-panel-gap)) / 2)); + transform: translateX(calc((var(--vscode-sash-size) - max(var(--vscode-sash-size), var(--agents-floating-panel-gap)) - var(--agents-floating-panel-gap) + var(--agents-side-pane-frame-inset) + var(--agents-side-pane-frame-inset)) / 2)); } .agent-sessions-workbench:not(.nopanel) .monaco-split-view2.vertical:has(> .monaco-scrollable-element > .split-view-container > .split-view-view > .part.panel) > .sash-container > .monaco-sash.horizontal { height: max(var(--vscode-sash-size), var(--agents-floating-panel-gap)); - transform: translateY(calc((var(--vscode-sash-size) - max(var(--vscode-sash-size), var(--agents-floating-panel-gap)) + var(--agents-floating-panel-gap)) / 2)); + transform: translateY(calc((var(--vscode-sash-size) - max(var(--vscode-sash-size), var(--agents-floating-panel-gap)) + var(--agents-floating-panel-gap) - var(--agents-session-frame-inset) - var(--agents-session-frame-inset)) / 2)); } /* @@ -283,6 +298,7 @@ * dot is the pseudo-element and box shadows draw the outer dots; hide all three * while the full hover/drag highlight communicates the active resize target. */ +.agent-sessions-workbench .part.sessionspart .monaco-sash.agents-grid-gap-sash::after, .agent-sessions-workbench:not(.noeditorpane) .monaco-sash.vertical.sessions-side-pane-divider:not(.disabled)::after, .agent-sessions-workbench:not(.nopanel) .monaco-split-view2.vertical:has(> .monaco-scrollable-element > .split-view-container > .split-view-view > .part.panel) > .sash-container > .monaco-sash.horizontal:not(.disabled)::after { content: ''; @@ -299,19 +315,27 @@ transform: translate(-50%, -50%); } +.agent-sessions-workbench .part.sessionspart .monaco-sash.agents-grid-gap-sash.disabled::after { + content: none; +} + +.agent-sessions-workbench .part.sessionspart .monaco-sash.agents-grid-gap-sash.vertical::after, .agent-sessions-workbench:not(.noeditorpane) .monaco-sash.vertical.sessions-side-pane-divider:not(.disabled)::after { box-shadow: 0 calc(-1 * var(--agents-floating-panel-gap)) currentColor, 0 var(--agents-floating-panel-gap) currentColor; } +.agent-sessions-workbench .part.sessionspart .monaco-sash.agents-grid-gap-sash.horizontal::after, .agent-sessions-workbench:not(.nopanel) .monaco-split-view2.vertical:has(> .monaco-scrollable-element > .split-view-container > .split-view-view > .part.panel) > .sash-container > .monaco-sash.horizontal:not(.disabled)::after { box-shadow: calc(-1 * var(--agents-floating-panel-gap)) 0 currentColor, var(--agents-floating-panel-gap) 0 currentColor; } +.agent-sessions-workbench .part.sessionspart .monaco-sash.agents-grid-gap-sash:is(.hover, .active)::after, .agent-sessions-workbench:not(.noeditorpane) .monaco-sash.vertical.sessions-side-pane-divider:is(.hover, .active)::after, .agent-sessions-workbench:not(.nopanel) .monaco-split-view2.vertical:has(> .monaco-scrollable-element > .split-view-container > .split-view-view > .part.panel) > .sash-container > .monaco-sash.horizontal:is(.hover, .active)::after { opacity: 0; } +.monaco-enable-motion .agent-sessions-workbench .part.sessionspart .monaco-sash.agents-grid-gap-sash::after, .monaco-enable-motion .agent-sessions-workbench:not(.noeditorpane) .monaco-sash.vertical.sessions-side-pane-divider:not(.disabled)::after, .monaco-enable-motion .agent-sessions-workbench:not(.nopanel) .monaco-split-view2.vertical:has(> .monaco-scrollable-element > .split-view-container > .split-view-view > .part.panel) > .sash-container > .monaco-sash.horizontal:not(.disabled)::after { transition: opacity 0.1s ease-out; @@ -319,6 +343,7 @@ @media (forced-colors: active) { + .agent-sessions-workbench .part.sessionspart .monaco-sash.agents-grid-gap-sash::after, .agent-sessions-workbench:not(.noeditorpane) .monaco-sash.vertical.sessions-side-pane-divider:not(.disabled)::after, .agent-sessions-workbench:not(.nopanel) .monaco-split-view2.vertical:has(> .monaco-scrollable-element > .split-view-container > .split-view-view > .part.panel) > .sash-container > .monaco-sash.horizontal:not(.disabled)::after { background: CanvasText; @@ -369,11 +394,11 @@ border-bottom-right-radius: var(--agents-window-corner-radius); } - &.modern-ui-tabs.modern-ui-connected-editor-tabs.nopanel.nosidebar .agents-part-card .session-view.session-grid-bottom-left { + &.modern-ui-tabs.nopanel.nosidebar .agents-part-card .session-view.session-grid-bottom-left { border-bottom-left-radius: max(0px, calc(var(--agents-window-corner-radius) - var(--vscode-strokeThickness))); } - &.modern-ui-tabs.modern-ui-connected-editor-tabs.nopanel.noeditorpane .agents-part-card .session-view.session-grid-bottom-right { + &.modern-ui-tabs.nopanel.noeditorpane .agents-part-card .session-view.session-grid-bottom-right { border-bottom-right-radius: max(0px, calc(var(--agents-window-corner-radius) - var(--vscode-strokeThickness))); } } diff --git a/src/vs/sessions/browser/parts/agentsPartCard.ts b/src/vs/sessions/browser/parts/agentsPartCard.ts index 7d0360d20358b2..c97a4b907f7479 100644 --- a/src/vs/sessions/browser/parts/agentsPartCard.ts +++ b/src/vs/sessions/browser/parts/agentsPartCard.ts @@ -7,12 +7,15 @@ import { IColorTheme } from '../../../platform/theme/common/themeService.js'; import { agentsCardBorder, agentsPanelBackground, agentsPanelForeground } from '../../common/theme.js'; import { AGENTS_FLOATING_PANEL_GAP } from '../../common/layoutConstants.js'; +const AGENTS_PART_CARD_BORDER_WIDTH = 1; + /** * Marks a part as a floating content card. Carries the shared background, * border, corner radius and outer margin (see `media/workbench.css`) so every * content part in the Agents window is styled from one place. */ export const AGENTS_PART_CARD_CLASS = 'agents-part-card'; +export const AGENTS_SIDE_PANE_MULTIPLE_TABS_CLASS = 'agents-side-pane-multiple-tabs'; /** Visual metrics of a card part, kept in sync with the CSS in `media/workbench.css`. */ export const AgentsPartCard = { @@ -22,18 +25,64 @@ export const AgentsPartCard = { MARGIN_RIGHT: AGENTS_FLOATING_PANEL_GAP, MARGIN_RIGHT_NO_EDITOR_PANE: 0, MARGIN_BOTTOM: 0, - BORDER_WIDTH: 1, + BORDER_WIDTH: AGENTS_PART_CARD_BORDER_WIDTH, } as const; +/** Returns the inset between the Sessions Part allocation and its visible session frames. */ +export function getSessionsPartFrameInset(container: HTMLElement): number { + return container.classList.contains('modern-ui-tabs') + && !container.classList.contains('modern-ui-compact') + && !container.classList.contains('phone-layout') + && !container.classList.contains('nosessionspart') + ? AgentsPartCard.BORDER_WIDTH + : 0; +} + +/** Returns the inset between the side-pane allocation and its connected editor frame. */ +export function getSidePaneFrameInset(container: HTMLElement): number { + return getSessionsPartFrameInset(container) > 0 + && container.classList.contains('dock-detail-panel') + && container.classList.contains('modern-ui-connected-editor-tabs') + && container.classList.contains(AGENTS_SIDE_PANE_MULTIPLE_TABS_CLASS) + && !container.classList.contains('noeditorpane') + ? AgentsPartCard.BORDER_WIDTH + : 0; +} + +/** Returns the amount needed to align an outer side-pane frame with Sessions above a justified panel. */ +export function getSidePaneBottomFrameInset(container: HTMLElement): number { + const sessionFrameInset = getSessionsPartFrameInset(container); + if (sessionFrameInset === 0 + || container.classList.contains('noeditorpane') + || container.classList.contains('nopanel') + || !container.classList.contains('panel-alignment-justify')) { + return 0; + } + + return Math.max(0, sessionFrameInset - getSidePaneFrameInset(container)); +} + +/** Returns the Sessions Part margin that preserves the visible gap beside the side pane. */ +export function getSessionsPartCardMarginRight(container: HTMLElement): number { + const sessionFrameInset = getSessionsPartFrameInset(container); + if (sessionFrameInset === 0) { + return AgentsPartCard.MARGIN_RIGHT; + } + + return Math.max(0, AgentsPartCard.MARGIN_RIGHT - sessionFrameInset - getSidePaneFrameInset(container)); +} + /** Content box of a card part, filling the grid in compact or phone layouts. */ -export function getAgentsPartCardContentSize(width: number, height: number, editorPaneVisible: boolean, sidebarVisible: boolean, phoneLayout: boolean, compact = false): { readonly width: number; readonly height: number } { +export function getAgentsPartCardContentSize(width: number, height: number, editorPaneVisible: boolean, sidebarVisible: boolean, phoneLayout: boolean, compact = false, editorPaneMarginRight: number = AgentsPartCard.MARGIN_RIGHT): { readonly width: number; readonly height: number } { if (phoneLayout || compact) { return { width, height }; } const borderTotal = AgentsPartCard.BORDER_WIDTH * 2; const marginLeft = sidebarVisible ? AgentsPartCard.MARGIN_LEFT : AgentsPartCard.MARGIN_LEFT_NO_SIDEBAR; - const marginRight = editorPaneVisible ? AgentsPartCard.MARGIN_RIGHT : AgentsPartCard.MARGIN_RIGHT_NO_EDITOR_PANE; + const marginRight = editorPaneVisible + ? editorPaneMarginRight + : AgentsPartCard.MARGIN_RIGHT_NO_EDITOR_PANE; return { width: width - marginLeft - marginRight - borderTotal, diff --git a/src/vs/sessions/browser/parts/auxiliaryBarPart.ts b/src/vs/sessions/browser/parts/auxiliaryBarPart.ts index 45495fccaee5ae..02e83113da66bc 100644 --- a/src/vs/sessions/browser/parts/auxiliaryBarPart.ts +++ b/src/vs/sessions/browser/parts/auxiliaryBarPart.ts @@ -37,6 +37,7 @@ import { getFlatContextMenuActions } from '../../../platform/actions/browser/men import { IDisposable, MutableDisposable } from '../../../base/common/lifecycle.js'; import { Extensions } from '../../../workbench/browser/panecomposite.js'; import { mainWindow } from '../../../base/browser/window.js'; +import { getSidePaneBottomFrameInset } from './agentsPartCard.js'; /** * Auxiliary bar part specifically for agent sessions workbench. @@ -306,7 +307,9 @@ export class AuxiliaryBarPart extends AbstractPaneCompositePart { const borderTotal = compact ? 0 : 2; const editorVisible = this.layoutService.isVisible(Parts.EDITOR_PART, mainWindow); const paddingLeft = editorVisible && !compact ? AuxiliaryBarPart.CONTENT_PADDING_LEFT : 0; - const marginBottom = AuxiliaryBarPart.MARGIN_BOTTOM; + const marginBottom = !this.layoutService.mainContainer.classList.contains('dock-detail-panel') + ? getSidePaneBottomFrameInset(this.layoutService.mainContainer) + : AuxiliaryBarPart.MARGIN_BOTTOM; super.layout( width - borderTotal - paddingLeft, diff --git a/src/vs/sessions/browser/parts/desktopEditorPart.ts b/src/vs/sessions/browser/parts/desktopEditorPart.ts index e28f18f06d9335..862d254f6e410a 100644 --- a/src/vs/sessions/browser/parts/desktopEditorPart.ts +++ b/src/vs/sessions/browser/parts/desktopEditorPart.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { scheduleAtNextAnimationFrame } from '../../../base/browser/dom.js'; import { mainWindow } from '../../../base/browser/window.js'; import { DisposableMap, IDisposable, MutableDisposable } from '../../../base/common/lifecycle.js'; import { IConfigurationService } from '../../../platform/configuration/common/configuration.js'; @@ -21,6 +22,7 @@ import { DockedEditorInput } from '../../common/dockedEditorInput.js'; import { DockedAuxiliaryBarController } from '../dockedAuxiliaryBarController.js'; import { Menus } from '../menus.js'; import { IAgentWorkbenchLayoutService } from '../workbench.js'; +import { AGENTS_SIDE_PANE_MULTIPLE_TABS_CLASS } from './agentsPartCard.js'; import { MainEditorPart } from './editorPart.js'; import { DesktopAuxiliaryBarPart } from './desktopAuxiliaryBarPart.js'; @@ -41,6 +43,7 @@ export class DesktopMainEditorPart extends MainEditorPart { private _dockedAuxBar: DockedAuxiliaryBarController | undefined; private readonly _groupRelayoutListeners = this._register(new DisposableMap()); private readonly _tabsOverride = this._register(new MutableDisposable()); + private readonly _tabsPresentationRelayout = this._register(new MutableDisposable()); private _enforcedShowTabs: 'multiple' | 'single' | undefined; protected override getGroupViewOptions(): IEditorGroupViewOptions { @@ -131,6 +134,19 @@ export class DesktopMainEditorPart extends MainEditorPart { return this._auxiliaryBar; } + private _updateSidePaneTabsClass(scheduleRelayout: boolean): void { + const root = this.agentWorkbenchLayoutService.mainContainer; + const multiple = this.partOptions.showTabs === 'multiple'; + if (root.classList.contains(AGENTS_SIDE_PANE_MULTIPLE_TABS_CLASS) === multiple) { + return; + } + + root.classList.toggle(AGENTS_SIDE_PANE_MULTIPLE_TABS_CLASS, multiple); + if (scheduleRelayout) { + this._tabsPresentationRelayout.value = scheduleAtNextAnimationFrame(mainWindow, () => this.agentWorkbenchLayoutService.layout()); + } + } + /** * Creates the editor part's DOM. Besides the base content (the editor grid), the * desktop part docks the auxiliary bar here — in the same place the base part @@ -139,6 +155,12 @@ export class DesktopMainEditorPart extends MainEditorPart { protected override createContentArea(parent: HTMLElement, options?: IEditorPartCreationOptions): HTMLElement { this._updateTabsOverride(); const container = super.createContentArea(parent, options); + this._updateSidePaneTabsClass(false); + this._register(this.onDidChangeEditorPartOptions(event => { + if (event.oldPartOptions.showTabs !== event.newPartOptions.showTabs) { + this._updateSidePaneTabsClass(true); + } + })); this._registerGroupRelayoutListeners(); diff --git a/src/vs/sessions/browser/parts/editorPart.ts b/src/vs/sessions/browser/parts/editorPart.ts index ce53cc40eb9a07..d4a7a826231c99 100644 --- a/src/vs/sessions/browser/parts/editorPart.ts +++ b/src/vs/sessions/browser/parts/editorPart.ts @@ -9,6 +9,7 @@ import { MainEditorPart as MainEditorPartBase } from '../../../workbench/browser import { Parts } from '../../../workbench/services/layout/browser/layoutService.js'; import { AGENTS_FLOATING_PANEL_GAP } from '../../common/layoutConstants.js'; import { AgentWorkbenchLayout, type IAgentWorkbenchLayoutService } from '../workbench.js'; +import { getSidePaneBottomFrameInset } from './agentsPartCard.js'; import { EDITOR_PART_MINIMUM_WIDTH } from './editorPartSizing.js'; import { isPhoneLayout } from './mobile/mobileLayout.js'; @@ -43,8 +44,9 @@ export class MainEditorPart extends MainEditorPartBase { && !this.layoutService.isVisible(Parts.SESSIONS_PART) ? AGENTS_FLOATING_PANEL_GAP : MainEditorPart.MARGIN_LEFT; const borderTotal = compact ? 0 : 2; + const bottomFrameInset = getSidePaneBottomFrameInset(this.layoutService.mainContainer); const adjustedWidth = width - MainEditorPart.MARGIN_RIGHT - marginLeft - borderTotal; - const adjustedHeight = height - MainEditorPart.MARGIN_TOP - MainEditorPart.MARGIN_BOTTOM - borderTotal; + const adjustedHeight = height - MainEditorPart.MARGIN_TOP - MainEditorPart.MARGIN_BOTTOM - borderTotal - bottomFrameInset; super.layout(adjustedWidth, adjustedHeight, top, left); diff --git a/src/vs/sessions/browser/parts/gridGap.ts b/src/vs/sessions/browser/parts/gridGap.ts new file mode 100644 index 00000000000000..18dc94a37a7d8c --- /dev/null +++ b/src/vs/sessions/browser/parts/gridGap.ts @@ -0,0 +1,88 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { isHTMLElement } from '../../../base/browser/dom.js'; +import { Direction, Grid, GridNode, IView, isGridBranchNode } from '../../../base/browser/ui/grid/grid.js'; + +export const GRID_GAP_SASH_CLASS = 'agents-grid-gap-sash'; + +/** Insets that expose a shared visual gap along the interior edges of a grid view. */ +export interface IGridEdgeInsets { + readonly top: number; + readonly right: number; + readonly bottom: number; + readonly left: number; +} + +export const NO_GRID_EDGE_INSETS: IGridEdgeInsets = { top: 0, right: 0, bottom: 0, left: 0 }; + +/** Splits a shared gap between each view and its adjacent grid neighbors. */ +export function getGridEdgeInsets(grid: Grid, view: T, gap: number): IGridEdgeInsets { + if (gap <= 0) { + return NO_GRID_EDGE_INSETS; + } + const before = Math.floor(gap / 2); + const after = gap - before; + return { + top: grid.getNeighborViews(view, Direction.Up, false).length > 0 ? before : 0, + right: grid.getNeighborViews(view, Direction.Right, false).length > 0 ? after : 0, + bottom: grid.getNeighborViews(view, Direction.Down, false).length > 0 ? after : 0, + left: grid.getNeighborViews(view, Direction.Left, false).length > 0 ? before : 0, + }; +} + +function getDirectChild(element: Element, className: string): HTMLElement | undefined { + for (const child of element.children) { + if (isHTMLElement(child) && child.classList.contains(className)) { + return child; + } + } + return undefined; +} + +/** Marks only the gap sashes owned by this grid, excluding grids nested inside its leaves. */ +export function updateGridGapSashes(grid: Grid, enabled: boolean): void { + const visit = (element: HTMLElement, node: GridNode): void => { + if (!isGridBranchNode(node)) { + return; + } + const splitView = getDirectChild(element, 'monaco-split-view2'); + const sashContainer = splitView && getDirectChild(splitView, 'sash-container'); + const scrollable = splitView && getDirectChild(splitView, 'monaco-scrollable-element'); + const viewContainer = scrollable && getDirectChild(scrollable, 'split-view-container'); + if (!splitView || !sashContainer || !viewContainer) { + return; + } + + const positionProperty = splitView.classList.contains('horizontal') ? 'left' : 'top'; + const sashes = Array.from(sashContainer.children) + .filter((child): child is HTMLElement => isHTMLElement(child) && child.classList.contains('monaco-sash')) + .map((sash, index) => ({ sash, index, position: Number.parseFloat(sash.style[positionProperty]) })) + .sort((first, second) => { + if (Number.isNaN(first.position) || Number.isNaN(second.position)) { + return first.index - second.index; + } + return first.position - second.position; + }) + .map(({ sash }) => sash); + const views = Array.from(viewContainer.children) + .filter((child): child is HTMLElement => isHTMLElement(child) && child.classList.contains('split-view-view')); + + for (const sash of sashes) { + sash.classList.toggle(GRID_GAP_SASH_CLASS, enabled); + } + for (let index = 0; index < node.children.length; index++) { + const childElement = views[index]?.firstElementChild; + if (isHTMLElement(childElement) && childElement.classList.contains('monaco-grid-branch-node')) { + visit(childElement, node.children[index]); + } + } + }; + + const root = getDirectChild(grid.element, 'monaco-grid-branch-node'); + if (root) { + visit(root, grid.getViews()); + } +} diff --git a/src/vs/sessions/browser/parts/media/chatCompositeBar.css b/src/vs/sessions/browser/parts/media/chatCompositeBar.css index afc24e351017f7..0fc8aede839621 100644 --- a/src/vs/sessions/browser/parts/media/chatCompositeBar.css +++ b/src/vs/sessions/browser/parts/media/chatCompositeBar.css @@ -578,32 +578,33 @@ --modern-ui-connected-tab-surface: var(--session-view-background); } -/* Paint the frame in the same box as the tabs, rather than clipping a second - * rounded tab border against the outside of the card. Keep its layout space. */ -.monaco-workbench.agent-sessions-workbench.modern-ui-tabs.modern-ui-connected-editor-tabs:not(.phone-layout) .part.sessionspart.agents-part-card { +/* Default-density sessions own their individual frames in both tab styles. + * Connected tabs retain the same ownership in compact density. */ +.monaco-workbench.agent-sessions-workbench.modern-ui-tabs:not(.phone-layout):is(.modern-ui-connected-editor-tabs, :not(.modern-ui-compact)) .part.sessionspart.agents-part-card { border-color: transparent; + background-clip: padding-box; } -.monaco-workbench.agent-sessions-workbench.modern-ui-tabs.modern-ui-connected-editor-tabs:not(.phone-layout) .part.sessionspart.agents-part-card::after { +.monaco-workbench.agent-sessions-workbench.modern-ui-tabs:not(.phone-layout):is(.modern-ui-connected-editor-tabs, :not(.modern-ui-compact)) .part.sessionspart.agents-part-card::after { content: none; } -.monaco-workbench.agent-sessions-workbench.modern-ui-tabs.modern-ui-connected-editor-tabs:not(.phone-layout) .session-view { +.monaco-workbench.agent-sessions-workbench.modern-ui-tabs:not(.phone-layout):is(.modern-ui-connected-editor-tabs, :not(.modern-ui-compact)) .session-view { position: relative; border-radius: max(0px, calc(var(--agents-part-corner-radius, var(--vscode-cornerRadius-large)) - var(--vscode-strokeThickness))); overflow: hidden; } -.monaco-workbench.agent-sessions-workbench.modern-ui-tabs.modern-ui-connected-editor-tabs:not(.phone-layout) .session-view > .session-view-content { +.monaco-workbench.agent-sessions-workbench.modern-ui-tabs:not(.phone-layout):is(.modern-ui-connected-editor-tabs, :not(.modern-ui-compact)) .session-view > .session-view-content { /* Keep sticky-scroll and other transcript overlays below the session frame. */ isolation: isolate; } -.monaco-workbench.agent-sessions-workbench.modern-ui-tabs.modern-ui-connected-editor-tabs:not(.phone-layout) .session-view::after { +.monaco-workbench.agent-sessions-workbench.modern-ui-tabs:not(.phone-layout):is(.modern-ui-connected-editor-tabs, :not(.modern-ui-compact)) .session-view::after { content: ''; position: absolute; inset: 0; - border: var(--vscode-strokeThickness) solid var(--modern-ui-connected-tab-border); + border: var(--vscode-strokeThickness) solid var(--modern-ui-connected-tab-border, var(--part-border-color, transparent)); border-width: var(--agents-part-frame-border-width, var(--vscode-strokeThickness)); border-radius: inherit; box-sizing: border-box; diff --git a/src/vs/sessions/browser/parts/media/chatGroupsView.css b/src/vs/sessions/browser/parts/media/chatGroupsView.css index e1dd643a05f8d1..f7166143b4337d 100644 --- a/src/vs/sessions/browser/parts/media/chatGroupsView.css +++ b/src/vs/sessions/browser/parts/media/chatGroupsView.css @@ -17,6 +17,7 @@ position: relative; overflow: hidden; contain: paint; + box-sizing: border-box; background-color: var(--session-view-background); color: var(--session-view-foreground); } diff --git a/src/vs/sessions/browser/parts/media/editorPart.css b/src/vs/sessions/browser/parts/media/editorPart.css index a2f03f1d8dc692..38d1b80b0c21d4 100644 --- a/src/vs/sessions/browser/parts/media/editorPart.css +++ b/src/vs/sessions/browser/parts/media/editorPart.css @@ -68,6 +68,12 @@ border-color: var(--vscode-agentsPanel-border, var(--vscode-contrastBorder)); } +/* Keep the whole justified upper row on the same frame baseline above the panel. */ +.monaco-workbench.agent-sessions-workbench.modern-ui-tabs.panel-alignment-justify:not(.nopanel):not(.phone-layout):not(.modern-ui-compact):not(.noeditorpane) .part.editor:not(.modal-editor-part), +.monaco-workbench.agent-sessions-workbench.modern-ui-tabs.panel-alignment-justify:not(.dock-detail-panel):not(.nopanel):not(.phone-layout):not(.modern-ui-compact):not(.noeditorpane) .part.auxiliarybar { + max-height: calc(100% - var(--agents-session-frame-inset) + var(--agents-side-pane-frame-inset)); +} + .monaco-workbench.agent-sessions-workbench.modern-ui-tabs.modern-ui-connected-editor-tabs.dock-detail-panel:not(.phone-layout) .part.editor.editor-tabs-multiple:not(.modal-editor-part) > .content .editor-group-container > .title.tabs .tab:first-child > .tab-fill, .monaco-workbench.agent-sessions-workbench.modern-ui-tabs.modern-ui-connected-editor-tabs.dock-detail-panel:not(.phone-layout) .part.editor.editor-tabs-multiple:not(.modal-editor-part) > .content .editor-group-container > .title.tabs .tab.last-in-row + .tab > .tab-fill { background-clip: border-box; diff --git a/src/vs/sessions/browser/parts/media/sessionsPart.css b/src/vs/sessions/browser/parts/media/sessionsPart.css index 1c2a36056de346..12270bbb6f238d 100644 --- a/src/vs/sessions/browser/parts/media/sessionsPart.css +++ b/src/vs/sessions/browser/parts/media/sessionsPart.css @@ -19,6 +19,15 @@ height: 100%; } +.session-grid-leaf { + box-sizing: border-box; +} + +.session-grid.session-grid-has-gaps .session-grid-leaf { + border-color: var(--vscode-agents-background); + border-style: solid; +} + .monaco-workbench .part.sessionspart > .title, .monaco-workbench .part.sessionspart > .content { position: relative; diff --git a/src/vs/sessions/browser/parts/panelPart.ts b/src/vs/sessions/browser/parts/panelPart.ts index 6348a1c3de9dda..3246c9591dd864 100644 --- a/src/vs/sessions/browser/parts/panelPart.ts +++ b/src/vs/sessions/browser/parts/panelPart.ts @@ -35,6 +35,7 @@ import { IConfigurationService } from '../../../platform/configuration/common/co import { Extensions } from '../../../workbench/browser/panecomposite.js'; import { isPhoneLayout } from './mobile/mobileLayout.js'; import { mainWindow } from '../../../base/browser/window.js'; +import { getSessionsPartFrameInset } from './agentsPartCard.js'; /** * Panel part specifically for agent sessions workbench. @@ -125,7 +126,7 @@ export class PanelPart extends AbstractPaneCompositePart { })); this._register(this.layoutService.onDidChangePanelAlignment(() => this.relayoutForInsets())); this._register(this.layoutService.onDidChangePartVisibility(e => { - if (e.partId === Parts.SIDEBAR_PART || e.partId === Parts.EDITOR_PART || e.partId === Parts.AUXILIARYBAR_PART) { + if (e.partId === Parts.SIDEBAR_PART || e.partId === Parts.EDITOR_PART || e.partId === Parts.AUXILIARYBAR_PART || e.partId === Parts.SESSIONS_PART) { this.relayoutForInsets(); } })); @@ -203,7 +204,10 @@ export class PanelPart extends AbstractPaneCompositePart { // Layout content with reduced dimensions to account for visual margins and border. const compact = this.layoutService.isModernUICompact(); const borderTotal = compact ? 0 : 2; - const marginTop = compact ? 0 : PanelPart.MARGIN_TOP; + const sessionFrameInset = this.layoutService.isVisible(Parts.SESSIONS_PART) + ? getSessionsPartFrameInset(this.layoutService.mainContainer) + : 0; + const marginTop = compact ? 0 : Math.max(0, PanelPart.MARGIN_TOP - sessionFrameInset); const marginLeft = !compact && !isPhoneLayout(this.layoutService) && !this.layoutService.isVisible(Parts.SIDEBAR_PART) ? AGENTS_FLOATING_PANEL_GAP : 0; diff --git a/src/vs/sessions/browser/parts/sessionGridLayout.ts b/src/vs/sessions/browser/parts/sessionGridLayout.ts index 3079ac17d8375a..e244fb93a516b3 100644 --- a/src/vs/sessions/browser/parts/sessionGridLayout.ts +++ b/src/vs/sessions/browser/parts/sessionGridLayout.ts @@ -4,19 +4,20 @@ *--------------------------------------------------------------------------------------------*/ import { $, getActiveElement, getWindow, isAncestor, isHTMLElement, runAtThisOrScheduleAtNextAnimationFrame } from '../../../base/browser/dom.js'; -import { Direction, GridNode, IGridStyles, ISerializableView, ISerializedGrid, ISerializedNode, IView, SerializableGrid, Sizing, isGridBranchNode } from '../../../base/browser/ui/grid/grid.js'; +import { Direction, GridNode, IGridStyles, ISerializableView, ISerializedGrid, ISerializedNode, IView, IViewSize, SerializableGrid, Sizing, isGridBranchNode } from '../../../base/browser/ui/grid/grid.js'; import { Orientation } from '../../../base/browser/ui/sash/sash.js'; -import { Emitter } from '../../../base/common/event.js'; +import { Emitter, Event } from '../../../base/common/event.js'; import { Disposable, IDisposable, MutableDisposable } from '../../../base/common/lifecycle.js'; import { ISessionGridPlacement } from '../../services/sessions/browser/sessionsPartService.js'; import { isSessionGridLeafData, projectSessionGrid } from '../../services/sessions/browser/sessionGridState.js'; import { Color } from '../../../base/common/color.js'; +import { getGridEdgeInsets, IGridEdgeInsets, NO_GRID_EDGE_INSETS, updateGridGapSashes } from './gridGap.js'; export interface ISessionGridView extends IView { /** Applies the allocated geometry without laying out descendants. */ layoutContainer(width: number, height: number, top: number, left: number): void; /** Lays out descendants using the most recently applied geometry. */ - layoutContents(): void; + layoutContents(gap?: number): void; } export interface ISessionGridEntry { @@ -33,26 +34,56 @@ interface ILayout { } /** A host keeps grid identity stable while its content is projected onto the desktop surface. */ -class SessionGridLeaf implements ISerializableView { +class SessionGridLeaf extends Disposable implements ISerializableView { readonly element = $('.session-grid-leaf'); + private insets = NO_GRID_EDGE_INSETS; + private readonly _onDidChangeConstraints = this._register(new Emitter()); + readonly onDidChange: Event; private projected = false; private active = false; private visible = true; private lastLayout: ILayout | undefined; private projectedLayout: ILayout | undefined; private appliedLayout: ILayout | undefined; + private appliedInsets: IGridEdgeInsets | undefined; + private appliedGap: number | undefined; private appliedVisible: boolean | undefined; private contentsDirty = false; - constructor(readonly id: string, readonly view: ISessionGridView, private readonly requestLayout: (leaf: SessionGridLeaf) => void) { + constructor( + readonly id: string, + readonly view: ISessionGridView, + private readonly requestLayout: (leaf: SessionGridLeaf) => void, + ) { + super(); + this.onDidChange = Event.any( + Event.map(view.onDidChange, size => size && ({ + width: size.width + this.insets.left + this.insets.right, + height: size.height + this.insets.top + this.insets.bottom, + })), + this._onDidChangeConstraints.event, + ); this.element.appendChild(view.element); } - get minimumWidth() { return this.view.minimumWidth; } - get maximumWidth() { return this.view.maximumWidth; } - get minimumHeight() { return this.view.minimumHeight; } - get maximumHeight() { return this.view.maximumHeight; } - get onDidChange() { return this.view.onDidChange; } + get minimumWidth() { return this.view.minimumWidth + this.insets.left + this.insets.right; } + get maximumWidth() { return this.view.maximumWidth + this.insets.left + this.insets.right; } + get minimumHeight() { return this.view.minimumHeight + this.insets.top + this.insets.bottom; } + get maximumHeight() { return this.view.maximumHeight + this.insets.top + this.insets.bottom; } + get preferredWidth() { return this.view.preferredWidth === undefined ? undefined : this.view.preferredWidth + this.insets.left + this.insets.right; } + get preferredHeight() { return this.view.preferredHeight === undefined ? undefined : this.view.preferredHeight + this.insets.top + this.insets.bottom; } + + setInsets(insets: IGridEdgeInsets): boolean { + if (this.insets.top === insets.top && this.insets.right === insets.right && this.insets.bottom === insets.bottom && this.insets.left === insets.left) { + return false; + } + this.insets = insets; + return true; + } + + notifyConstraintsChanged(): void { + this._onDidChangeConstraints.fire(undefined); + } layout(width: number, height: number, top: number, left: number): void { this.lastLayout = { width, height, top, left }; @@ -86,19 +117,40 @@ class SessionGridLeaf implements ISerializableView { this.requestLayout(this); } - applyGeometry(): void { + applyGeometry(gap: number): void { const dimensions = this.projected ? this.projectedLayout : this.lastLayout; if (!(this.projected ? this.active : this.visible) || !dimensions) { return; } - const { width, height, top, left } = dimensions; + const insets = this.insets; + const layout = { + width: Math.max(0, dimensions.width - insets.left - insets.right), + height: Math.max(0, dimensions.height - insets.top - insets.bottom), + top: dimensions.top + insets.top, + left: dimensions.left + insets.left, + }; const previous = this.appliedLayout; - if (previous?.width === width && previous.height === height && previous.top === top && previous.left === left) { + const previousInsets = this.appliedInsets; + const geometryChanged = !( + previous?.width === layout.width && previous.height === layout.height && previous.top === layout.top && previous.left === layout.left + && previousInsets?.top === insets.top && previousInsets.right === insets.right + && previousInsets.bottom === insets.bottom && previousInsets.left === insets.left + ); + if (!geometryChanged && this.appliedGap === gap) { return; } - this.appliedLayout = dimensions; + this.appliedGap = gap; this.contentsDirty = true; - this.view.layoutContainer(width, height, top, left); + if (!geometryChanged) { + return; + } + this.appliedLayout = layout; + this.appliedInsets = insets; + this.element.style.borderTopWidth = `${insets.top}px`; + this.element.style.borderRightWidth = `${insets.right}px`; + this.element.style.borderBottomWidth = `${insets.bottom}px`; + this.element.style.borderLeftWidth = `${insets.left}px`; + this.view.layoutContainer(layout.width, layout.height, layout.top, layout.left); } applyContents(isCurrent: () => boolean): void { @@ -111,7 +163,7 @@ class SessionGridLeaf implements ISerializableView { this.view.setVisible?.(visible); } if (visible && this.appliedLayout && layout && isCurrent()) { - this.view.layoutContents(); + this.view.layoutContents(this.appliedGap); } } @@ -123,6 +175,7 @@ export class SessionGridLayout extends Disposable { readonly element = $('.session-grid'); private readonly projectionHost = $('.session-grid-projection'); private readonly grid = this._register(new MutableDisposable>()); + private gridLaidOut = false; private readonly leaves = new Map(); private readonly placements = new Map(); private readonly _onDidChangeMaximized = this._register(new Emitter()); @@ -132,6 +185,7 @@ export class SessionGridLayout extends Disposable { private phone = false; private active: string | undefined; private styles: IGridStyles = { separatorBorder: Color.transparent }; + private gap = 0; private maximizedId: string | undefined; private updatingLayout = false; private flushingLayout = false; @@ -176,6 +230,7 @@ export class SessionGridLayout extends Disposable { this.pendingLayouts.delete(leaf); leaf.project(undefined, false); this.grid.value!.removeView(leaf); + leaf.dispose(); this.placements.delete(id); } } @@ -356,14 +411,17 @@ export class SessionGridLayout extends Disposable { const previous = this.grid.value; previous?.element.remove(); this.grid.value = grid; - grid.style(this.styles); + this.gridLaidOut = false; + this.applyStyles(); this.element.prepend(grid.element); this.layoutViews(); } - layout(width: number, height: number, top: number, left: number, phone: boolean): void { + layout(width: number, height: number, top: number, left: number, phone: boolean, gap = 0): void { this.preserveFocus(() => { this.phone = phone; + this.gap = phone ? 0 : Math.max(0, gap); + this.applyStyles(); this.phoneDimensions = phone ? { width, height, top, left } : undefined; const initialLayout = !this.dimensions; if (!phone || initialLayout) { @@ -381,6 +439,11 @@ export class SessionGridLayout extends Disposable { private layoutViews(): void { const projectedId = this.projectedId; const host = projectedId === undefined ? undefined : this.projectionHost; + const hasGaps = this.gap > 0 && this.leaves.size > 1 && projectedId === undefined; + this.element.classList.toggle('session-grid-has-gaps', hasGaps); + if (this.grid.value) { + updateGridGapSashes(this.grid.value, hasGaps); + } let bottomLeftId = projectedId; let bottomRightId = projectedId; if (projectedId === undefined && this.grid.value) { @@ -399,14 +462,28 @@ export class SessionGridLayout extends Disposable { this.grid.value.element.style.display = host ? 'none' : ''; } this.projectionHost.style.display = host ? '' : 'none'; + if (hasGaps && this.dimensions && this.grid.value && !this.gridLaidOut) { + const { width, height, top, left } = this.dimensions; + this.grid.value.layout(width, height, top, left); + this.gridLaidOut = true; + } + const constraintChanges: SessionGridLeaf[] = []; for (const leaf of this.leaves.values()) { + if (leaf.setInsets(hasGaps ? this.getInsets(leaf) : NO_GRID_EDGE_INSETS)) { + constraintChanges.push(leaf); + } leaf.project(host, leaf.id === projectedId); leaf.view.element.classList.toggle('session-grid-bottom-left', leaf.id === bottomLeftId); leaf.view.element.classList.toggle('session-grid-bottom-right', leaf.id === bottomRightId); + this.requestLayout(leaf); + } + for (const leaf of constraintChanges) { + leaf.notifyConstraintsChanged(); } if (this.dimensions) { const { width, height, top, left } = this.dimensions; this.grid.value?.layout(width, height, top, left); + this.gridLaidOut = true; } const projected = projectedId && this.leaves.get(projectedId); const dimensions = this.phone ? this.phoneDimensions : this.dimensions; @@ -418,6 +495,14 @@ export class SessionGridLayout extends Disposable { } } + private getInsets(leaf: SessionGridLeaf): IGridEdgeInsets { + const grid = this.grid.value; + if (!grid || this.leaves.size < 2) { + return NO_GRID_EDGE_INSETS; + } + return getGridEdgeInsets(grid, leaf, this.gap); + } + private requestLayout(leaf: SessionGridLeaf): void { if (this._store.isDisposed || this.leaves.get(leaf.id) !== leaf) { return; @@ -440,7 +525,7 @@ export class SessionGridLayout extends Disposable { this.pendingLayouts.clear(); for (const leaf of leaves) { if (this.leaves.get(leaf.id) === leaf) { - leaf.applyGeometry(); + leaf.applyGeometry(this.gap); } } for (const leaf of leaves) { @@ -461,7 +546,14 @@ export class SessionGridLayout extends Disposable { style(styles: IGridStyles): void { this.styles = styles; - this.grid.value?.style(styles); + this.applyStyles(); + } + + private applyStyles(): void { + this.grid.value?.style({ + ...this.styles, + separatorBorder: this.gap > 0 ? Color.transparent : this.styles.separatorBorder, + }); } private preserveFocus(fn: () => void): void { @@ -488,6 +580,7 @@ export class SessionGridLayout extends Disposable { this.pendingLayouts.clear(); for (const leaf of this.leaves.values()) { leaf.view.element.remove(); + leaf.dispose(); } this.leaves.clear(); this.placements.clear(); diff --git a/src/vs/sessions/browser/parts/sessionsPart.ts b/src/vs/sessions/browser/parts/sessionsPart.ts index c4091d0911d24e..d0bcde3a847ced 100644 --- a/src/vs/sessions/browser/parts/sessionsPart.ts +++ b/src/vs/sessions/browser/parts/sessionsPart.ts @@ -33,13 +33,14 @@ import { defaultProgressBarStyles } from '../../../platform/theme/browser/defaul import { IProgressIndicator } from '../../../platform/progress/common/progress.js'; import { AbstractProgressScope, ScopedProgressIndicator } from '../../../workbench/services/progress/browser/progressIndicator.js'; import { IAgentWorkbenchLayoutService } from '../workbench.js'; -import { applyAgentsPartCardStyles, getAgentsPartCardContentSize } from './agentsPartCard.js'; +import { applyAgentsPartCardStyles, getAgentsPartCardContentSize, getSessionsPartCardMarginRight } from './agentsPartCard.js'; import { isPhoneLayout } from './mobile/mobileLayout.js'; import { SessionsChatBackgroundRenderer } from '../../services/chatBackground/browser/chatBackgroundRenderer.js'; import { ISessionsChatBackgroundService } from '../../services/chatBackground/browser/chatBackgroundService.js'; import { noSessionPickerVisibility, SessionPickerVisibilityContextKeys } from '../../services/sessions/common/sessionPickerVisibility.js'; import { ISessionGridSlot, SessionGridRequest } from '../../services/sessions/browser/sessionsPartService.js'; import { SessionGridLayout } from './sessionGridLayout.js'; +import { AGENTS_FLOATING_PANEL_GAP } from '../../common/layoutConstants.js'; interface IGridSlot { readonly id: string; @@ -479,20 +480,24 @@ export class SessionsPart extends Part { this._lastLayout = { width, height, top, left }; + const phoneLayout = isPhoneLayout(this.layoutService); + const compactLayout = this.layoutService.isModernUICompact(); const cardSize = getAgentsPartCardContentSize( width, height, this.agentWorkbenchLayoutService.isEditorPaneVisible(), this.layoutService.isVisible(Parts.SIDEBAR_PART), - isPhoneLayout(this.layoutService), - this.layoutService.isModernUICompact() + phoneLayout, + compactLayout, + getSessionsPartCardMarginRight(this.layoutService.mainContainer) ); // Size the content area with the reduced dimensions. const { contentSize } = this.layoutContents(cardSize.width, cardSize.height); // Layout the internal grid widget within the content area. - this._gridWidget?.layout(contentSize.width, contentSize.height, top, left, isPhoneLayout(this.layoutService)); + const gridGap = phoneLayout || compactLayout ? 0 : AGENTS_FLOATING_PANEL_GAP; + this._gridWidget?.layout(contentSize.width, contentSize.height, top, left, phoneLayout, gridGap); // Store the full grid-allocated dimensions so that Part.relayout() works correctly. super.layout(width, height, top, left); diff --git a/src/vs/sessions/common/sessionConfig.ts b/src/vs/sessions/common/sessionConfig.ts index 7441ab175b53f7..8204f40f4643c9 100644 --- a/src/vs/sessions/common/sessionConfig.ts +++ b/src/vs/sessions/common/sessionConfig.ts @@ -13,6 +13,16 @@ export const SESSIONS_CHAT_TABS_SETTING = 'sessions.showChatTabs'; export const SESSIONS_LIST_GROUP_EXTERNAL_SESSIONS_SETTING = 'sessions.list.groupExternalSessions'; +export const SESSIONS_LIST_EXTERNAL_APPLICATION_BADGE_SETTING = 'sessions.list.externalApplicationBadge'; + +export const SESSIONS_LIST_EXTERNAL_APPLICATION_BADGE_SHOW_FROM_SETTING = 'sessions.list.externalApplicationBadge.showFrom'; + +export const enum ExternalSessionApplicationBadgeMode { + Off = 'off', + Title = 'title', + Details = 'details', +} + export const SESSIONS_SIDEBAR_SEPARATE_NAVIGATION_SETTING = 'sessions.sidebar.separateNavigation'; export const SESSIONS_LIST_REARRANGE_TREATMENT = 'sessions.list.rearrage'; diff --git a/src/vs/sessions/contrib/automations/browser/automationDialog.ts b/src/vs/sessions/contrib/automations/browser/automationDialog.ts index 966136c9d35eb1..4584e6413169b8 100644 --- a/src/vs/sessions/contrib/automations/browser/automationDialog.ts +++ b/src/vs/sessions/contrib/automations/browser/automationDialog.ts @@ -43,7 +43,7 @@ import { ILogService } from '../../../../platform/log/common/log.js'; import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; import { IQuickInputService, IQuickTreeItem } from '../../../../platform/quickinput/common/quickInput.js'; import { IWorkspaceTrustRequestService } from '../../../../platform/workspace/common/workspaceTrust.js'; -import { defaultButtonStyles, defaultCheckboxStyles, defaultInputBoxStyles, defaultSelectBoxStyles } from '../../../../platform/theme/browser/defaultStyles.js'; +import { defaultButtonStyles, defaultCheckboxStyles, defaultSelectBoxStyles, getInputBoxStyle } from '../../../../platform/theme/browser/defaultStyles.js'; import { hasNativeContextMenu } from '../../../../platform/window/common/window.js'; import { IWorkspacePickerItem, WorkspacePicker } from '../../chat/browser/sessionWorkspacePicker.js'; import { BranchPicker, IBranchPickerBranch, NEW_WORKTREE_LABEL } from '../../chat/browser/branchPicker.js'; @@ -61,6 +61,7 @@ import { AgentSessionTarget } from '../../../../workbench/contrib/chat/browser/a import { IChatWidget, ISessionTypePickerDelegate } from '../../../../workbench/contrib/chat/browser/chat.js'; import { ChatInputPart, IChatInputPartOptions, IChatInputStyles } from '../../../../workbench/contrib/chat/browser/widget/input/chatInputPart.js'; import { ChatInputPickerResponsiveLayout, IChatInputPickerResponsiveLayoutItem } from '../../../../workbench/contrib/chat/browser/widget/input/chatInputPickerResponsiveLayout.js'; +import { settingsTextInputBackground, settingsTextInputBorder, settingsTextInputForeground } from '../../../../workbench/contrib/preferences/common/settingsEditorColorRegistry.js'; import { IWorkbenchLayoutService } from '../../../../workbench/services/layout/browser/layoutService.js'; import { AutomationIsolationModel, normalizeAutomationBranchNames } from '../common/isolationGroupModel.js'; import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; @@ -78,6 +79,12 @@ import { Menus } from '../../../browser/menus.js'; const $ = DOM.$; +const automationNameInputBoxStyles = getInputBoxStyle({ + inputBackground: settingsTextInputBackground, + inputForeground: settingsTextInputForeground, + inputBorder: settingsTextInputBorder, +}); + const INTERVALS: { readonly value: AutomationInterval; readonly label: string }[] = [ { value: 'manual', label: localize('automation.interval.manual', "Manual") }, { value: 'hourly', label: localize('automation.interval.hourly', "Hourly") }, @@ -1066,7 +1073,7 @@ export function renderForm( DOM.append(nameRow, $('span.automation-form-label', undefined, localize('automation.form.name', "Name"))); const nameInputContainer = DOM.append(nameRow, $('.automation-form-input-host')); const nameInput = disposables.add(new InputBox(nameInputContainer, contextViewService, { - inputBoxStyles: defaultInputBoxStyles, + inputBoxStyles: automationNameInputBoxStyles, placeholder: localize('automation.form.namePlaceholder', "e.g. Morning standup notes"), ariaLabel: localize('automation.form.name', "Name"), })); diff --git a/src/vs/sessions/contrib/automations/browser/automationDialogService.ts b/src/vs/sessions/contrib/automations/browser/automationDialogService.ts index 8cf5dafa739b63..a38e863023e365 100644 --- a/src/vs/sessions/contrib/automations/browser/automationDialogService.ts +++ b/src/vs/sessions/contrib/automations/browser/automationDialogService.ts @@ -366,12 +366,13 @@ export class AutomationDialogService implements IAutomationDialogService { titlebar.setAttribute('aria-hidden', 'true'); titlebar.textContent = title; - const description = DOM.append(container, $('.automation-description')); + const scrollable = DOM.append(container, $('.automation-dialog-scrollable')); + const description = DOM.append(scrollable, $('.automation-description')); description.textContent = isEdit ? localize('automation.dialog.editDescription', "Update the schedule, prompt, or run target for this automation.") : localize('automation.dialog.createDescription', "Define a prompt that will run on a schedule against the selected target."); - const formPane = DOM.append(container, $('.automation-form-pane')); + const formPane = DOM.append(scrollable, $('.automation-form-pane')); const form = DOM.append(formPane, $('.automation-form')); const handle = renderForm(form, state, disposables, validation, () => revalidate(), this.instantiationService, this.contextKeyService, this.contextViewService, this.configurationService, this.layoutService, this.logService, this.sessionsManagementService, this.workspaceTrustRequestService, initial?.prompt ?? '', initialTarget, initialSessionConfiguration, allowedProviders, providerId => this.automationService.getProviderConfiguration?.(providerId), isEdit, { service: this.automationService, hoverService: this.hoverService, existingId: existing?.id }, error => this.notificationService.error(error), this.quickInputService); disposables.add(autorun(reader => { diff --git a/src/vs/sessions/contrib/automations/browser/media/automationDialog.css b/src/vs/sessions/contrib/automations/browser/media/automationDialog.css index e1ca2d6d3b1645..34f97bc0a85e89 100644 --- a/src/vs/sessions/contrib/automations/browser/media/automationDialog.css +++ b/src/vs/sessions/contrib/automations/browser/media/automationDialog.css @@ -46,8 +46,8 @@ * shrinks gracefully on narrow viewports. * * Padding is stripped (vs. the base 8px) so our `.automation-titlebar` - * stripe can paint edge-to-edge inside the dialog body. The form pane - * re-adds its own padding. + * can sit edge-to-edge inside the dialog body. The form pane re-adds + * its own padding. * * Note: `extraClasses` on Dialog adds the class to `.monaco-dialog-box` * itself (not an ancestor), so we attach the rule directly here. @@ -71,7 +71,7 @@ * Float the close-X over the titlebar so the title text sits flush * with the top edge of the modal (QuickInput-style). Without this, * `.dialog-toolbar-row` reserves ~24px of dead space above the title. - * `z-index` keeps the X above the sticky titlebar. + * `z-index` keeps the X above the titlebar. */ .monaco-dialog-box.automation-dialog .dialog-toolbar-row { position: absolute; @@ -84,8 +84,10 @@ .automation-dialog-body { display: flex; + flex: 1 1 auto; flex-direction: column; min-height: 0; + overflow: hidden; } .monaco-dialog-box.automation-dialog > .dialog-buttons-row:not(.automation-dialog-footer-row) { @@ -119,9 +121,9 @@ * our titlebar can full-bleed. The form pane below re-adds horizontal * padding so the form fields don't crash into the dialog edge. * - * Container still owns the scrolling (per base/.../dialog.css), so - * the titlebar uses `position: sticky; top: 0` to stay visible as the - * form scrolls underneath it. + * The base message container normally owns scrolling. This dialog + * clips it instead and delegates scrolling to `.automation-dialog-scrollable` + * so the titlebar stays outside the scrolling form. */ .monaco-dialog-box.automation-dialog .dialog-message-row { padding: 0; @@ -144,20 +146,25 @@ */ flex: 1 1 auto; min-width: 0; + overflow: hidden; } -/* Keep the sticky titlebar solid so scrolled form content cannot show through. */ .automation-titlebar { - position: sticky; - top: 0; - z-index: 1; + flex: 0 0 auto; /* Right padding reserves room for the floating close-X chip. */ padding: 8px 36px 8px 14px; text-align: left; font-weight: var(--vscode-fontWeight-semiBold); font-size: var(--vscode-fontSize-heading2); color: var(--vscode-editorWidget-foreground); - background-color: var(--modern-ui-solid-dialog-background, var(--vscode-editorWidget-background)); +} + +.automation-dialog-scrollable { + flex: 1 1 auto; + min-width: 0; + min-height: 0; + overflow-x: hidden; + overflow-y: auto; } .automation-description { @@ -176,33 +183,32 @@ /* * Theme the native overflow scrollbar on the dialog body so it matches - * the rest of VS Code. .dialog-message-container is the scroll host - * (see base/browser/ui/dialog/dialog.css). Without this the dialog + * the rest of VS Code. Without this the dialog * shows the platform's default scrollbar which clashes visually with * the chat input embedded above. */ -.monaco-dialog-box.automation-dialog .dialog-message-row .dialog-message-container::-webkit-scrollbar { +.monaco-dialog-box.automation-dialog .automation-dialog-scrollable::-webkit-scrollbar { width: 10px; } -.monaco-dialog-box.automation-dialog .dialog-message-row .dialog-message-container::-webkit-scrollbar-track { +.monaco-dialog-box.automation-dialog .automation-dialog-scrollable::-webkit-scrollbar-track { background-color: transparent; } -.monaco-dialog-box.automation-dialog .dialog-message-row .dialog-message-container::-webkit-scrollbar-thumb { +.monaco-dialog-box.automation-dialog .automation-dialog-scrollable::-webkit-scrollbar-thumb { min-height: 20px; background-color: var(--vscode-scrollbarSlider-background); } -.monaco-dialog-box.automation-dialog .dialog-message-row .dialog-message-container::-webkit-scrollbar-thumb:hover { +.monaco-dialog-box.automation-dialog .automation-dialog-scrollable::-webkit-scrollbar-thumb:hover { background-color: var(--vscode-scrollbarSlider-hoverBackground); } -.monaco-dialog-box.automation-dialog .dialog-message-row .dialog-message-container::-webkit-scrollbar-thumb:active { +.monaco-dialog-box.automation-dialog .automation-dialog-scrollable::-webkit-scrollbar-thumb:active { background-color: var(--vscode-scrollbarSlider-activeBackground); } -.monaco-dialog-box.automation-dialog .dialog-message-row .dialog-message-container::-webkit-scrollbar-corner { +.monaco-dialog-box.automation-dialog .automation-dialog-scrollable::-webkit-scrollbar-corner { display: none; } diff --git a/src/vs/sessions/contrib/automations/test/browser/automationDialog.test.ts b/src/vs/sessions/contrib/automations/test/browser/automationDialog.test.ts index 149bc5c6dd3f07..ac74ea3b1c646e 100644 --- a/src/vs/sessions/contrib/automations/test/browser/automationDialog.test.ts +++ b/src/vs/sessions/contrib/automations/test/browser/automationDialog.test.ts @@ -230,6 +230,36 @@ suite('Automation dialog creation', () => { }; } + test('keeps the title outside scrolling content and themes the Name input consistently', async () => { + const dialog = openDialog(); + const body = dialog.container.querySelector('.automation-dialog-body')!; + const scrollable = dialog.container.querySelector('.automation-dialog-scrollable')!; + const titlebar = dialog.container.querySelector('.automation-titlebar')!; + const nameInputBox = dialog.container.querySelector('.automation-form-input-host > .monaco-inputbox')!; + + assert.deepStrictEqual({ + bodyChildren: Array.from(body.children, element => element.className), + titleInScrollableContent: scrollable.contains(titlebar), + scrollableChildren: Array.from(scrollable.children, element => element.className), + nameInputStyles: { + background: nameInputBox.style.backgroundColor, + foreground: nameInputBox.style.color, + border: nameInputBox.style.border, + }, + }, { + bodyChildren: ['automation-dialog-progress', 'automation-titlebar', 'automation-dialog-scrollable'], + titleInScrollableContent: false, + scrollableChildren: ['automation-description', 'automation-form-pane'], + nameInputStyles: { + background: 'var(--vscode-settings-textInputBackground)', + foreground: 'var(--vscode-settings-textInputForeground)', + border: '1px solid var(--vscode-settings-textInputBorder, transparent)', + }, + }); + dialog.cancelButton.click(); + await dialog.result; + }); + function toolCatalog(tools: readonly IAutomationTool[] = [{ id: 'read', label: 'Read Files' }, { id: 'edit', label: 'Edit Files' }]): AutomationToolCatalog { return { kind: 'ready', groups: [{ id: 'files', label: 'Files', tools }] }; } diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionsGrid.fixture.ts b/src/vs/sessions/contrib/chat/test/browser/sessionsGrid.fixture.ts index b39b39be26d0f5..22bf12a688c6c1 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionsGrid.fixture.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionsGrid.fixture.ts @@ -3,24 +3,33 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Direction } from '../../../../../base/browser/ui/grid/grid.js'; +import { $, append } from '../../../../../base/browser/dom.js'; +import { Direction, ISerializableView } from '../../../../../base/browser/ui/grid/grid.js'; +import { mainWindow } from '../../../../../base/browser/window.js'; import { assert } from '../../../../../base/common/assert.js'; import { Codicon } from '../../../../../base/common/codicons.js'; +import { Event } from '../../../../../base/common/event.js'; import { autorun, constObservable, observableValue, transaction } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { IChatWidgetFixtureHandle, renderChatWidget } from '../../../../../workbench/test/browser/componentFixtures/chat/chatWidget.fixture.js'; +import { renderEditorTabsFixture } from '../../../../../workbench/test/browser/componentFixtures/editor/tabs.fixture.js'; import { ComponentFixtureContext, defineComponentFixture, defineThemedFixtureGroup } from '../../../../../workbench/test/browser/componentFixtures/fixtureUtils.js'; -import { getAgentsPartCardContentSize } from '../../../../browser/parts/agentsPartCard.js'; +import { AgentsPartCard, getAgentsPartCardContentSize, getSidePaneBottomFrameInset } from '../../../../browser/parts/agentsPartCard.js'; import { AbstractChatView, ChatViewKind } from '../../../../browser/parts/chatView.js'; +import { GRID_GAP_SASH_CLASS } from '../../../../browser/parts/gridGap.js'; +import { ISessionsChatBackground } from '../../../../services/chatBackground/browser/chatBackgroundService.js'; import { IChatViewFactory } from '../../../../services/chatView/browser/chatViewFactory.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; -import { IChat, ISession, ISessionWorkspace } from '../../../../services/sessions/common/session.js'; +import { ChatOriginKind, IChat, ISession, ISessionWorkspace } from '../../../../services/sessions/common/session.js'; import { IActiveSession } from '../../../../services/sessions/common/sessionsManagement.js'; import { createTestActiveSession } from '../../../../test/browser/sessionViewTestUtils.js'; +import { AGENTS_FLOATING_PANEL_GAP } from '../../../../common/layoutConstants.js'; +import { SessionsChatTabsMode } from '../../../../common/sessionConfig.js'; import { createSessionsWorkbenchFixture } from './sessionsWorkbenchFixtureUtils.js'; import '../../../../browser/media/style.css'; +import '../../../../browser/parts/media/editorPart.css'; import '../../browser/media/chatView.css'; class FixtureChatView extends AbstractChatView { @@ -73,7 +82,84 @@ class FixtureChatView extends AbstractChatView { } /** Cloud sessions keep the production chrome independent of local tasks, authentication, and network access. */ -async function renderSessionsGrid(context: ComponentFixtureContext, options: { maximized?: boolean; multipleChats?: boolean; sideBySide?: boolean } = {}): Promise { +type MultipleChatsLayout = 'sideBySide' | 'bottomRight' | 'mixedRelated'; + +const SIDE_PANE_WIDTH = 360; +const BOTTOM_PANEL_HEIGHT = 240; +const FIXTURE_CHAT_BACKGROUND = { + kind: 'image', + backgroundImage: 'linear-gradient(135deg, color-mix(in srgb, var(--vscode-textLink-foreground) 18%, transparent), transparent 55%)', + backgroundRepeat: 'repeat', + backgroundSize: '96px 96px', + backgroundPosition: 'left top', +} satisfies ISessionsChatBackground; + +function createSidePaneFixture(context: ComponentFixtureContext, showTabs: 'multiple' | 'single'): { readonly view: ISerializableView; readonly connectedFrame: HTMLElement; readonly pillFrame: HTMLElement } { + const element = $('.sessions-grid-side-pane-fixture'); + renderEditorTabsFixture({ ...context, container: element }, { + modernUI: true, + width: SIDE_PANE_WIDTH - AgentsPartCard.BORDER_WIDTH * 2, + partOptions: { showTabs }, + editors: [{ resource: URI.file('/Changes'), icon: Codicon.diffMultiple, pinned: true, active: true }], + }); + const editor = element.querySelector('.part.editor'); + const content = element.querySelector('.part.editor > .content'); + const frame = element.querySelector('.editor-group-container'); + const title = element.querySelector('.editor-group-container > .title'); + const editorContainer = element.querySelector('.editor-container'); + assert(editor !== null && content !== null && frame !== null && title !== null && editorContainer !== null, 'Expected a rendered connected side pane'); + editorContainer.textContent = '3 files changed'; + + return { + connectedFrame: frame, + pillFrame: editor, + view: { + element, + minimumWidth: 240, + maximumWidth: Number.POSITIVE_INFINITY, + minimumHeight: 200, + maximumHeight: Number.POSITIVE_INFINITY, + onDidChange: Event.None, + layout: (width, height) => { + element.style.width = `${width}px`; + element.style.height = `${height}px`; + editor.style.width = '100%'; + editor.style.height = '100%'; + const contentWidth = Math.max(0, width - AgentsPartCard.BORDER_WIDTH * 2); + const contentHeight = Math.max(0, height - AgentsPartCard.BORDER_WIDTH * 2 - getSidePaneBottomFrameInset(context.container)); + content.style.width = `${contentWidth}px`; + content.style.height = `${contentHeight}px`; + frame.style.width = `${contentWidth}px`; + frame.style.height = `${contentHeight}px`; + editorContainer.style.height = `${Math.max(0, contentHeight - title.offsetHeight)}px`; + }, + toJSON: () => ({}), + }, + }; +} + +function createBottomPanelFixture(): { readonly view: ISerializableView; readonly frame: HTMLElement } { + const element = $('.part.panel.basepanel.bottom.sessions-grid-bottom-panel-fixture'); + append(element, $('.content')); + + return { + frame: element, + view: { + element, + minimumWidth: 300, + maximumWidth: Number.POSITIVE_INFINITY, + minimumHeight: 77, + maximumHeight: Number.POSITIVE_INFINITY, + onDidChange: Event.None, + layout: (_width, height) => { + element.style.height = `calc(${height}px - var(--agents-floating-panel-gap) + var(--agents-session-frame-inset))`; + }, + toJSON: () => ({}), + }, + }; +} + +async function renderSessionsGrid(context: ComponentFixtureContext, options: { bottomPanel?: boolean; chatTabsMode?: SessionsChatTabsMode; connectedEditorTabs?: boolean; customBackground?: boolean; maximized?: boolean; multipleChats?: MultipleChatsLayout; relatedChats?: boolean; sideBySide?: boolean; sidePaneShowTabs?: 'multiple' | 'single'; sidePanel?: boolean } = {}): Promise { const workspace = constObservable({ uri: URI.parse('https://github.com/microsoft/vscode'), label: 'microsoft/vscode', @@ -86,8 +172,18 @@ async function renderSessionsGrid(context: ComponentFixtureContext, options: { m const sessions = (options.sideBySide ? titles.slice(0, 2) : titles).map((title, index) => { const session = createTestActiveSession(`fixture-${index}`); const chat: IChat = { ...session.mainChat.get(), workspace, title: constObservable(title) }; - const sessionChats = options.multipleChats && index === 0 - ? [chat, { ...chat, resource: chat.resource.with({ path: '/regression-tests' }), title: constObservable('Regression tests') }] + const multipleChatsIndex = options.multipleChats === 'bottomRight' ? 2 : 0; + const sessionChats: readonly IChat[] = options.multipleChats && index === multipleChatsIndex + ? [chat, { + ...chat, + resource: chat.resource.with({ path: '/regression-tests' }), + title: constObservable('Regression tests'), + origin: options.relatedChats || options.multipleChats === 'mixedRelated' ? { kind: ChatOriginKind.SideChat, parentChat: chat.resource } : undefined, + }, ...(options.multipleChats === 'mixedRelated' ? [{ + ...chat, + resource: chat.resource.with({ path: '/documentation' }), + title: constObservable('Documentation'), + }] : [])] : [chat]; return { ...session, @@ -130,7 +226,19 @@ async function renderSessionsGrid(context: ComponentFixtureContext, options: { m target.activeChat.set(chat, undefined); } }(); - const { part, grid, layout, instantiationService } = createSessionsWorkbenchFixture(context, 1440, 900, sessionsService); + const sidePaneShowTabs = options.sidePaneShowTabs ?? 'multiple'; + const sidePane = options.sidePanel ? createSidePaneFixture(context, sidePaneShowTabs) : undefined; + const bottomPanel = options.bottomPanel ? createBottomPanelFixture() : undefined; + const { part, grid, layout, instantiationService } = createSessionsWorkbenchFixture(context, 1440, 900, sessionsService, { + sidePane: sidePane?.view, + sidePaneWidth: SIDE_PANE_WIDTH, + bottomPanel: bottomPanel?.view, + bottomPanelHeight: BOTTOM_PANEL_HEIGHT, + chatBackground: options.customBackground ? FIXTURE_CHAT_BACKGROUND : undefined, + chatTabsMode: options.chatTabsMode, + connectedEditorTabs: options.connectedEditorTabs, + sidePaneShowTabs, + }); const chats: FixtureChatView[] = []; instantiationService.stub(IChatViewFactory, new class extends mock() { override createNewChatView(isNewChatInSession: boolean): AbstractChatView { @@ -152,12 +260,36 @@ async function renderSessionsGrid(context: ComponentFixtureContext, options: { m part.updateVisibleSessions(visible, sessionsService.activeSession.read(reader), slots.filter(slot => visible.some(session => session.sessionId === slot.id))); })); layout(); + const sidePaneDivider = sidePane + ? Array.from(grid.element.querySelectorAll('.monaco-sash.vertical')) + .find(sash => sash.closest('.monaco-grid-view') === grid.element) + : undefined; + if (sidePane) { + assert(sidePaneDivider !== undefined, 'Expected a Sessions/side-pane divider'); + sidePaneDivider.classList.add('sessions-side-pane-divider'); + } + const sidePaneFrame = sidePane + ? options.connectedEditorTabs !== false && sidePaneShowTabs === 'multiple' ? sidePane.connectedFrame : sidePane.pillFrame + : undefined; if (bottom) { - part.resizeSession(left.sessionId, options.multipleChats ? Direction.Right : Direction.Left, options.multipleChats ? 180 : 120); + const sideBySideChats = options.multipleChats === 'sideBySide' || options.multipleChats === 'mixedRelated'; + part.resizeSession(left.sessionId, sideBySideChats ? Direction.Right : Direction.Left, sideBySideChats ? 180 : 120); part.resizeSession(top.sessionId, Direction.Down, 40); } - if (options.multipleChats) { + if (options.multipleChats === 'sideBySide') { part.getSessionView(left.sessionId)!.splitChatToSide(left.chats.get()[1].resource); + await Promise.resolve(); + } else if (options.multipleChats === 'bottomRight') { + assert(bottom !== undefined, 'Expected a bottom-right session'); + part.getSessionView(bottom.sessionId)!.splitActiveChat('bottom'); + await Promise.resolve(); + } else if (options.multipleChats === 'mixedRelated') { + const sessionView = part.getSessionView(left.sessionId)!; + sessionView.splitActiveChat('right'); + await Promise.resolve(); + await sessionsService.openChat(left, left.chats.get()[2].resource); + sessionView.splitActiveChat('bottom'); + await Promise.resolve(); } await Promise.all(chats.map(chat => chat.render())); context.disposableStore.add(part.onDidFocusSession(id => sessionsService.setActive(sessions.find(session => session.sessionId === id)))); @@ -168,6 +300,36 @@ async function renderSessionsGrid(context: ComponentFixtureContext, options: { m const element = part.getSessionView(session.sessionId)!.element; return { width: element.offsetWidth, height: element.offsetHeight }; }); + const gridGapSashes = Array.from(context.container.querySelectorAll(`.${GRID_GAP_SASH_CLASS}`)); + const expectedGridGapSashes = options.maximized ? 0 : sessions.length - 1; + assert(gridGapSashes.length === expectedGridGapSashes, 'Expected only session gutters to have grippers'); + for (const sash of gridGapSashes) { + const grip = mainWindow.getComputedStyle(sash, '::after'); + const bounds = sash.getBoundingClientRect(); + assert( + grip.content === '""' + && Math.abs(parseFloat(grip.left) - bounds.width / 2) < 0.01 + && Math.abs(parseFloat(grip.top) - bounds.height / 2) < 0.01, + 'Expected every grid gripper to be centered on its sash' + ); + } + const sessionsCard = context.container.querySelector('.part.sessionspart.agents-part-card'); + assert(sessionsCard !== null, 'Expected a Sessions card'); + assert(mainWindow.getComputedStyle(sessionsCard).backgroundClip === 'padding-box', 'Expected no enclosing Sessions-group stroke'); + if (options.customBackground) { + const background = sessionsCard.querySelector('.sessions-chat-background'); + const leaves = [...sessionsCard.querySelectorAll('.session-grid-leaf')]; + const sessionViews = [...sessionsCard.querySelectorAll('.session-view')]; + assert( + sessionsCard.classList.contains('has-chat-background') + && background !== null + && !background.hidden + && mainWindow.getComputedStyle(background).backgroundImage !== 'none' + && leaves.every(leaf => mainWindow.getComputedStyle(leaf).backgroundColor === 'rgba(0, 0, 0, 0)') + && sessionViews.every(view => mainWindow.getComputedStyle(view).backgroundColor === 'rgba(0, 0, 0, 0)'), + 'Expected the custom background to remain visible inside every gapped session panel' + ); + } if (options.maximized) { const allocation = grid.getViewSize(part); const contentSize = getAgentsPartCardContentSize(allocation.width, allocation.height, false, false, false); @@ -175,12 +337,71 @@ async function renderSessionsGrid(context: ComponentFixtureContext, options: { m } else if (options.sideBySide) { assert(sizes.length === 2 && sizes.every(size => size.width > 0 && size.height === sizes[0].height), 'Expected two full-height side-by-side sessions'); } else { - assert(options.multipleChats ? sizes[0].width > sizes[1].width : sizes[0].width < sizes[1].width, 'Expected the intended session widths'); - assert(sizes[0].height === sizes[1].height + sizes[2].height && sizes[1].height > sizes[2].height, 'Expected unequal nested splits'); + assert(options.multipleChats === 'sideBySide' || options.multipleChats === 'mixedRelated' ? sizes[0].width > sizes[1].width : sizes[0].width < sizes[1].width, 'Expected the intended session widths'); + assert(sizes[0].height === sizes[1].height + sizes[2].height + AGENTS_FLOATING_PANEL_GAP && sizes[1].height > sizes[2].height, 'Expected unequal nested splits with the configured session gap'); } if (options.multipleChats) { - const groups = part.getSessionView(left.sessionId)!.element.querySelectorAll('.chat-group-view'); - assert(groups.length === 2 && [...groups].every(group => group.offsetWidth > 0), 'Expected two visible chat groups within one session'); + const splitSession = options.multipleChats === 'bottomRight' ? bottom! : left; + const groups = part.getSessionView(splitSession.sessionId)!.element.querySelectorAll('.chat-group-view'); + const expectedGroups = options.multipleChats === 'mixedRelated' ? 3 : 2; + assert(groups.length === expectedGroups && [...groups].every(group => group.offsetWidth > 0), 'Expected every split chat group to be visible within one session'); + if (options.multipleChats === 'mixedRelated') { + const chatGroups = part.getSessionView(splitSession.sessionId)!.element; + assert( + chatGroups.querySelectorAll(`.${GRID_GAP_SASH_CLASS}`).length === 0 + && chatGroups.querySelectorAll('.chat-groups-view-boundary-segment').length === 0 + && chatGroups.querySelectorAll('.monaco-split-view2').length === chatGroups.querySelectorAll('.monaco-split-view2.separator-border').length, + 'Expected every nested chat boundary to be only a one-stroke divider' + ); + } else { + const first = mainWindow.getComputedStyle(groups[0]); + const second = mainWindow.getComputedStyle(groups[1]); + const sharedGap = options.multipleChats === 'sideBySide' + ? parseFloat(first.borderRightWidth) + parseFloat(second.borderLeftWidth) + : parseFloat(first.borderBottomWidth) + parseFloat(second.borderTopWidth); + assert(sharedGap === 0, 'Expected split chats to remain flush in every tab presentation'); + } + } + if (sidePane) { + assert(sidePaneDivider !== undefined, 'Expected a Sessions/side-pane divider'); + const bounds = sessions.map(session => part.getSessionView(session.sessionId)!.element.getBoundingClientRect()); + const left = bounds.reduce((candidate, current) => current.left < candidate.left ? current : candidate); + const right = bounds.reduce((candidate, current) => current.left > candidate.left ? current : candidate); + const internalGap = right.left - left.right; + const sidePaneBounds = sidePaneFrame!.getBoundingClientRect(); + const sideGap = sidePaneBounds.left - Math.max(...bounds.map(bound => bound.right)); + assert(Math.abs(internalGap - AGENTS_FLOATING_PANEL_GAP) < 0.01 && Math.abs(sideGap - internalGap) < 0.01, 'Expected the side pane and session panels to use the same visible gap'); + const dividerBounds = sidePaneDivider.getBoundingClientRect(); + const dividerCenter = (dividerBounds.left + dividerBounds.right) / 2; + const sideGapCenter = (Math.max(...bounds.map(bound => bound.right)) + sidePaneBounds.left) / 2; + const grip = mainWindow.getComputedStyle(sidePaneDivider, '::after'); + assert(Math.abs(dividerCenter - sideGapCenter) < 0.01 && grip.content === '""', 'Expected the side-pane gripper to be centered in the visible gap'); + } + if (bottomPanel) { + const bounds = sessions.map(session => part.getSessionView(session.sessionId)!.element.getBoundingClientRect()); + const sessionBottom = Math.max(...bounds.map(bound => bound.bottom)); + const panelBounds = bottomPanel.frame.getBoundingClientRect(); + const panelGap = panelBounds.top - sessionBottom; + const panelGapCenter = (sessionBottom + panelBounds.top) / 2; + const horizontalDividers = Array.from(grid.element.querySelectorAll('.monaco-sash.horizontal')) + .filter(sash => sash.closest('.monaco-grid-view') === grid.element); + assert(horizontalDividers.length > 0, 'Expected a bottom-panel divider'); + const panelDivider = horizontalDividers.reduce((candidate, sash) => { + const candidateBounds = candidate.getBoundingClientRect(); + const sashBounds = sash.getBoundingClientRect(); + const candidateDistance = Math.abs((candidateBounds.top + candidateBounds.bottom) / 2 - panelGapCenter); + const sashDistance = Math.abs((sashBounds.top + sashBounds.bottom) / 2 - panelGapCenter); + return sashDistance < candidateDistance ? sash : candidate; + }); + const dividerBounds = panelDivider.getBoundingClientRect(); + const dividerCenter = (dividerBounds.top + dividerBounds.bottom) / 2; + const grip = mainWindow.getComputedStyle(panelDivider, '::after'); + assert(Math.abs(panelGap - AGENTS_FLOATING_PANEL_GAP) < 0.01, 'Expected the bottom panel and session panels to use the same visible gap'); + assert(Math.abs(dividerCenter - panelGapCenter) < 0.01 && grip.content === '""', 'Expected the bottom-panel gripper to be centered in the visible gap'); + if (sidePaneFrame) { + const sidePanePanelGap = panelBounds.top - sidePaneFrame.getBoundingClientRect().bottom; + assert(Math.abs(sidePanePanelGap - panelGap) < 0.01, 'Expected the bottom panel to use the same visible gap below Sessions and the side pane'); + } } } @@ -188,23 +409,88 @@ export default defineThemedFixtureGroup({ path: 'sessions/grid/' }, { SideBySide: defineComponentFixture({ additionalThemes: ['darkHighContrast', 'lightHighContrast'], labels: { kind: 'screenshot' }, - expectedVisualDescriptions: ['Two full-height sessions sit side by side within one floating Sessions card. Each has a session header, a completed conversation, and a chat input. The shared outer card styling is unchanged. A subtle divider remains visible at rest between the sessions in dark and light themes, while high-contrast themes retain a full-strength contrast border.'], + expectedVisualDescriptions: ['Two full-height session panels sit side by side. Each has a session header, a completed conversation, a chat input, and a complete one-stroke panel border. The same floating-panel gap used elsewhere in the Agents window separates the bordered panels without an additional divider, a three-dot gripper is centered in the gap, and no frame surrounds the Sessions group.'], render: context => renderSessionsGrid(context, { sideBySide: true }), }), NestedSplits: defineComponentFixture({ additionalThemes: ['darkHighContrast', 'lightHighContrast'], labels: { kind: 'screenshot' }, - expectedVisualDescriptions: ['The Agents workbench title bar sits above the floating Sessions card, with sidebars and panels hidden. Three session panes fill the card: a narrow full-height pane on the left and two unequal-height panes stacked on the right. Each pane has a session header, a completed conversation, and a chat input.'], + expectedVisualDescriptions: ['The Agents workbench title bar sits above three session panels, with sidebars and panels hidden. A narrow full-height panel is on the left and two unequal-height panels are stacked on the right. Floating-panel gaps separate the sessions, each gap has a centered three-dot gripper, each session retains its complete one-stroke panel border, and no frame surrounds the Sessions group. Each panel has a session header, a completed conversation, and a chat input.'], render: context => renderSessionsGrid(context), }), + CustomBackground: defineComponentFixture({ + additionalThemes: ['darkHighContrast', 'lightHighContrast'], + labels: { kind: 'screenshot' }, + expectedVisualDescriptions: ['A repeating custom background remains visible inside all three transparent session panels. The solid workbench-colored floating-panel gaps separate the complete panel frames, include centered three-dot grippers, and do not show the custom background through the gutters.'], + render: context => renderSessionsGrid(context, { customBackground: true }), + }), + SidePanelGap: defineComponentFixture({ + additionalThemes: ['darkHighContrast', 'lightHighContrast'], + labels: { kind: 'screenshot' }, + expectedVisualDescriptions: ['The connected Changes side pane and all three fully bordered session panels use the same floating-panel gap. Every gap has a centered three-dot gripper, with no extra line or inset beside the side pane and no frame surrounding the Sessions group.'], + render: context => renderSessionsGrid(context, { sidePanel: true }), + }), + PillSidePanelGap: defineComponentFixture({ + additionalThemes: ['darkHighContrast', 'lightHighContrast'], + labels: { kind: 'screenshot' }, + expectedVisualDescriptions: ['The pill-tab Changes side pane and all three fully bordered session panels use the same floating-panel gap. Every gap has a centered three-dot gripper, with no extra line or off-ramp spacing beside the side pane and no frame surrounding the Sessions group.'], + render: context => renderSessionsGrid(context, { connectedEditorTabs: false, sidePanel: true }), + }), + ConnectedSingleTabSidePanelGap: defineComponentFixture({ + additionalThemes: ['darkHighContrast', 'lightHighContrast'], + labels: { kind: 'screenshot' }, + expectedVisualDescriptions: ['The connected single-tab Changes side pane and all three fully bordered session panels use the same floating-panel gap. Every gap has a centered three-dot gripper, with no extra line or off-center spacing beside the side pane and no frame surrounding the Sessions group.'], + render: context => renderSessionsGrid(context, { sidePaneShowTabs: 'single', sidePanel: true }), + }), + BottomPanelGap: defineComponentFixture({ + additionalThemes: ['darkHighContrast', 'lightHighContrast'], + labels: { kind: 'screenshot' }, + expectedVisualDescriptions: ['A fully bordered bottom panel sits below the three session panels. The same floating-panel gap separates the bottom panel from the session frames, and a three-dot gripper is centered vertically in that gap without an extra divider.'], + render: context => renderSessionsGrid(context, { bottomPanel: true }), + }), + JustifiedPillSideAndBottomPanelGap: defineComponentFixture({ + additionalThemes: ['darkHighContrast', 'lightHighContrast'], + labels: { kind: 'screenshot' }, + expectedVisualDescriptions: ['A pill-tab Changes side pane sits beside three session panels above a justified bottom panel. One consistent floating-panel gap separates every frame horizontally and vertically, and each sash gripper is centered in its gap without an extra divider.'], + render: context => renderSessionsGrid(context, { bottomPanel: true, connectedEditorTabs: false, sidePanel: true }), + }), Maximized: defineComponentFixture({ labels: { kind: 'screenshot' }, - expectedVisualDescriptions: ['The Agents workbench title bar and floating Sessions card are visible, with sidebars and panels hidden. Only the Grid layout session fills the card with its header, completed conversation, and chat input. No sibling panes or split sashes are visible.'], + expectedVisualDescriptions: ['The Agents workbench title bar and one fully bordered session panel are visible, with sidebars and panels hidden. Only the Grid layout session fills the available surface with its header, completed conversation, and chat input. No sibling panes, split sashes, or enclosing Sessions-group frame are visible.'], render: context => renderSessionsGrid(context, { maximized: true }), }), MultipleChats: defineComponentFixture({ labels: { kind: 'screenshot' }, - expectedVisualDescriptions: ['The Agents workbench shows three sessions with sidebars and panels hidden. The wide left session contains two side-by-side chats, Grid layout and Regression tests, each with its own tab strip, conversation, and input below one shared session header. The other two sessions remain stacked on the right.'], - render: context => renderSessionsGrid(context, { multipleChats: true }), + expectedVisualDescriptions: ['The Agents workbench shows three fully bordered session panels separated by floating-panel gaps, with sidebars and panels hidden and no enclosing Sessions-group frame. The wide left session contains two flush side-by-side chats, Grid layout and Regression tests, separated only by a single themed divider with no internal gap or gripper. Each chat has its own tab strip, conversation, and input below one shared session header.'], + render: context => renderSessionsGrid(context, { multipleChats: 'sideBySide' }), + }), + RelatedChats: defineComponentFixture({ + additionalThemes: ['darkHighContrast', 'lightHighContrast'], + labels: { kind: 'screenshot' }, + expectedVisualDescriptions: ['The Agents workbench shows three fully bordered session panels separated by floating-panel gaps. The wide left session contains a parent chat and related child chat side by side as one continuous session surface, separated by a single themed divider without an internal gap or gripper. The outer session gaps retain their centered grippers.'], + render: context => renderSessionsGrid(context, { multipleChats: 'sideBySide', relatedChats: true }), + }), + MixedRelatedChats: defineComponentFixture({ + additionalThemes: ['darkHighContrast', 'lightHighContrast'], + labels: { kind: 'screenshot' }, + expectedVisualDescriptions: ['The wide left session contains a parent chat on the right with two chats stacked on the left. Every internal chat boundary is flush and rendered as a single themed divider, without internal gaps or grippers. Only the outer gaps between separate session panels retain grippers.'], + render: context => renderSessionsGrid(context, { multipleChats: 'mixedRelated' }), + }), + PillChatTabs: defineComponentFixture({ + additionalThemes: ['darkHighContrast', 'lightHighContrast'], + labels: { kind: 'screenshot' }, + expectedVisualDescriptions: ['The Agents workbench uses pill-style chat tabs while keeping three individually framed session panels separated by floating-panel gaps. The wide left session contains two flush side-by-side chats with pill tabs, separated only by a single themed divider without an internal gripper, and no frame surrounds the Sessions group.'], + render: context => renderSessionsGrid(context, { connectedEditorTabs: false, multipleChats: 'sideBySide' }), + }), + SingleChatPresentation: defineComponentFixture({ + additionalThemes: ['darkHighContrast', 'lightHighContrast'], + labels: { kind: 'screenshot' }, + expectedVisualDescriptions: ['The wide left session shows its parent and nested chat side by side as one continuous session surface. A single divider separates those chats without a floating-panel gap or gripper, while the outer session panels retain their normal gaps and centered grippers.'], + render: context => renderSessionsGrid(context, { chatTabsMode: SessionsChatTabsMode.Single, multipleChats: 'sideBySide', relatedChats: true }), + }), + NestedChatGroups: defineComponentFixture({ + labels: { kind: 'screenshot' }, + expectedVisualDescriptions: ['The Agents workbench shows three fully bordered sessions in a nested grid without an enclosing Sessions-group frame. The bottom-right session contains two flush vertically stacked chats separated only by a single themed divider without an internal gap or gripper. The gaps between separate session panels retain their centered grippers.'], + render: context => renderSessionsGrid(context, { multipleChats: 'bottomRight' }), }), }); diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionsWorkbenchFixtureUtils.ts b/src/vs/sessions/contrib/chat/test/browser/sessionsWorkbenchFixtureUtils.ts index 88635b9aeb8e63..cabef879729067 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionsWorkbenchFixtureUtils.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionsWorkbenchFixtureUtils.ts @@ -33,13 +33,14 @@ import { ComponentFixtureContext, createEditorServices, registerWorkbenchService import { TestProductService } from '../../../../../workbench/test/common/workbenchTestServices.js'; import { OpenInVSCodeAction, OpenInVSCodeWidgetContribution } from '../../../../browser/actions/vscodeActions.js'; import { Menus } from '../../../../browser/menus.js'; -import { AGENTS_PART_CARD_CLASS } from '../../../../browser/parts/agentsPartCard.js'; +import { AGENTS_PART_CARD_CLASS, AGENTS_SIDE_PANE_MULTIPLE_TABS_CLASS } from '../../../../browser/parts/agentsPartCard.js'; import { SessionsPart } from '../../../../browser/parts/sessionsPart.js'; import { TitlebarPart } from '../../../../browser/parts/titlebarPart.js'; import { IAgentWorkbenchLayoutService } from '../../../../browser/workbench.js'; import { AGENTS_FLOATING_PANEL_GAP } from '../../../../common/layoutConstants.js'; import { CanGoBackContext, MultipleSessionsVisibleContext, SessionWorkspaceIsVirtualContext } from '../../../../common/contextkeys.js'; -import { ISessionsChatBackgroundService } from '../../../../services/chatBackground/browser/chatBackgroundService.js'; +import { SESSIONS_CHAT_TABS_SETTING, SessionsChatTabsMode } from '../../../../common/sessionConfig.js'; +import { ISessionsChatBackground, ISessionsChatBackgroundService } from '../../../../services/chatBackground/browser/chatBackgroundService.js'; import { ISessionsPartService } from '../../../../services/sessions/browser/sessionsPartService.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; import { IActiveSession } from '../../../../services/sessions/common/sessionsManagement.js'; @@ -80,9 +81,25 @@ function acquireWorkbenchActions() { } /** Renders the visible desktop workbench parts without bootstrapping global workbench contributions. */ -export function createSessionsWorkbenchFixture(context: ComponentFixtureContext, width: number, height: number, sessionsService: ISessionsService) { +export function createSessionsWorkbenchFixture(context: ComponentFixtureContext, width: number, height: number, sessionsService: ISessionsService, options: { + readonly sidePane?: ISerializableView; + readonly sidePaneWidth?: number; + readonly bottomPanel?: ISerializableView; + readonly bottomPanelHeight?: number; + readonly chatBackground?: ISessionsChatBackground; + readonly chatTabsMode?: SessionsChatTabsMode; + readonly connectedEditorTabs?: boolean; + readonly sidePaneShowTabs?: 'multiple' | 'single'; +} = {}) { const { container, disposableStore, theme } = context; - container.classList.add('monaco-workbench', 'agent-sessions-workbench', 'modern-ui-tabs', 'nosidebar', 'noauxiliarybar', 'nopanel', 'noeditorpane'); + const sidePaneVisible = !!options.sidePane; + const bottomPanelVisible = !!options.bottomPanel; + container.classList.add('monaco-workbench', 'agent-sessions-workbench', 'modern-ui-tabs', 'nosidebar', 'noauxiliarybar', 'panel-alignment-justify'); + container.classList.toggle('modern-ui-connected-editor-tabs', options.connectedEditorTabs !== false); + container.classList.toggle('noeditorpane', !sidePaneVisible); + container.classList.toggle('nopanel', !bottomPanelVisible); + container.classList.toggle('dock-detail-panel', sidePaneVisible); + container.classList.toggle(AGENTS_SIDE_PANE_MULTIPLE_TABS_CLASS, sidePaneVisible && options.sidePaneShowTabs !== 'single'); container.style.width = `${width}px`; container.style.height = `${height}px`; @@ -91,9 +108,16 @@ export function createSessionsWorkbenchFixture(context: ComponentFixtureContext, override readonly mainContainer = container; override readonly mainContainerDimension = { width, height }; override readonly onDidChangePartVisibility = Event.None; - override isVisible(part: Parts) { return part === Parts.SESSIONS_PART || part === Parts.TITLEBAR_PART; } - override isEditorPaneVisible() { return false; } + override readonly onDidChangePanelAlignment = Event.None; + override isVisible(part: Parts) { + return part === Parts.SESSIONS_PART + || part === Parts.TITLEBAR_PART + || (sidePaneVisible && part === Parts.EDITOR_PART) + || (bottomPanelVisible && part === Parts.PANEL_PART); + } + override isEditorPaneVisible() { return sidePaneVisible; } override isModernUICompact() { return false; } + override getPanelAlignment() { return 'justify' as const; } override getContainer() { return container; } }(); const instantiationService = createEditorServices(disposableStore, { @@ -105,6 +129,7 @@ export function createSessionsWorkbenchFixture(context: ComponentFixtureContext, services.configurationService.setUserConfiguration('window', { titleBarStyle: 'custom', controlsStyle: 'hidden' }); services.configurationService.setUserConfiguration('accounts.showAvatar', false); services.configurationService.setUserConfiguration(NEW_SESSION_BUTTON_STYLE_SETTING, 'default'); + services.configurationService.setUserConfiguration(SESSIONS_CHAT_TABS_SETTING, options.chatTabsMode ?? SessionsChatTabsMode.Multiple); ChatContextKeys.enabled.bindTo(services.contextKeyService).set(true); IsSessionsWindowContext.bindTo(services.contextKeyService).set(true); CanGoBackContext.bindTo(services.contextKeyService).set(true); @@ -125,7 +150,7 @@ export function createSessionsWorkbenchFixture(context: ComponentFixtureContext, instantiationService.stub(ISessionsService, sessionsService); instantiationService.stub(ISessionsChatBackgroundService, new class extends mock() { override readonly onDidChangeBackground = Event.None; - override getBackground() { return undefined; } + override getBackground() { return options.chatBackground; } }()); instantiationService.stub(IHostService, new class extends mock() { override readonly onDidChangeFocus = Event.None; @@ -191,6 +216,12 @@ export function createSessionsWorkbenchFixture(context: ComponentFixtureContext, titlebar.create($('.part.titlebar')); const grid = disposableStore.add(new SerializableGrid(part, { proportionalLayout: false })); grid.addView(titlebar, titlebar.minimumHeight, part, Direction.Up); + if (options.bottomPanel) { + grid.addView(options.bottomPanel, options.bottomPanelHeight ?? 240, part, Direction.Down); + } + if (options.sidePane) { + grid.addView(options.sidePane, options.sidePaneWidth ?? 360, part, Direction.Right); + } append(container, grid.element); return { ...services, part, grid, layout: () => grid.layout(width - AGENTS_FLOATING_PANEL_GAP, height - AGENTS_FLOATING_PANEL_GAP) }; } diff --git a/src/vs/sessions/contrib/modernUI/test/browser/frostedGlass.test.ts b/src/vs/sessions/contrib/modernUI/test/browser/frostedGlass.test.ts index f862a036ec6a02..f84ff9b3f434df 100644 --- a/src/vs/sessions/contrib/modernUI/test/browser/frostedGlass.test.ts +++ b/src/vs/sessions/contrib/modernUI/test/browser/frostedGlass.test.ts @@ -87,20 +87,22 @@ suite('Agents frosted glass styles', () => { assert.deepStrictEqual(state(), before); }); - test('keeps sticky picker headings and the automation titlebar solid', () => { + test('keeps sticky picker headings solid while the automation titlebar shares the dialog glass', () => { const { root, quickInput, dialog } = createOverlays(); dialog.classList.add('automation-dialog'); const titlebar = append(dialog, 'automation-titlebar'); const sticky = append(quickInput, 'monaco-tree-sticky-container'); sticky.style.backgroundColor = 'var(--vscode-quickInput-background)'; - const original = [titlebar, sticky].map(element => readColor(element).rgba); + const originalSticky = readColor(sticky).rgba; root.classList.add('modern-ui-frosted-glass'); root.style.setProperty('--modern-ui-glass-opacity', '50%'); assert.deepStrictEqual({ - headings: [titlebar, sticky].map(element => readColor(element).rgba), + titlebar: readColor(titlebar).rgba, + sticky: readColor(sticky).rgba, dialogTint: Math.round(readColor(dialog, '::before').rgba.a * 100), }, { - headings: original, + titlebar: new RGBA(0, 0, 0, 0), + sticky: originalSticky, dialogTint: supportsGlass(root) ? 50 : 0, }); }); diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostPermissionPickerDelegate.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostPermissionPickerDelegate.ts index 0c2f99acf9fd4a..95627edf95759d 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostPermissionPickerDelegate.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostPermissionPickerDelegate.ts @@ -18,7 +18,7 @@ import { narrowClaudePermissionMode } from '../../../../../platform/agentHost/co import { narrowCodexPermissionsPreset } from '../../../../../platform/agentHost/common/codexSessionConfigKeys.js'; import { SessionConfigPropertySchema } from '../../../../../platform/agentHost/common/state/protocol/commands.js'; import { ChatConfiguration, ChatPermissionLevel, isChatPermissionLevel } from '../../../../../workbench/contrib/chat/common/constants.js'; -import { isAutoApprovePolicyRestricted } from '../../../../../workbench/contrib/chat/common/agentHostConfigPolicy.js'; +import { disableGlobalAutoApproveForPermissionSelection, isAutoApprovePolicyRestricted } from '../../../../../workbench/contrib/chat/common/agentHostConfigPolicy.js'; import { IPermissionLevelMeta, IPermissionPickerDelegate } from '../../copilotChatSessions/browser/permissionPicker.js'; import { getSessionConfigProvider, IAgentHostSessionsProvider, isAgentHostProvider, LOCAL_AGENT_HOST_PROVIDER_ID } from '../../../../common/agentHostSessionsProvider.js'; import { ISessionConfigProvider, ISessionsProvider } from '../../../../services/sessions/common/sessionsProvider.js'; @@ -274,6 +274,7 @@ export class AgentHostPermissionPickerDelegate extends Disposable implements IPe if (!approvalProperty || value === undefined || !isSessionConfigWritable(approvalProperty.schema, provider.getCreateSessionConfig(session.sessionId) !== undefined)) { throw new Error('Approval configuration is unavailable for this session'); } + await disableGlobalAutoApproveForPermissionSelection(this._configurationService, level); const operation = provider.setSessionConfigValue(session.sessionId, approvalProperty.key, value); provider.trackSessionConfigOperation?.(session.sessionId, operation); await operation.catch(onUnexpectedError); 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 26e5421f926a5d..2a98000db2b434 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 @@ -40,7 +40,7 @@ import { AgentHostCustomTerminalToolEnabledSettingId } from '../../../../../../. import { SessionConfigKey } from '../../../../../../../platform/agentHost/common/sessionConfigKeys.js'; import type { ISessionSandboxPolicy } from '../../../../../../../platform/agentHost/common/meta/agentSandboxPolicyMeta.js'; import type { RootConfigState } from '../../../../../../../platform/agentHost/common/state/protocol/state.js'; -import { ChatPermissionLevel } from '../../../../../../../workbench/contrib/chat/common/constants.js'; +import { ChatConfiguration, ChatPermissionLevel } from '../../../../../../../workbench/contrib/chat/common/constants.js'; import { AgentHostPermissionPickerDelegate, isWellKnownAutoApproveSchema, isWellKnownClaudePermissionModeSchema, isWellKnownModeSchema, isWellKnownModeValue } from '../../../browser/agentHostPermissionPickerDelegate.js'; import { getPermissionLevelMeta } from '../../../../copilotChatSessions/browser/permissionPicker.js'; import { IAgentHostSessionsProvider } from '../../../../../../common/agentHostSessionsProvider.js'; @@ -283,6 +283,25 @@ suite('AgentHostPermissionPickerDelegate', () => { }); }); + test('turns off global auto approve before selecting lower permissions', async () => { + const configuration = new class extends TestConfigurationService { + override updateValue(key: string, value: unknown): Promise { + return this.setUserConfiguration(key, value); + } + }({ [ChatConfiguration.GlobalAutoApprove]: true }); + const { delegate, provider } = setup(store, makeActiveSession('copilot'), 'autoApprove', undefined, undefined, configuration); + + await delegate.setPermissionLevel(ChatPermissionLevel.Default); + + assert.deepStrictEqual({ + globalAutoApprove: configuration.getValue(ChatConfiguration.GlobalAutoApprove), + writes: provider.setCalls, + }, { + globalAutoApprove: false, + writes: [[SESSION_ID, SessionConfigKey.AutoApprove, 'default']], + }); + }); + test('Copilot approval schema defaults are shown without inventing VS approval defaults', () => { const { delegate, provider } = setup(store, makeActiveSession('copilot')); provider.config = { diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/browserTunnelAgentHostService.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/browserTunnelAgentHostService.ts index fe70653c770e9d..0ed0a50ff69bef 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/browserTunnelAgentHostService.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/browserTunnelAgentHostService.ts @@ -54,6 +54,8 @@ import { type IDevTunnelsWeb, type IDevTunnelsWebManagementClient, type IDevTunn import { TunnelAgentHostStorage } from './tunnelAgentHostStorage.js'; import { MALFORMED_FRAMES_FORCE_CLOSE_THRESHOLD, MALFORMED_FRAMES_LOG_CAP } from '../../../../../platform/agentHost/common/transportConstants.js'; import { traceConnectionOperation, type ConnectionDiagnosticObserver } from '../../../../../platform/agentHost/common/connectionDiagnostics.js'; +import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; +import { createTunnelServiceCorrelation, tunnelServiceHeaders } from '../../../../../platform/remoteTunnel/common/tunnelServiceHeaders.js'; const LOG_PREFIX = '[BrowserTunnelAgentHost]'; @@ -141,12 +143,13 @@ class BrowserTunnelConnectionFactory extends Disposable implements IRemoteAgentH export class BrowserTunnelRelayClientFactory implements ITunnelRelayClientFactory { constructor( private readonly _loadDevTunnelsWeb: () => Promise, + private readonly _createHeaders: () => Record, ) { } async getTunnel(tunnelId: string, clusterId: string, authProvider: 'github' | 'microsoft', token: string): Promise { const devTunnels = await this._loadDevTunnelsWeb(); - const managementClient = createManagementClient(devTunnels, token, authProvider); + const managementClient = createManagementClient(devTunnels, token, authProvider, this._createHeaders()); const tunnel = await managementClient.getTunnel({ tunnelId, clusterId }, { includePorts: true, tokenScopes: ['connect'], @@ -246,6 +249,7 @@ export class BrowserTunnelAgentHostService extends Disposable implements ITunnel @IStorageService private readonly _storageService: IStorageService, @IRemoteAgentHostLocationPreferenceService private readonly _locationPreferenceService: IRemoteAgentHostLocationPreferenceService, @IDialogService private readonly _dialogService: IDialogService, + @ITelemetryService private readonly _telemetryService: ITelemetryService, options: IBrowserTunnelAgentHostServiceOptions = {}, ) { super(); @@ -259,7 +263,7 @@ export class BrowserTunnelAgentHostService extends Disposable implements ITunnel const load = options.loadDevTunnelsWeb ?? loadDevTunnelsWeb; this._loadDevTunnelsWeb = load; this._connector = options.connector ?? this._register(new TunnelAgentHostConnector( - new BrowserTunnelRelayClientFactory(load), + new BrowserTunnelRelayClientFactory(load, () => tunnelServiceHeaders(createTunnelServiceCorrelation(this._telemetryService, 'connect'))), new BrowserTunnelSocketFactory(), this._logService, )); @@ -284,7 +288,7 @@ export class BrowserTunnelAgentHostService extends Disposable implements ITunnel try { const sdk = await traceConnectionOperation(options?.onDiagnostic, 'discovery.sdk', () => this._loadDevTunnelsWeb()); - const managementClient = createManagementClient(sdk, auth.token, auth.provider); + const managementClient = createManagementClient(sdk, auth.token, auth.provider, tunnelServiceHeaders(createTunnelServiceCorrelation(this._telemetryService, 'list'))); const tunnels = await traceConnectionOperation(options?.onDiagnostic, 'discovery.enumeration', () => managementClient.listTunnels(undefined, undefined, { labels: [TUNNEL_LAUNCHER_LABEL], requireAllLabels: true, @@ -435,7 +439,7 @@ export class BrowserTunnelAgentHostService extends Disposable implements ITunnel if (!auth) { throw new Error('No authentication available'); } - const managementClient = createManagementClient(await this._loadDevTunnelsWeb(), auth.token, auth.provider); + const managementClient = createManagementClient(await this._loadDevTunnelsWeb(), auth.token, auth.provider, tunnelServiceHeaders(createTunnelServiceCorrelation(this._telemetryService, 'delete'))); await managementClient.deleteTunnel(tunnel); this.removeCachedTunnel(tunnel.tunnelId); } @@ -686,11 +690,14 @@ function createManagementClient( devTunnels: IDevTunnelsWeb, token: string, authProvider: 'github' | 'microsoft', + headers: Record, ): IDevTunnelsWebManagementClient { const authorization = authProvider === 'github' ? `github ${token}` : `Bearer ${token}`; - return new devTunnels.TunnelManagementHttpClient( + const client = new devTunnels.TunnelManagementHttpClient( 'vscode-sessions', devTunnels.ManagementApiVersions.Version20230927preview, async () => authorization, ); + client.additionalRequestHeaders = headers; + return client; } diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/devTunnelsWebLoader.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/devTunnelsWebLoader.ts index 4a11f016697332..663f2d3898ac51 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/devTunnelsWebLoader.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/devTunnelsWebLoader.ts @@ -27,6 +27,7 @@ export interface IDevTunnelsWebRequestOptions { /** Browser-compatible subset of the Dev Tunnels management client. */ export interface IDevTunnelsWebManagementClient { + additionalRequestHeaders?: Record; listTunnels( clusterId: string | undefined, domain: string | undefined, diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/webTunnelAgentHostService.contribution.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/webTunnelAgentHostService.contribution.ts index 72d5d98487cc28..346567f940dc3b 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/webTunnelAgentHostService.contribution.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/webTunnelAgentHostService.contribution.ts @@ -15,6 +15,7 @@ import { IInstantiationService } from '../../../../../platform/instantiation/com import { ILogService } from '../../../../../platform/log/common/log.js'; import { IProductService } from '../../../../../platform/product/common/productService.js'; import { IStorageService } from '../../../../../platform/storage/common/storage.js'; +import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { IAuthenticationService } from '../../../../../workbench/services/authentication/common/authentication.js'; import { IBrowserWorkbenchEnvironmentService } from '../../../../../workbench/services/environment/browser/environmentService.js'; import { BrowserTunnelAgentHostService } from './browserTunnelAgentHostService.js'; @@ -40,6 +41,7 @@ class BrowserTunnelAgentHostServiceSelector extends Disposable implements ITunne @IStorageService storageService: IStorageService, @IRemoteAgentHostLocationPreferenceService locationPreferenceService: IRemoteAgentHostLocationPreferenceService, @IDialogService dialogService: IDialogService, + @ITelemetryService telemetryService: ITelemetryService, ) { super(); this._delegate = this._register(environmentService.options?.tunnelDiscoveryProvider @@ -62,6 +64,7 @@ class BrowserTunnelAgentHostServiceSelector extends Disposable implements ITunne storageService, locationPreferenceService, dialogService, + telemetryService, )); this.onDidChangeTunnels = this._delegate.onDidChangeTunnels; } diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/browserTunnelAgentHostService.test.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/browserTunnelAgentHostService.test.ts index 0ae27d56950d19..9413502c31397e 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/browserTunnelAgentHostService.test.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/browserTunnelAgentHostService.test.ts @@ -21,6 +21,8 @@ import { TestInstantiationService } from '../../../../../../platform/instantiati import { NullLogService } from '../../../../../../platform/log/common/log.js'; import { type IProductService } from '../../../../../../platform/product/common/productService.js'; import { InMemoryStorageService } from '../../../../../../platform/storage/common/storage.js'; +import { NullTelemetryService } from '../../../../../../platform/telemetry/common/telemetryUtils.js'; +import { tunnelServiceHeaders } from '../../../../../../platform/remoteTunnel/common/tunnelServiceHeaders.js'; import { type IDiscoveredTunnel, type ITunnelDiscoveryProvider } from '../../../../../../workbench/browser/web.api.js'; import { IBrowserWorkbenchEnvironmentService } from '../../../../../../workbench/services/environment/browser/environmentService.js'; import { type AuthenticationSession, IAuthenticationService } from '../../../../../../workbench/services/authentication/common/authentication.js'; @@ -191,6 +193,7 @@ function createBrowserTunnelService( override getPreference() { return undefined; } }(), connectionOptions?.dialogService ?? new class extends mock() { }(), + NullTelemetryService, { connector: connectionOptions?.connector ?? new FakeConnector(undefined), loadDevTunnelsWeb: async () => bundle }, )); } @@ -456,6 +459,7 @@ suite('BrowserTunnelAgentHostService', () => { const requests: IDevTunnelsWebRequestOptions[] = []; let authorization = ''; let relay: FakeRelayClient | undefined; + let managementClient: IDevTunnelsWebManagementClient | undefined; class FakeManagementClient implements IDevTunnelsWebManagementClient { private readonly _userTokenCallback: () => Promise; @@ -485,6 +489,7 @@ suite('BrowserTunnelAgentHostService', () => { constructor(_managementClient: IDevTunnelsWebManagementClient) { relay = this; + managementClient = _managementClient; } connect(_tunnel: IDevTunnelsWebTunnel): Promise { @@ -509,14 +514,16 @@ suite('BrowserTunnelAgentHostService', () => { TunnelRelayTunnelClient: FakeRelayClient, TunnelAccessScopes: {}, }; - const session = await new BrowserTunnelRelayClientFactory(async () => bundle).getTunnel('tunnel-id', 'cluster-id', 'github', 'token'); + const headers = tunnelServiceHeaders({ sessionId: 'session', operationId: 'operation' }); + const session = await new BrowserTunnelRelayClientFactory(async () => bundle, () => headers).getTunnel('tunnel-id', 'cluster-id', 'github', 'token'); await session!.createRelayClient(); - assert.deepStrictEqual({ authorization, requests, acceptsLocal: relay?.acceptLocalConnectionsForForwardedPorts, endpoints: relay?.endpoints }, { + assert.deepStrictEqual({ authorization, requests, acceptsLocal: relay?.acceptLocalConnectionsForForwardedPorts, endpoints: relay?.endpoints, headers: managementClient?.additionalRequestHeaders }, { authorization: 'github token', requests: [{ includePorts: true, tokenScopes: ['connect'] }], acceptsLocal: false, endpoints: { relay: 'endpoint' }, + headers, }); }); }); diff --git a/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css b/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css index 064d99de4cd4d8..7a6558f26b6513 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css +++ b/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css @@ -257,7 +257,7 @@ } } - .monaco-list-row:is(:hover, .hovered), + .monaco-list-row:is(:hover, .hovered):not(.external-application-badge-hovered), .monaco-list-row.focused:not(.selected), .monaco-list:focus-within .monaco-list-row.focused, .session-item.archive-onboarding { @@ -265,6 +265,10 @@ display: block; } + .session-external-application-badge-title { + display: none; + } + .session-title { margin-right: 8px; } @@ -559,11 +563,49 @@ .session-title { /* push other items to the end */ flex: 1; + min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } + .session-external-application-badge { + display: flex; + align-items: center; + flex-shrink: 1; + min-width: 0; + max-width: 50%; + box-sizing: border-box; + padding: 0 var(--vscode-spacing-size60); + border: var(--vscode-strokeThickness) solid var(--vscode-widget-border, var(--vscode-contrastBorder, transparent)); + border-radius: var(--vscode-cornerRadius-circle); + background-color: var(--vscode-list-hoverBackground); + color: var(--vscode-descriptionForeground); + font-size: var(--vscode-fontSize-label2); + + .session-external-application-badge-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } + + .session-external-application-badge-title { + display: none; + height: 17px; + max-width: 40%; + margin-left: var(--vscode-spacing-size60); + + &.visible { + display: flex; + } + } + + .session-external-application-badge-details { + height: 15px; + } + .session-description { /* size to content so it doesn't push the timestamp to the end of the row */ overflow: hidden; @@ -1586,6 +1628,10 @@ height: auto; } + .monaco-list-row .session-external-application-badge-title { + display: none; + } + /* Center the codicon inside the 44x44 tap target. The base * `.action-label` is a flex container that defaults to start * alignment, so the icon sticks to the top-left of the square. */ diff --git a/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts b/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts index 35ba27a81e49a8..a646c63f1e03d5 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts @@ -25,7 +25,7 @@ import { SessionsMouseNavigationContribution } from './sessionsMouseNavigation.j import './sessionDetailsAction.js'; import { SESSIONS_MARK_AS_DONE_CONFETTI_SETTING } from '../../../../platform/chat/common/sessionArchiveActions.js'; import { SessionsWindowNotifier } from './sessionsWindowNotifier.js'; -import { SESSIONS_CHAT_TABS_DEFAULT, SESSIONS_CHAT_TABS_SETTING, SESSIONS_LIST_GROUP_EXTERNAL_SESSIONS_SETTING, SESSIONS_SIDEBAR_SEPARATE_NAVIGATION_SETTING, SESSIONS_LIST_REARRANGE_TREATMENT, SessionsChatTabsMode, USE_WORKTREE_SETTING, USE_WORKTREE_SETTING_TREATMENT } from '../../../common/sessionConfig.js'; +import { ExternalSessionApplicationBadgeMode, SESSIONS_CHAT_TABS_DEFAULT, SESSIONS_CHAT_TABS_SETTING, SESSIONS_LIST_EXTERNAL_APPLICATION_BADGE_SETTING, SESSIONS_LIST_EXTERNAL_APPLICATION_BADGE_SHOW_FROM_SETTING, SESSIONS_LIST_GROUP_EXTERNAL_SESSIONS_SETTING, SESSIONS_SIDEBAR_SEPARATE_NAVIGATION_SETTING, SESSIONS_LIST_REARRANGE_TREATMENT, SessionsChatTabsMode, USE_WORKTREE_SETTING, USE_WORKTREE_SETTING_TREATMENT } from '../../../common/sessionConfig.js'; const agentSessionsViewIcon = registerIcon('chat-sessions-icon', Codicon.commentDiscussionSparkle, localize('agentSessionsViewIcon', 'Icon for Agent Sessions View')); const AGENT_SESSIONS_VIEW_TITLE = localize2('agentSessions.view.label', "Sessions"); @@ -82,6 +82,30 @@ export const sessionsConfiguration = { default: false, experiment: { mode: 'auto' } }, + [SESSIONS_LIST_EXTERNAL_APPLICATION_BADGE_SETTING]: { + type: 'string', + tags: ['experimental'], + enum: [ + ExternalSessionApplicationBadgeMode.Off, + ExternalSessionApplicationBadgeMode.Title, + ExternalSessionApplicationBadgeMode.Details, + ], + enumDescriptions: [ + localize('sessions.list.externalApplicationBadge.off', "Do not show the creating application."), + localize('sessions.list.externalApplicationBadge.title', "Show the creating application next to the session title."), + localize('sessions.list.externalApplicationBadge.details', "Show the creating application first in the session details."), + ], + description: localize('sessions.list.externalApplicationBadge', "Controls where external sessions created in another application show the creating application."), + default: ExternalSessionApplicationBadgeMode.Off, + experiment: { mode: 'auto' } + }, + [SESSIONS_LIST_EXTERNAL_APPLICATION_BADGE_SHOW_FROM_SETTING]: { + type: 'boolean', + tags: ['experimental', 'advanced'], + description: localize('sessions.list.externalApplicationBadge.showFrom', "Controls whether external session application badges include the word 'From'."), + default: true, + experiment: { mode: 'auto' } + }, [SESSIONS_LIST_SHOW_EMPTY_DEFAULT_GROUPS_SETTING]: { type: 'boolean', tags: ['preview'], diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts index ade0b8a764e320..43c75389e9c627 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts @@ -37,7 +37,7 @@ import { IContextKey, IContextKeyService, RawContextKey } from '../../../../../p import { MarshalledId } from '../../../../../base/common/marshallingIds.js'; import { SessionProviderIdContext, SessionSupportsDeleteContext, SessionSupportsMultipleChatsContext, SessionSupportsRenameContext, SessionTypeContext, IsPhoneLayoutContext, IsQuickChatSessionContext, SessionIsArchivedContext, SessionIsReadContext, SessionHasPullRequestContext, SessionItemIsMultiSelectionContext } from '../../../../common/contextkeys.js'; import { ARCHIVE_SESSION_COMMAND_ID, RENAME_CHAT_COMMAND_ID, RENAME_SESSION_COMMAND_ID } from '../../../../common/sessionCommands.js'; -import { SESSIONS_LIST_GROUP_EXTERNAL_SESSIONS_SETTING } from '../../../../common/sessionConfig.js'; +import { ExternalSessionApplicationBadgeMode, SESSIONS_LIST_EXTERNAL_APPLICATION_BADGE_SETTING, SESSIONS_LIST_EXTERNAL_APPLICATION_BADGE_SHOW_FROM_SETTING, SESSIONS_LIST_GROUP_EXTERNAL_SESSIONS_SETTING } from '../../../../common/sessionConfig.js'; import { IContextMenuService, IContextViewService } from '../../../../../platform/contextview/browser/contextView.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js'; @@ -47,7 +47,7 @@ import { IStyleOverride, defaultButtonStyles, defaultFindWidgetStyles, defaultIn import { chartsOrange } from '../../../../../platform/theme/common/colors/chartsColors.js'; import { asCssVariable } from '../../../../../platform/theme/common/colorUtils.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; -import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { IConfigurationService, isConfigured } from '../../../../../platform/configuration/common/configuration.js'; import { observableConfigValue } from '../../../../../platform/observable/common/platformObservableUtils.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { logSettingExperimentTrigger } from '../../../../../platform/telemetry/common/experimentTrigger.js'; @@ -1211,12 +1211,16 @@ interface ISessionOnboardingTarget { readonly archiveAction: boolean; } +const SESSION_EXTERNAL_APPLICATION_BADGE_HOVERED_CLASS = 'external-application-badge-hovered'; + interface ISessionItemTemplate { readonly container: HTMLElement; readonly statusIcon: SessionStatusIcon; readonly title: HighlightedLabel; readonly titleRow: HTMLElement; readonly titleContainer: HTMLElement; + readonly externalApplicationBadge: HTMLElement; + readonly externalApplicationBadgeLabel: HTMLElement; readonly titleInputContainer: HTMLElement; readonly compactHoverDescription: HTMLElement; readonly titleToolbar: MenuWorkbenchToolBar | undefined; @@ -1315,6 +1319,10 @@ class SessionItemRenderer implements ITreeRenderer>; onboardingTarget?: IObservable; isRenderedInExternalSection?: (session: ISession) => boolean; + externalApplicationBadgeMode?: IObservable; + externalApplicationBadgeShowFrom?: IObservable; + reportExternalApplicationBadgeModeExperimentTrigger?: () => void; + reportExternalApplicationBadgeShowFromExperimentTrigger?: () => void; }, private readonly approvalModel: AgentSessionApprovalModel | undefined, private readonly ciFixModel: ISessionCIFixModel | undefined, @@ -1348,6 +1356,15 @@ class SessionItemRenderer implements ITreeRenderer { + externalApplicationBadge.closest('.monaco-list-row')?.classList.add(SESSION_EXTERNAL_APPLICATION_BADGE_HOVERED_CLASS); + })); + disposables.add(DOM.addDisposableListener(externalApplicationBadge, DOM.EventType.MOUSE_LEAVE, () => { + externalApplicationBadge.closest('.monaco-list-row')?.classList.remove(SESSION_EXTERNAL_APPLICATION_BADGE_HOVERED_CLASS); + })); const titleInputContainer = DOM.append(titleRow, $('.session-title-input.session-inline-rename-input')); for (const eventType of ['pointerdown', 'pointerup', 'click', 'dblclick'] as const) { disposables.add(DOM.addDisposableListener(titleInputContainer, eventType, e => e.stopPropagation())); @@ -1446,7 +1463,7 @@ class SessionItemRenderer implements ITreeRenderer, _index: number, template: ISessionItemTemplate): void { @@ -1459,6 +1476,7 @@ class SessionItemRenderer implements ITreeRenderer { template.renderedSession.set(undefined, undefined); @@ -1640,6 +1658,27 @@ class SessionItemRenderer implements ITreeRenderer { + const eligible = isExternalApplicationBadgeEligible(element, reader); + if (!eligible) { + template.externalApplicationBadge.classList.remove('visible'); + return; + } + this.options.reportExternalApplicationBadgeModeExperimentTrigger?.(); + const mode = this.options.externalApplicationBadgeMode?.read(reader) ?? ExternalSessionApplicationBadgeMode.Off; + if (eligible && mode !== ExternalSessionApplicationBadgeMode.Off) { + this.options.reportExternalApplicationBadgeShowFromExperimentTrigger?.(); + } + template.externalApplicationBadge.classList.toggle('visible', eligible && mode === ExternalSessionApplicationBadgeMode.Title); + const badgeLabel = getExternalApplicationBadgeLabel( + element.application.read(reader).label, + this.options.externalApplicationBadgeShowFrom?.read(reader) ?? true, + ); + template.externalApplicationBadgeLabel.textContent = badgeLabel; + if (mode === ExternalSessionApplicationBadgeMode.Title) { + reader.store.add(this.hoverService.setupDelayedHover(template.externalApplicationBadge, { content: badgeLabel }, { groupId: 'sessions-list' })); + } + })); template.elementDisposables.add(autorun(reader => { const editingSession = this.editingSession.read(reader); const editing = !!editingSession && isEqual(editingSession.resource, element.resource); @@ -1662,6 +1701,7 @@ class SessionItemRenderer implements ITreeRenderer 0) { + DOM.append(template.detailsRow, $('span.session-separator.has-separator')); + } let icon: ThemeIcon; if (isQuickChat) { icon = Codicon.commentDiscussion; @@ -2011,6 +2067,22 @@ function getWorkspaceBadgeLabel(workspace: ISessionWorkspace): string | undefine return workspace.label; } +function isExternalApplicationBadgeEligible(session: ISession, reader: IReader): boolean { + const application = session.isExternal?.read(reader) === true ? session.application?.read(reader) : undefined; + return application !== undefined && application.id !== 'vscode'; +} + +function getExternalApplicationBadgeLabel(applicationLabel: string, showFrom: boolean): string { + return showFrom ? localize('sessionExternalApplicationBadge', "From {0}", applicationLabel) : applicationLabel; +} + +function logDefaultSettingExperimentTrigger(configurationService: IConfigurationService, telemetryService: ITelemetryService, settingId: string): void { + const inspected = configurationService.inspect(settingId); + if (!isConfigured(inspected) && inspected.memoryValue === undefined && inspected.policyValue === undefined) { + logSettingExperimentTrigger(telemetryService, settingId); + } +} + //#endregion //#region Section Header Renderer @@ -2775,6 +2847,9 @@ interface ISessionsAccessibilityProviderOptions extends ICompactInputNeededPrese readonly deriveStatusFromMainChat?: boolean; readonly collapsedSessionIds?: IObservable>; readonly approvalModel?: AgentSessionApprovalModel; + readonly externalApplicationBadgeMode?: IObservable; + readonly reportExternalApplicationBadgeModeExperimentTrigger?: () => void; + readonly reportExternalApplicationBadgeShowFromExperimentTrigger?: () => void; } class SessionsAccessibilityProvider { @@ -2909,6 +2984,15 @@ class SessionsAccessibilityProvider { if (this.options?.deriveStatusFromMainChat) { label = localize('sessionItemStatusAria', "{0}, {1}", label, getSessionConversationStatusAriaLabel(status)); } + const eligible = isExternalApplicationBadgeEligible(element, reader); + if (eligible) { + this.options?.reportExternalApplicationBadgeModeExperimentTrigger?.(); + } + const badgeMode = this.options?.externalApplicationBadgeMode?.read(reader) ?? ExternalSessionApplicationBadgeMode.Off; + if (eligible && badgeMode !== ExternalSessionApplicationBadgeMode.Off) { + this.options?.reportExternalApplicationBadgeShowFromExperimentTrigger?.(); + label = localize('sessionItemExternalAria', "{0}, created in {1}", label, element.application.read(reader).label); + } if (!element.isArchived.read(reader) && !getSessionRowIsRead(element, reader, !!this.options?.deriveStatusFromMainChat)) { label = localize('sessionItemUnreadAria', "{0}, unread", label); } @@ -3698,6 +3782,10 @@ export class SessionsList extends Disposable implements ISessionsList { // TEMPORARY (#320480): see the note on the `IAgentSessionsService` import. const agentSessionsService = instantiationService.invokeFunction(accessor => accessor.get(IAgentSessionsService)); const voicePlaybackService = instantiationService.invokeFunction(accessor => accessor.get(IVoicePlaybackService)); + const externalApplicationBadgeMode = observableConfigValue(SESSIONS_LIST_EXTERNAL_APPLICATION_BADGE_SETTING, ExternalSessionApplicationBadgeMode.Off, this.configurationService); + const externalApplicationBadgeShowFrom = observableConfigValue(SESSIONS_LIST_EXTERNAL_APPLICATION_BADGE_SHOW_FROM_SETTING, true, this.configurationService); + const reportExternalApplicationBadgeModeExperimentTrigger = () => logDefaultSettingExperimentTrigger(this.configurationService, this.telemetryService, SESSIONS_LIST_EXTERNAL_APPLICATION_BADGE_SETTING); + const reportExternalApplicationBadgeShowFromExperimentTrigger = () => logDefaultSettingExperimentTrigger(this.configurationService, this.telemetryService, SESSIONS_LIST_EXTERNAL_APPLICATION_BADGE_SHOW_FROM_SETTING); const sessionRenderer = new SessionItemRenderer( { grouping: this.options.grouping, @@ -3723,6 +3811,10 @@ export class SessionsList extends Disposable implements ISessionsList { activeGuideSessionIds: this.activeGuideSessionIds, deriveStatusFromMainChat: true, activeSession: this._sessionsService.activeSession, + externalApplicationBadgeMode, + externalApplicationBadgeShowFrom, + reportExternalApplicationBadgeModeExperimentTrigger, + reportExternalApplicationBadgeShowFromExperimentTrigger, collapsedSessionIds: this.collapsedSessionIds, onboardingTarget: this.onboardingTarget, }, @@ -3915,6 +4007,9 @@ export class SessionsList extends Disposable implements ISessionsList { collapsedSessionIds: this.collapsedSessionIds, showUnreadInCollapsedSections, sessionsWithFailingCI, + externalApplicationBadgeMode, + reportExternalApplicationBadgeModeExperimentTrigger, + reportExternalApplicationBadgeShowFromExperimentTrigger, }), dnd: this._register(new SessionsListDragAndDrop({ isReorderable: session => this.isReorderable(session), @@ -6562,6 +6657,7 @@ export class SessionsFlatList extends Disposable { @IOpenerService openerService: IOpenerService, @ILabelService labelService: ILabelService, @IPreferencesService preferencesService: IPreferencesService, + @ITelemetryService telemetryService: ITelemetryService, ) { super(); @@ -6576,6 +6672,10 @@ export class SessionsFlatList extends Disposable { // the only reference. See the note on the `IAgentSessionsService` import. const agentSessionsService = instantiationService.invokeFunction(accessor => accessor.get(IAgentSessionsService)); const useCompactQuickChatRows = this.options.useCompactQuickChatRows ?? true; + const externalApplicationBadgeMode = observableConfigValue(SESSIONS_LIST_EXTERNAL_APPLICATION_BADGE_SETTING, ExternalSessionApplicationBadgeMode.Off, configurationService); + const externalApplicationBadgeShowFrom = observableConfigValue(SESSIONS_LIST_EXTERNAL_APPLICATION_BADGE_SHOW_FROM_SETTING, true, configurationService); + const reportExternalApplicationBadgeModeExperimentTrigger = () => logDefaultSettingExperimentTrigger(configurationService, telemetryService, SESSIONS_LIST_EXTERNAL_APPLICATION_BADGE_SETTING); + const reportExternalApplicationBadgeShowFromExperimentTrigger = () => logDefaultSettingExperimentTrigger(configurationService, telemetryService, SESSIONS_LIST_EXTERNAL_APPLICATION_BADGE_SHOW_FROM_SETTING); const sessionRenderer = new SessionItemRenderer( { @@ -6594,6 +6694,10 @@ export class SessionsFlatList extends Disposable { toolbarTelemetrySource: this.options.toolbarTelemetrySource ?? 'sessionsFlatList.row', inlineRename: false, handleToolbarAction: this.options.onToolbarAction, + externalApplicationBadgeMode, + externalApplicationBadgeShowFrom, + reportExternalApplicationBadgeModeExperimentTrigger, + reportExternalApplicationBadgeShowFromExperimentTrigger, }, approvalModel, this.options.ciFixModel, @@ -6625,6 +6729,9 @@ export class SessionsFlatList extends Disposable { grouping: () => SessionsGrouping.Date, isPinned: session => this._sessionsListModelService.isSessionPinned(session), includeQuickChatInAriaLabel: !useCompactQuickChatRows, + externalApplicationBadgeMode, + reportExternalApplicationBadgeModeExperimentTrigger, + reportExternalApplicationBadgeShowFromExperimentTrigger, }), identityProvider: { getId: (element: SessionListItem) => (element as ISession).resource.toString(), diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessions.contribution.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessions.contribution.test.ts index fcffd7c1d61317..cf894b685526f2 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessions.contribution.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessions.contribution.test.ts @@ -9,7 +9,7 @@ import { ConfigurationScope, Extensions as ConfigurationExtensions, IConfigurati import { Registry } from '../../../../../platform/registry/common/platform.js'; import { SESSIONS_MARK_AS_DONE_CONFETTI_SETTING } from '../../../../../platform/chat/common/sessionArchiveActions.js'; import { SESSIONS_LIST_SHOW_ARCHIVED_BY_DEFAULT_SETTING, SESSIONS_LIST_SHOW_UNREAD_IN_COLLAPSED_SECTIONS_SETTING } from '../../browser/views/sessionsList.js'; -import { SESSIONS_CHAT_TABS_DEFAULT, SESSIONS_CHAT_TABS_SETTING, SESSIONS_LIST_GROUP_EXTERNAL_SESSIONS_SETTING, SessionsChatTabsMode } from '../../../../common/sessionConfig.js'; +import { ExternalSessionApplicationBadgeMode, SESSIONS_CHAT_TABS_DEFAULT, SESSIONS_CHAT_TABS_SETTING, SESSIONS_LIST_EXTERNAL_APPLICATION_BADGE_SETTING, SESSIONS_LIST_EXTERNAL_APPLICATION_BADGE_SHOW_FROM_SETTING, SESSIONS_LIST_GROUP_EXTERNAL_SESSIONS_SETTING, SessionsChatTabsMode } from '../../../../common/sessionConfig.js'; import '../../browser/sessions.contribution.js'; @@ -19,6 +19,8 @@ const collapsedSectionStatusProperty = configurationRegistry.getConfigurationPro const showChatTabsProperty = configurationRegistry.getConfigurationProperties()[SESSIONS_CHAT_TABS_SETTING]; const markAsDoneConfettiProperty = configurationRegistry.getConfigurationProperties()[SESSIONS_MARK_AS_DONE_CONFETTI_SETTING]; const groupExternalSessionsProperty = configurationRegistry.getConfigurationProperties()[SESSIONS_LIST_GROUP_EXTERNAL_SESSIONS_SETTING]; +const externalApplicationBadgeProperty = configurationRegistry.getConfigurationProperties()[SESSIONS_LIST_EXTERNAL_APPLICATION_BADGE_SETTING]; +const externalApplicationBadgeShowFromProperty = configurationRegistry.getConfigurationProperties()[SESSIONS_LIST_EXTERNAL_APPLICATION_BADGE_SHOW_FROM_SETTING]; const showArchivedByDefaultProperty = configurationRegistry.getConfigurationProperties()[SESSIONS_LIST_SHOW_ARCHIVED_BY_DEFAULT_SETTING]; suite('Sessions Contribution', () => { @@ -36,6 +38,38 @@ suite('Sessions Contribution', () => { }); }); + test('disables external application badges by default with automatic experiments', () => { + assert.deepStrictEqual({ + type: externalApplicationBadgeProperty.type, + enum: externalApplicationBadgeProperty.enum, + default: externalApplicationBadgeProperty.default, + experiment: externalApplicationBadgeProperty.experiment, + }, { + type: 'string', + enum: [ + ExternalSessionApplicationBadgeMode.Off, + ExternalSessionApplicationBadgeMode.Title, + ExternalSessionApplicationBadgeMode.Details, + ], + default: ExternalSessionApplicationBadgeMode.Off, + experiment: { mode: 'auto' }, + }); + }); + + test('shows the external application badge prefix by default with an advanced automatic experiment', () => { + assert.deepStrictEqual({ + type: externalApplicationBadgeShowFromProperty.type, + default: externalApplicationBadgeShowFromProperty.default, + tags: externalApplicationBadgeShowFromProperty.tags, + experiment: externalApplicationBadgeShowFromProperty.experiment, + }, { + type: 'boolean', + default: true, + tags: ['experimental', 'advanced', 'onExP'], + experiment: { mode: 'auto' }, + }); + }); + test('disables collapsed section status indicators by default with automatic experiments', () => { assert.deepStrictEqual({ type: collapsedSectionStatusProperty.type, diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsList.fixture.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsList.fixture.ts index 28270ddad286d4..58596bf45c4627 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsList.fixture.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsList.fixture.ts @@ -7,7 +7,7 @@ import { ChatSessionArchiveActionWording, ChatSessionArchiveActionWordingSetting import { ONBOARDING_TARGET_ATTR } from '../../../../../workbench/contrib/onboarding/browser/spotlight/onboardingTarget.js'; import { SpotlightOverlay } from '../../../../../workbench/contrib/onboarding/browser/spotlight/spotlightOverlay.js'; import { ComponentFixtureContext, defineThemedFixtureGroup } from '../../../../../workbench/test/browser/componentFixtures/fixtureUtils.js'; -import { SESSIONS_LIST_GROUP_EXTERNAL_SESSIONS_SETTING } from '../../../../common/sessionConfig.js'; +import { ExternalSessionApplicationBadgeMode, SESSIONS_LIST_EXTERNAL_APPLICATION_BADGE_SETTING, SESSIONS_LIST_EXTERNAL_APPLICATION_BADGE_SHOW_FROM_SETTING, SESSIONS_LIST_GROUP_EXTERNAL_SESSIONS_SETTING } from '../../../../common/sessionConfig.js'; import { SessionStatus } from '../../../../services/sessions/common/session.js'; import { createSessionArchiveTour } from '../../../onboardingTours/browser/tours/sessionArchiveTour.js'; import { SESSIONS_LIST_SHOW_UNREAD_IN_COLLAPSED_SECTIONS_SETTING, SessionsGrouping } from '../../browser/views/sessionsList.js'; @@ -79,9 +79,9 @@ const GROUPED_SESSIONS: readonly ISessionsListFixtureSession[] = [ ]; const EXTERNAL_SESSIONS: readonly ISessionsListFixtureSession[] = [ { id: 'regular', title: 'Update onboarding copy', workspace: 'vscode', minutesAgo: 10 }, - { id: 'external-repo', title: 'Hello world test', workspace: 'vscode', minutesAgo: 15, isExternal: true, supportsMultipleChats: true }, - { id: 'external-directory', title: 'Directory-only SDK session', workspace: 'scratch', minutesAgo: 30, isExternal: true, supportsMultipleChats: true }, - { id: 'done', title: 'Completed external session', workspace: 'vscode', minutesAgo: 60, isExternal: true, isArchived: true }, + { id: 'external-repo', title: 'Add retry diagnostics', workspace: 'vscode', application: 'github/cli', minutesAgo: 15, isExternal: true, supportsMultipleChats: true }, + { id: 'external-directory', title: 'Investigate authentication report', workspace: 'vscode', application: 'slack', minutesAgo: 30, isExternal: true, supportsMultipleChats: true }, + { id: 'done', title: 'Prepare release notes', workspace: 'vscode', application: 'github/autopilot', minutesAgo: 60, isExternal: true, isArchived: true }, ]; const COLLAPSED_SECTION_SESSIONS: readonly ISessionsListFixtureSession[] = [ { id: 'grouped-unread', title: 'Unread session in the group only', workspace: 'vscode', minutesAgo: 12, isRead: false }, @@ -192,6 +192,88 @@ export default defineThemedFixtureGroup({ path: 'sessions/' }, { view: { height: 340 }, settings: { [SESSIONS_LIST_GROUP_EXTERNAL_SESSIONS_SETTING]: false, [ChatSessionArchiveActionWordingSettingId]: ChatSessionArchiveActionWording.MarkAsDone }, }, { labels: { kind: 'screenshot' } }), + SessionsList_ExternalApplicationBadge: defineSessionsListFixture({ + sessions: EXTERNAL_SESSIONS.filter(session => !session.isArchived), + view: { height: 270 }, + settings: { + [SESSIONS_LIST_GROUP_EXTERNAL_SESSIONS_SETTING]: false, + [SESSIONS_LIST_EXTERNAL_APPLICATION_BADGE_SETTING]: ExternalSessionApplicationBadgeMode.Title, + }, + }, { + labels: { kind: 'screenshot' }, + additionalThemes: ['darkHighContrast', 'lightHighContrast'], + expectedVisualDescriptions: ['External sessions are mixed with the regular session and show a rounded "From [application]" badge after the title.'], + }), + SessionsList_ExternalApplicationBadgeDetails: defineSessionsListFixture({ + sessions: EXTERNAL_SESSIONS.filter(session => !session.isArchived), + view: { height: 270 }, + settings: { + [SESSIONS_LIST_GROUP_EXTERNAL_SESSIONS_SETTING]: false, + [SESSIONS_LIST_EXTERNAL_APPLICATION_BADGE_SETTING]: ExternalSessionApplicationBadgeMode.Details, + }, + }, { + labels: { kind: 'screenshot' }, + expectedVisualDescriptions: ['External sessions are mixed with the regular session and show a rounded "From [application]" badge as the first item in the details row.'], + }), + SessionsList_ExternalApplicationBadgeWithoutFrom: defineSessionsListFixture({ + sessions: EXTERNAL_SESSIONS.filter(session => !session.isArchived), + view: { height: 270 }, + settings: { + [SESSIONS_LIST_GROUP_EXTERNAL_SESSIONS_SETTING]: false, + [SESSIONS_LIST_EXTERNAL_APPLICATION_BADGE_SETTING]: ExternalSessionApplicationBadgeMode.Title, + [SESSIONS_LIST_EXTERNAL_APPLICATION_BADGE_SHOW_FROM_SETTING]: false, + }, + }, { + labels: { kind: 'screenshot' }, + expectedVisualDescriptions: ['External sessions show a rounded application badge after the title without the "From" prefix.'], + }), + SessionsList_ExternalApplicationBadgeLongLabel: defineSessionsListFixture({ + sessions: [ + EXTERNAL_SESSIONS[0], + { + ...EXTERNAL_SESSIONS[1], + application: 'external_tool_with_an_exceptionally_long_application_name', + }, + ], + view: { height: 220, width: 260 }, + settings: { + [SESSIONS_LIST_GROUP_EXTERNAL_SESSIONS_SETTING]: false, + [SESSIONS_LIST_EXTERNAL_APPLICATION_BADGE_SETTING]: ExternalSessionApplicationBadgeMode.Title, + }, + }, { + labels: { kind: 'screenshot' }, + expectedVisualDescriptions: ['The long external application badge is capped and ellipsized so the session title remains visible in the narrow list.'], + }), + SessionsList_ExternalApplicationBadgeHovered: defineSessionsListFixture({ + sessions: EXTERNAL_SESSIONS.filter(session => !session.isArchived), + view: { height: 270 }, + settings: { + [SESSIONS_LIST_GROUP_EXTERNAL_SESSIONS_SETTING]: false, + [SESSIONS_LIST_EXTERNAL_APPLICATION_BADGE_SETTING]: ExternalSessionApplicationBadgeMode.Title, + }, + }, { + labels: { kind: 'screenshot' }, + afterRender: fixture => { + const row = fixture.list.getItemRow(fixture.resolve({ session: 'external-repo' })); + if (!row) { + throw new Error('Expected the external session row to render.'); + } + row.classList.add('hovered'); + }, + expectedVisualDescriptions: ['The hovered external session shows its row actions and hides its application badge without changing the row content height. The other external session retains its badge.'], + }), + SessionsList_ExternalApplicationBadgeFocused: defineSessionsListFixture({ + sessions: EXTERNAL_SESSIONS.filter(session => !session.isArchived), + view: { height: 270 }, + settings: { + [SESSIONS_LIST_GROUP_EXTERNAL_SESSIONS_SETTING]: false, + [SESSIONS_LIST_EXTERNAL_APPLICATION_BADGE_SETTING]: ExternalSessionApplicationBadgeMode.Title, + }, + interaction: { focused: { session: 'external-repo' } }, + }, { + labels: { kind: 'screenshot' }, + expectedVisualDescriptions: ['The keyboard-focused external session shows its row actions and hides its application badge without changing the row content height. The other external session retains its badge.'], + }), SessionsList_WorkspaceSection: defineSessionsListFixture({ sessions: [{ id: 'c', title: 'Update onboarding copy', workspace: 'vscode-docs', minutesAgo: 180 }], }), 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 746f609870ddc7..e14004d1ee02b3 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts @@ -65,7 +65,7 @@ import { AgentMergeSessionState } from '../../../../../platform/agentHost/common import { getSessionChatDragData, isSessionChatDrag, SessionsDataTransfers } from '../../../../browser/dnd.js'; import { IsPhoneLayoutContext, IsQuickChatSessionContext, SessionIsArchivedContext, SessionSupportsMultipleChatsContext } from '../../../../common/contextkeys.js'; import { ARCHIVE_CHAT_COMMAND_ID, ARCHIVE_SESSION_COMMAND_ID, UNARCHIVE_CHAT_COMMAND_ID } from '../../../../common/sessionCommands.js'; -import { SESSIONS_LIST_GROUP_EXTERNAL_SESSIONS_SETTING } from '../../../../common/sessionConfig.js'; +import { ExternalSessionApplicationBadgeMode, SESSIONS_LIST_EXTERNAL_APPLICATION_BADGE_SETTING, SESSIONS_LIST_EXTERNAL_APPLICATION_BADGE_SHOW_FROM_SETTING, SESSIONS_LIST_GROUP_EXTERNAL_SESSIONS_SETTING } from '../../../../common/sessionConfig.js'; import { IAgentHostSessionsProvider, LOCAL_AGENT_HOST_PROVIDER_ID } from '../../../../common/agentHostSessionsProvider.js'; import { ICustomViewService } from '../../../../services/customView/browser/customViewService.js'; import type { ICustomViewDescriptor } from '../../../../services/customView/browser/customView.js'; @@ -1720,12 +1720,13 @@ suite('Sessions - SessionsList', () => { instantiationService.stub(ITelemetryService, telemetryService); configure(instantiationService); }); - const list = harness.store.add(harness.instantiationService.createInstance(SessionsList, harness.createContainer(), { + const container = harness.createContainer(); + const list = harness.store.add(harness.instantiationService.createInstance(SessionsList, container, { grouping: () => SessionsGrouping.Workspace, sorting: () => SessionsSorting.Created, onSessionOpen: () => { }, })); - return { list, triggers: telemetryService.triggers, harness }; + return { list, triggers: telemetryService.triggers, harness, container }; } test('reports the Done default trigger when the Filter Sessions dropdown shows, until an archived filter is chosen', () => { @@ -1775,6 +1776,95 @@ suite('Sessions - SessionsList', () => { }); }); + test('renders each external application badge mode and triggers only relevant experiments', () => { + const results = ([ + [ExternalSessionApplicationBadgeMode.Off, true], + [ExternalSessionApplicationBadgeMode.Title, true], + [ExternalSessionApplicationBadgeMode.Details, true], + [ExternalSessionApplicationBadgeMode.Title, false], + ] as const).map(([mode, showFrom]) => { + let badgeHover: string | undefined; + const session = createTestSession('External session', { + application: 'github/cli', + environment: 'cloud', + isExternal: true, + }).session; + const { list, triggers, container } = renderList([session], instantiationService => { + const configurationService = instantiationService.get(IConfigurationService) as TestConfigurationService; + configurationService.setUserConfiguration(SESSIONS_LIST_EXTERNAL_APPLICATION_BADGE_SETTING, mode); + configurationService.setUserConfiguration(SESSIONS_LIST_EXTERNAL_APPLICATION_BADGE_SHOW_FROM_SETTING, showFrom); + instantiationService.stub(IHoverService, { + ...NullHoverService, + setupDelayedHover: (...args: Parameters) => { + const target = args[0]; + const options = args[1]; + if (target.classList.contains('session-external-application-badge') && typeof options !== 'function' && typeof options.content === 'string') { + badgeHover = options.content; + } + return NullHoverService.setupDelayedHover(...args); + }, + }); + }); + list.layout(300, 400); + const titleBadge = container.querySelector('.session-external-application-badge-title.visible'); + const detailsBadge = container.querySelector('.session-external-application-badge-details'); + const row = container.querySelector('.monaco-list-row[role="treeitem"][aria-level="2"]'); + return { + titleBadge: titleBadge?.textContent, + detailsBadge: detailsBadge?.textContent, + badgeHover, + ariaIncludesApplication: row?.getAttribute('aria-label')?.includes('created in Copilot CLI'), + triggers, + }; + }); + + const explicitTriggers = () => [`config.${SESSIONS_LIST_GROUP_EXTERNAL_SESSIONS_SETTING}`]; + assert.deepStrictEqual(results, [ + { titleBadge: undefined, detailsBadge: undefined, badgeHover: undefined, ariaIncludesApplication: false, triggers: explicitTriggers() }, + { titleBadge: 'From Copilot CLI', detailsBadge: undefined, badgeHover: 'From Copilot CLI', ariaIncludesApplication: true, triggers: explicitTriggers() }, + { titleBadge: undefined, detailsBadge: 'From Copilot CLI', badgeHover: 'From Copilot CLI', ariaIncludesApplication: true, triggers: explicitTriggers() }, + { titleBadge: 'Copilot CLI', detailsBadge: undefined, badgeHover: 'Copilot CLI', ariaIncludesApplication: true, triggers: explicitTriggers() }, + ]); + }); + + test('triggers external application badge experiments only while their values come from defaults', () => { + const results = ([ + ExternalSessionApplicationBadgeMode.Off, + ExternalSessionApplicationBadgeMode.Title, + ] as const).map(mode => { + const session = createTestSession('External session', { + application: 'github/cli', + environment: 'cloud', + isExternal: true, + }).session; + const { triggers } = renderList([session], instantiationService => { + instantiationService.stub(IConfigurationService, new class extends TestConfigurationService { + constructor() { + super({ + [SESSIONS_LIST_EXTERNAL_APPLICATION_BADGE_SETTING]: mode, + [SESSIONS_LIST_EXTERNAL_APPLICATION_BADGE_SHOW_FROM_SETTING]: true, + }); + } + + override inspect(key: string) { + const value = this.getValue(key); + return { value, defaultValue: value }; + } + }()); + }); + return triggers; + }); + + const baseTriggers = [ + `config.${SESSIONS_LIST_GROUP_EXTERNAL_SESSIONS_SETTING}`, + `config.${SESSIONS_LIST_EXTERNAL_APPLICATION_BADGE_SETTING}`, + ]; + assert.deepStrictEqual(results, [ + baseTriggers, + [...baseTriggers, `config.${SESSIONS_LIST_EXTERNAL_APPLICATION_BADGE_SHOW_FROM_SETTING}`], + ]); + }); + test('reports the Done default trigger for archived sessions until an archived filter is chosen', () => { const results = Object.fromEntries(([ ['default filter', true, undefined], diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsListFixtureUtils.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsListFixtureUtils.ts index 0e5e0648d95c20..a9fe34727f7986 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsListFixtureUtils.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsListFixtureUtils.ts @@ -752,6 +752,12 @@ export async function renderSessionsListFixture(context: ComponentFixtureContext onSessionOpen: () => { }, approvalModel, })); + for (const spec of state.sessions) { + if (spec.application) { + const session = getSession(spec.id).session; + list.filters.setExcluded({ kind: 'application', environment: session.environment, id: session.application.get().id }, false); + } + } if (view.showArchived ?? state.sessions.some(spec => spec.isArchived)) { list.setExcludeArchived(false); } diff --git a/src/vs/sessions/test/browser/agentsPartCard.test.ts b/src/vs/sessions/test/browser/agentsPartCard.test.ts index 4e72c8d37b2367..83c1736d99de92 100644 --- a/src/vs/sessions/test/browser/agentsPartCard.test.ts +++ b/src/vs/sessions/test/browser/agentsPartCard.test.ts @@ -16,7 +16,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../base/test/comm import { MainEditorPart as MainEditorPartBase } from '../../../workbench/browser/parts/editor/editorPart.js'; import { AbstractPaneCompositePart } from '../../../workbench/browser/parts/paneCompositePart.js'; import { Parts } from '../../../workbench/services/layout/browser/layoutService.js'; -import { getAgentsPartCardContentSize } from '../../browser/parts/agentsPartCard.js'; +import { AGENTS_SIDE_PANE_MULTIPLE_TABS_CLASS, getAgentsPartCardContentSize, getSessionsPartCardMarginRight, getSessionsPartFrameInset, getSidePaneBottomFrameInset, getSidePaneFrameInset } from '../../browser/parts/agentsPartCard.js'; import { CustomViewGridPart } from '../../browser/parts/customViewGridPart.js'; import { AgentWorkbenchLayout } from '../../browser/workbench.js'; import { MainEditorPart } from '../../browser/parts/editorPart.js'; @@ -35,6 +35,7 @@ suite('Sessions - Agents Part Card', () => { container.style.width = '800px'; container.style.setProperty('--vscode-agents-layout-floatingPanelGap', '4px'); container.style.setProperty('--vscode-cornerRadius-large', '8px'); + container.style.setProperty('--vscode-sash-size', '4px'); container.style.setProperty('--vscode-strokeThickness', '1px'); container.style.setProperty('--vscode-spacing-size20', '2px'); container.style.setProperty('--vscode-spacing-size40', '4px'); @@ -97,6 +98,258 @@ suite('Sessions - Agents Part Card', () => { }); }); + for (const { connectedEditorTabs, showTabs } of [ + { connectedEditorTabs: false, showTabs: 'multiple' as const }, + { connectedEditorTabs: true, showTabs: 'multiple' as const }, + { connectedEditorTabs: true, showTabs: 'single' as const }, + ]) { + test(`matches the ${connectedEditorTabs ? `connected ${showTabs}-tab` : 'pill'} side-pane gap to the floating-panel gap`, () => { + const { container, card } = createCard(true, true); + container.classList.add('modern-ui-tabs', 'dock-detail-panel'); + container.classList.toggle('modern-ui-connected-editor-tabs', connectedEditorTabs); + container.classList.toggle(AGENTS_SIDE_PANE_MULTIPLE_TABS_CLASS, showTabs === 'multiple'); + card.classList.add('part', 'sessionspart'); + const row = append(container, $('.monaco-grid-view')); + row.style.display = 'flex'; + const sessionAllocation = append(row, $('.session-allocation')); + sessionAllocation.style.width = '500px'; + sessionAllocation.style.height = '400px'; + sessionAllocation.style.flex = '0 0 auto'; + append(sessionAllocation, card); + card.style.height = '100%'; + const sidePaneMarginRight = getSessionsPartCardMarginRight(container); + const contentSize = getAgentsPartCardContentSize(500, 400, true, true, false, false, sidePaneMarginRight); + const content = append(card, $('.content')); + content.style.width = `${contentSize.width}px`; + content.style.height = `${contentSize.height}px`; + const session = append(content, $('.session-view')); + session.style.width = '100%'; + session.style.height = '100%'; + const editor = append(row, $('.part.editor')); + editor.classList.toggle('editor-tabs-multiple', showTabs === 'multiple'); + editor.style.width = '300px'; + editor.style.height = '400px'; + editor.style.flex = '0 0 auto'; + const divider = append(row, $('.monaco-sash.vertical.sessions-side-pane-divider')); + const dividerStyle = mainWindow.getComputedStyle(divider); + const gripStyle = mainWindow.getComputedStyle(divider, '::after'); + const editorBounds = editor.getBoundingClientRect(); + const sidePaneFrameInset = getSidePaneFrameInset(container); + const sidePaneFrameLeft = editorBounds.left + sidePaneFrameInset; + const insetFrame = connectedEditorTabs && showTabs === 'multiple'; + + assert.deepStrictEqual({ + sessionFrameInset: getSessionsPartFrameInset(container), + sidePaneFrameInset, + sidePaneMarginRight, + marginRight: mainWindow.getComputedStyle(card).marginRight, + backgroundClip: mainWindow.getComputedStyle(card).backgroundClip, + renderedContentWidth: card.clientWidth, + contentSize, + frameGap: sidePaneFrameLeft - session.getBoundingClientRect().right, + divider: { + transform: dividerStyle.transform, + width: dividerStyle.width, + gripContent: gripStyle.content, + gripWidth: gripStyle.width, + gripHeight: gripStyle.height, + }, + }, { + sessionFrameInset: 1, + sidePaneFrameInset: insetFrame ? 1 : 0, + sidePaneMarginRight: insetFrame ? 2 : 3, + marginRight: insetFrame ? '2px' : '3px', + backgroundClip: 'padding-box', + renderedContentWidth: insetFrame ? 496 : 495, + contentSize: { width: insetFrame ? 496 : 495, height: 398 }, + frameGap: 4, + divider: { + transform: insetFrame ? 'matrix(1, 0, 0, 1, -1, 0)' : 'matrix(1, 0, 0, 1, -2, 0)', + width: '4px', + gripContent: '""', + gripWidth: '2px', + gripHeight: '2px', + }, + }); + }); + } + + test('aligns pill and connected side-pane frames above a justified bottom panel', () => { + const { container } = createCard(true, true); + container.classList.add('modern-ui-tabs', 'dock-detail-panel', 'panel-alignment-justify'); + const grid = append(container, $('.monaco-grid-view')); + grid.style.height = '400px'; + const editor = append(grid, $('.part.editor')); + editor.style.width = '300px'; + editor.style.height = '100%'; + const content = append(editor, $('.content')); + const baseLayout = sinon.stub(MainEditorPartBase.prototype, 'layout').callsFake((width, height) => { + content.style.width = `${width}px`; + content.style.height = `${height}px`; + }); + const part = { + layoutService: { + mainContainer: container, + agentWorkbenchLayout: AgentWorkbenchLayout.Desktop, + isModernUICompact: () => false, + isVisible: (partId: Parts) => partId === Parts.EDITOR_PART + || partId === Parts.SESSIONS_PART + || partId === Parts.PANEL_PART, + }, + }; + const layout = MainEditorPart.prototype.layout as (this: typeof part, width: number, height: number, top: number, left: number) => void; + const states = [ + { name: 'pill multiple', connected: false, multiple: true }, + { name: 'connected single', connected: true, multiple: false }, + { name: 'connected multiple', connected: true, multiple: true }, + ]; + + const actual = states.map(state => { + container.classList.toggle('modern-ui-connected-editor-tabs', state.connected); + container.classList.toggle(AGENTS_SIDE_PANE_MULTIPLE_TABS_CLASS, state.multiple); + editor.classList.toggle('editor-tabs-multiple', state.multiple); + layout.call(part, 300, 400, 0, 0); + const sidePaneFrameInset = getSidePaneFrameInset(container); + + return { + name: state.name, + sidePaneFrameInset, + bottomFrameInset: getSidePaneBottomFrameInset(container), + visibleFrameHeight: editor.getBoundingClientRect().height - sidePaneFrameInset, + layout: baseLayout.lastCall.args, + }; + }); + + assert.deepStrictEqual(actual, [ + { name: 'pill multiple', sidePaneFrameInset: 0, bottomFrameInset: 1, visibleFrameHeight: 399, layout: [298, 397, 0, 0] }, + { name: 'connected single', sidePaneFrameInset: 0, bottomFrameInset: 1, visibleFrameHeight: 399, layout: [298, 397, 0, 0] }, + { name: 'connected multiple', sidePaneFrameInset: 1, bottomFrameInset: 0, visibleFrameHeight: 399, layout: [298, 398, 0, 0] }, + ]); + }); + + test('aligns non-docked side-pane frames after leaving phone layout', () => { + const { container, card } = createCard(true, true, true); + container.classList.add('modern-ui-tabs', 'modern-ui-connected-editor-tabs', AGENTS_SIDE_PANE_MULTIPLE_TABS_CLASS, 'panel-alignment-justify'); + container.classList.remove('phone-layout'); + card.classList.add('part', 'sessionspart'); + const row = append(container, $('.monaco-grid-view')); + row.style.display = 'flex'; + row.style.width = '800px'; + row.style.height = '400px'; + const sessionAllocation = append(row, $('.session-allocation')); + sessionAllocation.style.width = '500px'; + sessionAllocation.style.height = '400px'; + sessionAllocation.style.flex = '0 0 auto'; + sessionAllocation.appendChild(card); + card.style.height = '100%'; + const sidePaneMarginRight = getSessionsPartCardMarginRight(container); + const sessionContentSize = getAgentsPartCardContentSize(500, 400, true, true, false, false, sidePaneMarginRight); + const sessionContent = append(card, $('.content')); + sessionContent.style.width = `${sessionContentSize.width}px`; + sessionContent.style.height = `${sessionContentSize.height}px`; + const session = append(sessionContent, $('.session-view')); + session.style.width = '100%'; + session.style.height = '100%'; + const editor = append(row, $('.part.editor')); + editor.style.width = '200px'; + editor.style.height = '100%'; + editor.style.flex = '0 0 auto'; + const editorContent = append(editor, $('.content')); + const auxiliaryBar = append(row, $('.part.auxiliarybar')); + auxiliaryBar.style.width = '100px'; + auxiliaryBar.style.height = '100%'; + auxiliaryBar.style.flex = '0 0 auto'; + auxiliaryBar.style.transition = 'none'; + const auxiliaryContent = append(auxiliaryBar, $('.content')); + const layoutService = { + mainContainer: container, + agentWorkbenchLayout: AgentWorkbenchLayout.Mobile, + isModernUICompact: () => false, + isVisible: (partId: Parts) => partId === Parts.EDITOR_PART + || partId === Parts.AUXILIARYBAR_PART + || partId === Parts.SESSIONS_PART + || partId === Parts.SIDEBAR_PART + || partId === Parts.PANEL_PART, + getPanelAlignment: () => 'justify' as const, + }; + const editorLayout = sinon.stub(MainEditorPartBase.prototype, 'layout').callsFake((width, height) => { + editorContent.style.width = `${width}px`; + editorContent.style.height = `${height}px`; + }); + const auxiliaryLayout = sinon.stub(AbstractPaneCompositePart.prototype, 'layout').callsFake((width, height) => { + auxiliaryContent.style.width = `${width}px`; + auxiliaryContent.style.height = `${height}px`; + }); + const layoutEditor = MainEditorPart.prototype.layout as (this: { layoutService: typeof layoutService }, width: number, height: number, top: number, left: number) => void; + const layoutAuxiliaryBar = AuxiliaryBarPart.prototype.layout as typeof layoutEditor; + layoutEditor.call({ layoutService }, 200, 400, 0, 500); + layoutAuxiliaryBar.call({ layoutService }, 100, 400, 0, 700); + + assert.deepStrictEqual({ + docked: container.classList.contains('dock-detail-panel'), + sessionFrameInset: getSessionsPartFrameInset(container), + sidePaneFrameInset: getSidePaneFrameInset(container), + bottomFrameInset: getSidePaneBottomFrameInset(container), + sidePaneMarginRight, + renderedMarginRight: mainWindow.getComputedStyle(card).marginRight, + horizontalGap: editor.getBoundingClientRect().left - session.getBoundingClientRect().right, + editorFrameHeight: editor.getBoundingClientRect().height, + auxiliaryFrameHeight: auxiliaryBar.getBoundingClientRect().height, + editorLayout: editorLayout.lastCall.args, + auxiliaryLayout: auxiliaryLayout.lastCall.args, + }, { + docked: false, + sessionFrameInset: 1, + sidePaneFrameInset: 0, + bottomFrameInset: 1, + sidePaneMarginRight: 3, + renderedMarginRight: '3px', + horizontalGap: 4, + editorFrameHeight: 399, + auxiliaryFrameHeight: 399, + editorLayout: [198, 397, 0, 500], + auxiliaryLayout: [93, 397, 0, 700], + }); + }); + + test('compensates the bottom-panel margin for the inset session frame', () => { + const { container } = createCard(true, false); + container.classList.add('modern-ui-tabs'); + const panel = append(container, $('.part.panel')); + panel.style.transition = 'none'; + const baseLayout = sinon.stub(AbstractPaneCompositePart.prototype, 'layout'); + let sessionsVisible = true; + const part = { + layoutService: { + mainContainer: container, + isModernUICompact: () => false, + isVisible: (partId: Parts) => partId === Parts.PANEL_PART + || partId === Parts.SIDEBAR_PART + || (partId === Parts.SESSIONS_PART && sessionsVisible), + getPanelAlignment: () => 'justify' as const, + }, + }; + const layoutPanel = PanelPart.prototype.layout as (this: typeof part, width: number, height: number, top: number, left: number) => void; + + const actual = [true, false, true].map(visible => { + sessionsVisible = visible; + container.classList.toggle('nosessionspart', !visible); + layoutPanel.call(part, 800, 200, 400, 0); + return { + sessionsVisible: visible, + sessionFrameInset: getSessionsPartFrameInset(container), + marginTop: mainWindow.getComputedStyle(panel).marginTop, + layout: baseLayout.lastCall.args, + }; + }); + + assert.deepStrictEqual(actual, [ + { sessionsVisible: true, sessionFrameInset: 1, marginTop: '3px', layout: [798, 195, 400, 0] }, + { sessionsVisible: false, sessionFrameInset: 0, marginTop: '4px', layout: [798, 194, 400, 0] }, + { sessionsVisible: true, sessionFrameInset: 1, marginTop: '3px', layout: [798, 195, 400, 0] }, + ]); + }); + for (const desktop of [false, true]) { test(`keeps the expanded ${desktop ? 'desktop' : 'mobile'} editor gutter and content size in sync`, () => { const workbench = append(mainWindow.document.body, $('.monaco-workbench.agent-sessions-workbench.noauxiliarybar')); diff --git a/src/vs/sessions/test/browser/chatCompositeBar.test.ts b/src/vs/sessions/test/browser/chatCompositeBar.test.ts index cb9d921fc109a2..157970439a1612 100644 --- a/src/vs/sessions/test/browser/chatCompositeBar.test.ts +++ b/src/vs/sessions/test/browser/chatCompositeBar.test.ts @@ -418,7 +418,7 @@ suite('Sessions - ChatCompositeBar', () => { }); }); - test('connected chat tabs and their card share one inset frame at fractional zoom', () => { + test('chat tabs and their card share one inset frame at fractional zoom', () => { const harness = createHarness(disposables); const root = attachConnectedBar(harness); root.classList.add('noeditorpane'); @@ -498,8 +498,13 @@ suite('Sessions - ChatCompositeBar', () => { root.classList.remove('phone-layout', 'modern-ui-connected-editor-tabs'); assert.deepStrictEqual({ frame: mainWindow.getComputedStyle(harness.container, '::after').content, + frameColor: mainWindow.getComputedStyle(harness.container, '::after').borderTopColor, outerBorder: mainWindow.getComputedStyle(card).borderTopColor, - }, { frame: 'none', outerBorder: 'rgb(171, 205, 239)' }); + }, { + frame: '""', + frameColor: 'rgb(171, 205, 239)', + outerBorder: 'rgba(0, 0, 0, 0)', + }); }); test('connected chat tabs retain clipped outlines and reveal the full terminal shoulder', () => { diff --git a/src/vs/sessions/test/browser/chatGroupsView.test.ts b/src/vs/sessions/test/browser/chatGroupsView.test.ts index a1f829aec9d056..2c33612dbc90ed 100644 --- a/src/vs/sessions/test/browser/chatGroupsView.test.ts +++ b/src/vs/sessions/test/browser/chatGroupsView.test.ts @@ -28,6 +28,7 @@ import { workbenchInstantiationService } from '../../../workbench/test/browser/w import { IChatSessionHistoryStatus } from '../../../platform/chat/common/chatSessionHistory.js'; import { AbstractChatView, ChatViewKind, IChatViewOptions } from '../../browser/parts/chatView.js'; import { ChatGroupsView } from '../../browser/parts/chatGroupsView.js'; +import { GRID_GAP_SASH_CLASS } from '../../browser/parts/gridGap.js'; import { SessionDropTarget } from '../../browser/parts/sessionDropTarget.js'; import { DraggedSessionIdentifier, SessionsDataTransfers } from '../../browser/dnd.js'; import { SessionActiveChatCanArchiveContext, SessionActiveChatHasSideChatsContext, SessionActiveChatIsClosableContext, SessionActiveChatIsDeletableContext, SessionActiveChatIsUntitledContext, SessionActiveChatResourceContext, SessionFocusedChatIsRenameTargetContext, SessionHeaderActiveChatIsPinnedContext, SessionHeaderShowsChatContext, SessionHeaderTargetsChatContext, SessionToolbarShowsSessionContext } from '../../common/contextkeys.js'; @@ -362,6 +363,174 @@ suite('Sessions - ChatGroupsView', () => { }); }); + test('uses a single divider between vertically split chats', async () => { + const { view } = createHarness(disposables); + const main = createChat('main'); + const secondary = createChat('secondary'); + view.setSession(new TestActiveSession([main, secondary]), options); + view.layout(800, 600, 0, 0); + + view.splitActiveChat('bottom'); + await timeout(0); + + assert.deepStrictEqual({ + hasGaps: view.element.classList.contains('chat-groups-view-has-gaps'), + gapSashes: view.element.querySelectorAll(`.${GRID_GAP_SASH_CLASS}`).length, + boundaryOverlays: view.element.querySelectorAll('.chat-groups-view-boundary-segment').length, + splitViews: view.element.querySelectorAll('.monaco-split-view2').length, + }, { + hasGaps: false, + gapSashes: 0, + boundaryOverlays: 0, + splitViews: 1, + }); + }); + + test('uses dividers between every split chat in the session', async () => { + const { view } = createHarness(disposables); + const main = createChat('main'); + const firstChild = createChat('firstChild', SessionStatus.Completed, main.resource); + const secondChild = createChat('secondChild', SessionStatus.Completed, main.resource); + const unrelated = createChat('unrelated'); + const session = new TestActiveSession([main, firstChild, secondChild, unrelated]); + view.setSession(session, options); + view.layout(1200, 600, 0, 0); + + view.splitChatToSide(firstChild.resource); + await timeout(0); + await view.openChatInNewGroup(secondChild.resource, firstChild.resource); + await view.openChatInNewGroup(unrelated.resource, secondChild.resource); + + assert.deepStrictEqual({ + hasGaps: view.element.classList.contains('chat-groups-view-has-gaps'), + gapSashes: view.element.querySelectorAll(`.${GRID_GAP_SASH_CLASS}`).length, + boundaryOverlays: view.element.querySelectorAll('.chat-groups-view-boundary-segment').length, + sashes: view.element.querySelectorAll('.monaco-sash').length, + }, { + hasGaps: false, + gapSashes: 0, + boundaryOverlays: 0, + sashes: 3, + }); + }); + + test('uses only dividers in a nested chat grid', async () => { + const { sessionsService, view } = createHarness(disposables); + const main = createChat('main'); + const child = createChat('child', SessionStatus.Completed, main.resource); + const unrelated = createChat('unrelated'); + const session = new TestActiveSession([main, child, unrelated]); + view.setSession(session, options); + view.layout(1000, 600, 0, 0); + + view.splitActiveChat('right'); + await timeout(0); + await sessionsService.openChat(session, unrelated.resource); + view.splitActiveChat('bottom'); + await timeout(0); + + assert.deepStrictEqual({ + hasGaps: view.element.classList.contains('chat-groups-view-has-gaps'), + gapSashes: view.element.querySelectorAll(`.${GRID_GAP_SASH_CLASS}`).length, + boundaryOverlays: view.element.querySelectorAll('.chat-groups-view-boundary-segment').length, + splitViews: view.element.querySelectorAll('.monaco-split-view2').length, + }, { + hasGaps: false, + gapSashes: 0, + boundaryOverlays: 0, + splitViews: 2, + }); + }); + + test('keeps a divider when the active chat changes', async () => { + const { sessionsService, view } = createHarness(disposables); + const main = createChat('main'); + const child = createChat('child', SessionStatus.Completed, main.resource); + const unrelated = createChat('unrelated'); + const session = new TestActiveSession([main, child, unrelated]); + view.setSession(session, options); + view.layout(800, 600, 0, 0); + + view.splitChatToSide(child.resource); + await timeout(0); + + const snapshot = () => ({ + hasGaps: view.element.classList.contains('chat-groups-view-has-gaps'), + gapSashes: view.element.querySelectorAll(`.${GRID_GAP_SASH_CLASS}`).length, + sashes: view.element.querySelectorAll('.monaco-sash').length, + }); + const related = snapshot(); + await sessionsService.openChat(session, unrelated.resource); + const unrelatedActive = snapshot(); + await sessionsService.openChat(session, main.resource); + + assert.deepStrictEqual({ related, unrelatedActive, restored: snapshot() }, { + related: { + hasGaps: false, + gapSashes: 0, + sashes: 1, + }, + unrelatedActive: { + hasGaps: false, + gapSashes: 0, + sashes: 1, + }, + restored: { + hasGaps: false, + gapSashes: 0, + sashes: 1, + }, + }); + }); + + test('uses the same split-chat divider in multiple and single presentation', async () => { + const { configurationService, view } = createHarness(disposables); + const main = createChat('main'); + const secondary = createChat('secondary'); + view.setSession(new TestActiveSession([main, secondary]), options); + view.layout(800, 600, 0, 0); + view.splitChatToSide(secondary.resource); + await timeout(0); + + const snapshot = () => ({ + hasGaps: view.element.classList.contains('chat-groups-view-has-gaps'), + gapSashes: view.element.querySelectorAll(`.${GRID_GAP_SASH_CLASS}`).length, + sashes: view.element.querySelectorAll('.monaco-sash').length, + }); + const setChatTabsMode = async (mode: SessionsChatTabsMode) => { + await configurationService.setUserConfiguration(SESSIONS_CHAT_TABS_SETTING, mode); + configurationService.onDidChangeConfigurationEmitter.fire({ + source: ConfigurationTarget.USER, + affectedKeys: new Set([SESSIONS_CHAT_TABS_SETTING]), + change: { keys: [SESSIONS_CHAT_TABS_SETTING], overrides: [] }, + affectsConfiguration: key => key === SESSIONS_CHAT_TABS_SETTING, + }); + }; + + const multiple = snapshot(); + await setChatTabsMode(SessionsChatTabsMode.Single); + const single = snapshot(); + await setChatTabsMode(SessionsChatTabsMode.Multiple); + + assert.deepStrictEqual({ multiple, single, restored: snapshot() }, { + multiple: { + hasGaps: false, + gapSashes: 0, + sashes: 1, + }, + single: { + hasGaps: false, + gapSashes: 0, + sashes: 1, + }, + restored: { + hasGaps: false, + gapSashes: 0, + sashes: 1, + }, + }); + }); + test('opens a session with an active child chat after initial layout', () => { const { view, chatViewFactory } = createHarness(disposables); const main = createChat('main'); diff --git a/src/vs/sessions/test/browser/parts/panelPart.test.ts b/src/vs/sessions/test/browser/parts/panelPart.test.ts index 41b15abb8f09dc..d8254253f4aaac 100644 --- a/src/vs/sessions/test/browser/parts/panelPart.test.ts +++ b/src/vs/sessions/test/browser/parts/panelPart.test.ts @@ -4,15 +4,22 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import sinon from 'sinon'; import { mainWindow } from '../../../../base/browser/window.js'; import { IAction, Separator, SubmenuAction, toAction } from '../../../../base/common/actions.js'; +import { Emitter } from '../../../../base/common/event.js'; import { mock } from '../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { MenuItemAction, SubmenuItemAction } from '../../../../platform/actions/common/actions.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { ContextKeyExpr, ContextKeyExpression, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { TestInstantiationService } from '../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { IViewDescriptorService } from '../../../../workbench/common/views.js'; +import { Part } from '../../../../workbench/browser/part.js'; import { IPaneCompositeBarOptions } from '../../../../workbench/browser/parts/paneCompositeBar.js'; -import { Position } from '../../../../workbench/services/layout/browser/layoutService.js'; +import { IPartVisibilityChangeEvent, IWorkbenchLayoutService, Parts, Position } from '../../../../workbench/services/layout/browser/layoutService.js'; +import { ViewDescriptorService } from '../../../../workbench/services/views/browser/viewDescriptorService.js'; +import { TestLayoutService, workbenchInstantiationService } from '../../../../workbench/test/browser/workbenchTestServices.js'; import { PanelPart } from '../../../browser/parts/panelPart.js'; interface IPanelPartTestHarness { @@ -35,6 +42,35 @@ interface IPanelPartTestHarness { const getCompositeBarOptions = Reflect.get(PanelPart.prototype, 'getCompositeBarOptions') as (this: IPanelPartTestHarness) => IPaneCompositeBarOptions; const transformContextMenuActionsForComposite = Reflect.get(PanelPart.prototype, 'transformContextMenuActionsForComposite') as (this: Pick, actions: IAction[]) => IAction[]; +class MutableTestLayoutService extends TestLayoutService { + + private readonly _visibleParts = new Map([ + [Parts.PANEL_PART, true], + [Parts.SESSIONS_PART, true], + [Parts.SIDEBAR_PART, true], + ]); + + private readonly _onDidChangePartVisibility = new Emitter(); + override readonly onDidChangePartVisibility = this._onDidChangePartVisibility.event; + + override isVisible(part: Parts, _targetWindow?: Window): boolean { + return this._visibleParts.get(part) ?? false; + } + + override getPanelAlignment(): 'justify' { + return 'justify'; + } + + setVisible(part: Parts, visible: boolean): void { + this._visibleParts.set(part, visible); + this._onDidChangePartVisibility.fire({ partId: part, visible }); + } + + dispose(): void { + this._onDidChangePartVisibility.dispose(); + } +} + function getAlignmentSubmenu(actions: readonly IAction[]): SubmenuAction { const submenu = actions.find(action => action instanceof SubmenuAction); if (!submenu) { @@ -46,7 +82,29 @@ function getAlignmentSubmenu(actions: readonly IAction[]): SubmenuAction { suite('Sessions - Panel Part', () => { - ensureNoDisposablesAreLeakedInTestSuite(); + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + teardown(() => sinon.restore()); + + test('relayouts when Sessions visibility changes', () => { + const layoutService = disposables.add(new MutableTestLayoutService()); + const instantiationService: TestInstantiationService = workbenchInstantiationService({}, disposables); + instantiationService.stub(IWorkbenchLayoutService, layoutService); + const viewDescriptorService = disposables.add(instantiationService.createInstance(ViewDescriptorService)); + instantiationService.stub(IViewDescriptorService, viewDescriptorService); + const panelPart = disposables.add(instantiationService.createInstance(PanelPart)); + Part.prototype.layout.call(panelPart, 800, 200, 400, 0); + const layout = sinon.stub(panelPart, 'layout'); + + layoutService.setVisible(Parts.TITLEBAR_PART, false); + layoutService.setVisible(Parts.SESSIONS_PART, false); + layoutService.setVisible(Parts.SESSIONS_PART, true); + + assert.deepStrictEqual(layout.args, [ + [800, 200, 400, 0], + [800, 200, 400, 0], + ]); + }); test('replaces only view container movement with panel alignment actions', async () => { let alignment: 'center' | 'justify' = 'justify'; diff --git a/src/vs/sessions/test/browser/sessionGridLayout.test.ts b/src/vs/sessions/test/browser/sessionGridLayout.test.ts index 49c24eb7cad444..8f80597fada1f2 100644 --- a/src/vs/sessions/test/browser/sessionGridLayout.test.ts +++ b/src/vs/sessions/test/browser/sessionGridLayout.test.ts @@ -10,6 +10,7 @@ import { mainWindow } from '../../../base/browser/window.js'; import { Event } from '../../../base/common/event.js'; import { IDisposable, toDisposable } from '../../../base/common/lifecycle.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../base/test/common/utils.js'; +import { GRID_GAP_SASH_CLASS } from '../../browser/parts/gridGap.js'; import { ISessionGridEntry, ISessionGridView, SessionGridLayout } from '../../browser/parts/sessionGridLayout.js'; import { getSessionDropDirection } from '../../browser/parts/sessionDropTarget.js'; import { ISessionGridState, isSessionGridState, projectSessionGrid } from '../../services/sessions/browser/sessionGridState.js'; @@ -28,6 +29,7 @@ suite('Sessions - Grid Layout', () => { readonly onDidChange = Event.None; visible = true; size = { width: 0, height: 0 }; + readonly contentGaps: number[] = []; onLayoutContainer: (() => void) | undefined; onLayoutContents: (() => void) | undefined; onVisibilityChange: ((visible: boolean) => void) | undefined; @@ -39,7 +41,10 @@ suite('Sessions - Grid Layout', () => { this.size = { width, height }; this.onLayoutContainer?.(); } - layoutContents(): void { this.onLayoutContents?.(); } + layoutContents(gap = 0): void { + this.contentGaps.push(gap); + this.onLayoutContents?.(); + } setVisible(visible: boolean): void { this.visible = visible; this.onVisibilityChange?.(visible); @@ -75,6 +80,206 @@ suite('Sessions - Grid Layout', () => { assert.deepStrictEqual(operations, ['geometry a', 'geometry b', 'contents a', 'contents b']); }); + test('insets adjacent views by the configured gap and restores flush compact allocations', () => { + const { grid, a, b, c } = harness(); + grid.reconcile([a, b, c], 'a'); + const leafElements = new Map([a, b, c].map(entry => [entry.id, entry.view.element.parentElement!])); + const grippers = () => Array.from(grid.element.querySelectorAll('.monaco-sash')) + .filter(sash => sash.closest('.monaco-grid-view')?.parentElement === grid.element) + .map(sash => sash.classList.contains(GRID_GAP_SASH_CLASS)); + const snapshot = () => ({ + hasGaps: grid.element.classList.contains('session-grid-has-gaps'), + grippers: grippers(), + views: [a, b, c].map(entry => { + const style = leafElements.get(entry.id)!.style; + return { + size: entry.view.size, + insets: [style.borderTopWidth, style.borderRightWidth, style.borderBottomWidth, style.borderLeftWidth], + }; + }), + }); + + grid.layout(1200, 600, 0, 0, false, 4); + const gapped = snapshot(); + grid.toggleMaximized('a'); + const maximized = { + hasGaps: grid.element.classList.contains('session-grid-has-gaps'), + grippers: grippers(), + size: a.view.size, + insets: snapshot().views[0].insets, + }; + grid.toggleMaximized('a'); + grid.layout(1200, 600, 0, 0, false); + const compact = snapshot(); + + assert.deepStrictEqual({ gapped, maximized, compact }, { + gapped: { + hasGaps: true, + grippers: [true, true], + views: [ + { size: { width: 398, height: 600 }, insets: ['0px', '2px', '0px', '0px'] }, + { size: { width: 798, height: 298 }, insets: ['0px', '0px', '2px', '2px'] }, + { size: { width: 798, height: 298 }, insets: ['2px', '0px', '0px', '2px'] }, + ], + }, + maximized: { + hasGaps: false, + grippers: [false, false], + size: { width: 1200, height: 600 }, + insets: ['0px', '0px', '0px', '0px'], + }, + compact: { + hasGaps: false, + grippers: [false, false], + views: [ + { size: { width: 400, height: 600 }, insets: ['0px', '0px', '0px', '0px'] }, + { size: { width: 800, height: 300 }, insets: ['0px', '0px', '0px', '0px'] }, + { size: { width: 800, height: 300 }, insets: ['0px', '0px', '0px', '0px'] }, + ], + }, + }); + }); + + test('hides the gripper when a constrained session sash is disabled', () => { + const { grid, a, b } = harness(); + const workbench = mainWindow.document.createElement('div'); + workbench.classList.add('agent-sessions-workbench'); + workbench.style.setProperty('--vscode-foreground', '#ffffff'); + workbench.style.setProperty('--vscode-spacing-size20', '2px'); + workbench.style.setProperty('--vscode-cornerRadius-circle', '50%'); + workbench.style.setProperty('--agents-floating-panel-gap', '4px'); + const sessionsPart = mainWindow.document.createElement('div'); + sessionsPart.classList.add('part', 'sessionspart'); + sessionsPart.appendChild(grid.element); + workbench.appendChild(sessionsPart); + mainWindow.document.body.appendChild(workbench); + store.add(toDisposable(() => workbench.remove())); + + grid.reconcile([a, b], 'a'); + const snapshot = () => { + const sash = grid.element.querySelector(`.monaco-sash.${GRID_GAP_SASH_CLASS}`); + assert.ok(sash); + return { + disabled: sash.classList.contains('disabled'), + gripper: mainWindow.getComputedStyle(sash, '::after').content, + }; + }; + grid.layout(400, 600, 0, 0, false, 4); + const resizable = snapshot(); + grid.layout(160, 600, 0, 0, false, 4); + const constrained = { + ...snapshot(), + contentWidths: [a.view.size.width, b.view.size.width], + wrapperWidths: [grid.getSize('a')?.width, grid.getSize('b')?.width], + }; + grid.layout(400, 600, 0, 0, false, 4); + + assert.deepStrictEqual({ resizable, constrained, restored: snapshot() }, { + resizable: { disabled: false, gripper: '""' }, + constrained: { + disabled: true, + gripper: 'none', + contentWidths: [80, 80], + wrapperWidths: [82, 82], + }, + restored: { disabled: false, gripper: '""' }, + }); + }); + + test('updates constraints when the density gap changes', () => { + const { grid, a, b } = harness(); + const leaves = Reflect.get(grid, 'leaves') as ReadonlyMap; + const events: number[] = []; + store.add(leaves.get('a')!.onDidChange(() => events.push(leaves.get('a')!.minimumWidth))); + const snapshot = () => ({ + constraints: [leaves.get('a')!.minimumWidth, leaves.get('b')!.minimumWidth], + contentWidths: [a.view.size.width, b.view.size.width], + wrapperWidths: [grid.getSize('a')?.width, grid.getSize('b')?.width], + }); + + grid.layout(160, 600, 0, 0, false, 4); + const gapped = snapshot(); + grid.layout(160, 600, 0, 0, false); + const compact = snapshot(); + grid.layout(160, 600, 0, 0, false, 4); + + assert.deepStrictEqual({ gapped, compact, restored: snapshot(), events }, { + gapped: { + constraints: [82, 82], + contentWidths: [80, 80], + wrapperWidths: [82, 82], + }, + compact: { + constraints: [80, 80], + contentWidths: [80, 80], + wrapperWidths: [80, 80], + }, + restored: { + constraints: [82, 82], + contentWidths: [80, 80], + wrapperWidths: [82, 82], + }, + events: [82, 80, 82], + }); + }); + + test('updates constraints when the grid topology changes', () => { + const { grid, a, b, c } = harness(); + const leaves = Reflect.get(grid, 'leaves') as ReadonlyMap; + const bEvents: { width: number; height: number }[] = []; + store.add(leaves.get('b')!.onDidChange(() => bEvents.push({ + width: leaves.get('b')!.minimumWidth, + height: leaves.get('b')!.minimumHeight, + }))); + + grid.reconcile([a, b, c], 'a'); + grid.layout(160, 160, 0, 0, false, 4); + const nested = { + constraints: [b, c].map(entry => { + const leaf = leaves.get(entry.id)!; + return { width: leaf.minimumWidth, height: leaf.minimumHeight }; + }), + contentSizes: [b, c].map(entry => entry.view.size), + wrapperSizes: [b, c].map(entry => grid.getSize(entry.id)), + }; + + grid.reconcile([a, b], 'a'); + grid.layout(160, 160, 0, 0, false, 4); + + assert.deepStrictEqual({ + nested, + flattenedConstraint: { + width: leaves.get('b')!.minimumWidth, + height: leaves.get('b')!.minimumHeight, + }, + flattenedContentSize: b.view.size, + flattenedWrapperSize: grid.getSize('b'), + bEvents, + }, { + nested: { + constraints: [{ width: 82, height: 82 }, { width: 82, height: 82 }], + contentSizes: [{ width: 80, height: 80 }, { width: 80, height: 80 }], + wrapperSizes: [{ width: 82, height: 82 }, { width: 82, height: 82 }], + }, + flattenedConstraint: { width: 82, height: 80 }, + flattenedContentSize: { width: 80, height: 160 }, + flattenedWrapperSize: { width: 82, height: 160 }, + bEvents: [{ width: 82, height: 82 }, { width: 82, height: 80 }], + }); + }); + + test('propagates density gap changes when a single leaf keeps the same geometry', () => { + const { grid, a } = harness(); + grid.reconcile([a], 'a'); + grid.layout(1200, 600, 0, 0, false); + a.view.contentGaps.length = 0; + + grid.layout(1200, 600, 0, 0, false, 4); + grid.layout(1200, 600, 0, 0, false); + + assert.deepStrictEqual(a.view.contentGaps, [4, 0]); + }); + test('coalesces direct grid allocations into one cancellable frame using the latest dimensions', () => { const frames = new Set<() => void>(); const { grid, a } = harness(callback => { @@ -158,10 +363,10 @@ suite('Sessions - Grid Layout', () => { }); }); - test('only exposed session-grid corners use the native connected-tabs radius', () => { + test('connected-tab styling preserves complete panel frames and exposed grid corners', () => { const root = document.createElement('div'); root.className = 'monaco-workbench agent-sessions-workbench mac modern-ui-tabs modern-ui-connected-editor-tabs nopanel noeditorpane nosidebar'; - root.style.cssText = '--vscode-cornerRadius-large: 8px; --vscode-agents-layout-floatingPanelGap: 4px; --vscode-strokeThickness: 1px; --window-corner-radius: 16px;'; + root.style.cssText = '--vscode-cornerRadius-large: 8px; --vscode-agents-layout-floatingPanelGap: 4px; --vscode-strokeThickness: 1px; --window-corner-radius: 16px; --modern-ui-connected-tab-border: #445566;'; const card = document.createElement('div'); card.className = 'part sessionspart agents-part-card'; root.appendChild(card); @@ -197,9 +402,13 @@ suite('Sessions - Grid Layout', () => { return [style.borderBottomLeftRadius, style.borderBottomRightRadius]; }); grid.reconcile([a, b], 'a'); - grid.layout(1200, 600, 20, 100, false); + grid.layout(1200, 600, 20, 100, false, 4); grid.reconcile([a, d, b, c], 'a'); const split = corners(); + const frames = [a, b, c, d].map(({ view }) => { + const style = mainWindow.getComputedStyle(view.element, '::after'); + return [style.borderTopWidth, style.borderRightWidth, style.borderBottomWidth, style.borderLeftWidth]; + }); grid.reconcile([d, a, c, b].map(({ id, view }) => ({ id, view })), 'a'); const reordered = corners(); grid.toggleMaximized('c'); @@ -211,8 +420,14 @@ suite('Sessions - Grid Layout', () => { grid.reconcile([a], 'a'); const single = corners()[0]; - assert.deepStrictEqual({ split, reordered, maximized, unmaximized, resized, single }, { + assert.deepStrictEqual({ split, frames, reordered, maximized, unmaximized, resized, single }, { split: [['7px', '7px'], ['7px', '7px'], ['7px', '11px'], ['11px', '7px']], + frames: [ + ['1px', '1px', '1px', '1px'], + ['1px', '1px', '1px', '1px'], + ['1px', '1px', '1px', '1px'], + ['1px', '1px', '1px', '1px'], + ], reordered: [['11px', '7px'], ['7px', '11px'], ['7px', '7px'], ['7px', '7px']], maximized: [['7px', '7px'], ['7px', '7px'], ['11px', '11px'], ['7px', '7px']], unmaximized: [['11px', '7px'], ['7px', '11px'], ['7px', '7px'], ['7px', '7px']], diff --git a/src/vs/sessions/test/browser/sessionViewTestUtils.ts b/src/vs/sessions/test/browser/sessionViewTestUtils.ts index 8bd1e6ae62bd62..d59d7058735be5 100644 --- a/src/vs/sessions/test/browser/sessionViewTestUtils.ts +++ b/src/vs/sessions/test/browser/sessionViewTestUtils.ts @@ -24,7 +24,7 @@ import { SessionsPart } from '../../browser/parts/sessionsPart.js'; import { IAgentWorkbenchLayoutService } from '../../browser/workbench.js'; import { SessionHarnessPickerVisibleContext, SessionIsolationPickerVisibleContext, SessionWorkspacePickerVisibleContext } from '../../common/contextkeys.js'; import { SESSIONS_CHAT_TABS_SETTING, SessionsChatTabsMode } from '../../common/sessionConfig.js'; -import { ISessionsChatBackgroundService } from '../../services/chatBackground/browser/chatBackgroundService.js'; +import { ISessionsChatBackground, ISessionsChatBackgroundService } from '../../services/chatBackground/browser/chatBackgroundService.js'; import { IChatViewFactory } from '../../services/chatView/browser/chatViewFactory.js'; import { ISessionsListModelService } from '../../services/sessions/browser/sessionsListModelService.js'; import { ISessionsPartService } from '../../services/sessions/browser/sessionsPartService.js'; @@ -120,7 +120,7 @@ export function createSessionViewTestServices(store: Pick, mobile = false, options?: { container?: HTMLElement; instantiationService?: TestInstantiationService; layoutService?: IAgentWorkbenchLayoutService }) { +export function createSessionsPartTestHarness(store: Pick, mobile = false, options?: { container?: HTMLElement; instantiationService?: TestInstantiationService; layoutService?: IAgentWorkbenchLayoutService; compactLayout?: () => boolean; chatBackground?: ISessionsChatBackground }) { const services = createSessionViewTestServices(store, options?.instantiationService); const container = options?.container ?? document.createElement('div'); container.classList.toggle('phone-layout', mobile); @@ -130,11 +130,11 @@ export function createSessionsPartTestHarness(store: Pick() { override readonly onDidChangeBackground = Event.None; - override getBackground() { return undefined; } + override getBackground() { return options?.chatBackground; } }()); if (!options?.container) { document.body.appendChild(container); diff --git a/src/vs/sessions/test/browser/sessionsPart.test.ts b/src/vs/sessions/test/browser/sessionsPart.test.ts index 1e209c5c189611..d0a02aeebc1cd0 100644 --- a/src/vs/sessions/test/browser/sessionsPart.test.ts +++ b/src/vs/sessions/test/browser/sessionsPart.test.ts @@ -5,6 +5,7 @@ import assert from 'assert'; import { EventType } from '../../../base/browser/dom.js'; +import { mainWindow } from '../../../base/browser/window.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../base/test/common/utils.js'; import { SessionsPart } from '../../browser/parts/sessionsPart.js'; import { SessionHarnessPickerVisibleContext, SessionIsolationPickerVisibleContext, SessionWorkspacePickerVisibleContext } from '../../common/contextkeys.js'; @@ -34,7 +35,7 @@ suite('Sessions - Sessions Part', () => { const activateCodicon = Reflect.get(SessionsPart.prototype, 'activateCodicon') as (this: ICodiconActivationTestHarness, element: HTMLElement) => void; - test('session separators stay subtle at rest and preserve contrast borders across theme changes', () => { + test('session gaps omit separators while compact density preserves themed separators', () => { const colors = { [agentsPanelBorder]: '#ff0000', [agentsCardBorder]: '#00ff00' }; const themes = [ new TestColorTheme(colors, ColorScheme.DARK), @@ -48,7 +49,49 @@ suite('Sessions - Sessions Part', () => { const themeService = new TestThemeService(themes[0]); const instantiationService = workbenchInstantiationService(undefined, store); instantiationService.stub(IThemeService, themeService); - const { part, container } = createSessionsPartTestHarness(store, false, { instantiationService }); + let compact = false; + const { part, container } = createSessionsPartTestHarness(store, false, { instantiationService, compactLayout: () => compact }); + const first = createTestActiveSession('first'); + const second = createTestActiveSession('second'); + part.updateVisibleSessions([first, second], first); + part.layout(1202, 802, 0, 0); + const splitView = container.querySelector('.session-grid .monaco-split-view2'); + assert.ok(splitView); + const getStyles = () => ({ + separator: splitView.classList.contains('separator-border'), + separatorColor: splitView.style.getPropertyValue('--separator-border'), + cardBorder: container.style.getPropertyValue('--part-border-color'), + }); + const gapped = [getStyles()]; + for (const theme of themes.slice(1)) { + themeService.setTheme(theme); + gapped.push(getStyles()); + } + themeService.setTheme(themes[0]); + compact = true; + part.layout(1202, 802, 0, 0); + const compactStyles = getStyles(); + assert.deepStrictEqual({ gapped, compact: compactStyles }, { + gapped: themes.map(() => ({ separator: false, separatorColor: '', cardBorder: '#00ff00' })), + compact: { separator: true, separatorColor: 'rgba(255, 0, 0, 0.5)', cardBorder: '#00ff00' }, + }); + }); + + test('compact session separators preserve contrast borders across theme changes', () => { + const colors = { [agentsPanelBorder]: '#ff0000', [agentsCardBorder]: '#00ff00' }; + const themes = [ + new TestColorTheme(colors, ColorScheme.DARK), + new TestColorTheme({ ...colors, [contrastBorder]: '#ffffff' }, ColorScheme.HIGH_CONTRAST_DARK), + new TestColorTheme({ ...colors, [contrastBorder]: '#000000' }, ColorScheme.HIGH_CONTRAST_LIGHT), + new TestColorTheme(colors, ColorScheme.LIGHT), + new TestColorTheme({ ...colors, [contrastBorder]: '#0000ff' }, ColorScheme.DARK), + new TestColorTheme({ ...colors, [agentsPanelBorder]: '#ff000080' }, ColorScheme.LIGHT), + new TestColorTheme({ [agentsCardBorder]: '#00ff00' }, ColorScheme.DARK), + ]; + const themeService = new TestThemeService(themes[0]); + const instantiationService = workbenchInstantiationService(undefined, store); + instantiationService.stub(IThemeService, themeService); + const { part, container } = createSessionsPartTestHarness(store, false, { instantiationService, compactLayout: () => true }); const first = createTestActiveSession('first'); const second = createTestActiveSession('second'); part.updateVisibleSessions([first, second], first); @@ -76,6 +119,78 @@ suite('Sessions - Sessions Part', () => { ]); }); + test('uses floating-panel gaps only in default desktop density', () => { + let compact = false; + const { part, container } = createSessionsPartTestHarness(store, false, { compactLayout: () => compact }); + const first = createTestActiveSession('first'); + const second = createTestActiveSession('second'); + part.updateVisibleSessions([first, second], first); + const snapshot = () => ({ + hasGaps: container.querySelector('.session-grid')?.classList.contains('session-grid-has-gaps'), + insets: [...container.querySelectorAll('.session-grid-leaf')].map(leaf => [ + leaf.style.borderTopWidth, + leaf.style.borderRightWidth, + leaf.style.borderBottomWidth, + leaf.style.borderLeftWidth, + ]), + }); + + part.layout(1202, 802, 0, 0); + const defaultDensity = snapshot(); + compact = true; + part.layout(1202, 802, 0, 0); + const compactDensity = snapshot(); + + assert.deepStrictEqual({ defaultDensity, compactDensity }, { + defaultDensity: { + hasGaps: true, + insets: [ + ['0px', '2px', '0px', '0px'], + ['0px', '0px', '0px', '2px'], + ], + }, + compactDensity: { + hasGaps: false, + insets: [ + ['0px', '0px', '0px', '0px'], + ['0px', '0px', '0px', '0px'], + ], + }, + }); + }); + + test('keeps custom backgrounds visible within gapped session panels', () => { + const { part, container } = createSessionsPartTestHarness(store, false, { chatBackground: { kind: 'codicons' } }); + container.style.setProperty('--vscode-agents-background', '#123456'); + const first = createTestActiveSession('first'); + const second = createTestActiveSession('second'); + part.updateVisibleSessions([first, second], first); + + part.layout(1202, 802, 0, 0); + + const leaves = [...container.querySelectorAll('.session-grid-leaf')]; + const sessionView = container.querySelector('.session-view'); + const background = container.querySelector('.sessions-chat-background'); + assert.ok(sessionView); + assert.ok(background); + assert.deepStrictEqual({ + hasBackground: container.classList.contains('has-chat-background'), + backgroundHidden: background.hidden, + leafBackgrounds: leaves.map(leaf => mainWindow.getComputedStyle(leaf).backgroundColor), + gutters: leaves.map(leaf => { + const style = mainWindow.getComputedStyle(leaf); + return `${style.borderLeftWidth} ${style.borderRightWidth} ${style.borderStyle} ${style.borderColor}`; + }), + sessionBackground: mainWindow.getComputedStyle(sessionView).backgroundColor, + }, { + hasBackground: true, + backgroundHidden: false, + leafBackgrounds: ['rgba(0, 0, 0, 0)', 'rgba(0, 0, 0, 0)'], + gutters: ['0px 2px solid rgb(18, 52, 86)', '2px 0px solid rgb(18, 52, 86)'], + sessionBackground: 'rgba(0, 0, 0, 0)', + }); + }); + function assertActivation(eventFactory: () => Event): void { const { part } = createSessionsPartTestHarness(store); const first = createTestActiveSession('minimized'); @@ -146,7 +261,7 @@ suite('Sessions - Sessions Part', () => { } part.layout(1402, 902, 0, 0); part.layout(1402, 902, 0, 0); - assert.deepStrictEqual(observations, [['700px', '700px'], ['700px', '700px']]); + assert.deepStrictEqual(observations, [['698px', '698px'], ['698px', '698px']]); }); for (const maximized of [false, true]) { diff --git a/src/vs/sessions/test/browser/workbench.test.ts b/src/vs/sessions/test/browser/workbench.test.ts index 2537c8c7244a21..a38702e59a2dea 100644 --- a/src/vs/sessions/test/browser/workbench.test.ts +++ b/src/vs/sessions/test/browser/workbench.test.ts @@ -4,9 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { $, append } from '../../../base/browser/dom.js'; +import { $, append, scheduleAtNextAnimationFrame } from '../../../base/browser/dom.js'; import { Direction, Grid, ISerializableView, ISerializedGrid, ISerializedNode, LayoutPriority, SerializableGrid, Sizing } from '../../../base/browser/ui/grid/grid.js'; -import { DisposableStore, IDisposable } from '../../../base/common/lifecycle.js'; +import { Emitter, type Event as BaseEvent } from '../../../base/common/event.js'; +import { DisposableStore, IDisposable, MutableDisposable } from '../../../base/common/lifecycle.js'; import { IObservable, observableValue } from '../../../base/common/observable.js'; import { SashState } from '../../../base/browser/ui/sash/sash.js'; import { mainWindow } from '../../../base/browser/window.js'; @@ -20,16 +21,18 @@ import { TestConfigurationService } from '../../../platform/configuration/test/c import { DockedAuxiliaryBarController, IDockedAuxiliaryBarHost } from '../../browser/dockedAuxiliaryBarController.js'; import { AgentWorkbenchLayout, ISidePaneState, ISidePaneToggleEvent, Workbench } from '../../browser/workbench.js'; import { DesktopWorkbench, DockedEditorSizeMemento } from '../../browser/desktopWorkbench.js'; +import { AGENTS_SIDE_PANE_MULTIPLE_TABS_CLASS } from '../../browser/parts/agentsPartCard.js'; import { DesktopMainEditorPart } from '../../browser/parts/desktopEditorPart.js'; +import { MainEditorPart } from '../../browser/parts/editorPart.js'; import { EditorParts } from '../../browser/parts/editorParts.js'; import { DockedEditorInput } from '../../common/dockedEditorInput.js'; -import { EditorInputCapabilities } from '../../../workbench/common/editor.js'; +import { EditorInputCapabilities, IEditorPartOptions, IEditorPartOptionsChangeEvent } from '../../../workbench/common/editor.js'; import { GroupDirection, GroupOrientation } from '../../../workbench/services/editor/common/editorGroupsService.js'; 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'; -import { IEditorGroupViewOptions } from '../../../workbench/browser/parts/editor/editor.js'; +import { DEFAULT_EDITOR_PART_OPTIONS, IEditorGroupViewOptions } from '../../../workbench/browser/parts/editor/editor.js'; import { EditorInput } from '../../../workbench/common/editor/editorInput.js'; import '../../browser/parts/media/chatCompositeBar.css'; @@ -691,7 +694,7 @@ suite('Sessions - Workbench', () => { sessionViewAbovePanel: ['7px', '7px', '7px', '7px'], sessionViewBorder: ['7px', '7px', '11px', '11px'], internalSessionView: ['7px', '7px', '7px', '7px'], - sessionViewWithoutConnectedTabs: ['0px', '0px', '0px', '0px'], + sessionViewWithoutConnectedTabs: ['7px', '7px', '11px', '11px'], sessionViewWithoutModernTabs: ['0px', '0px', '0px', '0px'], customView: ['8px', '8px', '12px', '8px'], editor: ['8px', '8px', '12px', '8px'], @@ -2244,6 +2247,87 @@ suite('Sessions - Workbench', () => { }); }); + test('desktop editor part tracks effective tab presentation through its content lifecycle', async () => { + interface ITabsPresentationLifecycleHarness { + readonly partOptions: IEditorPartOptions; + readonly onDidChangeEditorPartOptions: BaseEvent; + readonly agentWorkbenchLayoutService: { readonly mainContainer: HTMLElement; layout(): void }; + readonly _tabsPresentationRelayout: MutableDisposable; + _updateTabsOverride(): void; + _register(disposable: T): T; + _registerGroupRelayoutListeners(): never; + } + + const root = append(mainWindow.document.body, $('.monaco-workbench.agent-sessions-workbench')); + const disposables = new DisposableStore(); + const optionsEmitter = disposables.add(new Emitter()); + const relayout = disposables.add(new MutableDisposable()); + const originalCreateContentArea = Reflect.get(MainEditorPart.prototype, 'createContentArea'); + const stopAfterTabsLifecycle = new Error('Tabs lifecycle registered'); + let partOptions: IEditorPartOptions = { ...DEFAULT_EDITOR_PART_OPTIONS, showTabs: 'single' }; + let layouts = 0; + + Reflect.set(MainEditorPart.prototype, 'createContentArea', function (this: { element: HTMLElement }, parent: HTMLElement) { + this.element = parent; + return parent; + }); + try { + const editorPart = Object.assign(Object.create(DesktopMainEditorPart.prototype), { + onDidChangeEditorPartOptions: optionsEmitter.event, + agentWorkbenchLayoutService: { + mainContainer: root, + layout: () => layouts++, + }, + _tabsPresentationRelayout: relayout, + _updateTabsOverride: () => { }, + _register(disposable: T): T { + return disposables.add(disposable); + }, + _registerGroupRelayoutListeners(): never { + throw stopAfterTabsLifecycle; + }, + }) as ITabsPresentationLifecycleHarness; + Object.defineProperty(editorPart, 'partOptions', { get: () => partOptions }); + const createContentArea = Reflect.get(DesktopMainEditorPart.prototype, 'createContentArea') as (this: ITabsPresentationLifecycleHarness, parent: HTMLElement) => HTMLElement; + let thrown: unknown; + try { + createContentArea.call(editorPart, root); + } catch (error) { + thrown = error; + } + + const states = [{ + showTabs: partOptions.showTabs, + multipleTabsClass: root.classList.contains(AGENTS_SIDE_PANE_MULTIPLE_TABS_CLASS), + layouts, + }]; + for (const showTabs of ['multiple', 'single'] as const) { + const oldPartOptions = partOptions; + partOptions = { ...partOptions, showTabs }; + optionsEmitter.fire({ oldPartOptions, newPartOptions: partOptions }); + await new Promise(resolve => disposables.add(scheduleAtNextAnimationFrame(mainWindow, resolve))); + states.push({ + showTabs, + multipleTabsClass: root.classList.contains(AGENTS_SIDE_PANE_MULTIPLE_TABS_CLASS), + layouts, + }); + } + + assert.deepStrictEqual({ thrown, states }, { + thrown: stopAfterTabsLifecycle, + states: [ + { showTabs: 'single', multipleTabsClass: false, layouts: 0 }, + { showTabs: 'multiple', multipleTabsClass: true, layouts: 1 }, + { showTabs: 'single', multipleTabsClass: false, layouts: 2 }, + ], + }); + } finally { + Reflect.set(MainEditorPart.prototype, 'createContentArea', originalCreateContentArea); + disposables.dispose(); + root.remove(); + } + }); + test('desktop editor part initializes tabs from restored visibility at content creation', () => { interface ITabsOverrideLifecycleHarness { configurationService: { getValue(): 'single' }; diff --git a/src/vs/workbench/api/browser/mainThreadUriOpeners.ts b/src/vs/workbench/api/browser/mainThreadUriOpeners.ts index 39fc994eb63d5b..e8ac825e5f33b9 100644 --- a/src/vs/workbench/api/browser/mainThreadUriOpeners.ts +++ b/src/vs/workbench/api/browser/mainThreadUriOpeners.ts @@ -74,7 +74,11 @@ export class MainThreadUriOpeners extends Disposable implements MainThreadUriOpe }, openExternalUri: async (uri, ctx, token) => { try { - await this.proxy.$openUri(id, { resolvedUri: uri, sourceUri: ctx.sourceUri }, token); + await this.proxy.$openUri(id, { + resolvedUri: uri, + sourceUri: ctx.sourceUri, + viewColumn: ctx.viewColumn + }, token); } catch (e) { if (!isCancellationError(e)) { const openDefaultAction = new Action('default', localize('openerFailedUseDefault', "Open using default opener"), undefined, undefined, async () => { diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index c48345c0df93f1..cb8fb1da647372 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -35,7 +35,7 @@ import { ILocalizedString } from '../../../platform/action/common/action.js'; import { ConfigurationTarget, IConfigurationChange, IConfigurationData, IConfigurationOverrides } from '../../../platform/configuration/common/configuration.js'; import { LinkPresentationKind } from '../../../platform/dataChannel/common/dataChannel.js'; import { ConfigurationScope } from '../../../platform/configuration/common/configurationRegistry.js'; -import { IEditorOptions } from '../../../platform/editor/common/editor.js'; +import { IEditorOptions, ViewColumn } from '../../../platform/editor/common/editor.js'; import { IExtensionIdWithVersion } from '../../../platform/extensionManagement/common/extensionStorage.js'; import { ExtensionIdentifier, IExtensionDescription } from '../../../platform/extensions/common/extensions.js'; import * as files from '../../../platform/files/common/files.js'; @@ -1948,7 +1948,7 @@ export interface MainThreadUriOpenersShape extends IDisposable { export interface ExtHostUriOpenersShape { $canOpenUri(id: string, uri: UriComponents, token: CancellationToken): Promise; - $openUri(id: string, context: { resolvedUri: UriComponents; sourceUri: UriComponents }, token: CancellationToken): Promise; + $openUri(id: string, context: { resolvedUri: UriComponents; sourceUri: UriComponents; viewColumn?: ViewColumn }, token: CancellationToken): Promise; } export interface MainThreadChatOutputRendererShape extends IDisposable { diff --git a/src/vs/workbench/api/common/extHostTypes.ts b/src/vs/workbench/api/common/extHostTypes.ts index d7ef2163ca3abb..5766210a4c572f 100644 --- a/src/vs/workbench/api/common/extHostTypes.ts +++ b/src/vs/workbench/api/common/extHostTypes.ts @@ -33,6 +33,7 @@ import { WorkspaceEdit } from './extHostTypes/workspaceEdit.js'; import { HookTypeValue } from '../../contrib/chat/common/promptSyntax/hookTypes.js'; export { CodeActionKind } from './extHostTypes/codeActionKind.js'; +export { ViewColumn } from '../../../platform/editor/common/editor.js'; export { Diagnostic, DiagnosticRelatedInformation, DiagnosticSeverity, DiagnosticTag @@ -644,20 +645,6 @@ export enum InlineCompletionDisplayLocationKind { Label = 2 } -export enum ViewColumn { - Active = -1, - Beside = -2, - One = 1, - Two = 2, - Three = 3, - Four = 4, - Five = 5, - Six = 6, - Seven = 7, - Eight = 8, - Nine = 9 -} - export enum StatusBarAlignment { Left = 1, Right = 2 diff --git a/src/vs/workbench/api/common/extHostUriOpener.ts b/src/vs/workbench/api/common/extHostUriOpener.ts index 7e97bb4fba017d..203ddc0afbd9c7 100644 --- a/src/vs/workbench/api/common/extHostUriOpener.ts +++ b/src/vs/workbench/api/common/extHostUriOpener.ts @@ -8,6 +8,7 @@ import { toDisposable } from '../../../base/common/lifecycle.js'; import { Schemas } from '../../../base/common/network.js'; import { URI, UriComponents } from '../../../base/common/uri.js'; import * as languages from '../../../editor/common/languages.js'; +import { ViewColumn } from '../../../platform/editor/common/editor.js'; import { ExtensionIdentifier } from '../../../platform/extensions/common/extensions.js'; import type * as vscode from 'vscode'; import { ExtHostUriOpenersShape, IMainContext, MainContext, MainThreadUriOpenersShape } from './extHost.protocol.js'; @@ -61,14 +62,15 @@ export class ExtHostUriOpeners implements ExtHostUriOpenersShape { return opener.canOpenExternalUri(uri, token); } - async $openUri(id: string, context: { resolvedUri: UriComponents; sourceUri: UriComponents }, token: CancellationToken): Promise { + async $openUri(id: string, context: { resolvedUri: UriComponents; sourceUri: UriComponents; viewColumn?: ViewColumn }, token: CancellationToken): Promise { const opener = this._openers.get(id); if (!opener) { throw new Error(`Unknown opener id: '${id}'`); } return opener.openExternalUri(URI.revive(context.resolvedUri), { - sourceUri: URI.revive(context.sourceUri) + sourceUri: URI.revive(context.sourceUri), + viewColumn: context.viewColumn }, token); } } diff --git a/src/vs/workbench/api/test/browser/extHostUriOpener.test.ts b/src/vs/workbench/api/test/browser/extHostUriOpener.test.ts new file mode 100644 index 00000000000000..1ff398c61406eb --- /dev/null +++ b/src/vs/workbench/api/test/browser/extHostUriOpener.test.ts @@ -0,0 +1,82 @@ +/*--------------------------------------------------------------------------------------------- + * 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 type * as vscode from 'vscode'; +import { URI } from '../../../../base/common/uri.js'; +import { mock } from '../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { OpenerService } from '../../../../editor/browser/services/openerService.js'; +import { ExternalUriOpenerPriority } from '../../../../editor/common/languages.js'; +import { TestCodeEditorService } from '../../../../editor/test/browser/editorTestServices.js'; +import { NullCommandService } from '../../../../platform/commands/test/common/nullCommandService.js'; +import { TestConfigurationService } from '../../../../platform/configuration/test/common/testConfigurationService.js'; +import { ExtensionIdentifier } from '../../../../platform/extensions/common/extensions.js'; +import { NullLogService } from '../../../../platform/log/common/log.js'; +import { TestNotificationService } from '../../../../platform/notification/test/common/testNotificationService.js'; +import { IQuickInputService } from '../../../../platform/quickinput/common/quickInput.js'; +import { TestThemeService } from '../../../../platform/theme/test/common/testThemeService.js'; +import { ExternalUriOpenerService } from '../../../contrib/externalUriOpener/common/externalUriOpenerService.js'; +import { IPreferencesService } from '../../../services/preferences/common/preferences.js'; +import { TestExtensionService, TestStorageService } from '../../../test/common/workbenchTestServices.js'; +import { MainThreadUriOpeners } from '../../browser/mainThreadUriOpeners.js'; +import { ExtHostContext, MainContext } from '../../common/extHost.protocol.js'; +import { ExtHostUriOpeners } from '../../common/extHostUriOpener.js'; +import { ViewColumn } from '../../common/extHostTypes.js'; +import { TestRPCProtocol } from '../common/testRPCProtocol.js'; + +suite('ExtHostUriOpeners', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + const sourceUri = URI.parse('https://source.example.com'); + const resolvedUri = URI.parse('https://resolved.example.com'); + let openerService: OpenerService; + let opened: { uri: vscode.Uri; context: vscode.OpenExternalUriContext }[]; + + setup(async () => { + opened = []; + const rpc = store.add(new TestRPCProtocol()); + openerService = store.add(new OpenerService( + store.add(new TestCodeEditorService(new TestThemeService())), + NullCommandService + )); + const externalUriOpenerService = store.add(new ExternalUriOpenerService( + openerService, + new TestConfigurationService(), + new NullLogService(), + new class extends mock() { }, + new class extends mock() { } + )); + const extHostUriOpeners = rpc.set(ExtHostContext.ExtHostUriOpeners, new ExtHostUriOpeners(rpc)); + rpc.set(MainContext.MainThreadUriOpeners, store.add(new MainThreadUriOpeners( + rpc, + store.add(new TestStorageService()), + externalUriOpenerService, + new TestExtensionService(), + openerService, + new TestNotificationService() + ))); + store.add(openerService.registerExternalUriResolver({ + async resolveExternalUri() { + return { resolved: resolvedUri, dispose() { } }; + } + })); + store.add(extHostUriOpeners.registerExternalUriOpener(new ExtensionIdentifier('test.opener'), 'test-opener', { + canOpenExternalUri: () => ExternalUriOpenerPriority.Preferred, + openExternalUri: (uri, context) => { opened.push({ uri, context }); } + }, { schemes: ['https'], label: 'Test Opener' })); + await rpc.sync(); + }); + + for (const openToSide of [true, false, undefined]) { + test(`maps internal openToSide=${openToSide} to viewColumn`, async () => { + await openerService.open(sourceUri, { allowContributedOpeners: true, openToSide }); + + assert.deepStrictEqual(opened, [{ + uri: resolvedUri, + context: { sourceUri, viewColumn: openToSide ? ViewColumn.Beside : undefined } + }]); + }); + } +}); diff --git a/src/vs/workbench/contrib/accessibility/browser/accessibilityConfiguration.ts b/src/vs/workbench/contrib/accessibility/browser/accessibilityConfiguration.ts index f2e87aa28c5a63..403ef7f7c55247 100644 --- a/src/vs/workbench/contrib/accessibility/browser/accessibilityConfiguration.ts +++ b/src/vs/workbench/contrib/accessibility/browser/accessibilityConfiguration.ts @@ -52,6 +52,7 @@ export const enum AccessibilityVerbositySettingId { MergeEditor = 'accessibility.verbosity.mergeEditor', Chat = 'accessibility.verbosity.panelChat', CustomizationMigrations = 'accessibility.verbosity.customizationMigrations', + McpServerConfiguration = 'accessibility.verbosity.mcpServerConfiguration', CustomizationDiscovery = 'accessibility.verbosity.customizationDiscovery', InlineChat = 'accessibility.verbosity.inlineChat', TerminalInlineChat = 'accessibility.verbosity.terminalChat', @@ -159,6 +160,10 @@ const configuration: IConfigurationNode = { description: localize('verbosity.customizationMigrations.description', "Provide information about how to access accessibility help for the customization migration checklist."), ...baseVerbosityProperty }, + [AccessibilityVerbositySettingId.McpServerConfiguration]: { + description: localize('verbosity.mcpServerConfiguration.description', "Provide information about how to access accessibility help when an MCP server configuration form is focused."), + ...baseVerbosityProperty + }, [AccessibilityVerbositySettingId.CustomizationDiscovery]: { description: localize('verbosity.customizationDiscovery.description', "Provide information about how to access accessibility help when discovering customizations."), ...baseVerbosityProperty diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts index 6209b95ea9a82e..0b3f045ca1e1fc 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts @@ -97,12 +97,12 @@ export function getAccessibilityHelpText(type: 'panelChat' | 'inlineChat' | 'qui : localize('chat.sessionArchiveNudge', "An archive suggestion appears above the chat input when the session's pull requests are merged. Use Tab or Shift+Tab to reach Archive, Configure Automatic Cleanup, or Dismiss Archive Suggestion, then press Enter or Space. Configure Automatic Cleanup opens the settings for automatically archiving inactive merged sessions and permanently deleting automatically archived merged sessions. The explanation under What Does \"Archive\" Do? is collapsed by default. Use Tab to reach it and Enter or Space to expand or collapse it. Dismissing the suggestion, including with Escape while it is focused, returns to the chat input. Archiving the session hides it from the sessions list so you can focus on your remaining tasks. The session is not deleted. Ask your agent to find it, or look in the \"Archived\" section of the sessions list. You can unarchive it anytime. The worktree created for the session, if any, will be deleted. You can recreate it by unarchiving the session.")); } if (type === 'panelChat' || type === 'quickChat' || type === 'editsView' || type === 'agentView') { - content.push(localize('chat.modelPicker.autoMode', "In the model picker, the active Copilot tab has an Auto switch beside the provider name. The provider button and switch are separate keyboard targets. Turning Auto on replaces Copilot's individual models with automatic routing choices; provider tabs and search remain available. Turn Auto off to restore the previous manual Copilot model. The switch is disabled when no individual Copilot model is available. Browsing another provider does not change the selected model.")); + content.push(localize('chat.modelPicker.autoMode', "In the model picker, Auto is the first model in the Copilot tab and is selected like any other model. Provider tabs and search remain available. Browsing another provider does not change the selected model.")); const tabbedPickerKeys = getTabbedActionListKeybindingLabels(keybindingService); content.push(localize('chat.modelPicker.providerTabs', "In the model picker, use {0} and {1} to switch to the next or previous provider tab. When a provider tab is focused, Left and Right Arrow also switch tabs.", tabbedPickerKeys.nextTab, tabbedPickerKeys.previousTab)); - content.push(localize('chat.modelPicker.managedDefault', "In the model picker, Org default identifies the model your organization sets for new chats. It remains on that model when you choose another model for this chat; the checkmark identifies your current selection. The Auto section carries the same label when Auto is the organization default.")); + content.push(localize('chat.modelPicker.managedDefault', "In the model picker, Org default identifies the model your organization sets for new chats. It remains on that model when you choose another model for this chat; the checkmark identifies your current selection. Auto carries the same label when it is the organization default.")); content.push(localize('chat.modelPicker.detailsPage', "In the model picker, thinking effort and context appear as a configuration button on the hovered, selected, or keyboard-focused model. Use Up and Down Arrow to focus a model and Enter to select it. To open its information and configuration page without selecting it, press Right Arrow, or use Tab to reach its configuration button and press Enter or Space. The button shows Details when no readout is available. Models without configurable settings still open their information page. The thinking effort and context readout beside the model name in the chat input opens the same page for the current model. Opening the page focuses its configuration controls when available. Known values are shown even at their defaults. The Back to Models arrow is beside the model name; navigation and model actions stay at the top while details scroll. Back to Models, or {0}, returns to the previous provider, search, and model list with focus on that model. Escape closes the picker from any page and restores focus.", tabbedPickerKeys.back)); - content.push(localize('chat.modelPicker.optimizeFor', "In Auto mode, use Up and Down Arrow to focus an \"Optimize for\" preference: Efficiency, Balance, or Intelligence. Enter or Space applies the preference and keeps the picker open. HydraFusion, when available, is an alternative routing choice, not an additional toggle. Focus HydraFusion and press Tab to reach Learn more. Selecting it clears the active Auto tier; selecting a tier returns to Auto. Turning Auto off preserves the preference and routing choice. Auto has no separate details page: both the Auto name and preference readout in the chat input open these routing choices.")); + content.push(localize('chat.modelPicker.optimizeFor', "Auto's details hold its \"Optimize for\" preference: Efficiency, Balance, or Intelligence, followed by HydraFusion when available. Open them from Auto like any model's details, then use arrow keys to move between preferences and Enter or Space to choose one. Choosing a preference selects Auto and keeps its details open. HydraFusion is a research preview: choosing it selects HydraFusion and shows its description below the preferences, with Learn more reachable by Tab. While HydraFusion is in use, Auto stays checked in the model list with HydraFusion as its preference; choosing another preference returns to Auto. The preference readout beside Auto in the chat input opens the same details.")); content.push(localize('chat.modelPicker.resetToDefault', "In a model's details, use Tab to reach thinking effort, context, and model actions. Use arrow keys to move between options, then Enter or Space to choose one. Reset to Default appears beside Pin Model when thinking effort or context has been changed. It restores both settings to the model's defaults without changing its pinned state. Choosing an option or resetting the settings selects that model and keeps its details open. Pinning or unpinning updates the model list without moving its details or keyboard focus. Back does not undo saved settings, but a save that finishes after leaving details does not override a newer model selection.")); content.push(localize('chat.modelPicker.pricingDetails', "Pricing Details expands in place without moving the model's controls. Expansion and collapse are immediate when reduced motion is enabled. If the details exceed the available space, use Page Up or Page Down while the model details have focus to scroll.")); content.push(localize('chat.modelPicker.search', "Type while the model list is focused to search across all providers. In the search field, use Up and Down Arrow to navigate results, Enter to select a model, and Escape to close the picker. Left and Right Arrow move the text cursor.")); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatCodeblockActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatCodeblockActions.ts index b798cd354d4664..62a42798c852ae 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatCodeblockActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatCodeblockActions.ts @@ -35,7 +35,8 @@ import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { ChatCopyKind, IChatService } from '../../common/chatService/chatService.js'; import { getAgentHostProviderForTelemetry, IChatSessionsService, isAgentHostSessionResource } from '../../common/chatSessionsService.js'; import { getChatSessionType } from '../../common/model/chatUri.js'; -import { getChatSessionTelemetryContext } from '../../common/chatService/chatServiceTelemetry.js'; +import { getChatSessionTelemetryIds } from '../../common/chatService/chatServiceTelemetry.js'; +import { IAgentHostConnectionsService } from '../../../../../platform/agentHost/common/agentHostConnectionsService.js'; import { IChatRequestViewModel, IChatResponseViewModel, isRequestVM, isResponseVM } from '../../common/model/chatViewModel.js'; import { ChatAgentLocation } from '../../common/constants.js'; import { IChatCodeBlockContextProviderService, IChatWidgetService } from '../chat.js'; @@ -167,6 +168,7 @@ export function registerChatCodeBlockActions() { const aiEditTelemetryService = accessor.get(IAiEditTelemetryService); const chatService = accessor.get(IChatService); const chatSessionsService = accessor.get(IChatSessionsService); + const sessionTelemetry = isResponseVM(context.element) ? getChatSessionTelemetryIds(context.element.sessionResource, accessor.get(IAgentHostConnectionsService)) : undefined; await clipboardService.writeText(context.code); if (isResponseVM(context.element)) { @@ -205,7 +207,7 @@ export function registerChatCodeBlockActions() { applyCodeBlockSuggestionId: undefined, source: undefined, sourceRequestId: requestId, - chatSessionId: getChatSessionTelemetryContext(context.element.sessionResource).chatSessionId, + ...sessionTelemetry, isAgentHostSession: isAgentHostSessionResource(context.element.sessionResource), provider: getAgentHostProviderForTelemetry(getChatSessionType(context.element.sessionResource), chatSessionsService), }); @@ -239,6 +241,7 @@ export function registerChatCodeBlockActions() { const chatService = accessor.get(IChatService); const aiEditTelemetryService = accessor.get(IAiEditTelemetryService); const chatSessionsService = accessor.get(IChatSessionsService); + const sessionTelemetry = isResponseVM(context.element) ? getChatSessionTelemetryIds(context.element.sessionResource, accessor.get(IAgentHostConnectionsService)) : undefined; const element = context.element; const reportCopy = () => { if (!isResponseVM(element)) { @@ -279,7 +282,7 @@ export function registerChatCodeBlockActions() { applyCodeBlockSuggestionId: undefined, source: undefined, sourceRequestId: requestId, - chatSessionId: getChatSessionTelemetryContext(element.sessionResource).chatSessionId, + ...sessionTelemetry, isAgentHostSession: isAgentHostSessionResource(element.sessionResource), provider: getAgentHostProviderForTelemetry(getChatSessionType(element.sessionResource), chatSessionsService), }); @@ -408,6 +411,7 @@ export function registerChatCodeBlockActions() { const chatService = accessor.get(IChatService); const aiEditTelemetryService = accessor.get(IAiEditTelemetryService); const chatSessionsService = accessor.get(IChatSessionsService); + const sessionTelemetry = isResponseVM(context.element) ? getChatSessionTelemetryIds(context.element.sessionResource, accessor.get(IAgentHostConnectionsService)) : undefined; const editor = await editorService.openEditor({ contents: context.code, languageId: context.languageId, resource: undefined } satisfies IUntitledTextResourceEditorInput); @@ -445,7 +449,7 @@ export function registerChatCodeBlockActions() { applyCodeBlockSuggestionId: undefined, source: undefined, sourceRequestId: requestId, - chatSessionId: getChatSessionTelemetryContext(context.element.sessionResource).chatSessionId, + ...sessionTelemetry, isAgentHostSession: isAgentHostSessionResource(context.element.sessionResource), provider: getAgentHostProviderForTelemetry(getChatSessionType(context.element.sessionResource), chatSessionsService), }); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatPluginActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatPluginActions.ts index c382420df4b521..9a8fca5404a0b9 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatPluginActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatPluginActions.ts @@ -230,6 +230,7 @@ export class ManagePluginMarketplacesAction extends Action2 { while (true) { const { extraValues, effectiveValues } = readConfiguredMarketplaces(configurationService); const refs = parseMarketplaceReferences(effectiveValues); + const strictMarketplaces = getStrictKnownMarketplaces(configurationService.getValue(ChatConfiguration.StrictMarketplaces)); const policyCanonicalIds = new Set(parseMarketplaceReferences(extraValues).map(reference => reference.canonicalId)); const defaultCanonicalIds = new Set(parseMarketplaceReferences( configurationService.inspect(ChatConfiguration.PluginMarketplaces)?.defaultValue ?? [] @@ -246,9 +247,11 @@ export class ManagePluginMarketplacesAction extends Action2 { if (refs.length > 0) { items.push({ type: 'separator', label: localize('configuredMarketplaces', "Configured Marketplaces") }); items.push(...refs.map(reference => { + const allowed = isMarketplaceReferenceAllowed(strictMarketplaces, reference); const descriptions = [ defaultCanonicalIds.has(reference.canonicalId) ? localize('defaultMarketplace', "Default") : undefined, - policyCanonicalIds.has(reference.canonicalId) ? localize('managedMarketplace', "Managed by Enterprise Policy") : undefined, + policyCanonicalIds.has(reference.canonicalId) ? localize('managedMarketplace', "Managed by Organization") : undefined, + !allowed ? localize('disabledMarketplace', "Disabled by Organization") : undefined, ].filter((description): description is string => description !== undefined); return { id: reference.canonicalId, @@ -260,6 +263,8 @@ export class ManagePluginMarketplacesAction extends Action2 { kind: 'marketplace' as const, reference, managedByPolicy: policyCanonicalIds.has(reference.canonicalId), + disabled: !allowed, + pickable: allowed, }; })); } @@ -380,10 +385,6 @@ export class ManagePluginMarketplacesAction extends Action2 { if (configured.some(candidate => candidate.canonicalId === reference.canonicalId)) { return localize('marketplaceAlreadyConfigured', "This marketplace is already configured."); } - const allowlist = getStrictKnownMarketplaces(configurationService.getValue(ChatConfiguration.StrictMarketplaces)); - if (!isMarketplaceReferenceAllowed(allowlist, reference)) { - return localize('marketplaceNotAllowed', "This marketplace is not allowed by enterprise policy."); - } return undefined; } } diff --git a/src/vs/workbench/contrib/chat/browser/actions/codeBlockOperations.ts b/src/vs/workbench/contrib/chat/browser/actions/codeBlockOperations.ts index 529f3a0da549ff..fdffea2a14ec88 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/codeBlockOperations.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/codeBlockOperations.ts @@ -38,7 +38,8 @@ import { ICodeMapperCodeBlock, ICodeMapperRequest, ICodeMapperResponse, ICodeMap import { ChatUserAction, IChatService } from '../../common/chatService/chatService.js'; import { getAgentHostProviderForTelemetry, IChatSessionsService, isAgentHostSessionResource } from '../../common/chatSessionsService.js'; import { getChatSessionType } from '../../common/model/chatUri.js'; -import { getChatSessionTelemetryContext } from '../../common/chatService/chatServiceTelemetry.js'; +import { getChatSessionTelemetryIds } from '../../common/chatService/chatServiceTelemetry.js'; +import { IAgentHostConnectionsService } from '../../../../../platform/agentHost/common/agentHostConnectionsService.js'; import { IChatRequestViewModel, isRequestVM, isResponseVM } from '../../common/model/chatViewModel.js'; import { ICodeBlockActionContext } from '../widget/chatContentParts/codeBlockPart.js'; @@ -53,6 +54,7 @@ export class InsertCodeBlockOperation { @IDialogService private readonly dialogService: IDialogService, @IAiEditTelemetryService private readonly aiEditTelemetryService: IAiEditTelemetryService, @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, + @IAgentHostConnectionsService private readonly agentHostConnectionsService: IAgentHostConnectionsService, ) { } @@ -96,7 +98,7 @@ export class InsertCodeBlockOperation { applyCodeBlockSuggestionId: undefined, source: undefined, sourceRequestId: requestId, - chatSessionId: getChatSessionTelemetryContext(context.element.sessionResource).chatSessionId, + ...getChatSessionTelemetryIds(context.element.sessionResource, this.agentHostConnectionsService), isAgentHostSession: isAgentHostSessionResource(context.element.sessionResource), provider: getAgentHostProviderForTelemetry(getChatSessionType(context.element.sessionResource), this.chatSessionsService), }); 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 91dee6c4e9292f..8f07d1268cd6a8 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts @@ -51,7 +51,7 @@ import { IWorkspaceContextService } from '../../../../../../platform/workspace/c import type { IChatWidget } from '../../chat.js'; import { ChatConfiguration, ChatPermissionLevel, isChatPermissionLevel } from '../../../common/constants.js'; import { SessionType } from '../../../common/chatSessionsService.js'; -import { isAutoApprovePolicyRestricted, isAutoApproveValuePolicyRestricted, normalizeSessionConfigValue } from '../../../common/agentHostConfigPolicy.js'; +import { disableGlobalAutoApproveForPermissionSelection, isAutoApprovePolicyRestricted, isAutoApproveValuePolicyRestricted, normalizeSessionConfigValue } from '../../../common/agentHostConfigPolicy.js'; import { maybeConfirmElevatedPermissionLevel } from '../../../common/chatPermissionWarnings.js'; import { getChatSessionType, isUntitledChatSession } from '../../../common/model/chatUri.js'; import { withChatInputPickerMotion } from '../../widget/input/chatInputPickerActionItem.js'; @@ -1185,6 +1185,9 @@ export class AgentHostChatInputPicker extends Disposable { : approval ? writeSessionApprovalLevel(approval, approvalValue) : normalizeSessionConfigValue(ctx.key, value, isAutoApprovePolicyRestricted(this._configurationService, ctx.configSchema)); validateSessionConfigWrite(ctx.configSchema, ctx.values, ctx.key, normalizedValue, isUntitledChatSession(sessionResource)); + if (property === SessionConfigKey.AutoApprove && !this._generic && isChatPermissionLevel(value)) { + await disableGlobalAutoApproveForPermissionSelection(this._configurationService, value); + } const partial = { [ctx.key]: normalizedValue }; const nextConfig = filterSessionConfigValues(ctx.configSchema, { ...this._readCurrentValues(), ...partial }); diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts index fde2aa46156971..49f78630f206f4 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts @@ -377,6 +377,7 @@ export class AICustomizationManagementEditor extends EditorPane { private editorActionButtonIcon!: HTMLElement; private editorModeButton: HTMLButtonElement | undefined; private editorMigrationDetailActions: IMigrationDetailActions | undefined; + private editorMigrationDetailWarning: HTMLElement | undefined; private editorActionButtonInProgress = false; private editorDisplayMode: 'preview' | 'raw' = 'preview'; private currentCustomizationDetail = false; @@ -1756,7 +1757,7 @@ export class AICustomizationManagementEditor extends EditorPane { const confirmed = await this.dialogService.confirm({ type: 'question', message: confirmation.message, - detail: this.getFileMigrationConfirmationDetail(confirmation.detail, files, targetFolders), + detail: confirmation.detail, primaryButton: confirmation.primaryButton, ...(confirmation.deleteOriginalsLabel ? { checkbox: { label: confirmation.deleteOriginalsLabel, checked: true } } : {}), }); @@ -1820,7 +1821,7 @@ export class AICustomizationManagementEditor extends EditorPane { const confirmResult = await this.dialogService.confirm({ type: servers.some(server => Object.keys(server.removedProperties ?? {}).length > 0) ? 'warning' : 'question', message: confirmation.message, - detail: this.getMcpMigrationConfirmationDetail(confirmation.detail, servers), + detail: confirmation.detail, primaryButton: confirmation.primaryButton, }); if (!confirmResult.confirmed || !this.isCustomizationMigrationSessionActive(sessionResource)) { @@ -1867,44 +1868,6 @@ export class AICustomizationManagementEditor extends EditorPane { } } - private getFileMigrationConfirmationDetail( - detail: string, - files: readonly MigratableConfiguration[], - targetFolders: CustomizationMigrationTargetFolders, - ): string { - const changes = files.flatMap(file => { - const folder = this.getEffectiveCustomizationMigrationTargetFolder(file, targetFolders); - if (!folder) { - return []; - } - const source = this.labelService.getUriLabel(file.uri, { relative: file.storage === PromptsStorage.local }); - const destination = file.type === PromptsType.agent || file.type === PromptsType.instructions - ? this.labelService.getUriLabel(URI.joinPath(folder.uri, basename(file.uri)), { relative: file.storage === PromptsStorage.local }) - : this.getCustomizationMigrationFolderLabel(folder); - return [localize( - 'fileMigrationConfirmationChange', - "• {0}\n From: {1}\n Destination: {2}", - file.name ?? basename(file.uri), - source, - destination, - )]; - }); - return changes.length - ? localize('migrationConfirmationWithChanges', "{0}\n\nPlanned changes:\n{1}", detail, changes.join('\n')) - : detail; - } - - private getMcpMigrationConfirmationDetail(detail: string, servers: readonly IMcpServerCustomizationMigrationCandidate[]): string { - const changes = servers.map(server => localize( - 'mcpMigrationConfirmationChange', - "• {0}\n From: {1}\n To: {2}", - server.name, - this.labelService.getUriLabel(server.sourceUri, { relative: server.storage === PromptsStorage.local }), - this.labelService.getUriLabel(server.targetUri, { relative: server.storage === PromptsStorage.local }), - )); - return localize('migrationConfirmationWithChanges', "{0}\n\nPlanned changes:\n{1}", detail, changes.join('\n')); - } - private getConfiguredLocationSettingsToClear(category: ICustomizationMigrationCategory, customizations: readonly MigratableConfiguration[]): readonly string[] { if (category.id !== CustomizationMigrationCategoryId.ConfiguredLocations) { return []; @@ -2327,8 +2290,8 @@ export class AICustomizationManagementEditor extends EditorPane { private getHomepageMigrationCategory(id: CustomizationMigrationCategoryId, candidates: readonly CustomizationMigrationCandidate[], storage: PromptsStorage): ICustomizationMigrationDashboardCategory { const count = candidates.length; const scopeLabel = storage === PromptsStorage.local ? localize('migrationWorkspaceScope', "Workspace") : localize('migrationUserScope', "User"); + const category = getCustomizationMigrationCategory(id); const items = candidates.map((candidate, index) => { - const category = getCustomizationMigrationCategory(id); const sourceUri = isMcpServerCustomizationMigrationCandidate(candidate) ? candidate.sourceUri : candidate.uri; return { id: `${id}:${storage}:candidate:${index}:${sourceUri.toString()}`, @@ -2350,7 +2313,7 @@ export class AICustomizationManagementEditor extends EditorPane { return { id, count, label: this.getMigrationDashboardCategoryLabel(id), - description: localize('migrationChecklistPromptsDescription', "Convert reusable prompt files into skills so agents can discover and run them as supported customizations."), + description: category.preMigrationConsequences ?? '', countLabel: count === 1 ? localize('migrationChecklistOnePrompt', "1 prompt") : localize('migrationChecklistPromptsCount', "{0} prompts", count), highRisk: true, migrateDisabled: this.customizationMigrationInProgress || selectedCount === 0, @@ -2602,12 +2565,15 @@ export class AICustomizationManagementEditor extends EditorPane { const key = this.getCustomizationMigrationTargetKey(destination.targetType, destination.storage); const currentFolder = this.selectedCustomizationMigrationTargets.get(key); const availableFolders = this.getCustomizationMigrationFolders(destination.targetType, destination.storage); - const picks: IMigrationDestinationQuickPickItem[] = availableFolders.map(folder => ({ + const folders = currentFolder && !availableFolders.some(folder => isEqual(folder.uri, currentFolder.uri)) + ? [...availableFolders, currentFolder] + : availableFolders; + const picks: IMigrationDestinationQuickPickItem[] = folders.map(folder => ({ label: folder.label, description: this.getCustomizationMigrationFolderPickDescription(folder, this.getCustomizationMigrationFolderLabel(folder)), - picked: currentFolder ? isEqual(folder.uri, currentFolder.uri) : false, folder, })); + const activeItem = currentFolder ? picks.find(pick => pick.folder && isEqual(pick.folder.uri, currentFolder.uri)) : undefined; picks.push({ label: localize('chooseAnotherMigrationFolder', "Choose another folder..."), description: localize('chooseAnotherMigrationFolderDescription', "Use a custom migration destination"), @@ -2618,6 +2584,7 @@ export class AICustomizationManagementEditor extends EditorPane { canPickMany: false, placeHolder: localize('chooseMigrationDestination', "Choose a destination for {0}", destination.contextLabel), matchOnDescription: true, + activeItem, }); if (!selected || !this.isCustomizationMigrationSessionActive(sessionResource)) { return; @@ -3739,6 +3706,16 @@ export class AICustomizationManagementEditor extends EditorPane { this.editorSaveIndicator = DOM.append(editorHeader, $('.editor-save-indicator')); + this.editorMigrationDetailWarning = DOM.append(this.editorContentContainer, $('section.mcp-detail-diagnostic-card.migration.warning.migration-detail-warning')); + const migrationWarningHeader = DOM.append(this.editorMigrationDetailWarning, $('.mcp-detail-diagnostic-header')); + const migrationWarningIcon = DOM.append(migrationWarningHeader, $('.mcp-detail-diagnostic-icon')); + migrationWarningIcon.classList.add(...ThemeIcon.asClassNameArray(Codicon.warning)); + migrationWarningIcon.setAttribute('aria-hidden', 'true'); + DOM.append(migrationWarningHeader, $('.mcp-detail-diagnostic-summary', {}, localize('promptMigrationWarningSummary', "Prompt-to-skill conversion changes"))); + const migrationWarningDetails = DOM.append(this.editorMigrationDetailWarning, $('.mcp-detail-diagnostic-details')); + DOM.append(migrationWarningDetails, $('p.mcp-detail-diagnostic-message', {}, + getCustomizationMigrationCategory(CustomizationMigrationCategoryId.PromptFiles).preMigrationConsequences ?? '')); + this.editorPreviewContainer = DOM.append(this.editorContentContainer, $('.editor-preview-container')); this.editorPreviewScrollContainer = DOM.append(this.editorPreviewContainer, $('.editor-preview-scroll-container')); this.editorPreviewScrollContainer.setAttribute('role', 'region'); @@ -3916,6 +3893,7 @@ export class AICustomizationManagementEditor extends EditorPane { this.currentCustomizationDetail = customizationDetail; this.viewMode = 'editor'; this.editorContentContainer?.classList.toggle('customization-detail-mode', customizationDetail); + this.updateMigrationDetailWarning(); this.editorItemMarketplaceIcon = marketplace?.resource.icon; this.renderEditorItemIcon(); @@ -3955,6 +3933,7 @@ export class AICustomizationManagementEditor extends EditorPane { this.editorReturnViewMode = 'list'; this.customizationDetailOrigin = undefined; this.currentMigrationDetail = undefined; + this.updateMigrationDetailWarning(); const fileUri = this.currentEditingUri; const backgroundSaveRequest = this.createExistingCustomizationSaveRequest(); if (backgroundSaveRequest) { @@ -4948,6 +4927,12 @@ export class AICustomizationManagementEditor extends EditorPane { this.updateMigrationDetailActionButtons(this.mcpMigrationDetailActions, this.viewMode === 'mcpDetail' ? this.currentMigrationDetail : undefined); } + private updateMigrationDetailWarning(): void { + if (this.editorMigrationDetailWarning) { + this.editorMigrationDetailWarning.style.display = this.currentMigrationDetail?.item.promptType === PromptsType.prompt ? '' : 'none'; + } + } + private updateMigrationDetailActionButtons(actions: IMigrationDetailActions | undefined, detail: IMigrationDetailContext | undefined): void { if (!actions) { return; diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMigration.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMigration.ts index d575a6fb86fd33..2a96ac06f97927 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMigration.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMigration.ts @@ -130,6 +130,7 @@ export function migratePromptFileToSkill(promptFile: MigratableConfiguration, co const friendlyName = promptFile.name?.trim() || parsed.header?.name?.trim() || getCleanPromptName(promptFile.uri); const skillName = skillNameOverride ?? sanitizeSkillName(friendlyName); const description = promptFile.description?.trim() || parsed.header?.description?.trim() || friendlyName; + const descriptionAttribute = parsed.header?.getAttribute(PromptHeaderAttributes.description); const argumentHint = parsed.header?.argumentHint?.trim(); const argumentHintAttribute = parsed.header?.getAttribute(PromptHeaderAttributes.argumentHint); const body = getPromptBody(parsed, content); @@ -140,7 +141,7 @@ export function migratePromptFileToSkill(promptFile: MigratableConfiguration, co const headerLines = [ '---', `name: ${skillName}`, - `description: ${description}`, + `description: ${formatMigratedDescription(description, descriptionAttribute)}`, 'disable-model-invocation: true', ]; @@ -157,6 +158,36 @@ export function migratePromptFileToSkill(promptFile: MigratableConfiguration, co }; } +function formatMigratedDescription(value: string, sourceAttribute: IHeaderAttribute | undefined): string { + if (/[\u0000-\u001F\u007F-\u009F]/.test(value)) { + return quoteYamlString(value); + } + + if (sourceAttribute?.value.type === 'scalar') { + switch (sourceAttribute.value.format) { + case 'single': + return `'${value.replace(/'/g, `''`)}'`; + case 'double': + return quoteYamlString(value); + case 'none': + if (isSafePlainYamlScalar(value)) { + return value; + } + } + } + + return isSafePlainYamlScalar(value) ? value : quoteYamlString(value); +} + +function quoteYamlString(value: string): string { + return JSON.stringify(value).replace(/[\u007F-\u009F]/g, character => `\\u${character.charCodeAt(0).toString(16).padStart(4, '0')}`); +} + +function isSafePlainYamlScalar(value: string): boolean { + return /^[A-Za-z][A-Za-z0-9 ._/()'!?+=-]*$/.test(value) + && !/^(?:true|false|null)$/i.test(value); +} + function formatMigratedHeaderValue(value: string, sourceAttribute: IHeaderAttribute | undefined): string { if (sourceAttribute?.value.type === 'scalar') { switch (sourceAttribute.value.format) { diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMigrationAccessibility.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMigrationAccessibility.ts index 27129d3b3eb938..323fc149da62e5 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMigrationAccessibility.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMigrationAccessibility.ts @@ -12,6 +12,7 @@ import { IEditorService } from '../../../../services/editor/common/editorService import { AccessibilityVerbositySettingId } from '../../../accessibility/browser/accessibilityConfiguration.js'; import { CONTEXT_AI_CUSTOMIZATION_MANAGEMENT_EDITOR } from './aiCustomizationManagement.js'; import { AICustomizationManagementEditor } from './aiCustomizationManagementEditor.js'; +import { CustomizationMigrationCategoryId, getCustomizationMigrationCategory } from './customizationMigrationCategories.js'; class CustomizationMigrationAccessibleView implements IAccessibleViewImplementation { readonly priority = 110; @@ -37,7 +38,8 @@ class CustomizationMigrationAccessibleView implements IAccessibleViewImplementat localize('migrationHelpOverview', "The migrations tree groups each migration type by workspace or user. Expand a group to inspect each customization, its source location, and its destination."), localize('migrationHelpNavigation', "Use the arrow keys to move through and expand the tree. Use each row's checkbox to include or exclude it. Open a customization to view its editor or details. Use the header actions or the row's More Actions menu to migrate or delete one item."), localize('migrationHelpAgent', "Use Migrate to have VS Code update the selected customizations. Migrate with Agent closes Agent Customizations and focuses a guided migration in chat for the selected harness. Agent migration uses credits."), - localize('migrationHelpActions', "Migrate applies to the selected items in one group and shows the planned source and destination changes before modifying files. Ignore permanently hides a group. Show Ignored Migrations restores hidden groups."), + localize('migrationHelpActions', "Migrate applies to the selected items in one group and asks for confirmation before modifying files. Ignore permanently hides a group. Show Ignored Migrations restores hidden groups."), + getCustomizationMigrationCategory(CustomizationMigrationCategoryId.PromptFiles).preMigrationConsequences ?? '', localize('migrationHelpLocations', "Activate a destination path to change the destination. Workspace MCP servers migrate to the root .mcp.json. User MCP servers migrate to mcp-config.json in Copilot home. Disabled user servers may become enabled after migration."), localize('migrationHelpMcpChanges', "MCP servers that migrate with changes explain each property removal in their row. Review these warnings before confirming migration."), localize('migrationHelpMcpDetails', "MCP servers that cannot migrate automatically appear in the Needs manual review group. Open a server to review its details; Edit Configuration opens its source file and selects the server's JSON configuration."), diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMigrationCategories.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMigrationCategories.ts index 420f8042caecca..aeb79da93c7216 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMigrationCategories.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMigrationCategories.ts @@ -54,6 +54,7 @@ export interface ICustomizationMigrationCategory { readonly cardActionLabel: string; readonly cardActionAriaLabel: string; readonly noFilesMigratedMessage: string; + readonly preMigrationConsequences?: string; isCandidate?(customization: MigratableConfiguration): boolean; getCandidateLabel(customization: CustomizationMigrationCandidate): string; getCandidateWarnings?(customization: CustomizationMigrationCandidate, harnessLabel: string): readonly string[]; @@ -75,6 +76,11 @@ const CONFIGURED_LOCATION_SETTING_IDS = [ PromptsConfig.INSTRUCTIONS_LOCATION_KEY, ] as const; +const promptFileMigrationConsequences = localize( + 'promptFileMigrationConsequences', + "Unsupported prompt-only headers are removed. Converted skills set disable-model-invocation: true, so the agent will not load them automatically; invoke them manually with /name.", +); + /** * Converts `*.prompt.md` files into skills. Agent-host harnesses ignore prompt * files entirely, so both workspace and user prompts are offered here. @@ -90,11 +96,11 @@ const promptFilesMigrationCategory: ICustomizationMigrationCategory = { cardActionLabel: localize('promptMigrationCardAction', "Convert to Skills..."), cardActionAriaLabel: localize('promptMigrationCardActionAriaLabel', "Convert prompt files to skills"), noFilesMigratedMessage: localize('promptMigrationNoFilesConverted', "No prompt files were converted."), + preMigrationConsequences: promptFileMigrationConsequences, isCandidate: isPromptFileMigrationCandidate, getCandidateLabel: getFileCandidateLabel, - getShortcutAriaLabel(count) { return localize('promptMigrationShortcutAriaLabelWithCount', "Prompts, {0} deprecated prompt files need migration", count); }, @@ -126,14 +132,14 @@ const promptFilesMigrationCategory: ICustomizationMigrationCategory = { getConfirmation(customizations) { const { workspaceCount, userCount } = countPromptStorages(customizations); - const detail = workspaceCount > 0 && userCount > 0 + const scopeDetail = workspaceCount > 0 && userCount > 0 ? localize('promptMigrationConfirmDetailWorkspaceAndUser', "This converts {0} workspace prompt files and {1} user prompt files into skills.", workspaceCount, userCount) : workspaceCount > 0 ? localize('promptMigrationConfirmDetailWorkspace', "This converts {0} workspace prompt files into skills.", workspaceCount) : localize('promptMigrationConfirmDetailUser', "This converts {0} user prompt files into skills.", userCount); return { message: localize('promptMigrationConfirmMessage', "Convert prompt files to skills?"), - detail, + detail: localize('promptMigrationConfirmDetailWithConsequences', "{0}\n\n{1}", scopeDetail, promptFileMigrationConsequences), primaryButton: localize('promptMigrationConfirmButton', "Convert to Skills"), deleteOriginalsLabel: localize('promptMigrationDeletePromptFilesCheckbox', "Delete original prompt files after migration"), }; diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMigrationDashboard.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMigrationDashboard.ts index 5b5064bec90efb..183f4ffe3e4191 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMigrationDashboard.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMigrationDashboard.ts @@ -265,17 +265,27 @@ export class CustomizationMigrationDashboard extends Disposable { showLoading(title: string, description: string, retry?: () => void): void { this.allMigrationsComplete = false; - this.pendingFocus ??= this.getFocusedKey(); + const activeElement = DOM.getActiveElement(); + if (this.element.contains(activeElement)) { + this.pendingFocus ??= this.getFocusedKey() ?? focusPreferredTarget; + } + const shouldFocusError = retry !== undefined + && this.pendingFocus !== undefined + && (this.element.contains(activeElement) || activeElement === DOM.getWindow(this.element).document.body); this.prepareRender(); const page = this.renderHeader(title, description); page.setAttribute('aria-busy', String(!retry)); + let retryButton: Button | undefined; if (retry) { - this.button(page, 'retry', localize('retry', "Retry"), localize('retryMigrations', "Retry loading migrations"), () => { + retryButton = this.button(page, 'retry', localize('retry', "Retry"), localize('retryMigrations', "Retry loading migrations"), () => { this.callbacks.actionClicked('retryClicked'); retry(); }); } this.callbacks.onDidChangeContent?.(); + if (shouldFocusError) { + (retryButton?.element ?? this.focusTargets.get('title'))?.focus(); + } } showOverview(overview: ICustomizationMigrationDashboardOverview): void { @@ -300,6 +310,7 @@ export class CustomizationMigrationDashboard extends Disposable { ); if (migrationGroups.length) { + this.renderMigrationWarnings(page, migrationGroups); this.renderMigrationTree(page, migrationGroups); } else { DOM.append(page, $('p.migration-empty', {}, overview.hasIgnoredGroups @@ -432,6 +443,22 @@ export class CustomizationMigrationDashboard extends Disposable { : migrationGroups; } + private renderMigrationWarnings(parent: HTMLElement, groups: ReturnType): void { + const promptMigration = groups.find(group => group.kind === 'migration' && group.category.id === CustomizationMigrationCategoryId.PromptFiles); + if (!promptMigration || promptMigration.kind !== 'migration') { + return; + } + + const warning = DOM.append(parent, $('section.mcp-detail-diagnostic-card.migration.warning.migration-warning-banner')); + const header = DOM.append(warning, $('.mcp-detail-diagnostic-header')); + const icon = DOM.append(header, $('.mcp-detail-diagnostic-icon')); + icon.classList.add(...ThemeIcon.asClassNameArray(Codicon.warning)); + icon.setAttribute('aria-hidden', 'true'); + DOM.append(header, $('.mcp-detail-diagnostic-summary', {}, localize('promptMigrationWarningSummary', "Prompt-to-skill conversion changes"))); + const details = DOM.append(warning, $('.mcp-detail-diagnostic-details')); + DOM.append(details, $('p.mcp-detail-diagnostic-message', {}, promptMigration.category.description)); + } + private renderMigrationTree(parent: HTMLElement, groups: ReturnType): void { const treeContainer = DOM.append(parent, $('.migration-tree.customization-tree-container')); const groupRenderer = new CustomizationGroupHeaderRenderer( @@ -529,7 +556,7 @@ export class CustomizationMigrationDashboard extends Disposable { count: items.length, isFirst: index === 0, description: category.highRisk - ? localize('highRiskMigrationDescription', "High risk. {0}", category.description) + ? localize('highRiskMigrationDescription', "High risk") : category.description, collapsed: this.collapsedMigrationGroups.has(groupId), categoryId: category.id, 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 c7ce7e698d212d..1368fc86f61ffc 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css @@ -164,6 +164,10 @@ text-align: center; } +.ai-customization-management-editor .sidebar-migration-button.selected .sidebar-migration-count { + color: inherit; +} + /* Section list items */ .ai-customization-management-editor .section-list-item { display: flex; @@ -1691,7 +1695,7 @@ per-word capitalization does not survive translation. */ .ai-customization-management-editor .mcp-detail-migration-link { display: inline-block; margin-top: var(--vscode-spacing-size100); - color: var(--vscode-textLink-foreground); + color: var(--vscode-inputValidation-warningForeground, var(--vscode-foreground)); font-weight: var(--vscode-fontWeight-semiBold); } @@ -1701,7 +1705,7 @@ per-word capitalization does not survive translation. */ .ai-customization-management-editor .mcp-detail-migration-link:hover, .ai-customization-management-editor .mcp-detail-migration-link:active { - color: var(--vscode-textLink-activeForeground); + color: var(--vscode-inputValidation-warningForeground, var(--vscode-foreground)); } /* Embedded plugin detail view */ @@ -3968,6 +3972,12 @@ per-word capitalization does not survive translation. */ flex-shrink: 0; } +.ai-customization-management-editor .migration-detail-warning { + flex: 0 0 auto; + min-height: 0; + margin: var(--vscode-spacing-size80) var(--vscode-spacing-size160) 0; +} + .ai-customization-management-editor .editor-mode-button:hover { background: var(--vscode-button-secondaryHoverBackground); } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationWelcomePromptLaunchers.css b/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationWelcomePromptLaunchers.css index 9940a063e33657..344fc887663c56 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationWelcomePromptLaunchers.css +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationWelcomePromptLaunchers.css @@ -273,7 +273,7 @@ margin-top: var(--vscode-spacing-size100); font-size: var(--vscode-agents-fontSize-body2); font-weight: var(--vscode-agents-fontWeight-semiBold); - color: var(--vscode-textLink-foreground); + color: var(--vscode-inputValidation-warningForeground, var(--vscode-foreground)); } .ai-customization-management-editor .welcome-prompts-card-footer { diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/media/customizationMigrationDashboard.css b/src/vs/workbench/contrib/chat/browser/aiCustomization/media/customizationMigrationDashboard.css index 871ded02898876..caf8015c12eb38 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/media/customizationMigrationDashboard.css +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/media/customizationMigrationDashboard.css @@ -103,6 +103,12 @@ overflow: hidden; } +.customization-migration-dashboard .migration-warning-banner { + flex: 0 0 auto; + min-height: 0; + margin-top: var(--vscode-spacing-size80); +} + .customization-migration-dashboard .migration-tree .monaco-list-row { border-radius: var(--vscode-cornerRadius-small); } diff --git a/src/vs/workbench/contrib/chat/browser/attachments/chatImplicitContext.ts b/src/vs/workbench/contrib/chat/browser/attachments/chatImplicitContext.ts index 18c0b1b4372db5..5a3d5d37866390 100644 --- a/src/vs/workbench/contrib/chat/browser/attachments/chatImplicitContext.ts +++ b/src/vs/workbench/contrib/chat/browser/attachments/chatImplicitContext.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { CancellationTokenSource } from '../../../../../base/common/cancellation.js'; +import { RunOnceScheduler } from '../../../../../base/common/async.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { Disposable, DisposableMap, DisposableStore, MutableDisposable } from '../../../../../base/common/lifecycle.js'; import { Schemas } from '../../../../../base/common/network.js'; @@ -28,8 +29,10 @@ import { ILanguageModelIgnoredFilesService } from '../../common/ignoredFiles.js' import { IChatWidget, IChatWidgetService } from '../chat.js'; import { IChatContextService } from '../contextContrib/chatContextService.js'; import { ITextModel } from '../../../../../editor/common/model.js'; -import { IRange } from '../../../../../editor/common/core/range.js'; +import { IRange, Range } from '../../../../../editor/common/core/range.js'; +import { Selection } from '../../../../../editor/common/core/selection.js'; import { BrowserEditorInput } from '../../../browserView/common/browserEditorInput.js'; +import { CustomEditorInput } from '../../../customEditor/browser/customEditorInput.js'; export class ChatImplicitContextContribution extends Disposable implements IWorkbenchContribution { static readonly ID = 'chat.implicitContext'; @@ -70,6 +73,24 @@ export class ChatImplicitContextContribution extends Disposable implements IWork 500)(() => this.updateImplicitContext())); } + const customEditor = this.findActiveCustomEditor(); + if (customEditor) { + const update = activeEditorDisposables.add(new RunOnceScheduler(() => this.updateImplicitContext(), 500)); + activeEditorDisposables.add(customEditor.webview.onMessage(() => update.schedule())); + const navigationDisposables = activeEditorDisposables.add(new DisposableStore()); + activeEditorDisposables.add(Event.runAndSubscribe(customEditor.onDidChangeNavigation, () => { + update.cancel(); + navigationDisposables.clear(); + const navigation = customEditor.navigation; + if (navigation) { + navigationDisposables.add(navigation.onDidChangeSelection(() => update.schedule())); + navigationDisposables.add(navigation.model.onDidChangeContent(() => update.schedule())); + navigationDisposables.add(navigation.model.onDidChangeLanguage(() => update.schedule())); + } + this.updateImplicitContext(); + })); + } + const notebookEditor = this.findActiveNotebookEditor(); if (notebookEditor) { const activeCellDisposables = activeEditorDisposables.add(new DisposableStore()); @@ -96,7 +117,7 @@ export class ChatImplicitContextContribution extends Disposable implements IWork 500)(() => this.updateImplicitContext())); } const webviewEditor = this.findActiveWebviewEditor(); - if (webviewEditor) { + if (webviewEditor && !customEditor) { activeEditorDisposables.add(Event.debounce((webviewEditor.input as WebviewInput).webview.onMessage, () => undefined, 500)(() => { this.updateImplicitContext(); })); @@ -135,7 +156,15 @@ export class ChatImplicitContextContribution extends Disposable implements IWork })); } + override dispose(): void { + this._currentCancelTokenSource.value?.cancel(); + super.dispose(); + } + private findActiveCodeEditor(): ICodeEditor | undefined { + if (this.findActiveCustomEditor()) { + return undefined; + } const codeEditor = this.codeEditorService.getActiveCodeEditor(); if (codeEditor) { const model = codeEditor.getModel(); @@ -164,6 +193,11 @@ export class ChatImplicitContextContribution extends Disposable implements IWork return undefined; } + private findActiveCustomEditor(): CustomEditorInput | undefined { + const input = this.editorService.activeEditorPane?.input; + return input instanceof CustomEditorInput ? input : undefined; + } + private findActiveWebviewEditor(): WebviewEditor | undefined { const activeEditorPane = this.editorService.activeEditorPane; if (activeEditorPane?.input instanceof WebviewInput) { @@ -185,10 +219,13 @@ export class ChatImplicitContextContribution extends Disposable implements IWork } private async updateImplicitContext(updateWidget?: IChatWidget): Promise { + this._currentCancelTokenSource.value?.cancel(); const cancelTokenSource = this._currentCancelTokenSource.value = new CancellationTokenSource(); + const customEditor = this.findActiveCustomEditor(); + const navigation = customEditor?.navigation; const codeEditor = this.findActiveCodeEditor(); - const model = codeEditor?.getModel(); - const selection = codeEditor?.getSelection(); + const model = navigation?.model ?? codeEditor?.getModel(); + const selection = navigation ? navigation.selection && Selection.liftSelection(navigation.selection) : codeEditor?.getSelection(); const useSuggestedContext = this.configurationService.getValue('chat.implicitContext.suggestedContext'); let newValue: Location | URI | StringChatContextValue | undefined; let isSelection = false; @@ -197,7 +234,7 @@ export class ChatImplicitContextContribution extends Disposable implements IWork let providerContext: StringChatContextValue | undefined; if (model) { languageId = model.getLanguageId(); - if (selection && !selection.isEmpty()) { + if (selection && !Range.isEmpty(selection)) { newValue = { uri: model.uri, range: selection } satisfies Location; isSelection = true; } else { @@ -219,7 +256,7 @@ export class ChatImplicitContextContribution extends Disposable implements IWork } } // Also check if a chat context provider can provide additional context for this text editor resource - providerContext = await this.chatContextService.contextForResource(model.uri, languageId); + providerContext = await this.chatContextService.contextForResource(model.uri, languageId, customEditor?.viewType); } const notebookEditor = this.findActiveNotebookEditor(); @@ -257,7 +294,7 @@ export class ChatImplicitContextContribution extends Disposable implements IWork } const webviewEditor = this.findActiveWebviewEditor(); - if (webviewEditor?.input instanceof WebviewInput && webviewEditor.input.resource) { + if (!navigation && webviewEditor?.input instanceof WebviewInput && webviewEditor.input.resource) { const webviewContext = await this.chatContextService.contextForResource(webviewEditor.input.resource, undefined, webviewEditor.input.viewType); if (webviewContext) { newValue = webviewContext; @@ -281,7 +318,11 @@ export class ChatImplicitContextContribution extends Disposable implements IWork return; } - const widgets = updateWidget ? [updateWidget] : [...this.chatWidgetService.getWidgetsByLocations(ChatAgentLocation.Chat), ...this.chatWidgetService.getWidgetsByLocations(ChatAgentLocation.EditorInline)]; + const widgets = new Set([ + ...this.chatWidgetService.getWidgetsByLocations(ChatAgentLocation.Chat), + ...this.chatWidgetService.getWidgetsByLocations(ChatAgentLocation.EditorInline), + ...(updateWidget ? [updateWidget] : []), + ]); for (const widget of widgets) { if (!widget.input.implicitContext) { continue; @@ -293,7 +334,7 @@ export class ChatImplicitContextContribution extends Disposable implements IWork // existing values so the attachment bar stays visible. // But when there's no active editor at all, clear the values. const hasActiveEditor = !!this.editorService.activeEditor; - if (newValue !== undefined || !widget.input.implicitContext.hasValue || !hasActiveEditor || browser) { + if (newValue !== undefined || !widget.input.implicitContext.hasValue || !hasActiveEditor || browser || customEditor) { widget.input.implicitContext.setValues([{ value: newValue, isSelection }, { value: providerContext, isSelection: false }]); } } else { 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 e98b5be7c93ebc..b5f8d309f8e769 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts @@ -19,7 +19,7 @@ import { AccessibleViewRegistry } from '../../../../platform/accessibility/brows import { registerAction2 } from '../../../../platform/actions/common/actions.js'; import '../../../../platform/agentHost/browser/agentHostEnablementService.js'; import '../../../../platform/agentHost/common/agentHostEnablementService.js'; -import { AgentHostAutoReplyEnabledConfigKey, AgentHostEditAutoApprovePatternsConfigKey, AgentHostExternalSessionsMode, AgentHostGlobalAutoApproveEnabledConfigKey, AgentHostMcpConnectorsEnabledConfigKey, AgentHostMigrateLegacyCopilotCliEnabledConfigKey, AgentHostSessionCatalogEnabledConfigKey, AgentHostSessionSyncEnabledConfigKey, AgentHostShowExternalSessionsConfigKey } from '../../../../platform/agentHost/common/agentHostSchema.js'; +import { AgentHostAutoReplyEnabledConfigKey, AgentHostByokUtilityModelDefaultConfigKey, AgentHostEditAutoApprovePatternsConfigKey, AgentHostExternalSessionsMode, AgentHostGlobalAutoApproveEnabledConfigKey, AgentHostMcpConnectorsEnabledConfigKey, AgentHostMigrateLegacyCopilotCliEnabledConfigKey, AgentHostSessionCatalogEnabledConfigKey, AgentHostSessionSyncEnabledConfigKey, AgentHostShowExternalSessionsConfigKey, AgentHostUtilitySmallModelConfigKey } from '../../../../platform/agentHost/common/agentHostSchema.js'; import '../../../../platform/agentHost/common/agentHostStarter.config.contribution.js'; import { AgentMergeSettingId } from '../../../../platform/agentHost/common/agentMerge.js'; import { AgentHostAhpJsonlLoggingSettingId, AgentHostAllowSignedOutWhenUsableSettingId, CodexPreferAgentHostEditorSettingId } from '../../../../platform/agentHost/common/agentService.js'; @@ -1902,6 +1902,7 @@ configurationRegistry.registerConfiguration({ nls.localize('chat.byokUtilityModelDefault.copilot.description', "Use the default GitHub Copilot utility models."), ], default: BYOKUtilityModelDefault.Copilot, + agentHost: { key: AgentHostByokUtilityModelDefaultConfigKey, scope: AgentHostConfigurationSyncScope.Local }, }, [ChatConfiguration.UtilityModel]: { type: 'string', @@ -1917,7 +1918,8 @@ configurationRegistry.registerConfiguration({ default: '', enum: UtilitySmallModelContribution.modelIds, enumItemLabels: UtilitySmallModelContribution.modelLabels, - markdownEnumDescriptions: UtilitySmallModelContribution.modelDescriptions + markdownEnumDescriptions: UtilitySmallModelContribution.modelDescriptions, + agentHost: { key: AgentHostUtilitySmallModelConfigKey, scope: AgentHostConfigurationSyncScope.Local }, }, [ChatConfiguration.RequestQueueingDefaultAction]: { type: 'string', diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingDeletedFileEntry.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingDeletedFileEntry.ts index a88991de260f9e..2b5bb04fcd1bbd 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingDeletedFileEntry.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingDeletedFileEntry.ts @@ -23,6 +23,7 @@ import { IEditorPane } from '../../../../common/editor.js'; import { IFilesConfigurationService } from '../../../../services/filesConfiguration/common/filesConfigurationService.js'; import { stringToSnapshot } from '../../../../services/textfile/common/textfiles.js'; import { IAiEditTelemetryService } from '../../../editTelemetry/browser/telemetry/aiEditTelemetry/aiEditTelemetryService.js'; +import { IAgentHostConnectionsService } from '../../../../../platform/agentHost/common/agentHostConnectionsService.js'; import { ICellEditOperation } from '../../../notebook/common/notebookCommon.js'; import { IChatService } from '../../common/chatService/chatService.js'; import { ChatEditKind, IModifiedEntryTelemetryInfo, IModifiedFileEntry, IModifiedFileEntryEditorIntegration, ISnapshotEntry, ModifiedFileEntryState } from '../../common/editing/chatEditingService.js'; @@ -83,6 +84,7 @@ export class ChatEditingDeletedFileEntry extends AbstractChatEditingModifiedFile @IUndoRedoService undoRedoService: IUndoRedoService, @IInstantiationService instantiationService: IInstantiationService, @IAiEditTelemetryService aiEditTelemetryService: IAiEditTelemetryService, + @IAgentHostConnectionsService agentHostConnectionsService: IAgentHostConnectionsService, ) { super( resource, @@ -95,6 +97,7 @@ export class ChatEditingDeletedFileEntry extends AbstractChatEditingModifiedFile undoRedoService, instantiationService, aiEditTelemetryService, + agentHostConnectionsService, ); this._originalContent = originalContent; diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedDocumentEntry.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedDocumentEntry.ts index 5447a44a09c069..bcf020cf7765ae 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedDocumentEntry.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedDocumentEntry.ts @@ -31,6 +31,7 @@ import { IEditorPane, SaveReason } from '../../../../common/editor.js'; import { IFilesConfigurationService } from '../../../../services/filesConfiguration/common/filesConfigurationService.js'; import { ITextFileService, isTextFileEditorModel, stringToSnapshot } from '../../../../services/textfile/common/textfiles.js'; import { IAiEditTelemetryService } from '../../../editTelemetry/browser/telemetry/aiEditTelemetry/aiEditTelemetryService.js'; +import { IAgentHostConnectionsService } from '../../../../../platform/agentHost/common/agentHostConnectionsService.js'; import { ICellEditOperation } from '../../../notebook/common/notebookCommon.js'; import { IChatService } from '../../common/chatService/chatService.js'; import { ChatEditKind, IModifiedEntryTelemetryInfo, IModifiedFileEntry, IModifiedFileEntryEditorIntegration, ISnapshotEntry, ModifiedFileEntryState } from '../../common/editing/chatEditingService.js'; @@ -103,6 +104,7 @@ export class ChatEditingModifiedDocumentEntry extends AbstractChatEditingModifie @IInstantiationService instantiationService: IInstantiationService, @IAiEditTelemetryService aiEditTelemetryService: IAiEditTelemetryService, @IEditorWorkerService private readonly _editorWorkerService: IEditorWorkerService, + @IAgentHostConnectionsService agentHostConnectionsService: IAgentHostConnectionsService, ) { super( resourceRef.object.textEditorModel.uri, @@ -115,6 +117,7 @@ export class ChatEditingModifiedDocumentEntry extends AbstractChatEditingModifie undoRedoService, instantiationService, aiEditTelemetryService, + agentHostConnectionsService, ); this._docFileEditorModel = this._register(resourceRef).object; diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedFileEntry.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedFileEntry.ts index 273e1fb48a2ca8..5b9841b833715c 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedFileEntry.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedFileEntry.ts @@ -24,6 +24,8 @@ import { IFilesConfigurationService } from '../../../../services/filesConfigurat import { IAiEditTelemetryService } from '../../../editTelemetry/browser/telemetry/aiEditTelemetry/aiEditTelemetryService.js'; import { ICellEditOperation } from '../../../notebook/common/notebookCommon.js'; import { ChatUserAction, IChatService } from '../../common/chatService/chatService.js'; +import { getChatSessionTelemetryIds } from '../../common/chatService/chatServiceTelemetry.js'; +import { IAgentHostConnectionsService } from '../../../../../platform/agentHost/common/agentHostConnectionsService.js'; import { isAgentHostSessionResource } from '../../common/chatSessionsService.js'; import { ChatEditKind, IModifiedEntryTelemetryInfo, IModifiedFileEntry, IModifiedFileEntryEditorIntegration, ISnapshotEntry, ModifiedFileEntryState } from '../../common/editing/chatEditingService.js'; import { IChatEditMetadata, IChatResponseModel } from '../../common/model/chatModel.js'; @@ -112,6 +114,7 @@ export abstract class AbstractChatEditingModifiedFileEntry extends Disposable im @IUndoRedoService private readonly _undoRedoService: IUndoRedoService, @IInstantiationService protected readonly _instantiationService: IInstantiationService, @IAiEditTelemetryService private readonly _aiEditTelemetryService: IAiEditTelemetryService, + @IAgentHostConnectionsService private readonly _agentHostConnectionsService: IAgentHostConnectionsService, ) { super(); @@ -294,6 +297,7 @@ export abstract class AbstractChatEditingModifiedFileEntry extends Disposable im source: undefined, sourceRequestId: this._telemetryInfo.requestId, isAgentHostSession, + ...getChatSessionTelemetryIds(this._telemetryInfo.sessionResource, this._agentHostConnectionsService), }); } else if (action.kind === 'chatEditingHunkAction' && action.outcome === 'rejected') { this._aiEditTelemetryService.handleCodeRejected({ @@ -314,6 +318,7 @@ export abstract class AbstractChatEditingModifiedFileEntry extends Disposable im source: undefined, sourceRequestId: this._telemetryInfo.requestId, isAgentHostSession, + ...getChatSessionTelemetryIds(this._telemetryInfo.sessionResource, this._agentHostConnectionsService), }); } diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedNotebookEntry.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedNotebookEntry.ts index 1c538ad1df553e..d8cc792af4cb3e 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedNotebookEntry.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedNotebookEntry.ts @@ -31,6 +31,7 @@ import { IEditorPane, SaveReason } from '../../../../common/editor.js'; import { IFilesConfigurationService } from '../../../../services/filesConfiguration/common/filesConfigurationService.js'; import { SnapshotContext } from '../../../../services/workingCopy/common/fileWorkingCopy.js'; import { IAiEditTelemetryService } from '../../../editTelemetry/browser/telemetry/aiEditTelemetry/aiEditTelemetryService.js'; +import { IAgentHostConnectionsService } from '../../../../../platform/agentHost/common/agentHostConnectionsService.js'; import { NotebookTextDiffEditor } from '../../../notebook/browser/diff/notebookDiffEditor.js'; import { INotebookTextDiffEditor } from '../../../notebook/browser/diff/notebookDiffEditorBrowser.js'; import { CellDiffInfo } from '../../../notebook/browser/diff/notebookDiffViewModel.js'; @@ -190,8 +191,9 @@ export class ChatEditingModifiedNotebookEntry extends AbstractChatEditingModifie @INotebookLoggingService private readonly loggingService: INotebookLoggingService, @INotebookEditorModelResolverService private readonly notebookResolver: INotebookEditorModelResolverService, @IAiEditTelemetryService aiEditTelemetryService: IAiEditTelemetryService, + @IAgentHostConnectionsService agentHostConnectionsService: IAgentHostConnectionsService, ) { - super(modifiedResourceRef.object.notebook.uri, telemetryInfo, kind, configurationService, fileConfigService, chatService, fileService, undoRedoService, instantiationService, aiEditTelemetryService); + super(modifiedResourceRef.object.notebook.uri, telemetryInfo, kind, configurationService, fileConfigService, chatService, fileService, undoRedoService, instantiationService, aiEditTelemetryService, agentHostConnectionsService); this.initialContentComparer = new SnapshotComparer(initialContent); this.modifiedModel = this._register(modifiedResourceRef).object.notebook; this.originalModel = this._register(originalResourceRef).object.notebook; diff --git a/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxApiService.ts b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxApiService.ts index c257fe43dc5977..0f373d9791fa98 100644 --- a/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxApiService.ts +++ b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxApiService.ts @@ -470,16 +470,17 @@ export class CloudSandboxApiService extends Disposable implements ICloudSandboxA } const status = binding ? taskSessionStatus(full.sessions?.find(session => session.id === binding.sessionId)?.state ?? full.state ?? task.state, this._logService) : undefined; const eventType = full.event_type || task.event_type; + const repositoryId = [full.repository?.id, task.repository?.id].find(id => id !== undefined && id > 0); cached = { summary: task, - repositoryId: full.repository?.id ?? task.repository?.id, + repositoryId, unstartedSince: getUnstartedSessionCreatedAt(full), session: binding ? { ...binding, taskId: task.id, ...(eventType ? { eventType } : {}), name: full.name ?? task.name ?? `Sandbox ${task.id}`, - hasRepository: task.hasRepository || !!(full.repository ?? task.repository), + hasRepository: task.hasRepository || repositoryId !== undefined, updatedAt: full.updated_at ?? task.updated_at, ...(status !== undefined ? { status } : {}), ...(full.archived_at ? { isArchived: true } : {}), 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 ef804ad9d3135a..0534dc1065bff7 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatPlanReviewPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatPlanReviewPart.ts @@ -11,6 +11,7 @@ import { DomScrollableElement } from '../../../../../../base/browser/ui/scrollba import { Action, Separator } from '../../../../../../base/common/actions.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; +import { toErrorMessage } from '../../../../../../base/common/errorMessage.js'; import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; import { KeyCode } from '../../../../../../base/common/keyCodes.js'; import { Disposable, DisposableStore, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; @@ -27,6 +28,7 @@ import { IContextMenuService } from '../../../../../../platform/contextview/brow import { IDialogService } from '../../../../../../platform/dialogs/common/dialogs.js'; import { FileChangeType, IFileService } from '../../../../../../platform/files/common/files.js'; import { IHoverService } from '../../../../../../platform/hover/browser/hover.js'; +import { INotificationService } from '../../../../../../platform/notification/common/notification.js'; import { CHAT_CARD_LARGE_CLASS, chatCardButtonStyles } from '../chatCard.js'; import { IMarkdownRendererService } from '../../../../../../platform/markdown/browser/markdownRenderer.js'; import { defaultButtonStyles } from '../../../../../../platform/theme/browser/defaultStyles.js'; @@ -98,6 +100,7 @@ export class ChatPlanReviewPart extends Disposable implements IChatContentPart { @ITextFileService private readonly _textFileService: ITextFileService, @IModelService private readonly _modelService: IModelService, @IFileService private readonly _fileService: IFileService, + @INotificationService private readonly _notificationService: INotificationService, ) { super(); @@ -757,7 +760,6 @@ export class ChatPlanReviewPart extends Disposable implements IChatContentPart { if (this.review.planUri && !await this.savePlanFile()) { return; } - this._isSubmitted = true; // Only the textarea-only flow (no planUri) attaches a draft to the action click. const ridesAlong = !this.review.planUri; const textareaFeedback = ridesAlong ? this._feedbackTextarea?.value.trim() : undefined; @@ -767,7 +769,10 @@ export class ChatPlanReviewPart extends Disposable implements IChatContentPart { rejected: false, ...(textareaFeedback ? { feedback: textareaFeedback, feedbackOverall: textareaFeedback } : {}), }); + this._isSubmitted = true; void this.markUsed(); + } catch (error) { + this._notificationService.error(localize('chat.planReview.approvalFailed', "Unable to approve the plan: {0}", toErrorMessage(error))); } finally { if (!this._isSubmitted) { this._isSubmitting = false; @@ -784,14 +789,16 @@ export class ChatPlanReviewPart extends Disposable implements IChatContentPart { if (this.review.planUri && !await this.savePlanFile()) { return; } - this._isSubmitted = true; const ridesAlong = !this.review.planUri; const textareaFeedback = ridesAlong ? this._feedbackTextarea?.value.trim() : undefined; this._options.onSubmit({ rejected: true, ...(textareaFeedback ? { feedback: textareaFeedback, feedbackOverall: textareaFeedback } : {}), }); + this._isSubmitted = true; void this.markUsed(); + } catch (error) { + this._notificationService.error(localize('chat.planReview.rejectionFailed', "Unable to reject the plan: {0}", toErrorMessage(error))); } finally { if (!this._isSubmitted) { this._isSubmitting = false; @@ -805,7 +812,7 @@ export class ChatPlanReviewPart extends Disposable implements IChatContentPart { } const planUri = URI.revive(this.review.planUri); if (this._textFileService.isDirty(planUri) && !await this._textFileService.save(planUri)) { - return false; + throw new Error(localize('chat.planReview.saveFailed', "The plan file could not be saved.")); } if (this.review instanceof ChatPlanReviewData) { if (!this.updatePlanContentFromModel()) { @@ -930,6 +937,12 @@ export class ChatPlanReviewPart extends Disposable implements IChatContentPart { } const feedback = sections.join('\n\n'); + this._options.onSubmit({ + rejected: false, + feedback, + feedbackOverall: textareaFeedback || undefined, + feedbackInlineMarkdown, + }); this._isSubmitted = true; const planUri = this.review.planUri ? URI.revive(this.review.planUri) : undefined; if (planUri) { @@ -937,14 +950,11 @@ export class ChatPlanReviewPart extends Disposable implements IChatContentPart { this._planReviewFeedbackService.removeFeedback(planUri, item.id); } } - this._options.onSubmit({ - rejected: false, - feedback, - feedbackOverall: textareaFeedback || undefined, - feedbackInlineMarkdown, - }); await this.markUsed(); return true; + } catch (error) { + this._notificationService.error(localize('chat.planReview.feedbackFailed', "Unable to submit plan feedback: {0}", toErrorMessage(error))); + return false; } finally { if (!this._isSubmitted) { this._isSubmitting = false; 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 b844c5f6ba5314..2c5eacd41bf3a0 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -174,6 +174,8 @@ import { ChatPetAchievementIds, didExplicitlySwitchChatPetModel } from '../../ch import { IChatPetService } from '../../chatPetService.js'; import { DelegationSessionPickerActionItem } from './delegationSessionPickerActionItem.js'; import { ModelPickerActionItem, IModelPickerDelegate, IModelPickerPresentationOptions } from './modelPicker/modelPickerActionItem.js'; +import { getChatSessionTelemetryContext, getChatSessionTelemetryIds } from '../../../common/chatService/chatServiceTelemetry.js'; +import { IAgentHostConnectionsService } from '../../../../../../platform/agentHost/common/agentHostConnectionsService.js'; import { IModePickerDelegate, ModePickerActionItem } from './modePickerActionItem.js'; import { IPermissionPickerDelegate, PermissionPickerActionItem } from './permissionPickerActionItem.js'; import { SessionTypePickerActionItem } from './sessionTargetPickerActionItem.js'; @@ -1025,6 +1027,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge @IActionViewItemService private readonly actionViewItemService: IActionViewItemService, @IAccountPolicyGateService private readonly accountPolicyGateService: IAccountPolicyGateService, @IRemoteAgentService remoteAgentService: IRemoteAgentService, + @IAgentHostConnectionsService private readonly agentHostConnectionsService: IAgentHostConnectionsService, ) { super(); remoteAgentService.getEnvironment().then(environment => { @@ -1522,6 +1525,14 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this.renderAttachedContext(); }, getModels: () => this.getModels(), + getChatSessionId: () => { + const resource = this._widget?.viewModel?.sessionResource; + return resource ? getChatSessionTelemetryContext(resource).chatSessionId : undefined; + }, + getAgentSessionId: () => { + const resource = this._widget?.viewModel?.sessionResource; + return resource ? getChatSessionTelemetryIds(resource, this.agentHostConnectionsService).agentSessionId : undefined; + }, getProvider: () => getAgentHostProviderForTelemetry(this.getCurrentSessionType(), this.chatSessionsService), getSessionType: () => this.modelTargetSessionType, isCacheWarm: () => (this._widget?.viewModel?.model.getRequests().length ?? 0) > 0, 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 ff0ea740e583d5..13b99c3e2aa20b 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 @@ -498,28 +498,6 @@ font-size: var(--vscode-codiconFontSize); } -.action-widget .chat-model-picker-tabbar .tabbed-action-list-tab { - flex: 0 1 auto; -} - -.action-widget .chat-model-picker-tabbar .tabbed-action-list-tab.has-toggle { - border-radius: var(--vscode-cornerRadius-small); - color: var(--vscode-modernTab-activeForeground); - background: var(--vscode-modernTab-activeBackground); - box-shadow: inset 0 0 0 var(--vscode-strokeThickness) var(--vscode-contrastActiveBorder, transparent); -} - -.action-widget .chat-model-picker-tabbar .tabbed-action-list-tab.has-toggle > .monaco-button.active { - background: transparent; - box-shadow: none; - font-size: var(--vscode-fontSize-label1); -} - -.action-widget .chat-model-picker-tabbar .tabbed-action-list-tab-toggle { - flex: 0 0 auto; - border-left: var(--vscode-strokeThickness) solid var(--vscode-menu-separatorBackground); -} - .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); @@ -1160,6 +1138,21 @@ margin: 0; } +.chat-model-card-description a, +.chat-model-card-description a:visited { + color: var(--vscode-textLink-foreground); +} + +.chat-model-card-description a:hover, +.chat-model-card-description a:active { + color: var(--vscode-textLink-activeForeground); +} + +.chat-model-card-description a:focus-visible { + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: var(--vscode-strokeThickness); +} + .model-picker-workflow-footer { padding: var(--vscode-spacing-size80); } 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 dbab9e6cbf8a79..fc151f652767c9 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 @@ -47,6 +47,7 @@ export interface IModelPickerDelegate { * Returns `undefined` when no session is active. */ getChatSessionId?(): string | undefined; + getAgentSessionId?(): string | undefined; getProvider?(): string | undefined; /** * The session type (harness) the picker selects models for. Used to scope 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 index c1b5c7e5cd1e6b..b12902c9e2866d 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerCard.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerCard.ts @@ -16,6 +16,7 @@ import { Event } from '../../../../../../../base/common/event.js'; import { DisposableStore, MutableDisposable } from '../../../../../../../base/common/lifecycle.js'; import { formatTokenCount } from '../../../../../../../base/common/numbers.js'; import { ThemeIcon } from '../../../../../../../base/common/themables.js'; +import { URI } from '../../../../../../../base/common/uri.js'; import { localize } from '../../../../../../../nls.js'; import { IOpenerService } from '../../../../../../../platform/opener/common/opener.js'; import { getModelContextWindowTotal, ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier } from '../../../../common/languageModels.js'; @@ -55,6 +56,8 @@ export interface IModelCardOptions { readonly pricingDisclosure?: IPricingDisclosure; /** The faster twin of this model, when the provider offers one. */ readonly speedVariants?: IModelSpeedVariants; + /** A separate routing model offered as Auto's last tier, such as HydraFusion. */ + readonly routingAlternative?: IModelCardRoutingAlternative; /** Marks the start of a model-affecting interaction, before an asynchronous save. */ readonly onWillSelect?: () => void; /** Selects the model for the latest saved configuration or speed choice. */ @@ -63,6 +66,19 @@ export interface IModelCardOptions { readonly onDidAccept?: () => void; } +/** + * A routing model listed after Auto's own tiers. Choosing it selects that model + * rather than saving a tier, and reveals what it is. + */ +export interface IModelCardRoutingAlternative { + readonly model: ILanguageModelChatMetadataAndIdentifier; + /** Shown below the tiers while the alternative is chosen. */ + readonly description: string; + readonly learnMoreUrl?: URI; + /** Whether the alternative is the model in use, rather than one of Auto's tiers. */ + readonly selected: boolean; +} + /** * Model information and configuration, shared by the picker's details page and fixtures. * Configuration changes are written through and preserve keyboard focus. @@ -91,7 +107,9 @@ export class ModelCard extends DisposableStore { /** Refreshes model, pin, and policy state without replacing the card or moving keyboard focus. */ update(options: IModelCardOptions): void { const disclosureChanged = options.pricingDisclosure !== this._options.pricingDisclosure; - const changed = options.model !== this._options.model || options.isPinned !== this._options.isPinned || options.organizationDefaultModel !== this._options.organizationDefaultModel || options.externalHeader !== this._options.externalHeader || disclosureChanged; + const alternativeChanged = options.routingAlternative?.model !== this._options.routingAlternative?.model + || options.routingAlternative?.selected !== this._options.routingAlternative?.selected; + const changed = options.model !== this._options.model || options.isPinned !== this._options.isPinned || options.organizationDefaultModel !== this._options.organizationDefaultModel || options.externalHeader !== this._options.externalHeader || disclosureChanged || alternativeChanged; if (options.model.identifier !== this._options.model.identifier) { this._configurationChangeVersion++; } @@ -135,6 +153,7 @@ export class ModelCard extends DisposableStore { await setModelConfigValues(options.model, options.configurationAccess, values, options.onDidChangeConfiguration); if (!this.isDisposed && version === this._configurationChangeVersion) { options.onSelect?.(options.model); + this._setRoutingAlternativeSelected(false); } await Promise.all([...this._groupControls.values()].map(control => control.whenSelectionAnimationSettles())); } finally { @@ -358,29 +377,57 @@ export class ModelCard extends DisposableStore { /** * 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. + * Auto's tiers end with its routing alternative, which is described once chosen. */ private _renderChoiceSection(property: IModelConfigProperty, group: string, title: string): void { const choices = getModelConfigChoices(property); + const isAuto = isAutoModel(this._options.model); + const alternative = isAuto && group === MODEL_CONFIG_GROUP_EFFORT ? this._options.routingAlternative : undefined; + const items = choices.map(({ label, description, checked, readOnly }) => ({ + text: label, + tooltip: description, + ariaLabel: description ? localize('chat.modelPicker.optionDescription', "{0}, {1}", label, description) : label, + isActive: checked && !alternative?.selected, + disabled: readOnly, + })); + if (alternative) { + const label = alternative.model.metadata.name; + items.push({ + text: label, + tooltip: alternative.description, + ariaLabel: localize('chat.modelPicker.optionDescription', "{0}, {1}", label, alternative.description), + isActive: alternative.selected, + disabled: false, + }); + } const section = this._renderSection(title); const control = this._contentDisposables.add(new Radio({ ariaLabel: title, className: 'segmented', // Arrow keys move focus without changing the model's configuration. arrowKeyBehavior: 'focus', - items: choices.map(({ label, description, checked, readOnly }) => ({ - text: label, - tooltip: description, - ariaLabel: description ? localize('chat.modelPicker.optionDescription', "{0}, {1}", label, description) : label, - isActive: checked, - disabled: readOnly, - })), + items, })); - const onDidChoose = isAutoModel(this._options.model) ? control.onDidActivate : control.onDidSelect; + let alternativeDescription: HTMLElement | undefined; + const onDidChoose = isAuto ? control.onDidActivate : control.onDidSelect; this._contentDisposables.add(onDidChoose(index => { - this._setValues({ [property.key]: choices[index].value }, group).catch(onUnexpectedError); + if (alternative && index === choices.length) { + this._selectVariant(alternative.model, control).catch(onUnexpectedError); + } else { + this._setValues({ [property.key]: choices[index].value }, group).catch(onUnexpectedError); + } + if (alternativeDescription) { + alternativeDescription.hidden = index !== choices.length; + } })); this._groupControls.set(group, control); section.appendChild(control.domNode); + if (alternative) { + alternativeDescription = renderModelDescription(alternative.description, this._options.openerService, this._contentDisposables, alternative.learnMoreUrl).element; + alternativeDescription.classList.add('chat-model-card-description', 'chat-model-card-alternative-description'); + alternativeDescription.hidden = !alternative.selected; + section.appendChild(alternativeDescription); + } } /** @@ -414,17 +461,29 @@ export class ModelCard extends DisposableStore { section.appendChild(control.domNode); } + /** Selects a separate model: a faster twin, or Auto's routing alternative. */ private async _selectVariant(model: ILanguageModelChatMetadataAndIdentifier, control: Radio): Promise { const options = this._options; const version = ++this._configurationChangeVersion; options.onWillSelect?.(); options.onSelect?.(model); + if (model === options.routingAlternative?.model) { + this._setRoutingAlternativeSelected(true); + } await control.whenSelectionAnimationSettles(); if (!this.isDisposed && version === this._configurationChangeVersion) { options.onDidAccept?.(); } } + /** Keeps the alternative's state in step with a choice made here until the caller next updates the card. */ + private _setRoutingAlternativeSelected(selected: boolean): void { + const alternative = this._options.routingAlternative; + if (alternative && alternative.selected !== selected) { + this._options = { ...this._options, routingAlternative: { ...alternative, selected } }; + } + } + private _renderContextWindow(metadata: ILanguageModelChatMetadata): void { const total = getModelContextWindowTotal(metadata); if (!total) { 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 0708b0d30b5762..585e52baf39945 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 @@ -7,12 +7,11 @@ import * as dom from '../../../../../../../base/browser/dom.js'; import { localize } from '../../../../../../../nls.js'; import { ILanguageModelChatMetadataAndIdentifier } from '../../../../common/languageModels.js'; import { getModelConfigDescription, getModelConfigProperty, getModelConfigSummary, IModelConfigurationAccess, MODEL_CONFIG_GROUP_CONTEXT, MODEL_CONFIG_GROUP_EFFORT } from './modelPickerModelConfig.js'; -import { isAutoModel, isHydraFusionModel } from './modelPickerPresentation.js'; /** * Renders the thinking effort and context readout shown beside the model name in the - * chat input. It opens the model's details, or the routing choices for Auto and - * HydraFusion, and is hidden when there is nothing to show. + * chat input. It opens the model's details, which for Auto hold its routing tiers, and + * is hidden when there is nothing to show. */ export function renderModelConfigurationButton( button: HTMLElement, @@ -31,7 +30,5 @@ export function renderModelConfigurationButton( dom.reset(button, dom.$('span.chat-input-picker-label', undefined, label)); button.style.display = ''; const description = getModelConfigDescription(model, configurationAccess) ?? label; - button.ariaLabel = isAutoModel(model) || isHydraFusionModel(model) - ? localize('chat.modelPicker.autoOptionsAriaLabel', "{0} options, {1}", model.metadata.name, description) - : localize('chat.modelPicker.detailsAriaLabel', "{0} details, {1}", model.metadata.name, description); + button.ariaLabel = localize('chat.modelPicker.detailsAriaLabel', "{0} details, {1}", model.metadata.name, description); } 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 169dc90b452863..ba27429c928c11 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 @@ -13,7 +13,7 @@ export function isAutoModel(model: ILanguageModelChatMetadataAndIdentifier): boo return isAutoLanguageModel(model); } -/** Whether the model is the HydraFusion research preview, which the picker lists right below Auto. */ +/** Whether the model is the HydraFusion research preview, which the picker offers as Auto's last tier. */ export function isHydraFusionModel(model: ILanguageModelChatMetadataAndIdentifier): boolean { return model.metadata.id === COPILOT_HYDRA_FUSION_MODEL_ID; } 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 index 4a373d4074ad7b..23ef8251eb18ee 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerTabbedWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerTabbedWidget.ts @@ -14,12 +14,11 @@ import { IStringDictionary } from '../../../../../../../base/common/collections. import { Codicon } from '../../../../../../../base/common/codicons.js'; import { Emitter } from '../../../../../../../base/common/event.js'; import { AnchorPosition } from '../../../../../../../base/common/layout.js'; -import { onUnexpectedError } from '../../../../../../../base/common/errors.js'; import { Disposable, DisposableMap, DisposableStore, MutableDisposable, toDisposable } 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 { IActionWidgetDropdownAction, withActionWidgetDropdownMotion } from '../../../../../../../platform/actionWidget/browser/actionWidgetDropdown.js'; import { ITabBarAction, ITabDescriptor, TabbedActionListWidget } from '../../../../../../../platform/actionWidget/browser/tabbedActionListWidget.js'; import { COPILOT_HYDRA_FUSION_MODEL_ID } from '../../../../../../../platform/agentHost/common/copilotCliConfig.js'; import { IConfigurationService } from '../../../../../../../platform/configuration/common/configuration.js'; @@ -34,12 +33,11 @@ import { ChatEntitlement, IChatEntitlementService } from '../../../../../../serv import { ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService, IModelControlEntry, isUserProvidedModel } from '../../../../common/languageModels.js'; import { ChatConfiguration } from '../../../../common/constants.js'; import { resolveConfiguredModel } from '../../../../common/modelSelection.js'; -import { withChatInputPickerMotion } from '../chatInputPickerActionItem.js'; -import { getModelConfigChoices, getModelConfigDescription, getModelConfigProperty, getModelConfigSummary, IModelConfigProperty, IModelConfigurationAccess, MODEL_CONFIG_GROUP_EFFORT, ModelConfigChangeListener, setModelConfigValues } from './modelPickerModelConfig.js'; +import { getModelConfigDescription, getModelConfigProperty, getModelConfigSummary, IModelConfigurationAccess, MODEL_CONFIG_GROUP_EFFORT, ModelConfigChangeListener } from './modelPickerModelConfig.js'; import { IModelCardOptions, IPricingDisclosure, ModelCard } from './modelPickerCard.js'; import { getPreferredSpeedVariant, IModelSpeedVariants } from './modelPickerVariants.js'; import { getModelBadge, getOrganizationDefaultDescription, organizationDefaultLabel } from './modelPickerBadges.js'; -import { createModelAction, createModelItem, createUnavailableModelItem, getUnavailableReason, requiresNewerVSCode } from './modelPickerItemPrimitives.js'; +import { createModelAction, createUnavailableModelItem, getUnavailableReason, requiresNewerVSCode } from './modelPickerItemPrimitives.js'; import { getModelPickerAccessibilityProvider, getModelPickerControlModels } from './modelPickerItems.js'; import { filterModelPickerControlModelsForEntitlement, filterModelPickerModelsForEntitlement, isAutoModel, isHydraFusionModel, isHydraFusionUpgradeOnly } from './modelPickerPresentation.js'; import { buildModelPickerDestinations, buildModelPickerSections, getModelProviderLabel, hasPromotedModels, IModelPickerDestination, IModelPickerProviderPlaceholder, IModelPickerSections, IModelPickerUnavailableEntry, MODEL_PICKER_BUILT_IN_DESTINATION } from './modelPickerTabs.js'; @@ -52,7 +50,6 @@ const OTHER_MODELS_SECTION = 'other'; const PICKER_WIDTH = 320; const PRICING_EXPANDED_STORAGE_KEY = 'chat.modelPicker.pricingExpanded'; const MODEL_DETAILS_ACTION_ID = 'chat.modelPicker.details'; -const AUTO_TIER_ACTION_PREFIX = 'autoTier:'; /** Everything the picker needs for one showing, gathered by the owning widget. */ export interface ITabbedModelPickerContext { @@ -93,7 +90,7 @@ export interface ITabbedModelPickerContext { readonly configurationCacheBreakHint?: ITabbedModelPickerContext['cacheBreakHint']; } -/** A provider-tabbed picker with Copilot routing modes and a drill-in configuration page. */ +/** A provider-tabbed picker with Auto leading the Copilot tab and a drill-in configuration page. */ export class TabbedModelPicker extends Disposable { private readonly _onDidHide = this._register(new Emitter()); @@ -125,11 +122,6 @@ export class TabbedModelPicker extends Disposable { private readonly _preferredSpeedVariants = new Map(); private _selectionVersion = 0; private _models: readonly ILanguageModelChatMetadataAndIdentifier[] = []; - /** The manual Copilot model to restore when Auto is switched off. */ - private _lastExplicitModelId: string | undefined; - private _lastRoutingModelId: string | undefined; - /** Copilot's view mode is retained while another provider is being browsed. */ - private _autoMode = false; get isVisible(): boolean { return this._widget.isVisible; @@ -210,13 +202,13 @@ export class TabbedModelPicker extends Disposable { this._context = pickerContext; this._configurationListener.value = context.configurationAccess.onDidChange?.(() => this.refresh()); this._contextViewLayer = contextViewLayer; - this._rememberSelection(pickerContext.selectedModelId); if (options?.initialFilterValue !== undefined) { this._searchVisible = true; } this._showCurrent(options?.initialFilterValue, options?.initialFocusItemId); - const detailsModel = pickerContext.models.find(model => model.identifier === detailsModelId); - if (detailsModel && !isAutoModel(detailsModel) && !isHydraFusionModel(detailsModel)) { + const requestedModel = pickerContext.models.find(model => model.identifier === detailsModelId); + const detailsModel = requestedModel && this._getDetailsModel(requestedModel, pickerContext); + if (detailsModel) { this._showModelDetails(detailsModel, focusConfiguration); } } @@ -225,7 +217,6 @@ export class TabbedModelPicker extends Disposable { if (this._context && this._context.selectedModelId !== modelId) { this._selectionVersion++; this._context = { ...this._context, selectedModelId: modelId }; - this._rememberSelection(modelId); this.refresh(); } } @@ -302,24 +293,12 @@ export class TabbedModelPicker extends Disposable { this._widget.show({ user: 'ChatTabbedModelPicker', anchor, - tabs: destinations.map((destination): ITabDescriptor => { - // Auto can't be turned off when it's the only choice, so the tab is named after it. - const label = destination.id === MODEL_PICKER_BUILT_IN_DESTINATION && this._isAutoOnly(context) - ? localize('chat.modelPicker.auto', "Auto") - : destination.label; - return { - id: destination.id, - label, - icon: destination.icon, - tooltip: label, - toggle: !step && destination.id === MODEL_PICKER_BUILT_IN_DESTINATION ? { - label: localize('chat.modelPicker.auto', "Auto"), - ariaLabel: localize('chat.modelPicker.autoModeToggle', "Use Auto mode in {0}", destination.label), - getState: () => this._getAutoModeToggleState(this._context ?? context), - onChange: enabled => this._toggleAutoMode(enabled), - } : undefined, - }; - }), + tabs: destinations.map((destination): ITabDescriptor => ({ + id: destination.id, + label: destination.label, + icon: destination.icon, + tooltip: destination.label, + })), initialTab: this._activeDestination, // The built-in provider fixes the popup's height, unless all it lists is what an // upgrade would unlock, which is too short to hold another provider's models. @@ -340,24 +319,13 @@ export class TabbedModelPicker extends Disposable { const currentDestinations = this._buildDestinations(current); const destination = currentDestinations.find(candidate => candidate.id === activeTab) ?? currentDestinations[0]; const sections = this._buildSections(destination, current); - const isBuiltIn = destination.id === MODEL_PICKER_BUILT_IN_DESTINATION; - // Size to the Copilot tab, whichever Copilot mode is selected: the taller of - // its model list and its Auto view, unless Auto is all there is. - const showAuto = !step && isBuiltIn && (forSizing - ? this._isAutoOnly(current) - : this._isAutoMode(current)); - const alternateSizingItems = forSizing && isBuiltIn && !showAuto && (this._autoModel(current) || this._hydraFusionModel(current)) - ? [this._buildAutoModeItems(destination, sections, current)] - : undefined; // Search spans every destination at once, so each model names its provider. const searching = this._searchVisible && !forSizing; const items = searching ? currentDestinations.flatMap(candidate => this._buildSearchItems(candidate, candidate === destination ? sections : this._buildSections(candidate, current), current)) - : showAuto - ? this._buildAutoModeItems(destination, sections, current) - : this._buildItems(destination, sections, current); + : this._buildItems(destination, sections, current); const hint = step ? { text: `${step.title}\n${step.description}`, link: undefined, dismiss: undefined } : current.cacheBreakHint ?? current.configurationCacheBreakHint; - const baseListOptions = withChatInputPickerMotion({ + const baseListOptions = withActionWidgetDropdownMotion({ className: 'chat-model-picker-dropdown chat-model-picker-tabbed', stopToolbarPointerPropagation: true, tabThroughItemActions: true, @@ -393,13 +361,14 @@ export class TabbedModelPicker extends Disposable { hideDefaultKeybindingTooltip: true, reserveSubmenuSpace: false, }); + // Above the input like the other input pickers, unless the input sits too close to + // the top for it, e.g. inline chat at the top of an editor. const listOptions = anchor.closest('.monaco-dialog-box') ? { ...baseListOptions, anchorPosition: AnchorPosition.BELOW } - : baseListOptions; + : { ...baseListOptions, preferredAnchorPosition: AnchorPosition.ABOVE }; return { items: step ? items.filter(item => !item.item || !current.models.some(model => model.identifier === item.item?.id && (isAutoModel(model) || isHydraFusionModel(model)))) : items, listOptions, - alternateSizingItems, }; }, renderFooter: workflow && step ? container => this._renderWorkflowFooter(container, workflow) : undefined, @@ -472,26 +441,25 @@ export class TabbedModelPicker extends Disposable { return context.pinnedModelIds.filter(id => id === modelIdentifier || id === pair?.standard.identifier || id === pair?.fast.identifier); } - private _isAutoMode(context: ITabbedModelPickerContext): boolean { - return (this._autoMode || this._isAutoOnly(context)) && !!(this._autoModel(context) || this._hydraFusionModel(context)); + /** + * Whether HydraFusion is offered as Auto's last tier, which needs an Auto with tiers + * to join. Otherwise HydraFusion keeps a row of its own beside Auto. + */ + private _hostsHydraFusion(context: ITabbedModelPickerContext): boolean { + return !!this._hydraFusionModel(context) && !!getModelConfigProperty(this._autoModel(context), context.configurationAccess, MODEL_CONFIG_GROUP_EFFORT); } - /** Whether the plan offers Copilot only through routing, with no individual Copilot model to switch to. */ - private _isAutoOnly(context: ITabbedModelPickerContext): boolean { - return !!(this._autoModel(context) || this._hydraFusionModel(context)) && !this._fallbackModel(context); + /** Whether the selection is HydraFusion as Auto's tier, which Auto then represents. */ + private _isHydraFusionTierSelected(context: ITabbedModelPickerContext): boolean { + return !!context.selectedModelId && context.selectedModelId === this._hydraFusionModel(context)?.identifier && this._hostsHydraFusion(context); } - private _rememberSelection(modelId: string | undefined): void { - const model = this._context?.models.find(model => model.identifier === modelId); - if (!model || isUserProvidedModel(model, this._languageModelsService)) { - return; - } - this._autoMode = isAutoModel(model) || isHydraFusionModel(model); - if (this._autoMode) { - this._lastRoutingModelId = model.identifier; - } else { - this._lastExplicitModelId = model.identifier; + /** The model whose details stand for the given one: Auto's for its HydraFusion tier, none for HydraFusion's own row. */ + private _getDetailsModel(model: ILanguageModelChatMetadataAndIdentifier, context: ITabbedModelPickerContext): ILanguageModelChatMetadataAndIdentifier | undefined { + if (!isHydraFusionModel(model)) { + return model; } + return model === this._hydraFusionModel(context) && this._hostsHydraFusion(context) ? this._autoModel(context) : undefined; } private _destinationForSelectedModel(destinations: readonly IModelPickerDestination[], context: ITabbedModelPickerContext): string | undefined { @@ -639,12 +607,15 @@ export class TabbedModelPicker extends Disposable { } private _buildItems(destination: IModelPickerDestination, sections: IModelPickerSections, context: ITabbedModelPickerContext): IActionListItem[] { + const isBuiltIn = destination.id === MODEL_PICKER_BUILT_IN_DESTINATION; + // Auto leads the built-in provider's list, ahead of any section. Guided selection + // compares individual models, so it leaves routing out. + const items: IActionListItem[] = isBuiltIn && !context.workflow?.state.get() ? this._buildRoutingItems(context) : []; // 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) { + if (!items.length && !destination.models.length && !sections.unavailable.length) { return []; } - const items: IActionListItem[] = []; const appendSection = ( label: string | undefined, models: readonly ILanguageModelChatMetadataAndIdentifier[], @@ -678,7 +649,7 @@ export class TabbedModelPicker extends Disposable { // The shortlist is the default state, so it goes unlabelled. When Copilot offers no // model to pick by hand, though, the plan is Auto alone, and the models it lacks // are headed as what an upgrade would add. - const upsellLabel = destination.id === MODEL_PICKER_BUILT_IN_DESTINATION && !this._fallbackModel(context) + const upsellLabel = isBuiltIn && !this._fallbackModel(context) ? this._getUpsellSectionLabel(sections.unavailable, context) : undefined; if (upsellLabel) { @@ -726,62 +697,28 @@ export class TabbedModelPicker extends Disposable { .map(model => this._createModelItem(model, context, undefined, getModelProviderLabel(model, this._languageModelsService))); } - private _buildAutoModeItems(destination: IModelPickerDestination, sections: IModelPickerSections, context: ITabbedModelPickerContext): IActionListItem[] { + /** + * Auto, listed like any other model, with its tiers in its details. HydraFusion is + * Auto's last tier, and gets a row of its own only without an Auto with tiers to join. + */ + private _buildRoutingItems(context: ITabbedModelPickerContext): IActionListItem[] { const items: IActionListItem[] = []; const auto = this._autoModel(context); if (auto) { - const property = getModelConfigProperty(auto, context.configurationAccess, MODEL_CONFIG_GROUP_EFFORT); - if (property) { - const title = property.schema.title ?? localize('chat.modelPicker.optimizeFor', "Optimize for"); - items.push({ - kind: ActionListItemKind.Separator, - label: auto.metadata.detail ? localize('chat.modelPicker.autoHeading', "{0} · {1}", title, auto.metadata.detail) : title, - additionalBadges: this._getOrganizationDefaultBadges(auto, context), - }); - for (const { index, value, label, description: detail, checked: tierChecked, readOnly } of getModelConfigChoices(property)) { - const id = `${AUTO_TIER_ACTION_PREFIX}${auto.identifier}/${index}`; - const checked = context.selectedModelId === auto.identifier && tierChecked; - const disabled = readOnly && !tierChecked; - items.push({ - ...createModelItem(toAction({ - id, label, checked, enabled: !disabled, - tooltip: [label, detail].filter(Boolean).join(' \u00b7 '), - run: () => this._selectAutoTier(auto, property, value).catch(onUnexpectedError), - })), - detail, - disabled, - ariaDescription: detail, - className: ['chat-model-picker-model', 'chat-model-picker-routing-model', ...(checked ? ['chat-model-picker-current'] : [])].join(' '), - }); - } - } else { - items.push(this._createModelItem(auto, context)); - } + items.push(this._createModelItem(auto, context)); } - const hydra = this._hydraFusionModel(context); - const unavailableHydra = sections.unavailable.filter(entry => entry.id === COPILOT_HYDRA_FUSION_MODEL_ID); - if (hydra || unavailableHydra.length) { - items.push({ kind: ActionListItemKind.Separator, label: localize('chat.modelPicker.alternativeRouting', "Alternative routing") }); - if (hydra) { - const item = this._createModelItem(hydra, context); - const description = localize('chat.modelPicker.hydraFusionDescription', "Picks a workflow per task, using one or more models to draft, review, or escalate."); - items.push({ - ...item, - detail: description, - detailLink: { label: localize('chat.modelPicker.learnMore', "Learn more"), uri: HYDRA_FUSION_LEARN_MORE_URL }, - badge: item.badge ?? hydra.metadata.detail, - ariaDescription: [item.ariaDescription, description].filter(Boolean).join(', '), - tooltip: [item.tooltip, description].filter(Boolean).join(' \u00b7 '), - className: `${item.className} chat-model-picker-routing-model${!item.badge && hydra.metadata.detail ? ' chat-model-picker-badge-preview' : ''}`, - }); - } - for (const unavailableEntry of unavailableHydra) { - items.push(createUnavailableModelItem(unavailableEntry.id, unavailableEntry.entry, this._getUnavailableReason(unavailableEntry, context), context.unavailableContext.manageSettingsUrl, context.unavailableContext.updateStateType, this._entitlementService)); - } - } - if (!this._fallbackModel(context)) { - items.push(...this._buildItems(destination, { ...sections, unavailable: sections.unavailable.filter(entry => entry.id !== COPILOT_HYDRA_FUSION_MODEL_ID) }, context)); + if (hydra && !this._hostsHydraFusion(context)) { + const item = this._createModelItem(hydra, context); + const description = localize('chat.modelPicker.hydraFusionDescription', "Picks a workflow per task, using one or more models to draft, review, or escalate."); + items.push({ + ...item, + detail: description, + detailLink: { label: localize('chat.modelPicker.learnMore', "Learn more"), uri: HYDRA_FUSION_LEARN_MORE_URL }, + ariaDescription: [item.ariaDescription, description].filter(Boolean).join(', '), + tooltip: [item.tooltip, description].filter(Boolean).join(' \u00b7 '), + className: `${item.className} chat-model-picker-routing-model`, + }); } return items; } @@ -797,24 +734,6 @@ export class TabbedModelPicker extends Disposable { : undefined; } - private async _selectAutoTier(model: ILanguageModelChatMetadataAndIdentifier, property: IModelConfigProperty, value: IModelConfigProperty['value']): Promise { - const context = this._context; - if (!context) { - return; - } - this._selectionVersion++; - if (!property.schema.readOnly) { - await setModelConfigValues(model, context.configurationAccess, { [property.key]: value }, - (...change) => context.onConfigurationChanged(model, ...change)); - } - // Choosing a tier closes the picker, so this can run after it hid. Switch to - // Auto only once the tier is saved, and not over a model chosen since. - const current = this._context; - if (current?.configurationAccess === context.configurationAccess && current.selectedModelId === context.selectedModelId && current.selectedModelId !== model.identifier) { - this._applyModelSelection(model, current); - } - } - private _getOrganizationDefaultBadges(model: ILanguageModelChatMetadataAndIdentifier, context: ITabbedModelPickerContext): IActionListItem['additionalBadges'] { const defaultModel = this._getOrganizationDefaultForModel(model, context); return defaultModel ? [{ @@ -851,38 +770,49 @@ export class TabbedModelPicker extends Disposable { className: 'chat-model-picker-model', }; } - const { action, ariaDescription } = createModelAction(model, context.selectedModelId, next => { + const hydraFusionTier = isAutoModel(model) && this._isHydraFusionTierSelected(context) ? this._hydraFusionModel(context) : undefined; + // HydraFusion as Auto's tier is shown as Auto, with HydraFusion in place of its tier. + const { action, ariaDescription } = createModelAction(model, hydraFusionTier ? model.identifier : context.selectedModelId, next => { this._selectionVersion++; + // Auto with its HydraFusion tier is already in use, so picking Auto keeps it. + if (isAutoModel(next) && this._isHydraFusionTierSelected(this._context ?? context)) { + return; + } const pair = this._speedVariants.get(next.identifier); const selected = pair ? getPreferredSpeedVariant(pair, this._context?.selectedModelId, this._preferredSpeedVariants.get(pair.standard.identifier)) : next; this._applyModelSelection(selected, this._context ?? context); - if (isAutoModel(selected) || isHydraFusionModel(selected)) { - this.refresh(); - } }, section, true); - const badge = getModelBadge(model, { providerLabel }); - const summary = getModelConfigSummary(model, context.configurationAccess); + const routingModel = isAutoModel(model) || isHydraFusionModel(model); + const modelBadge = getModelBadge(model, { providerLabel }); + // A routing model's detail, e.g. Auto's discount, is its badge when nothing outranks it. + const routingBadge = !modelBadge && routingModel ? model.metadata.detail : undefined; + const badgeClassName = modelBadge ? `chat-model-picker-badge-${modelBadge.tone}` : routingBadge ? 'chat-model-picker-badge-preview' : undefined; + const summary = hydraFusionTier?.metadata.name ?? getModelConfigSummary(model, context.configurationAccess); + const configDescription = hydraFusionTier + ? localize('chat.modelPicker.autoTierValue', "{0}: {1}", getModelConfigProperty(model, context.configurationAccess, MODEL_CONFIG_GROUP_EFFORT)?.schema.title ?? localize('chat.modelPicker.optimizeFor', "Optimize for"), hydraFusionTier.metadata.name) + : getModelConfigDescription(model, context.configurationAccess); const defaultModel = this._getOrganizationDefaultForModel(model, context); const defaultDescription = defaultModel && getOrganizationDefaultDescription(defaultModel.metadata.name); - const routingModel = isAutoModel(model) || isHydraFusionModel(model); + // Auto opens its tiers like any model's details; HydraFusion has none of its own. + const hasDetails = !isHydraFusionModel(model); return { item: action, kind: ActionListItemKind.Action, label: action.label, - description: badge ? undefined : action.description, - badge: badge?.text, + description: modelBadge || routingBadge ? undefined : action.description, + badge: modelBadge?.text ?? routingBadge, additionalBadges: this._getOrganizationDefaultBadges(model, context), - ariaDescription: [ariaDescription, getModelConfigDescription(model, context.configurationAccess)].filter(Boolean).join(', '), + ariaDescription: [ariaDescription, configDescription].filter(Boolean).join(', '), // Right Arrow on the row opens the same details as its button. - opensDialog: !routingModel, + opensDialog: hasDetails, group: { title: '', icon: action.icon ?? ThemeIcon.fromId(action.checked ? Codicon.check.id : Codicon.blank.id) }, hideIcon: false, section, - className: ['chat-model-picker-model', ...(action.checked ? ['chat-model-picker-current'] : []), ...(badge ? [`chat-model-picker-badge-${badge.tone}`] : [])].join(' '), + className: ['chat-model-picker-model', ...(action.checked ? ['chat-model-picker-current'] : []), ...(badgeClassName ? [badgeClassName] : [])].join(' '), toolbarLabels: true, - toolbarActions: routingModel ? undefined : [this._createDetailsAction(model, summary)], + toolbarActions: hasDetails ? [this._createDetailsAction(model, summary)] : undefined, toolbarDialogActionIds: [MODEL_DETAILS_ACTION_ID], tooltip: [model.metadata.name, summary, defaultDescription].filter(Boolean).join(' \u00b7 '), }; @@ -914,6 +844,7 @@ export class TabbedModelPicker extends Disposable { private _getModelCard(model: ILanguageModelChatMetadataAndIdentifier, context: ITabbedModelPickerContext): ModelCard { let selectionVersion = this._selectionVersion; const routingModel = isAutoModel(model) || isHydraFusionModel(model); + const hydraFusion = isAutoModel(model) && this._hostsHydraFusion(context) ? this._hydraFusionModel(context) : undefined; const cardOptions: IModelCardOptions = { model, configurationAccess: context.configurationAccess, @@ -924,11 +855,20 @@ export class TabbedModelPicker extends Disposable { externalHeader: true, pricingDisclosure: this._pricingDisclosure, speedVariants: this._speedVariants.get(model.identifier), + routingAlternative: hydraFusion ? { + model: hydraFusion, + description: localize('chat.modelPicker.hydraFusionTierDescription', "{0} is a research preview. It picks a workflow per task, using one or more models to draft, review, or escalate.", hydraFusion.metadata.name), + learnMoreUrl: HYDRA_FUSION_LEARN_MORE_URL, + selected: context.selectedModelId === hydraFusion.identifier, + } : undefined, onWillSelect: () => { selectionVersion = ++this._selectionVersion; }, onSelect: next => { if (selectionVersion === this._selectionVersion && this._widget.isVisible && next.identifier !== (this._context ?? context).selectedModelId) { this._rememberSpeedVariant(next.identifier); - this._detailsModelId = next.identifier; + // HydraFusion is chosen from Auto's tiers, so Auto's details stay open. + if (next !== hydraFusion) { + this._detailsModelId = next.identifier; + } this._applyModelSelection(next, this._context ?? context); } }, @@ -1039,46 +979,11 @@ export class TabbedModelPicker extends Disposable { this.refresh(); } - private _getAutoModeToggleState(context: ITabbedModelPickerContext): ReturnType['getState']> { - if (!this._autoModel(context) && !this._hydraFusionModel(context)) { - return undefined; - } - if (this._isAutoOnly(context)) { - return undefined; - } - const defaultModel = this._getOrganizationDefaultModel(context); - return { - checked: this._isAutoMode(context), - enabled: true, - description: defaultModel && isAutoModel(defaultModel) - ? getOrganizationDefaultDescription(defaultModel.metadata.name) - : undefined, - }; - } - - private _toggleAutoMode(enabled: boolean): void { - const context = this._context; - if (!context) { - return; - } - const routingModels = [this._autoModel(context), this._hydraFusionModel(context)]; - const next = enabled - ? routingModels.find(model => model?.identifier === this._lastRoutingModelId) ?? routingModels.find(model => !!model) - : this._fallbackModel(context); - if (!next) { - return; - } - this._selectionVersion++; - this._applyModelSelection(next, context); - this.refresh(); - } - private _applyModelSelection(model: ILanguageModelChatMetadataAndIdentifier, context: ITabbedModelPickerContext, resetWorkflow = true): void { if (resetWorkflow) { context.workflow?.reset(); } this._context = { ...context, selectedModelId: model.identifier }; - this._rememberSelection(model.identifier); context.onSelect(model); } @@ -1087,7 +992,7 @@ export class TabbedModelPicker extends Disposable { const isCandidate = (model: ILanguageModelChatMetadataAndIdentifier) => !isAutoModel(model) && !isHydraFusionModel(model) && !isUserProvidedModel(model, this._languageModelsService) && !requiresNewerVSCode(model, context.controlModels, context.unavailableContext.currentVSCodeVersion); - const candidates = [this._lastExplicitModelId, ...context.recentModelIds, ...context.pinnedModelIds]; + const candidates = [...context.recentModelIds, ...context.pinnedModelIds]; for (const id of candidates) { const model = context.models.find(candidate => candidate.identifier === id); if (model && isCandidate(model)) { 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 index d7a226c3fb22ce..cdd52889d153a4 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerTelemetry.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerTelemetry.ts @@ -9,6 +9,12 @@ import { TelemetryTrustedValue } from '../../../../../../../platform/telemetry/c import { getTelemetryModelIdentifier, ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService, isUserProvidedModel } from '../../../../common/languageModels.js'; import { MODEL_CONFIG_GROUP_CONTEXT, MODEL_CONFIG_GROUP_EFFORT } from './modelPickerModelConfig.js'; +type ChatSessionIds = { readonly chatSessionId?: string; readonly agentSessionId?: string }; +type ChatSessionIdsClassification = { + chatSessionId?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The workbench chat identifier at picker open, matching chat request telemetry.' }; + agentSessionId?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The owning Agent Host session identifier, when the chat has a resolved backend session.' }; +}; + /** How the user opened a model picker surface. */ export type ModelPickerEntryPoint = /** The model name pill in the chat input. */ @@ -27,7 +33,7 @@ export interface IModelPickerOpenTrigger { readonly inputMethod: ModelPickerOpenInputMethod; } -type ChatModelPickerOpenedClassification = { +type ChatModelPickerOpenedClassification = ChatSessionIdsClassification & { provider: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Identifies the agent implementation handling the associated chat session at picker open, such as copilotcli, claude, or codex.' }; owner: 'lramos15'; comment: 'Reporting when a model picker surface is opened, to measure picker interactions from open to close'; @@ -35,16 +41,14 @@ type ChatModelPickerOpenedClassification = { entryPoint: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'How the picker was opened: modelName, configuration, or command' }; inputMethod: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The input the picker was opened with: keyboard, mouse, or unknown when opened by a command' }; model: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The model selected when the picker opened; "unknown" for models the user brought' }; - chatSessionId?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The id of the current chat session, used to correlate the picker interaction with the session.' }; }; -type ChatModelPickerOpenedEvent = { +type ChatModelPickerOpenedEvent = ChatSessionIds & { provider: string | undefined; pickerSessionId: string; entryPoint: ModelPickerEntryPoint; inputMethod: ModelPickerOpenInputMethod; model: string | TelemetryTrustedValue; - chatSessionId?: string; }; type ChatModelPickerClosedClassification = { @@ -63,31 +67,29 @@ type ChatModelPickerClosedEvent = { searched: boolean; }; -type ChatModelChangeClassification = { +type ChatModelChangeClassification = ChatSessionIdsClassification & { provider: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Identifies the agent implementation handling the associated chat session at picker open, such as copilotcli, claude, or codex.' }; owner: 'lramos15'; comment: 'Reporting when the model picker is switched'; fromModel?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The previous chat model' }; toModel: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The new chat model' }; - chatSessionId?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The id of the current chat session, used to correlate the model switch with the session.' }; durationMs: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Time in milliseconds from opening the picker to this model change' }; searched: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether the user searched or filtered the model list in this picker before this change. The search text is not collected.' }; otherModelsExpanded: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether the user expanded the Other Models section in this picker before this change' }; pickerSessionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The id of the picker open this change was made in' }; }; -type ChatModelChangeEvent = { +type ChatModelChangeEvent = ChatSessionIds & { provider: string | undefined; fromModel: string | TelemetryTrustedValue | undefined; toModel: string | TelemetryTrustedValue; - chatSessionId?: string; durationMs: number; searched: boolean; otherModelsExpanded: boolean; pickerSessionId: string; }; -type ChatThinkingEffortChangeClassification = { +type ChatThinkingEffortChangeClassification = ChatSessionIdsClassification & { provider: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Identifies the agent implementation handling the associated chat session at picker open, such as copilotcli, claude, or codex.' }; owner: 'lramos15'; comment: 'Reporting when a model configuration value (e.g. thinking effort, or the Auto routing tier) is changed'; @@ -99,7 +101,7 @@ type ChatThinkingEffortChangeClassification = { pickerSessionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The id of the picker open this change was made in' }; }; -type ChatThinkingEffortChangeEvent = { +type ChatThinkingEffortChangeEvent = ChatSessionIds & { provider: string | undefined; model: string | TelemetryTrustedValue; property: string; @@ -143,25 +145,30 @@ export class ModelPickerTelemetrySession { private _otherModelsExpanded = false; private _closed = false; private readonly _provider: string | undefined; + private readonly _sessionIds: ChatSessionIds; constructor( private readonly _telemetryService: ITelemetryService, private readonly _languageModelsService: ILanguageModelsService, trigger: IModelPickerOpenTrigger, model: ILanguageModelChatMetadataAndIdentifier | undefined, - chatSessionId: string | undefined, + sessionIds: ChatSessionIds, provider: string | undefined, private readonly _now: () => number = () => Date.now(), ) { this._openedAt = this._now(); this._provider = provider; + this._sessionIds = { + ...(sessionIds.chatSessionId !== undefined ? { chatSessionId: sessionIds.chatSessionId } : {}), + ...(sessionIds.agentSessionId !== undefined ? { agentSessionId: sessionIds.agentSessionId } : {}), + }; this._telemetryService.publicLog2('chat.modelPickerOpened', { provider: this._provider, pickerSessionId: this.id, entryPoint: trigger.entryPoint, inputMethod: trigger.inputMethod, model: getTelemetryModelIdentifier(model, this._languageModelsService), - chatSessionId, + ...this._sessionIds, }); } @@ -182,13 +189,12 @@ export class ModelPickerTelemetrySession { logModelChange( fromModel: ILanguageModelChatMetadataAndIdentifier | undefined, toModel: ILanguageModelChatMetadataAndIdentifier, - chatSessionId: string | undefined, ): void { this._telemetryService.publicLog2('chat.modelChange', { provider: this._provider, fromModel: getTelemetryModelIdentifier(fromModel, this._languageModelsService), toModel: getTelemetryModelIdentifier(toModel, this._languageModelsService), - chatSessionId, + ...this._sessionIds, durationMs: this._elapsed(), searched: this._searched, otherModelsExpanded: this._otherModelsExpanded, @@ -222,6 +228,7 @@ export class ModelPickerTelemetrySession { } if (group === MODEL_CONFIG_GROUP_EFFORT) { this._telemetryService.publicLog2('chat.thinkingEffortChange', { + ...this._sessionIds, provider: this._provider, model: getTelemetryModelIdentifier(model, this._languageModelsService), // Models the user brought choose their own property keys. 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 8671ee311cc134..22bbd8476034a3 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 @@ -537,10 +537,6 @@ export class ModelPickerWidget extends Disposable { (target?.isConnected ? target : previous)?.focus(); }); trigger?.setAttribute('aria-expanded', 'true'); - // Routing models have no Details, so their readout opens this same list. - if (this._selectedModel && (isAutoModel(this._selectedModel) || isHydraFusionModel(this._selectedModel))) { - this._configButton?.setAttribute('aria-expanded', 'true'); - } this._domNode?.classList.add('model-picker-active'); picker.show(anchor, context, detailsModelId, focusConfiguration, this._contextViewLayer, options); if (!picker.isVisible) { @@ -572,10 +568,13 @@ export class ModelPickerWidget extends Disposable { return; } - const telemetrySession = new ModelPickerTelemetrySession(this._telemetryService, this._languageModelsService, trigger, this._selectedModel, this._delegate.getChatSessionId?.(), this._delegate.getProvider ? this._delegate.getProvider() : 'unknown'); + const telemetrySession = new ModelPickerTelemetrySession(this._telemetryService, this._languageModelsService, trigger, this._selectedModel, { + chatSessionId: this._delegate.getChatSessionId?.(), + agentSessionId: this._delegate.getAgentSessionId?.(), + }, this._delegate.getProvider ? this._delegate.getProvider() : 'unknown'); const onSelect = (model: ILanguageModelChatMetadataAndIdentifier) => { - telemetrySession.logModelChange(this._selectedModel, model, this._delegate.getChatSessionId?.()); + telemetrySession.logModelChange(this._selectedModel, model); this._selectedModel = model; this._renderLabel(); this._onDidChangeSelection.fire(model); @@ -658,7 +657,7 @@ export class ModelPickerWidget extends Disposable { link: this.getCacheBreakLearnMoreLink(), dismiss: () => this.dismissCacheBreakHint(), } : undefined, - }, telemetrySession, showDetails && this._selectedModel && !isAutoModel(this._selectedModel) && !isHydraFusionModel(this._selectedModel) ? this._selectedModel.identifier : undefined, focusConfiguration, options); + }, telemetrySession, showDetails ? this._selectedModel?.identifier : undefined, focusConfiguration, options); } private _updateBadge(): void { @@ -714,7 +713,6 @@ export class ModelPickerWidget extends Disposable { ? localize('chat.modelPicker.noModels', "No models available") : (name ?? localize('chat.modelPicker.auto', "Auto")); const showModelLabel = !compact || !modelIcon || noModelsAvailable; - const showingAuto = !unavailable && !activating && !genericNoModels && (!this._selectedModel || isAutoModel(this._selectedModel)); if (showModelLabel) { nameChildren.push(dom.$('span.chat-input-picker-label', undefined, modelLabel)); } @@ -724,8 +722,7 @@ export class ModelPickerWidget extends Disposable { dom.reset(this._nameButton, ...nameChildren); if (this._configButton) { - const opensDetails = !showingAuto && !(this._selectedModel && isHydraFusionModel(this._selectedModel)); - this._configButton.setAttribute('aria-haspopup', opensDetails ? 'dialog' : 'menu'); + this._configButton.setAttribute('aria-haspopup', 'dialog'); renderModelConfigurationButton(this._configButton, this._selectedModel, this._delegate.modelConfiguration ?? this._languageModelsService, !!workflow || minimal || compact || noModelsAvailable); } const configVisible = !!this._configButton && this._configButton.style.display !== 'none'; diff --git a/src/vs/workbench/contrib/chat/common/agentHostConfigPolicy.ts b/src/vs/workbench/contrib/chat/common/agentHostConfigPolicy.ts index 50ccf5b21f699d..5bd565e448b544 100644 --- a/src/vs/workbench/contrib/chat/common/agentHostConfigPolicy.ts +++ b/src/vs/workbench/contrib/chat/common/agentHostConfigPolicy.ts @@ -39,6 +39,13 @@ export function isAutoApproveValuePolicyRestricted(value: unknown, policyRestric return policyRestricted && value !== ChatPermissionLevel.Default; } +/** Disables the global override when the user explicitly selects a lower session permission. */ +export async function disableGlobalAutoApproveForPermissionSelection(configurationService: IConfigurationService, level: ChatPermissionLevel): Promise { + if (level !== ChatPermissionLevel.AutoApprove && configurationService.getValue(ChatConfiguration.GlobalAutoApprove) === true) { + await configurationService.updateValue(ChatConfiguration.GlobalAutoApprove, false); + } +} + export function normalizeSessionConfigValue(property: string, value: string, policyRestricted: boolean): string; export function normalizeSessionConfigValue(property: string, value: unknown, policyRestricted: boolean): unknown; export function normalizeSessionConfigValue(property: string, value: unknown, policyRestricted: boolean): unknown { diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts index fa93d98ab15285..954324d93fd16f 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts @@ -2305,9 +2305,11 @@ export type ChatPendingRequestChangeEvent = { source: string; requestId?: string; chatSessionId?: string; + agentSessionId?: string; }; export type ChatPendingRequestChangeClassification = { + agentSessionId?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The owning Agent Host session identifier, when the chat has a resolved backend session.' }; action: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether a pending request was added or removed.' }; source: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The method that triggered the pending request change.' }; requestId?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The request ID associated with the pending request change.' }; diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index 13d11ca27931e8..0958e1f34f5817 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -47,7 +47,8 @@ import { ChatModelStore, IStartSessionProps } from '../model/chatModelStore.js'; import { chatAgentLeader, ChatRequestAgentPart, ChatRequestAgentSubcommandPart, ChatRequestSlashCommandPart, ChatRequestTextPart, chatSubcommandLeader, getPromptText, IParsedChatRequest } from '../requestParser/chatParserTypes.js'; import { ChatRequestParser } from '../requestParser/chatRequestParser.js'; import { ChatMcpServersStarting, ChatPendingRequestChangeClassification, ChatPendingRequestChangeEvent, ChatPendingRequestChangeEventName, ChatRequestQueueKind, ChatSendResult, ChatSendResultQueued, ChatSendResultSent, ChatStopCancellationNoopClassification, ChatStopCancellationNoopEvent, ChatStopCancellationNoopEventName, IChatCompleteResponse, IChatDetail, IChatFollowup, IChatModelReference, IChatProgress, IChatQuestionAnswers, IChatRequestAcceptedEvent, IChatRequestSubmittedEvent, IChatSendRequestOptions, IChatSendRequestResponseState, IChatService, IChatSessionStartOptions, IChatUserActionEvent, IRemotePendingRequest, ResponseModelState } from './chatService.js'; -import { ChatRequestTelemetry, ChatServiceTelemetry } from './chatServiceTelemetry.js'; +import { ChatRequestTelemetry, ChatServiceTelemetry, getChatSessionTelemetryIds } from './chatServiceTelemetry.js'; +import { IAgentHostConnectionsService } from '../../../../../platform/agentHost/common/agentHostConnectionsService.js'; import { getChatSessionHistoryContent, IChatSessionsService, IChatSessionHistoryItem, isAgentHostTarget, isTerminalCommandPrompt, localChatSessionType } from '../chatSessionsService.js'; import { ChatSessionStore, IChatSessionEntryMetadata } from '../model/chatSessionStore.js'; import { IChatSlashCommandService } from '../participants/chatSlashCommands.js'; @@ -269,6 +270,7 @@ export class ChatService extends Disposable implements IChatService { @IChatDebugService private readonly chatDebugService: IChatDebugService, @IManagedSettingsService managedSettingsService: IManagedSettingsService, @IAccountPolicyGateService private readonly accountPolicyGateService: IAccountPolicyGateService, + @IAgentHostConnectionsService private readonly agentHostConnectionsService: IAgentHostConnectionsService, ) { super(); this.managedPolicy = observableFromEvent(this, managedSettingsService.onDidChangeManagedSettings, () => requiresCopilotAgentHost(managedSettingsService)); @@ -1122,7 +1124,7 @@ export class ChatService extends Disposable implements IChatService { const cancellableRequest = this.instantiationService.createInstance(CancellableRequest, new CancellationTokenSource(), undefined, undefined, undefined); this._syntheticPendingRequests.add(cancellableRequest); this._pendingRequests.set(model.sessionResource, cancellableRequest); - this.telemetryService.publicLog2(ChatPendingRequestChangeEventName, { action: 'add', source: 'remoteSession', chatSessionId: chatSessionResourceToId(model.sessionResource) }); + this.telemetryService.publicLog2(ChatPendingRequestChangeEventName, { action: 'add', source: 'remoteSession', ...getChatSessionTelemetryIds(model.sessionResource, this.agentHostConnectionsService) }); cancellationListener.value = createCancellationListener(cancellableRequest.cancellationTokenSource.token); }; @@ -1266,7 +1268,7 @@ export class ChatService extends Disposable implements IChatService { completeLastResponse(); } - this.telemetryService.publicLog2(ChatPendingRequestChangeEventName, { action: 'notCancelable', source: 'remoteSession', chatSessionId: chatSessionResourceToId(model.sessionResource) }); + this.telemetryService.publicLog2(ChatPendingRequestChangeEventName, { action: 'notCancelable', source: 'remoteSession', ...getChatSessionTelemetryIds(model.sessionResource, this.agentHostConnectionsService) }); if (lastRequest && model.editingSession) { // wait for timeline to load so that a 'changes' part is added when the response completes await chatEditingSessionIsReady(model.editingSession); @@ -1979,7 +1981,7 @@ export class ChatService extends Disposable implements IChatService { })); pendingRequest.requestId ??= requestProps.requestId; if (pendingRequest.requestId) { - this.telemetryService.publicLog2(ChatPendingRequestChangeEventName, { action: 'add', source: 'sendRequestId', requestId: pendingRequest.requestId, chatSessionId: chatSessionResourceToId(sessionResource) }); + this.telemetryService.publicLog2(ChatPendingRequestChangeEventName, { action: 'add', source: 'sendRequestId', requestId: pendingRequest.requestId, ...getChatSessionTelemetryIds(sessionResource, this.agentHostConnectionsService) }); } } @@ -2110,13 +2112,13 @@ export class ChatService extends Disposable implements IChatService { // Note- requestId is not known at this point, assigned later const cancellableRequest = this.instantiationService.createInstance(CancellableRequest, source, undefined, rawResponsePromise, options); this._pendingRequests.set(model.sessionResource, cancellableRequest); - this.telemetryService.publicLog2(ChatPendingRequestChangeEventName, { action: 'add', source: 'sendRequest', chatSessionId: chatSessionResourceToId(model.sessionResource) }); + this.telemetryService.publicLog2(ChatPendingRequestChangeEventName, { action: 'add', source: 'sendRequest', ...getChatSessionTelemetryIds(model.sessionResource, this.agentHostConnectionsService) }); rawResponsePromise.finally(() => { markChat(sessionResource, ChatPerfMark.RequestComplete); clearChatMarks(sessionResource); if (this._pendingRequests.get(model.sessionResource) === cancellableRequest) { this._pendingRequests.deleteAndDispose(model.sessionResource); - this.telemetryService.publicLog2(ChatPendingRequestChangeEventName, { action: 'remove', source: 'sendRequestComplete', requestId: cancellableRequest.requestId, chatSessionId: chatSessionResourceToId(model.sessionResource) }); + this.telemetryService.publicLog2(ChatPendingRequestChangeEventName, { action: 'remove', source: 'sendRequestComplete', requestId: cancellableRequest.requestId, ...getChatSessionTelemetryIds(model.sessionResource, this.agentHostConnectionsService) }); } // Process the next pending request from the queue if any if (shouldProcessPending) { @@ -2369,7 +2371,7 @@ export class ChatService extends Disposable implements IChatService { if (pendingRequest?.requestId === requestId) { pendingRequest.cancel(); this._pendingRequests.deleteAndDispose(sessionResource); - this.telemetryService.publicLog2(ChatPendingRequestChangeEventName, { action: 'remove', source: 'removeRequest', requestId, chatSessionId: chatSessionResourceToId(model.sessionResource) }); + this.telemetryService.publicLog2(ChatPendingRequestChangeEventName, { action: 'remove', source: 'removeRequest', requestId, ...getChatSessionTelemetryIds(model.sessionResource, this.agentHostConnectionsService) }); } model.removeRequest(requestId); @@ -2392,8 +2394,8 @@ export class ChatService extends Disposable implements IChatService { if (cts) { cts.requestId = request.id; this._pendingRequests.set(target.sessionResource, cts); - this.telemetryService.publicLog2(ChatPendingRequestChangeEventName, { action: 'remove', source: 'adoptRequest', requestId: request.id, chatSessionId: chatSessionResourceToId(oldOwner.sessionResource) }); - this.telemetryService.publicLog2(ChatPendingRequestChangeEventName, { action: 'add', source: 'adoptRequest', requestId: request.id, chatSessionId: chatSessionResourceToId(target.sessionResource) }); + this.telemetryService.publicLog2(ChatPendingRequestChangeEventName, { action: 'remove', source: 'adoptRequest', requestId: request.id, ...getChatSessionTelemetryIds(oldOwner.sessionResource, this.agentHostConnectionsService) }); + this.telemetryService.publicLog2(ChatPendingRequestChangeEventName, { action: 'add', source: 'adoptRequest', requestId: request.id, ...getChatSessionTelemetryIds(target.sessionResource, this.agentHostConnectionsService) }); } } } @@ -2449,9 +2451,13 @@ export class ChatService extends Disposable implements IChatService { } const responseCompletePromise = pendingRequest.responseCompletePromise; + const activeRequest = this._sessionModels.get(sessionResource)?.lastRequest; + // Loaded and server-initiated turns have synthetic cancellation tokens without a request ID. + const requestId = pendingRequest.requestId ?? (activeRequest?.response?.isInProgress ? activeRequest.id : undefined); + const sessionTelemetry = getChatSessionTelemetryIds(sessionResource, this.agentHostConnectionsService); pendingRequest.cancel(); this._pendingRequests.deleteAndDispose(sessionResource); - this.telemetryService.publicLog2(ChatPendingRequestChangeEventName, { action: 'remove', source: source ?? 'cancelRequest', requestId: pendingRequest.requestId, chatSessionId: chatSessionResourceToId(sessionResource) }); + this.telemetryService.publicLog2(ChatPendingRequestChangeEventName, { action: 'remove', source: source ?? 'cancelRequest', requestId, ...sessionTelemetry }); if (responseCompletePromise) { await raceTimeout(responseCompletePromise, 1000); diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceTelemetry.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceTelemetry.ts index 75bfd18f4c7f33..770660dd17561b 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceTelemetry.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceTelemetry.ts @@ -4,6 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import { URI } from '../../../../../base/common/uri.js'; +import { AgentSession } from '../../../../../platform/agentHost/common/agent.js'; +import { IAgentHostConnectionsService } from '../../../../../platform/agentHost/common/agentHostConnectionsService.js'; import { isLocation } from '../../../../../editor/common/languages.js'; import { escapeModelIdForTelemetry, ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { IChatAgentData } from '../participants/chatAgents.js'; @@ -13,7 +15,7 @@ import { ChatAgentVoteDirection, ChatCopyKind, IChatSendRequestOptions, IChatUse import { isImageVariableEntry } from '../attachments/chatVariableEntries.js'; import { ChatAgentLocation, ChatModeKind, ChatPermissionLevel } from '../constants.js'; import { COPILOT_VENDOR_ID, ILanguageModelsService } from '../languageModels.js'; -import { chatSessionResourceToId, getChatSessionType } from '../model/chatUri.js'; +import { chatSessionResourceToId, getChatSessionType, isUntitledChatSession } from '../model/chatUri.js'; import { getAgentHostProviderForTelemetry, IChatSessionsService, isAgentHostSessionResource } from '../chatSessionsService.js'; import { isCopilotAgentHostProvider, isRemoteAgentHostSessionType, parseRemoteAgentHostHarness } from '../../../../../platform/agentHost/common/agentHostSessionType.js'; import { IChatEntitlementService } from '../../../../services/chat/common/chatEntitlementService.js'; @@ -483,6 +485,28 @@ interface IChatSessionTelemetryContext { readonly harness: string | undefined; } +/** Preserves workbench chat IDs while resolving the owning Agent Host session, without its connection authority. */ +export function getChatSessionTelemetryIds(sessionResource: URI, connectionsService: Pick): { chatSessionId: string; agentSessionId?: string } { + const chatSessionId = getChatSessionIdForTelemetry(sessionResource); + if (!isAgentHostSessionResource(sessionResource)) { + return { chatSessionId }; + } + const identity = connectionsService.resolveSessionResourceIdentity(sessionResource); + if (!identity) { + return { chatSessionId }; + } + // Advertised backend resources are opaque; only a legacy fallback can be a provisional draft. + const isProvisionalFallback = !identity.backendSessionIsAdvertised + && (isUntitledChatSession(sessionResource) || isUntitledChatSession(identity.backendSession)); + if (isProvisionalFallback) { + return { chatSessionId }; + } + return { + chatSessionId, + agentSessionId: AgentSession.id(identity.backendSession), + }; +} + /** Returns telemetry-safe session context, excluding remote Agent Host connection authorities. */ export function getChatSessionTelemetryContext(sessionResource: URI): IChatSessionTelemetryContext { return { diff --git a/src/vs/workbench/contrib/chat/test/browser/accessibility/chatAccessibilityHelp.test.ts b/src/vs/workbench/contrib/chat/test/browser/accessibility/chatAccessibilityHelp.test.ts index 9261e6a7fe9258..1d6c47c4884f16 100644 --- a/src/vs/workbench/contrib/chat/test/browser/accessibility/chatAccessibilityHelp.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/accessibility/chatAccessibilityHelp.test.ts @@ -189,15 +189,14 @@ suite('Chat Accessibility Help', () => { }, { focus: true, open: true, inline: true, status: true, modelSettings: true, resourceLinks: true, nestedSubagent: true }); }); - test('documents the Copilot tab switch and independent provider navigation', () => { + test('documents Auto as the first Copilot model and independent provider navigation', () => { const help = getAccessibilityHelpText('agentView', new MockKeybindingService(), true); assert.deepStrictEqual({ - switch: help.includes('active Copilot tab has an Auto switch beside the provider name'), - keyboardTargets: help.includes('provider button and switch are separate keyboard targets'), - providers: help.includes('provider tabs and search remain available'), - restored: help.includes('Turn Auto off to restore the previous manual Copilot model'), + autoEntry: help.includes('Auto is the first model in the Copilot tab and is selected like any other model'), + providers: help.includes('Provider tabs and search remain available'), + noSwitch: help.includes('Auto switch'), noImplicitSelection: help.includes('Browsing another provider does not change the selected model'), - }, { switch: true, keyboardTargets: true, providers: true, restored: true, noImplicitSelection: true }); + }, { autoEntry: true, providers: true, noSwitch: false, noImplicitSelection: true }); }); test('explains organization-managed defaults separately from this chat selection', () => { @@ -205,11 +204,11 @@ suite('Chat Accessibility Help', () => { assert.deepStrictEqual({ managedDefault: help.includes('Org default identifies the model your organization sets for new chats'), perChatChoice: help.includes('the checkmark identifies your current selection'), - auto: help.includes('Auto section carries the same label when Auto is the organization default'), + auto: help.includes('Auto carries the same label when it is the organization default'), }, { managedDefault: true, perChatChoice: true, auto: true }); }); - test('documents model details and activating Auto through Optimize for', () => { + test('documents model details and Auto\'s Optimize for preferences, including HydraFusion', () => { const help = getAccessibilityHelpText('agentView', new MockKeybindingService(), true); assert.deepStrictEqual({ details: help.includes('Tab to reach its configuration button'), @@ -217,13 +216,13 @@ suite('Chat Accessibility Help', () => { inputShortcut: help.includes('opens the same page for the current model'), defaults: help.includes('Known values are shown even at their defaults'), visibility: help.includes('on the hovered, selected, or keyboard-focused model'), - inlinePreferences: help.includes('In Auto mode, use Up and Down Arrow to focus an "Optimize for" preference'), - hydra: help.includes('HydraFusion, when available, is an alternative routing choice'), - hydraDescription: help.includes('Focus HydraFusion and press Tab to reach Learn more'), - activation: help.includes('Enter or Space applies the preference and keeps the picker open'), - autoEntry: help.includes('both the Auto name and preference readout in the chat input open these routing choices'), - noAutoDetails: help.includes('Auto has no separate details page'), - }, { details: true, inspection: true, inputShortcut: true, defaults: true, visibility: true, inlinePreferences: true, hydra: true, hydraDescription: true, activation: true, autoEntry: true, noAutoDetails: true }); + preferences: help.includes('Auto\'s details hold its "Optimize for" preference: Efficiency, Balance, or Intelligence, followed by HydraFusion when available'), + activation: help.includes('Choosing a preference selects Auto and keeps its details open'), + hydra: help.includes('HydraFusion is a research preview'), + hydraDescription: help.includes('shows its description below the preferences, with Learn more reachable by Tab'), + hydraSelection: help.includes('Auto stays checked in the model list with HydraFusion as its preference'), + autoEntry: help.includes('The preference readout beside Auto in the chat input opens the same details'), + }, { details: true, inspection: true, inputShortcut: true, defaults: true, visibility: true, preferences: true, activation: true, hydra: true, hydraDescription: true, hydraSelection: true, autoEntry: true }); }); test('documents Right Arrow details, provider tab keys, and the Back keys', () => { diff --git a/src/vs/workbench/contrib/chat/test/browser/actions/chatCodeblockActions.test.ts b/src/vs/workbench/contrib/chat/test/browser/actions/chatCodeblockActions.test.ts index 1d20f01dfe0dad..797725b2f8741f 100644 --- a/src/vs/workbench/contrib/chat/test/browser/actions/chatCodeblockActions.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/actions/chatCodeblockActions.test.ts @@ -17,6 +17,7 @@ import { ILanguageService } from '../../../../../../editor/common/languages/lang import { CopyAction } from '../../../../../../editor/contrib/clipboard/browser/clipboard.js'; import { createTextModel } from '../../../../../../editor/test/common/testTextModel.js'; import { IClipboardService } from '../../../../../../platform/clipboard/common/clipboardService.js'; +import { IAgentHostConnectionsService } from '../../../../../../platform/agentHost/common/agentHostConnectionsService.js'; import { CommandsRegistry } from '../../../../../../platform/commands/common/commands.js'; import { IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; import { IDialogService } from '../../../../../../platform/dialogs/common/dialogs.js'; @@ -64,6 +65,9 @@ suite('Chat code-block action telemetry', () => { ['custom-agent-session', 'custom-provider'], ].map(([type, agentHostProviderId]) => ({ type, agentHostProviderId, name: type, displayName: type, description: '' }))); instantiationService.stub(IChatSessionsService, chatSessionsService); + instantiationService.stub(IAgentHostConnectionsService, { + resolveSessionResourceIdentity: () => ({ connectionAuthority: 'private-host', backendSession: URI.parse('copilotcli:/backend-session') }), + }); accepted = []; userActions = []; clipboardWrites = []; @@ -158,6 +162,7 @@ suite('Chat code-block action telemetry', () => { method: data.acceptanceMethod, requestId: data.sourceRequestId, sessionId: data.chatSessionId, + agentSessionId: data.agentSessionId, isAgentHostSession: data.isAgentHostSession, provider: data.provider, feature: data.feature, @@ -166,6 +171,7 @@ suite('Chat code-block action telemetry', () => { method, requestId: 'request-origin', sessionId, + agentSessionId: isAgentHostSession ? 'backend-session' : undefined, isAgentHostSession, provider, feature: 'sideBarChat', diff --git a/src/vs/workbench/contrib/chat/test/browser/actions/chatPluginActions.test.ts b/src/vs/workbench/contrib/chat/test/browser/actions/chatPluginActions.test.ts index 4f7445605c9f22..c7acdce785235a 100644 --- a/src/vs/workbench/contrib/chat/test/browser/actions/chatPluginActions.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/actions/chatPluginActions.test.ts @@ -37,11 +37,13 @@ suite('ManagePluginMarketplacesAction', () => { class TestQuickInputService extends mock() { readonly pickSnapshots: { id: string | undefined; label: string; type: string }[][] = []; + readonly picks: QuickPickInput[][] = []; inputOptions: IInputOptions | undefined; inputValue: string | undefined; pickIds: (string | undefined)[] = []; override async pick(picks: QuickPickInput[]): Promise { + this.picks.push(picks); this.pickSnapshots.push(picks.map(pick => ({ id: pick.id, label: pick.label ?? '', @@ -235,7 +237,82 @@ suite('ManagePluginMarketplacesAction', () => { }]); }); - test('rejects marketplaces blocked by strict enterprise policy', async () => { + test('disables marketplaces outside strict policy and labels organization-managed marketplaces', async () => { + const defaultMarketplace = parseMarketplaceReference('github/awesome-copilot#marketplace')!; + const managedMarketplace = parseMarketplaceReference('microsoft/vscode-team-kit')!; + const fixture = createFixture({ + [ChatConfiguration.PluginMarketplaces]: [defaultMarketplace.rawValue], + [ChatConfiguration.ExtraMarketplaces]: { + 'vscode-team-kit': managedMarketplace.rawValue, + }, + [ChatConfiguration.StrictMarketplaces]: [{ source: 'github', repo: managedMarketplace.rawValue }], + }); + + await fixture.instantiationService.invokeFunction(accessor => new ManagePluginMarketplacesAction().run(accessor)); + + assert.deepStrictEqual( + fixture.quickInputService.picks[0] + .filter((pick): pick is IQuickPickItem => pick.type !== 'separator' && pick.id !== 'addMarketplace') + .map(pick => ({ + id: pick.id, + label: pick.label, + description: pick.description, + disabled: pick.disabled, + pickable: pick.pickable, + })), + [ + { + id: defaultMarketplace.canonicalId, + label: defaultMarketplace.displayLabel, + description: 'Disabled by Organization', + disabled: true, + pickable: false, + }, + { + id: managedMarketplace.canonicalId, + label: 'vscode-team-kit', + description: 'Managed by Organization', + disabled: false, + pickable: true, + }, + ], + ); + }); + + test('disables organization-managed marketplaces outside strict policy', async () => { + const managedMarketplace = parseMarketplaceReference('microsoft/vscode-team-kit')!; + const fixture = createFixture({ + [ChatConfiguration.PluginMarketplaces]: [], + [ChatConfiguration.ExtraMarketplaces]: { + 'vscode-team-kit': managedMarketplace.rawValue, + }, + [ChatConfiguration.StrictMarketplaces]: [{ source: 'github', repo: 'approved/catalog' }], + }); + + await fixture.instantiationService.invokeFunction(accessor => new ManagePluginMarketplacesAction().run(accessor)); + + assert.deepStrictEqual( + fixture.quickInputService.picks[0] + .filter((pick): pick is IQuickPickItem => pick.type !== 'separator' && pick.id !== 'addMarketplace') + .map(pick => ({ + id: pick.id, + label: pick.label, + description: pick.description, + disabled: pick.disabled, + pickable: pick.pickable, + })), + [{ + id: managedMarketplace.canonicalId, + label: 'vscode-team-kit', + description: 'Managed by Organization, Disabled by Organization', + disabled: true, + pickable: false, + }], + ); + }); + + test('adds marketplaces outside strict policy as disabled', async () => { + const blockedMarketplace = parseMarketplaceReference('blocked/catalog')!; const fixture = createFixture({ [ChatConfiguration.PluginMarketplaces]: [], [ChatConfiguration.ExtraMarketplaces]: {}, @@ -245,12 +322,29 @@ suite('ManagePluginMarketplacesAction', () => { fixture.quickInputService.inputValue = 'blocked/catalog'; await fixture.instantiationService.invokeFunction(accessor => new ManagePluginMarketplacesAction().run(accessor)); + assert.deepStrictEqual({ updates: fixture.configurationService.updates, notifications: fixture.notifications.map(notification => notification.message), + addedMarketplace: fixture.quickInputService.picks[1] + .find((pick): pick is IQuickPickItem => pick.type !== 'separator' && pick.id === blockedMarketplace.canonicalId), }, { - updates: [], - notifications: ['This marketplace is not allowed by enterprise policy.'], + updates: [{ + key: ChatConfiguration.PluginMarketplaces, + value: [blockedMarketplace.rawValue], + }], + notifications: [], + addedMarketplace: { + id: blockedMarketplace.canonicalId, + label: blockedMarketplace.displayLabel, + description: 'Disabled by Organization', + detail: blockedMarketplace.cloneUrl, + kind: 'marketplace', + reference: blockedMarketplace, + managedByPolicy: false, + disabled: true, + pickable: false, + }, }); }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index aea813bfc6ee63..5885d3ece6deab 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -8071,6 +8071,31 @@ suite('AgentHostChatContribution', () => { await turnPromise; })); + test('plan-review rejection dispatches a decline without selecting an implementation action', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); + const { turnPromise, collected, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables); + const request: ChatInputRequestWithPlanReview = { + id: 'plan-reject', + planReview: { + title: 'Review Plan', content: 'Plan', canProvideFeedback: true, answerQuestionId: 'action', + actions: [{ id: 'interactive', label: 'Implement Plan', default: true }], + }, + }; + fire({ type: ActionType.ChatInputRequested, request } as ChatAction); + await timeout(10); + const review = collected.flat().find(part => part.kind === 'planReview') as ChatPlanReviewData; + agentHostService.dispatchedActions.length = 0; + review.completion.complete({ rejected: true }); + await timeout(10); + assert.deepStrictEqual(agentHostService.dispatchedActions.find(d => d.action.type === ActionType.ChatInputCompleted)?.action, { + type: ActionType.ChatInputCompleted, + requestId: 'plan-reject', + response: ChatInputResponseKind.Decline, + }); + fire({ type: ActionType.ChatTurnComplete, turnId, endedAt: '2025-01-01T00:00:00.000Z' } as ChatAction); + await turnPromise; + })); + test('plan-review feedback dispatches accepted text answer for revision', () => runWithFakedTimers({ useFakeTimers: true }, async () => { const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); const { turnPromise, collected, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables); 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 32c82bcc82883d..6cc081f5a2b878 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 @@ -109,6 +109,9 @@ suite('AgentHostChatInputPicker - combined mode and permissions', () => { const result = super.inspect(key); return { ...result, policyValue: this.policyRestricted && key === ChatConfiguration.GlobalAutoApprove ? result.value : undefined }; } + override updateValue(key: string, value: unknown): Promise { + return this.setUserConfiguration(key, value); + } }({ [ChatConfiguration.ExperimentalModePermissionsPicker]: combined, [ChatConfiguration.GlobalAutoApprove]: false, @@ -1003,6 +1006,23 @@ suite('AgentHostChatInputPicker - combined mode and permissions', () => { }); for (const combined of [false, true]) { + test(`turns off global auto approve when selecting lower permissions (${combined ? 'combined' : 'separate'} picker)`, async () => { + const { modePicker, permissionPicker, configuration, config, actionWidget, dispatches } = setup(combined); + await configuration.setUserConfiguration(ChatConfiguration.GlobalAutoApprove, true); + config.values.autoApprove = 'autoApprove'; + const picker = combined ? modePicker : permissionPicker; + await picker['_showPicker'](document.createElement('div'), combined); + await actionWidget.select('Manual permissions'); + + assert.deepStrictEqual({ + globalAutoApprove: configuration.getValue(ChatConfiguration.GlobalAutoApprove), + dispatches, + }, { + globalAutoApprove: false, + dispatches: [{ type: ActionType.SessionConfigChanged, config: { autoApprove: 'default' } }], + }); + }); + test(`offers experimental Assisted permissions without an opt-in setting (${combined ? 'combined' : 'separate'} picker)`, async () => { const { modePicker, permissionPicker, configuration, config, actionWidget, dispatches } = setup(combined); await configuration.setUserConfiguration('chat.assistedPermissions.enabled', false); 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 4c2966cc605fb8..6de940cc38344d 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 @@ -39,7 +39,7 @@ import { PromptFileSource, PromptsType, Target } from '../../../common/promptSyn import { AICustomizationManagementSection, AICustomizationSources, type AICustomizationSource } from '../../../common/aiCustomizationWorkspaceService.js'; import { CustomizationMigrationCategoryId, getCustomizationMigrationCategory, ICustomizationMigrationCategory } from '../../../browser/aiCustomization/customizationMigrationCategories.js'; import type { ICustomizationHarnessService, ICustomizationSourceFolder } from '../../../common/customizationHarnessService.js'; -import type { CustomizationMigrationTargetFolders, IMigratedCustomizationsWithFailureReasonsResult } from '../../../browser/aiCustomization/customizationMigration.js'; +import type { IMigratedCustomizationsWithFailureReasonsResult } from '../../../browser/aiCustomization/customizationMigration.js'; import type { ICustomizationMigrationCategorySummary, IInstalledCustomizationTarget } from '../../../browser/aiCustomization/aiCustomizationWelcomePage.js'; import { AICustomizationManagementEditorInput } from '../../../browser/aiCustomization/aiCustomizationManagementEditorInput.js'; import { aiCustomizationManagementSectionRegistry, IAICustomizationManagementSectionWidget } from '../../../browser/aiCustomization/aiCustomizationManagementSectionRegistry.js'; @@ -181,6 +181,17 @@ suite('aiCustomizationManagementEditor', () => { assert.deepStrictEqual(states, [true, false]); }); + type TestQuickPickItem = { + label: string; + description?: string; + picked?: boolean; + folder?: ICustomizationSourceFolder; + destination?: ICustomizationMigrationDashboardDestination; + chooseAnother?: boolean; + }; + + type TestQuickPickResult = Omit & { label?: string }; + type TestableEditor = { currentEditingPromptType: PromptsType | undefined; currentEditingSource: string | undefined; @@ -205,10 +216,12 @@ suite('aiCustomizationManagementEditor', () => { migrationShortcutContainer: HTMLElement | undefined; migrationShortcutButton: HTMLButtonElement | undefined; migrationShortcutCount: HTMLElement | undefined; + migrationDashboard: { element: HTMLElement } | undefined; layoutSidebar(width: number, height: number): void; updateSidebarMigrationShortcut(): void; startCustomizationMigration(categoryId?: CustomizationMigrationCategoryId, migrationFlowId?: string): Promise; showCustomizationMigrationDashboard(): void; + getMigrationAccessibilityContent(): string | undefined; storageService: IStorageService; workspaceService: { activeProjectRoot: ISettableObservable; @@ -234,6 +247,9 @@ suite('aiCustomizationManagementEditor', () => { editorPreviewBodySection: HTMLElement | undefined; editorPreviewBodyTitle: HTMLElement | undefined; editorPreviewBodyContainer: HTMLElement | undefined; + editorMigrationDetailWarning: HTMLElement | undefined; + currentMigrationDetail: { readonly item: ICustomizationMigrationDashboardItem; readonly storage: PromptsStorage } | undefined; + updateMigrationDetailWarning(): void; markdownRendererService: { render(markdown: { value: string }): { element: HTMLElement; dispose(): void } }; editorPreviewDisposables: DisposableStore; editorPreviewRenderScheduler: { cancel(): void; schedule(): void }; @@ -259,7 +275,7 @@ suite('aiCustomizationManagementEditor', () => { customizationMigrationTelemetryService: ICustomizationMigrationTelemetryService; dialogService: Pick; quickInputService: { - pick(items: readonly { label: string; description?: string; folder?: ICustomizationSourceFolder; destination?: ICustomizationMigrationDashboardDestination; chooseAnother?: boolean }[]): Promise<{ label?: string; folder?: ICustomizationSourceFolder; destination?: ICustomizationMigrationDashboardDestination; chooseAnother?: boolean } | undefined>; + pick(items: readonly TestQuickPickItem[], options?: { activeItem?: TestQuickPickItem }): Promise; }; notificationService: { error(message: string): void; info(message: string): void; warn(message: string): void }; fileDialogService: { showOpenDialog(): Promise }; @@ -297,8 +313,6 @@ suite('aiCustomizationManagementEditor', () => { showEmbeddedMcpDetail(server: IMcpServerDetailInput, origin?: { readonly kind: 'migration' }, migrationDetail?: { readonly item: ICustomizationMigrationDashboardItem; readonly storage: PromptsStorage }): Promise; openMigrationCustomization(item: ICustomizationMigrationDashboardItem, storage: PromptsStorage): Promise; getConfiguredLocationSettingsToClear(category: ICustomizationMigrationCategory, customizations: readonly MigratableConfiguration[]): readonly string[]; - getFileMigrationConfirmationDetail(detail: string, files: readonly MigratableConfiguration[], targetFolders: CustomizationMigrationTargetFolders): string; - getMcpMigrationConfirmationDetail(detail: string, servers: readonly IMcpServerCustomizationMigrationCandidate[]): string; clearConfiguredLocationSettings(settingIds: readonly string[]): Promise; migrateSelectedCustomizations(category: ICustomizationMigrationCategory, customizations: readonly CustomizationMigrationCandidate[]): Promise; runCustomizationMigration(customizations: readonly MigratableConfiguration[]): Promise; @@ -390,6 +404,7 @@ suite('aiCustomizationManagementEditor', () => { editor.knownMcpServerMigrationItems = new Set(); editor.recentlyMigratedCustomizationItems = new Set(); editor.migrationFlowId = undefined; + editor.migrationDashboard = undefined; editor.editorDisplayMode = 'preview'; editor.currentCustomizationDetail = false; editor.currentEditingUri = undefined; @@ -406,6 +421,7 @@ suite('aiCustomizationManagementEditor', () => { editor.editorPreviewBodyTitle = document.createElement('h2'); editor.editorPreviewBodyContainer = document.createElement('div'); editor.editorPreviewBodySection.append(editor.editorPreviewBodyTitle, editor.editorPreviewBodyContainer); + editor.editorMigrationDetailWarning = document.createElement('section'); editor.editorPreviewDisposables = new DisposableStore(); editor.editorDisposables = editor.editorPreviewDisposables.add(new DisposableStore()); editor.customizationMigrationRefreshSequence = 0; @@ -1673,34 +1689,85 @@ suite('aiCustomizationManagementEditor', () => { { uri: URI.file('/workspace/.claude/skills'), label: 'skills', source: PromptsStorage.local }, { uri: URI.file('/workspace/.github/skills'), label: 'skills', source: PromptsStorage.local }, ]); - let pickerLabels: readonly string[] = []; - let pickerDescriptions: readonly (string | undefined)[] = []; + const pickerStates: { labels: readonly string[]; descriptions: readonly (string | undefined)[]; activeFolder: string | undefined }[] = []; editor.quickInputService = { - pick: async items => { - pickerLabels = items.map(item => item.label); - pickerDescriptions = items.map(item => item.description); - return { chooseAnother: true }; + pick: async (items, options) => { + pickerStates.push({ + labels: items.map(item => item.label), + descriptions: items.map(item => item.description), + activeFolder: options?.activeItem?.folder?.uri.path, + }); + return pickerStates.length === 1 ? { chooseAnother: true } : undefined; }, }; editor.fileDialogService = { showOpenDialog: async () => [URI.file('/workspace/custom/skills')] }; editor.renderCustomizationMigrationDashboardState = () => { }; - await editor.chooseCustomizationMigrationDestination({ + const destination: ICustomizationMigrationDashboardDestination = { targetType: PromptsType.skill, storage: PromptsStorage.local, contextLabel: 'Workspace skills', label: '.github/skills', ariaLabel: 'Change destination for workspace skills', - }); + }; + await editor.chooseCustomizationMigrationDestination(destination); + await editor.chooseCustomizationMigrationDestination(destination); assert.deepStrictEqual({ selectedPath: editor.selectedCustomizationMigrationTargets.get(`${PromptsType.skill}:${PromptsStorage.local}`)?.uri.path, - pickerLabels, - pickerDescriptions, + pickerStates, }, { selectedPath: '/workspace/custom/skills', - pickerLabels: ['skills', 'skills', 'Choose another folder...'], - pickerDescriptions: ['/workspace/.github/skills (Recommended)', '/workspace/.agents/skills', 'Use a custom migration destination'], + pickerStates: [ + { + labels: ['skills', 'skills', 'Choose another folder...'], + descriptions: ['/workspace/.github/skills (Recommended)', '/workspace/.agents/skills', 'Use a custom migration destination'], + activeFolder: undefined, + }, + { + labels: ['skills', 'skills', '/workspace/custom/skills', 'Choose another folder...'], + descriptions: ['/workspace/.github/skills (Recommended)', '/workspace/.agents/skills', '/workspace/custom/skills', 'Use a custom migration destination'], + activeFolder: '/workspace/custom/skills', + }, + ], + }); + editor.editorPreviewDisposables.dispose(); + }); + + test('focuses the current migration destination when reopening the picker', async () => { + const editor = createTestEditor(); + const currentFolder: ICustomizationSourceFolder = { + uri: URI.file('/workspace/.agents/skills'), + label: 'skills', + source: PromptsStorage.local, + }; + editor.customizationMigrationTargetFoldersByType.set(PromptsType.skill, [ + { uri: URI.file('/workspace/.github/skills'), label: 'skills', source: PromptsStorage.local }, + currentFolder, + ]); + editor.selectedCustomizationMigrationTargets.set(`${PromptsType.skill}:${PromptsStorage.local}`, currentFolder); + let pickerState: { activeFolder: string | undefined; pickedCount: number } | undefined; + editor.quickInputService = { + pick: async (items, options) => { + pickerState = { + activeFolder: options?.activeItem?.folder?.uri.path, + pickedCount: items.filter(item => item.picked).length, + }; + return undefined; + }, + }; + + await editor.chooseCustomizationMigrationDestination({ + targetType: PromptsType.skill, + storage: PromptsStorage.local, + contextLabel: 'Workspace skills', + label: '.agents/skills', + ariaLabel: 'Change destination for workspace skills', + }); + + assert.deepStrictEqual(pickerState, { + activeFolder: '/workspace/.agents/skills', + pickedCount: 0, }); editor.editorPreviewDisposables.dispose(); }); @@ -2168,14 +2235,16 @@ suite('aiCustomizationManagementEditor', () => { editor.editorPreviewDisposables.dispose(); }); - test('confirmation details enumerate planned source and destination changes', () => { - const editor = createTestEditor(); - const file: MigratableConfiguration = { - uri: URI.file('/workspace/.github/agents/reviewer.agent.md'), + test('migration confirmations omit per-item source and destination paths', async () => { + const editor = createTestEditor(undefined, createConfigurationServiceStub({ + [ChatConfiguration.ChatCustomizationsMigrationEnabled]: true, + })); + const prompt: MigratableConfiguration = { + uri: URI.file('/workspace/.github/prompts/review.prompt.md'), storage: PromptsStorage.local, - type: PromptsType.agent, + type: PromptsType.prompt, source: PromptFileSource.GitHubWorkspace, - name: 'reviewer', + name: 'Review', }; const server: IMcpServerCustomizationMigrationCandidate = { type: CustomizationMigrationType.McpServers, @@ -2186,22 +2255,40 @@ suite('aiCustomizationManagementEditor', () => { targetUri: URI.file('/workspace/.mcp.json'), projectedConfiguration: { type: McpServerType.LOCAL, command: 'node' }, }; - const targetFolders: CustomizationMigrationTargetFolders = new Map([[ - PromptsType.agent, - new Map([[PromptsStorage.local, { - uri: URI.file('/workspace/.agents/agents'), - label: '.agents/agents', - source: PromptsStorage.local, - }]]), - ]]); - - assert.deepStrictEqual({ - file: editor.getFileMigrationConfirmationDetail('Move the file.', [file], targetFolders), - mcp: editor.getMcpMigrationConfirmationDetail('Move the server.', [server]), - }, { - file: 'Move the file.\n\nPlanned changes:\n• reviewer\n From: /workspace/.github/agents/reviewer.agent.md\n Destination: /workspace/.agents/agents/reviewer.agent.md', - mcp: 'Move the server.\n\nPlanned changes:\n• Server\n From: /workspace/.vscode/mcp.json\n To: /workspace/.mcp.json', + editor.selectedCustomizationMigrationTargets.set(`${PromptsType.skill}:${PromptsStorage.local}`, { + uri: URI.file('/workspace/.github/skills'), + label: '.github/skills', + source: PromptsStorage.local, }); + const confirmations: IConfirmation[] = []; + editor.dialogService = { + confirm: async confirmation => { + confirmations.push(confirmation); + return { confirmed: false }; + }, + }; + + await editor.migrateSelectedCustomizations(getCustomizationMigrationCategory(CustomizationMigrationCategoryId.PromptFiles), [prompt]); + await editor.migrateSelectedCustomizations(getCustomizationMigrationCategory(CustomizationMigrationCategoryId.McpServers), [server]); + + assert.deepStrictEqual(confirmations, [ + { + type: 'question', + message: 'Convert prompt files to skills?', + detail: 'This converts 1 workspace prompt files into skills.\n\nUnsupported prompt-only headers are removed. Converted skills set disable-model-invocation: true, so the agent will not load them automatically; invoke them manually with /name.', + primaryButton: 'Convert to Skills', + checkbox: { + label: 'Delete original prompt files after migration', + checked: true, + }, + }, + { + type: 'question', + message: 'Migrate 1 MCP server to .mcp.json?', + detail: 'Eligible entries are removed from .vscode/mcp.json after they are written and verified in .mcp.json. Entries that cannot be migrated stay in place.', + primaryButton: 'Migrate', + }, + ]); editor.editorPreviewDisposables.dispose(); }); @@ -2310,7 +2397,6 @@ suite('aiCustomizationManagementEditor', () => { confirmations: [{ type: 'warning', ...confirmation, - detail: editor.getMcpMigrationConfirmationDetail(confirmation.detail, [server]), }], inProgress: false, writesInProgress: false, @@ -2359,7 +2445,13 @@ suite('aiCustomizationManagementEditor', () => { label: '.github', source: PromptsStorage.local, }); - editor.dialogService = { confirm: async () => ({ confirmed: true }) }; + const confirmations: IConfirmation[] = []; + editor.dialogService = { + confirm: async confirmation => { + confirmations.push(confirmation); + return { confirmed: true }; + }, + }; editor.runCustomizationMigration = async () => { editor.migrationFlowId = 'new-migration-flow-id'; return { @@ -2388,16 +2480,46 @@ suite('aiCustomizationManagementEditor', () => { dashboardShown, migrationCompleted, notifications, + confirmationDetails: confirmations.map(confirmation => confirmation.detail), remainingCandidates: editor.getMigrationCandidates(getCustomizationMigrationCategory(CustomizationMigrationCategoryId.PromptFiles)), }, { dashboardShown: 1, migrationCompleted: [[CustomizationMigrationType.PromptFiles, 1, 1, 0, [], 'migration-flow-id']], notifications: ['Converted 1 prompt files to skills.'], + confirmationDetails: [ + 'This converts 1 workspace prompt files into skills.\n\nUnsupported prompt-only headers are removed. Converted skills set disable-model-invocation: true, so the agent will not load them automatically; invoke them manually with /name.', + ], remainingCandidates: [], }); editor.editorPreviewDisposables.dispose(); }); + test('includes prompt-to-skill consequences in migration Accessible View content', () => { + const editor = createTestEditor(undefined, createConfigurationServiceStub({ + [ChatConfiguration.ChatCustomizationsMigrationEnabled]: true, + })); + const prompt: MigratableConfiguration = { + uri: URI.file('/workspace/.github/prompts/review.prompt.md'), + name: 'Review', + storage: PromptsStorage.local, + type: PromptsType.prompt, + source: PromptFileSource.GitHubWorkspace, + }; + editor.setCustomizationsToMigrate(new Map([[CustomizationMigrationCategoryId.PromptFiles, [prompt]]]), new Map()); + editor.viewMode = 'migration'; + const dashboard = document.body.appendChild(document.createElement('div')); + const focusedElement = dashboard.appendChild(document.createElement('button')); + editor.migrationDashboard = { element: dashboard }; + focusedElement.focus(); + + assert.strictEqual( + editor.getMigrationAccessibilityContent(), + 'Customization migrations\n\nConvert Prompt to Skills (Workspace): 1 prompt. High risk. Unsupported prompt-only headers are removed. Converted skills set disable-model-invocation: true, so the agent will not load them automatically; invoke them manually with /name.\nReview, selected, source /workspace/.github/prompts/review.prompt.md', + ); + dashboard.remove(); + editor.editorPreviewDisposables.dispose(); + }); + test('groups the dashboard by location without filtering global candidates', () => { const editor = createTestEditor(undefined, createConfigurationServiceStub({ [ChatConfiguration.ChatCustomizationsMigrationEnabled]: true, @@ -2526,6 +2648,42 @@ suite('aiCustomizationManagementEditor', () => { editor.editorPreviewDisposables.dispose(); }); + test('shows prompt migration consequences in the migration detail warning', () => { + const editor = createTestEditor(); + editor.currentMigrationDetail = { + item: { + id: 'prompt', + label: 'review', + scopeLabel: 'User', + sourceLabel: '/profile/review.prompt.md', + promptType: PromptsType.prompt, + }, + storage: PromptsStorage.user, + }; + editor.updateMigrationDetailWarning(); + const promptDisplay = editor.editorMigrationDetailWarning?.style.display; + editor.currentMigrationDetail = { + item: { + id: 'agent', + label: 'review', + scopeLabel: 'User', + sourceLabel: '/profile/review.agent.md', + promptType: PromptsType.agent, + }, + storage: PromptsStorage.user, + }; + editor.updateMigrationDetailWarning(); + + assert.deepStrictEqual({ + promptDisplay, + agentDisplay: editor.editorMigrationDetailWarning?.style.display, + }, { + promptDisplay: '', + agentDisplay: 'none', + }); + editor.editorPreviewDisposables.dispose(); + }); + test('shows the migrations sidebar entry only while migrations are pending', () => { const editor = createTestEditor(undefined, createConfigurationServiceStub({ [ChatConfiguration.ChatCustomizationsMigrationEnabled]: true, diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationWelcomePagePromptLaunchers.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationWelcomePagePromptLaunchers.test.ts index 37a0f4baee74b3..8c8d272c19c7c3 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationWelcomePagePromptLaunchers.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationWelcomePagePromptLaunchers.test.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { mainWindow } from '../../../../../../base/browser/window.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; import { IHoverService } from '../../../../../../platform/hover/browser/hover.js'; @@ -49,6 +50,8 @@ suite('aiCustomizationWelcomePagePromptLaunchers', () => { try { page.setMigrationCategories([category]); const card = parent.querySelector('.welcome-prompts-migration-card'); + parent.classList.add('ai-customization-management-editor'); + parent.style.setProperty('--vscode-inputValidation-warningForeground', 'rgb(1, 2, 3)'); card?.click(); page.focus(); @@ -56,11 +59,13 @@ suite('aiCustomizationWelcomePagePromptLaunchers', () => { cardTagName: card?.tagName, buttonCount: card?.querySelectorAll('button').length, focusedCard: document.activeElement === card, + actionColor: mainWindow.getComputedStyle(card!.querySelector('.welcome-prompts-card-action-label')!).color, reviewedMigrations, }, { cardTagName: 'BUTTON', buttonCount: 0, focusedCard: true, + actionColor: 'rgb(1, 2, 3)', reviewedMigrations: true, }); } finally { diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/customizationMigration.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/customizationMigration.test.ts index 1921902dab607c..6e07643afb1c7d 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/customizationMigration.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/customizationMigration.test.ts @@ -8,6 +8,7 @@ import { URI } from '../../../../../../base/common/uri.js'; import { VSBuffer } from '../../../../../../base/common/buffer.js'; import { Schemas } from '../../../../../../base/common/network.js'; import { dirname, isEqual } from '../../../../../../base/common/resources.js'; +import { parseFrontMatter, YamlParseError } from '../../../../../../base/common/yaml.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { FileService } from '../../../../../../platform/files/common/fileService.js'; import { InMemoryFileSystemProvider } from '../../../../../../platform/files/common/inMemoryFilesystemProvider.js'; @@ -130,6 +131,38 @@ suite('customizationMigration', () => { }); }); + test('explains prompt-to-skill metadata loss and invocation behavior before migration', () => { + const category = getCustomizationMigrationCategory(CustomizationMigrationCategoryId.PromptFiles); + const workspacePrompt: MigratableConfiguration = { + uri: URI.file('/workspace/.github/prompts/review.prompt.md'), + storage: PromptsStorage.local, + type: PromptsType.prompt, + source: PromptFileSource.GitHubWorkspace, + }; + const userPrompt: MigratableConfiguration = { + ...workspacePrompt, + uri: URI.file('/user-data/prompts/release.prompt.md'), + storage: PromptsStorage.user, + source: PromptFileSource.UserData, + }; + const consequences = 'Unsupported prompt-only headers are removed. Converted skills set disable-model-invocation: true, so the agent will not load them automatically; invoke them manually with /name.'; + + assert.deepStrictEqual({ + consequences: category.preMigrationConsequences, + candidateWarnings: category.getCandidateWarnings?.(workspacePrompt, 'Copilot'), + confirmation: category.getConfirmation([workspacePrompt, userPrompt], 'Copilot'), + }, { + consequences, + candidateWarnings: undefined, + confirmation: { + message: 'Convert prompt files to skills?', + detail: `This converts 1 workspace prompt files and 1 user prompt files into skills.\n\n${consequences}`, + primaryButton: 'Convert to Skills', + deleteOriginalsLabel: 'Delete original prompt files after migration', + }, + }); + }); + test('builds an agent prompt from discovered sources and harness-reported targets', () => { const recoveryBundleFolder = URI.file('/recovery/vscode-customization-migration'); const prompt = createCustomizationMigrationAgentPrompt( @@ -364,15 +397,112 @@ suite('customizationMigration', () => { ].join('\n'); const migrated = migratePromptFileToSkill(promptFile, content); + const errors: YamlParseError[] = []; + const parsed = parseFrontMatter(migrated.content, errors); + const headerKeys = parsed?.header?.type === 'map' + ? parsed.header.properties.map(property => property.key.value) + : []; + + assert.deepStrictEqual({ + skillName: migrated.skillName, + unsupportedHeaderKeys: migrated.unsupportedHeaderKeys, + errors, + headerKeys, + name: parsed?.getStringValue('name'), + description: parsed?.getStringValue('description'), + disableModelInvocation: parsed?.getBooleanValue('disable-model-invocation'), + argumentHint: parsed?.getStringValue('argument-hint'), + body: parsed?.body, + }, { + skillName: 'review-prompt', + unsupportedHeaderKeys: ['tools', 'mode'], + errors: [], + headerKeys: ['name', 'description', 'disable-model-invocation', 'argument-hint'], + name: 'review-prompt', + description: 'Review the active change', + disableModelInvocation: true, + argumentHint: '[diff]', + body: '## Steps\n\n- Review the diff', + }); + }); + + test('serializes migrated skill descriptions as valid YAML strings', () => { + const promptFile: IPromptPath = { + uri: URI.file('/workspace/.github/prompts/review.prompt.md'), + name: 'Review Prompt', + storage: PromptsStorage.local, + type: PromptsType.prompt, + source: PromptFileSource.GitHubWorkspace, + }; + const cases = [ + { + content: 'description: "Review: changes #42"', + expectedLine: 'description: "Review: changes #42"', + expectedDescription: 'Review: changes #42', + }, + { + content: 'description: \'Review the author\'\'s changes\'', + expectedLine: 'description: \'Review the author\'\'s changes\'', + expectedDescription: 'Review the author\'s changes', + }, + { + content: 'description: Review the author\'s changes', + expectedLine: 'description: Review the author\'s changes', + expectedDescription: 'Review the author\'s changes', + }, + { + content: ['description: |-', ' Review the first change.', ' Review the second change.'].join('\n'), + expectedLine: 'description: "Review the first change.\\nReview the second change."', + expectedDescription: 'Review the first change.\nReview the second change.', + }, + { + content: 'description: "Review\\x7Fchanges"', + expectedLine: 'description: "Review\\u007fchanges"', + expectedDescription: 'Review\u007Fchanges', + }, + ]; + + const actual = cases.map(testCase => { + const content = ['---', 'name: Review Prompt', testCase.content, '---', 'Review body'].join('\n'); + const migrated = migratePromptFileToSkill(promptFile, content); + const errors: YamlParseError[] = []; + const parsed = parseFrontMatter(migrated.content, errors); + return { + descriptionLine: migrated.content.split('\n').find(line => line.startsWith('description:')), + description: parsed?.getStringValue('description'), + errors, + }; + }); + + assert.deepStrictEqual(actual, cases.map(testCase => ({ + descriptionLine: testCase.expectedLine, + description: testCase.expectedDescription, + errors: [], + }))); + }); + + test('quotes YAML-sensitive description metadata without source formatting', () => { + const promptFile: IPromptPath = { + uri: URI.file('/workspace/.github/prompts/review.prompt.md'), + name: 'Review Prompt', + description: 'Review: changes #42', + storage: PromptsStorage.local, + type: PromptsType.prompt, + source: PromptFileSource.GitHubWorkspace, + }; + const migrated = migratePromptFileToSkill(promptFile, 'Review body'); + const errors: YamlParseError[] = []; + const parsed = parseFrontMatter(migrated.content, errors); - assert.strictEqual(migrated.skillName, 'review-prompt'); - assert.deepStrictEqual(migrated.unsupportedHeaderKeys, ['tools', 'mode']); - assert.ok(migrated.content.includes('name: review-prompt')); - assert.ok(migrated.content.includes('description: Review the active change')); - assert.ok(migrated.content.includes('disable-model-invocation: true')); - assert.ok(migrated.content.includes('argument-hint: "[diff]"')); - assert.ok(!migrated.content.includes('tools: [read_file, edit_file]')); - assert.ok(migrated.content.includes('## Steps')); + assert.deepStrictEqual({ + descriptionLine: migrated.content.split('\n').find(line => line.startsWith('description:')), + description: parsed?.getStringValue('description'), + errors, + }, { + descriptionLine: 'description: "Review: changes #42"', + description: 'Review: changes #42', + errors: [], + }); }); test('preserves argument-hint formatting from source prompt', () => { diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/customizationMigrationDashboard.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/customizationMigrationDashboard.test.ts index 006ef217df5bb0..6c0ea3c9df3946 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/customizationMigrationDashboard.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/customizationMigrationDashboard.test.ts @@ -82,7 +82,12 @@ suite('CustomizationMigrationDashboard', () => { id: CustomizationMigrationCategoryId.PromptFiles, label: 'Convert Prompt to Skills', description: 'Convert prompts to skills.', count: 1, selectedCount: 1, countLabel: '1 prompt', highRisk: true, destinationLabel: '.github/skills', destinationAriaLabel: 'Change destination for workspace prompt migrations', - items: [{ id: 'workspace-prompt', label: 'Build', scopeLabel: 'Workspace', sourceLabel: '.github/prompts/build.prompt.md' }], + items: [{ + id: 'workspace-prompt', + label: 'Build', + scopeLabel: 'Workspace', + sourceLabel: '.github/prompts/build.prompt.md', + }], }, { id: CustomizationMigrationCategoryId.McpServers, label: 'MCP Servers', description: 'Move supported servers.', count: 1, selectedCount: 1, countLabel: '1 server', @@ -135,6 +140,7 @@ suite('CustomizationMigrationDashboard', () => { items: [...parent.querySelectorAll('.migration-tree-item-label')].map(element => element.textContent), sources: [...parent.querySelectorAll('.migration-tree-item-source')].map(element => element.textContent), changes: [...parent.querySelectorAll('.migration-tree-item-changes')].filter(element => (element as HTMLElement).style.display !== 'none').map(element => element.textContent), + warning: parent.querySelector('.migration-warning-banner')?.textContent, checklistCopy: parent.textContent?.includes('Your migration checklist'), intro: parent.querySelector('.migration-intro')?.textContent, agentButtonInTitleRow: agentMigrationButton.parentElement?.classList.contains('migration-page-title-actions'), @@ -150,7 +156,10 @@ suite('CustomizationMigrationDashboard', () => { counts: ['1', '2', '1', '2', '1'], items: ['Build', 'Release', 'Triage', 'Postgres', 'Planner', 'Review', 'Team rules'], sources: ['.github/prompts/build.prompt.md', '~/.copilot/prompts/release.prompt.md', '~/.copilot/prompts/triage.prompt.md', '.vscode/mcp.json', '~/.copilot/agents/planner.agent.md', '~/.copilot/instructions/review.instructions.md', 'team/rules.instructions.md'], - changes: ['The gallery property will be removed.'], + changes: [ + 'The gallery property will be removed.', + ], + warning: 'Prompt-to-skill conversion changesConvert prompts to skills.', checklistCopy: false, intro: 'Some of your agent customizations need an update to keep working. Use Migrate to have VS Code update selected customizations, or Migrate with Agent for a guided migration in chat. Agent migration uses credits.', agentButtonInTitleRow: true, @@ -321,4 +330,32 @@ suite('CustomizationMigrationDashboard', () => { telemetryActions, }, { retries: 1, busy: 'false', focus: 'Retry', telemetryActions: ['retryClicked'] }); }); + + test('focuses refresh errors and restores the prior overview target after retry', () => { + let retries = 0; + const { parent, dashboard } = createDashboard(); + dashboard.showOverview(overview()); + dashboard.focusDestination(PromptsStorage.user); + const overviewFocus = document.activeElement?.getAttribute('aria-label'); + dashboard.showLoading('Migrations', 'Loading migrations'); + dashboard.showLoading('Migrations unavailable', 'Try again.', () => { + retries++; + dashboard.showLoading('Migrations', 'Loading migrations'); + }); + const errorFocus = document.activeElement?.getAttribute('aria-label'); + button(parent, 'Retry loading migrations').click(); + dashboard.showOverview(overview()); + + assert.deepStrictEqual({ + overviewFocus, + errorFocus, + retries, + restoredFocus: document.activeElement?.getAttribute('aria-label'), + }, { + overviewFocus: 'Change destination for user prompt migrations', + errorFocus: 'Retry loading migrations', + retries: 1, + restoredFocus: 'Change destination for user prompt migrations', + }); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/embeddedMcpServerDetail.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/embeddedMcpServerDetail.test.ts index 2e954f51ab982f..4b6c3bc34ab12f 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/embeddedMcpServerDetail.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/embeddedMcpServerDetail.test.ts @@ -4,7 +4,13 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { $, getWindow } from '../../../../../../base/browser/dom.js'; +import { $, getActiveElement, getWindow } from '../../../../../../base/browser/dom.js'; +import { mainWindow } from '../../../../../../base/browser/window.js'; +import { toDisposable } from '../../../../../../base/common/lifecycle.js'; +import { IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; +import { AccessibilityVerbositySettingId } from '../../../../accessibility/browser/accessibilityConfiguration.js'; +import { McpServerConfigurationFormFocusContext } from '../../../../mcp/browser/mcpServerConfigurationForm.js'; +import { McpServerConfigurationFormAccessibilityHelp } from '../../../../mcp/browser/mcpServerConfigurationFormAccessibility.js'; import { DeferredPromise, timeout } from '../../../../../../base/common/async.js'; import { VSBuffer } from '../../../../../../base/common/buffer.js'; import { Event } from '../../../../../../base/common/event.js'; @@ -89,7 +95,7 @@ suite('EmbeddedMcpServerDetail', () => { const complete = (index: number, text = content) => reads[index].result.complete(new class extends mock() { override readonly value = VSBuffer.fromString(text); }()); - return { detail, input, reads, snapshot, complete, executedCommands, openedEditors }; + return { detail, input, reads, snapshot, complete, executedCommands, openedEditors, instantiationService }; } test('loads only the selected definition after migration metadata changes during the read', async () => { @@ -322,6 +328,140 @@ suite('EmbeddedMcpServerDetail', () => { }); }); + test('a file change while the form has edits keeps the edits and shows the other changes', async () => { + const saves: { previous: IMcpServerConfiguration; config: IMcpServerConfiguration }[] = []; + const original: IMcpServerConfiguration = { type: McpServerType.LOCAL, command: 'server', args: ['--old'], env: { TOKEN: 'old', PORT: '1' } }; + let config = original; + const { detail, input } = createDetail(() => ({ + config, + format: McpResourceFormat.CopilotGlobal, + save: async (previous, next) => { saves.push({ previous, config: next }); }, + })); + mainWindow.document.body.appendChild(detail.element); + store.add(toDisposable(() => detail.element.remove())); + const refresh = async () => { + detail.setInput(input); + await timeout(0); + }; + await refresh(); + + const form = detail.element.querySelector('.mcp-detail-configuration-form')!; + const argsValue = () => [...form.querySelectorAll('.mcp-config-form-field')].find(field => field.querySelector('.mcp-config-form-label')?.firstChild?.textContent === 'Arguments')!.querySelector('input')!.value; + const envRows = () => [...form.querySelectorAll('.mcp-config-form-kv-row')].map(row => [...row.querySelectorAll('input')].map(input => input.value)); + const tokenValue = form.querySelector('.mcp-config-form-kv-value input')!; + tokenValue.focus(); + tokenValue.value = 'mine'; + tokenValue.dispatchEvent(new (getWindow(tokenValue).Event)('input')); + tokenValue.setSelectionRange(2, 2); + + // While the TOKEN edit is unsaved, the file changes PORT and the arguments, for example through Other Properties. + config = { ...original, args: ['--new'], env: { TOKEN: 'old', PORT: '2' } }; + await refresh(); + const focused = getActiveElement(); + const whileDirty = { + args: argsValue(), + env: envRows(), + focusInTokenValue: focused === form.querySelector('.mcp-config-form-kv-value input'), + caret: focused instanceof HTMLInputElement ? focused.selectionStart : undefined, + }; + const [, , saveButton] = form.querySelectorAll('.mcp-config-form-footer .monaco-button'); + saveButton.click(); + await timeout(0); + + assert.deepStrictEqual({ whileDirty, saves }, { + whileDirty: { args: '--new', env: [['TOKEN', 'mine'], ['PORT', '2']], focusInTokenValue: true, caret: 2 }, + saves: [{ previous: config, config: { ...config, env: { TOKEN: 'mine', PORT: '2' } } }], + }); + }); + + test('the configuration form has accessibility help that returns focus to the form', async () => { + const { detail, input, instantiationService } = createDetail(() => ({ + config: { type: McpServerType.LOCAL, command: 'memory-server' }, + format: McpResourceFormat.CopilotGlobal, + save: async () => { }, + })); + mainWindow.document.body.appendChild(detail.element); + store.add(toDisposable(() => detail.element.remove())); + detail.setInput(input); + await timeout(0); + + const contextKeyService = instantiationService.get(IContextKeyService); + const focusKey = () => contextKeyService.getContextKeyValue(McpServerConfigurationFormFocusContext.key); + const form = detail.element.querySelector('.mcp-config-form')!; + const commandInput = form.querySelector('.mcp-config-form-field input')!; + // The test window may not have focus, in which case focus() does not dispatch focus events. + const FocusEventCtor = getWindow(commandInput).FocusEvent; + commandInput.focus(); + commandInput.dispatchEvent(new FocusEventCtor('focus')); + const focusedInForm = focusKey(); + const help = new McpServerConfigurationFormAccessibilityHelp(); + const provider = store.add(instantiationService.invokeFunction(accessor => help.getProvider(accessor))); + const outside = mainWindow.document.body.appendChild($('button')); + store.add(toDisposable(() => outside.remove())); + outside.focus(); + commandInput.dispatchEvent(new FocusEventCtor('blur')); + await timeout(0); + const focusedOutside = focusKey(); + provider.onClose(); + + assert.deepStrictEqual({ + role: form.getAttribute('role'), + ariaLabel: form.getAttribute('aria-label'), + focusedInForm, + focusedOutside, + when: help.when.serialize(), + verbositySetting: provider.verbositySettingKey, + documentsArguments: provider.provideContent().includes('JSON array'), + focusRestored: getActiveElement() === commandInput, + }, { + role: 'group', + ariaLabel: 'MCP server configuration', + focusedInForm: true, + focusedOutside: false, + when: 'mcpServerConfigurationFormFocus', + verbositySetting: AccessibilityVerbositySettingId.McpServerConfiguration, + documentsArguments: true, + focusRestored: true, + }); + }); + + test('closing accessibility help returns focus to the field when the focused row was rebuilt', async () => { + let config: IMcpServerConfiguration = { type: McpServerType.LOCAL, command: 'memory-server', env: { TOKEN: 'old' } }; + const { detail, input, instantiationService } = createDetail(() => ({ + config, + format: McpResourceFormat.CopilotGlobal, + save: async () => { }, + })); + mainWindow.document.body.appendChild(detail.element); + store.add(toDisposable(() => detail.element.remove())); + detail.setInput(input); + await timeout(0); + + const form = detail.element.querySelector('.mcp-config-form')!; + const tokenValue = form.querySelector('.mcp-config-form-kv-value input')!; + tokenValue.focus(); + const provider = store.add(instantiationService.invokeFunction(accessor => new McpServerConfigurationFormAccessibilityHelp().getProvider(accessor))); + const outside = mainWindow.document.body.appendChild($('button')); + store.add(toDisposable(() => outside.remove())); + outside.focus(); + // The file changes while Help is open, which rebuilds the environment variable rows. + config = { ...config, env: { TOKEN: 'new', PORT: '1' } }; + detail.setInput(input); + await timeout(0); + provider.onClose(); + + const focused = getActiveElement(); + assert.deepStrictEqual({ + rowRebuilt: !tokenValue.isConnected, + focusInEnvironmentVariables: !!focused?.closest('.mcp-config-form-field')?.querySelector('.mcp-config-form-kv-rows'), + focused: focused === form.querySelector('.mcp-config-form-kv-name input'), + }, { + rowRebuilt: true, + focusInEnvironmentVariables: true, + focused: true, + }); + }); + for (const { format, types, envFile, workingDirectory } of [ { format: McpResourceFormat.CopilotGlobal, types: ['stdio', 'http', 'sse'], envFile: false, workingDirectory: true }, { format: McpResourceFormat.WorkspaceRoot, types: ['stdio', 'http'], envFile: false, workingDirectory: false }, diff --git a/src/vs/workbench/contrib/chat/test/browser/attachments/chatImplicitContext.test.ts b/src/vs/workbench/contrib/chat/test/browser/attachments/chatImplicitContext.test.ts new file mode 100644 index 00000000000000..8855c86e52c057 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/attachments/chatImplicitContext.test.ts @@ -0,0 +1,312 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Emitter, Event } from '../../../../../../base/common/event.js'; +import { Disposable } from '../../../../../../base/common/lifecycle.js'; +import { constObservable } from '../../../../../../base/common/observable.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 { ICodeEditor } from '../../../../../../editor/browser/editorBrowser.js'; +import { ICodeEditorService } from '../../../../../../editor/browser/services/codeEditorService.js'; +import { ISelection, Selection } from '../../../../../../editor/common/core/selection.js'; +import { ITextModel } from '../../../../../../editor/common/model.js'; +import { createTextModel } from '../../../../../../editor/test/common/testTextModel.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { IVisibleEditorPane } from '../../../../../common/editor.js'; +import { IEditorService } from '../../../../../services/editor/common/editorService.js'; +import { workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js'; +import { CustomEditorInput } from '../../../../customEditor/browser/customEditorInput.js'; +import { ICustomEditorService } from '../../../../customEditor/common/customEditor.js'; +import { ICustomTextEditorNavigation } from '../../../../customEditor/common/customTextEditorNavigation.js'; +import { IOverlayWebview } from '../../../../webview/browser/webview.js'; +import { IWebviewWorkbenchService } from '../../../../webviewPanel/browser/webviewWorkbenchService.js'; +import { ChatImplicitContextContribution, ChatImplicitContexts } from '../../../browser/attachments/chatImplicitContext.js'; +import { IChatWidget, IChatWidgetService } from '../../../browser/chat.js'; +import { IChatContextService } from '../../../browser/contextContrib/chatContextService.js'; +import { ChatInputPart } from '../../../browser/widget/input/chatInputPart.js'; +import { StringChatContextValue } from '../../../common/attachments/chatVariableEntries.js'; +import { IChatService } from '../../../common/chatService/chatService.js'; +import { ChatAgentLocation } from '../../../common/constants.js'; +import { IChatEditingService } from '../../../common/editing/chatEditingService.js'; +import { ILanguageModelIgnoredFilesService } from '../../../common/ignoredFiles.js'; + +suite('Chat implicit custom editor context', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + function setupContext(options: { + readonly enabled?: string; + readonly ignored?: boolean; + readonly contextForResource?: IChatContextService['contextForResource']; + } = {}) { + const instantiation = workbenchInstantiationService(undefined, store); + instantiation.stub(ICustomEditorService, { getCustomEditorCapabilities: () => undefined }); + instantiation.stub(IWebviewWorkbenchService, {}); + const configuration = new TestConfigurationService({ + chat: { implicitContext: { enabled: { [ChatAgentLocation.Chat]: options.enabled ?? 'always' }, suggestedContext: true } } + }); + instantiation.stub(IConfigurationService, configuration); + const model = store.add(createTextModel('# Heading\nSome **selected** text\nMore text', 'markdown', undefined, URI.file('/workspace/document.md'))); + const codeModel = store.add(createTextModel('other editor', 'plaintext', undefined, URI.file('/workspace/other.txt'))); + const codeSelection = new Selection(1, 1, 1, 6); + const codeEditor = upcastPartial({ + getModel: () => codeModel, + getSelection: () => codeSelection, + onDidChangeModel: Event.None, + onDidChangeModelLanguage: Event.None, + onDidChangeCursorSelection: Event.None, + onDidScrollChange: Event.None, + }); + instantiation.stub(ICodeEditorService, { + getActiveCodeEditor: () => codeEditor, + getFocusedCodeEditor: () => codeEditor, + }); + const activeEditorChanged = store.add(new Emitter()); + let activeEditorPane: IVisibleEditorPane | undefined; + instantiation.stub(IEditorService, { + onDidActiveEditorChange: activeEditorChanged.event, + get activeEditorPane() { return activeEditorPane; }, + get activeEditor() { return activeEditorPane?.input; }, + }); + const implicitContext = store.add(new ChatImplicitContexts()); + const widgets: IChatWidget[] = []; + const widgetAdded = store.add(new Emitter()); + function addWidget(context = store.add(new ChatImplicitContexts())) { + const widget = upcastPartial({ + location: ChatAgentLocation.Chat, + input: upcastPartial({ implicitContext: context }), + }); + widgets.push(widget); + widgetAdded.fire(widget); + return context; + } + addWidget(implicitContext); + instantiation.stub(IChatWidgetService, { + getWidgetsByLocations: location => location === ChatAgentLocation.Chat ? widgets : [], + onDidAddWidget: widgetAdded.event, + }); + instantiation.stub(IChatService, { onDidSubmitRequest: Event.None }); + instantiation.stub(IChatEditingService, { editingSessionsObs: constObservable([]) }); + instantiation.stub(ILanguageModelIgnoredFilesService, { fileIsIgnored: async () => options.ignored ?? false }); + instantiation.stub(IChatContextService, { contextForResource: options.contextForResource ?? (async () => undefined) }); + + function createEditor() { + const messages = store.add(new Emitter<{ message: unknown; transfer?: readonly ArrayBuffer[] }>()); + const webview = upcastPartial({ onMessage: messages.event, dispose: () => { } }); + const input = store.add(instantiation.createInstance(CustomEditorInput, { + resource: model.uri, viewType: 'test.richMarkdown', webviewTitle: undefined, preferredName: 'document.md', iconPath: undefined, + }, webview, {})); + const navigation = store.add(new TestNavigation(model)); + return { input, navigation, messages }; + } + + function activate(input: CustomEditorInput | undefined) { + activeEditorPane = input ? upcastPartial({ input, getControl: () => undefined, getId: () => 'test.customEditor' }) : undefined; + activeEditorChanged.fire(); + } + + const editor = createEditor(); + activate(editor.input); + const contribution = store.add(instantiation.createInstance(ChatImplicitContextContribution)); + const values = () => implicitContext.values.map(value => ({ value: value.value, isSelection: value.isSelection })); + return { ...editor, createEditor, activate, addWidget, model, codeModel, codeSelection, values, implicitContext, contribution }; + } + + test('navigation becoming available supplies source selection instead of the previous code editor', async () => { + const fixture = setupContext(); + await timeout(0); + assert.deepStrictEqual(fixture.values(), []); + + fixture.navigation.selection = { selectionStartLineNumber: 2, selectionStartColumn: 6, positionLineNumber: 2, positionColumn: 18 }; + fixture.input.navigation = fixture.navigation; + await timeout(0); + assert.deepStrictEqual(fixture.values(), [{ + value: { uri: fixture.model.uri, range: new Selection(2, 6, 2, 18) }, isSelection: true, + }]); + assert.strictEqual(fixture.model.getValueInRange(fixture.implicitContext.getLocations()[0].range), '**selected**'); + fixture.implicitContext.setEnabled(true); + assert.strictEqual(fixture.implicitContext.enabledBaseEntries(false)[0].id, 'vscode.implicit.selection'); + }); + + test('selection changes, collapsed carets and cleared selections update context without webview messages', async () => { + const fixture = setupContext(); + fixture.input.navigation = fixture.navigation; + fixture.navigation.updateSelection(new Selection(3, 5, 2, 1)); + await timeout(510); + assert.deepStrictEqual(fixture.values(), [{ + value: { uri: fixture.model.uri, range: new Selection(3, 5, 2, 1) }, isSelection: true, + }]); + fixture.navigation.updateSelection(new Selection(2, 1, 2, 1)); + await timeout(510); + assert.deepStrictEqual(fixture.values(), [{ value: fixture.model.uri, isSelection: false }]); + fixture.navigation.updateSelection(undefined); + await timeout(510); + assert.deepStrictEqual(fixture.values(), [{ value: fixture.model.uri, isSelection: false }]); + }); + + test('switching panels uses only the active panel and detaches the previous navigation', async () => { + const fixture = setupContext(); + fixture.input.navigation = fixture.navigation; + const second = fixture.createEditor(); + second.navigation.selection = new Selection(3, 1, 3, 5); + second.input.navigation = second.navigation; + fixture.activate(second.input); + await timeout(0); + fixture.navigation.updateSelection(new Selection(1, 1, 1, 4)); + await timeout(0); + assert.deepStrictEqual(fixture.values(), [{ + value: { uri: fixture.model.uri, range: second.navigation.selection }, isSelection: true, + }]); + second.input.navigation = undefined; + await timeout(0); + assert.deepStrictEqual(fixture.values(), []); + second.navigation.updateSelection(new Selection(2, 1, 2, 5)); + await timeout(0); + assert.deepStrictEqual(fixture.values(), []); + second.input.navigation = fixture.navigation; + await timeout(0); + assert.deepStrictEqual(fixture.values(), [{ + value: { uri: fixture.model.uri, range: fixture.navigation.selection }, isSelection: true, + }]); + }); + + test('custom-editor context providers supplement rather than replace the selection', async () => { + const context: StringChatContextValue = { name: 'Additional context', value: 'Context', uri: URI.file('/workspace/document.md'), handle: 1 }; + const fixture = setupContext({ contextForResource: async () => context }); + await timeout(0); + assert.deepStrictEqual(fixture.values(), [{ value: context, isSelection: false }]); + fixture.navigation.selection = new Selection(2, 1, 2, 5); + fixture.input.navigation = fixture.navigation; + await timeout(0); + assert.deepStrictEqual(fixture.values(), [ + { value: { uri: fixture.model.uri, range: fixture.navigation.selection }, isSelection: true }, + { value: context, isSelection: false }, + ]); + }); + + for (const options of [{ enabled: 'never' }, { ignored: true }]) { + test(`respects implicit context policy ${JSON.stringify(options)}`, async () => { + const fixture = setupContext(options); + fixture.navigation.selection = new Selection(2, 1, 2, 5); + fixture.input.navigation = fixture.navigation; + await timeout(0); + assert.deepStrictEqual(fixture.values(), []); + }); + } + + test('an older async selection update cannot replace a newer selection or editor', async () => { + let pending: DeferredPromise | undefined; + const fixture = setupContext({ contextForResource: () => pending?.p ?? Promise.resolve(undefined) }); + fixture.input.navigation = fixture.navigation; + await timeout(0); + pending = new DeferredPromise(); + fixture.navigation.updateSelection(new Selection(2, 1, 2, 5)); + await timeout(510); + const previous = pending; + pending = undefined; + fixture.navigation.updateSelection(new Selection(3, 1, 3, 5)); + await timeout(510); + await previous.complete(undefined); + await timeout(0); + assert.deepStrictEqual(fixture.values(), [{ + value: { uri: fixture.model.uri, range: new Selection(3, 1, 3, 5) }, isSelection: true, + }]); + + pending = new DeferredPromise(); + fixture.navigation.updateSelection(new Selection(2, 1, 2, 5)); + await timeout(510); + const previousEditor = pending; + pending = undefined; + fixture.activate(undefined); + await timeout(0); + await previousEditor.complete(undefined); + await timeout(0); + assert.deepStrictEqual(fixture.values(), [{ + value: { uri: fixture.codeModel.uri, range: fixture.codeSelection }, isSelection: true, + }]); + }); + + test('disposing the contribution cancels pending updates and removes selection listeners', async () => { + let pending: DeferredPromise | undefined; + const fixture = setupContext({ contextForResource: () => pending?.p ?? Promise.resolve(undefined) }); + fixture.input.navigation = fixture.navigation; + await timeout(0); + pending = new DeferredPromise(); + fixture.navigation.updateSelection(new Selection(2, 1, 2, 5)); + await timeout(510); + fixture.contribution.dispose(); + await pending.complete(undefined); + pending = undefined; + fixture.navigation.updateSelection(new Selection(3, 1, 3, 5)); + await timeout(0); + assert.deepStrictEqual(fixture.values(), [{ value: fixture.model.uri, isSelection: false }]); + }); + + test('coalesces selection, content, language and webview events into one provider request', async () => { + let calls = 0; + const fixture = setupContext({ contextForResource: async () => { calls++; return undefined; } }); + fixture.input.navigation = fixture.navigation; + await timeout(0); + calls = 0; + for (let i = 0; i < 10; i++) { + fixture.model.setValue(`Edit ${i}`); + fixture.navigation.updateSelection(new Selection(1, 1, 1, 5)); + fixture.messages.fire({ message: { selectionChanged: true } }); + } + fixture.model.setLanguage('plaintext'); + await timeout(250); + assert.strictEqual(calls, 0); + await timeout(260); + assert.deepStrictEqual({ calls, values: fixture.values() }, { + calls: 1, + values: [{ value: { uri: fixture.model.uri, range: new Selection(1, 1, 1, 5) }, isSelection: true }], + }); + }); + + test('adding a widget during a pending full refresh updates both old and new widgets', async () => { + let pending: DeferredPromise | undefined; + const fixture = setupContext({ contextForResource: () => pending?.p ?? Promise.resolve(undefined) }); + fixture.input.navigation = fixture.navigation; + await timeout(0); + pending = new DeferredPromise(); + fixture.navigation.updateSelection(new Selection(2, 1, 2, 5)); + await timeout(510); + const oldRefresh = pending; + pending = undefined; + const added = fixture.addWidget(); + await timeout(0); + await oldRefresh.complete(undefined); + await timeout(0); + assert.deepStrictEqual({ + existing: fixture.implicitContext.getLocations(), + added: added.getLocations(), + }, { + existing: [{ uri: fixture.model.uri, range: new Selection(2, 1, 2, 5) }], + added: [{ uri: fixture.model.uri, range: new Selection(2, 1, 2, 5) }], + }); + }); +}); + +class TestNavigation extends Disposable implements ICustomTextEditorNavigation { + private readonly _onDidChangeSelection = this._register(new Emitter()); + readonly onDidChangeSelection = this._onDidChangeSelection.event; + readonly onDidDispose = Event.None; + selection: ISelection | undefined; + + constructor(readonly model: ITextModel) { + super(); + } + + updateSelection(selection: Selection | undefined): void { + this.selection = selection; + this._onDidChangeSelection.fire(); + } + + async revealRange(): Promise { } + captureViewState() { return Disposable.None; } +} diff --git a/src/vs/workbench/contrib/chat/test/browser/chatUserInteractionTelemetry.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatUserInteractionTelemetry.test.ts index ac05e2d4041d29..54f5f7a24d52b9 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatUserInteractionTelemetry.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatUserInteractionTelemetry.test.ts @@ -12,7 +12,10 @@ import { ExtensionIdentifier } from '../../../../../platform/extensions/common/e import { TelemetryTrustedValue } from '../../../../../platform/telemetry/common/telemetryUtils.js'; import { ChatUserInteractionTimingResult, isChatFirstVisibleProgress } from '../../browser/chatUserInteractionTelemetry.js'; import { IChatProgress, IChatToolInvocation, IChatToolInvocationSerialized } from '../../common/chatService/chatService.js'; -import { getChatSessionTelemetryContext } from '../../common/chatService/chatServiceTelemetry.js'; +import { getChatSessionTelemetryContext, getChatSessionTelemetryIds } from '../../common/chatService/chatServiceTelemetry.js'; +import { IAgentHostConnectionsService } from '../../../../../platform/agentHost/common/agentHostConnectionsService.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { LocalChatSessionUri } from '../../common/model/chatUri.js'; import { ChatAgentLocation, ChatModeKind, ChatPermissionLevel } from '../../common/constants.js'; import { IChatProgressResponseContent, IChatRequestModel, IChatResponseModel } from '../../common/model/chatModel.js'; import { ToolInvocationPresentation } from '../../common/tools/languageModelToolsService.js'; @@ -227,6 +230,64 @@ suite('ChatUserInteractionTelemetry', () => { ]); }); + test('resolves owner identity without guessing from frontend aliases or remote authorities', () => { + const instantiation = disposables.add(new TestInstantiationService()); + instantiation.stub(IAgentHostConnectionsService, { + resolveSessionResourceIdentity: () => ({ + connectionAuthority: 'private-host', + backendSession: URI.parse('copilotcli:/backend-session'), + }), + }); + for (const scheme of ['agent-host-copilot', 'remote-private-host-copilot']) { + for (const fragment of ['', 'peer-one', 'peer-two']) { + const resource = URI.from({ scheme, path: '/frontend-session', fragment }); + const ids = getChatSessionTelemetryIds(resource, instantiation.get(IAgentHostConnectionsService)); + assert.deepStrictEqual(ids, { + chatSessionId: getChatSessionTelemetryContext(resource).chatSessionId, + agentSessionId: 'backend-session', + }); + assert.ok(!JSON.stringify(ids).includes('private-host')); + } + } + instantiation.stub(IAgentHostConnectionsService, { resolveSessionResourceIdentity: () => undefined }); + assert.deepStrictEqual(getChatSessionTelemetryIds(URI.parse('remote-unknown-host-copilot:/session'), instantiation.get(IAgentHostConnectionsService)), { chatSessionId: 'session' }); + }); + + test('does not resolve an Agent Host owner for local sessions', () => { + const resource = LocalChatSessionUri.forSession('local-session'); + assert.deepStrictEqual(getChatSessionTelemetryIds(resource, { resolveSessionResourceIdentity: () => assert.fail('Unexpected owner resolution') }), { + chatSessionId: 'local-session', + }); + }); + + for (const scheme of ['agent-host-copilotcli', 'remote-private-host-copilotcli']) { + for (const [frontendPath, backendPath, advertised, owner] of [ + ['/untitled-host-session', '/untitled-host-session', true, 'untitled-host-session'], + ['/frontend-session', '/untitled-backend-session', true, 'untitled-backend-session'], + ['/untitled-frontend-session', '/backend-session', true, 'backend-session'], + ['/untitled-draft', '/untitled-draft', false, undefined], + ['/frontend-session', '/untitled-draft', false, undefined], + ['/untitled-draft', '/backend-session', false, undefined], + ] as const) { + test(`honors advertised identity before draft conventions (${scheme}, ${frontendPath}, ${backendPath}, advertised: ${advertised})`, () => { + const resource = URI.from({ scheme, path: frontendPath, fragment: 'peer-chat' }); + assert.deepStrictEqual(getChatSessionTelemetryIds(resource, { + resolveSessionResourceIdentity: resolvedResource => { + assert.strictEqual(resolvedResource, resource); + return { + connectionAuthority: 'private-host', + backendSession: URI.from({ scheme: 'ahp-session', path: backendPath }), + ...(advertised ? { backendSessionIsAdvertised: true } as const : {}), + }; + }, + }), { + chatSessionId: getChatSessionTelemetryContext(resource).chatSessionId, + ...(owner !== undefined ? { agentSessionId: owner } : {}), + }); + }); + } + } + for (const result of ['cancelled', 'error', 'completedWithoutProgress', 'notDispatched', 'queued', 'navigated', 'hidden', 'disposed'] satisfies Exclude[]) { test(`reports ${result} with only a termination duration`, () => { const h = createChatUserInteractionTestHarness(disposables); diff --git a/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/cloudSandboxApiService.test.ts b/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/cloudSandboxApiService.test.ts index 3c3f894725e218..67ccb499beb2f6 100644 --- a/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/cloudSandboxApiService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/cloudSandboxApiService.test.ts @@ -165,7 +165,7 @@ function createService(store: Pick<{ add(t: T): T if (!!task.archived_at !== (query.get('is_archived') === 'true')) { return false; } - if (query.has('with_repo') && (task.repository?.id !== undefined) !== (query.get('with_repo') === 'true')) { + if (query.has('with_repo') && ((task.repository?.id ?? 0) > 0) !== (query.get('with_repo') === 'true')) { return false; } if (query.has('include_environment_kinds') && task.current_environment?.kind !== query.get('include_environment_kinds')) { @@ -899,6 +899,78 @@ suite('CloudSandboxApiService repository resolution', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); + for (const repository of [{ id: 0 }, {}]) { + test(`ignores an empty repository reference ${JSON.stringify(repository)}`, async () => { + const current = { + ...task('repo-less', 'General chat', 0, 'session-1', 'environment-1'), + updated_at: '2026-10-01T00:01:00.000Z', + }; + const { service, requestedUrls } = createService(store, { + tasks: [current], + repositories: new Map(), + onRequest: url => url.pathname.endsWith('/tasks/repo-less') ? jsonResponse({ ...current, repository }) : undefined, + discoveryDate: () => '2026-10-01T00:03:00.000Z', + }); + + const initial = await service.listSessions(CancellationToken.None); + const incremental = await service.listSessions(CancellationToken.None, { incremental: true }); + + assert.deepStrictEqual({ + sessions: initial.kind === 'failed' ? initial : initial.sessions.map(session => ({ + id: session.sessionId, hasRepository: session.hasRepository, repoName: session.repoName, + })), + incremental, + repositoryLookups: requestedUrls.filter(url => url.includes('/repositories/')), + detailReads: requestedUrls.filter(url => url.endsWith('/tasks/repo-less')).length, + }, { + sessions: [{ id: 'session-1', hasRepository: false, repoName: undefined }], + incremental: { kind: 'incremental', sessions: [], removedTaskIds: [] }, + repositoryLookups: [], + detailReads: 1, + }); + }); + } + + test('accepts a positive repository reference discovered after a repo-less list response', async () => { + const current = task('new-repository', 'Repository chat', 0, 'session-1', 'environment-1'); + const { service, requestedUrls } = createService(store, { + tasks: [current], + repositories: new Map([[42, { full_name: 'owner/repository' }]]), + onRequest: url => url.pathname.endsWith('/tasks/new-repository') ? jsonResponse({ ...current, repository: { id: 42 } }) : undefined, + }); + const result = await service.listSessions(CancellationToken.None); + + assert.deepStrictEqual({ + sessions: result.kind === 'failed' ? result : result.sessions.map(session => ({ + hasRepository: session.hasRepository, repoName: session.repoName, + })), + repositoryLookups: requestedUrls.filter(url => url.includes('/repositories/')).map(url => new URL(url).pathname), + }, { + sessions: [{ hasRepository: true, repoName: 'owner/repository' }], + repositoryLookups: ['/repositories/42'], + }); + }); + + test('retains the listed repository when task details contain an empty reference', async () => { + const current = task('with-repository', 'Repository chat', 42, 'session-1', 'environment-1'); + const { service, requestedUrls } = createService(store, { + tasks: [current], + repositories: new Map([[42, { full_name: 'owner/repository' }]]), + onRequest: url => url.pathname.endsWith('/tasks/with-repository') ? jsonResponse({ ...current, repository: { id: 0 } }) : undefined, + }); + const result = await service.listSessions(CancellationToken.None); + + assert.deepStrictEqual({ + sessions: result.kind === 'failed' ? result : result.sessions.map(session => ({ + hasRepository: session.hasRepository, repoName: session.repoName, + })), + repositoryLookups: requestedUrls.filter(url => url.includes('/repositories/')).map(url => new URL(url).pathname), + }, { + sessions: [{ hasRepository: true, repoName: 'owner/repository' }], + repositoryLookups: ['/repositories/42'], + }); + }); + test('distinguishes repo-less tasks from unresolved repository names', async () => { const { service } = createService(store, { tasks: [ 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 e61dfb148b94e1..21a84b670a4da3 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 @@ -27,6 +27,8 @@ 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'; +import { INotificationService } from '../../../../../../../platform/notification/common/notification.js'; +import { toAgentHostUri, fromAgentHostUri } from '../../../../../../../platform/agentHost/common/agentHostUri.js'; function createMockReview(overrides?: Partial): IChatPlanReview { return { @@ -90,9 +92,13 @@ suite('ChatPlanReviewPart', () => { let lastCommentsBridge: AgentEditorCommentsBridge | undefined; let lastContextMenuService: IContextMenuService | undefined; let fileChangesEmitter: Emitter | undefined; + const notificationErrors: string[] = []; function createWidget(review: IChatPlanReview, dialogService?: TestDialogService, onSubmit?: () => void): ChatPlanReviewPart { const instantiationService = workbenchInstantiationService(undefined, store); + sinon.stub(instantiationService.get(INotificationService), 'error').callsFake(message => { + notificationErrors.push(String(message)); + }); const commentsBridge = store.add(new AgentEditorCommentsBridge()); const feedbackService = store.add(new PlanReviewFeedbackService(commentsBridge)); instantiationService.stub(IAgentEditorCommentsBridge, commentsBridge); @@ -138,6 +144,7 @@ suite('ChatPlanReviewPart', () => { lastCommentsBridge = undefined; lastContextMenuService = undefined; fileChangesEmitter = undefined; + notificationErrors.length = 0; sinon.restore(); }); @@ -562,6 +569,56 @@ suite('ChatPlanReviewPart', () => { }); suite('Inline comments list', () => { + test('failed feedback submission preserves comments and overall feedback for retry', async () => { + const review = createMockReviewWithPlan(); + let failSubmission = true; + createWidget(review, undefined, () => { + if (failSubmission) { + throw new Error('Submission failed'); + } + }); + const planUri = URI.revive(review.planUri!); + const service = lastFeedbackService!; + service.addFeedback(planUri, 5, 1, 'Keep this comment'); + const textarea = widget.domNode.querySelector('.chat-plan-review-feedback-textarea') as HTMLTextAreaElement; + textarea.value = 'Keep this overall feedback'; + textarea.dispatchEvent(new Event('input')); + const submitted = await service.submitAllFeedback(planUri); + + assert.deepStrictEqual({ + submitted, + used: widget.domNode.classList.contains('chat-plan-review-used'), + comments: service.getFeedback(planUri).map(item => item.text), + overall: textarea.value, + errors: notificationErrors, + }, { + submitted: false, + used: false, + comments: ['Keep this comment'], + overall: 'Keep this overall feedback', + errors: ['Unable to submit plan feedback: Submission failed'], + }); + + failSubmission = false; + const retried = await service.submitAllFeedback(planUri); + assert.deepStrictEqual({ + retried, + used: widget.domNode.classList.contains('chat-plan-review-used'), + comments: service.getFeedback(planUri), + result: lastSubmitResult, + }, { + retried: true, + used: true, + comments: [], + result: { + rejected: false, + feedback: 'Keep this overall feedback\n\nInline comments on `plan.md`:\n- **Line 5:** Keep this comment', + feedbackOverall: 'Keep this overall feedback', + feedbackInlineMarkdown: 'Inline comments on `plan.md`:\n- **Line 5:** Keep this comment', + }, + }); + }); + test('renders comments list and updates Submit Feedback count when service has items', async () => { const review = createMockReviewWithPlan(); createWidget(review); @@ -706,7 +763,7 @@ suite('ChatPlanReviewPart', () => { feedbackInlineMarkdown: 'Inline comments on `plan.md`:\n- **Line 5:** Fix this step', }, didSubmit: true, - commentsChanged: 2, + commentsChanged: 1, remainingComments: [], }); assert.ok(widget.domNode.classList.contains('chat-plan-review-used')); @@ -959,6 +1016,54 @@ suite('ChatPlanReviewPart', () => { }); suite('Multiple actions', () => { + test('Open Full Plan preserves the Windows host artifact URI and native identity', async () => { + const nativeUri = URI.parse('file:///c:/Users/test/.copilot/session-state/native-id/plan.md'); + const remoteUri = toAgentHostUri(nativeUri, 'windows-host'); + createWidget(createMockReviewWithPlan({ planUri: remoteUri.toJSON() })); + const open = sinon.stub(lastEditorService!, 'openEditor').resolves(undefined); + getReviewButton(widget)!.click(); + await tick(); + assert.deepStrictEqual({ + input: open.firstCall.args[0], + native: fromAgentHostUri(remoteUri).toString(), + }, { + input: { resource: remoteUri, options: { pinned: true, override: 'vscode.markdown.editor' } }, + native: nativeUri.toString(), + }); + }); + + for (const rejected of [false, true]) { + test(`plan read failure is visible and ${rejected ? 'rejection' : 'approval'} can be retried`, async () => { + const planUri = URI.parse('vscode-agent-host://windows-host/c:/Users/test/.copilot/session-state/native-id/plan.md'); + const review = new ChatPlanReviewData('Review Plan', 'Summary', [{ id: 'implement', label: 'Implement Plan', default: true }], true, planUri.toJSON()); + createWidget(review); + sinon.stub(lastTextFileService!, 'isDirty').returns(false); + const read = sinon.stub(lastTextFileService!, 'read'); + read.onFirstCall().rejects(new Error('Resource is outside the host workspace grants')); + read.onSecondCall().resolves({ resource: planUri, name: 'plan.md', size: 11, mtime: 1, ctime: 1, etag: '1', readonly: false, locked: false, executable: false, encoding: 'utf8', value: '# Full plan' }); + const label = rejected ? 'Reject' : 'Implement Plan'; + getFooterButtons(widget).find(button => button.textContent?.includes(label))!.click(); + await tick(); + assert.deepStrictEqual({ errors: notificationErrors, submitCount, used: widget.domNode.classList.contains('chat-plan-review-used') }, { + errors: [`Unable to ${rejected ? 'reject' : 'approve'} the plan: Resource is outside the host workspace grants`], submitCount: 0, used: false, + }); + getFooterButtons(widget).find(button => button.textContent?.includes(label))!.click(); + await tick(); + assert.deepStrictEqual({ result: lastSubmitResult, submitCount, content: review.content }, { + result: rejected ? { rejected: true } : { action: 'Implement Plan', actionId: 'implement', rejected: false }, submitCount: 1, content: '# Full plan', + }); + }); + } + + test('failed plan save reports an error without submitting', async () => { + createWidget(createMockReviewWithPlan({ actions: [{ id: 'implement', label: 'Implement Plan', default: true }] })); + sinon.stub(lastTextFileService!, 'isDirty').returns(true); + sinon.stub(lastTextFileService!, 'save').resolves(undefined); + getFooterButtons(widget).find(button => button.textContent?.includes('Implement Plan'))!.click(); + await tick(); + assert.deepStrictEqual({ errors: notificationErrors, submitCount }, { errors: ['Unable to approve the plan: The plan file could not be saved.'], submitCount: 0 }); + }); + test('persists edited plan content before submission', async () => { const planUri = URI.parse('file:///plan.md'); const review = new ChatPlanReviewData( diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelConfigurationStore.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelConfigurationStore.test.ts index 21a3c0d810d0d1..1740f25f3a6ce0 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelConfigurationStore.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelConfigurationStore.test.ts @@ -23,6 +23,8 @@ import { resolveContextWindowInputTokens } from '../../../../browser/widgetHosts import { ILanguageModelChatMetadata, ILanguageModelConfigurationSchema, ILanguageModelsService } from '../../../../common/languageModels.js'; import { IChatModelInputState, IInputModel } from '../../../../common/model/chatModel.js'; import { ChatModeKind } from '../../../../common/constants.js'; +import { IAgentHostConnectionsService } from '../../../../../../../platform/agentHost/common/agentHostConnectionsService.js'; +import { TestInstantiationService } from '../../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; const schema: ILanguageModelConfigurationSchema = { properties: { @@ -52,6 +54,24 @@ function createStubService(global?: IStringDictionary): ILanguageModels suite('ChatModelConfigurationStore', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); + test('the production input delegate supplies the active chat and resolved owner to the picker', () => { + const instantiationService = store.add(new TestInstantiationService()); + instantiationService.stub(IAgentHostConnectionsService, { + resolveSessionResourceIdentity: () => ({ connectionAuthority: 'local', backendSession: URI.parse('copilotcli:/owner') }), + }); + const widget = { viewModel: { sessionResource: URI.parse('agent-host-copilotcli:/owner#peer-one') } }; + const input: ChatInputPart = Object.assign(Object.create(ChatInputPart.prototype), { + agentHostConnectionsService: instantiationService.get(IAgentHostConnectionsService), + _widget: widget, + }); + const delegate = input['_createModelPickerDelegate'](); + assert.deepStrictEqual([delegate.getChatSessionId?.(), delegate.getAgentSessionId?.()], ['agent-host-copilotcli:/owner#peer-one', 'owner']); + widget.viewModel.sessionResource = URI.parse('agent-host-copilotcli:/owner#peer-two'); + assert.deepStrictEqual([delegate.getChatSessionId?.(), delegate.getAgentSessionId?.()], ['agent-host-copilotcli:/owner#peer-two', 'owner']); + widget.viewModel.sessionResource = URI.parse('agent-host-copilotcli:/untitled-draft'); + assert.strictEqual(delegate.getAgentSessionId?.(), undefined); + }); + function createStore(storage: InMemoryStorageService, service: ILanguageModelsService, isEmpty = () => true, managedSettings: IManagedSettingsService = new NullManagedSettingsService(), useAgentHostManagedDefault = false): ChatModelConfigurationStore { return store.add(new ChatModelConfigurationStore(() => KEY, isEmpty, constObservable(useAgentHostManagedDefault), service, storage, managedSettings, new NullLogService())); } diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelPickerCard.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelPickerCard.test.ts index 8affb1d3fd4367..7d3b1a63ba479b 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelPickerCard.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelPickerCard.test.ts @@ -12,6 +12,7 @@ import { Color, RGBA } from '../../../../../../../../base/common/color.js'; import { errorHandler, setUnexpectedErrorHandler } from '../../../../../../../../base/common/errors.js'; import { Emitter } from '../../../../../../../../base/common/event.js'; import { toDisposable } from '../../../../../../../../base/common/lifecycle.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 { NullOpenerService } from '../../../../../../../../platform/opener/test/common/nullOpenerService.js'; @@ -198,6 +199,48 @@ suite('ModelCard', () => { }); }); + test('Auto details end with a routing alternative that is selected and described rather than saved', async () => { + const hydraFusion = { ...createModel({ id: 'hydrafusion', name: 'HydraFusion', configurationSchema: undefined }), identifier: 'copilot/hydrafusion' }; + const alternative = (selected: boolean): IModelCardOptions['routingAlternative'] => ({ + model: hydraFusion, description: 'HydraFusion is a research preview.', learnMoreUrl: URI.parse('https://example.com/hydrafusion'), selected, + }); + const result = createCard({ tier: 'balance' }, { model: createAutoModel(), routingAlternative: alternative(false) }); + const choices = () => Array.from(result.card.element.querySelectorAll('[role="radio"]')); + const description = () => { + const element = result.card.element.querySelector('.chat-model-card-alternative-description'); + return element && !element.hidden ? element.textContent : undefined; + }; + const initial = { options: choices().map(choice => choice.textContent), selected: selectedOptions(result.card), description: description() }; + choices()[3].focus(); + choices()[3].click(); + const onClick = description(); + await timeout(0); + // The picker marks the alternative selected once it accepts the choice. + result.update({ routingAlternative: alternative(true) }); + const chosen = { + selected: selectedOptions(result.card), + description: description(), + ariaLabel: choices()[3].getAttribute('aria-label'), + focused: document.activeElement?.textContent, + }; + choices()[0].click(); + const onTier = description(); + await timeout(0); + assert.deepStrictEqual({ initial, onClick, chosen, onTier, writes: result.writes, selectedModels: result.selectedModels }, { + initial: { options: ['Efficiency', 'Balance', 'Intelligence', 'HydraFusion'], selected: ['Balance'], description: undefined }, + onClick: 'HydraFusion is a research preview. Learn more', + chosen: { + selected: ['HydraFusion'], + description: 'HydraFusion is a research preview. Learn more', + ariaLabel: 'HydraFusion, HydraFusion is a research preview.', + focused: 'HydraFusion', + }, + onTier: undefined, + writes: [{ tier: 'efficiency' }], + selectedModels: ['copilot/hydrafusion', 'copilot/auto'], + }); + }); + test('Auto is selected only after its tier saves without stealing focus moved elsewhere', async () => { const pending = new DeferredPromise(); const values = { tier: 'balance' }; 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 9c7193fab8df5b..641e0c43a00bad 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 @@ -186,11 +186,11 @@ suite('ModelPickerConfiguration', () => { }); }); - test('the Auto readout describes routing options rather than Details', () => { + test('the Auto readout opens its Details like any model', () => { assert.deepStrictEqual(render(createTierModel()), { visible: true, label: 'Balance', - ariaLabel: 'Auto options, Optimize for: Balance', + ariaLabel: 'Auto details, Optimize for: Balance', }); }); @@ -198,7 +198,7 @@ suite('ModelPickerConfiguration', () => { assert.deepStrictEqual(render(createTierModel(), { tier: 'max' }), { visible: true, label: 'Intelligence', - ariaLabel: 'Auto options, Optimize for: Intelligence', + ariaLabel: 'Auto details, Optimize for: Intelligence', }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelPickerTabbedWidget.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelPickerTabbedWidget.test.ts index d3f2bda14482c7..9cece92d6449fc 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelPickerTabbedWidget.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelPickerTabbedWidget.test.ts @@ -94,6 +94,8 @@ suite('TabbedModelPicker', () => { cacheWarm?: boolean; contextViewLayer?: number; inDialog?: boolean; + /** Vertical placement of the anchor, which otherwise sits at the bottom of the window like the chat input. */ + anchorTop?: string; policyDefault?: string; userDefault?: string; selectedModelId?: string; @@ -111,7 +113,7 @@ suite('TabbedModelPicker', () => { disposables.add(toDisposable(() => container.remove())); const anchorContainer = options.inDialog ? dom.append(container, dom.$('.monaco-dialog-box')) : container; const anchor = dom.append(anchorContainer, dom.$('button')); - anchor.style.cssText = 'position: fixed; bottom: 20px; left: 20px; width: 120px; height: 22px;'; + anchor.style.cssText = `position: fixed; ${options.anchorTop !== undefined ? `top: ${options.anchorTop}` : 'bottom: 20px'}; left: 20px; width: 120px; height: 22px;`; const popup = dom.append(container, dom.$('div')); const render = disposables.add(new MutableDisposable()); let activeDelegate: IContextViewDelegate | undefined; @@ -253,10 +255,51 @@ suite('TabbedModelPicker', () => { }); }); + for (const { placement, anchorTop, position } of [ + // Inline chat's input sits near the top of an editor, with little room above it. + { placement: 'near the top of the window', anchorTop: '40px', position: AnchorPosition.BELOW }, + { placement: 'with too little room on either side', anchorTop: 'calc(50% - 11px)', position: AnchorPosition.ABOVE }, + ]) { + test(`the popup and its tabs fit beside an anchor ${placement} without covering it, on every tab`, () => { + const local = model('Local'); + const result = createPicker({ + models: [...Array.from({ length: 40 }, (_, index) => model(`Model ${index}`)), { + ...local, identifier: 'ollama/local', metadata: { ...local.metadata, vendor: 'ollama' }, + }], + anchorTop, + cacheWarm: true, + }); + const anchorBounds = result.anchor.getBoundingClientRect(); + const placed = () => { + const space = result.anchorPosition === AnchorPosition.ABOVE ? anchorBounds.top : result.anchor.ownerDocument.defaultView!.innerHeight - anchorBounds.bottom; + const height = element(result.popup, '.chat-model-picker-widget').getBoundingClientRect().height; + return { position: result.anchorPosition, fits: height <= space, height }; + }; + const initial = placed(); + element(result.popup, '.chat-model-picker-tabbar [aria-label="Ollama"]').click(); + assert.deepStrictEqual({ initial, otherProvider: placed() }, { + initial: { position, fits: true, height: initial.height }, + otherProvider: { position, fits: true, height: initial.height }, + }); + }); + } + + test('opens below at full height when only the tabs keep the popup from fitting above', () => { + const natural = createPicker({ cacheWarm: true }); + const height = element(natural.popup, '.chat-model-picker-widget').getBoundingClientRect().height; + const tabBarHeight = element(natural.popup, '.chat-model-picker-tabbar').getBoundingClientRect().height; + natural.dismiss(); + // Above, there is room for the list and its banner but not for all of the tabs. + const result = createPicker({ cacheWarm: true, anchorTop: `${height - tabBarHeight / 2}px` }); + assert.deepStrictEqual({ + position: result.anchorPosition, + height: element(result.popup, '.chat-model-picker-widget').getBoundingClientRect().height, + }, { position: AnchorPosition.BELOW, height }); + }); + function defaultBadgeModels(popup: HTMLElement): string[] { return Array.from(popup.querySelectorAll('.chat-model-picker-org-default-badge:not([hidden])'), badge => { - const name = badge.closest('.monaco-list-row')?.querySelector('.title')?.textContent - ?? (badge.closest('.separator') ? 'Auto' : undefined); + const name = badge.closest('.monaco-list-row')?.querySelector('.title')?.textContent; assert.ok(name); return name; }); @@ -286,10 +329,12 @@ suite('TabbedModelPicker', () => { }; const { popup, picker, selections } = createPicker({ models: [createAutoModel(), createHydraFusionModel(), ...models], workflow }); element(popup, '[aria-label="Compare Models"]').click(); + // Counted from the set size, since a short popup may scroll some choices out of view. + const offered = (role: string) => Number(popup.querySelector(`[role="${role}"]`)?.getAttribute('aria-setsize') ?? 0); assert.deepStrictEqual({ heading: popup.querySelector('.action-list-header-text')?.textContent, routing: listRows(popup).some(label => label === 'Auto' || label === 'HydraFusion'), - checks: popup.querySelectorAll('[role="menuitemcheckbox"]').length, + checks: offered('menuitemcheckbox'), }, { heading: 'Attempts\nSelect models.', routing: false, checks: 3 }); const list = element(popup, '.monaco-list'); list.focus(); @@ -304,14 +349,14 @@ suite('TabbedModelPicker', () => { assert.deepStrictEqual({ heading: popup.querySelector('.action-list-header-text')?.textContent, buttons: buttons().map(button => button.textContent?.trim()), - radios: popup.querySelectorAll('[role="menuitemradio"]').length, + radios: offered('menuitemradio'), checkboxes: popup.querySelectorAll('[role="menuitemcheckbox"]').length, }, { heading: 'Judge\nOptional review.', buttons: ['Cancel', 'Back', 'Done'], radios: 3, checkboxes: 0 }); element(popup, '.chat-model-picker-model').click(); buttons()[2].click(); assert.deepStrictEqual({ heading: popup.querySelector('.action-list-header-text')?.textContent, - radios: popup.querySelectorAll('[role="menuitemradio"]').length, + radios: offered('menuitemradio'), cancel: buttons()[0].textContent?.trim(), }, { heading: 'Synthesizer\nOptional review.', radios: 3, cancel: 'Cancel' }); buttons()[1].click(); @@ -436,7 +481,7 @@ suite('TabbedModelPicker', () => { }); for (const initiallyAuto of [false, true]) { - test(`mode and provider changes retain the popup size when opened in ${initiallyAuto ? 'Auto' : 'manual'} mode`, () => { + test(`provider changes retain the popup size when opened with ${initiallyAuto ? 'Auto' : 'a manual model'} selected`, () => { const older = model('example-5.5'); const auto = createAutoModel(); const local = model('Local'); @@ -450,31 +495,18 @@ suite('TabbedModelPicker', () => { const { width, height, x, y } = element(popup, '.chat-model-picker-widget').getBoundingClientRect(); return { width, height, x, y }; }; - // Copilot has only two models here, so the Auto view is the taller layout and - // must still fit: Efficiency, Balance, Intelligence, and HydraFusion. - const visibleRoutes = () => { - const listBottom = element(popup, '.monaco-list').getBoundingClientRect().bottom; - return Array.from(popup.querySelectorAll('.chat-model-picker-routing-model'), row => row.getBoundingClientRect()) - .filter(row => row.height > 0 && row.bottom <= listBottom + 0.5).length; - }; const initial = bounds(); - const switchTop = element(popup, '[role="switch"]').getBoundingClientRect().top; - const initialRoutes = visibleRoutes(); - element(popup, '[role="switch"]').click(); - const toggled = bounds(); - const toggledSwitchTop = element(popup, '[role="switch"]').getBoundingClientRect().top; - const autoRoutes = initiallyAuto ? initialRoutes : visibleRoutes(); - element(popup, '[role="switch"]').click(); + const firstRow = listRows(popup)[0]; element(popup, '.chat-model-picker-tabbar [aria-label="Ollama"]').click(); const otherProvider = bounds(); element(popup, '.chat-model-picker-tabbar [aria-label="Copilot"]').click(); - assert.deepStrictEqual({ measurable: initial.height > 0, toggled, toggledSwitchTop, otherProvider, restored: bounds(), autoRoutes }, { - measurable: true, toggled: initial, toggledSwitchTop: switchTop, otherProvider: initial, restored: initial, autoRoutes: 4, + assert.deepStrictEqual({ measurable: initial.height > 0, firstRow, otherProvider, restored: bounds() }, { + measurable: true, firstRow: 'Auto', otherProvider: initial, restored: initial, }); }); } - for (const target of ['list', 'switch', 'search', 'details', 'detailsControl', 'back'] as const) { + for (const target of ['list', 'search', 'details', 'detailsControl', 'back'] as const) { test(`Escape from ${target} closes the picker in one step`, () => { const { picker, popup, selections } = createPicker({ models: [...models, createAutoModel()] }); let hidden = 0; @@ -486,7 +518,6 @@ suite('TabbedModelPicker', () => { } const selectors = { list: '.monaco-list', - switch: '[role="switch"]', search: 'input', details: '.tabbed-action-list-details', detailsControl: '.tabbed-action-list-details [role="radio"]', @@ -508,8 +539,8 @@ suite('TabbedModelPicker', () => { { name: 'the selected managed default', policyDefault: ' First ', expected: ['First'] }, { name: 'a per-chat override', policyDefault: 'Second', expected: ['Second'] }, { name: 'an unavailable managed default', policyDefault: 'Unavailable', expected: [] }, - { name: 'Auto as the managed default', models: [createAutoModel(), ...models], policyDefault: 'auto', expected: ['Auto'], selected: ['Balance'] }, - { name: 'Auto overriding the managed default', models: [createAutoModel(), ...models], policyDefault: 'First', expected: [], selected: ['Balance'] }, + { name: 'Auto as the managed default', models: [createAutoModel(), ...models], policyDefault: 'auto', expected: ['Auto'], selected: ['Auto'] }, + { name: 'Auto overriding the managed default', models: [createAutoModel(), ...models], policyDefault: 'First', expected: ['First'], selected: ['Auto'] }, ...[false, true].map(pinned => ({ name: `an older managed default ${pinned ? 'pinned' : 'in the shortlist'}`, models: [model('example-5.5'), model('example-5.6')], @@ -550,17 +581,17 @@ suite('TabbedModelPicker', () => { }); }); - test('refreshes the policy badge and mode switch when models arrive or leave', () => { + test('refreshes the policy badge and the Auto row when models arrive or leave', () => { const { picker, popup, selections } = createPicker({ policyDefault: 'Late' }); - const state = () => ({ defaults: defaultBadgeModels(popup), switchHidden: element(popup, '.tabbed-action-list-tab-toggle').hidden }); + const state = () => ({ defaults: defaultBadgeModels(popup), auto: listRows(popup).includes('Auto') }); const initial = state(); picker.refresh([...models, model('Late'), createAutoModel()]); const added = state(); picker.refresh(models); assert.deepStrictEqual({ initial, added, removed: state(), selections }, { - initial: { defaults: [], switchHidden: true }, - added: { defaults: ['Late'], switchHidden: false }, - removed: { defaults: [], switchHidden: true }, selections: [], + initial: { defaults: [], auto: false }, + added: { defaults: ['Late'], auto: true }, + removed: { defaults: [], auto: false }, selections: [], }); }); @@ -616,16 +647,28 @@ suite('TabbedModelPicker', () => { }); }); - function selectRoutingChoice(popup: HTMLElement, label: string): void { + /** The labels of the options in the open details' "Optimize for" control. */ + function autoTiers(popup: HTMLElement): string[] { + return Array.from(popup.querySelectorAll('.tabbed-action-list-details [role="radiogroup"][aria-label="Optimize for"] [role="radio"]'), option => option.textContent ?? ''); + } + + function chooseOption(popup: HTMLElement, label: string): void { + const option = Array.from(popup.querySelectorAll('.tabbed-action-list-details [role="radio"]')).find(option => option.textContent === label); + assert.ok(option, label); + option.click(); + } + + function selectRow(popup: HTMLElement, label: string): void { const row = Array.from(popup.querySelectorAll('.chat-model-picker-model')).find(row => row.querySelector('.title')?.textContent === label); assert.ok(row, label); row.click(); } - test('HydraFusion routing shows its description and Learn more under the entry, without a flyout', () => { - const auto = createAutoModel(); + test('HydraFusion without an Auto to join shows its description and Learn more under its own entry, without a flyout', () => { + const auto = model('auto', false); const hydra = createHydraFusionModel(); const result = createPicker({ models: [auto, hydra, ...models], selectedModelId: auto.identifier }); + const leading = listRows(result.popup).slice(0, 2); const row = Array.from(result.popup.querySelectorAll('.chat-model-picker-routing-model')) .find(row => row.querySelector('.title')?.textContent === 'HydraFusion'); assert.ok(row); @@ -636,6 +679,7 @@ suite('TabbedModelPicker', () => { const detailBounds = detail.getBoundingClientRect(); const linkBounds = link.getBoundingClientRect(); const snapshot = { + leading, detail: detail.lastChild?.textContent, link: link.textContent, ariaLabel: row.getAttribute('aria-label'), @@ -645,6 +689,7 @@ suite('TabbedModelPicker', () => { }; row.click(); assert.deepStrictEqual({ ...snapshot, selections: result.selections }, { + leading: ['auto', 'HydraFusion'], detail: 'Picks a workflow per task, using one or more models to draft, review, or escalate.', link: 'Learn more', ariaLabel: 'HydraFusion, Research preview, Picks a workflow per task, using one or more models to draft, review, or escalate.', @@ -655,6 +700,51 @@ suite('TabbedModelPicker', () => { }); }); + test('HydraFusion is Auto\'s last tier and describes itself as a research preview once chosen', async () => { + const auto = createAutoModel(); + const hydra = createHydraFusionModel(); + const result = createPicker({ models: [...models, auto, hydra], selectedModelId: models[0].identifier }); + const autoRow = () => { + const row = Array.from(result.popup.querySelectorAll('.chat-model-picker-model')).find(row => row.querySelector('.title')?.textContent === 'Auto')!; + return { checked: row.getAttribute('aria-checked'), readout: row.querySelector('.action-label')?.textContent }; + }; + const description = () => { + const element = result.popup.querySelector('.chat-model-card-alternative-description'); + return element && !element.hidden ? element.textContent : undefined; + }; + const list = { rows: listRows(result.popup), auto: autoRow() }; + openDetails(result.popup, 'Auto'); + const tiers = { options: autoTiers(result.popup), description: description() }; + chooseOption(result.popup, 'HydraFusion'); + await timeout(0); + const chosen = { + model: result.popup.querySelector('.tabbed-action-list-details .chat-model-card-name')?.textContent, + active: result.popup.querySelector('.tabbed-action-list-details [role="radio"][aria-checked="true"]')?.textContent, + description: description(), + learnMore: !!result.popup.querySelector('.chat-model-card-alternative-description a'), + }; + goBack(result.popup); + const listWithHydraFusion = autoRow(); + // Auto stands for its HydraFusion tier, so picking it again keeps HydraFusion. + selectRow(result.popup, 'Auto'); + reopen(result); + openDetails(result.popup, 'Auto'); + chooseOption(result.popup, 'Efficiency'); + await timeout(0); + const returned = { description: description(), values: result.values.get(auto.identifier) }; + assert.deepStrictEqual({ list, tiers, chosen, listWithHydraFusion, returned, selections: result.selections }, { + list: { rows: ['Auto', '--', 'First', 'Fixed', 'Second'], auto: { checked: 'false', readout: 'Balance' } }, + tiers: { options: ['Efficiency', 'Balance', 'Intelligence', 'HydraFusion'], description: undefined }, + chosen: { + model: 'Auto', active: 'HydraFusion', learnMore: true, + description: 'HydraFusion is a research preview. It picks a workflow per task, using one or more models to draft, review, or escalate. Learn more', + }, + listWithHydraFusion: { checked: 'true', readout: 'HydraFusion' }, + returned: { description: undefined, values: { tier: 'efficiency' } }, + selections: [hydra.identifier, auto.identifier], + }); + }); + /** Reopens the picker on the latest selection, as the chat input does. */ function reopen(result: ReturnType): void { result.picker.show(result.anchor, { ...result.context, selectedModelId: result.selections.at(-1) ?? result.context.selectedModelId }); @@ -668,22 +758,23 @@ suite('TabbedModelPicker', () => { models: [auto, ...models], beforeSave: async next => { writes.push(next); await pending.p; }, }); - selectRoutingChoice(result.popup, 'Intelligence'); - const closed = !result.picker.isVisible; - reopen(result); - selectRoutingChoice(result.popup, 'Efficiency'); + openDetails(result.popup, 'Auto'); + chooseOption(result.popup, 'Intelligence'); + chooseOption(result.popup, 'Efficiency'); await timeout(0); const before = [...writes]; await pending.complete(); await timeout(0); - reopen(result); + const open = !!result.popup.querySelector('.tabbed-action-list-details'); + goBack(result.popup); const changes = result.configurationChanges.map(([, , , from, to]) => ({ from, to })); - assert.deepStrictEqual({ closed, before, writes, changes, selected: selectedModels(result.popup), selections: result.selections }, { - closed: true, + const readout = result.popup.querySelector('.chat-model-picker-model[aria-checked="true"] .action-label')?.textContent; + assert.deepStrictEqual({ open, before, writes, changes, selected: selectedModels(result.popup), readout, selections: result.selections }, { + open: true, before: [{ tier: 'intelligence' }], writes: [{ tier: 'intelligence' }, { tier: 'efficiency' }], changes: [{ from: 'balance', to: 'intelligence' }, { from: 'intelligence', to: 'efficiency' }], - selected: ['Efficiency'], selections: [], + selected: ['Auto'], readout: 'Efficiency', selections: [], }); }); @@ -695,7 +786,6 @@ suite('TabbedModelPicker', () => { const ollama = { ...local, identifier: 'ollama/local', metadata: { ...local.metadata, vendor: 'ollama' } }; const result = createPicker({ models: [auto, ...models, ollama], beforeSave: async () => { throw failure; } }); if (start !== 'Auto') { - // The Copilot tab remembers Auto mode, so its tiers are one click away. result.picker.show(result.anchor, { ...result.context, selectedModelId: ollama.identifier }); element(result.popup, '.chat-model-picker-tabbar [aria-label="Copilot"]').click(); } @@ -703,31 +793,34 @@ suite('TabbedModelPicker', () => { const previousHandler = errorHandler.getUnexpectedErrorHandler(); setUnexpectedErrorHandler(error => reported.push(error)); try { - selectRoutingChoice(result.popup, 'Intelligence'); + openDetails(result.popup, 'Auto'); + chooseOption(result.popup, 'Intelligence'); await timeout(0); } finally { setUnexpectedErrorHandler(previousHandler); } + result.picker.hide(); result.picker.show(result.anchor, { ...result.context, selectedModelId: start === 'Auto' ? auto.identifier : ollama.identifier }); element(result.popup, '.chat-model-picker-tabbar [aria-label="Copilot"]').click(); assert.deepStrictEqual({ reported, changes: result.configurationChanges, selected: selectedModels(result.popup), + readout: element(result.popup, '[aria-label^="Auto Details"]').textContent, selections: result.selections, - }, { reported: [failure], changes: [], selected: start === 'Auto' ? ['Balance'] : [], selections: [] }); + }, { reported: [failure], changes: [], selected: start === 'Auto' ? ['Auto'] : [], readout: 'Balance', selections: [] }); }); } - for (const reopen of [false, true]) { - test(`pending tier saves cannot override ${reopen ? 'a reopened picker with a new scope' : 'switching to manual mode'}`, async () => { + for (const newScope of [false, true]) { + test(`pending Auto tier saves cannot override ${newScope ? 'a reopened picker with a new scope' : 'a model chosen since'}`, async () => { const auto = createAutoModel(); const pending = new DeferredPromise(); - const result = createPicker({ models: [auto, ...models], beforeSave: () => pending.p }); - selectRoutingChoice(result.popup, 'Intelligence'); - result.picker.show(result.anchor, result.context); - element(result.popup, '[role="switch"]').click(); - if (reopen) { + const result = createPicker({ models: [auto, ...models], selectedModelId: models[0].identifier, beforeSave: () => pending.p }); + openDetails(result.popup, 'Auto'); + chooseOption(result.popup, 'Intelligence'); + goBack(result.popup); + if (newScope) { result.picker.hide(); result.picker.show(result.anchor, { ...result.context, selectedModelId: models[0].identifier, @@ -737,19 +830,21 @@ suite('TabbedModelPicker', () => { setModelConfiguration: async () => assert.fail('The new scope must not receive the old write'), }, }); + } else { + selectRow(result.popup, 'Second'); + reopen(result); } await pending.complete(); await timeout(0); assert.deepStrictEqual({ values: result.values.get(auto.identifier), selected: selectedModels(result.popup), - mode: element(result.popup, '[role="switch"]').getAttribute('aria-checked'), selections: result.selections, - }, { values: { tier: 'intelligence' }, selected: ['First'], mode: 'false', selections: [models[0].identifier] }); + }, { values: { tier: 'intelligence' }, selected: [newScope ? 'First' : 'Second'], selections: newScope ? [] : [models[1].identifier] }); }); } - test('Copilot modes retain their choices, use exclusive routing, and preserve switch focus', async () => { + test('Auto leads only the Copilot tab, and search still finds HydraFusion', () => { const auto = createAutoModel(); const hydra = createHydraFusionModel(); const local = model('Local'); @@ -758,72 +853,33 @@ suite('TabbedModelPicker', () => { ...local, identifier: 'ollama/local', metadata: { ...local.metadata, vendor: 'ollama' }, }] }); - const state = () => ({ selected: selectedModels(result.popup), defaults: defaultBadgeModels(result.popup) }); - const states = [state()]; - let toggle = element(result.popup, '[role="switch"]'); - toggle.focus(); - toggle.click(); - const focusAfterToggle = document.activeElement === toggle; - const separateControl = !toggle.closest('.monaco-button'); - const routes = Array.from(result.popup.querySelectorAll('.chat-model-picker-model .title'), title => title.textContent); - const closedAfter: boolean[] = []; - const choose = (label: string) => { - selectRoutingChoice(result.popup, label); - closedAfter.push(!result.picker.isVisible); - reopen(result); - }; - choose('Balance'); - await timeout(0); - states.push(state()); + const initial = { rows: listRows(result.popup), selected: selectedModels(result.popup), defaults: defaultBadgeModels(result.popup) }; + const autoRow = element(result.popup, '[aria-label^="Auto Details"]').closest('.monaco-list-row')!; + const autoRowA11y = { hasPopup: autoRow.getAttribute('aria-haspopup'), badge: autoRow.querySelector('.action-item-badge')?.textContent }; + selectRow(result.popup, 'Auto'); + const closed = !result.picker.isVisible; + reopen(result); + const reopened = selectedModels(result.popup); element(result.popup, '.chat-model-picker-tabbar [aria-label="Ollama"]').click(); - const otherProvider = { selected: selectedModels(result.popup), switch: result.popup.querySelector('[role="switch"]'), defaults: defaultBadgeModels(result.popup) }; - element(result.popup, '.chat-model-picker-tabbar [aria-label="Copilot"]').click(); - states.push(state()); + const otherProvider = listRows(result.popup); element(result.popup, '[data-id="search"]').click(); const input = result.popup.querySelector('input')!; input.value = 'HydraFusion'; input.dispatchEvent(new InputEvent('input', { bubbles: true })); const searchResults = Array.from(result.popup.querySelectorAll('.chat-model-picker-model .title'), title => title.textContent); - choose('HydraFusion'); - toggle = element(result.popup, '[role="switch"]'); - states.push(state()); - toggle.click(); - states.push(state()); - toggle.click(); - states.push(state()); - choose('Efficiency'); - await timeout(0); - reopen(result); - toggle = element(result.popup, '[role="switch"]'); - toggle.click(); - toggle.click(); - states.push(state()); - assert.deepStrictEqual({ - states, routes, otherProvider, searchResults, focusAfterToggle, separateControl, closedAfter, - selections: result.selections, - changes: result.configurationChanges.map(([, , , from, to]) => ({ from, to })), - }, { - states: [ - { selected: ['First'], defaults: [] }, - { selected: ['Balance'], defaults: ['Auto'] }, - { selected: ['Balance'], defaults: ['Auto'] }, - { selected: ['HydraFusion'], defaults: ['Auto'] }, - { selected: ['First'], defaults: [] }, - { selected: ['HydraFusion'], defaults: ['Auto'] }, - { selected: ['Efficiency'], defaults: ['Auto'] }, - ], - routes: ['Efficiency', 'Balance', 'Intelligence', 'HydraFusion'], - otherProvider: { selected: [], switch: null, defaults: [] }, + selectRow(result.popup, 'HydraFusion'); + assert.deepStrictEqual({ initial, autoRowA11y, closed, reopened, otherProvider, searchResults, selections: result.selections }, { + initial: { rows: ['Auto', '--', 'First', 'Fixed', 'Second'], selected: ['First'], defaults: ['Auto'] }, + autoRowA11y: { hasPopup: 'dialog', badge: '10% discount' }, + closed: true, + reopened: ['Auto'], + otherProvider: ['Local'], searchResults: ['HydraFusion'], - focusAfterToggle: true, - separateControl: true, - closedAfter: [true, true, true], - selections: [auto.identifier, hydra.identifier, models[0].identifier, hydra.identifier, auto.identifier, models[0].identifier, auto.identifier], - changes: [{ from: 'balance', to: 'efficiency' }], + selections: [auto.identifier, hydra.identifier], }); }); - test('neither routing choices nor the manual fallback select unsupported models or other providers', () => { + test('HydraFusion that needs a newer VS Code is offered as an update rather than a tier', () => { const auto = createAutoModel(); const local = model('Local'); const gated = model('Gated'); @@ -835,17 +891,22 @@ suite('TabbedModelPicker', () => { hydrafusion: { label: 'HydraFusion', exists: true, featured: true, minVSCodeVersion: '99.0.0' }, }, }); - const hydra = element(result.popup, '.chat-model-picker-unavailable'); - hydra.click(); - const routing = selectedModels(result.popup); - element(result.popup, '[role="switch"]').click(); - assert.deepStrictEqual({ unavailable: hydra.querySelector('.title')?.textContent, routing, selections: result.selections }, { - unavailable: 'HydraFusion', routing: ['Balance'], selections: [models[1].identifier], + const unavailable = Array.from(result.popup.querySelectorAll('.chat-model-picker-unavailable')); + unavailable.forEach(row => row.click()); + openDetails(result.popup, 'Auto'); + assert.deepStrictEqual({ + unavailable: unavailable.map(row => row.querySelector('.title')?.textContent), + tiers: autoTiers(result.popup), + selections: result.selections, + }, { + unavailable: ['Gated', 'HydraFusion'], + tiers: ['Efficiency', 'Balance', 'Intelligence'], + selections: [], }); }); for (const entitlement of [ChatEntitlement.Free, ChatEntitlement.EDU]) { - test(`${ChatEntitlement[entitlement]} plans show HydraFusion as an unavailable upgrade instead of a routing choice`, () => { + test(`${ChatEntitlement[entitlement]} plans show HydraFusion as an unavailable upgrade instead of a tier`, () => { const auto = createAutoModel(); const hydra = createHydraFusionModel(); const result = createPicker({ @@ -858,15 +919,17 @@ suite('TabbedModelPicker', () => { result.picker.refresh([auto, hydra, ...models]); const unavailableHydra = element(result.popup, '.chat-model-picker-unavailable'); unavailableHydra.click(); - assert.deepStrictEqual({ + const state = { label: unavailableHydra.querySelector('.title')?.textContent, upgrade: unavailableHydra.textContent?.includes('Upgrade'), selected: selectedModels(result.popup), - selections: result.selections, - }, { + }; + openDetails(result.popup, 'Auto'); + assert.deepStrictEqual({ ...state, tiers: autoTiers(result.popup), selections: result.selections }, { label: 'HydraFusion', upgrade: true, - selected: ['Balance'], + selected: ['Auto'], + tiers: ['Efficiency', 'Balance', 'Intelligence'], selections: [], }); }); @@ -903,7 +966,9 @@ suite('TabbedModelPicker', () => { paid: { hydrafusion: { label: 'HydraFusion', exists: true, featured: true } }, }, }); - const before = Array.from(result.popup.querySelectorAll('.chat-model-picker-routing-model .title'), element => element.textContent); + openDetails(result.popup, 'Auto'); + const before = autoTiers(result.popup); + goBack(result.popup); result.setEntitlement(ChatEntitlement.Free); const unavailableHydra = element(result.popup, '.chat-model-picker-unavailable'); assert.deepStrictEqual({ @@ -916,7 +981,7 @@ suite('TabbedModelPicker', () => { before: ['Efficiency', 'Balance', 'Intelligence', 'HydraFusion'], label: 'HydraFusion', upgrade: true, - selected: ['Balance'], + selected: ['Auto'], selections: [], }); }); @@ -937,14 +1002,19 @@ suite('TabbedModelPicker', () => { paid: { hydrafusion: { label: 'HydraFusion', exists: true, featured: true } }, }, }); + const before = element(result.popup, '[aria-label^="Auto Details"]').textContent; result.setEntitlement(ChatEntitlement.Free); assert.deepStrictEqual({ + before, upgrade: element(result.popup, '.chat-model-picker-unavailable').textContent?.includes('Upgrade'), selected: selectedModels(result.popup), + after: element(result.popup, '[aria-label^="Auto Details"]').textContent, selections: result.selections, }, { + before: 'HydraFusion', upgrade: true, - selected: ['Balance'], + selected: ['Auto'], + after: 'Balance', selections: [auto.identifier], }); }); @@ -978,7 +1048,7 @@ suite('TabbedModelPicker', () => { }); }); - test('Auto-only plans name the tab Auto without a switch and retain unavailable models and provider navigation', () => { + test('Auto-only plans list Auto beside unavailable models and retain provider navigation', () => { const result = createPicker({ models: [createAutoModel()], showUnavailable: true, @@ -987,26 +1057,25 @@ suite('TabbedModelPicker', () => { }); const initial = { tabs: Array.from(result.popup.querySelectorAll('.chat-model-picker-tabbar [role="radio"]'), tab => tab.getAttribute('aria-label')), - switchHidden: element(result.popup, '.tabbed-action-list-tab-toggle').hidden, selected: selectedModels(result.popup), unavailable: result.popup.textContent?.includes('Locked Model'), }; element(result.popup, '.chat-model-picker-tabbar [aria-label="Ollama"]').click(); assert.deepStrictEqual({ initial, emptyProvider: !!result.popup.querySelector('.tabbed-action-list-empty'), selections: result.selections }, { - initial: { tabs: ['Auto', 'Ollama'], switchHidden: true, selected: ['Balance'], unavailable: true }, + initial: { tabs: ['Copilot', 'Ollama'], selected: ['Auto'], unavailable: true }, emptyProvider: true, selections: [], }); }); - test('Free and Student plans head the models that Auto-only access lacks as upgrades, beside the routing choices', () => { + test('Free and Student plans head the models that Auto-only access lacks as upgrades, below Auto', () => { const rows = [ChatEntitlement.Free, ChatEntitlement.EDU].map(entitlement => listRows(createPicker({ models: [createAutoModel()], entitlement, showUnavailable: true, controlModels: { locked: { label: 'Locked Model', exists: false, featured: true } }, }).popup)); - assert.deepStrictEqual(rows, Array(2).fill(['--Optimize for · 10% discount', 'Efficiency', 'Balance', 'Intelligence', '--Upgrade for More Models', 'Locked Model'])); + assert.deepStrictEqual(rows, Array(2).fill(['Auto', '--Upgrade for More Models', 'Locked Model'])); }); test('Free and Student plans keep the Copilot tab for upgrades when Copilot relays no selectable model', () => { @@ -1031,58 +1100,74 @@ suite('TabbedModelPicker', () => { }); }); - test('HydraFusion alone stays grouped under the Auto tab without a switch', () => { + test('HydraFusion alone keeps its own row in the Copilot tab, without Details', () => { const hydra = createHydraFusionModel(); const { popup, selections } = createPicker({ models: [hydra], selectedModelId: hydra.identifier }); assert.deepStrictEqual({ tabs: Array.from(popup.querySelectorAll('.chat-model-picker-tabbar [role="radio"]'), tab => tab.getAttribute('aria-label')), - switchHidden: element(popup, '.tabbed-action-list-tab-toggle').hidden, rows: listRows(popup), selected: selectedModels(popup), + actions: popup.querySelectorAll('.chat-model-picker-model .action-label').length, selections, }, { - tabs: ['Auto'], - switchHidden: true, - rows: ['--Alternative routing', 'HydraFusion'], + tabs: ['Copilot'], + rows: ['HydraFusion'], selected: ['HydraFusion'], + actions: 0, selections: [], }); }); - for (const [name, auto] of [['Auto with tiers', createAutoModel()], ['Auto without a schema', model('auto', false)], ['HydraFusion', createHydraFusionModel()]] as const) { - test(`${name} has no Details entry point`, () => { - const { picker, popup, anchor, context, selections } = createPicker({ models: [auto, ...models], details: auto.identifier }); - const initial = { - details: !!popup.querySelector('.tabbed-action-list-details'), - actions: popup.querySelectorAll('.chat-model-picker-model .action-label').length, - mode: element(popup, '[role="switch"]').getAttribute('aria-checked'), + for (const { name, routing, requested, expected } of [ + { name: 'Auto with tiers', routing: [createAutoModel()], requested: 'auto', expected: { model: 'Auto', groups: ['Optimize for'] } }, + { name: 'Auto without a schema', routing: [model('auto', false)], requested: 'auto', expected: { model: 'auto', groups: [] } }, + { name: 'HydraFusion as Auto\'s tier', routing: [createAutoModel(), createHydraFusionModel()], requested: 'hydrafusion', expected: { model: 'Auto', groups: ['Optimize for'] } }, + { name: 'HydraFusion without an Auto to join', routing: [createHydraFusionModel()], requested: 'hydrafusion', expected: undefined }, + ]) { + test(`${name} ${expected ? 'opens Auto\'s Details' : 'has no Details'} from the chat input, the list, and search`, () => { + const { picker, popup, anchor, context, selections } = createPicker({ models: [...routing, ...models], details: `copilot/${requested}` }); + const details = () => { + const page = popup.querySelector('.tabbed-action-list-details'); + return page ? { + model: page.querySelector('.chat-model-card-name')?.textContent, + groups: Array.from(page.querySelectorAll('[role="radiogroup"]'), group => group.getAttribute('aria-label')), + } : undefined; }; + const direct = details(); + if (direct) { + goBack(popup); + } + const label = expected?.model ?? 'HydraFusion'; + const listAction = !!popup.querySelector(`[aria-label^="${label} Details"]`); element(popup, '[data-id="search"]').click(); const input = popup.querySelector('input')!; - input.value = auto.metadata.name; + input.value = label; input.dispatchEvent(new InputEvent('input', { bubbles: true })); - const searchActions = popup.querySelectorAll('.chat-model-picker-model .action-label').length; + const searchAction = !!popup.querySelector(`[aria-label^="${label} Details"]`); picker.hide(); - picker.show(anchor, context, auto.identifier, true); - assert.deepStrictEqual({ initial, searchActions, reopenedDetails: !!popup.querySelector('.tabbed-action-list-details'), selections }, { - initial: { details: false, actions: 0, mode: 'true' }, searchActions: 0, reopenedDetails: false, selections: [], + picker.show(anchor, context, `copilot/${requested}`, true); + assert.deepStrictEqual({ direct, listAction, searchAction, reopened: details(), selections }, { + direct: expected, listAction: !!expected, searchAction: !!expected, reopened: expected, selections: [], }); }); } - test('an Auto tier controlled by a read-only schema cannot be changed', () => { + test('an Auto tier controlled by a read-only schema cannot be changed, but HydraFusion can still be chosen', async () => { const auto = createAutoModel(); + const hydra = createHydraFusionModel(); const result = createPicker({ - models: [{ ...auto, metadata: { ...auto.metadata, configurationSchema: { properties: { tier: { ...auto.metadata.configurationSchema!.properties!.tier, readOnly: true } } } } }, ...models], + models: [{ ...auto, metadata: { ...auto.metadata, configurationSchema: { properties: { tier: { ...auto.metadata.configurationSchema!.properties!.tier, readOnly: true } } } } }, hydra, ...models], + }); + openDetails(result.popup, 'Auto'); + const disabled = Array.from(result.popup.querySelectorAll('.tabbed-action-list-details [role="radio"]'), option => option.getAttribute('aria-disabled') === 'true'); + chooseOption(result.popup, 'Intelligence'); + await timeout(0); + const saved = result.values.get(auto.identifier); + chooseOption(result.popup, 'HydraFusion'); + await timeout(0); + assert.deepStrictEqual({ disabled, saved, selections: result.selections }, { + disabled: [true, true, true, false], saved: undefined, selections: [hydra.identifier], }); - selectRoutingChoice(result.popup, 'Intelligence'); - const inlineSelection = result.popup.querySelector('.chat-model-picker-model[aria-checked="true"] .title')?.textContent; - assert.deepStrictEqual({ - inlineSelection, - disabled: Array.from(result.popup.querySelectorAll('.chat-model-picker-model'), choice => choice.classList.contains('option-disabled')), - saved: result.values.get(auto.identifier), - selections: result.selections, - }, { inlineSelection: 'Balance', disabled: [true, false, true], saved: undefined, selections: [] }); }); test('configuration readouts are visible for the current model and keyboard focus, not pointer-only focus', () => { @@ -1352,16 +1437,18 @@ suite('TabbedModelPicker', () => { }); }); - test('live scoped values refresh Auto choices directly without a Details page', () => { + test('live scoped values refresh Auto\'s Details and its readout', () => { const auto = model('auto'); const result = createPicker({ models: [auto, ...models], details: auto.identifier }); result.values.set(auto.identifier, { effort: 'high' }); result.changed.fire(auto.identifier); + const tier = result.popup.querySelector('.tabbed-action-list-details [role="radio"][aria-checked="true"]')?.textContent; + goBack(result.popup); assert.deepStrictEqual({ - inlineTier: result.popup.querySelector('.chat-model-picker-model[aria-checked="true"] .title')?.textContent, - details: !!result.popup.querySelector('.tabbed-action-list-details'), + tier, + readout: element(result.popup, '[aria-label^="auto Details"]').textContent, visible: result.picker.isVisible, selections: result.selections, - }, { inlineTier: 'High', details: false, visible: true, selections: [] }); + }, { tier: 'High', readout: 'High · 32K', visible: true, selections: [] }); }); test('cached details reread configuration and reset state after returning to the list', () => { diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelPickerTelemetry.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelPickerTelemetry.test.ts index e0fb95bd134579..605fe8c4f13290 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelPickerTelemetry.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelPickerTelemetry.test.ts @@ -120,7 +120,6 @@ suite('ModelPickerTelemetry', () => { let showCard: (label: string) => HTMLElement = () => assert.fail('Picker has not opened'); let refreshList = () => { }; let searchModels: () => void = () => assert.fail('Tabbed picker has not opened'); - let toggleAuto: () => void = () => assert.fail('Tabbed picker has not opened'); const hideTabbedPicker = () => { visible = false; detailsOptions = undefined; @@ -188,12 +187,6 @@ suite('ModelPickerTelemetry', () => { const rendered = options.renderEmpty?.(body, activeTab); return rendered ? (store.add(rendered), body) : undefined; }; - toggleAuto = () => { - const toggle = options.tabs.find(tab => tab.id === activeTab)?.toggle; - const state = toggle?.getState(); - assert.ok(toggle && state?.enabled); - toggle.onChange(!state.checked); - }; searchModels = () => { const search = options.tabBarActions?.find(action => action.id === 'search'); assert.ok(search && !search.checked, 'Model must be available in the picker or in search'); @@ -242,6 +235,14 @@ suite('ModelPickerTelemetry', () => { // Replace the random session id with its open order and check each duration // is a real elapsed time, so events stay deterministic to compare. const normalized: IStringDictionary = { ...data }; + if (name === 'chat.modelPickerOpened' || name === 'chat.modelChange' || name === 'chat.thinkingEffortChange') { + assert.strictEqual(normalized.agentSessionId, 'owner-1'); + assert.strictEqual(normalized.chatSessionId, 'session-1'); + delete normalized.agentSessionId; + if (name === 'chat.thinkingEffortChange') { + delete normalized.chatSessionId; + } + } if (name !== 'chat.modelPickerInteraction') { assert.strictEqual(normalized.provider, 'copilotcli'); delete normalized.provider; @@ -296,6 +297,7 @@ suite('ModelPickerTelemetry', () => { setModelProgrammatically: supportsProgrammaticSelection ? model => programmaticDelegateSelections.push(model.identifier) : undefined, getModels: () => models, getChatSessionId: () => 'session-1', + getAgentSessionId: () => 'owner-1', getProvider: () => 'copilotcli', getPresentationOptions: () => ({ showManageModelsAction: false, showUnavailableFeatured: true, @@ -329,7 +331,6 @@ suite('ModelPickerTelemetry', () => { }, selectItem: (label: string) => selectItem(label), selectTab: (label: string) => selectTab(label), - toggleAuto: () => toggleAuto(), hide: () => hideTabbedPicker(), showCard: (label: string) => showCard(label), backToModels: () => hideDetails(), @@ -469,8 +470,8 @@ suite('ModelPickerTelemetry', () => { }, { layer: 1, model: model.metadata.name, expanded: 'true', events: [] }); }); - for (const target of ['name', 'config']) { - test(`the Auto ${target} pill opens routing choices, not Details`, async () => { + for (const target of ['name', 'config'] as const) { + test(`the Auto ${target} pill opens ${target === 'name' ? 'the model list' : 'Auto\'s Details'} and restores focus`, async () => { const result = createPicker(autoModel); result.picker.render(result.container); result.picker.show(result.container); @@ -478,15 +479,15 @@ suite('ModelPickerTelemetry', () => { trigger.focus(); trigger.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true, button: 0 })); const opened = { - details: !!result.container.querySelector('.chat-model-card'), + details: result.container.querySelector('.chat-model-card-name')?.textContent, expanded: Array.from(result.container.querySelectorAll('.model-picker-section'), pill => pill.getAttribute('aria-expanded')), readoutPopup: result.container.querySelector('.model-picker-config')?.getAttribute('aria-haspopup'), }; - result.selectItem('Efficiency'); + option(result.showCard(autoModel.metadata.name), 'Efficiency').click(); await timeout(0); result.picker.show(result.container); assert.deepStrictEqual({ opened, focused: document.activeElement === trigger, saved: result.configurations.get(autoModel.identifier) }, { - opened: { details: false, expanded: ['true', 'true'], readoutPopup: 'menu' }, + opened: { details: target === 'config' ? autoModel.metadata.name : undefined, expanded: target === 'config' ? ['false', 'true'] : ['true', 'false'], readoutPopup: 'dialog' }, focused: true, saved: { tier: 'efficiency' }, }); }); @@ -523,7 +524,7 @@ suite('ModelPickerTelemetry', () => { }, { hasConfig: true, targets: ['button', 'button'], summary: 'Medium · 264K' }); }); - test('opening from the model name keeps the whole chip active in Auto until dismissal', () => { + test('opening from the model name keeps the whole chip active in Auto until dismissal', async () => { const result = createPicker(); result.picker.render(result.container); result.picker.show(result.container); @@ -531,8 +532,9 @@ suite('ModelPickerTelemetry', () => { const name = result.container.querySelector('.model-picker-name')!; name.dispatchEvent(new MouseEvent('mousedown', { button: 0, bubbles: true, cancelable: true })); const afterOpen = chip.classList.contains('model-picker-active'); - result.toggleAuto(); - const inAuto = chip.classList.contains('model-picker-active'); + option(result.showCard(autoModel.metadata.name), 'Efficiency').click(); + await timeout(0); + const inAuto = chip.classList.contains('model-picker-active') && result.picker.selectedModel?.identifier === autoModel.identifier; result.picker.show(result.container); assert.deepStrictEqual({ afterOpen, @@ -740,11 +742,7 @@ suite('ModelPickerTelemetry', () => { ]) { test(`reports ${change.model.metadata.vendor} ${change.property ?? 'context size'} changes`, async () => { const result = createPicker(change.model); - if (change.model === autoModel) { - result.selectItem(change.label); - } else { - option(result.showCard(change.model.metadata.name), change.label).click(); - } + option(result.showCard(change.model.metadata.name), change.label).click(); await timeout(0); assert.deepStrictEqual(result.events, [{ name: change.event, @@ -794,9 +792,9 @@ suite('ModelPickerTelemetry', () => { assert.strictEqual(data?.provider, 'codex-openai'); logged.push({ name, durationMs: data?.durationMs, pickerSessionId: data?.pickerSessionId }); }, - }), new NullLanguageModelsService(), { entryPoint: 'modelName', inputMethod: 'mouse' }, model, 'session-1', 'codex-openai', () => now); + }), new NullLanguageModelsService(), { entryPoint: 'modelName', inputMethod: 'mouse' }, model, { chatSessionId: 'session-1' }, 'codex-openai', () => now); now = 1250.4; - session.logModelChange(model, otherModel, 'session-1'); + session.logModelChange(model, otherModel); now = 2000; const saved = new DeferredPromise(); session.close(saved.p); @@ -818,6 +816,25 @@ suite('ModelPickerTelemetry', () => { }); }); + test('retains the originating chat when a configuration save completes after context changes', () => { + const events: { name: string; data: IStringDictionary | undefined }[] = []; + const context = { chatSessionId: 'chat-original', agentSessionId: 'owner-original' }; + const session = new ModelPickerTelemetrySession(upcastPartial({ + publicLog2: (name: string, data?: IStringDictionary) => events.push({ name, data }), + }), new NullLanguageModelsService(), { entryPoint: 'configuration', inputMethod: 'mouse' }, model, context, 'copilotcli'); + context.chatSessionId = 'chat-new'; + context.agentSessionId = 'owner-new'; + session.logModelChange(model, otherModel); + session.logConfigurationChange(model, 'navigation', 'reasoningEffort', 'medium', 'high', Date.now()); + assert.deepStrictEqual(events.map(event => ({ + name: event.name, + chatSessionId: event.data?.chatSessionId, + agentSessionId: event.data?.agentSessionId, + })), ['chat.modelPickerOpened', 'chat.modelChange', 'chat.thinkingEffortChange'].map(name => ({ + name, chatSessionId: 'chat-original', agentSessionId: 'owner-original', + }))); + }); + test('dismissing during a pending configuration save still reports the change before the close', async () => { const saved = new DeferredPromise(); const result = createPicker(model, () => saved.p); @@ -898,7 +915,7 @@ suite('ModelPickerTelemetry', () => { const relayedAuto = relayed(autoModel); const relayedModel = relayed(model); const result = createPicker(relayedAuto, undefined, [relayedAuto, relayedModel]); - result.toggleAuto(); + result.selectItem(relayedModel.metadata.name); assert.deepStrictEqual(result.events, [{ name: 'chat.modelChange', data: { @@ -909,38 +926,38 @@ suite('ModelPickerTelemetry', () => { }]); }); - test('tabbed Auto toggles report the current previous model while the popup stays open', () => { + test('choosing Auto from the list reports the current previous model each time', () => { const result = createPicker(); - result.toggleAuto(); - result.toggleAuto(); - result.toggleAuto(); - assert.deepStrictEqual(result.events, [modelChange(model, autoModel), modelChange(autoModel, model), modelChange(model, autoModel)]); + result.selectItem(autoModel.metadata.name); + result.picker.show(result.container); + result.selectItem(model.metadata.name); + result.picker.show(result.container); + result.selectItem(autoModel.metadata.name); + assert.deepStrictEqual(result.events, [modelChange(model, autoModel, 0), modelChange(autoModel, model, 1), modelChange(model, autoModel, 2)]); }); - test('activating the remembered Auto tier only reports switching to Auto', async () => { + test('activating Auto\'s current tier from its Details only reports switching to Auto', async () => { const result = createPicker(); - result.toggleAuto(); - result.selectItem('Balance'); + option(result.showCard(autoModel.metadata.name), 'Balance').click(); await timeout(0); assert.deepStrictEqual(result.events, [modelChange(model, autoModel)]); }); - test('activating the current Auto tier while already enabled does not report a change', async () => { + test('activating the current Auto tier while Auto is selected does not report a change', async () => { const result = createPicker(autoModel); - result.selectItem('Balance'); + option(result.showCard(autoModel.metadata.name), 'Balance').click(); await timeout(0); assert.deepStrictEqual(result.events, []); }); - test('enabling Auto and then changing the tier reports each change once', async () => { + test('choosing another Auto tier reports the tier and the switch to Auto once each', async () => { const result = createPicker(); - result.toggleAuto(); - result.selectItem('Intelligence'); + option(result.showCard(autoModel.metadata.name), 'Intelligence').click(); await timeout(0); - assert.deepStrictEqual(result.events, [modelChange(model, autoModel), { + assert.deepStrictEqual(result.events, [{ name: 'chat.thinkingEffortChange', data: { model: new TelemetryTrustedValue(autoModel.identifier), property: 'tier', fromValue: 'balance', toValue: 'intelligence', pickerSessionId: 0 }, - }]); + }, modelChange(model, autoModel)]); }); test('configuring another model reports both the configuration and model change', async () => { @@ -1002,8 +1019,7 @@ suite('ModelPickerTelemetry', () => { }); option(result.showCard(otherModel.metadata.name), 'High').click(); if (latest === 'Auto') { - result.backToModels(); - result.toggleAuto(); + option(result.showCard(autoModel.metadata.name), 'Balance').click(); } else { option(result.showCard(model.metadata.name), 'High').click(); } diff --git a/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts b/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts index 894c27ba706168..4dcb68050e592b 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts @@ -81,6 +81,7 @@ import { AGENT_DEBUG_LOG_FILE_LOGGING_ENABLED_SETTING, COPILOT_SKILL_URI_SCHEME, import { ChatRequestSlashPromptPart } from '../../../common/requestParser/chatParserTypes.js'; import { NullLanguageModelsService } from '../languageModels.js'; import { ICanvasContext } from '../../../../canvases/common/canvas.js'; +import { IAgentHostConnectionsService } from '../../../../../../platform/agentHost/common/agentHostConnectionsService.js'; const chatAgentWithUsedContextId = 'ChatProviderWithUsedContext'; const chatAgentWithUsedContext: IChatAgent = { @@ -213,6 +214,7 @@ suite('ChatService', () => { instantiationService.stub(IConfigurationService, new TestConfigurationService()); instantiationService.stub(IChatService, new MockChatService()); instantiationService.stub(IChatSessionsService, new MockChatSessionsService()); + instantiationService.stub(IAgentHostConnectionsService, { resolveSessionResourceIdentity: () => undefined }); instantiationService.stub(ILanguageModelsService, new NullLanguageModelsService()); instantiationService.stub(IEnvironmentService, { workspaceStorageHome: URI.file('/test/path/to/workspaceStorage') }); instantiationService.stub(ILifecycleService, { onWillShutdown: Event.None }); @@ -415,6 +417,70 @@ suite('ChatService', () => { } } + for (const adopt of [false, true]) { + test(`correlates remote pending requests through ${adopt ? 'adoption between owners' : 'send and completion'}`, async () => { + const sessionType = 'remote-private-host-copilotcli'; + const source = URI.from({ scheme: sessionType, path: '/source', fragment: 'peer-one' }); + const target = URI.from({ scheme: sessionType, path: '/target', fragment: 'peer-two' }); + const sessions = new MockChatSessionsService(); + sessions.setContributions([{ type: sessionType, name: 'Remote', displayName: 'Remote', description: '', agentHostProviderId: 'copilotcli' }]); + testDisposables.add(sessions.registerChatSessionContentProvider(sessionType, { + provideChatSessionContent: async resource => ({ sessionResource: resource, history: [], onWillDispose: Event.None, dispose() { } }), + })); + instantiationService.stub(IChatSessionsService, sessions); + instantiationService.stub(IAgentHostConnectionsService, { + resolveSessionResourceIdentity: resource => ({ connectionAuthority: 'private-host', backendSession: resource.with({ scheme: 'copilotcli', fragment: '' }) }), + }); + const events: Record[] = []; + instantiationService.stub(ITelemetryService, { + publicLog2: (name, data) => { + if (name === 'chat.pendingRequestChange' && data) { + events.push(data); + } + }, + }); + const started = new DeferredPromise(); + const complete = new DeferredPromise(); + testDisposables.add(chatAgentService.registerAgent(sessionType, { ...getAgentData(sessionType), isDefault: true })); + testDisposables.add(chatAgentService.registerAgentImplementation(sessionType, { + async invoke() { started.complete(); await complete.p; return {}; }, + })); + const service = createChatService(); + const sourceRef = await service.acquireOrLoadSession(source, ChatAgentLocation.Chat, CancellationToken.None); + assert.ok(sourceRef); + testDisposables.add(sourceRef); + const targetRef = await service.acquireOrLoadSession(target, ChatAgentLocation.Chat, CancellationToken.None); + assert.ok(targetRef); + testDisposables.add(targetRef); + const sent = await service.sendRequest(source, 'test', { agentId: sessionType }); + ChatSendResult.assertSent(sent); + try { + await started.p; + if (adopt) { + const request = sourceRef.object.getRequests().at(-1); + assert.ok(request); + await service.adoptRequest(target, request); + } + } finally { + complete.complete(); + await sent.data.responseCompletePromise; + } + const selected = events.filter(event => adopt ? event.source === 'adoptRequest' : event.source === 'sendRequest' || event.source === 'sendRequestComplete'); + assert.deepStrictEqual(selected.map(event => ({ + action: event.action, source: event.source, agentSessionId: event.agentSessionId, chatSessionId: event.chatSessionId, + })), adopt ? [ + { action: 'remove', source: 'adoptRequest', agentSessionId: 'source', chatSessionId: 'source#peer-one' }, + { action: 'add', source: 'adoptRequest', agentSessionId: 'target', chatSessionId: 'target#peer-two' }, + ] : [ + { action: 'add', source: 'sendRequest', agentSessionId: 'source', chatSessionId: 'source#peer-one' }, + { action: 'remove', source: 'sendRequestComplete', agentSessionId: 'source', chatSessionId: 'source#peer-one' }, + ]); + if (adopt) { + await service.cancelCurrentRequestForSession(target, 'testCleanup'); + } + }); + } + test('propagates Agents Voice Mode input to the participant request', async () => { const captured = new DeferredPromise(); testDisposables.add(chatAgentService.registerAgent('voiceAgent', getAgentData('voiceAgent'))); @@ -4858,6 +4924,36 @@ suite('ChatService', () => { assert.strictEqual(interruptCalls, 1, 'Interrupt callback should be invoked once'); }); + test('correlates Stop with the owning host session and pending request, without counting a no-op Stop', async () => { + const events: { name: string; data: Record | undefined }[] = []; + instantiationService.stub(ITelemetryService, { + publicLog2: (name, data) => events.push({ name, data }), + }); + instantiationService.stub(IAgentHostConnectionsService, { + resolveSessionResourceIdentity: () => ({ connectionAuthority: 'private-host', backendSession: URI.parse('copilotcli:/owner-session') }), + }); + const { resource } = setupRemoteProvider({ + history: [{ type: 'request', id: 'turn-1', prompt: 'hello', participant: remoteScheme }], + progressObs: observableValue('progress', []), + isCompleteObs: observableValue('isComplete', false), + interruptActiveResponseCallback: async () => true, + }); + const service = createChatService(); + const ref = await service.acquireOrLoadSession(resource, ChatAgentLocation.Chat, CancellationToken.None); + assert.ok(ref); + testDisposables.add(ref); + await service.cancelCurrentRequestForSession(resource, 'cancelAction'); + await service.cancelCurrentRequestForSession(resource, 'cancelAction'); + assert.deepStrictEqual(events.filter(event => event.name === 'chat.pendingRequestChange'), [{ + name: 'chat.pendingRequestChange', + data: { action: 'add', source: 'remoteSession', chatSessionId: resource.path.slice(1), agentSessionId: 'owner-session' }, + }, { + name: 'chat.pendingRequestChange', + data: { action: 'remove', source: 'cancelAction', requestId: 'turn-1', chatSessionId: resource.path.slice(1), agentSessionId: 'owner-session' }, + }]); + assert.strictEqual(events.filter(event => event.name === 'chat.stopCancellationNoop').length, 1); + }); + test('transition of isCompleteObs to true clears pending request and completes response', async () => { const progressObs = observableValue('progress', []); const isCompleteObs = observableValue('isComplete', false); diff --git a/src/vs/workbench/contrib/customEditor/browser/customEditorInput.ts b/src/vs/workbench/contrib/customEditor/browser/customEditorInput.ts index 1819d6a2826f36..dc98e06256aea8 100644 --- a/src/vs/workbench/contrib/customEditor/browser/customEditorInput.ts +++ b/src/vs/workbench/contrib/customEditor/browser/customEditorInput.ts @@ -10,6 +10,7 @@ import { VSBuffer } from '../../../../base/common/buffer.js'; import { IMarkdownString } from '../../../../base/common/htmlContent.js'; import { IReference } from '../../../../base/common/lifecycle.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { Emitter } from '../../../../base/common/event.js'; import { Range } from '../../../../editor/common/core/range.js'; import { Selection, SelectionDirection } from '../../../../editor/common/core/selection.js'; import { Schemas } from '../../../../base/common/network.js'; @@ -49,7 +50,20 @@ interface CustomEditorInputInitInfo { export class CustomEditorInput extends LazilyResolvedWebviewEditorInput { - public navigation: ICustomTextEditorNavigation | undefined; + private readonly _onDidChangeNavigation = this._register(new Emitter()); + readonly onDidChangeNavigation = this._onDidChangeNavigation.event; + private _navigation: ICustomTextEditorNavigation | undefined; + + get navigation(): ICustomTextEditorNavigation | undefined { + return this._navigation; + } + + set navigation(value: ICustomTextEditorNavigation | undefined) { + if (this._navigation !== value) { + this._navigation = value; + this._onDidChangeNavigation.fire(); + } + } public override async applyOptions(options: ITextEditorOptions, token: CancellationToken): Promise { const selection = options.selection; diff --git a/src/vs/workbench/contrib/debug/browser/callStackView.ts b/src/vs/workbench/contrib/debug/browser/callStackView.ts index 35c9c1e5175b4b..5fb4b6fbbd605e 100644 --- a/src/vs/workbench/contrib/debug/browser/callStackView.ts +++ b/src/vs/workbench/contrib/debug/browser/callStackView.ts @@ -44,7 +44,7 @@ import { IThemeService } from '../../../../platform/theme/common/themeService.js import { ViewAction, ViewPane } from '../../../browser/parts/views/viewPane.js'; import { IViewletViewOptions } from '../../../browser/parts/views/viewsViewlet.js'; import { IViewDescriptorService } from '../../../common/views.js'; -import { CALLSTACK_VIEW_ID, CONTEXT_CALLSTACK_FOCUSED, CONTEXT_CALLSTACK_ITEM_STOPPED, CONTEXT_CALLSTACK_ITEM_TYPE, CONTEXT_CALLSTACK_SESSION_HAS_ONE_THREAD, CONTEXT_CALLSTACK_SESSION_IS_ATTACH, CONTEXT_DEBUG_STATE, CONTEXT_FOCUSED_SESSION_IS_NO_DEBUG, CONTEXT_STACK_FRAME_SUPPORTS_RESTART, getStateLabel, IDebugModel, IDebugService, IDebugSession, IRawStoppedDetails, isFrameDeemphasized, IStackFrame, IThread, State } from '../common/debug.js'; +import { CALLSTACK_VIEW_ID, CONTEXT_CALLSTACK_FOCUSED, CONTEXT_CALLSTACK_ITEM_STOPPED, CONTEXT_CALLSTACK_ITEM_TYPE, CONTEXT_CALLSTACK_SESSION_HAS_ONE_THREAD, CONTEXT_CALLSTACK_SESSION_IS_ATTACH, CONTEXT_DEBUG_STATE, CONTEXT_FOCUSED_SESSION_IS_NO_DEBUG, CONTEXT_THREAD_HAS_MULTIPLE_STACK_FRAMES, CONTEXT_STACK_FRAME_SUPPORTS_RESTART, getStateLabel, hasMultipleStackFrames, IDebugModel, IDebugService, IDebugSession, IRawStoppedDetails, isFrameDeemphasized, IStackFrame, IThread, State } from '../common/debug.js'; import { StackFrame, Thread, ThreadAndSessionIds } from '../common/debugModel.js'; import { isSessionAttach } from '../common/debugUtils.js'; import { renderViewTree } from './baseDebugView.js'; @@ -523,12 +523,15 @@ interface IStackFrameTemplateData { elementDisposables: DisposableStore; } -function getSessionContextOverlay(session: IDebugSession): [string, ContextKeyValue][] { +export function getSessionContextOverlay(session: IDebugSession): [string, ContextKeyValue][] { + const threads = session.getAllThreads(); return [ [CONTEXT_CALLSTACK_ITEM_TYPE.key, 'session'], [CONTEXT_CALLSTACK_SESSION_IS_ATTACH.key, isSessionAttach(session)], [CONTEXT_CALLSTACK_ITEM_STOPPED.key, session.state === State.Stopped], - [CONTEXT_CALLSTACK_SESSION_HAS_ONE_THREAD.key, session.getAllThreads().length === 1], + [CONTEXT_CALLSTACK_SESSION_HAS_ONE_THREAD.key, threads.length === 1], + // The session row can target a thread only when the session has exactly one. + [CONTEXT_THREAD_HAS_MULTIPLE_STACK_FRAMES.key, hasMultipleStackFrames(threads.length === 1 ? threads[0] : undefined)], ]; } @@ -639,10 +642,11 @@ class SessionsRenderer implements ICompressibleTreeRenderer { const contextKeyService = accessor.get(IContextKeyService); if (CONTEXT_DISASSEMBLY_VIEW_FOCUS.getValue(contextKeyService)) { diff --git a/src/vs/workbench/contrib/debug/browser/debugService.ts b/src/vs/workbench/contrib/debug/browser/debugService.ts index 4bdacd758f281f..d7bac98d243f78 100644 --- a/src/vs/workbench/contrib/debug/browser/debugService.ts +++ b/src/vs/workbench/contrib/debug/browser/debugService.ts @@ -175,6 +175,7 @@ export class DebugService implements IDebugService { this.debugStorage.storeDebugUxState(debugUxValue); })); this.disposables.add(this.model.onDidChangeCallStack(() => { + this.viewModel.updateFocusedThreadHasMultipleStackFrames(); const numberOfSessions = this.model.getSessions().filter(s => !s.parentSession).length; this.activity?.dispose(); if (numberOfSessions > 0) { diff --git a/src/vs/workbench/contrib/debug/browser/debugToolBar.ts b/src/vs/workbench/contrib/debug/browser/debugToolBar.ts index a801205ecc74a4..1470fed4d7e468 100644 --- a/src/vs/workbench/contrib/debug/browser/debugToolBar.ts +++ b/src/vs/workbench/contrib/debug/browser/debugToolBar.ts @@ -35,7 +35,7 @@ import { IThemeService, Themable } from '../../../../platform/theme/common/theme import { getTitleBarStyle, TitlebarStyle } from '../../../../platform/window/common/window.js'; import { IWorkbenchContribution } from '../../../common/contributions.js'; import { EditorTabsMode, IWorkbenchLayoutService, LayoutSettings, Parts } from '../../../services/layout/browser/layoutService.js'; -import { CONTEXT_DEBUG_STATE, CONTEXT_FOCUSED_SESSION_IS_ATTACH, CONTEXT_FOCUSED_SESSION_IS_NO_DEBUG, CONTEXT_IN_DEBUG_MODE, CONTEXT_MULTI_SESSION_DEBUG, CONTEXT_STEP_BACK_SUPPORTED, CONTEXT_SUSPEND_DEBUGGEE_SUPPORTED, CONTEXT_TERMINATE_DEBUGGEE_SUPPORTED, IDebugConfiguration, IDebugService, State, VIEWLET_ID } from '../common/debug.js'; +import { CONTEXT_DEBUG_STATE, CONTEXT_FOCUSED_SESSION_IS_ATTACH, CONTEXT_FOCUSED_SESSION_IS_NO_DEBUG, CONTEXT_THREAD_HAS_MULTIPLE_STACK_FRAMES, CONTEXT_IN_DEBUG_MODE, CONTEXT_MULTI_SESSION_DEBUG, CONTEXT_STEP_BACK_SUPPORTED, CONTEXT_SUSPEND_DEBUGGEE_SUPPORTED, CONTEXT_TERMINATE_DEBUGGEE_SUPPORTED, IDebugConfiguration, IDebugService, State, VIEWLET_ID } from '../common/debug.js'; import { FocusSessionActionViewItem } from './debugActionViewItems.js'; import { debugToolBarBackground, debugToolBarBorder } from './debugColors.js'; import { CONTINUE_ID, CONTINUE_LABEL, DISCONNECT_AND_SUSPEND_ID, DISCONNECT_AND_SUSPEND_LABEL, DISCONNECT_ID, DISCONNECT_LABEL, FOCUS_SESSION_ID, FOCUS_SESSION_LABEL, PAUSE_ID, PAUSE_LABEL, RESTART_LABEL, RESTART_SESSION_ID, REVERSE_CONTINUE_ID, STEP_BACK_ID, STEP_INTO_ID, STEP_INTO_LABEL, STEP_OUT_ID, STEP_OUT_LABEL, STEP_OVER_ID, STEP_OVER_LABEL, STOP_ID, STOP_LABEL } from './debugCommands.js'; @@ -449,7 +449,7 @@ registerDebugToolBarItem(STOP_ID, STOP_LABEL, 70, icons.debugStop, CONTEXT_FOCUS registerDebugToolBarItem(DISCONNECT_ID, DISCONNECT_LABEL, 70, icons.debugDisconnect, CONTEXT_FOCUSED_SESSION_IS_ATTACH, undefined, { id: STOP_ID, title: STOP_LABEL, icon: icons.debugStop, precondition: ContextKeyExpr.and(CONTEXT_FOCUSED_SESSION_IS_ATTACH, CONTEXT_TERMINATE_DEBUGGEE_SUPPORTED), }); registerDebugToolBarItem(STEP_OVER_ID, STEP_OVER_LABEL, 20, icons.debugStepOver, undefined, CONTEXT_DEBUG_STATE.isEqualTo('stopped')); registerDebugToolBarItem(STEP_INTO_ID, STEP_INTO_LABEL, 30, icons.debugStepInto, undefined, CONTEXT_DEBUG_STATE.isEqualTo('stopped')); -registerDebugToolBarItem(STEP_OUT_ID, STEP_OUT_LABEL, 40, icons.debugStepOut, undefined, CONTEXT_DEBUG_STATE.isEqualTo('stopped')); +registerDebugToolBarItem(STEP_OUT_ID, STEP_OUT_LABEL, 40, icons.debugStepOut, undefined, ContextKeyExpr.and(CONTEXT_DEBUG_STATE.isEqualTo('stopped'), CONTEXT_THREAD_HAS_MULTIPLE_STACK_FRAMES)); registerDebugToolBarItem(RESTART_SESSION_ID, RESTART_LABEL, 60, icons.debugRestart); registerDebugToolBarItem(STEP_BACK_ID, localize('stepBackDebug', "Step Back"), 50, icons.debugStepBack, CONTEXT_STEP_BACK_SUPPORTED, CONTEXT_DEBUG_STATE.isEqualTo('stopped')); registerDebugToolBarItem(REVERSE_CONTINUE_ID, localize('reverseContinue', "Reverse"), 55, icons.debugReverseContinue, CONTEXT_STEP_BACK_SUPPORTED, CONTEXT_DEBUG_STATE.isEqualTo('stopped')); diff --git a/src/vs/workbench/contrib/debug/common/debug.ts b/src/vs/workbench/contrib/debug/common/debug.ts index 61768f3351dc1c..ddbbc6386f66c5 100644 --- a/src/vs/workbench/contrib/debug/common/debug.ts +++ b/src/vs/workbench/contrib/debug/common/debug.ts @@ -105,6 +105,20 @@ export const CONTEXT_DISASSEMBLE_REQUEST_SUPPORTED = new RawContextKey( export const CONTEXT_DISASSEMBLY_VIEW_FOCUS = new RawContextKey('disassemblyViewFocus', false, { type: 'boolean', description: nls.localize('disassemblyViewFocus', "True when the Disassembly View is focused.") }); export const CONTEXT_LANGUAGE_SUPPORTS_DISASSEMBLE_REQUEST = new RawContextKey('languageSupportsDisassembleRequest', false, { type: 'boolean', description: nls.localize('languageSupportsDisassembleRequest', "True when the language in the current editor supports disassemble request.") }); export const CONTEXT_FOCUSED_STACK_FRAME_HAS_INSTRUCTION_POINTER_REFERENCE = new RawContextKey('focusedStackFrameHasInstructionReference', false, { type: 'boolean', description: nls.localize('focusedStackFrameHasInstructionReference', "True when the focused stack frame has instruction pointer reference.") }); +export const CONTEXT_THREAD_HAS_MULTIPLE_STACK_FRAMES = new RawContextKey('threadHasMultipleStackFrames', false, { type: 'boolean', description: nls.localize('threadHasMultipleStackFrames', "True when the target thread has more than one stack frame.") }); + +/** + * Returns whether the thread has a caller frame, treating an incomplete stack with unknown depth as potentially having one. + */ +export function hasMultipleStackFrames(thread: IThread | undefined): boolean { + if (!thread) { + return false; + } + + const loadedFrameCount = thread.getCallStack().length; + const totalFrameCount = thread.stoppedDetails?.totalFrames; + return loadedFrameCount > 1 || (typeof totalFrameCount === 'number' ? totalFrameCount > 1 : !thread.reachedEndOfCallStack); +} export const debuggerDisabledMessage = (debugType: string) => nls.localize('debuggerDisabled', "Configured debug type '{0}' is installed but not supported in this environment.", debugType); @@ -538,6 +552,11 @@ export interface IThread extends ITreeElement { */ readonly stopped: boolean; + /** + * Indicates whether the end of the call stack has been reached. + */ + readonly reachedEndOfCallStack: boolean; + next(granularity?: DebugProtocol.SteppingGranularity): Promise; stepIn(granularity?: DebugProtocol.SteppingGranularity): Promise; stepOut(granularity?: DebugProtocol.SteppingGranularity): Promise; diff --git a/src/vs/workbench/contrib/debug/common/debugModel.ts b/src/vs/workbench/contrib/debug/common/debugModel.ts index 8f2e05a6262aed..39b82190848c59 100644 --- a/src/vs/workbench/contrib/debug/common/debugModel.ts +++ b/src/vs/workbench/contrib/debug/common/debugModel.ts @@ -1661,15 +1661,17 @@ export class DebugModel extends Disposable implements IDebugModel { this.schedulers.set(thread.getId(), { completeDeferred: deferred, scheduler: new RunOnceScheduler(() => { + const wasCallStackComplete = thread.reachedEndOfCallStack; thread.fetchCallStack(19).then(() => { const stale = thread.getStaleCallStack(); const current = thread.getCallStack(); - let bottomOfCallStackChanged = stale.length !== current.length; - for (let i = 1; i < stale.length && !bottomOfCallStackChanged; i++) { - bottomOfCallStackChanged = !stale[i].equals(current[i]); + // A thread's call stack can become complete without adding frames, so fire the change event to notify consumers. + let callStackChanged = stale.length !== current.length || wasCallStackComplete !== thread.reachedEndOfCallStack; + for (let i = 1; i < stale.length && !callStackChanged; i++) { + callStackChanged = !stale[i].equals(current[i]); } - if (bottomOfCallStackChanged) { + if (callStackChanged) { this._onDidChangeCallStack.fire(); } }).finally(() => { diff --git a/src/vs/workbench/contrib/debug/common/debugViewModel.ts b/src/vs/workbench/contrib/debug/common/debugViewModel.ts index 12825128fa77d9..51acd2923f81a2 100644 --- a/src/vs/workbench/contrib/debug/common/debugViewModel.ts +++ b/src/vs/workbench/contrib/debug/common/debugViewModel.ts @@ -6,7 +6,7 @@ import { Emitter, Event } from '../../../../base/common/event.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; -import { CONTEXT_DISASSEMBLE_REQUEST_SUPPORTED, CONTEXT_EXPRESSION_SELECTED, CONTEXT_FOCUSED_SESSION_IS_ATTACH, CONTEXT_FOCUSED_SESSION_IS_NO_DEBUG, CONTEXT_FOCUSED_STACK_FRAME_HAS_INSTRUCTION_POINTER_REFERENCE, CONTEXT_JUMP_TO_CURSOR_SUPPORTED, CONTEXT_LOADED_SCRIPTS_SUPPORTED, CONTEXT_MULTI_SESSION_DEBUG, CONTEXT_RESTART_FRAME_SUPPORTED, CONTEXT_SET_DATA_BREAKPOINT_BYTES_SUPPORTED, CONTEXT_SET_EXPRESSION_SUPPORTED, CONTEXT_SET_VARIABLE_SUPPORTED, CONTEXT_STEP_BACK_SUPPORTED, CONTEXT_STEP_INTO_TARGETS_SUPPORTED, CONTEXT_SUSPEND_DEBUGGEE_SUPPORTED, CONTEXT_TERMINATE_DEBUGGEE_SUPPORTED, CONTEXT_TERMINATE_THREADS_SUPPORTED, IDebugSession, IExpression, IExpressionContainer, IStackFrame, IThread, IViewModel } from './debug.js'; +import { CONTEXT_DISASSEMBLE_REQUEST_SUPPORTED, CONTEXT_EXPRESSION_SELECTED, CONTEXT_FOCUSED_SESSION_IS_ATTACH, CONTEXT_FOCUSED_SESSION_IS_NO_DEBUG, CONTEXT_FOCUSED_STACK_FRAME_HAS_INSTRUCTION_POINTER_REFERENCE, CONTEXT_THREAD_HAS_MULTIPLE_STACK_FRAMES, CONTEXT_JUMP_TO_CURSOR_SUPPORTED, CONTEXT_LOADED_SCRIPTS_SUPPORTED, CONTEXT_MULTI_SESSION_DEBUG, CONTEXT_RESTART_FRAME_SUPPORTED, CONTEXT_SET_DATA_BREAKPOINT_BYTES_SUPPORTED, CONTEXT_SET_EXPRESSION_SUPPORTED, CONTEXT_SET_VARIABLE_SUPPORTED, CONTEXT_STEP_BACK_SUPPORTED, CONTEXT_STEP_INTO_TARGETS_SUPPORTED, CONTEXT_SUSPEND_DEBUGGEE_SUPPORTED, CONTEXT_TERMINATE_DEBUGGEE_SUPPORTED, CONTEXT_TERMINATE_THREADS_SUPPORTED, hasMultipleStackFrames, IDebugSession, IExpression, IExpressionContainer, IStackFrame, IThread, IViewModel } from './debug.js'; import { isSessionAttach } from './debugUtils.js'; export class ViewModel extends Disposable implements IViewModel { @@ -43,6 +43,7 @@ export class ViewModel extends Disposable implements IViewModel { private terminateThreadsSupported!: IContextKey; private disassembleRequestSupported!: IContextKey; private focusedStackFrameHasInstructionPointerReference!: IContextKey; + private threadHasMultipleStackFrames!: IContextKey; constructor(private contextKeyService: IContextKeyService) { super(); @@ -64,6 +65,7 @@ export class ViewModel extends Disposable implements IViewModel { this.terminateThreadsSupported = CONTEXT_TERMINATE_THREADS_SUPPORTED.bindTo(contextKeyService); this.disassembleRequestSupported = CONTEXT_DISASSEMBLE_REQUEST_SUPPORTED.bindTo(contextKeyService); this.focusedStackFrameHasInstructionPointerReference = CONTEXT_FOCUSED_STACK_FRAME_HAS_INSTRUCTION_POINTER_REFERENCE.bindTo(contextKeyService); + this.threadHasMultipleStackFrames = CONTEXT_THREAD_HAS_MULTIPLE_STACK_FRAMES.bindTo(contextKeyService); }); } @@ -107,6 +109,7 @@ export class ViewModel extends Disposable implements IViewModel { this.terminateThreadsSupported.set(!!session?.capabilities.supportsTerminateThreadsRequest); this.disassembleRequestSupported.set(!!session?.capabilities.supportsDisassembleRequest); this.focusedStackFrameHasInstructionPointerReference.set(!!stackFrame?.instructionPointerReference); + this.updateFocusedThreadHasMultipleStackFrames(); const attach = !!session && isSessionAttach(session); this.focusedSessionIsAttach.set(attach); this.focusedSessionIsNoDebug.set(!!session && !!session.configuration.noDebug); @@ -124,6 +127,10 @@ export class ViewModel extends Disposable implements IViewModel { } } + updateFocusedThreadHasMultipleStackFrames(): void { + this.threadHasMultipleStackFrames.set(hasMultipleStackFrames(this._focusedThread)); + } + get onDidFocusSession(): Event { return this._onDidFocusSession.event; } diff --git a/src/vs/workbench/contrib/debug/test/browser/callStack.test.ts b/src/vs/workbench/contrib/debug/test/browser/callStack.test.ts index 0fa197d48128d5..f556e3b597d81f 100644 --- a/src/vs/workbench/contrib/debug/test/browser/callStack.test.ts +++ b/src/vs/workbench/contrib/debug/test/browser/callStack.test.ts @@ -17,11 +17,11 @@ import { TestInstantiationService } from '../../../../../platform/instantiation/ import { NullLogService } from '../../../../../platform/log/common/log.js'; import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; import { createDecorationsForStackFrame } from '../../browser/callStackEditorContribution.js'; -import { getContext, getContextForContributedActions, getSpecificSourceName } from '../../browser/callStackView.js'; +import { getContext, getContextForContributedActions, getSessionContextOverlay, getSpecificSourceName, getThreadContextOverlay } from '../../browser/callStackView.js'; import { debugStackframe, debugStackframeFocused } from '../../browser/debugIcons.js'; import { getStackFrameThreadAndSessionToFocus } from '../../browser/debugService.js'; import { DebugSession } from '../../browser/debugSession.js'; -import { IDebugService, IDebugSessionOptions, State } from '../../common/debug.js'; +import { CONTEXT_THREAD_HAS_MULTIPLE_STACK_FRAMES, IDebugService, IDebugSessionOptions, State } from '../../common/debug.js'; import { DebugModel, StackFrame, Thread } from '../../common/debugModel.js'; import { Source } from '../../common/debugSource.js'; import { MockRawSession } from '../common/mockDebug.js'; @@ -48,6 +48,65 @@ export function createTestSession(model: DebugModel, name = 'mockSession', optio } as IDebugService, undefined!, undefined!, new TestConfigurationService({ debug: { console: { collapseIdenticalLines: true } } }), undefined!, mockWorkspaceContextService, undefined!, undefined!, undefined!, mockUriIdentityService, new TestInstantiationService(), undefined!, undefined!, new NullLogService(), undefined!, undefined!, new TestAccessibilityService()); } +function createSingleStackFrame(session: DebugSession): StackFrame { + const stackFrames: StackFrame[] = []; + const thread = new class extends Thread { + public override getCallStack(): StackFrame[] { + return stackFrames; + } + }(session, 'mockthread', 1); + + const source = new Source({ + name: 'internalModule.js', + path: 'a/b/c/d/internalModule.js', + sourceReference: 10, + }, 'aDebugSessionId', mockUriIdentityService, new NullLogService()); + const stackFrame = new StackFrame(thread, 0, source, 'app.js', 'normal', { startLineNumber: 1, startColumn: 2, endLineNumber: 1, endColumn: 10 }, 0, true); + stackFrames.push(stackFrame); + // The adapter confirms this is the only frame in the stack. + thread.stoppedDetails = { totalFrames: 1 }; + return stackFrame; +} + +function createPartiallyLoadedMultipleFrameStack(session: DebugSession): Thread { + const stackFrames: StackFrame[] = []; + const thread = new class extends Thread { + public override getCallStack(): StackFrame[] { + return stackFrames; + } + }(session, 'mockthread', 1); + + const source = new Source({ + name: 'internalModule.js', + path: 'z/x/c/d/internalModule.js', + sourceReference: 11, + }, 'aDebugSessionId', mockUriIdentityService, new NullLogService()); + const loadedFrame = new StackFrame(thread, 0, source, 'app2.js', 'normal', { startLineNumber: 1, startColumn: 2, endLineNumber: 1, endColumn: 10 }, 0, true); + stackFrames.push(loadedFrame); + // The adapter reports two frames, but only the top frame has been loaded so far. + thread.stoppedDetails = { totalFrames: 2 }; + return thread; +} + +function createPartiallyLoadedStackWithUnknownDepth(session: DebugSession): Thread { + const stackFrames: StackFrame[] = []; + const thread = new class extends Thread { + public override getCallStack(): StackFrame[] { + return stackFrames; + } + }(session, 'mockthread', 1); + + const source = new Source({ + name: 'internalModule.js', + path: 'z/x/c/d/internalModule.js', + sourceReference: 11, + }, 'aDebugSessionId', mockUriIdentityService, new NullLogService()); + const loadedFrame = new StackFrame(thread, 0, source, 'app2.js', 'normal', { startLineNumber: 1, startColumn: 2, endLineNumber: 1, endColumn: 10 }, 0, true); + stackFrames.push(loadedFrame); + // The adapter does not provide totalFrames, so the stack depth is still unknown. + return thread; +} + function createTwoStackFrames(session: DebugSession): { firstStackFrame: StackFrame; secondStackFrame: StackFrame } { const thread = new class extends Thread { public override getCallStack(): StackFrame[] { @@ -464,6 +523,42 @@ suite('Debug - CallStack', () => { assert.strictEqual(contributedContext, session.getId()); }); + test('item-level stack depth contexts', () => { + const session = createTestSession(model); + disposables.add(session); + model.addSession(session); + + const singleFrame = createSingleStackFrame(session); + const partiallyLoadedMultipleFrameStack = createPartiallyLoadedMultipleFrameStack(session); + + const key = CONTEXT_THREAD_HAS_MULTIPLE_STACK_FRAMES.key; + const stackDepth = (overlay: ReturnType) => overlay.find(([contextKey]) => contextKey === key)?.[1]; + + assert.strictEqual(stackDepth(getThreadContextOverlay(singleFrame.thread)), false); + assert.strictEqual(stackDepth(getThreadContextOverlay(partiallyLoadedMultipleFrameStack)), true); + + const partiallyLoadedUnknownDepthStack = createPartiallyLoadedStackWithUnknownDepth(session); + assert.strictEqual(stackDepth(getThreadContextOverlay(partiallyLoadedUnknownDepthStack)), true); + + partiallyLoadedUnknownDepthStack.reachedEndOfCallStack = true; + assert.strictEqual(stackDepth(getThreadContextOverlay(partiallyLoadedUnknownDepthStack)), false); + + model.rawUpdate({ + sessionId: session.getId(), + threads: [{ + id: 1, + name: 'partiallyLoadedThread' + }], + stoppedDetails: { + reason: 'breakpoint', + threadId: 1, + totalFrames: 2, + allThreadsStopped: true + } + }); + assert.strictEqual(stackDepth(getSessionContextOverlay(session)), true); + }); + test('focusStackFrameThreadAndSession', () => { const threadId1 = 1; const threadName1 = 'firstThread'; diff --git a/src/vs/workbench/contrib/debug/test/browser/debugViewModel.test.ts b/src/vs/workbench/contrib/debug/test/browser/debugViewModel.test.ts index a179d8fcd74c1f..6323ce03c30b8a 100644 --- a/src/vs/workbench/contrib/debug/test/browser/debugViewModel.test.ts +++ b/src/vs/workbench/contrib/debug/test/browser/debugViewModel.test.ts @@ -7,6 +7,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { MockContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js'; import { NullLogService } from '../../../../../platform/log/common/log.js'; +import { CONTEXT_THREAD_HAS_MULTIPLE_STACK_FRAMES } from '../../common/debug.js'; import { Expression, StackFrame, Thread } from '../../common/debugModel.js'; import { Source } from '../../common/debugSource.js'; import { ViewModel } from '../../common/debugViewModel.js'; @@ -15,16 +16,18 @@ import { MockSession } from '../common/mockDebug.js'; suite('Debug - View Model', () => { let model: ViewModel; + let contextKeyService: MockContextKeyService; setup(() => { - model = new ViewModel(new MockContextKeyService()); + contextKeyService = new MockContextKeyService(); + model = new ViewModel(contextKeyService); }); teardown(() => { model.dispose(); }); - ensureNoDisposablesAreLeakedInTestSuite(); + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); test('focused stack frame', () => { assert.strictEqual(model.focusedStackFrame, undefined); @@ -52,6 +55,75 @@ suite('Debug - View Model', () => { assert.strictEqual(model.getSelectedExpression()?.expression, expression); }); + test('focused thread stack depth refreshes without changing focus', () => { + const session = new MockSession(); + const thread = new Thread(session, 'myThread', 1); + thread.stoppedDetails = { totalFrames: 2 }; + model.setFocus(undefined, thread, session, false); + + let focusEventCount = 0; + disposables.add(model.onDidFocusThread(() => focusEventCount++)); + disposables.add(model.onDidFocusStackFrame(() => focusEventCount++)); + disposables.add(model.onDidFocusSession(() => focusEventCount++)); + + const stackDepth = () => contextKeyService.getContextKeyValue(CONTEXT_THREAD_HAS_MULTIPLE_STACK_FRAMES.key); + const states = [stackDepth()]; + thread.stoppedDetails.totalFrames = 1; + model.updateFocusedThreadHasMultipleStackFrames(); + states.push(stackDepth()); + thread.stoppedDetails.totalFrames = 2; + model.updateFocusedThreadHasMultipleStackFrames(); + states.push(stackDepth()); + + assert.deepStrictEqual({ states, focusEventCount, focusedThread: model.focusedThread }, { + states: [true, false, true], + focusEventCount: 0, + focusedThread: thread + }); + }); + + test('focused thread stack depth refreshes when an unknown stack becomes complete', () => { + const session = new MockSession(); + const thread = new Thread(session, 'myThread', 1); + const source = new Source({ name: 'app.js', sourceReference: 1 }, session.getId(), mockUriIdentityService, new NullLogService()); + thread.getCallStack().push(new StackFrame(thread, 1, source, 'app.js', 'normal', { startColumn: 1, startLineNumber: 1, endColumn: 1, endLineNumber: 1 }, 0, true)); + model.setFocus(undefined, thread, session, false); + + const stackDepth = () => contextKeyService.getContextKeyValue(CONTEXT_THREAD_HAS_MULTIPLE_STACK_FRAMES.key); + const states = [stackDepth()]; + thread.reachedEndOfCallStack = true; + model.updateFocusedThreadHasMultipleStackFrames(); + states.push(stackDepth()); + + assert.deepStrictEqual(states, [true, false]); + }); + + test('focused thread stack depth follows focus changes and resets when focus clears', () => { + const session = new MockSession(); + const firstThread = new Thread(session, 'firstThread', 1); + firstThread.stoppedDetails = { totalFrames: 1 }; + const secondThread = new Thread(session, 'secondThread', 2); + secondThread.stoppedDetails = { totalFrames: 2 }; + + const stackDepth = () => contextKeyService.getContextKeyValue(CONTEXT_THREAD_HAS_MULTIPLE_STACK_FRAMES.key); + const states = [stackDepth()]; + model.setFocus(undefined, firstThread, session, false); + states.push(stackDepth()); + model.setFocus(undefined, secondThread, session, false); + states.push(stackDepth()); + firstThread.stoppedDetails.totalFrames = 2; + secondThread.stoppedDetails.totalFrames = 1; + model.updateFocusedThreadHasMultipleStackFrames(); + states.push(stackDepth()); + model.setFocus(undefined, undefined, undefined, false); + states.push(stackDepth()); + secondThread.stoppedDetails.totalFrames = 2; + model.updateFocusedThreadHasMultipleStackFrames(); + states.push(stackDepth()); + + assert.deepStrictEqual(states, [false, false, true, false, false, false]); + }); + test('multi session view and changed workbench state', () => { assert.strictEqual(model.isMultiSessionView(), false); model.setMultiSessionView(true); diff --git a/src/vs/workbench/contrib/debug/test/common/debugModel.test.ts b/src/vs/workbench/contrib/debug/test/common/debugModel.test.ts index bf9cd339455248..53213840de23b8 100644 --- a/src/vs/workbench/contrib/debug/test/common/debugModel.test.ts +++ b/src/vs/workbench/contrib/debug/test/common/debugModel.test.ts @@ -4,14 +4,14 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { DeferredPromise } from '../../../../../base/common/async.js'; +import { DeferredPromise, timeout } from '../../../../../base/common/async.js'; import { DisposableStore } from '../../../../../base/common/lifecycle.js'; import { mockObject, upcastDeepPartial, upcastPartial } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { NullLogService } from '../../../../../platform/log/common/log.js'; import { ITextFileService } from '../../../../services/textfile/common/textfiles.js'; import { TestStorageService } from '../../../../test/common/workbenchTestServices.js'; -import { IDebugSession, IInstructionBreakpoint } from '../../common/debug.js'; +import { IDebugSession, IInstructionBreakpoint, IStackFrame } from '../../common/debug.js'; import { DebugModel, ExceptionBreakpoint, FunctionBreakpoint, Thread } from '../../common/debugModel.js'; import { MockDebugStorage } from './mockDebug.js'; import { runWithFakedTimers } from '../../../../../base/test/common/timeTravelScheduler.js'; @@ -159,6 +159,42 @@ suite('DebugModel', () => { }); suite('DebugModel', () => { + test('refreshTopOfCallstack notifies when a repeated stop confirms the stack is complete without changing frames', async () => { + await runWithFakedTimers({}, async () => { + const frame = upcastPartial({}); + const fakeThread = upcastPartial({ + session: upcastDeepPartial({ capabilities: { supportsDelayedStackTraceLoading: true } }), + getCallStack: () => [frame], + getStaleCallStack: () => [frame], + reachedEndOfCallStack: false, + fetchCallStack: async (levels: number) => { + if (levels > 1) { + fakeThread.reachedEndOfCallStack = true; + } + }, + getId: () => '1', + }); + + const disposable = new DisposableStore(); + const storage = disposable.add(new TestStorageService()); + const model = new DebugModel(disposable.add(new MockDebugStorage(storage)), upcastPartial({ isDirty: (_: unknown) => false }), undefined!, new NullLogService()); + disposable.add(model); + + let callStackChangeCount = 0; + disposable.add(model.onDidChangeCallStack(() => callStackChangeCount++)); + const refresh = model.refreshTopOfCallstack(fakeThread); + await refresh.topCallStack; + assert.strictEqual(callStackChangeCount, 1); + + // Match the 420 ms delay hard-coded in refreshTopOfCallstack before it fetches the rest of the stack. + await timeout(420); + await refresh.wholeCallStack; + assert.strictEqual(callStackChangeCount, 2); + + disposable.dispose(); + }); + }); + test('refreshTopOfCallstack resolves all returned promises when called multiple times', async () => { return runWithFakedTimers({}, async () => { const topFrameDeferred = new DeferredPromise(); diff --git a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/agentHostEditMarkerService.ts b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/agentHostEditMarkerService.ts index 7d95ac4931e432..1bb26b3525b3de 100644 --- a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/agentHostEditMarkerService.ts +++ b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/agentHostEditMarkerService.ts @@ -580,6 +580,7 @@ export class AgentHostEditMarkerService extends Disposable implements IAgentHost candidate !== undefined && candidate.modelId === firstSource.modelId && candidate.autoTier === firstSource.autoTier && + candidate.agentSessionId === firstSource.agentSessionId && (firstSource.chatSessionId === undefined || candidate.conversationId === firstSource.conversationId) && candidate.chatSessionId === firstSource.chatSessionId && candidate.harness === firstSource.harness @@ -587,6 +588,7 @@ export class AgentHostEditMarkerService extends Disposable implements IAgentHost modelId: firstSource.modelId, autoTier: firstSource.autoTier, sessionId: firstSource.conversationId, + agentSessionId: firstSource.agentSessionId, chatSessionId: firstSource.chatSessionId, requestId: firstSource.requestId, harness: firstSource.harness, diff --git a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/aiEditTelemetry/aiEditTelemetryService.ts b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/aiEditTelemetry/aiEditTelemetryService.ts index 868d9ba5d2510b..72f6bedb0fff7a 100644 --- a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/aiEditTelemetry/aiEditTelemetryService.ts +++ b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/aiEditTelemetry/aiEditTelemetryService.ts @@ -76,9 +76,11 @@ export interface IEditTelemetryCodeSuggestedData extends IEditTelemetryBaseData } export interface IEditTelemetryCodeAcceptedData extends IEditTelemetryBaseData { + /** Owning Agent Host session ID, omitted for suggestions without an Agent Host owner. */ + agentSessionId?: string; /** The agent implementation that produced the code block. */ provider?: string; - /** Canonical chat session ID for code-block actions, matching chat request telemetry. */ + /** Canonical workbench chat ID, matching chat request telemetry. */ chatSessionId?: string; acceptanceMethod: @@ -95,6 +97,8 @@ export interface IEditTelemetryCodeAcceptedData extends IEditTelemetryBaseData { } export interface IEditTelemetryCodeRejectedData extends IEditTelemetryBaseData { + agentSessionId?: string; + chatSessionId?: string; rejectionMethod: /** User explicitly rejected/undid the suggestion (when presentation is `highlightedEdit`) or pressed Escape (when feature is `inlineSuggestion`) */ | 'reject'; diff --git a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/aiEditTelemetry/aiEditTelemetryServiceImpl.ts b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/aiEditTelemetry/aiEditTelemetryServiceImpl.ts index 732a90afc7c843..6ade2846c19e55 100644 --- a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/aiEditTelemetry/aiEditTelemetryServiceImpl.ts +++ b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/aiEditTelemetry/aiEditTelemetryServiceImpl.ts @@ -128,6 +128,7 @@ export class AiEditTelemetryServiceImpl implements IAiEditTelemetryService { isAgentHostSession: boolean | undefined; chatSessionId: string | undefined; + agentSessionId: string | undefined; provider: string | undefined; acceptanceMethod: | 'insertAtCursor' @@ -162,7 +163,8 @@ export class AiEditTelemetryServiceImpl implements IAiEditTelemetryService { applyCodeBlockSuggestionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'If this suggestion is for applying a suggested code block, this is the id of the suggested code block.' }; sourceRequestId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The chat request ID that produced the edit, for correlating accepts/rejects with specific requests.' }; isAgentHostSession: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether the edit was generated by an Agent Host-backed chat session.' }; - chatSessionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The canonical chat session identifier for a code-block action, matching interactiveSessionProviderInvoked.chatSessionId.' }; + chatSessionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The canonical workbench chat identifier for code-block or review actions, matching interactiveSessionProviderInvoked.chatSessionId.' }; + agentSessionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The owning Agent Host session identifier, when the chat has a resolved backend session.' }; provider: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Identifies the agent implementation that produced the suggestion, such as copilotcli, claude, or codex.' }; acceptanceMethod: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'How the user accepted the code suggestion. See #IEditTelemetryCodeAcceptedData.acceptanceMethod for possible values.' }; }>('editTelemetry.codeAccepted', { @@ -187,6 +189,7 @@ export class AiEditTelemetryServiceImpl implements IAiEditTelemetryService { sourceRequestId: data.sourceRequestId, isAgentHostSession: data.isAgentHostSession, chatSessionId: data.chatSessionId, + agentSessionId: data.agentSessionId, provider: data.provider || (data.isAgentHostSession ? 'unknown' : undefined), acceptanceMethod: data.acceptanceMethod, @@ -219,6 +222,8 @@ export class AiEditTelemetryServiceImpl implements IAiEditTelemetryService { isAgentHostSession: boolean | undefined; rejectionMethod: 'reject'; + chatSessionId: string | undefined; + agentSessionId: string | undefined; }, { owner: 'hediet'; comment: 'Reports when code from AI is rejected by the user. @sentToGitHub'; @@ -246,6 +251,8 @@ export class AiEditTelemetryServiceImpl implements IAiEditTelemetryService { sourceRequestId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The chat request ID that produced the edit, for correlating accepts/rejects with specific requests.' }; isAgentHostSession: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the edit was generated by an Agent Host-backed chat session.' }; rejectionMethod: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'How the user rejected the code suggestion. See #IEditTelemetryCodeRejectedData.rejectionMethod for possible values.' }; + chatSessionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The canonical workbench chat identifier for the review action, matching interactiveSessionProviderInvoked.chatSessionId.' }; + agentSessionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The owning Agent Host session identifier, when the chat has a resolved backend session.' }; }>('editTelemetry.codeRejected', { eventId: this._randomService.generatePrefixedUuid('evt'), suggestionId: data.suggestionId as unknown as string, @@ -268,6 +275,8 @@ export class AiEditTelemetryServiceImpl implements IAiEditTelemetryService { sourceRequestId: data.sourceRequestId, isAgentHostSession: data.isAgentHostSession, rejectionMethod: data.rejectionMethod, + chatSessionId: data.chatSessionId, + agentSessionId: data.agentSessionId, ...forwardToChannelIf(isCopilotLikeExtension(data.source?.extensionId)), }); diff --git a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTrackingImpl.ts b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTrackingImpl.ts index 8aa4c0bd1f0865..4618252ad3d238 100644 --- a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTrackingImpl.ts +++ b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTrackingImpl.ts @@ -27,8 +27,8 @@ import { AgentHostEditAttributionDeferredError, AgentHostEditAttributionUnknownO const FOCUS_CORRELATION_DRAIN_TIMEOUT = 1_000; function getDetailsGroupingKey(source: TextModelEditSource, sourceKey = source.toKey(1)): string { - return source.props.$origin === 'agentHost' && source.props.$$chatSessionId !== undefined - ? JSON.stringify([sourceKey, source.props.$$sessionId, source.props.$$chatSessionId]) + return source.props.$origin === 'agentHost' && (source.props.$$agentSessionId !== undefined || source.props.$$chatSessionId !== undefined) + ? JSON.stringify([sourceKey, source.props.$$agentSessionId, source.props.$$sessionId, source.props.$$chatSessionId]) : sourceKey; } @@ -316,6 +316,7 @@ class TrackedDocumentInfo extends Disposable { languageId: this._doc.document.languageId.get(), statsUuid: statsUuid, conversationId: repr.props.$$sessionId, + ...(repr.props.$$agentSessionId !== undefined ? { agentSessionId: repr.props.$$agentSessionId } : {}), ...(repr.props.$$chatSessionId !== undefined ? { chatSessionId: repr.props.$$chatSessionId } : {}), requestId: repr.props.$$requestId, origin: repr.props.$origin, diff --git a/src/vs/workbench/contrib/editTelemetry/test/browser/agentHostEditMarkerService.test.ts b/src/vs/workbench/contrib/editTelemetry/test/browser/agentHostEditMarkerService.test.ts index 9a5cbf8e0211da..dd5837bff86f69 100644 --- a/src/vs/workbench/contrib/editTelemetry/test/browser/agentHostEditMarkerService.test.ts +++ b/src/vs/workbench/contrib/editTelemetry/test/browser/agentHostEditMarkerService.test.ts @@ -57,6 +57,7 @@ suite('Agent Host Edit Marker Service', () => { modelId: 'gpt-5', conversationId: 'session-1', chatSessionId: 'hashed-side-chat', + agentSessionId: 'owner-session', requestId: 'turn-1', harness: 'copilotcli', })); @@ -68,6 +69,7 @@ suite('Agent Host Edit Marker Service', () => { sourceKey: resolution?.source?.toKey(1), sessionId: resolution?.source?.props.$$sessionId, chatSessionId: resolution?.source?.props.$$chatSessionId, + agentSessionId: resolution?.source?.props.$$agentSessionId, requestId: resolution?.source?.props.$$requestId, }, { sharedObservation: true, @@ -75,6 +77,7 @@ suite('Agent Host Edit Marker Service', () => { sourceKey: 'source:Chat.applyEdits-$modelId:gpt-5-$harness:copilotcli-$origin:agentHost', sessionId: 'session-1', chatSessionId: 'hashed-side-chat', + agentSessionId: 'owner-session', requestId: 'turn-1', }); }); @@ -367,6 +370,17 @@ suite('Agent Host Edit Marker Service', () => { }); }); + test('does not assign one owner to a reload spanning different Agent Host sessions', () => { + const context = createContext(); + const correlation = context.service.createCorrelation(context.resource); + const source = { modelId: 'model', conversationId: 'legacy-value', chatSessionId: 'default-hash', requestId: 'turn', harness: 'copilotcli' }; + context.fireMarker(marker(1, 'a', 'ab', { ...source, agentSessionId: 'owner-one' })); + context.fireMarker(marker(2, 'ab', 'abc', { ...source, agentSessionId: 'owner-two' })); + const observation = correlation.register('a', 'abc'); + assert.strictEqual(correlation.isSuppressed(observation), true); + assert.strictEqual(correlation.getResolution?.(observation)?.source, undefined); + }); + test('does not reuse a completed Agent content cycle', () => { const context = createContext(); const correlation = context.service.createCorrelation(context.resource); @@ -839,6 +853,7 @@ function marker(sequence: number, before: string, after: string, source?: { readonly autoTier?: string; readonly conversationId: string; readonly chatSessionId?: string; + readonly agentSessionId?: string; readonly requestId: string; readonly harness: string; }): IFileEditAttributionMarker { diff --git a/src/vs/workbench/contrib/editTelemetry/test/browser/editSourceTrackingImpl.test.ts b/src/vs/workbench/contrib/editTelemetry/test/browser/editSourceTrackingImpl.test.ts index 8c3ee4d575fce3..da153c7dbeaa58 100644 --- a/src/vs/workbench/contrib/editTelemetry/test/browser/editSourceTrackingImpl.test.ts +++ b/src/vs/workbench/contrib/editTelemetry/test/browser/editSourceTrackingImpl.test.ts @@ -392,6 +392,7 @@ suite('Edit Source Tracking Windows', () => { modelId: 'model', sessionId: chatSessionId !== undefined ? 'session-1' : `session-${index}`, chatSessionId, + agentSessionId: chatSessionId !== undefined ? 'owner-session' : undefined, requestId: `turn-${index}`, harness: 'copilotcli', })); @@ -412,6 +413,7 @@ suite('Edit Source Tracking Windows', () => { sourceKey: event.sourceKey, conversationId: event.conversationId, chatSessionId: event.chatSessionId, + agentSessionId: event.agentSessionId, hasChatSessionId: Object.hasOwn(event, 'chatSessionId'), modifiedCount: event.modifiedCount, deltaModifiedCount: event.deltaModifiedCount, @@ -421,6 +423,7 @@ suite('Edit Source Tracking Windows', () => { sourceKey: 'source:Chat.applyEdits-$modelId:model-$harness:copilotcli-$origin:agentHost', conversationId: chatSessionId !== undefined ? 'session-1' : 'session-12', chatSessionId, + agentSessionId: chatSessionId !== undefined ? 'owner-session' : undefined, hasChatSessionId: chatSessionId !== undefined, modifiedCount: chatSessionId !== undefined ? index + 4 : 27, deltaModifiedCount: chatSessionId !== undefined ? index + 4 : 27, @@ -474,6 +477,44 @@ suite('Edit Source Tracking Windows', () => { context.disposables.dispose(); })); + test('separates known owners with no chat provenance in both focus windows', () => runWithFakedTimers({}, async () => { + const visible = observableValue('visible', true); + const sources = new Map(); + const correlation: IExternalEditCorrelation = { + onDidSuppress: Event.None, + onDidInvalidate: Event.None, + register: (_before, after) => after, + isSuppressed: id => sources.has(id), + getResolution: id => sources.has(id) ? { id, source: sources.get(id) } : undefined, + release: () => { }, + }; + const context = setup(visible, { createCorrelation: () => correlation, prepareFlush: async () => undefined }); + await timeout(10); + let content = 'hello'; + for (const [index, agentSessionId] of ['owner-one', 'owner-two'].entries()) { + const newText = 'x'.repeat(index + 1); + content += newText; + sources.set(content, EditSources.agentHostChatApplyEdits({ + modelId: 'model', sessionId: 'same-legacy-value', agentSessionId, requestId: 'same-request', harness: 'copilotcli', + })); + context.document.applyEdit(StringEditWithReason.replace( + OffsetRange.emptyAt(context.document.value.get().value.length), newText, EditSources.reloadFromDisk(), + )); + await timeout(1500); + } + visible.set(false, undefined); + await timeout(10); + const project = (mode: string) => context.allDetails.filter(event => event.mode === mode) + .sort((a, b) => a.modifiedCount - b.modifiedCount) + .map(event => ({ agentSessionId: event.agentSessionId, chatSessionId: event.chatSessionId, modifiedCount: event.modifiedCount, deltaModifiedCount: event.deltaModifiedCount })); + const expected = [ + { agentSessionId: 'owner-one', chatSessionId: undefined, modifiedCount: 1, deltaModifiedCount: 1 }, + { agentSessionId: 'owner-two', chatSessionId: undefined, modifiedCount: 2, deltaModifiedCount: 2 }, + ]; + assert.deepStrictEqual({ short: project('10minFocusWindow'), long: project('20minFocusWindow') }, { short: expected, long: expected }); + context.disposables.dispose(); + })); + test('coordinates long-term totals with Agent Host attribution', () => runWithFakedTimers({}, async () => { const commits: number[] = []; const markerService: IAgentHostEditMarkerService = { @@ -997,6 +1038,7 @@ function setup( autoTier?: string; conversationId: string | undefined; chatSessionId?: string; + agentSessionId?: string; requestId: string | undefined; statsUuid: string; modifiedCount: number; diff --git a/src/vs/workbench/contrib/editTelemetry/test/browser/editTelemetry.test.ts b/src/vs/workbench/contrib/editTelemetry/test/browser/editTelemetry.test.ts index 7634514f47b650..68665d6b6c7596 100644 --- a/src/vs/workbench/contrib/editTelemetry/test/browser/editTelemetry.test.ts +++ b/src/vs/workbench/contrib/editTelemetry/test/browser/editTelemetry.test.ts @@ -74,6 +74,15 @@ suite('Edit Telemetry', () => { { eventName: 'editTelemetry.codeAccepted', isAgentHostSession: true, sourceRequestId: 'request-origin', chatSessionId: 'agent-host-copilotcli:/session#chat', provider: 'copilotcli' }, { eventName: 'editTelemetry.codeRejected', isAgentHostSession: false, sourceRequestId: undefined, chatSessionId: undefined, provider: undefined }, ]); + const ids = { agentSessionId: 'owner', chatSessionId: 'agent-host-copilotcli:/owner#peer', sourceRequestId: 'source-turn' }; + aiEditTelemetryService.handleCodeAccepted({ ...baseData, ...ids, acceptanceMethod: 'accept', isAgentHostSession: true }); + aiEditTelemetryService.handleCodeRejected({ ...baseData, ...ids, rejectionMethod: 'reject', isAgentHostSession: true }); + assert.deepStrictEqual(sentTelemetry.slice(-2).map(event => ({ + eventName: event.eventName, + agentSessionId: event.data?.agentSessionId, + chatSessionId: event.data?.chatSessionId, + sourceRequestId: event.data?.sourceRequestId, + })), ['editTelemetry.codeAccepted', 'editTelemetry.codeRejected'].map(eventName => ({ eventName, ...ids }))); }); test('1', async () => runWithFakedTimers({}, async () => { diff --git a/src/vs/workbench/contrib/externalUriOpener/common/externalUriOpenerService.ts b/src/vs/workbench/contrib/externalUriOpener/common/externalUriOpenerService.ts index bb3241f28eda95..d3ac4df99d80f1 100644 --- a/src/vs/workbench/contrib/externalUriOpener/common/externalUriOpenerService.ts +++ b/src/vs/workbench/contrib/externalUriOpener/common/externalUriOpenerService.ts @@ -12,6 +12,7 @@ import { URI } from '../../../../base/common/uri.js'; import * as languages from '../../../../editor/common/languages.js'; import * as nls from '../../../../nls.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { ViewColumn } from '../../../../platform/editor/common/editor.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { IExternalOpener, IOpenerService } from '../../../../platform/opener/common/opener.js'; @@ -33,7 +34,7 @@ export interface IExternalUriOpener { readonly label: string; canOpen(uri: URI, token: CancellationToken): Promise; - openExternalUri(uri: URI, ctx: { sourceUri: URI }, token: CancellationToken): Promise; + openExternalUri(uri: URI, ctx: { sourceUri: URI; viewColumn?: ViewColumn }, token: CancellationToken): Promise; } export interface IExternalUriOpenerService { @@ -138,7 +139,7 @@ export class ExternalUriOpenerService extends Disposable implements IExternalUri return validOpeners.map(value => value.opener); } - async openExternal(href: string, ctx: { sourceUri: URI; preferredOpenerId?: string }, token: CancellationToken): Promise { + async openExternal(href: string, ctx: { sourceUri: URI; preferredOpenerId?: string; viewColumn?: ViewColumn }, token: CancellationToken): Promise { const targetUri = typeof href === 'string' ? URI.parse(href) : href; @@ -191,7 +192,7 @@ export class ExternalUriOpenerService extends Disposable implements IExternalUri private async showOpenerPrompt( openers: ReadonlyArray, targetUri: URI, - ctx: { sourceUri: URI }, + ctx: { sourceUri: URI; viewColumn?: ViewColumn }, token: CancellationToken ): Promise { type PickItem = IQuickPickItem & { opener?: IExternalUriOpener | 'configureDefault' }; diff --git a/src/vs/workbench/contrib/externalUriOpener/test/common/externalUriOpenerService.test.ts b/src/vs/workbench/contrib/externalUriOpener/test/common/externalUriOpenerService.test.ts index 81aee12e9621ac..4a286d20a9bcc8 100644 --- a/src/vs/workbench/contrib/externalUriOpener/test/common/externalUriOpenerService.test.ts +++ b/src/vs/workbench/contrib/externalUriOpener/test/common/externalUriOpenerService.test.ts @@ -11,6 +11,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/tes import { ExternalUriOpenerPriority } from '../../../../../editor/common/languages.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { ViewColumn } from '../../../../../platform/editor/common/editor.js'; import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; import { IPickOptions, IQuickInputService, IQuickPickItem, QuickPickInput } from '../../../../../platform/quickinput/common/quickInput.js'; @@ -76,6 +77,7 @@ suite('ExternalUriOpenerService', () => { const externalUriOpenerService = disposables.add(instantiationService.createInstance(ExternalUriOpenerService)); let openedWithEnabled = false; + let viewColumn: ViewColumn | undefined; externalUriOpenerService.registerExternalOpenerProvider(new class implements IExternalOpenerProvider { async *getOpeners(_targetUri: URI): AsyncGenerator { yield { @@ -88,8 +90,9 @@ suite('ExternalUriOpenerService', () => { id: 'enabled-id', label: 'enabled', canOpen: async () => ExternalUriOpenerPriority.Default, - openExternalUri: async () => { + openExternalUri: async (_uri, context) => { openedWithEnabled = true; + viewColumn = context.viewColumn; return true; } }; @@ -97,9 +100,8 @@ suite('ExternalUriOpenerService', () => { }); const uri = URI.parse('http://contoso.com'); - const didOpen = await externalUriOpenerService.openExternal(uri.toString(), { sourceUri: uri }, CancellationToken.None); - assert.strictEqual(didOpen, true); - assert.strictEqual(openedWithEnabled, true); + const didOpen = await externalUriOpenerService.openExternal(uri.toString(), { sourceUri: uri, viewColumn: ViewColumn.Beside }, CancellationToken.None); + assert.deepStrictEqual({ didOpen, openedWithEnabled, viewColumn }, { didOpen: true, openedWithEnabled: true, viewColumn: ViewColumn.Beside }); }); test('Should automatically pick single preferred opener without prompt', async () => { diff --git a/src/vs/workbench/contrib/inlineChat/test/browser/inlineChatEditReviewSession.test.ts b/src/vs/workbench/contrib/inlineChat/test/browser/inlineChatEditReviewSession.test.ts index 0456b2deb370e6..9dbfe91e4c7ce5 100644 --- a/src/vs/workbench/contrib/inlineChat/test/browser/inlineChatEditReviewSession.test.ts +++ b/src/vs/workbench/contrib/inlineChat/test/browser/inlineChatEditReviewSession.test.ts @@ -30,6 +30,9 @@ import { ChatResponseModelChangeReason, IChatResponseModel } from '../../../chat import { ChatEditingModifiedDocumentEntry } from '../../../chat/browser/chatEditing/chatEditingModifiedDocumentEntry.js'; import { InlineChatEditReviewSession } from '../../browser/inlineChatEditReviewSession.js'; import { TestWorkerService } from './testWorkerService.js'; +import { IAgentHostConnectionsService } from '../../../../../platform/agentHost/common/agentHostConnectionsService.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IAiEditTelemetryService, IEditTelemetryCodeAcceptedData, IEditTelemetryCodeRejectedData } from '../../../editTelemetry/browser/telemetry/aiEditTelemetry/aiEditTelemetryService.js'; suite('InlineChatEditReviewSession', () => { @@ -44,6 +47,8 @@ suite('InlineChatEditReviewSession', () => { let models: ResourceMap; let dirtyResources: ResourceSet; let cancelSave: boolean; + let instantiation: IInstantiationService; + let editActions: { kind: 'accepted' | 'rejected'; data: IEditTelemetryCodeAcceptedData | IEditTelemetryCodeRejectedData }[]; interface ITestChatResponse extends IChatResponseModel { addExternalEdit(edit: IChatExternalEdit): void; @@ -58,7 +63,7 @@ suite('InlineChatEditReviewSession', () => { return model; } - function createResponse(parts: readonly IChatExternalEdit[] = [], requestId = 'request-1'): ITestChatResponse { + function createResponse(parts: readonly IChatExternalEdit[] = [], requestId = 'request-1', sessionResource = chatSessionResource): ITestChatResponse { const responseParts = [...parts]; const onDidChange = store.add(new Emitter()); return new class extends mock() { @@ -72,7 +77,7 @@ suite('InlineChatEditReviewSession', () => { override readonly agent = { id: 'agent' } as IChatResponseModel['agent']; override readonly slashCommand = { name: 'inline' } as IChatResponseModel['slashCommand']; override readonly request = { modelId: 'model', modeInfo: { telemetryModeId: 'edit' } } as IChatResponseModel['request']; - override readonly session = { sessionResource: chatSessionResource } as IChatResponseModel['session']; + override readonly session = { sessionResource } as IChatResponseModel['session']; override readonly result = undefined; override readonly onDidChange = onDidChange.event; override addExternalEdit(edit: IChatExternalEdit): void { @@ -95,6 +100,7 @@ suite('InlineChatEditReviewSession', () => { models = new ResourceMap(); dirtyResources = new ResourceSet(); cancelSave = false; + editActions = []; const textModelService = new class extends mock() { override async createModelReference(resource: URI): Promise> { @@ -141,6 +147,15 @@ suite('InlineChatEditReviewSession', () => { }(); const collection = new ServiceCollection(); + collection.set(IAgentHostConnectionsService, new class extends mock() { + override resolveSessionResourceIdentity() { + return { connectionAuthority: 'local', backendSession: URI.parse('copilotcli:/owner-session') }; + } + }()); + collection.set(IAiEditTelemetryService, new class extends mock() { + override handleCodeAccepted(data: IEditTelemetryCodeAcceptedData): void { editActions.push({ kind: 'accepted', data }); } + override handleCodeRejected(data: IEditTelemetryCodeRejectedData): void { editActions.push({ kind: 'rejected', data }); } + }()); collection.set(ITextModelService, textModelService); collection.set(ITextFileService, textFileService); collection.set(IFilesConfigurationService, filesConfigurationService); @@ -156,6 +171,7 @@ suite('InlineChatEditReviewSession', () => { }()); const insta = store.add(store.add(workbenchInstantiationService(undefined, store)).createChild(collection)); + instantiation = insta; modelService = insta.get(IModelService); store.add(insta.get(IEditorWorkerService) as TestWorkerService); session = store.add(insta.createInstance(InlineChatEditReviewSession, chatSessionResource, targetUri)); @@ -367,6 +383,31 @@ suite('InlineChatEditReviewSession', () => { assert.deepStrictEqual(session.entries.get().map(entry => entry.state.get()), [ModifiedFileEntryState.Rejected]); }); + for (const outcome of ['accepted', 'rejected'] as const) { + test(`attributes AH inline review ${outcome} edits to their originating session and request`, async () => { + const resource = URI.parse('agent-host-copilotcli:/owner-session#peer'); + session.dispose(); + session = store.add(instantiation.createInstance(InlineChatEditReviewSession, resource, targetUri)); + seededContents.set(targetUri, 'before\n'); + await beginAndEnd('after\n', createResponse([], 'source-turn', resource)); + const entry = session.getEntry(targetUri); + assert.ok(entry instanceof ChatEditingModifiedDocumentEntry); + await waitForState(entry.diffInfo, value => value.changes.length > 0); + if (outcome === 'accepted') { + await session.accept(); + } else { + await session.reject(); + } + assert.deepStrictEqual(editActions.map(({ kind, data }) => ({ + kind, agentSessionId: data.agentSessionId, chatSessionId: data.chatSessionId, + sourceRequestId: data.sourceRequestId, isAgentHostSession: data.isAgentHostSession, feature: data.feature, + })), [{ + kind: outcome, agentSessionId: 'owner-session', chatSessionId: resource.toString(), + sourceRequestId: 'source-turn', isAgentHostSession: true, feature: 'inlineChat', + }]); + }); + } + test('does not create duplicate entries for the target across turns', async () => { seededContents.set(targetUri, 'before'); diff --git a/src/vs/workbench/contrib/mcp/browser/mcp.contribution.ts b/src/vs/workbench/contrib/mcp/browser/mcp.contribution.ts index 64f893f058cb81..0f6bf31c1bdc8a 100644 --- a/src/vs/workbench/contrib/mcp/browser/mcp.contribution.ts +++ b/src/vs/workbench/contrib/mcp/browser/mcp.contribution.ts @@ -47,6 +47,8 @@ import { McpElicitationService } from './mcpElicitationService.js'; import { McpLanguageFeatures } from './mcpLanguageFeatures.js'; import { McpConfigMigrationContribution } from './mcpMigration.js'; import { McpResourceQuickAccess } from './mcpResourceQuickAccess.js'; +import { AccessibleViewRegistry } from '../../../../platform/accessibility/browser/accessibleViewRegistry.js'; +import { McpServerConfigurationFormAccessibilityHelp } from './mcpServerConfigurationFormAccessibility.js'; import { McpServerEditor } from './mcpServerEditor.js'; import { McpServerEditorInput } from './mcpServerEditorInput.js'; import { MCPContextsInitialisation, McpWorkbenchService } from './mcpWorkbenchService.js'; @@ -56,6 +58,7 @@ registerSingleton(IMcpCopilotGlobalConfigurationService, McpCopilotGlobalConfigu registerSingleton(IMcpSandboxService, McpSandboxService, InstantiationType.Delayed); registerSingleton(IMcpService, McpService, InstantiationType.Delayed); registerSingleton(IMcpWorkbenchService, McpWorkbenchService, InstantiationType.Eager); +AccessibleViewRegistry.register(new McpServerConfigurationFormAccessibilityHelp()); registerSingleton(IMcpDevModeDebugging, McpDevModeDebugging, InstantiationType.Delayed); registerSingleton(IMcpSamplingService, McpSamplingService, InstantiationType.Delayed); registerSingleton(IMcpElicitationService, McpElicitationService, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/mcp/browser/mcpServerConfigurationForm.ts b/src/vs/workbench/contrib/mcp/browser/mcpServerConfigurationForm.ts index 25b396cabd74b3..cac1dbe4ed616a 100644 --- a/src/vs/workbench/contrib/mcp/browser/mcpServerConfigurationForm.ts +++ b/src/vs/workbench/contrib/mcp/browser/mcpServerConfigurationForm.ts @@ -14,29 +14,51 @@ import { Action } from '../../../../base/common/actions.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { getErrorMessage } from '../../../../base/common/errors.js'; import { Emitter } from '../../../../base/common/event.js'; -import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; import { equals } from '../../../../base/common/objects.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { localize } from '../../../../nls.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; import { IContextViewService } from '../../../../platform/contextview/browser/contextView.js'; +import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; import { IMcpServerConfiguration } from '../../../../platform/mcp/common/mcpPlatformTypes.js'; import { INotificationService } from '../../../../platform/notification/common/notification.js'; import { defaultButtonStyles, getInputBoxStyle } from '../../../../platform/theme/browser/defaultStyles.js'; +import { AccessibilityVerbositySettingId } from '../../accessibility/browser/accessibilityConfiguration.js'; +import { AccessibilityCommandId } from '../../accessibility/common/accessibilityCommands.js'; import { settingsTextInputBorder } from '../../preferences/common/settingsEditorColorRegistry.js'; import { McpResourceFormat } from '../../../../platform/mcp/common/mcpWorkspaceConfiguration.js'; import { IEditableMcpServerConfiguration } from '../common/mcpTypes.js'; -import { getMcpServerFormatError, getMcpServerFormCapabilities, IMcpServerFormCapabilities, IMcpServerFormKeyValue, IMcpServerFormState, IMcpServerFormValidation, isMcpServerFormValid, McpServerFormKind, toMcpServerConfiguration, toMcpServerFormState, validateMcpServerFormState } from '../common/mcpServerConfigurationForm.js'; +import { getMcpServerFormatError, getMcpServerFormCapabilities, IMcpServerFormCapabilities, IMcpServerFormKeyValue, IMcpServerFormState, IMcpServerFormValidation, isMcpServerFormValid, McpServerFormKind, mergeMcpServerFormKeyValues, toMcpServerConfiguration, toMcpServerFormState, validateMcpServerFormState } from '../common/mcpServerConfigurationForm.js'; const $ = DOM.$; -/** - * A server configuration shown in {@link McpServerConfigurationForm}, together with how to save it. - */ +/** Whether focus is inside an {@link McpServerConfigurationForm}. */ +export const McpServerConfigurationFormFocusContext = new RawContextKey('mcpServerConfigurationFormFocus', false, localize('mcpServerConfigurationFormFocus', "Whether focus is in an MCP server configuration form")); + +/** Forms that have not been disposed, used to find the form that has focus. */ +const liveForms = new Set(); + +/** The configuration form that contains the focused element, if any. */ +export function getFocusedMcpServerConfigurationForm(): McpServerConfigurationForm | undefined { + const active = DOM.getActiveElement(); + for (const form of liveForms) { + if (DOM.isAncestor(active, form.element)) { + return form; + } + } + return undefined; +} + export interface IMcpServerConfigurationFormOptions { /** Opens the configuration file at the server's entry, to edit properties the form does not show. */ readonly openConfiguration: () => void; } +/** + * A server configuration shown in {@link McpServerConfigurationForm}, together with how to save it. + */ export interface IMcpServerConfigurationFormInput extends IEditableMcpServerConfiguration { /** Identifies the server; unsaved edits are kept while the same server is shown. */ readonly id: string; @@ -48,6 +70,20 @@ export interface IMcpServerConfigurationFormInput extends IEditableMcpServerConf // Match the Settings and Profiles editors, whose inputs stay visible on themes with a faint `input.border`. const formInputBoxStyles = getInputBoxStyle({ inputBorder: settingsTextInputBorder }); +interface IKeyValueRow { + readonly entry: IMcpServerFormKeyValue; + readonly name: InputBox; + readonly value: InputBox; + readonly actions: ActionBar; +} + +interface IKeyValueFocus { + readonly entry: IMcpServerFormKeyValue; + readonly index: number; + readonly control: 'name' | 'value' | 'remove'; + readonly selection?: readonly [number | null, number | null]; +} + interface IKeyValueListLabels { readonly namePlaceholder: string; readonly valuePlaceholder: string; @@ -75,7 +111,7 @@ class KeyValueList extends Disposable { private readonly errorEl: HTMLElement; private readonly addButton: Button; private readonly rowDisposables = this._register(new DisposableStore()); - private nameInputs: InputBox[] = []; + private rows: IKeyValueRow[] = []; private entries: IMcpServerFormKeyValue[] = []; constructor( @@ -99,7 +135,7 @@ class KeyValueList extends Disposable { this._register(this.addButton.onDidClick(() => { this.entries.push({ name: '', value: '' }); this.renderRows(); - this.nameInputs.at(-1)?.focus(); + this.rows.at(-1)?.name.focus(); this._onDidChange.fire(); })); } @@ -109,8 +145,13 @@ class KeyValueList extends Disposable { } set value(entries: IMcpServerFormKeyValue[]) { + // Rebuilding the rows drops keyboard focus, so return it to the same row if it remains. + const focus = this.getFocus(); this.entries = entries; this.renderRows(); + if (focus) { + this.restoreFocus(focus); + } } setError(message: string | undefined): void { @@ -121,7 +162,7 @@ class KeyValueList extends Disposable { private renderRows(): void { this.rowDisposables.clear(); DOM.clearNode(this.rowsEl); - this.nameInputs = []; + this.rows = []; this.headerEl.style.display = this.entries.length ? '' : 'none'; this.entries.forEach((entry, index) => { @@ -132,7 +173,6 @@ class KeyValueList extends Disposable { inputBoxStyles: formInputBoxStyles, })); nameInput.value = entry.name; - this.nameInputs.push(nameInput); const valueInput = this.rowDisposables.add(new InputBox(DOM.append(row, $('.mcp-config-form-kv-value')), this.contextViewService, { placeholder: this.labels.valuePlaceholder, @@ -144,6 +184,7 @@ class KeyValueList extends Disposable { const removeAction = this.rowDisposables.add(new Action('mcpForm.removeEntry', this.labels.removeAriaLabel(entry.name, index + 1), ThemeIcon.asClassName(Codicon.close), true, () => this.removeEntry(index))); const actionBar = this.rowDisposables.add(new ActionBar(DOM.append(row, $('.mcp-config-form-kv-actions')))); actionBar.push(removeAction, { icon: true, label: false }); + this.rows.push({ entry, name: nameInput, value: valueInput, actions: actionBar }); this.rowDisposables.add(nameInput.onDidChange(name => { entry.name = name; @@ -163,14 +204,52 @@ class KeyValueList extends Disposable { private removeEntry(index: number): void { this.entries.splice(index, 1); this.renderRows(); - // Keep keyboard focus in the list after the focused row disappears. - const next = this.nameInputs[Math.min(index, this.nameInputs.length - 1)]; + this.focusRowAt(index); + this._onDidChange.fire(); + } + + /** Keeps keyboard focus in the list when the row at {@link index} is gone. */ + private focusRowAt(index: number): void { + const next = this.rows[Math.min(index, this.rows.length - 1)]; if (next) { - next.focus(); + next.name.focus(); } else { this.addButton.focus(); } - this._onDidChange.fire(); + } + + /** Where focus is in the list, so {@link restoreFocus} can return it there after the rows are rebuilt. */ + getFocus(): IKeyValueFocus | undefined { + const active = DOM.getActiveElement(); + for (let index = 0; index < this.rows.length; index++) { + const { entry, name, value, actions } = this.rows[index]; + for (const [control, input] of [['name', name], ['value', value]] as const) { + if (active === input.inputElement) { + return { entry, index, control, selection: [input.inputElement.selectionStart, input.inputElement.selectionEnd] }; + } + } + if (DOM.isAncestor(active, actions.domNode)) { + return { entry, index, control: 'remove' }; + } + } + return undefined; + } + + restoreFocus({ entry, index, control, selection }: IKeyValueFocus): void { + const row = this.rows.find(row => row.entry === entry); + if (!row) { + this.focusRowAt(index); + return; + } + if (control === 'remove') { + row.actions.focus(0); + return; + } + const input = row[control]; + input.focus(); + if (selection) { + input.inputElement.setSelectionRange(selection[0], selection[1]); + } } } @@ -218,10 +297,27 @@ export class McpServerConfigurationForm extends Disposable { options: IMcpServerConfigurationFormOptions, @INotificationService private readonly notificationService: INotificationService, @IContextViewService private readonly contextViewService: IContextViewService, + @IContextKeyService contextKeyService: IContextKeyService, + @IConfigurationService private readonly configurationService: IConfigurationService, + @IKeybindingService private readonly keybindingService: IKeybindingService, ) { super(); - this.element = DOM.append(parent, $('.mcp-config-form')); + this.element = DOM.append(parent, $('.mcp-config-form', { role: 'group' })); + liveForms.add(this); + this._register(toDisposable(() => liveForms.delete(this))); + this.updateAriaLabel(); + this._register(this.configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(AccessibilityVerbositySettingId.McpServerConfiguration)) { + this.updateAriaLabel(); + } + })); + this._register(this.keybindingService.onDidUpdateKeybindings(() => this.updateAriaLabel())); + const focusContext = McpServerConfigurationFormFocusContext.bindTo(contextKeyService); + const focusTracker = this._register(DOM.trackFocus(this.element)); + this._register(focusTracker.onDidFocus(() => focusContext.set(true))); + this._register(focusTracker.onDidBlur(() => focusContext.reset())); + this._register(toDisposable(() => focusContext.reset())); const typeField = this.appendField(this.element, localize('mcpForm.type', "Server Type")); this.kindRadio = this._register(new Radio({ @@ -344,23 +440,47 @@ export class McpServerConfigurationForm extends Disposable { * are kept when its configuration changes on disk. */ setInput(input: IMcpServerConfigurationFormInput): void { - const { config } = input; - const keepEdits = this.input?.id === input.id && this.input.format === input.format && (this.isDirty || this.saving || equals(config, this.baseline)); + const sameServer = this.input?.id === input.id && this.input.format === input.format; this.input = input; - this.baseline = config; - this.initialState = toMcpServerFormState(config); - if (keepEdits) { - this.onDidChangeState(); + if (sameServer && (this.saving || equals(input.config, this.baseline))) { return; } - this.state = toMcpServerFormState(config); - this.renderState(); + if (sameServer && this.isDirty) { + this.rebase(input.config); + return; + } + this.reset(input.config); } focus(): void { this.kindRadio.focusActiveItem(); } + /** + * Remembers where focus is in the form and returns a function that moves it back there, for + * example when a dialog closes. A file change in the meantime can rebuild the focused row, in + * which case focus returns to the same row, the nearest remaining row or the form. + */ + captureFocus(): () => void { + const focused = DOM.getActiveElement(); + const listFocus = [this.envList, this.headersList].map(list => ({ list, focus: list.getFocus() })).find(({ focus }) => focus); + return () => { + if (DOM.isHTMLElement(focused) && focused.isConnected) { + focused.focus(); + if (DOM.getActiveElement() === focused) { + return; + } + } + if (listFocus?.focus) { + listFocus.list.restoreFocus(listFocus.focus); + if (DOM.isAncestor(DOM.getActiveElement(), this.element)) { + return; + } + } + this.focus(); + }; + } + private appendField(parent: HTMLElement, label: string, optional = false): HTMLElement { const field = DOM.append(parent, $('.mcp-config-form-field')); const labelEl = DOM.append(field, $('.mcp-config-form-label')); @@ -378,18 +498,22 @@ export class McpServerConfigurationForm extends Disposable { /** Offers only what the destination file can store; an unsupported kind already in the file stays selectable. */ private renderCapabilities(format: McpResourceFormat, currentKind: McpServerFormKind): void { this.capabilities = getMcpServerFormCapabilities(format); - this.kinds = this.capabilities.kinds.includes(currentKind) ? this.capabilities.kinds : [...this.capabilities.kinds, currentKind]; - // Labels are the `type` values written to the file, so they are not localized. - this.kindRadio.setItems(this.kinds.map(kind => { - switch (kind) { - case McpServerFormKind.Stdio: - return { text: 'stdio', tooltip: localize('mcpForm.type.stdio.tooltip', "Run the server as a local process that communicates over standard input and output") }; - case McpServerFormKind.Http: - return { text: 'http', tooltip: localize('mcpForm.type.http.tooltip', "Connect to a remote server using Streamable HTTP, falling back to SSE") }; - case McpServerFormKind.Sse: - return { text: 'sse', tooltip: localize('mcpForm.type.sse.tooltip', "Connect to a remote server using Server-Sent Events") }; - } - })); + const kinds = this.capabilities.kinds.includes(currentKind) ? this.capabilities.kinds : [...this.capabilities.kinds, currentKind]; + // Rebuilding the options would move focus out of the server type control during a refresh. + if (!equals(kinds, this.kinds)) { + this.kinds = kinds; + // Labels are the `type` values written to the file, so they are not localized. + this.kindRadio.setItems(this.kinds.map(kind => { + switch (kind) { + case McpServerFormKind.Stdio: + return { text: 'stdio', tooltip: localize('mcpForm.type.stdio.tooltip', "Run the server as a local process that communicates over standard input and output") }; + case McpServerFormKind.Http: + return { text: 'http', tooltip: localize('mcpForm.type.http.tooltip', "Connect to a remote server using Streamable HTTP, falling back to SSE") }; + case McpServerFormKind.Sse: + return { text: 'sse', tooltip: localize('mcpForm.type.sse.tooltip', "Connect to a remote server using Server-Sent Events") }; + } + })); + } this.envFileField.style.display = this.capabilities.envFile ? '' : 'none'; this.cwdField.style.display = this.capabilities.cwd ? '' : 'none'; // VS Code variables such as ${workspaceFolder} are only resolved in VS Code's own mcp.json. @@ -440,9 +564,18 @@ export class McpServerConfigurationForm extends Disposable { this.envFileInput.value = state.envFile; this.cwdInput.value = state.cwd; this.urlInput.value = state.url; - // Lists edit the state in place, so give them copies to keep the initial state intact. - this.envList.value = state.env = state.env.map(entry => ({ ...entry })); - this.headersList.value = state.headers = state.headers.map(entry => ({ ...entry })); + // Lists edit their rows in place; the state's rows are never shared with the initial state. A + // list whose rows are unchanged keeps its DOM, so a refresh does not interrupt typing in a row. + if (equals(this.envList.value, state.env)) { + state.env = this.envList.value; + } else { + this.envList.value = state.env; + } + if (equals(this.headersList.value, state.headers)) { + state.headers = this.headersList.value; + } else { + this.headersList.value = state.headers; + } this.updateSections(); this.onDidChangeState(); } @@ -487,15 +620,62 @@ export class McpServerConfigurationForm extends Disposable { return validation; } - private discard(): void { - if (!this.initialState) { + /** + * Moves unsaved edits onto {@link config}, the configuration now in the file: fields the user + * edited keep their edits and all other fields show the file's values. Saving then writes only + * the user's edits, so changes made to the file in the meantime are kept. + */ + private rebase(config: IMcpServerConfiguration): void { + const { state, initialState } = this; + if (!state || !initialState) { + this.reset(config); return; } - this.state = structuredClone(this.initialState); + const next = toMcpServerFormState(config); + const merged: IMcpServerFormState = { + kind: equals(state.kind, initialState.kind) ? next.kind : state.kind, + command: equals(state.command, initialState.command) ? next.command : state.command, + args: equals(state.args, initialState.args) ? next.args : state.args, + cwd: equals(state.cwd, initialState.cwd) ? next.cwd : state.cwd, + envFile: equals(state.envFile, initialState.envFile) ? next.envFile : state.envFile, + env: mergeMcpServerFormKeyValues(initialState.env, state.env, next.env), + url: equals(state.url, initialState.url) ? next.url : state.url, + headers: mergeMcpServerFormKeyValues(initialState.headers, state.headers, next.headers), + }; + this.baseline = config; + this.initialState = toMcpServerFormState(config); + this.state = merged; + this.renderState(); + } + + /** Shows {@link config}, the latest configuration from the file, without any edits. */ + private reset(config: IMcpServerConfiguration): void { + this.baseline = config; + this.initialState = toMcpServerFormState(config); + this.state = toMcpServerFormState(config); this.renderState(); + } + + private discard(): void { + if (!this.input) { + return; + } + this.reset(this.input.config); this.focus(); } + private updateAriaLabel(): void { + const label = localize('mcpForm.ariaLabel', "MCP server configuration"); + if (!this.configurationService.getValue(AccessibilityVerbositySettingId.McpServerConfiguration)) { + this.element.setAttribute('aria-label', label); + return; + } + const keybinding = this.keybindingService.lookupKeybinding(AccessibilityCommandId.OpenAccessibilityHelp)?.getAriaLabel(); + this.element.setAttribute('aria-label', keybinding + ? localize('mcpForm.ariaLabelWithHelp', "{0}, use {1} for accessibility help", label, keybinding) + : localize('mcpForm.ariaLabelWithHelpNoKeybinding', "{0}, run the command Open Accessibility Help which is currently not triggerable via keybinding", label)); + } + private async save(): Promise { const { input, state, baseline } = this; if (!input || !state || !baseline || this.saving || !isMcpServerFormValid(this.validate())) { @@ -503,14 +683,23 @@ export class McpServerConfigurationForm extends Disposable { } const config = toMcpServerConfiguration(state, baseline); + // The name each row is saved under, which becomes the row's origin once the save succeeds. + const savedRows = [...state.env, ...state.headers].map(row => [row, row.name.trim()] as const); const label = input.label || input.name; this.saving = true; this.updateButtons(); try { await input.save(baseline, config); - if (this.input === input) { + if (this.input?.id === input.id) { this.baseline = config; this.initialState = toMcpServerFormState(config); + for (const [row, name] of savedRows) { + if (name) { + row.origin = name; + } else { + delete row.origin; + } + } } status(localize('mcpForm.saved', "Saved configuration for {0}", label)); } catch (error) { diff --git a/src/vs/workbench/contrib/mcp/browser/mcpServerConfigurationFormAccessibility.ts b/src/vs/workbench/contrib/mcp/browser/mcpServerConfigurationFormAccessibility.ts new file mode 100644 index 00000000000000..cef69b1599dda6 --- /dev/null +++ b/src/vs/workbench/contrib/mcp/browser/mcpServerConfigurationFormAccessibility.ts @@ -0,0 +1,47 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { AccessibleContentProvider, AccessibleViewProviderId, AccessibleViewType } from '../../../../platform/accessibility/browser/accessibleView.js'; +import { IAccessibleViewImplementation } from '../../../../platform/accessibility/browser/accessibleViewRegistry.js'; +import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { AccessibilityVerbositySettingId } from '../../accessibility/browser/accessibilityConfiguration.js'; +import { getFocusedMcpServerConfigurationForm, McpServerConfigurationFormFocusContext } from './mcpServerConfigurationForm.js'; + +/** + * Accessibility help for {@link McpServerConfigurationForm}. The form uses native text fields, + * so it does not need an Accessible View. + */ +export class McpServerConfigurationFormAccessibilityHelp implements IAccessibleViewImplementation { + // Above the Agent Customizations editor's own help, which also matches while the form is focused. + readonly priority = 115; + readonly name = 'mcp-server-configuration-form'; + readonly type = AccessibleViewType.Help; + readonly when = McpServerConfigurationFormFocusContext; + + getProvider(_accessor: ServicesAccessor): AccessibleContentProvider { + // Captured now: a file change while Help is open can rebuild the focused row. + const restoreFocus = getFocusedMcpServerConfigurationForm()?.captureFocus(); + return new AccessibleContentProvider( + AccessibleViewProviderId.McpServerConfiguration, + { type: AccessibleViewType.Help }, + () => getMcpServerConfigurationFormHelpContent(), + () => restoreFocus?.(), + AccessibilityVerbositySettingId.McpServerConfiguration, + ); + } +} + +export function getMcpServerConfigurationFormHelpContent(): string { + return [ + localize('mcpFormHelp.overview', "You are in the configuration form of an MCP server. It edits the server's entry in its configuration file, and only offers the fields that file can store."), + localize('mcpFormHelp.type', "Server Type is a group of options: stdio runs a local process, http connects to a remote server, and sse connects using Server-Sent Events. Use the arrow keys to change the type; the fields below change to match it."), + localize('mcpFormHelp.arguments', "Arguments are separated by spaces. If an argument contains spaces or is empty, enter all arguments as a JSON array of strings instead, for example [\"--path\", \"My Documents\"]."), + localize('mcpFormHelp.rows', "Environment variables and headers are rows of a name field, a value field and a Remove button. Use Add Variable or Add Header to add a row; focus moves to its name field. Removing a row moves focus to the next row, or to the add button when no rows remain."), + localize('mcpFormHelp.validation', "Invalid fields are marked as invalid and announce their error. Errors that apply to the whole form, such as a value the configuration file does not support, are announced below the fields."), + localize('mcpFormHelp.save', "Save writes only the changed properties to the configuration file and is unavailable until there is a valid change. Discard Changes restores the configuration from the file."), + localize('mcpFormHelp.otherProperties', "Other Properties opens the configuration file at the server's entry, to edit properties the form does not show."), + ].join('\n\n'); +} diff --git a/src/vs/workbench/contrib/mcp/common/mcpServerConfigurationForm.ts b/src/vs/workbench/contrib/mcp/common/mcpServerConfigurationForm.ts index 1aea54a47a25e7..90cbd35f1f10f3 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpServerConfigurationForm.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpServerConfigurationForm.ts @@ -23,6 +23,11 @@ export const enum McpServerFormKind { export interface IMcpServerFormKeyValue { name: string; value: string; + /** + * Name of the entry in the configuration this row was loaded from, used to merge the row with + * later changes to the file. Absent for rows the user added. + */ + origin?: string; } /** @@ -101,14 +106,14 @@ export function toMcpServerFormState(config: IMcpServerConfiguration): IMcpServe args: formatMcpServerArgs(config.args), cwd: config.cwd ?? '', envFile: config.envFile ?? '', - env: Object.entries(config.env ?? {}).map(([name, value]) => ({ name, value: value === null ? '' : String(value) })), + env: Object.entries(config.env ?? {}).map(([name, value]) => ({ name, value: value === null ? '' : String(value), origin: name })), }; } return { ...empty, kind: config.transport === 'sse' ? McpServerFormKind.Sse : McpServerFormKind.Http, url: config.url ?? '', - headers: Object.entries(config.headers ?? {}).map(([name, value]) => ({ name, value })), + headers: Object.entries(config.headers ?? {}).map(([name, value]) => ({ name, value, origin: name })), }; } @@ -174,6 +179,42 @@ export function validateMcpServerFormState(state: IMcpServerFormState): IMcpServ return result; } +/** + * Three-way merges name/value rows after the configuration file changed while the form had unsaved + * edits. {@link base} is the configuration the edits were made against, {@link local} the rows in + * the form and {@link remote} the configuration now in the file. Rows the user added, removed, + * renamed or changed keep the user's edit; all other rows take the file's current entry, and entries + * the file added are appended. Rows taken from the file are copies, so {@link remote} is not shared. + */ +export function mergeMcpServerFormKeyValues(base: readonly IMcpServerFormKeyValue[], local: readonly IMcpServerFormKeyValue[], remote: readonly IMcpServerFormKeyValue[]): IMcpServerFormKeyValue[] { + const baseByName = new Map(base.map(entry => [entry.name, entry])); + const remoteByName = new Map(remote.map(entry => [entry.name, entry])); + const merged: IMcpServerFormKeyValue[] = []; + // Names the merged rows already account for, so a file entry is not added twice. + const covered = new Set(); + for (const row of local) { + const origin = row.origin === undefined ? undefined : baseByName.get(row.origin); + if (origin) { + covered.add(origin.name); + } + if (origin && row.name.trim() === origin.name && row.value === origin.value) { + const latest = remoteByName.get(origin.name); + if (latest) { + merged.push({ ...latest }); + } + continue; + } + covered.add(row.name.trim()); + merged.push(row); + } + for (const entry of remote) { + if (!baseByName.has(entry.name) && !covered.has(entry.name)) { + merged.push({ ...entry }); + } + } + return merged; +} + export function isMcpServerFormValid(validation: IMcpServerFormValidation): boolean { return Object.keys(validation).length === 0; } diff --git a/src/vs/workbench/contrib/mcp/test/common/mcpServerConfigurationForm.test.ts b/src/vs/workbench/contrib/mcp/test/common/mcpServerConfigurationForm.test.ts index ad8fc73beb391f..7bd9c9af771676 100644 --- a/src/vs/workbench/contrib/mcp/test/common/mcpServerConfigurationForm.test.ts +++ b/src/vs/workbench/contrib/mcp/test/common/mcpServerConfigurationForm.test.ts @@ -7,7 +7,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { IMcpServerConfiguration, McpServerType } from '../../../../../platform/mcp/common/mcpPlatformTypes.js'; import { McpResourceFormat } from '../../../../../platform/mcp/common/mcpWorkspaceConfiguration.js'; -import { formatMcpServerArgs, getMcpServerFormatError, McpServerFormKind, parseMcpServerArgs, toMcpServerConfiguration, toMcpServerFormState, validateMcpServerFormState } from '../../common/mcpServerConfigurationForm.js'; +import { formatMcpServerArgs, getMcpServerFormatError, McpServerFormKind, mergeMcpServerFormKeyValues, parseMcpServerArgs, toMcpServerConfiguration, toMcpServerFormState, validateMcpServerFormState } from '../../common/mcpServerConfigurationForm.js'; suite('MCP Server Configuration Form', () => { @@ -79,14 +79,38 @@ suite('MCP Server Configuration Form', () => { changedCwd: getMcpServerFormatError('server', previous, { ...previous, cwd: '/other' }, McpResourceFormat.WorkspaceRoot) !== undefined, emptyCommand: getMcpServerFormatError('server', previous, { ...previous, command: ' ' }, McpResourceFormat.CopilotGlobal) !== undefined, inputVariable: getMcpServerFormatError('server', previous, { ...previous, env: { A: '${input:a}' } }, McpResourceFormat.CopilotGlobal) !== undefined, + unchangedInputVariable: getMcpServerFormatError('server', { ...previous, env: { A: '${input:a}', B: '1' } }, { ...previous, env: { A: '${input:a}', B: '2' } }, McpResourceFormat.CopilotGlobal), + unchangedInputArgument: getMcpServerFormatError('server', { ...previous, args: ['${input:a}'] }, { ...previous, args: ['${input:a}', '--verbose'] }, McpResourceFormat.CopilotGlobal), + duplicatedInputArgument: getMcpServerFormatError('server', { ...previous, args: ['${input:a}'] }, { ...previous, args: ['${input:a}', '${input:a}'] }, McpResourceFormat.CopilotGlobal) !== undefined, }, { unchangedCwd: undefined, changedCwd: true, emptyCommand: true, inputVariable: true, + unchangedInputVariable: undefined, + unchangedInputArgument: undefined, + duplicatedInputArgument: true, }); }); + test('merging rows keeps the user\'s edits and takes every other change from the file', () => { + const base = toMcpServerFormState({ type: McpServerType.LOCAL, command: 'node', env: { EDITED: '1', UNTOUCHED: '1', REMOVED_LOCALLY: '1', REMOVED_REMOTELY: '1', RENAMED: '1' } }).env; + const local = base.map(row => ({ ...row })); + local[0].value = 'mine'; + local[4].name = 'RENAMED_LOCALLY'; + local.splice(2, 1); + local.push({ name: 'ADDED_LOCALLY', value: '1' }); + const remote = toMcpServerFormState({ type: McpServerType.LOCAL, command: 'node', env: { EDITED: 'theirs', UNTOUCHED: 'theirs', REMOVED_LOCALLY: 'theirs', RENAMED: 'theirs', ADDED_REMOTELY: '1', ADDED_LOCALLY: 'theirs' } }).env; + + assert.deepStrictEqual(mergeMcpServerFormKeyValues(base, local, remote).map(({ name, value }) => [name, value]), [ + ['EDITED', 'mine'], + ['UNTOUCHED', 'theirs'], + ['RENAMED_LOCALLY', '1'], + ['ADDED_LOCALLY', '1'], + ['ADDED_REMOTELY', '1'], + ]); + }); + test('validation reports missing and duplicate values', () => { const local = toMcpServerFormState({ type: McpServerType.LOCAL, command: '' }); local.args = '[oops'; diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/tabbedModelPicker.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/chat/tabbedModelPicker.fixture.ts index 113082bc53bad3..571ecce05f9db0 100644 --- a/src/vs/workbench/test/browser/componentFixtures/chat/tabbedModelPicker.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/chat/tabbedModelPicker.fixture.ts @@ -205,7 +205,7 @@ const RELAYED_MODELS = [ 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' }), ]; -/** The research preview the Copilot agent host adds when enabled, listed right below Auto. */ +/** The research preview the Copilot agent host adds when enabled, offered as Auto's last tier. */ const HYDRA_FUSION_MODEL: ILanguageModelChatMetadataAndIdentifier = (() => { const model = createModel('hydrafusion', 'HydraFusion', { vendor: 'agent-host-copilotcli', isBYOK: true, modelGroupId: 'copilot', detail: 'Research preview' }); return { ...model, metadata: { ...model.metadata, tooltip: 'HydraFusion routes the first eligible turn and may use multiple models. Premium usage varies with the selected route.' } }; @@ -616,14 +616,9 @@ export default defineThemedFixtureGroup({ path: 'chat/input/tabbedModelPicker' } additionalThemes: ['darkHighContrast', 'lightHighContrast'], render: context => renderPicker(context, { anchored: true, selectedModelId: AUTO_MODEL.identifier }), }), - PickerAutoMode: defineComponentFixture({ - render: context => renderPicker(context, { anchored: true, motionReduced: false }), - }), - PickerAutoModeReducedMotion: defineComponentFixture({ - render: context => { - context.container.classList.add('monaco-reduce-motion'); - return renderPicker(context, { anchored: true, motionReduced: true }); - }, + PickerAutoDetails: defineComponentFixture({ + additionalThemes: ['darkHighContrast', 'lightHighContrast'], + render: context => renderPicker(context, { selectedModelId: AUTO_MODEL.identifier, openDetailsFor: 'Auto' }), }), Picker: defineComponentFixture({ render: context => renderPicker(context, { models: COPILOT_ONLY_MODELS }) }), PickerWithAddedModels: defineComponentFixture({ render: context => renderPicker(context) }), @@ -670,10 +665,10 @@ export default defineThemedFixtureGroup({ path: 'chat/input/tabbedModelPicker' } }), }), PickerWithAutoSelected: defineComponentFixture({ render: context => renderPicker(context, { models: COPILOT_ONLY_MODELS, selectedModelId: 'copilot/auto' }) }), - PickerAutoModeOtherProvider: defineComponentFixture({ + PickerAutoSelectedOtherProvider: defineComponentFixture({ render: context => renderPicker(context, { selectedModelId: AUTO_MODEL.identifier, initialTabLabel: 'Ollama' }), }), - PickerAutoModeWithManyProviders: defineComponentFixture({ + PickerAutoSelectedWithManyProviders: defineComponentFixture({ additionalThemes: ['darkHighContrast', 'lightHighContrast'], render: context => renderPicker(context, { models: [...ALL_MODELS, ...OPENAI_MODELS, ...ANTHROPIC_MODELS, ...GOOGLE_MODELS], selectedModelId: AUTO_MODEL.identifier }), }), @@ -704,13 +699,20 @@ export default defineThemedFixtureGroup({ path: 'chat/input/tabbedModelPicker' } PickerWithHydraFusion: defineComponentFixture({ render: context => renderPicker(context, { models: [...RELAYED_MODELS, HYDRA_FUSION_MODEL], selectedModelId: 'agent-host-copilotcli/gpt-5-5', entitlement: ChatEntitlement.Pro }), }), - PickerAutoModeWithHydraFusion: defineComponentFixture({ + /** HydraFusion as Auto's last tier, before it is chosen. */ + PickerAutoDetailsWithHydraFusion: defineComponentFixture({ additionalThemes: ['darkHighContrast', 'lightHighContrast'], - render: context => renderPicker(context, { models: [...RELAYED_MODELS, HYDRA_FUSION_MODEL], selectedModelId: 'agent-host-copilotcli/auto', entitlement: ChatEntitlement.Pro }), + render: context => renderPicker(context, { models: [...RELAYED_MODELS, HYDRA_FUSION_MODEL], selectedModelId: 'agent-host-copilotcli/auto', entitlement: ChatEntitlement.Pro, openDetailsFor: 'Auto' }), }), + /** Auto stands for its HydraFusion tier in the list. */ PickerHydraFusionSelected: defineComponentFixture({ render: context => renderPicker(context, { models: [...RELAYED_MODELS, HYDRA_FUSION_MODEL], selectedModelId: HYDRA_FUSION_MODEL.identifier, entitlement: ChatEntitlement.Pro }), }), + /** The research preview description appears once HydraFusion is chosen. */ + PickerHydraFusionSelectedDetails: defineComponentFixture({ + additionalThemes: ['darkHighContrast', 'lightHighContrast'], + render: context => renderPicker(context, { models: [...RELAYED_MODELS, HYDRA_FUSION_MODEL], selectedModelId: HYDRA_FUSION_MODEL.identifier, entitlement: ChatEntitlement.Pro, openDetailsFor: 'Auto' }), + }), PickerHydraFusionNeedsUpdate: defineComponentFixture({ render: context => renderPicker(context, { models: [...RELAYED_MODELS, HYDRA_FUSION_MODEL], 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 01de6df42094b3..5a65f76cd924d7 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts @@ -5,6 +5,7 @@ import * as DOM from '../../../../../base/browser/dom.js'; import { Dimension } from '../../../../../base/browser/dom.js'; +import { Dialog } from '../../../../../base/browser/ui/dialog/dialog.js'; import { mainWindow } from '../../../../../base/browser/window.js'; import { assert } from '../../../../../base/common/assert.js'; import { DeferredPromise, timeout } from '../../../../../base/common/async.js'; @@ -23,7 +24,7 @@ import { IModelService } from '../../../../../editor/common/services/model.js'; import { IResolvedTextEditorModel, ITextModelService } from '../../../../../editor/common/services/resolverService.js'; import { CustomizationMarketplaceMediaType, getCustomizationMarketplaceResourceKey, ICustomizationMarketplacePage, ICustomizationMarketplaceQuery, ICustomizationMarketplaceResource, ICustomizationMarketplaceService } from '../../../../../platform/customizationMarketplace/common/customizationMarketplaceService.js'; import { CustomizationMarketplaceConfiguration, CustomizationMarketplaceSources } from '../../../../../platform/customizationMarketplace/common/customizationMarketplaceSources.js'; -import { IDialogService, IFileDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; +import { IConfirmation, IConfirmationResult, IDialogService, IFileDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { IFileContent, IFileService, IFileStatWithMetadata } from '../../../../../platform/files/common/files.js'; import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; import { HoverService } from '../../../../../platform/hover/browser/hoverService.js'; @@ -33,6 +34,7 @@ import { IListService, ListService, WorkbenchList } from '../../../../../platfor import { IQuickInputService } from '../../../../../platform/quickinput/common/quickInput.js'; import { IRequestService } from '../../../../../platform/request/common/request.js'; import { InMemoryStorageService, IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; +import { defaultButtonStyles, defaultCheckboxStyles, defaultDialogStyles, defaultInputBoxStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; import { IRequestContext } from '../../../../../base/parts/request/common/request.js'; import { IMarkdownRendererService, MarkdownRendererService } from '../../../../../platform/markdown/browser/markdownRenderer.js'; import { IWorkspace, IWorkspaceContextService, WorkbenchState } from '../../../../../platform/workspace/common/workspace.js'; @@ -59,9 +61,10 @@ import { ICopilotConnector, ICopilotConnectorsService } from '../../../../contri import { ICustomizationHarnessService, ICustomizationItem, ICustomizationItemProvider, ICustomizationMcpServerCompatibility, ICustomizationSourceFolder, IHarnessDescriptor, createVSCodeHarnessDescriptor } from '../../../../contrib/chat/common/customizationHarnessService.js'; import { IChatSessionsService } from '../../../../contrib/chat/common/chatSessionsService.js'; import { getChatSessionType, LocalChatSessionUri } from '../../../../contrib/chat/common/model/chatUri.js'; -import { ICustomizationMigrationService } from '../../../../contrib/chat/common/promptSyntax/service/customizationMigrationService.js'; +import { CustomizationMigrationType, ICustomizationMigrationService, IMcpServerCustomizationMigrationCandidate } from '../../../../contrib/chat/common/promptSyntax/service/customizationMigrationService.js'; import { ICustomizationMigrationTelemetryService } from '../../../../contrib/chat/common/promptSyntax/service/customizationMigrationTelemetryService.js'; import { CustomizationMigrationService } from '../../../../contrib/chat/browser/aiCustomization/customizationMigrationServiceImpl.js'; +import { CustomizationMigrationCategoryId, getCustomizationMigrationCategory } from '../../../../contrib/chat/browser/aiCustomization/customizationMigrationCategories.js'; import { AgentHostMcpServerMigrationProvider } from '../../../../contrib/chat/browser/agentSessions/agentHost/agentHostMcpServerMigrationProvider.js'; import { IMcpCopilotGlobalConfigurationService } from '../../../../contrib/mcp/common/mcpCopilotGlobalConfigurationService.js'; import { IPromptsService, AgentInstructionFileType, PromptsStorage, IAgentSkill, IChatPromptSlashCommand, IAgentInstructionFile } from '../../../../contrib/chat/common/promptSyntax/service/promptsService.js'; @@ -113,7 +116,7 @@ import { ICodeReviewService } from '../../../../../sessions/contrib/codeReview/b import { createMockCodeReviewService } from './mockCodeReviewService.js'; import { IChatEditingService } from '../../../../contrib/chat/common/editing/chatEditingService.js'; import { IAgentSessionsService } from '../../../../contrib/chat/browser/agentSessions/agentSessionsService.js'; -import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from '../fixtureUtils.js'; +import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices, waitForFixtureCondition } from '../fixtureUtils.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; // Ensure theme colors & widget CSS are loaded @@ -1060,6 +1063,8 @@ interface IRenderEditorOptions { readonly pluginReadmeContent?: string; readonly editorDisplayMode?: 'preview' | 'raw'; readonly migrationDashboard?: boolean; + readonly openFirstMigrationItem?: boolean; + readonly migrationConfirmation?: 'promptFiles' | 'mcpWarning'; readonly migrationActivity?: boolean; } @@ -1204,6 +1209,30 @@ async function renderEditor(ctx: ComponentFixtureContext, options: IRenderEditor const sourceEnabled = () => configurationService.getValue(CustomizationMarketplaceConfiguration.MarketplaceEnabled) === true; ctx.disposableStore.add({ dispose: () => configurationService.onDidChangeConfigurationEmitter.dispose() }); registerWorkbenchServices(reg); + if (options.migrationConfirmation) { + reg.defineInstance(IDialogService, new class extends mock() { + override confirm(confirmation: IConfirmation): Promise { + const dialog = ctx.disposableStore.add(new Dialog( + ctx.container, + confirmation.message, + [confirmation.primaryButton ?? 'Yes', confirmation.cancelButton ?? 'Cancel'], + { + cancelId: 1, + detail: typeof confirmation.detail === 'string' ? confirmation.detail : confirmation.detail?.value, + checkboxLabel: confirmation.checkbox?.label, + checkboxChecked: confirmation.checkbox?.checked, + type: typeof confirmation.type === 'string' ? confirmation.type : undefined, + buttonStyles: defaultButtonStyles, + checkboxStyles: defaultCheckboxStyles, + inputBoxStyles: defaultInputBoxStyles, + dialogStyles: defaultDialogStyles, + }, + )); + void dialog.show(); + return new Promise(() => { }); + } + }()); + } reg.defineInstance(IChatEntitlementService, new class extends mock() { override readonly sentiment = { hidden: false }; override readonly onDidChangeSentiment = Event.None; @@ -2122,6 +2151,56 @@ async function renderEditor(ctx: ComponentFixtureContext, options: IRenderEditor editor.showCustomizationMigrationDashboard(); } + if (options.openFirstMigrationItem) { + await waitForFixtureCondition( + () => ctx.container.querySelector('.migration-tree-item') !== null, + 'Migration item did not appear', + ); + const firstMigrationItem = ctx.container.querySelector('.migration-tree-item'); + if (!firstMigrationItem) { + throw new Error('Migration item not found'); + } + firstMigrationItem.click(); + await waitForFixtureCondition( + () => ctx.container.querySelector('.migration-detail-warning')?.style.display !== 'none', + 'Prompt migration detail warning did not appear', + ); + } + + if (options.migrationConfirmation === 'promptFiles') { + await waitForFixtureCondition( + () => [...ctx.container.querySelectorAll('.migration-tree-group-actions .migration-secondary-button')] + .some(button => button.textContent?.trim().startsWith('Migrate')), + 'The workspace prompt migration action did not render.', + ); + const migrateButton = [...ctx.container.querySelectorAll('.migration-tree-group-actions .migration-secondary-button')] + .find(button => button.textContent?.trim().startsWith('Migrate'))!; + migrateButton.click(); + } else if (options.migrationConfirmation === 'mcpWarning') { + const server: IMcpServerCustomizationMigrationCandidate = { + type: CustomizationMigrationType.McpServers, + storage: PromptsStorage.local, + id: 'fixture-server', + name: 'Repository tools', + sourceUri: URI.file('/workspace/.vscode/mcp.json'), + targetUri: URI.file('/workspace/.mcp.json'), + projectedConfiguration: { type: McpServerType.LOCAL, command: 'node' }, + removedProperties: { gallery: true }, + }; + const confirmation = getCustomizationMigrationCategory(CustomizationMigrationCategoryId.McpServers) + .getConfirmation([server], 'Copilot'); + void instantiationService.get(IDialogService).confirm({ + ...confirmation, + type: 'warning', + }); + } + if (options.migrationConfirmation) { + await waitForFixtureCondition( + () => ctx.container.querySelector('.monaco-dialog-box') !== null, + 'The migration confirmation dialog did not render.', + ); + } + if (options.openFirstItem) { if (options.pluginReadmeContent !== undefined) { const plugin = installedPlugins.find(plugin => plugin.label === 'CircleCI'); @@ -2932,8 +3011,20 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { // need to be migrated because the active harness only consumes skills. AgentHostPromptMigration: defineComponentFixture({ labels: { kind: 'screenshot', blocksCi: false }, + additionalThemes: ['darkHighContrast', 'lightHighContrast'], + expectedVisualDescriptions: ['The yellow migration warning card spans the Overview and keeps Review Migrations emphasized with the warning foreground instead of the generic blue link color.'], + render: ctx => renderEditor(ctx, { + sessionResource: agentHostCopilotSessionResource, + }), + }), + + AgentHostPromptMigrationNarrow: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: false }, + expectedVisualDescriptions: ['In a narrow Overview, the migration warning copy wraps without clipping and Review Migrations remains emphasized with the warning foreground.'], render: ctx => renderEditor(ctx, { sessionResource: agentHostCopilotSessionResource, + width: 550, + height: 650, }), }), @@ -3301,6 +3392,51 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { }), }), + MigrationPromptDetailNarrow: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: false }, + render: ctx => renderEditor(ctx, { + sessionResource: agentHostCopilotSessionResource, + migrationDashboard: true, + openFirstMigrationItem: true, + width: 550, + height: 500, + }), + }), + + MigrationConfirmation: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: false }, + virtualTime: { enabled: false }, + expectedVisualDescriptions: ['A standard confirmation dialog clearly summarizes the prompt-to-skill migration without listing individual source and destination paths. The delete-originals checkbox is visible, and Convert to Skills is the focused primary action.'], + render: ctx => renderEditor(ctx, { + sessionResource: agentHostCopilotSessionResource, + migrationDashboard: true, + migrationConfirmation: 'promptFiles', + }), + }), + + MigrationConfirmationConstrainedHeight: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: false }, + virtualTime: { enabled: false }, + expectedVisualDescriptions: ['In a constrained-height editor, the concise prompt migration confirmation remains fully visible with its checkbox and both actions available.'], + render: ctx => renderEditor(ctx, { + sessionResource: agentHostCopilotSessionResource, + migrationDashboard: true, + migrationConfirmation: 'promptFiles', + height: 500, + }), + }), + + McpMigrationWarningConfirmation: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: false }, + virtualTime: { enabled: false }, + expectedVisualDescriptions: ['The MCP migration warning concisely explains the move and removal behavior without listing per-server source and destination paths. Migrate and Cancel remain visible.'], + render: ctx => renderEditor(ctx, { + sessionResource: agentHostCopilotSessionResource, + migrationDashboard: true, + migrationConfirmation: 'mcpWarning', + }), + }), + MigrationDashboardActivityScrollable: defineComponentFixture({ labels: { kind: 'screenshot', blocksCi: false }, render: ctx => renderEditor(ctx, { @@ -4117,7 +4253,8 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { EmbeddedMcpDetailMigratable: defineComponentFixture({ labels: { kind: 'screenshot', blocksCi: false }, - expectedVisualDescriptions: ['A yellow Migrate MCP Server card appears above Configuration with a warning icon, text explaining that migration is required to keep working, and an emphasized Review Migrations link. No error or compatibility issue card is shown.'], + additionalThemes: ['darkHighContrast', 'lightHighContrast'], + expectedVisualDescriptions: ['A yellow Migrate MCP Server card appears above Configuration with a warning icon, text explaining that migration is required to keep working, and an emphasized Review Migrations link using the warning foreground instead of the generic blue link color. No error or compatibility issue card is shown.'], render: ctx => renderEmbeddedMcpDetail( ctx, makeLocalMcpServer('mcp-postgres', 'PostgreSQL', LocalMcpServerScope.Workspace, 'Database access', { @@ -4135,6 +4272,26 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { ), }), + EmbeddedMcpDetailMigratableNarrow: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: false }, + expectedVisualDescriptions: ['In a narrow MCP detail, the migration warning copy and warning-foreground Review Migrations link remain readable without clipping or horizontal overflow.'], + render: ctx => renderEmbeddedMcpDetail( + ctx, + makeLocalMcpServer('mcp-postgres', 'PostgreSQL', LocalMcpServerScope.Workspace, 'Database access', { + type: McpServerType.LOCAL, + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-postgres'], + }), + { + width: 550, + height: 500, + harnessLabel: 'Copilot', + compatibility: { id: 'mcp-postgres', kind: 'supported' }, + migratable: true, + }, + ), + }), + // Standalone embedded MCP detail widget before marketplace installation. EmbeddedMcpDetailUninstalled: defineComponentFixture({ labels: { kind: 'screenshot', blocksCi: false }, diff --git a/src/vs/workbench/test/browser/workbenchTestServices.ts b/src/vs/workbench/test/browser/workbenchTestServices.ts index 26a78c7fb344ff..61e83b5cb9a2ca 100644 --- a/src/vs/workbench/test/browser/workbenchTestServices.ts +++ b/src/vs/workbench/test/browser/workbenchTestServices.ts @@ -194,6 +194,7 @@ import { IWorkingCopyFileService, WorkingCopyFileService } from '../../services/ import { IWorkingCopyService, WorkingCopyService } from '../../services/workingCopy/common/workingCopyService.js'; import { TestChatEntitlementService, TestContextService, TestExtensionService, TestFileService, TestHistoryService, TestLifecycleService, TestLoggerService, TestMarkerService, TestProductService, TestStorageService, TestTextResourcePropertiesService, TestWorkspaceTrustManagementService, TestWorkspaceTrustRequestService } from '../common/workbenchTestServices.js'; import { DefaultAccountService } from '../../services/accounts/browser/defaultAccount.js'; +import { IAgentHostConnectionsService } from '../../../platform/agentHost/common/agentHostConnectionsService.js'; // Backcompat export export { TestFileService, TestLifecycleService }; @@ -381,6 +382,7 @@ export function workbenchInstantiationService( instantiationService.stub(ICustomEditorLabelService, disposables.add(new CustomEditorLabelService(configService, workspaceContextService))); instantiationService.stub(IHoverService, NullHoverService); instantiationService.stub(IChatEntitlementService, new TestChatEntitlementService()); + instantiationService.stub(IAgentHostConnectionsService, { resolveSessionResourceIdentity: () => undefined }); instantiationService.stub(IManagedSettingsService, new NullManagedSettingsService()); instantiationService.stub(IAccountPolicyGateService, { _serviceBrand: undefined, gateInfo: { state: AccountPolicyGateState.Inactive }, onDidChangeGateInfo: Event.None, whenInitialized: async () => { } }); instantiationService.stub(IMarkdownRendererService, instantiationService.createInstance(MarkdownRendererService)); diff --git a/src/vscode-dts/vscode.proposed.externalUriOpener.d.ts b/src/vscode-dts/vscode.proposed.externalUriOpener.d.ts index ceb28ac33ee592..142bf8a313e6af 100644 --- a/src/vscode-dts/vscode.proposed.externalUriOpener.d.ts +++ b/src/vscode-dts/vscode.proposed.externalUriOpener.d.ts @@ -104,6 +104,12 @@ declare module 'vscode' { * Due to port forwarding, this may not match the `resolvedUri` passed to `openExternalUri`. */ readonly sourceUri: Uri; + + /** + * The preferred column for openers that display the resource in an editor, defaulting to {@link ViewColumn.Active}. + * Use {@link ViewColumn.Beside} to open beside the active editor. + */ + readonly viewColumn?: ViewColumn; } /** diff --git a/test/componentFixtures/playwright/tests/screenshotStability.spec.ts b/test/componentFixtures/playwright/tests/screenshotStability.spec.ts index e7e5b84874dac8..6f3c4de35fc802 100644 --- a/test/componentFixtures/playwright/tests/screenshotStability.spec.ts +++ b/test/componentFixtures/playwright/tests/screenshotStability.spec.ts @@ -68,8 +68,8 @@ for (const theme of ['Dark', 'Light', 'DarkHighContrast', 'LightHighContrast']) await expect(page.locator('.session-view:visible').first().getByRole('button', { name: 'Unpin', exact: true })).toHaveCount(1); await expect(page.locator('.session-view:visible').getByRole('button', { name: 'More Actions...', exact: true })).toHaveCount(scenario.visibleSessions); - const border = await page.locator('.part.sessionspart').evaluate(element => { - const style = getComputedStyle(element); + const border = await page.locator('.session-view:visible').first().evaluate(element => { + const style = getComputedStyle(element, '::after'); return { width: style.borderTopWidth, style: style.borderTopStyle, transparent: style.borderTopColor === 'rgba(0, 0, 0, 0)' }; }); expect(border).toEqual({ width: '1px', style: 'solid', transparent: false }); @@ -119,28 +119,6 @@ test('sessions grid header actions update their owning state', async ({ page }) expect(errors).toEqual([]); }); -test('sessions grid gallery mounts all variants without shared-registration conflicts', async ({ context }) => { - const gallery = await context.newPage(); - const errors: string[] = []; - gallery.on('pageerror', error => errors.push(error.message)); - gallery.on('console', message => { - if (message.type() === 'error' || message.type() === 'warning') { - errors.push(message.text()); - } - }); - try { - await gallery.setViewportSize({ width: 3400, height: 6400 }); - await gallery.goto(`${getBaseURL()}/___explorer?fixture=sessions%2Fgrid%2FsessionsGrid`, { waitUntil: 'networkidle' }); - await expect(gallery.locator('.part.titlebar')).toHaveCount(12, { timeout: 20_000 }); - await expect(gallery.locator('.session-view:visible')).toHaveCount(28); - await expect(gallery.locator('.part.titlebar').getByRole('button', { name: 'New Session', exact: true })).toHaveCount(12); - await expect(gallery.locator('.sessions-account-titlebar-widget')).toHaveCount(12); - expect(errors).toEqual([]); - } finally { - await gallery.close(); - } -}); - async function expectStableScreenshot(page: Page, fixtureId: string, precedingFixtureId: string): Promise { const container = page.locator('#root > div').last(); const initial = await container.screenshot();