From 713b67bc0b73dd855ca83dbd06011ff97377f2e5 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 23 Oct 2026 00:05:02 -0500 Subject: [PATCH] =?UTF-8?q?Reconstruct=20batch=20037/124:=202026-09-03=20?= =?UTF-8?q?=E2=86=92=202026-09-06=20(sessions,=20chat,=20build,=20agenthos?= =?UTF-8?q?t)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Squashed reconstruction of 50 upstream commits (c772f67cd2f..da68c9a29b9). 295 files changed, 13658 insertions(+), 3192 deletions(-) sessions: - Agents - only show the badge for uncommitted changes on the new session page (#334488) - sessions: consolidate remote workspace selection (#334487) - Implements "Diff View" submenu for agents window and multi file diff editors (#334498) - Allow dragging a grouped session back into its workspace section (#333998) - Fix Multi Diff debug command in Agents window - sessions: move unified workspace picker setting (#334554) - Move customization entrypoint into new chat view (#334604) - Keep the chat request edit input opaque over Agents Window backgrounds (#334021) - sessions: redesign unified workspace picker (#334591) - sessions: fix unified workspace picker setting references (#334677) - Enhance cloud sandbox UX for remote connections (#334644) - sessions: drop the composer card, give each control its own surface (#334655) - sessions: remove duplicate workspace picker imports (#334689) - sessions: measure and unblock V3 onboarding GitHub personalization (#334696) - Fix Copilot Sessions Provider to Resolve Changes Summary Correctly (#334695) chat: - Implement migration page for chat customization dialog (#334270) - Improve customization message UI and migration behavior (#334303) - deprecation message for customization location settings + merge migrate.enabled settings (default true) (#334518) - chat: add telemetry for collapsible content toggles (#332656) - chat: Avoid underlining rich link pills (#334545) - Separate the configuration migration settings again (#334546) - chat: restore GitHub issue and PR context attachments (#334544) - chat: fix GitHub context repository selection (#334594) - More model picker improvements (#334674) - chat: always enable turn status pills (#334702) build: - Update Component Explorer packages - Fix Rspack dependency lockfile - Preserve existing transitive dependency versions - Engineering - bump timeout for macOS jobs (#334471) - Support queue-time build title prefixes (#334533) - Make build title prefix optional (#334658) - build: override product Copilot runtime (#334648) agenthost: - Agents - handle errors during branch checkout (#334452) - agentHost: Keep Agent Merge notice changesets empty (#334465) - agentHost: Preserve workspace transition boundaries (#334534) - agentHost: publish client tools before starting turns (#334385) - agentHost: Own SDK auto-download preference (#334316) - agentHost: preserve peer checkpoints after host notices (#334719) extensions/copilot: - fix(copilot): don't auto-retry expired vision attachments, show actionable error (#334129) - Bump version to 1.138.0 (#334539) - Record CAPI X-Copilot-Service-Request-Id on chat telemetry events (#334638) agent host: - Agent Host: Use VS Code certificates for Dev Container CLI (#334490) actionwidget: - Cleanup some minor issues on new model picker (#334522) dictation: - dictation: fix indefinite hangs by adding timeouts to Foundry Local native calls (#334526) remote: - Bump tas-client to 0.4.6 and vscode-tas-client to 0.3.3 (#334538) misc: - Update endgame notebook milestones to 1.137.0 (#334540) pet: - pet: fix peaking when chat pill is present (#334612) policy: - policy: preserve managed sandbox force-on for Agent Host routing (#334654) eslint: - eslint: test no bracket notation rule (#334753) Co-Authored-By: Claude Opus 5.5 --- ...ode-no-bracket-notation-for-identifiers.ts | 8 +- .../github-managed-settings.md | 6 + .vscode/notebooks/endgame.github-issues | 2 +- .vscode/notebooks/my-endgame.github-issues | 2 +- .../common/apply-sdk-canary-override.ts | 40 +- .../common/apply-sdk-canary.yml | 12 +- .../darwin/product-build-darwin-ci.yml | 2 +- .../darwin/product-build-darwin-universal.yml | 2 +- .../darwin/product-build-darwin.yml | 2 +- .../azure-pipelines/product-build-ado-ci.yml | 13 +- build/azure-pipelines/product-build.yml | 24 +- ...odeNoBracketNotationForIdentifiers.test.ts | 80 ++++ build/rspack/package-lock.json | 16 +- build/rspack/package.json | 4 +- build/vite/package-lock.json | 16 +- build/vite/package.json | 4 +- extensions/copilot/package-lock.json | 22 +- extensions/copilot/package.json | 6 +- .../copilotCloudSessionsProvider.ts | 32 +- .../test/copilotCloudSessionsProvider.spec.ts | 33 +- .../test/streamedCompletionSplitter.test.ts | 1 + .../vscode-node/lib/src/openai/fetch.fake.ts | 1 + .../vscode-node/lib/src/test/fetcher.ts | 1 + .../node/oaiLanguageModelServer.ts | 4 +- .../extension/intents/node/toolCallingLoop.ts | 6 +- .../node/toolCallingLoopAutopilot.spec.ts | 14 +- .../extension/prompt/node/chatMLFetcher.ts | 80 +++- .../prompt/node/chatMLFetcherTelemetry.ts | 24 + .../chatMLFetcherResponseApiTelemetry.spec.ts | 32 +- .../src/platform/chat/common/commonTypes.ts | 49 +- .../chat/test/common/commonTypes.spec.ts | 35 ++ .../platform/endpoint/node/chatEndpoint.ts | 4 +- .../src/platform/endpoint/node/messagesApi.ts | 11 +- .../platform/endpoint/node/responsesApi.ts | 8 +- .../endpoint/node/test/responsesApi.spec.ts | 1 + .../node/test/responsesApiToolSearch.spec.ts | 2 +- .../stream.sseProcessor.spec.ts.snap | 10 + .../endpoint/test/node/messagesApi.spec.ts | 1 + .../git/vscode-node/gitServiceImpl.ts | 57 ++- .../src/platform/networking/common/fetch.ts | 29 ++ .../platform/networking/common/networking.ts | 4 + .../networking/node/chatWebSocketManager.ts | 16 + .../networking/node/chatWebSocketTelemetry.ts | 18 + .../networking/test/node/fetch.spec.ts | 11 + .../telemetry/common/telemetryData.ts | 1 + package-lock.json | 28 +- package.json | 8 +- remote/package-lock.json | 8 +- remote/package.json | 2 +- remote/web/package-lock.json | 8 +- remote/web/package.json | 2 +- src/vs/base/browser/ui/radio/radio.css | 29 +- src/vs/base/browser/ui/radio/radio.ts | 54 ++- src/vs/base/common/jsonSchema.ts | 2 +- .../base/test/browser/ui/radio/radio.test.ts | 264 ++++++++++ .../widget/diffEditor/diffEditorWidget.ts | 4 + .../multiDiffEditor/multiDiffEditorWidget.ts | 15 +- .../multiDiffEditorWidgetImpl.ts | 12 + src/vs/editor/common/config/editorOptions.ts | 2 + .../widget/multiDiffEditorWidget.test.ts | 8 +- .../actionWidget/browser/actionList.ts | 386 +++++++++++---- .../browser/tabbedActionListWidget.css | 12 + .../browser/tabbedActionListWidget.ts | 185 +++++++- .../test/browser/actionList.test.ts | 353 +++++++++++++- .../browser/tabbedActionListWidget.test.ts | 403 +++++++++++++++- src/vs/platform/agentHost/common/agent.ts | 2 +- .../agentHostChatContributionsService.ts | 2 + .../agentHost/common/agentHostGitService.ts | 12 +- .../agentHost/common/agentSdkSetup.ts | 53 +-- .../common/meta/agentCheckoutOperationMeta.ts | 45 +- .../meta/agentSystemNotificationMeta.ts | 49 ++ .../meta/agentWorkspaceContinuationMeta.ts | 21 + .../agentHost/common/sessionDataService.ts | 21 + .../agentHost/common/state/sessionState.ts | 27 +- .../node/agentHostChangesetService.ts | 32 +- .../node/agentHostCheckoutOperationHandler.ts | 42 +- .../agentHost/node/agentHostGitService.ts | 44 +- .../node/agentSdkDownloadTelemetry.ts | 7 +- .../agentHost/node/agentSdkDownloader.ts | 25 + .../agentHost/node/agentSdkSetupChannel.ts | 140 ++++-- .../platform/agentHost/node/agentService.ts | 43 +- .../sessionWorkspaceConversionContribution.ts | 86 +++- .../sessionWorkspaceConversionService.ts | 142 +++++- .../agentHost/node/claude/claudeAgent.ts | 31 +- .../node/claude/claudeAgentSdkService.ts | 5 +- .../agentHost/node/codex/codexAgent.ts | 29 +- .../node/devContainerAgentHostService.ts | 92 +++- .../agentHost/node/localAgentHostMetadata.ts | 6 +- .../agentHost/node/sessionDatabase.ts | 371 +++++++++------ .../test/common/agentSdkSetup.test.ts | 55 +-- .../test/common/sessionTestHelpers.ts | 56 ++- .../node/agentHostChangesetService.test.ts | 96 +++- .../agentHostCheckoutOperationHandler.test.ts | 156 +++++- .../agentHostCommitOperationHandler.test.ts | 1 + ...HostDiscardChangesOperationHandler.test.ts | 1 + .../agentHostGitService.integrationTest.ts | 77 +++ ...entHostPullRequestOperationHandler.test.ts | 1 + .../test/node/agentSdkDownloader.test.ts | 42 +- .../test/node/agentSdkSetupChannel.test.ts | 276 +++++++++++ .../agentHost/test/node/agentService.test.ts | 2 + .../test/node/claudeAgent.integrationTest.ts | 5 + .../agentHost/test/node/claudeAgent.test.ts | 33 +- .../test/node/codex/codexCreateChat.test.ts | 2 + .../test/node/codex/codexModelRefresh.test.ts | 1 - .../node/codex/codexPrewarmEviction.test.ts | 7 +- .../agentHost/test/node/copilotAgent.test.ts | 1 + .../test/node/copilotGitProject.test.ts | 1 + .../node/devContainerAgentHostService.test.ts | 146 +++++- .../e2e/suites/managementExtensionsSuite.ts | 2 +- .../test/node/sessionDatabase.test.ts | 265 ++++++++++- .../node/sessionWorkspaceConversion.test.ts | 449 +++++++++++++++++- .../test/node/testAgentSdkDownloader.ts | 9 + .../node/localTranscriptionService.ts | 112 ++++- .../policy/common/copilotManagedSettings.ts | 15 +- .../common/copilotManagedSettings.test.ts | 49 +- src/vs/sessions/AI_CUSTOMIZATIONS.md | 4 +- src/vs/sessions/browser/menus.ts | 1 + .../sessions/browser/parts/chatGroupView.ts | 33 +- .../sessions/browser/parts/chatGroupsView.ts | 4 + src/vs/sessions/browser/parts/chatView.ts | 12 + .../media/remoteHostUnavailableEmptyState.css | 1 + .../parts/remoteHostUnavailableEmptyState.ts | 10 +- .../browser/parts/sessionRemoteConnection.ts | 96 ++-- src/vs/sessions/browser/parts/sessionView.ts | 5 + .../common/agentHostSessionsProvider.ts | 17 + src/vs/sessions/common/customizations.ts | 6 + .../aiCustomizationTreeView.contribution.ts | 19 +- .../changes/browser/changesViewActions.ts | 125 ++++- .../changes/browser/sessionChangesEditor.ts | 15 +- .../browser/sessionChangesEditorInput.ts | 2 +- .../changes/browser/sessionChangesService.ts | 22 +- .../sessionsChangesAccessibilityHelp.ts | 4 +- .../changes/common/sessionChangesService.ts | 2 +- .../test/browser/changesViewActions.test.ts | 143 +++--- .../browser/sessionChangesService.test.ts | 43 +- .../contrib/chat/browser/chat.contribution.ts | 23 +- .../sessions/contrib/chat/browser/chatView.ts | 24 +- .../contrib/chat/browser/media/chatInput.css | 4 - .../contrib/chat/browser/media/chatView.css | 18 +- .../contrib/chat/browser/media/chatWidget.css | 46 +- .../contrib/chat/browser/newChatInput.ts | 82 ++-- .../chat/browser/newChatInputPasteTarget.ts | 26 +- .../contrib/chat/browser/newChatWidget.ts | 122 ++++- .../chat/browser/newSessionComposerService.ts | 17 +- .../chat/browser/newSessionPromptOptions.ts | 5 + .../chat/browser/sessionChatInputToolbar.ts | 11 +- .../chat/browser/sessionTurnChanges.ts | 12 +- .../chat/browser/sessionWorkspacePicker.ts | 245 +++++++--- .../browser/sessionsChatAccessibilityHelp.ts | 8 +- .../unifiedWorkspacePickerConfiguration.ts | 21 + .../sessions/contrib/chat/common/constants.ts | 1 + .../contrib/chat/common/newChatContextIds.ts | 14 + .../chat/test/browser/chatInput.fixture.ts | 2 +- .../chat/test/browser/chatView.test.ts | 85 +++- .../chat/test/browser/newChatInput.test.ts | 256 ++++++---- .../test/browser/newChatInputPaste.test.ts | 259 ++++++---- .../test/browser/newChatWidget.fixture.ts | 55 ++- .../chat/test/browser/newChatWidget.test.ts | 162 +++++-- .../browser/newSessionPromptOptions.test.ts | 83 +++- .../test/browser/sessionTurnChanges.test.ts | 47 ++ .../browser/sessionWorkspacePicker.test.ts | 349 ++++++++++++-- ...nifiedWorkspacePickerConfiguration.test.ts | 36 ++ .../diffEditor.sessions.contribution.ts | 53 ++- .../browser/diffEditorOptionsService.ts | 29 +- .../editor/common/diffEditorOptionsService.ts | 5 +- .../diffEditor.sessions.contribution.test.ts | 33 +- .../browser/diffEditorOptionsService.test.ts | 48 +- .../test/browser/layoutControllerTestUtils.ts | 2 + .../browser/newSessionViewV3Prompt.ts | 348 ++++++++++++-- .../browser/newSessionViewV3Prompt.test.ts | 386 ++++++++++++++- .../browser/agentHostSessionConfigPicker.ts | 69 ++- .../media/agentHostSessionConfigPicker.css | 2 + .../agentHostSessionConfigPicker.test.ts | 186 ++++++-- .../browser/copilotChatSessionsProvider.ts | 213 ++++++--- .../copilotChatSessionsProvider.test.ts | 304 ++++++++++-- .../cloudSandboxAgentHostContribution.ts | 198 ++++---- .../cloudSandboxReadOnlySessionHandler.ts | 7 +- .../remoteAgentHostSessionsProvider.ts | 33 +- .../cloudSandboxAgentHostContribution.test.ts | 276 ++++++++++- .../remoteAgentHostSessionsProvider.test.ts | 38 +- .../customizationsToolbar.contribution.ts | 131 ++++- .../browser/media/customizationsToolbar.css | 16 + .../sessions/browser/views/sessionsList.ts | 95 ++++ .../sessions/browser/views/sessionsView.ts | 110 +++-- .../browser/views/sessionsViewActions.ts | 19 +- .../test/browser/sessionsActions.test.ts | 77 ++- .../test/browser/sessionsList.test.ts | 129 ++++- .../test/browser/sessionsListTestUtils.ts | 17 +- .../test/browser/sessionsRename.test.ts | 3 + .../sessionsRecentWorkspacesService.ts | 75 ++- .../sessions/browser/sessionsService.ts | 9 - .../services/sessions/common/session.ts | 2 + .../browser/sessionsManagementService.test.ts | 30 ++ .../test/browser/chatGroupsView.test.ts | 187 +++++++- ...remoteHostUnavailableEmptyState.fixture.ts | 17 + .../api/common/configurationExtensionPoint.ts | 10 + .../browser/actions/developerActions.ts | 2 +- .../parts/editor/diffEditorCommands.ts | 3 +- .../parts/editor/diffEditorCommandsService.ts | 54 ++- .../parts/editor/editor.contribution.ts | 2 +- .../chatResponseAccessibleView.ts | 7 + .../browser/actions/chatAccessibilityHelp.ts | 1 + .../chat/browser/actions/chatContext.ts | 79 ++- .../agentHost/agentHostResponseFileChanges.ts | 17 +- .../agentHostSdkSetupNotification.ts | 43 +- .../agentHost/agentHostSessionHandler.ts | 46 +- .../agentHost/agentHostSessionInputPills.ts | 8 +- .../agentHost/stateToProgressAdapter.ts | 16 +- .../aiCustomizationManagement.contribution.ts | 6 +- .../aiCustomizationManagement.ts | 7 +- .../aiCustomizationManagementEditor.ts | 192 +++++++- .../aiCustomization/customizationMigration.ts | 28 +- ...stomizationMigrationAvailabilityService.ts | 112 +++++ .../customizationMigrationCategories.ts | 155 +++++- .../customizationMigrationServiceImpl.ts | 92 ++-- .../media/aiCustomizationManagement.css | 44 ++ .../attachments/chatDynamicVariables.ts | 143 +----- .../attachments/chatWidgetPasteTarget.ts | 9 +- .../chat/browser/chat.shared.contribution.ts | 78 ++- src/vs/workbench/contrib/chat/browser/chat.ts | 5 +- .../chatAutoModeResolutionContentPart.ts | 8 +- .../chatCollapsibleContentPart.ts | 62 ++- .../chatCollapsibleMarkdownContentPart.ts | 8 +- .../chatContentParts/chatHookContentPart.ts | 8 +- .../chatMarkdownDecorationsRenderer.ts | 16 - .../chatQuestionCarouselPart.ts | 10 +- .../chatReferencesContentPart.ts | 11 +- .../chatSubagentContentPart.ts | 8 +- .../chatSystemNotificationContentPart.ts | 30 +- .../chatThinkingContentPart.ts | 8 +- .../chatContentParts/chatTurnPillsPart.ts | 6 +- .../chatContentParts/media/chatRichLink.css | 4 + .../chatSystemNotificationContentPart.css | 39 ++ .../chatTerminalToolProgressPart.ts | 15 +- .../chat/browser/widget/chatListRenderer.ts | 37 +- .../chat/browser/widget/chatPetWidget.ts | 11 + .../chat/browser/widget/chatTurnPills.ts | 22 - .../editor/chatInputReferenceDecorations.ts | 22 +- .../widget/input/editor/chatPasteProviders.ts | 198 +++----- .../input/modelPicker/media/modelPicker.css | 48 +- .../input/modelPicker/modelPickerAutoRow.ts | 73 ++- .../modelPicker/modelPickerTabbedWidget.ts | 27 +- .../chat/browser/widget/media/chat.css | 26 +- .../chat/browser/widget/media/chatPet.css | 8 + .../chat/common/attachments/chatVariables.ts | 3 - .../chat/common/chatService/chatService.ts | 9 +- .../common/chatService/chatServiceImpl.ts | 50 +- .../contrib/chat/common/constants.ts | 5 +- .../contrib/chat/common/model/chatModel.ts | 26 +- .../service/customizationMigrationService.ts | 27 +- .../common/requestParser/chatParserTypes.ts | 26 +- .../common/requestParser/chatRequestParser.ts | 2 +- .../chatAccessibilityHelp.test.ts | 11 + .../chatResponseAccessibleView.test.ts | 32 +- .../test/browser/actions/chatContext.test.ts | 131 ++++- .../agentHostResponseFileChanges.test.ts | 62 +++ .../agentHostChatContribution.test.ts | 319 ++++++++++++- .../agentHostSdkSetupNotification.test.ts | 130 ++++- .../stateToProgressAdapter.test.ts | 88 +++- .../aiCustomizationManagement.test.ts | 13 +- .../aiCustomizationManagementEditor.test.ts | 135 +++++- .../customizationMigration.test.ts | 169 ++++++- ...zationMigrationAvailabilityService.test.ts | 211 ++++++++ .../customizationMigrationServiceImpl.test.ts | 67 ++- .../browser/attachments/chatVariables.test.ts | 105 +--- .../chatSystemNotificationContentPart.test.ts | 81 +++- .../chatTerminalToolProgressPart.test.ts | 65 +++ .../chatThinkingContentPart.test.ts | 34 ++ .../browser/widget/chatListRenderer.test.ts | 172 ++++--- .../browser/widget/chatListWidget.test.ts | 1 - .../test/browser/widget/chatPetWidget.test.ts | 50 +- .../input/editor/chatPasteProviders.test.ts | 274 ++++------- .../modelPicker/modelPickerAutoRow.test.ts | 443 ++++++++++++++++- .../common/chatService/chatService.test.ts | 71 ++- .../common/chatService/mockChatService.ts | 3 +- .../requestParser/chatRequestParser.test.ts | 46 -- .../multiDiffEditor/browser/actions.ts | 20 +- .../browser/multiDiffEditor.ts | 13 + .../browser/media/settingsEditor2.css | 9 + .../preferences/browser/settingsTree.ts | 9 +- .../test/browser/settingsTree.test.ts | 70 ++- .../agentHost/browser/agentSdkSetupService.ts | 55 +-- .../policies/common/accountPolicyService.ts | 5 +- .../test/browser/accountPolicyService.test.ts | 74 ++- .../preferences/common/preferences.ts | 1 + .../preferences/common/preferencesModels.ts | 3 + .../test/common/preferencesModels.test.ts | 27 ++ .../chat/chatTerminalCollapsible.fixture.ts | 1 + .../chat/chatTurnPills.fixture.ts | 18 +- .../chat/chatWidget.fixture.ts | 18 +- .../chat/tabbedModelPicker.fixture.ts | 109 ++++- ...aiCustomizationManagementEditor.fixture.ts | 103 ++-- .../sessionChatInputToolbar.fixture.ts | 16 +- .../editor/diffEditorCommandsService.test.ts | 28 ++ .../blocks-ci-screenshots.md | 104 ++-- 295 files changed, 13658 insertions(+), 3192 deletions(-) create mode 100644 build/lib/test/codeNoBracketNotationForIdentifiers.test.ts create mode 100644 src/vs/base/test/browser/ui/radio/radio.test.ts create mode 100644 src/vs/platform/agentHost/common/meta/agentWorkspaceContinuationMeta.ts create mode 100644 src/vs/platform/agentHost/test/node/agentSdkSetupChannel.test.ts create mode 100644 src/vs/sessions/common/customizations.ts create mode 100644 src/vs/sessions/contrib/chat/browser/unifiedWorkspacePickerConfiguration.ts create mode 100644 src/vs/sessions/contrib/chat/test/browser/unifiedWorkspacePickerConfiguration.test.ts create mode 100644 src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMigrationAvailabilityService.ts create mode 100644 src/vs/workbench/contrib/chat/test/browser/aiCustomization/customizationMigrationAvailabilityService.test.ts diff --git a/.eslint-plugin-local/code-no-bracket-notation-for-identifiers.ts b/.eslint-plugin-local/code-no-bracket-notation-for-identifiers.ts index 2b8004851ce478..55d9748332c8d5 100644 --- a/.eslint-plugin-local/code-no-bracket-notation-for-identifiers.ts +++ b/.eslint-plugin-local/code-no-bracket-notation-for-identifiers.ts @@ -39,9 +39,6 @@ export default new class NoBracketNotationForIdentifiers implements eslint.Rule. * Check if a string is a valid JavaScript identifier */ function isValidIdentifier(str: string): boolean { - if (str.includes('\\')) { - return false; - } const scanner = ts.createScanner(ts.ScriptTarget.Latest, false, ts.LanguageVariant.Standard, str); const token = scanner.scan(); const isIdentifierName = token === ts.SyntaxKind.Identifier @@ -63,6 +60,11 @@ export default new class NoBracketNotationForIdentifiers implements eslint.Rule. return; } + // Preserve escaped property names instead of replacing their authored representation. + if (memberExpr.property.raw.includes('\\')) { + return; + } + const propertyName = memberExpr.property.value; // If it's a valid identifier, report it diff --git a/.github/skills/policy-and-managed-settings/github-managed-settings.md b/.github/skills/policy-and-managed-settings/github-managed-settings.md index 16dfb499d8c5b8..55f732f83434f5 100644 --- a/.github/skills/policy-and-managed-settings/github-managed-settings.md +++ b/.github/skills/policy-and-managed-settings/github-managed-settings.md @@ -63,6 +63,12 @@ tampered with. The merged bag is then projected onto the declared schema (see be Client-side merging still happens *within* a channel's value (e.g. `enabledPlugins`, `extraKnownMarketplaces`). +**Sandbox routing exception:** `sandbox.enabled` follows the runtime's `force-on-wins` +contract: `true` from any managed channel wins over `false` from another channel. +The shared resolver supplies this result to harness selection, permissions UI, and +Policy Diagnostics; runtime enforcement remains authoritative. Other keys retain +their existing delivery-channel precedence. + ## Schema source of truth When the developer has `copilot-agent-runtime` checked out side-by-side, reference diff --git a/.vscode/notebooks/endgame.github-issues b/.vscode/notebooks/endgame.github-issues index 0252b71f8a4694..ba164616b9a1da 100644 --- a/.vscode/notebooks/endgame.github-issues +++ b/.vscode/notebooks/endgame.github-issues @@ -7,7 +7,7 @@ { "kind": 2, "language": "github-issues", - "value": "$MILESTONE=milestone:\"1.136.0\"\n\n$TPI_CREATION=2026-03-23 // Used to find fixes that need to be verified" + "value": "$MILESTONE=milestone:\"1.137.0\"\n\n$TPI_CREATION=2026-03-23 // Used to find fixes that need to be verified" }, { "kind": 1, diff --git a/.vscode/notebooks/my-endgame.github-issues b/.vscode/notebooks/my-endgame.github-issues index 9f86214b7eac9a..c79bf7ae97d019 100644 --- a/.vscode/notebooks/my-endgame.github-issues +++ b/.vscode/notebooks/my-endgame.github-issues @@ -7,7 +7,7 @@ { "kind": 2, "language": "github-issues", - "value": "$MILESTONE=milestone:\"1.136.0\"\n\n$MINE=assignee:@me" + "value": "$MILESTONE=milestone:\"1.137.0\"\n\n$MINE=assignee:@me" }, { "kind": 2, diff --git a/build/azure-pipelines/common/apply-sdk-canary-override.ts b/build/azure-pipelines/common/apply-sdk-canary-override.ts index d3a532cfb9bee0..6b67b56d3b04dd 100644 --- a/build/azure-pipelines/common/apply-sdk-canary-override.ts +++ b/build/azure-pipelines/common/apply-sdk-canary-override.ts @@ -17,19 +17,9 @@ import { execFileSync } from 'child_process'; * node_modules cache key, derived from these manifests + lockfiles, naturally * misses). * - * Driven by environment variables so it is a no-op in normal builds: - * VSCODE_SDK_CANARY_VERSION - version to pin `@github/copilot-sdk` to (empty = - * no override / normal build). The sentinel - * `latest-canary` resolves to the newest - * `@github/copilot-sdk` canary on the feed here, - * inside the build. - * VSCODE_CLI_CANARY_VERSION - version to pin `@github/copilot` to. When empty - * (and an SDK version is set) the CLI version is - * inferred from the SDK's own `@github/copilot` - * dependency so the two stay compatible. When set - * explicitly, it is validated against that same - * dependency range and the build fails fast on a - * confirmed incompatible SDK/CLI pair. + * Driven by environment variables so SDK and runtime overrides can be applied + * independently. Product builds use an exact checked-in runtime parameter + * default while retaining the SDK version from the OSS manifest. * * npm registry + auth must already be configured in the ambient environment * (the orchestrator authenticates to the private feed before invoking this). @@ -150,9 +140,7 @@ function assertCliSatisfiesSdk(sdkVersion: string, cliVersion: string): void { * orchestrator that queues the build never needs feed-read access. * * Canary versions look like `X.Y.Z-canary..g`; "newest" is the highest - * `[X, Y, Z, N]` tuple (numeric, so `canary.9` < `canary.10`). The resolved - * concrete version is also emitted as an ADO build tag (`sdk-canary=`) - * so the orchestrator can read it back for accurate Slack reporting. + * `[X, Y, Z, N]` tuple (numeric, so `canary.9` < `canary.10`). */ function resolveLatestCanary(): string { const versionsRaw = execFileSync(NPM, ['view', '@github/copilot-sdk', 'versions', '--json'], { encoding: 'utf8', shell: IS_WINDOWS }); @@ -189,9 +177,7 @@ function resolveLatestCanary(): string { function collectOverrides(): Override[] { let sdkVersion = (process.env['VSCODE_SDK_CANARY_VERSION'] ?? '').trim(); - if (!sdkVersion) { - return []; - } + const explicitCli = (process.env['VSCODE_CLI_CANARY_VERSION'] ?? '').trim(); // `latest-canary` sentinel: resolve the newest published @github/copilot-sdk // canary here, inside the build, where private-feed npm auth already exists — // so the GitHub-side orchestrator that queues this build never needs @@ -199,18 +185,20 @@ function collectOverrides(): Override[] { if (sdkVersion === 'latest-canary') { sdkVersion = resolveLatestCanary(); } - assertSafeSpec('SDK canary version', sdkVersion); - const overrides: Override[] = [{ name: '@github/copilot-sdk', version: sdkVersion }]; + const overrides: Override[] = []; + if (sdkVersion) { + assertSafeSpec('SDK canary version', sdkVersion); + overrides.push({ name: '@github/copilot-sdk', version: sdkVersion }); + } - // Explicit CLI version wins (but must be compatible with the SDK); empty - // means "infer a compatible CLI from the SDK". - const explicitCli = (process.env['VSCODE_CLI_CANARY_VERSION'] ?? '').trim(); let cliVersion: string | undefined; if (explicitCli) { assertSafeSpec('CLI canary version', explicitCli); - assertCliSatisfiesSdk(sdkVersion, explicitCli); + if (sdkVersion) { + assertCliSatisfiesSdk(sdkVersion, explicitCli); + } cliVersion = explicitCli; - } else { + } else if (sdkVersion) { cliVersion = inferCliVersion(sdkVersion); } if (cliVersion) { diff --git a/build/azure-pipelines/common/apply-sdk-canary.yml b/build/azure-pipelines/common/apply-sdk-canary.yml index 36bf0bc8540ba1..7d13c45cd70579 100644 --- a/build/azure-pipelines/common/apply-sdk-canary.yml +++ b/build/azure-pipelines/common/apply-sdk-canary.yml @@ -1,4 +1,4 @@ -# SDK canary integration (no-op unless VSCODE_SDK_CANARY_VERSION is set). +# Copilot product dependency integration (no-op unless an override is set). # Overrides @github/copilot-sdk / @github/copilot to a version published to the # private feed and refreshes the lockfile so the node_modules cache key below # misses and `npm ci` installs the canary. Authenticates to the feed first so @@ -16,32 +16,32 @@ steps: - powershell: | npm config set registry $env:NPM_REGISTRY Write-Host "##vso[task.setvariable variable=SDK_CANARY_NPMRC_PATH]$(npm config get userconfig)" - condition: and(succeeded(), ne(variables['VSCODE_SDK_CANARY_VERSION'], '')) + condition: and(succeeded(), or(ne(variables['VSCODE_SDK_CANARY_VERSION'], ''), ne(variables['VSCODE_CLI_CANARY_VERSION'], ''))) displayName: SDK canary — set registry for auth - ${{ else }}: - script: | set -e npm config set registry "$NPM_REGISTRY" echo "##vso[task.setvariable variable=SDK_CANARY_NPMRC_PATH]$(npm config get userconfig)" - condition: and(succeeded(), ne(variables['VSCODE_SDK_CANARY_VERSION'], '')) + condition: and(succeeded(), or(ne(variables['VSCODE_SDK_CANARY_VERSION'], ''), ne(variables['VSCODE_CLI_CANARY_VERSION'], ''))) displayName: SDK canary — set registry for auth - task: npmAuthenticate@0 inputs: workingFile: $(SDK_CANARY_NPMRC_PATH) - condition: and(succeeded(), ne(variables['VSCODE_SDK_CANARY_VERSION'], '')) + condition: and(succeeded(), or(ne(variables['VSCODE_SDK_CANARY_VERSION'], ''), ne(variables['VSCODE_CLI_CANARY_VERSION'], ''))) displayName: SDK canary — authenticate feed - ${{ if eq(parameters.windows, true) }}: - powershell: node build/azure-pipelines/common/apply-sdk-canary-override.ts - condition: and(succeeded(), ne(variables['VSCODE_SDK_CANARY_VERSION'], '')) + condition: and(succeeded(), or(ne(variables['VSCODE_SDK_CANARY_VERSION'], ''), ne(variables['VSCODE_CLI_CANARY_VERSION'], ''))) env: VSCODE_SDK_CANARY_VERSION: $(VSCODE_SDK_CANARY_VERSION) VSCODE_CLI_CANARY_VERSION: $(VSCODE_CLI_CANARY_VERSION) displayName: Apply SDK canary override - ${{ else }}: - script: node build/azure-pipelines/common/apply-sdk-canary-override.ts - condition: and(succeeded(), ne(variables['VSCODE_SDK_CANARY_VERSION'], '')) + condition: and(succeeded(), or(ne(variables['VSCODE_SDK_CANARY_VERSION'], ''), ne(variables['VSCODE_CLI_CANARY_VERSION'], ''))) env: VSCODE_SDK_CANARY_VERSION: $(VSCODE_SDK_CANARY_VERSION) VSCODE_CLI_CANARY_VERSION: $(VSCODE_CLI_CANARY_VERSION) diff --git a/build/azure-pipelines/darwin/product-build-darwin-ci.yml b/build/azure-pipelines/darwin/product-build-darwin-ci.yml index ac9d65e4e9827c..b6635a5aca62ca 100644 --- a/build/azure-pipelines/darwin/product-build-darwin-ci.yml +++ b/build/azure-pipelines/darwin/product-build-darwin-ci.yml @@ -7,7 +7,7 @@ parameters: jobs: - job: macOS${{ parameters.VSCODE_TEST_SUITE }} displayName: ${{ parameters.VSCODE_TEST_SUITE }} Tests - timeoutInMinutes: 120 + timeoutInMinutes: 240 variables: VSCODE_ARCH: arm64 BUILDS_API_URL: $(System.CollectionUri)$(System.TeamProject)/_apis/build/builds/$(Build.BuildId)/ diff --git a/build/azure-pipelines/darwin/product-build-darwin-universal.yml b/build/azure-pipelines/darwin/product-build-darwin-universal.yml index 293c87f0d58644..e14303dbb959d9 100644 --- a/build/azure-pipelines/darwin/product-build-darwin-universal.yml +++ b/build/azure-pipelines/darwin/product-build-darwin-universal.yml @@ -1,7 +1,7 @@ jobs: - job: macOSUniversal displayName: macOS (UNIVERSAL) - timeoutInMinutes: 120 + timeoutInMinutes: 240 variables: VSCODE_ARCH: universal BUILDS_API_URL: $(System.CollectionUri)$(System.TeamProject)/_apis/build/builds/$(Build.BuildId)/ diff --git a/build/azure-pipelines/darwin/product-build-darwin.yml b/build/azure-pipelines/darwin/product-build-darwin.yml index 005c6bb58caa24..a76f9e3ee7b2ba 100644 --- a/build/azure-pipelines/darwin/product-build-darwin.yml +++ b/build/azure-pipelines/darwin/product-build-darwin.yml @@ -16,7 +16,7 @@ parameters: jobs: - job: macOS_${{ parameters.VSCODE_ARCH }} displayName: macOS (${{ upper(parameters.VSCODE_ARCH) }}) - timeoutInMinutes: 120 + timeoutInMinutes: 240 variables: VSCODE_ARCH: ${{ parameters.VSCODE_ARCH }} BUILDS_API_URL: $(System.CollectionUri)$(System.TeamProject)/_apis/build/builds/$(Build.BuildId)/ diff --git a/build/azure-pipelines/product-build-ado-ci.yml b/build/azure-pipelines/product-build-ado-ci.yml index 5fcf81dbe211c4..2594f4e5be8454 100644 --- a/build/azure-pipelines/product-build-ado-ci.yml +++ b/build/azure-pipelines/product-build-ado-ci.yml @@ -73,17 +73,17 @@ parameters: displayName: "Use legacy OSS Notice" type: boolean default: false - # SDK -> VS Code integration: override the Copilot dependencies to a version - # already published to the private feed. 'none'/'auto' = normal build. + # SDK -> VS Code integration: override Copilot dependencies with versions + # already published to the private feed. 'none' = use the OSS SDK version. # See microsoft/vscode-engineering specs/sdk-vscode-integration.spec.md. - name: VSCODE_SDK_CANARY_VERSION displayName: "SDK canary: @github/copilot-sdk version ('none' = normal build)" type: string default: none - name: VSCODE_CLI_CANARY_VERSION - displayName: "SDK canary: @github/copilot version ('auto' = infer from the SDK)" + displayName: "Product @github/copilot runtime version ('auto' = infer from an SDK override)" type: string - default: auto + default: 1.0.84-canary.67.g6496fad.unsigned variables: - name: VSCODE_PRIVATE_BUILD @@ -94,9 +94,8 @@ variables: value: false - name: NPM_REGISTRY value: ${{ parameters.NPM_REGISTRY }} - # Normalize the canary sentinels ('none'/'auto') back to empty so the gated - # node-modules steps (condition: ne(..., '')) stay a no-op for normal builds - # and the override script infers the CLI when it is empty. + # Normalize sentinels back to empty so the override script can distinguish + # the normal SDK and inferred runtime paths. - name: VSCODE_SDK_CANARY_VERSION ${{ if eq(parameters.VSCODE_SDK_CANARY_VERSION, 'none') }}: value: "" diff --git a/build/azure-pipelines/product-build.yml b/build/azure-pipelines/product-build.yml index 18a439903b7db9..392b82420e5972 100644 --- a/build/azure-pipelines/product-build.yml +++ b/build/azure-pipelines/product-build.yml @@ -43,6 +43,10 @@ parameters: values: - Product - CI + - name: VSCODE_BUILD_TITLE_PREFIX + displayName: "Build Title Prefix" + type: string + default: none - name: NPM_REGISTRY displayName: "Custom NPM Registry" type: string @@ -122,26 +126,30 @@ parameters: displayName: "Run sanity tests against this build's artifacts (for unpublished builds)" type: boolean default: false - # SDK -> VS Code integration: override the Copilot dependencies to a version - # already published to the private feed. 'none'/'auto' = normal build. + # SDK -> VS Code integration: override Copilot dependencies with versions + # already published to the private feed. 'none' = use the OSS SDK version. # See microsoft/vscode-engineering specs/sdk-vscode-integration.spec.md. - name: VSCODE_SDK_CANARY_VERSION displayName: "SDK canary: @github/copilot-sdk version ('none' = normal build)" type: string default: none - name: VSCODE_CLI_CANARY_VERSION - displayName: "SDK canary: @github/copilot version ('auto' = infer from the SDK)" + displayName: "Product @github/copilot runtime version ('auto' = infer from an SDK override)" type: string - default: auto + default: 1.0.84-canary.67.g6496fad.unsigned variables: + - name: VSCODE_BUILD_TITLE_PREFIX + ${{ if eq(parameters.VSCODE_BUILD_TITLE_PREFIX, 'none') }}: + value: "" + ${{ else }}: + value: ${{ parameters.VSCODE_BUILD_TITLE_PREFIX }} - name: VSCODE_PRIVATE_BUILD value: ${{ ne(variables['Build.Repository.Uri'], 'https://github.com/microsoft/vscode.git') }} - name: NPM_REGISTRY value: ${{ parameters.NPM_REGISTRY }} - # Normalize the canary sentinels ('none'/'auto') back to empty so the gated - # node-modules steps (condition: ne(..., '')) stay a no-op for normal builds - # and the override script infers the CLI when it is empty. + # Normalize sentinels back to empty so the override script can distinguish + # the normal SDK and inferred runtime paths. - name: VSCODE_SDK_CANARY_VERSION ${{ if eq(parameters.VSCODE_SDK_CANARY_VERSION, 'none') }}: value: "" @@ -217,7 +225,7 @@ variables: - name: ARTIFACT_PREFIX value: '' -name: "$(Date:yyyyMMdd).$(Rev:r) (${{ parameters.VSCODE_QUALITY }})" +name: "$(VSCODE_BUILD_TITLE_PREFIX)$(Date:yyyyMMdd).$(Rev:r) (${{ parameters.VSCODE_QUALITY }})" resources: repositories: diff --git a/build/lib/test/codeNoBracketNotationForIdentifiers.test.ts b/build/lib/test/codeNoBracketNotationForIdentifiers.test.ts new file mode 100644 index 00000000000000..cfb1bac25a5b72 --- /dev/null +++ b/build/lib/test/codeNoBracketNotationForIdentifiers.test.ts @@ -0,0 +1,80 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { RuleTester } from 'eslint'; +import { suite, test } from 'node:test'; +import rule from '../../../.eslint-plugin-local/code-no-bracket-notation-for-identifiers.ts'; + +RuleTester.describe = suite; +RuleTester.it = test; + +new RuleTester().run('code-no-bracket-notation-for-identifiers', rule, { + valid: [ + 'object.property;', + 'object[computedProperty];', + 'object[42];', + 'object["property-with-dashes"];', + 'object["property with spaces"];', + 'object[`property`];', + String.raw`object["\u0061"];`, + String.raw`object["a\x62"];`, + ], + invalid: [ + { + name: 'normal property', + code: 'object["property"];', + output: 'object.property;', + errors: [{ messageId: 'noBracketNotation', data: { property: 'property' } }], + }, + { + name: 'private property', + code: 'object["_privateProperty"];', + output: 'object._privateProperty;', + errors: [{ messageId: 'noBracketNotation', data: { property: '_privateProperty' } }], + }, + { + name: 'keyword property', + code: 'object["default"];', + output: 'object.default;', + errors: [{ messageId: 'noBracketNotation', data: { property: 'default' } }], + }, + { + name: 'Unicode property', + code: 'object["π"];', + output: 'object.π;', + errors: [{ messageId: 'noBracketNotation', data: { property: 'π' } }], + }, + { + name: 'optional property access', + code: 'object?.["property"];', + output: 'object?.property;', + errors: [{ messageId: 'noBracketNotation', data: { property: 'property' } }], + }, + { + name: 'integer literal property access', + code: '1["toString"];', + output: '(1).toString;', + errors: [{ messageId: 'noBracketNotation', data: { property: 'toString' } }], + }, + { + name: 'decimal literal property access', + code: '1.5["toString"];', + output: '(1.5).toString;', + errors: [{ messageId: 'noBracketNotation', data: { property: 'toString' } }], + }, + { + name: 'comment before property', + code: 'object[/* comment */"property"];', + output: null, + errors: [{ messageId: 'noBracketNotation', data: { property: 'property' } }], + }, + { + name: 'comment after property', + code: 'object["property"/* comment */];', + output: null, + errors: [{ messageId: 'noBracketNotation', data: { property: 'property' } }], + }, + ], +}); diff --git a/build/rspack/package-lock.json b/build/rspack/package-lock.json index 817e8d68f0a4db..e8637c93dc029e 100644 --- a/build/rspack/package-lock.json +++ b/build/rspack/package-lock.json @@ -9,8 +9,8 @@ "version": "1.0.0", "license": "MIT", "dependencies": { - "@vscode/component-explorer": "^0.2.1-94", - "@vscode/component-explorer-webpack-plugin": "^0.3.1-89" + "@vscode/component-explorer": "^0.2.1-137", + "@vscode/component-explorer-webpack-plugin": "^0.3.1-130" }, "devDependencies": { "@rspack/cli": "^2.1.2", @@ -446,9 +446,9 @@ } }, "node_modules/@vscode/component-explorer": { - "version": "0.2.1-94", - "resolved": "https://registry.npmjs.org/@vscode/component-explorer/-/component-explorer-0.2.1-94.tgz", - "integrity": "sha512-IQftRt3pgRhz043tkIMZ5rwZB/TjZvVU7+1tymlf6VuiIkIKev1J684BCFoFp0tZfa6D7sGCZjp1VzkV4xZGTw==", + "version": "0.2.1-137", + "resolved": "https://registry.npmjs.org/@vscode/component-explorer/-/component-explorer-0.2.1-137.tgz", + "integrity": "sha512-Xhbm4UTjLtQxdAC3EQz2rj4T5bnP/oU4QCuuRzumkkhwGRx6yN6wQ3b0FgBej/unbzNd4JNowvT73NNFURKnjA==", "license": "MIT", "dependencies": { "react": "^18.3.1", @@ -457,9 +457,9 @@ } }, "node_modules/@vscode/component-explorer-webpack-plugin": { - "version": "0.3.1-89", - "resolved": "https://registry.npmjs.org/@vscode/component-explorer-webpack-plugin/-/component-explorer-webpack-plugin-0.3.1-89.tgz", - "integrity": "sha512-QN2RUfXlSAQZAC9KC1WSXpnW1OBUR6YtjgPy1SB2LP9KWjdFTIT5EAZSeZvReBtVIO57diLl/dON09RIx7wXTA==", + "version": "0.3.1-130", + "resolved": "https://registry.npmjs.org/@vscode/component-explorer-webpack-plugin/-/component-explorer-webpack-plugin-0.3.1-130.tgz", + "integrity": "sha512-MChqoBLWQUn37pttYLG9QTttBDr+keUDwtAZvBrRuA2NUYbkuCCSexvPsbT5XG5nHGFGd3aAfn+72xmGgMBizw==", "license": "MIT", "dependencies": { "tinyglobby": "^0.2.16" diff --git a/build/rspack/package.json b/build/rspack/package.json index e22f6494e91742..bfd15cf3e20238 100644 --- a/build/rspack/package.json +++ b/build/rspack/package.json @@ -14,7 +14,7 @@ "source-map-loader": "^5.0.0" }, "dependencies": { - "@vscode/component-explorer": "^0.2.1-94", - "@vscode/component-explorer-webpack-plugin": "^0.3.1-89" + "@vscode/component-explorer": "^0.2.1-137", + "@vscode/component-explorer-webpack-plugin": "^0.3.1-130" } } diff --git a/build/vite/package-lock.json b/build/vite/package-lock.json index 497d36af234994..50bd02363ee46b 100644 --- a/build/vite/package-lock.json +++ b/build/vite/package-lock.json @@ -8,8 +8,8 @@ "name": "@vscode/sample-source", "version": "0.0.0", "devDependencies": { - "@vscode/component-explorer": "^0.2.1-94", - "@vscode/component-explorer-vite-plugin": "^0.2.1-94", + "@vscode/component-explorer": "^0.2.1-137", + "@vscode/component-explorer-vite-plugin": "^0.2.1-137", "@vscode/rollup-plugin-esm-url": "^1.0.1-1", "rollup": "*", "vite": "npm:rolldown-vite@latest" @@ -683,9 +683,9 @@ "license": "MIT" }, "node_modules/@vscode/component-explorer": { - "version": "0.2.1-94", - "resolved": "https://registry.npmjs.org/@vscode/component-explorer/-/component-explorer-0.2.1-94.tgz", - "integrity": "sha512-IQftRt3pgRhz043tkIMZ5rwZB/TjZvVU7+1tymlf6VuiIkIKev1J684BCFoFp0tZfa6D7sGCZjp1VzkV4xZGTw==", + "version": "0.2.1-137", + "resolved": "https://registry.npmjs.org/@vscode/component-explorer/-/component-explorer-0.2.1-137.tgz", + "integrity": "sha512-Xhbm4UTjLtQxdAC3EQz2rj4T5bnP/oU4QCuuRzumkkhwGRx6yN6wQ3b0FgBej/unbzNd4JNowvT73NNFURKnjA==", "dev": true, "license": "MIT", "dependencies": { @@ -695,9 +695,9 @@ } }, "node_modules/@vscode/component-explorer-vite-plugin": { - "version": "0.2.1-94", - "resolved": "https://registry.npmjs.org/@vscode/component-explorer-vite-plugin/-/component-explorer-vite-plugin-0.2.1-94.tgz", - "integrity": "sha512-e8Asjy4BIgrGsg4Yon+F6H07JkdLbYK6pTjezqRBTgthcos/NzuM7RsGpbtGeUQ5juVZ9X07Itdl/4OzxA1xLg==", + "version": "0.2.1-137", + "resolved": "https://registry.npmjs.org/@vscode/component-explorer-vite-plugin/-/component-explorer-vite-plugin-0.2.1-137.tgz", + "integrity": "sha512-GZm9ZzzlSlyIiFs4AV30Ahhu++tfv9R6uN0VkD0RXO3CUv2GkmywvUJQE9k7sGp89Hs44ghLW7Zcd7gpzEYJxA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/build/vite/package.json b/build/vite/package.json index bb7253fa2aa548..bc1c848bf03774 100644 --- a/build/vite/package.json +++ b/build/vite/package.json @@ -9,8 +9,8 @@ "preview": "vite preview" }, "devDependencies": { - "@vscode/component-explorer": "^0.2.1-94", - "@vscode/component-explorer-vite-plugin": "^0.2.1-94", + "@vscode/component-explorer": "^0.2.1-137", + "@vscode/component-explorer-vite-plugin": "^0.2.1-137", "@vscode/rollup-plugin-esm-url": "^1.0.1-1", "rollup": "*", "vite": "npm:rolldown-vite@latest" diff --git a/extensions/copilot/package-lock.json b/extensions/copilot/package-lock.json index a0dd14fac482d0..39fc827a9c0079 100644 --- a/extensions/copilot/package-lock.json +++ b/extensions/copilot/package-lock.json @@ -1,12 +1,12 @@ { "name": "copilot-chat", - "version": "0.65.0", + "version": "0.66.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "copilot-chat", - "version": "0.65.0", + "version": "0.66.0", "hasInstallScript": true, "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { @@ -53,7 +53,7 @@ "markdown-it": "^14.2.0", "minimatch": "^10.2.1", "undici": "^7.24.1", - "vscode-tas-client": "^0.3.1", + "vscode-tas-client": "^0.3.3", "web-tree-sitter": "^0.23.0" }, "devDependencies": { @@ -143,7 +143,7 @@ "engines": { "node": ">=22.14.0", "npm": ">=9.0.0", - "vscode": "^1.137.0" + "vscode": "^1.138.0" } }, "node_modules/@anthropic-ai/sdk": { @@ -15741,9 +15741,9 @@ } }, "node_modules/tas-client": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/tas-client/-/tas-client-0.4.3.tgz", - "integrity": "sha512-6bqNgMv7ys5PL6Zqz+EoR8J5KrhAGFjodUPkcpM80DHFakKiWcjqKiID5qxsssC/E70fcgYYWPAUK7CWS29b+Q==", + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/tas-client/-/tas-client-0.4.6.tgz", + "integrity": "sha512-mmefC5nu8YZ5zGY4IyeXWnvK6YWv2YCz60/7q5banNU+R8JGZT2Q4lOS4g4xJyvnQIbqBFXzMFPsIitI8kVXqA==", "license": "MIT", "engines": { "node": ">=22" @@ -16545,12 +16545,12 @@ "dev": true }, "node_modules/vscode-tas-client": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/vscode-tas-client/-/vscode-tas-client-0.3.1.tgz", - "integrity": "sha512-UD9rTFHEpFfUQgbIVsugNYk17E5zn6JW/VGwAIDmcBWT0XvYEgb6xP9m1FamdjTEvBo4o039S/1uaropj1H9FQ==", + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/vscode-tas-client/-/vscode-tas-client-0.3.3.tgz", + "integrity": "sha512-oygEhcZEd4qjC46LdfN8s8I/U8EU2hNL1J8ZKKHAe3o+Wrm2YwLHk6d9HXkIWO48UQbwByM4Ns635SqctO8eTA==", "license": "MIT", "dependencies": { - "tas-client": "^0.4.3" + "tas-client": "^0.4.6" }, "engines": { "vscode": "^1.85.0" diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index ef1c3d8c91d017..c014c84ad103e0 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -2,7 +2,7 @@ "name": "copilot-chat", "displayName": "GitHub Copilot", "description": "AI chat features powered by Copilot", - "version": "0.65.0", + "version": "0.66.0", "build": "1", "completionsCoreVersion": "1.378.1799", "internalLargeStorageAriaKey": "ec712b3202c5462fb6877acae7f1f9d7-c19ad55e-3e3c-4f99-984b-827f6d95bd9e-6917", @@ -22,7 +22,7 @@ "icon": "assets/copilot.png", "pricing": "Trial", "engines": { - "vscode": "^1.137.0", + "vscode": "^1.138.0", "npm": ">=9.0.0", "node": ">=22.14.0" }, @@ -7116,7 +7116,7 @@ "markdown-it": "^14.2.0", "minimatch": "^10.2.1", "undici": "^7.24.1", - "vscode-tas-client": "^0.3.1", + "vscode-tas-client": "^0.3.3", "web-tree-sitter": "^0.23.0" }, "overrides": { diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCloudSessionsProvider.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCloudSessionsProvider.ts index 0ba90c6564105f..3c5d24ef7ac84f 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCloudSessionsProvider.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCloudSessionsProvider.ts @@ -14,7 +14,7 @@ import { IVSCodeExtensionContext } from '../../../platform/extContext/common/ext import { IFileSystemService } from '../../../platform/filesystem/common/fileSystemService'; import { FileType } from '../../../platform/filesystem/common/fileTypes'; import { IGitExtensionService } from '../../../platform/git/common/gitExtensionService'; -import { GithubRepoId, IGitService } from '../../../platform/git/common/gitService'; +import { getGithubRepoIdFromFetchUrl, GithubRepoId, IGitService, toGithubNwo } from '../../../platform/git/common/gitService'; import { derivePullRequestState, PullRequestSearchItem } from '../../../platform/github/common/githubAPI'; import { CCAEnabledResult, IGithubRepositoryService, IOctoKitService } from '../../../platform/github/common/githubService'; import { getModelCapabilitiesDescription, normalizeTokenPrices } from '../../conversation/common/languageModelAccess'; @@ -247,6 +247,30 @@ export function parseGitHubContextUrl(value: string, kind: 'issue' | 'pullReques }; } +export async function resolveGitHubContextRepository(gitService: IGitService, repository: string | vscode.Uri | undefined): Promise { + if (!repository || typeof repository === 'string') { + return repository; + } + + const repositoryInfo = await gitService.getRepositoryFetchUrls(repository); + for (const remoteUrl of repositoryInfo?.remoteFetchUrls ?? []) { + const repositoryId = remoteUrl && getGithubRepoIdFromFetchUrl(remoteUrl); + if (repositoryId) { + return toGithubNwo(repositoryId); + } + } + return undefined; +} + +export async function resolveOrPickGitHubContextRepository( + gitService: IGitService, + repository: string | vscode.Uri | undefined, + pickRepository: () => Promise, +): Promise { + const repositoryId = await resolveGitHubContextRepository(gitService, repository); + return repository && !repositoryId ? pickRepository() : repositoryId; +} + /** Context key gating the chat-input "Create pull request" toolbar action: true while the viewed cloud task is settled and has no PR yet. */ const CAN_CREATE_PULL_REQUEST_CONTEXT_KEY = 'github.copilot.chat.cloudTaskCanCreatePullRequest'; /** Context key gating the chat-input "Open pull request" toolbar action: true once the viewed cloud task has a pull request. */ @@ -767,8 +791,10 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C })); }); }; - this._register(vscode.commands.registerCommand(OPEN_ISSUE_COMMAND_ID, (repoId?: string) => openGitHubContext('issue', repoId))); - this._register(vscode.commands.registerCommand(OPEN_PULL_REQUEST_COMMAND_ID, (repoId?: string) => openGitHubContext('pullRequest', repoId))); + this._register(vscode.commands.registerCommand(OPEN_ISSUE_COMMAND_ID, async (repository?: string | vscode.Uri) => + openGitHubContext('issue', await resolveOrPickGitHubContextRepository(this._gitService, repository, openRepositoryCommand)))); + this._register(vscode.commands.registerCommand(OPEN_PULL_REQUEST_COMMAND_ID, async (repository?: string | vscode.Uri) => + openGitHubContext('pullRequest', await resolveOrPickGitHubContextRepository(this._gitService, repository, openRepositoryCommand)))); this._register(vscode.commands.registerCommand(CLEAR_CACHES_COMMAND_ID, () => { this.logService.debug('copilotCloudSessionsProvider#clearCaches: clearing all cloud agent caches'); diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCloudSessionsProvider.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCloudSessionsProvider.spec.ts index 5a463b596895cb..b8aea4409126fc 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCloudSessionsProvider.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCloudSessionsProvider.spec.ts @@ -12,7 +12,7 @@ import { mock } from '../../../../util/common/test/simpleMock'; import { ChatRequestTurn2, ChatResponseMarkdownPart, ChatResponseTurn2, ChatToolInvocationPart } from '../../../../vscodeTypes'; import { ITaskApiClient, ListTaskEventsOptions, ListTasksOptions } from '../../common/taskApiTypes'; import { ChatSessionContentBuilder, extractTaskErrorDetail, formatTaskStoppedMessage } from '../copilotCloudSessionContentBuilder'; -import { formatNewSessionContextReference, getCloudSessionItemMetadata, getCloudSessionResources, normalizeInitialSessionOptions, parseGitHubContextUrl, taskStateToChatSessionStatus } from '../copilotCloudSessionsProvider'; +import { formatNewSessionContextReference, getCloudSessionItemMetadata, getCloudSessionResources, normalizeInitialSessionOptions, parseGitHubContextUrl, resolveGitHubContextRepository, resolveOrPickGitHubContextRepository, taskStateToChatSessionStatus } from '../copilotCloudSessionsProvider'; import { TaskApiBackend, parseRepoFromTaskUrl, isCloudCodingAgentTask } from '../taskApiBackend'; import { isActiveTaskState, isFailedTaskState } from '../../vscode/copilotCodingAgentUtils'; import { NullCloudBackendInstrumentation } from '../cloudBackendTelemetry'; @@ -82,6 +82,37 @@ describe('copilotCloudSessionsProvider helpers', () => { }); }); + it('resolves a GitHub context repository from the selected workspace folder', async () => { + const gitService = new TestGitService(); + gitService.getRepositoryFetchUrls = vi.fn(async () => ({ + rootUri: vscode.Uri.file('/workspace/docs'), + remoteFetchUrls: ['https://github.com/microsoft/vscode-docs.git'], + })); + + expect({ + folder: await resolveGitHubContextRepository(gitService, vscode.Uri.file('/workspace/docs')), + repository: await resolveGitHubContextRepository(gitService, 'microsoft/vscode'), + }).toEqual({ + folder: 'microsoft/vscode-docs', + repository: 'microsoft/vscode', + }); + }); + + it('offers repository selection only when a selected folder cannot be resolved', async () => { + const gitService = new TestGitService(); + gitService.getRepositoryFetchUrls = vi.fn(async () => undefined); + const pickRepository = vi.fn(async () => 'microsoft/vscode'); + + expect({ + selectedFolder: await resolveOrPickGitHubContextRepository(gitService, vscode.Uri.file('/workspace/vscode'), pickRepository), + noFolder: await resolveOrPickGitHubContextRepository(gitService, undefined, pickRepository), + }).toEqual({ + selectedFolder: 'microsoft/vscode', + noFolder: undefined, + }); + expect(pickRepository).toHaveBeenCalledTimes(1); + }); + it('coerces object-shaped initialSessionOptions into option entries', () => { const logService = new RecordingLogService(); const sessionResource = vscode.Uri.parse('copilot-cloud-agent:/1'); diff --git a/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/ghostText/test/streamedCompletionSplitter.test.ts b/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/ghostText/test/streamedCompletionSplitter.test.ts index c2a3b5b95c4e29..3b15fa256858d0 100644 --- a/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/ghostText/test/streamedCompletionSplitter.test.ts +++ b/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/ghostText/test/streamedCompletionSplitter.test.ts @@ -55,6 +55,7 @@ class FakeCompletionsFetchService implements ICompletionsFetchService { serverExperiments: '', deploymentId: '', gitHubRequestId: '', + copilotServiceRequestId: '', completionId: '', created: 0 }, headers); diff --git a/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/openai/fetch.fake.ts b/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/openai/fetch.fake.ts index 0d1bc27db30c10..f8e1b83b85df8b 100644 --- a/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/openai/fetch.fake.ts +++ b/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/openai/fetch.fake.ts @@ -46,6 +46,7 @@ export function fakeAPIChoice( serverExperiments: 'dummy', deploymentId: 'dummy', gitHubRequestId: 'dummy', + copilotServiceRequestId: 'dummy', completionId: 'dummy', created: 0 }, diff --git a/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/test/fetcher.ts b/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/test/fetcher.ts index 1de22b800afb6a..16baab0c8faa9d 100644 --- a/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/test/fetcher.ts +++ b/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/test/fetcher.ts @@ -222,6 +222,7 @@ export class StaticCompletionsFetchService implements ICompletionsFetchService { serverExperiments: '', deploymentId: '', gitHubRequestId: '', + copilotServiceRequestId: '', completionId: '', created: 0 }, headers); diff --git a/extensions/copilot/src/extension/externalAgents/node/oaiLanguageModelServer.ts b/extensions/copilot/src/extension/externalAgents/node/oaiLanguageModelServer.ts index 45cb5c40f2cacf..176cd62575e0ae 100644 --- a/extensions/copilot/src/extension/externalAgents/node/oaiLanguageModelServer.ts +++ b/extensions/copilot/src/extension/externalAgents/node/oaiLanguageModelServer.ts @@ -459,8 +459,8 @@ class StreamingPassThroughEndpoint implements IChatEndpoint { // We parse the stream just to return a correct ChatCompletion for logging the response and token usage details. const requestId = response.headers.get('X-Request-ID') ?? generateUuid(); const ghRequestId = response.headers.get('x-github-request-id') ?? ''; - const { serverExperiments } = getRequestId(response.headers); - const processor = this.instantiationService.createInstance(OpenAIResponsesProcessor, telemetryData, telemetryService, requestId, ghRequestId, serverExperiments, getResponsesApiCompactionThresholdFromBody(this.requestBody)); + const { serverExperiments, copilotServiceRequestId } = getRequestId(response.headers); + const processor = this.instantiationService.createInstance(OpenAIResponsesProcessor, telemetryData, telemetryService, requestId, ghRequestId, copilotServiceRequestId, serverExperiments, getResponsesApiCompactionThresholdFromBody(this.requestBody)); const parser = new SSEParser((ev) => { try { logService.trace(`[StreamingPassThroughEndpoint] SSE: ${ev.data}`); diff --git a/extensions/copilot/src/extension/intents/node/toolCallingLoop.ts b/extensions/copilot/src/extension/intents/node/toolCallingLoop.ts index 9f770c47c259ec..c079733f7b49a9 100644 --- a/extensions/copilot/src/extension/intents/node/toolCallingLoop.ts +++ b/extensions/copilot/src/extension/intents/node/toolCallingLoop.ts @@ -10,7 +10,7 @@ import { IAuthenticationChatUpgradeService } from '../../../platform/authenticat import { IChatDebugFileLoggerService } from '../../../platform/chat/common/chatDebugFileLoggerService'; import { IChatHookService, SessionStartHookInput, SessionStartHookOutput, StopHookInput, StopHookOutput, SubagentStartHookInput, SubagentStartHookOutput, SubagentStopHookInput, SubagentStopHookOutput } from '../../../platform/chat/common/chatHookService'; import { FetchStreamSource, IResponsePart } from '../../../platform/chat/common/chatMLFetcher'; -import { CanceledResult, ChatFetchResponseType, ChatLocation, ChatResponse } from '../../../platform/chat/common/commonTypes'; +import { CanceledResult, ChatFetchResponseType, ChatLocation, ChatResponse, isVisionAttachmentInaccessibleError } from '../../../platform/chat/common/commonTypes'; import { IHistoricalTurn, ISessionTranscriptService, ToolRequest } from '../../../platform/chat/common/sessionTranscriptService'; import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService'; import { isAnthropicFamily, isGeminiFamily } from '../../../platform/endpoint/common/chatModelCapabilities'; @@ -989,6 +989,9 @@ export abstract class ToolCallingLoop= ToolCallingLoop.MAX_AUTOPILOT_RETRIES) { return false; } + if (isVisionAttachmentInaccessibleError(response)) { + return false; + } switch (response.type) { case ChatFetchResponseType.RateLimited: case ChatFetchResponseType.QuotaExceeded: @@ -2430,3 +2433,4 @@ export interface IToolCallLoopResult extends IToolCallSingleResult { toolCallRounds: IToolCallRound[]; toolCallResults: Record; } + diff --git a/extensions/copilot/src/extension/intents/test/node/toolCallingLoopAutopilot.spec.ts b/extensions/copilot/src/extension/intents/test/node/toolCallingLoopAutopilot.spec.ts index 8782f8aec2a293..c68990ab992da1 100644 --- a/extensions/copilot/src/extension/intents/test/node/toolCallingLoopAutopilot.spec.ts +++ b/extensions/copilot/src/extension/intents/test/node/toolCallingLoopAutopilot.spec.ts @@ -547,8 +547,8 @@ describe('ToolCallingLoop autopilot', () => { }); describe('shouldAutoRetry', () => { - function mockResponse(type: ChatFetchResponseType): ChatResponse { - return { type, reason: 'test', requestId: 'req-1', serverRequestId: undefined } as any; + function mockResponse(type: ChatFetchResponseType, reason = 'test', reasonDetail?: string): ChatResponse { + return { type, reason, reasonDetail, requestId: 'req-1', serverRequestId: undefined } as any; } it('should retry on network error in autoApprove mode', () => { @@ -617,6 +617,16 @@ describe('ToolCallingLoop autopilot', () => { // 2 retries done, still under the cap of 3 expect(loop.testShouldAutoRetry(mockResponse(ChatFetchResponseType.Failed))).toBe(true); }); + + it('should not retry BadRequest with vision code in reason', () => { + const loop = createLoop('autoApprove'); + expect(loop.testShouldAutoRetry(mockResponse(ChatFetchResponseType.BadRequest, 'vision_attachment_not_accessible'))).toBe(false); + }); + + it('should not retry Failed with vision code in reasonDetail', () => { + const loop = createLoop('autopilot'); + expect(loop.testShouldAutoRetry(mockResponse(ChatFetchResponseType.Failed, 'request failed', 'Vision_attachment_Not_Accessible'))).toBe(false); + }); }); describe('tool call limit extension', () => { diff --git a/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts b/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts index 73e58be2f1ecab..58937a731017f7 100644 --- a/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts +++ b/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts @@ -20,7 +20,7 @@ import { isAutoModel } from '../../../platform/endpoint/node/autoChatEndpoint'; import { getResponsesApiCompactionThresholdFromBody, OpenAIResponsesProcessor, responseApiInputToRawMessagesForLogging, sendCompletionOutputTelemetry } from '../../../platform/endpoint/node/responsesApi'; import { getImageTelemetryMeasurementsFromMessages, type ImageTelemetryMeasurements } from '../../../platform/image/common/imageTelemetry'; import { collectSingleLineErrorMessage, ILogService } from '../../../platform/log/common/logService'; -import { FinishedCallback, getRequestId, IResponseDelta, OptionalChatRequestParams, RequestId } from '../../../platform/networking/common/fetch'; +import { FinishedCallback, getCopilotServiceRequestId, getRequestId, IResponseDelta, OptionalChatRequestParams, RequestId } from '../../../platform/networking/common/fetch'; import { FetcherId, IFetcherService, Response } from '../../../platform/networking/common/fetcherService'; import { IChatEndpoint, IEndpointBody, InteractionTypeOverride, postRequest, stringifyUrlOrRequestMetadata } from '../../../platform/networking/common/networking'; import { CAPIChatMessage, ChatCompletion, FilterReason, FinishedCompletionReason, rawMessageToCAPI } from '../../../platform/networking/common/openai'; @@ -545,8 +545,10 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { parentRequestId: telemetryProperties.parentRequestId, retryAfterError: telemetryProperties.retryAfterError, retryAfterErrorGitHubRequestId: telemetryProperties.retryAfterErrorGitHubRequestId, + retryAfterErrorCopilotServiceRequestId: telemetryProperties.retryAfterErrorCopilotServiceRequestId, connectivityTestError: telemetryProperties.connectivityTestError, connectivityTestErrorGitHubRequestId: telemetryProperties.connectivityTestErrorGitHubRequestId, + connectivityTestErrorCopilotServiceRequestId: telemetryProperties.connectivityTestErrorCopilotServiceRequestId, retryAfterFilterCategory: telemetryProperties.retryAfterFilterCategory, fetcher: actualFetcher, suspendEventSeen, @@ -654,11 +656,11 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { if (err.resumeEventSeen) { resumeEventSeen = true; } - const processed = this.processError(err, ourRequestId, err.gitHubRequestId, usernameToScrub, isAutoModel(chatEndpoint) === 1); + const processed = this.processError(err, ourRequestId, err.gitHubRequestId, err.copilotServiceRequestId, usernameToScrub, isAutoModel(chatEndpoint) === 1); const retryNetworkError = enableRetryOnError && processed.type === ChatFetchResponseType.NetworkError && this._configurationService.getExperimentBasedConfig(ConfigKey.TeamInternal.RetryNetworkErrors, this._experimentationService); const retryWithoutWebSocket = enableRetryOnError && useWebSocket && (processed.type === ChatFetchResponseType.NetworkError || processed.type === ChatFetchResponseType.Failed); if (retryNetworkError || retryWithoutWebSocket) { - const { retryResult, connectivityTestError, connectivityTestErrorGitHubRequestId } = await this._retryAfterError({ + const { retryResult, connectivityTestError, connectivityTestErrorGitHubRequestId, connectivityTestErrorCopilotServiceRequestId } = await this._retryAfterError({ opts, processed, telemetryProperties, @@ -684,7 +686,7 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { if (retryResult) { return retryResult; } - telemetryProperties = { ...telemetryProperties, connectivityTestError, connectivityTestErrorGitHubRequestId }; + telemetryProperties = { ...telemetryProperties, connectivityTestError, connectivityTestErrorGitHubRequestId, connectivityTestErrorCopilotServiceRequestId }; } if (processed.type === ChatFetchResponseType.Canceled) { Telemetry.sendCancellationTelemetry( @@ -692,6 +694,7 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { { source: telemetryProperties.messageSource ?? 'unknown', requestId: ourRequestId, + copilotServiceRequestId: processed.copilotServiceRequestId, model: chatEndpoint.model, apiType: chatEndpoint.apiType, transport, @@ -701,8 +704,10 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { parentRequestId: telemetryProperties.parentRequestId, retryAfterError: telemetryProperties.retryAfterError, retryAfterErrorGitHubRequestId: telemetryProperties.retryAfterErrorGitHubRequestId, + retryAfterErrorCopilotServiceRequestId: telemetryProperties.retryAfterErrorCopilotServiceRequestId, connectivityTestError: telemetryProperties.connectivityTestError, connectivityTestErrorGitHubRequestId: telemetryProperties.connectivityTestErrorGitHubRequestId, + connectivityTestErrorCopilotServiceRequestId: telemetryProperties.connectivityTestErrorCopilotServiceRequestId, retryAfterFilterCategory: telemetryProperties.retryAfterFilterCategory, fetcher: actualFetcher, suspendEventSeen, @@ -748,11 +753,12 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { } } - private async _checkNetworkConnectivity(useFetcher?: FetcherId): Promise<{ retryRequest: boolean; connectivityTestError?: string; connectivityTestErrorGitHubRequestId?: string }> { + private async _checkNetworkConnectivity(useFetcher?: FetcherId): Promise<{ retryRequest: boolean; connectivityTestError?: string; connectivityTestErrorGitHubRequestId?: string; connectivityTestErrorCopilotServiceRequestId?: string }> { // Ping CAPI to check network connectivity before retrying const delays = this.connectivityCheckDelays; let connectivityTestError: string | undefined = undefined; let connectivityTestErrorGitHubRequestId: string | undefined = undefined; + let connectivityTestErrorCopilotServiceRequestId: string | undefined = undefined; for (const delay of delays) { this._logService.info(`Waiting ${delay}ms before pinging CAPI to check network connectivity...`); await new Promise(resolve => setTimeout(resolve, delay)); @@ -767,19 +773,21 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { }); if (res.status >= 200 && res.status < 300) { this._logService.info(`CAPI ping successful, proceeding with chat request retry...`); - return { retryRequest: true, connectivityTestError, connectivityTestErrorGitHubRequestId }; + return { retryRequest: true, connectivityTestError, connectivityTestErrorGitHubRequestId, connectivityTestErrorCopilotServiceRequestId }; } else { connectivityTestError = `Status ${res.status}: ${res.statusText}`; connectivityTestErrorGitHubRequestId = res.headers.get('x-github-request-id') ?? ''; + connectivityTestErrorCopilotServiceRequestId = getCopilotServiceRequestId(res.headers); this._logService.info(`CAPI ping returned status ${res.status}, retrying ping...`); } } catch (err) { connectivityTestError = collectSingleLineErrorMessage(err, true); connectivityTestErrorGitHubRequestId = undefined; // no response headers yet + connectivityTestErrorCopilotServiceRequestId = undefined; // no response headers yet this._logService.info(`CAPI ping failed with error, retrying ping: ${connectivityTestError}`); } } - return { retryRequest: false, connectivityTestError, connectivityTestErrorGitHubRequestId }; + return { retryRequest: false, connectivityTestError, connectivityTestErrorGitHubRequestId, connectivityTestErrorCopilotServiceRequestId }; } private async _getAuthHeaders(isGHEnterprise: boolean, url: string) { @@ -823,7 +831,7 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { suspendEventSeen: boolean | undefined; resumeEventSeen: boolean | undefined; interactionType: string; - }): Promise<{ retryResult?: ChatResponses; connectivityTestError?: string; connectivityTestErrorGitHubRequestId?: string }> { + }): Promise<{ retryResult?: ChatResponses; connectivityTestError?: string; connectivityTestErrorGitHubRequestId?: string; connectivityTestErrorCopilotServiceRequestId?: string }> { const { opts, processed, @@ -862,9 +870,10 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { const connectivity = await this._checkNetworkConnectivity(useFetcher); const connectivityTestError = connectivity.connectivityTestError ? this.scrubErrorDetail(connectivity.connectivityTestError, usernameToScrub) : undefined; const connectivityTestErrorGitHubRequestId = connectivity.connectivityTestErrorGitHubRequestId; + const connectivityTestErrorCopilotServiceRequestId = connectivity.connectivityTestErrorCopilotServiceRequestId; if (!connectivity.retryRequest) { this._logService.info(`Not retrying chat request as network connectivity could not be re-established.`); - return { connectivityTestError, connectivityTestErrorGitHubRequestId }; + return { connectivityTestError, connectivityTestErrorGitHubRequestId, connectivityTestErrorCopilotServiceRequestId }; } Telemetry.sendResponseErrorTelemetry( @@ -902,8 +911,10 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { ...telemetryProperties, retryAfterError: processed.reasonDetail || processed.reason, retryAfterErrorGitHubRequestId: processed.serverRequestId, + retryAfterErrorCopilotServiceRequestId: processed.copilotServiceRequestId, connectivityTestError, connectivityTestErrorGitHubRequestId, + connectivityTestErrorCopilotServiceRequestId, }, enableRetryOnError: false, useFetcher, @@ -918,7 +929,7 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { this._webSocketManager.closeConnection(opts.conversationId, opts.webSocketConnectionId); } } - return { retryResult, connectivityTestError, connectivityTestErrorGitHubRequestId }; + return { retryResult, connectivityTestError, connectivityTestErrorGitHubRequestId, connectivityTestErrorCopilotServiceRequestId }; } private async _fetchAndStreamChat( @@ -1186,6 +1197,7 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { await connection.connect(); } catch (err) { (err as any).gitHubRequestId = connection.gitHubRequestId; + (err as any).copilotServiceRequestId = connection.copilotServiceRequestId; throw err; } @@ -1234,7 +1246,7 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { const handle = connection.sendRequest(request, { userInitiated: !!userInitiatedRequest, turnId, requestId: ourRequestId, model: chatEndpointInfo.model, countTokens, tokenCountMax: chatEndpointInfo.maxOutputTokens, modelMaxPromptTokens: chatEndpointInfo.modelMaxPromptTokens, summarizedAtRoundId, modeChanged }, cancellationToken); const extendedBaseTelemetryData = baseTelemetryData.extendedBy({ modelCallId }); - const processor = this._instantiationService.createInstance(OpenAIResponsesProcessor, extendedBaseTelemetryData, this._telemetryService, modelRequestId.headerRequestId, modelRequestId.gitHubRequestId, modelRequestId.serverExperiments, getResponsesApiCompactionThresholdFromBody(request)); + const processor = this._instantiationService.createInstance(OpenAIResponsesProcessor, extendedBaseTelemetryData, this._telemetryService, modelRequestId.headerRequestId, modelRequestId.gitHubRequestId, modelRequestId.copilotServiceRequestId, modelRequestId.serverExperiments, getResponsesApiCompactionThresholdFromBody(request)); // Set up streaming first so event listeners are registered before we // await the first event — AsyncIterableObject runs its executor eagerly. @@ -1260,12 +1272,14 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { // Mid-stream CAPI error — throw so the caller can handle it const error = new Error(`${event.error.message} (${event.error.code})`); (error as any).gitHubRequestId = modelRequestId.gitHubRequestId; + (error as any).copilotServiceRequestId = modelRequestId.copilotServiceRequestId; (error as any).capiWebSocketError = event; reject(error); }); handle.onError(error => { (error as any).gitHubRequestId = modelRequestId.gitHubRequestId; + (error as any).copilotServiceRequestId = modelRequestId.copilotServiceRequestId; if (isCancellationError(error)) { reject(error); return; @@ -1407,6 +1421,7 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { let chatCompletions; const gitHubRequestId = response.headers.get('x-github-request-id') ?? ''; + const copilotServiceRequestId = getCopilotServiceRequestId(response.headers); try { const completions = await chatEndpointInfo.processResponseFromChatEndpoint( this._telemetryService, @@ -1426,6 +1441,7 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { } catch (err) { err.fetcherId = response.fetcher; err.gitHubRequestId = gitHubRequestId; + err.copilotServiceRequestId = copilotServiceRequestId; err.bytesReceived = response.bytesReceived; throw err; } @@ -1433,6 +1449,7 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { } catch (err) { err.fetcherId = response.fetcher; err.gitHubRequestId = gitHubRequestId; + err.copilotServiceRequestId = copilotServiceRequestId; err.bytesReceived = response.bytesReceived; throw err; } @@ -1902,6 +1919,7 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { value: successfulCompletions.map(c => getTextPart(c.message.content)), requestId, serverRequestId: successfulCompletions[0].requestId.headerRequestId, + copilotServiceRequestId: successfulCompletions[0].requestId.copilotServiceRequestId, modelCallId, }; } @@ -1917,6 +1935,7 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { value: completions.map(c => getTextPart(c.message.content)), requestId: requestId, serverRequestId: result.requestId.headerRequestId, + copilotServiceRequestId: result.requestId.copilotServiceRequestId, }; case FinishedCompletionReason.Refusal: return { @@ -1924,6 +1943,7 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { reason: 'Model declined to respond.', requestId: requestId, serverRequestId: result.requestId.headerRequestId, + copilotServiceRequestId: result.requestId.copilotServiceRequestId, }; case FinishedCompletionReason.Length: return { @@ -1931,6 +1951,7 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { reason: 'Response too long.', requestId: requestId, serverRequestId: result.requestId.headerRequestId, + copilotServiceRequestId: result.requestId.copilotServiceRequestId, truncatedValue: getTextPart(result.message.content) }; case FinishedCompletionReason.ServerError: @@ -1939,6 +1960,7 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { reason: 'Server error. Stream terminated', requestId: requestId, serverRequestId: result.requestId.headerRequestId, + copilotServiceRequestId: result.requestId.copilotServiceRequestId, streamError: result.error }; } @@ -1947,6 +1969,7 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { reason: RESPONSE_CONTAINED_NO_CHOICES, requestId: requestId, serverRequestId: result?.requestId.headerRequestId, + copilotServiceRequestId: result?.requestId.copilotServiceRequestId, }; } @@ -2070,49 +2093,50 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { private processFailedResponse(response: ChatRequestFailed, requestId: string, isAuto: boolean): ChatFetchError { const serverRequestId = response.modelRequestId?.gitHubRequestId; + const copilotServiceRequestId = response.modelRequestId?.copilotServiceRequestId; const reason = response.reason; if (response.failKind === ChatFailKind.RateLimited) { - return { type: ChatFetchResponseType.RateLimited, reason, requestId, serverRequestId, retryAfter: response.data?.retryAfter, rateLimitKey: (response.data?.rateLimitKey || ''), isAuto, capiError: response.data?.capiError }; + return { type: ChatFetchResponseType.RateLimited, reason, requestId, serverRequestId, copilotServiceRequestId, retryAfter: response.data?.retryAfter, rateLimitKey: (response.data?.rateLimitKey || ''), isAuto, capiError: response.data?.capiError }; } if (response.failKind === ChatFailKind.QuotaExceeded) { - return { type: ChatFetchResponseType.QuotaExceeded, reason, requestId, serverRequestId, retryAfter: response.data?.retryAfter, capiError: response.data?.capiError }; + return { type: ChatFetchResponseType.QuotaExceeded, reason, requestId, serverRequestId, copilotServiceRequestId, retryAfter: response.data?.retryAfter, capiError: response.data?.capiError }; } if (response.failKind === ChatFailKind.OffTopic) { - return { type: ChatFetchResponseType.OffTopic, reason, requestId, serverRequestId }; + return { type: ChatFetchResponseType.OffTopic, reason, requestId, serverRequestId, copilotServiceRequestId }; } if (response.failKind === ChatFailKind.TokenExpiredOrInvalid || response.failKind === ChatFailKind.ClientNotSupported || reason.includes('Bad request: ')) { - return { type: ChatFetchResponseType.BadRequest, reason, requestId, serverRequestId }; + return { type: ChatFetchResponseType.BadRequest, reason, requestId, serverRequestId, copilotServiceRequestId }; } if (response.failKind === ChatFailKind.ServerError) { - return { type: ChatFetchResponseType.Failed, reason, requestId, serverRequestId }; + return { type: ChatFetchResponseType.Failed, reason, requestId, serverRequestId, copilotServiceRequestId }; } if (response.failKind === ChatFailKind.ContentFilter) { - return { type: ChatFetchResponseType.PromptFiltered, reason, category: FilterReason.Prompt, requestId, serverRequestId }; + return { type: ChatFetchResponseType.PromptFiltered, reason, category: FilterReason.Prompt, requestId, serverRequestId, copilotServiceRequestId }; } if (response.failKind === ChatFailKind.AgentUnauthorized) { - return { type: ChatFetchResponseType.AgentUnauthorized, reason, authorizationUrl: response.data!.authorize_url, requestId, serverRequestId }; + return { type: ChatFetchResponseType.AgentUnauthorized, reason, authorizationUrl: response.data!.authorize_url, requestId, serverRequestId, copilotServiceRequestId }; } if (response.failKind === ChatFailKind.AgentFailedDependency) { - return { type: ChatFetchResponseType.AgentFailedDependency, reason, requestId, serverRequestId }; + return { type: ChatFetchResponseType.AgentFailedDependency, reason, requestId, serverRequestId, copilotServiceRequestId }; } if (response.failKind === ChatFailKind.ExtensionBlocked) { const retryAfter = typeof response.data?.retryAfter === 'number' ? response.data.retryAfter : 300; - return { type: ChatFetchResponseType.ExtensionBlocked, reason, requestId, retryAfter, learnMoreLink: response.data?.learnMoreLink ?? '', serverRequestId }; + return { type: ChatFetchResponseType.ExtensionBlocked, reason, requestId, retryAfter, learnMoreLink: response.data?.learnMoreLink ?? '', serverRequestId, copilotServiceRequestId }; } if (response.failKind === ChatFailKind.NotFound) { - return { type: ChatFetchResponseType.NotFound, reason, requestId, serverRequestId }; + return { type: ChatFetchResponseType.NotFound, reason, requestId, serverRequestId, copilotServiceRequestId }; } if (response.failKind === ChatFailKind.InvalidPreviousResponseId) { - return { type: ChatFetchResponseType.InvalidStatefulMarker, reason, requestId, serverRequestId }; + return { type: ChatFetchResponseType.InvalidStatefulMarker, reason, requestId, serverRequestId, copilotServiceRequestId }; } - return { type: ChatFetchResponseType.Failed, reason, requestId, serverRequestId }; + return { type: ChatFetchResponseType.Failed, reason, requestId, serverRequestId, copilotServiceRequestId }; } - private processError(err: unknown, requestId: string, gitHubRequestId: string | undefined, usernameToScrub: string | undefined, isAuto: boolean): ChatFetchError { + private processError(err: unknown, requestId: string, gitHubRequestId: string | undefined, copilotServiceRequestId: string | undefined, usernameToScrub: string | undefined, isAuto: boolean): ChatFetchError { const capiWebSocketError = (err as any)?.capiWebSocketError as CAPIWebSocketErrorEvent | undefined; if (capiWebSocketError) { - return this._handleWebSocketError(capiWebSocketError, requestId, gitHubRequestId, isAuto); + return { ...this._handleWebSocketError(capiWebSocketError, requestId, gitHubRequestId, isAuto), copilotServiceRequestId }; } const fetcher = this._fetcherService; @@ -2123,6 +2147,7 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { reason: 'network request aborted', requestId: requestId, serverRequestId: gitHubRequestId, + copilotServiceRequestId, }; } if (isCancellationError(err)) { @@ -2131,6 +2156,7 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { reason: 'Got a cancellation error', requestId: requestId, serverRequestId: gitHubRequestId, + copilotServiceRequestId, }; } if (err && ( @@ -2142,6 +2168,7 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { reason: 'Stream closed prematurely', requestId: requestId, serverRequestId: gitHubRequestId, + copilotServiceRequestId, }; } this._logService.error(ErrorUtils.fromUnknown(err), `Error on conversation request`); @@ -2156,6 +2183,7 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { reasonDetail: scrubbedErrorDetail, requestId: requestId, serverRequestId: gitHubRequestId, + copilotServiceRequestId, }; } else if (fetcher.isFetcherError(err)) { const isNetworkProcessCrash = fetcher.isNetworkProcessCrashedError(err); @@ -2165,6 +2193,7 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { reasonDetail: scrubbedErrorDetail, requestId: requestId, serverRequestId: gitHubRequestId, + copilotServiceRequestId, ...(isNetworkProcessCrash ? { isNetworkProcessCrash: true } : {}), }; } else { @@ -2174,6 +2203,7 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { reasonDetail: scrubbedErrorDetail, requestId: requestId, serverRequestId: gitHubRequestId, + copilotServiceRequestId, }; } } diff --git a/extensions/copilot/src/extension/prompt/node/chatMLFetcherTelemetry.ts b/extensions/copilot/src/extension/prompt/node/chatMLFetcherTelemetry.ts index 18fc29a288873d..4352578e41e71e 100644 --- a/extensions/copilot/src/extension/prompt/node/chatMLFetcherTelemetry.ts +++ b/extensions/copilot/src/extension/prompt/node/chatMLFetcherTelemetry.ts @@ -37,6 +37,7 @@ export interface IChatMLFetcherSuccessfulData { export interface IChatMLFetcherCancellationProperties { source: string; requestId: string; + copilotServiceRequestId?: string; model: string; apiType: string | undefined; transport: string; @@ -46,8 +47,10 @@ export interface IChatMLFetcherCancellationProperties { parentRequestId?: string; retryAfterError?: string; retryAfterErrorGitHubRequestId?: string; + retryAfterErrorCopilotServiceRequestId?: string; connectivityTestError?: string; connectivityTestErrorGitHubRequestId?: string; + connectivityTestErrorCopilotServiceRequestId?: string; retryAfterFilterCategory?: string; fetcher: FetcherId | undefined; suspendEventSeen: boolean | undefined; @@ -152,6 +155,7 @@ export class ChatMLFetcherTelemetrySender { "conversationId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Id for the current chat conversation." }, "requestId": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Id of the current turn request" }, "gitHubRequestId": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "GitHub request id if available" }, + "copilotServiceRequestId": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "CAPI service request id (X-Copilot-Service-Request-Id) if available. Opaque server-minted id used to join with CAPI server-side logs and traces." }, "associatedRequestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Another request ID that this request is associated with (eg, the originating request of a summarization request)." }, "turn": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "How many turns have been made in the conversation.", "isMeasurement": true }, "reasoningEffort": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Reasoning effort level" }, @@ -197,8 +201,10 @@ export class ChatMLFetcherTelemetrySender { "bytesReceived": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Number of bytes received in the response", "isMeasurement": true }, "retryAfterError": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Error of the original request." }, "retryAfterErrorGitHubRequestId": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "GitHub request id of the original request if available" }, + "retryAfterErrorCopilotServiceRequestId": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "CAPI service request id (X-Copilot-Service-Request-Id) of the original request if available" }, "connectivityTestError": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Error of the connectivity test." }, "connectivityTestErrorGitHubRequestId": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "GitHub request id of the connectivity test request if available" }, + "connectivityTestErrorCopilotServiceRequestId": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "CAPI service request id (X-Copilot-Service-Request-Id) of the connectivity test request if available" }, "retryAfterFilterCategory": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "If the response was filtered and this is a retry attempt, this contains the original filtered content category." }, "suspendEventSeen": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Whether a system suspend event was seen during the request", "isMeasurement": true }, "resumeEventSeen": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Whether a system resume event was seen during the request", "isMeasurement": true }, @@ -221,6 +227,7 @@ export class ChatMLFetcherTelemetrySender { apiType: chatEndpointInfo?.apiType, requestId: chatCompletion.requestId.headerRequestId, gitHubRequestId: chatCompletion.requestId.gitHubRequestId, + copilotServiceRequestId: chatCompletion.requestId.copilotServiceRequestId, associatedRequestId: baseTelemetry?.properties.associatedRequestId, parentRequestId: baseTelemetry?.properties.parentRequestId, reasoningEffort: requestBody.reasoning?.effort ?? requestBody.output_config?.effort ?? requestBody.reasoning_effort, @@ -233,8 +240,10 @@ export class ChatMLFetcherTelemetrySender { transport, ...(baseTelemetry?.properties.retryAfterError ? { retryAfterError: baseTelemetry.properties.retryAfterError } : {}), ...(baseTelemetry?.properties.retryAfterErrorGitHubRequestId ? { retryAfterErrorGitHubRequestId: baseTelemetry.properties.retryAfterErrorGitHubRequestId } : {}), + ...(baseTelemetry?.properties.retryAfterErrorCopilotServiceRequestId ? { retryAfterErrorCopilotServiceRequestId: baseTelemetry.properties.retryAfterErrorCopilotServiceRequestId } : {}), ...(baseTelemetry?.properties.connectivityTestError ? { connectivityTestError: baseTelemetry.properties.connectivityTestError } : {}), ...(baseTelemetry?.properties.connectivityTestErrorGitHubRequestId ? { connectivityTestErrorGitHubRequestId: baseTelemetry.properties.connectivityTestErrorGitHubRequestId } : {}), + ...(baseTelemetry?.properties.connectivityTestErrorCopilotServiceRequestId ? { connectivityTestErrorCopilotServiceRequestId: baseTelemetry.properties.connectivityTestErrorCopilotServiceRequestId } : {}), ...(baseTelemetry?.properties.retryAfterFilterCategory ? { retryAfterFilterCategory: baseTelemetry.properties.retryAfterFilterCategory } : {}), }, { turn: getTurnFromBaseTelemetry(baseTelemetry), @@ -269,6 +278,7 @@ export class ChatMLFetcherTelemetrySender { { source, requestId, + copilotServiceRequestId, model, apiType, transport, @@ -278,8 +288,10 @@ export class ChatMLFetcherTelemetrySender { parentRequestId, retryAfterError, retryAfterErrorGitHubRequestId, + retryAfterErrorCopilotServiceRequestId, connectivityTestError, connectivityTestErrorGitHubRequestId, + connectivityTestErrorCopilotServiceRequestId, retryAfterFilterCategory, fetcher, suspendEventSeen, @@ -309,6 +321,7 @@ export class ChatMLFetcherTelemetrySender { "source": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Source for why the request was made" }, "requestKind": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Resolved X-Interaction-Type for the request: 'conversation-agent', 'conversation-subagent', 'conversation-background', 'conversation-panel', 'conversation-inline', 'conversation-edits', 'conversation-other', 'conversation-notebook', or 'conversation-terminal'" }, "requestId": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Id of the request" }, + "copilotServiceRequestId": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "CAPI service request id (X-Copilot-Service-Request-Id) if available. Opaque server-minted id used to join with CAPI server-side logs and traces." }, "conversationId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Id for the current chat conversation." }, "associatedRequestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Another request ID that this request is associated with (eg, the originating request of a summarization request)." }, "parentRequestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "For a subagent: the request id of the main agent request that invoked this subagent." }, @@ -345,8 +358,10 @@ export class ChatMLFetcherTelemetrySender { "bytesReceived": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Number of bytes received before cancellation", "isMeasurement": true }, "retryAfterError": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Error of the original request." }, "retryAfterErrorGitHubRequestId": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "GitHub request id of the original request if available" }, + "retryAfterErrorCopilotServiceRequestId": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "CAPI service request id (X-Copilot-Service-Request-Id) of the original request if available" }, "connectivityTestError": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Error of the connectivity test." }, "connectivityTestErrorGitHubRequestId": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "GitHub request id of the connectivity test request if available" }, + "connectivityTestErrorCopilotServiceRequestId": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "CAPI service request id (X-Copilot-Service-Request-Id) of the connectivity test request if available" }, "retryAfterFilterCategory": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "If the response was filtered and this is a retry attempt, this contains the original filtered content category." }, "suspendEventSeen": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Whether a system suspend event was seen during the request", "isMeasurement": true }, "resumeEventSeen": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Whether a system resume event was seen during the request", "isMeasurement": true } @@ -356,6 +371,7 @@ export class ChatMLFetcherTelemetrySender { apiType, source, requestId, + ...(copilotServiceRequestId ? { copilotServiceRequestId } : {}), model, requestKind: interactionType, conversationId, @@ -365,8 +381,10 @@ export class ChatMLFetcherTelemetrySender { transport, ...(retryAfterError ? { retryAfterError } : {}), ...(retryAfterErrorGitHubRequestId ? { retryAfterErrorGitHubRequestId } : {}), + ...(retryAfterErrorCopilotServiceRequestId ? { retryAfterErrorCopilotServiceRequestId } : {}), ...(connectivityTestError ? { connectivityTestError } : {}), ...(connectivityTestErrorGitHubRequestId ? { connectivityTestErrorGitHubRequestId } : {}), + ...(connectivityTestErrorCopilotServiceRequestId ? { connectivityTestErrorCopilotServiceRequestId } : {}), ...(retryAfterFilterCategory ? { retryAfterFilterCategory } : {}) }, { totalTokenMax, @@ -421,6 +439,7 @@ export class ChatMLFetcherTelemetrySender { "requestKind": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Resolved X-Interaction-Type for the request: 'conversation-agent', 'conversation-subagent', 'conversation-background', 'conversation-panel', 'conversation-inline', 'conversation-edits', 'conversation-other', 'conversation-notebook', or 'conversation-terminal'" }, "requestId": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Id of the request" }, "gitHubRequestId": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "GitHub request id if available" }, + "copilotServiceRequestId": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "CAPI service request id (X-Copilot-Service-Request-Id) if available. Opaque server-minted id used to join with CAPI server-side logs and traces." }, "conversationId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Id for the current chat conversation." }, "associatedRequestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Another request ID that this request is associated with (eg, the originating request of a summarization request)." }, "parentRequestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "For a subagent: the request id of the main agent request that invoked this subagent." }, @@ -459,8 +478,10 @@ export class ChatMLFetcherTelemetrySender { "bytesReceived": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Number of bytes received before the error", "isMeasurement": true }, "retryAfterError": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Error of the original request." }, "retryAfterErrorGitHubRequestId": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "GitHub request id of the original request if available" }, + "retryAfterErrorCopilotServiceRequestId": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "CAPI service request id (X-Copilot-Service-Request-Id) of the original request if available" }, "connectivityTestError": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Error of the connectivity test." }, "connectivityTestErrorGitHubRequestId": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "GitHub request id of the connectivity test request if available" }, + "connectivityTestErrorCopilotServiceRequestId": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "CAPI service request id (X-Copilot-Service-Request-Id) of the connectivity test request if available" }, "retryAfterFilterCategory": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "If the response was filtered and this is a retry attempt, this contains the original filtered content category." }, "suspendEventSeen": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Whether a system suspend event was seen during the request", "isMeasurement": true }, "resumeEventSeen": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Whether a system resume event was seen during the request", "isMeasurement": true } @@ -473,6 +494,7 @@ export class ChatMLFetcherTelemetrySender { requestKind: interactionType, requestId: processed.requestId, gitHubRequestId: processed.serverRequestId, + copilotServiceRequestId: processed.copilotServiceRequestId, model: chatEndpointInfo.model, apiType: chatEndpointInfo.apiType, conversationId: telemetryProperties?.conversationId, @@ -484,8 +506,10 @@ export class ChatMLFetcherTelemetrySender { parentRequestId: telemetryProperties?.parentRequestId, ...(telemetryProperties?.retryAfterError ? { retryAfterError: telemetryProperties.retryAfterError } : {}), ...(telemetryProperties?.retryAfterErrorGitHubRequestId ? { retryAfterErrorGitHubRequestId: telemetryProperties.retryAfterErrorGitHubRequestId } : {}), + ...(telemetryProperties?.retryAfterErrorCopilotServiceRequestId ? { retryAfterErrorCopilotServiceRequestId: telemetryProperties.retryAfterErrorCopilotServiceRequestId } : {}), ...(telemetryProperties?.connectivityTestError ? { connectivityTestError: telemetryProperties.connectivityTestError } : {}), ...(telemetryProperties?.connectivityTestErrorGitHubRequestId ? { connectivityTestErrorGitHubRequestId: telemetryProperties.connectivityTestErrorGitHubRequestId } : {}), + ...(telemetryProperties?.connectivityTestErrorCopilotServiceRequestId ? { connectivityTestErrorCopilotServiceRequestId: telemetryProperties.connectivityTestErrorCopilotServiceRequestId } : {}), ...(telemetryProperties?.retryAfterFilterCategory ? { retryAfterFilterCategory: telemetryProperties.retryAfterFilterCategory } : {}) }, { totalTokenMax: chatEndpointInfo.modelMaxPromptTokens ?? -1, diff --git a/extensions/copilot/src/extension/prompt/node/test/chatMLFetcherResponseApiTelemetry.spec.ts b/extensions/copilot/src/extension/prompt/node/test/chatMLFetcherResponseApiTelemetry.spec.ts index 4feca1c578ceea..7841f103f847f0 100644 --- a/extensions/copilot/src/extension/prompt/node/test/chatMLFetcherResponseApiTelemetry.spec.ts +++ b/extensions/copilot/src/extension/prompt/node/test/chatMLFetcherResponseApiTelemetry.spec.ts @@ -18,7 +18,7 @@ import { ICAPIClientService } from '../../../../platform/endpoint/common/capiCli import { MockAuthenticationService } from '../../../../platform/ignore/node/test/mockAuthenticationService'; import { MockCAPIClientService } from '../../../../platform/ignore/node/test/mockCAPIClientService'; import { ILogService } from '../../../../platform/log/common/logService'; -import { FinishedCallback } from '../../../../platform/networking/common/fetch'; +import { FinishedCallback, getCopilotServiceRequestId } from '../../../../platform/networking/common/fetch'; import { FetcherId, IFetcherService, IHeaders, Response } from '../../../../platform/networking/common/fetcherService'; import { IChatEndpoint, IEndpointBody } from '../../../../platform/networking/common/networking'; import { NullChatWebSocketManager } from '../../../../platform/networking/node/chatWebSocketManager'; @@ -257,6 +257,28 @@ describe('ChatMLFetcherImpl request.options.tools telemetry', () => { expect(toolsEvents.length).toBe(0); }); + it('reports the CAPI service request id on response.success regardless of header casing', async () => { + const endpointWithoutTools = createChatCompletionEndpointWithoutTools(); + mockFetcherService.queueResponse(createSuccessResponse('Hello!', new Map([ + ['X-Copilot-Service-Request-Id', 'svc-req-abc'], + ]))); + + const opts: IFetchMLOptions = { + debugName: 'test-service-request-id', + messages: [{ role: Raw.ChatRole.User, content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: 'Hello' }] }], + endpoint: endpointWithoutTools, + location: ChatLocation.Panel, + requestOptions: {}, + finishedCb: undefined, + }; + + await fetcher.fetchMany(opts, cancellationTokenSource.token); + + const successEvent = spyingTelemetryService.getEvents().telemetryServiceEvents + .find(e => e.eventName === 'response.success'); + expect((successEvent?.properties as Record)?.copilotServiceRequestId).toBe('svc-req-abc'); + }); + it('multiplexes messagesJson when tool schemas exceed 8KB', async () => { const endpointWithLargeTools = createEndpointWithLargeTools(); mockFetcherService.queueResponse(createSuccessResponse('Hello!')); @@ -345,6 +367,7 @@ function createEndpointWithTools(): IChatEndpoint { requestId: { headerRequestId: response.headers.get('x-request-id') || 'test-request-id', gitHubRequestId: response.headers.get('x-github-request-id') || '', + copilotServiceRequestId: getCopilotServiceRequestId(response.headers), completionId: '', created: 0, serverExperiments: '', @@ -406,6 +429,7 @@ function createChatCompletionEndpointWithoutTools(): IChatEndpoint { requestId: { headerRequestId: response.headers.get('x-request-id') || 'test-request-id', gitHubRequestId: response.headers.get('x-github-request-id') || '', + copilotServiceRequestId: getCopilotServiceRequestId(response.headers), completionId: '', created: 0, serverExperiments: '', @@ -482,6 +506,7 @@ function createEndpointWithLargeTools(): IChatEndpoint { requestId: { headerRequestId: response.headers.get('x-request-id') || 'test-request-id', gitHubRequestId: response.headers.get('x-github-request-id') || '', + copilotServiceRequestId: getCopilotServiceRequestId(response.headers), completionId: '', created: 0, serverExperiments: '', @@ -556,6 +581,7 @@ function createResponseApiEndpoint(): IChatEndpoint { requestId: { headerRequestId: response.headers.get('x-request-id') || 'test-request-id', gitHubRequestId: response.headers.get('x-github-request-id') || '', + copilotServiceRequestId: getCopilotServiceRequestId(response.headers), completionId: '', created: 0, serverExperiments: '', @@ -619,6 +645,7 @@ function createChatCompletionEndpointWithEmptyMessages(): IChatEndpoint { requestId: { headerRequestId: response.headers.get('x-request-id') || 'test-request-id', gitHubRequestId: response.headers.get('x-github-request-id') || '', + copilotServiceRequestId: getCopilotServiceRequestId(response.headers), completionId: '', created: 0, serverExperiments: '', @@ -758,7 +785,7 @@ class FakeHeaders implements IHeaders { } } -function createSuccessResponse(content: string): Response { +function createSuccessResponse(content: string, extraHeaders?: ReadonlyMap): Response { const streamContent = `data: {"choices":[{"delta":{"content":"${content}"},"index":0}]}\n\ndata: {"choices":[{"delta":{},"finish_reason":"stop","index":0}]}\n\ndata: [DONE]\n\n`; return Response.fromText( 200, @@ -766,6 +793,7 @@ function createSuccessResponse(content: string): Response { new FakeHeaders(new Map([ ['content-type', 'text/event-stream'], ['x-request-id', 'test-request-id'], + ...(extraHeaders ?? []), ])), streamContent, 'node-fetch' as FetcherId diff --git a/extensions/copilot/src/platform/chat/common/commonTypes.ts b/extensions/copilot/src/platform/chat/common/commonTypes.ts index c21c21f43507a4..af2704745c8dcc 100644 --- a/extensions/copilot/src/platform/chat/common/commonTypes.ts +++ b/extensions/copilot/src/platform/chat/common/commonTypes.ts @@ -116,7 +116,21 @@ export enum ChatFetchResponseType { export const RESPONSE_CONTAINED_NO_CHOICES = 'Response contained no choices.'; -export type ChatFetchError = +export function isVisionAttachmentInaccessibleError(input: { type: ChatFetchResponseType; reason?: string; reasonDetail?: string }): boolean { + if (input.type !== ChatFetchResponseType.BadRequest && input.type !== ChatFetchResponseType.Failed) { + return false; + } + const haystack = `${input.reason ?? ''} ${input.reasonDetail ?? ''}`.toLowerCase(); + return haystack.includes('vision_attachment_not_accessible') || (haystack.includes('attachment') && haystack.includes('not accessible')); +} + +/** + * CAPI's `X-Copilot-Service-Request-Id` from the response that produced this result. Joins the + * client-side event with CAPI's server-side logs, traces and Sentry reports. + */ +type WithCopilotServiceRequestId = { copilotServiceRequestId?: string }; + +export type ChatFetchError = WithCopilotServiceRequestId & ( /** * We requested conversation, but the message was deemed off topic by the intent classifier. */ @@ -186,16 +200,17 @@ export type ChatFetchError = * The `statefulMarker` present in the request was invalid or expired. The * request may be retried without that marker to resubmit it anew. */ - | { type: ChatFetchResponseType.InvalidStatefulMarker; reason: string; reasonDetail?: string; requestId: string; serverRequestId: string | undefined }; + | { type: ChatFetchResponseType.InvalidStatefulMarker; reason: string; reasonDetail?: string; requestId: string; serverRequestId: string | undefined } +); -export type ChatFetchRetriableError = - /** - * We requested conversation, the response was filtered by RAI, but we want to retry. - */ - { type: ChatFetchResponseType.FilteredRetry; reason: string; category: FilterReason; value: T; requestId: string; serverRequestId: string | undefined }; +export type ChatFetchRetriableError = WithCopilotServiceRequestId & +/** + * We requested conversation, the response was filtered by RAI, but we want to retry. + */ +{ type: ChatFetchResponseType.FilteredRetry; reason: string; category: FilterReason; value: T; requestId: string; serverRequestId: string | undefined }; -export type FetchSuccess = - { type: ChatFetchResponseType.Success; value: T; requestId: string; serverRequestId: string | undefined; usage: APIUsage | undefined; resolvedModel: string; modelCallId?: string }; +export type FetchSuccess = WithCopilotServiceRequestId & +{ type: ChatFetchResponseType.Success; value: T; requestId: string; serverRequestId: string | undefined; usage: APIUsage | undefined; resolvedModel: string; modelCallId?: string }; export type FetchResponse = FetchSuccess | ChatFetchError; @@ -441,11 +456,19 @@ function getErrorDetailsFromChatFetchErrorInner(fetchResult: ChatFetchError, cop }; break; case ChatFetchResponseType.BadRequest: - case ChatFetchResponseType.Failed: - details = fetchResult.serverRequestId - ? { message: l10n.t(`Sorry, your request failed. Please try again.\n\nClient Request Id: {0}\n\nGH Request Id: {1}\n\nReason: {2}`, fetchResult.requestId, fetchResult.serverRequestId, fetchResult.reason) } - : { message: l10n.t(`Sorry, your request failed. Please try again.\n\nClient Request Id: {0}\n\nReason: {1}`, fetchResult.requestId, fetchResult.reason) }; + case ChatFetchResponseType.Failed: { + const isVisionExpired = isVisionAttachmentInaccessibleError(fetchResult); + if (isVisionExpired) { + details = fetchResult.serverRequestId + ? { message: l10n.t(`An image attached earlier in this conversation is no longer accessible, so the request failed. Remove the image attachment or start a new conversation.\n\nClient Request Id: {0}\n\nGH Request Id: {1}\n\nReason: {2}`, fetchResult.requestId, fetchResult.serverRequestId, fetchResult.reason) } + : { message: l10n.t(`An image attached earlier in this conversation is no longer accessible, so the request failed. Remove the image attachment or start a new conversation.\n\nClient Request Id: {0}\n\nReason: {1}`, fetchResult.requestId, fetchResult.reason) }; + } else { + details = fetchResult.serverRequestId + ? { message: l10n.t(`Sorry, your request failed. Please try again.\n\nClient Request Id: {0}\n\nGH Request Id: {1}\n\nReason: {2}`, fetchResult.requestId, fetchResult.serverRequestId, fetchResult.reason) } + : { message: l10n.t(`Sorry, your request failed. Please try again.\n\nClient Request Id: {0}\n\nReason: {1}`, fetchResult.requestId, fetchResult.reason) }; + } break; + } case ChatFetchResponseType.NetworkError: details = { message: l10n.t(`Sorry, there was a network error. Please try again later. Request id: {0}\n\nReason: {1}`, fetchResult.requestId, fetchResult.reason) }; break; diff --git a/extensions/copilot/src/platform/chat/test/common/commonTypes.spec.ts b/extensions/copilot/src/platform/chat/test/common/commonTypes.spec.ts index c28ed72eee021f..38dea4fce0c806 100644 --- a/extensions/copilot/src/platform/chat/test/common/commonTypes.spec.ts +++ b/extensions/copilot/src/platform/chat/test/common/commonTypes.spec.ts @@ -148,4 +148,39 @@ describe('getErrorDetailsFromChatFetchError', () => { expect(result.message).toContain('unknown_error'); }); }); + + describe('BadRequest/Failed with vision attachment inaccessible', () => { + test('BadRequest with vision reason and serverRequestId shows guidance with IDs', () => { + const result = getErrorDetailsFromChatFetchError( + { type: ChatFetchResponseType.BadRequest, reason: 'vision_attachment_not_accessible', requestId: 'req-1', serverRequestId: 'srv-1' }, + undefined, + GitHubOutageStatus.None, + ); + + expect(result.message).toContain('no longer accessible'); + expect(result.message).toContain('req-1'); + expect(result.message).toContain('srv-1'); + }); + + test('Failed without serverRequestId shows guidance', () => { + const result = getErrorDetailsFromChatFetchError( + { type: ChatFetchResponseType.Failed, reason: 'request failed', reasonDetail: 'attachment is not accessible', requestId: 'req-2', serverRequestId: undefined }, + undefined, + GitHubOutageStatus.None, + ); + + expect(result.message).toContain('no longer accessible'); + expect(result.message).toContain('req-2'); + }); + + test('generic BadRequest retains generic message', () => { + const result = getErrorDetailsFromChatFetchError( + { type: ChatFetchResponseType.BadRequest, reason: 'bad request', requestId: 'req-3', serverRequestId: undefined }, + undefined, + GitHubOutageStatus.None, + ); + + expect(result.message).toContain('Please try again'); + }); + }); }); diff --git a/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts b/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts index 91c51279188da2..43ad027b537997 100644 --- a/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts +++ b/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts @@ -119,7 +119,7 @@ export async function defaultNonStreamChatResponseProcessor(response: Response, const messageText = getTextPart(message.content); const requestId = response.headers.get('X-Request-ID') ?? generateUuid(); const ghRequestId = response.headers.get('x-github-request-id') ?? ''; - const { serverExperiments } = getRequestId(response.headers); + const { serverExperiments, copilotServiceRequestId } = getRequestId(response.headers); const completion: ChatCompletion = { @@ -131,7 +131,7 @@ export async function defaultNonStreamChatResponseProcessor(response: Response, message: message, usage: jsonResponse.usage, tokens: [], // This is used for repetition detection so not super important to be accurate - requestId: { headerRequestId: requestId, gitHubRequestId: ghRequestId, completionId: jsonResponse.id, created: jsonResponse.created, deploymentId: '', serverExperiments }, + requestId: { headerRequestId: requestId, gitHubRequestId: ghRequestId, copilotServiceRequestId, completionId: jsonResponse.id, created: jsonResponse.created, deploymentId: '', serverExperiments }, telemetryData: telemetryData }; const functionCall: ICopilotToolCall[] = []; diff --git a/extensions/copilot/src/platform/endpoint/node/messagesApi.ts b/extensions/copilot/src/platform/endpoint/node/messagesApi.ts index a5ba356868ccc6..ea2ce4c4845d7e 100644 --- a/extensions/copilot/src/platform/endpoint/node/messagesApi.ts +++ b/extensions/copilot/src/platform/endpoint/node/messagesApi.ts @@ -692,8 +692,8 @@ export async function processResponseFromMessagesEndpoint( return new AsyncIterableObject(async feed => { const requestId = response.headers.get('X-Request-ID') ?? generateUuid(); const ghRequestId = response.headers.get('x-github-request-id') ?? ''; - const { serverExperiments } = getRequestId(response.headers); - const processor = instantiationService.createInstance(AnthropicMessagesProcessor, telemetryData, requestId, ghRequestId, serverExperiments); + const { serverExperiments, copilotServiceRequestId } = getRequestId(response.headers); + const processor = instantiationService.createInstance(AnthropicMessagesProcessor, telemetryData, requestId, ghRequestId, copilotServiceRequestId, serverExperiments); const parser = new SSEParser((ev) => { try { logService.trace(`[messagesAPI]SSE: ${ev.data}`); @@ -775,6 +775,7 @@ interface AnthropicCompletionState { readonly thinkingTokens: number | undefined; readonly requestId: string; readonly ghRequestId: string; + readonly copilotServiceRequestId: string; readonly serverExperiments: string; readonly telemetryData: TelemetryData; readonly copilotUsage?: { total_nano_aiu: number }; @@ -818,6 +819,7 @@ function buildAnthropicCompletion(state: AnthropicCompletionState, logService: I requestId: { headerRequestId: state.requestId, gitHubRequestId: state.ghRequestId, + copilotServiceRequestId: state.copilotServiceRequestId, completionId: state.messageId, created: Date.now(), deploymentId: '', @@ -922,7 +924,7 @@ export async function processNonStreamingResponseFromMessagesEndpoint( telemetryData: TelemetryData ): Promise> { return new AsyncIterableObject(async feed => { - const { headerRequestId, serverExperiments } = getRequestId(response.headers); + const { headerRequestId, serverExperiments, copilotServiceRequestId } = getRequestId(response.headers); const requestId = headerRequestId || generateUuid(); const ghRequestId = response.headers.get('x-github-request-id') ?? ''; @@ -1035,6 +1037,7 @@ export async function processNonStreamingResponseFromMessagesEndpoint( thinkingTokens: usage?.output_tokens_details?.thinking_tokens, requestId, ghRequestId, + copilotServiceRequestId, serverExperiments, telemetryData, }, logService); @@ -1093,6 +1096,7 @@ export class AnthropicMessagesProcessor { private readonly telemetryData: TelemetryData, private readonly requestId: string, private readonly ghRequestId: string, + private readonly copilotServiceRequestId: string, private readonly serverExperiments: string, @ILogService private readonly logService: ILogService, @ITelemetryService private readonly telemetryService: ITelemetryService, @@ -1379,6 +1383,7 @@ export class AnthropicMessagesProcessor { thinkingTokens: this.thinkingTokens, requestId: this.requestId, ghRequestId: this.ghRequestId, + copilotServiceRequestId: this.copilotServiceRequestId, serverExperiments: this.serverExperiments, telemetryData: this.telemetryData, copilotUsage: this.copilotUsage, diff --git a/extensions/copilot/src/platform/endpoint/node/responsesApi.ts b/extensions/copilot/src/platform/endpoint/node/responsesApi.ts index 3988b2ef147011..95ba2bc487f02f 100644 --- a/extensions/copilot/src/platform/endpoint/node/responsesApi.ts +++ b/extensions/copilot/src/platform/endpoint/node/responsesApi.ts @@ -963,8 +963,8 @@ export async function processResponseFromChatEndpoint(instantiationService: IIns return new AsyncIterableObject(async feed => { const requestId = response.headers.get('X-Request-ID') ?? generateUuid(); const ghRequestId = response.headers.get('x-github-request-id') ?? ''; - const { serverExperiments } = getRequestId(response.headers); - const processor = instantiationService.createInstance(OpenAIResponsesProcessor, telemetryData, telemetryService, requestId, ghRequestId, serverExperiments, compactionThreshold); + const { serverExperiments, copilotServiceRequestId } = getRequestId(response.headers); + const processor = instantiationService.createInstance(OpenAIResponsesProcessor, telemetryData, telemetryService, requestId, ghRequestId, copilotServiceRequestId, serverExperiments, compactionThreshold); const dumper = createResponsesStreamDumper(requestId, logService); const parser = new SSEParser((ev) => { try { @@ -1184,6 +1184,7 @@ export class OpenAIResponsesProcessor { private readonly telemetryService: ITelemetryService, private readonly requestId: string, private readonly ghRequestId: string, + private readonly copilotServiceRequestId: string, private readonly serverExperiments: string, private readonly compactionThreshold: number | undefined, @ILogService private readonly logService: ILogService, @@ -1432,7 +1433,7 @@ export class OpenAIResponsesProcessor { model: chunk.response.model, tokens: [], telemetryData: this.telemetryData, - requestId: { headerRequestId: this.requestId, gitHubRequestId: this.ghRequestId, completionId: chunk.response.id, created: chunk.response.created_at, deploymentId: '', serverExperiments: this.serverExperiments }, + requestId: { headerRequestId: this.requestId, gitHubRequestId: this.ghRequestId, copilotServiceRequestId: this.copilotServiceRequestId, completionId: chunk.response.id, created: chunk.response.created_at, deploymentId: '', serverExperiments: this.serverExperiments }, usage: { prompt_tokens: chunk.response.usage?.input_tokens ?? 0, completion_tokens: chunk.response.usage?.output_tokens ?? 0, @@ -1510,6 +1511,7 @@ export class OpenAIResponsesProcessor { requestId: { headerRequestId: this.requestId, gitHubRequestId: this.ghRequestId, + copilotServiceRequestId: this.copilotServiceRequestId, completionId: response.id, created: response.created_at, deploymentId: '', diff --git a/extensions/copilot/src/platform/endpoint/node/test/responsesApi.spec.ts b/extensions/copilot/src/platform/endpoint/node/test/responsesApi.spec.ts index e2313d916b05c2..d6bcdd7890bda9 100644 --- a/extensions/copilot/src/platform/endpoint/node/test/responsesApi.spec.ts +++ b/extensions/copilot/src/platform/endpoint/node/test/responsesApi.spec.ts @@ -2099,6 +2099,7 @@ describe('processResponseFromChatEndpoint terminal events', () => { new SpyingTelemetryService(), 'req-1', 'gh-req-1', + 'svc-req-1', '', undefined, ); diff --git a/extensions/copilot/src/platform/endpoint/node/test/responsesApiToolSearch.spec.ts b/extensions/copilot/src/platform/endpoint/node/test/responsesApiToolSearch.spec.ts index f4c10fa02ba496..c8f4c1be845fa2 100644 --- a/extensions/copilot/src/platform/endpoint/node/test/responsesApiToolSearch.spec.ts +++ b/extensions/copilot/src/platform/endpoint/node/test/responsesApiToolSearch.spec.ts @@ -516,7 +516,7 @@ describe('OpenAIResponsesProcessor tool search events', () => { const ds = new DisposableStore(); const services = createPlatformServices(ds); const accessor = services.createTestingAccessor(); - return accessor.get(IInstantiationService).createInstance(OpenAIResponsesProcessor, telemetryData, telemetryService, 'req-123', 'gh-req-456', '', undefined); + return accessor.get(IInstantiationService).createInstance(OpenAIResponsesProcessor, telemetryData, telemetryService, 'req-123', 'gh-req-456', 'svc-req-789', '', undefined); } function collectDeltas(processor: OpenAIResponsesProcessor, events: any[]): IResponseDelta[] { diff --git a/extensions/copilot/src/platform/endpoint/test/node/__snapshots__/stream.sseProcessor.spec.ts.snap b/extensions/copilot/src/platform/endpoint/test/node/__snapshots__/stream.sseProcessor.spec.ts.snap index 13e7b315a08a8e..85e129fadcf4aa 100644 --- a/extensions/copilot/src/platform/endpoint/test/node/__snapshots__/stream.sseProcessor.spec.ts.snap +++ b/extensions/copilot/src/platform/endpoint/test/node/__snapshots__/stream.sseProcessor.spec.ts.snap @@ -22,6 +22,7 @@ exports[`SSEProcessor > real world snapshots > multiple tool calls > completion "requestId": { "headerRequestId": "", "gitHubRequestId": "", + "copilotServiceRequestId": "", "completionId": "ba49856c-7de2-485b-87cd-6ff61c58407e", "created": 1741911507, "serverExperiments": "", @@ -1942,6 +1943,7 @@ exports[`SSEProcessor > real world snapshots > n > 1 - intent detector > complet "requestId": { "headerRequestId": "", "gitHubRequestId": "", + "copilotServiceRequestId": "", "completionId": "chatcmpl-9XTwlPjQvCcybmQxvS39Ouj4EEG89", "created": 1717766967, "serverExperiments": "", @@ -1960,6 +1962,7 @@ exports[`SSEProcessor > real world snapshots > n > 1 - intent detector > complet "requestId": { "headerRequestId": "", "gitHubRequestId": "", + "copilotServiceRequestId": "", "completionId": "chatcmpl-9XTwlPjQvCcybmQxvS39Ouj4EEG89", "created": 1717766967, "serverExperiments": "", @@ -1978,6 +1981,7 @@ exports[`SSEProcessor > real world snapshots > n > 1 - intent detector > complet "requestId": { "headerRequestId": "", "gitHubRequestId": "", + "copilotServiceRequestId": "", "completionId": "chatcmpl-9XTwlPjQvCcybmQxvS39Ouj4EEG89", "created": 1717766967, "serverExperiments": "", @@ -2081,6 +2085,7 @@ exports[`SSEProcessor > real world snapshots > panel chat - plain text assistant "requestId": { "headerRequestId": "", "gitHubRequestId": "", + "copilotServiceRequestId": "", "completionId": "chatcmpl-9XTNuRVy7pKFgXL5VsZKe5wfpiWQU", "created": 1717764806, "serverExperiments": "", @@ -2212,6 +2217,7 @@ exports[`SSEProcessor > real world snapshots > single function call > completion "requestId": { "headerRequestId": "", "gitHubRequestId": "", + "copilotServiceRequestId": "", "completionId": "chatcmpl-9WkN0MexQZVb1yEgTn8jqMX24oHej", "created": 1717591770, "serverExperiments": "", @@ -2251,6 +2257,7 @@ exports[`SSEProcessor > real world snapshots > single tool call > completion res "requestId": { "headerRequestId": "", "gitHubRequestId": "", + "copilotServiceRequestId": "", "completionId": "chatcmpl-A5iQJExrRR9lZjObeHBzjesQ5HvT1", "created": 1725925767, "serverExperiments": "", @@ -2393,6 +2400,7 @@ exports[`SSEProcessor > real world snapshots > single tool call with annotations "requestId": { "headerRequestId": "", "gitHubRequestId": "", + "copilotServiceRequestId": "", "completionId": "chatcmpl-A5iQJExrRR9lZjObeHBzjesQ5HvT1", "created": 1725925767, "serverExperiments": "", @@ -3225,6 +3233,7 @@ exports[`SSEProcessor > real world snapshots > stream from @github (bing-search "requestId": { "headerRequestId": "", "gitHubRequestId": "", + "copilotServiceRequestId": "", "completionId": "chatcmpl-9X3aOY9DtjweQLHsmnLgzQYg36KpW", "created": 1717665636, "serverExperiments": "", @@ -3343,6 +3352,7 @@ exports[`SSEProcessor > real world snapshots > stream from @github (bing-search "requestId": { "headerRequestId": "", "gitHubRequestId": "", + "copilotServiceRequestId": "", "completionId": "chatcmpl-9X3aOY9DtjweQLHsmnLgzQYg36KpW", "created": 1717665636, "serverExperiments": "", diff --git a/extensions/copilot/src/platform/endpoint/test/node/messagesApi.spec.ts b/extensions/copilot/src/platform/endpoint/test/node/messagesApi.spec.ts index 5ff085661f7d0e..e717efb3a922bb 100644 --- a/extensions/copilot/src/platform/endpoint/test/node/messagesApi.spec.ts +++ b/extensions/copilot/src/platform/endpoint/test/node/messagesApi.spec.ts @@ -2111,6 +2111,7 @@ suite('AnthropicMessagesProcessor streaming cache_creation', () => { TelemetryData.createAndMarkAsIssued(), 'req-1', 'gh-req-1', + 'svc-req-1', '', logService, new NullTelemetryService(), diff --git a/extensions/copilot/src/platform/git/vscode-node/gitServiceImpl.ts b/extensions/copilot/src/platform/git/vscode-node/gitServiceImpl.ts index 7d68b09c5dd8b5..c879856d3a6412 100644 --- a/extensions/copilot/src/platform/git/vscode-node/gitServiceImpl.ts +++ b/extensions/copilot/src/platform/git/vscode-node/gitServiceImpl.ts @@ -21,7 +21,7 @@ import { isEqual } from '../../../util/vs/base/common/resources'; import { URI } from '../../../util/vs/base/common/uri'; import { ILogService } from '../../log/common/logService'; import { IGitExtensionService } from '../common/gitExtensionService'; -import { IGitService, RepoContext } from '../common/gitService'; +import { getOrderedRemoteUrlsFromContext, IGitService, RepoContext } from '../common/gitService'; import { parseGitRemotes } from '../common/utils'; import { API, APIState, Branch, Change, CommitOptions, CommitShortStat, DiffChange, Ref, RefQuery, Repository, RepositoryAccessDetails } from '../vscode/git'; @@ -197,6 +197,30 @@ export class GitServiceImpl extends Disposable implements IGitService { async getRepositoryFetchUrls(uri: URI): Promise | undefined> { this.logService.trace(`[GitServiceImpl][getRepositoryFetchUrls] URI: ${uri.toString()}`); + if (uri.scheme === 'file') { + try { + const uriStat = await vscode.workspace.fs.stat(uri); + if (uriStat.type === vscode.FileType.Directory) { + const config = await this.readLocalGitConfig(uri); + const parsedRemotes = parseGitRemotes(config); + const origin = parsedRemotes.find(remote => remote.name === 'origin'); + const orderedRemotes = origin + ? [origin, ...parsedRemotes.filter(remote => remote !== origin)] + : parsedRemotes; + const remotes = { + rootUri: uri, + remoteFetchUrls: orderedRemotes.map(remote => remote.fetchUrl), + }; + if (remotes.remoteFetchUrls.length > 0) { + this.logService.trace(`[GitServiceImpl][getRepositoryFetchUrls] Remotes (direct .git/config): ${JSON.stringify(remotes)}`); + return remotes; + } + } + } catch (error) { + this.logService.trace(`[GitServiceImpl][getRepositoryFetchUrls] Could not read remotes directly from .git/config: ${error.message}`); + } + } + // Answering before discovery settles reports the file as belonging to no repository, which // content exclusion reads as "no repository rules apply to this file". await this.waitForInitialDiscovery(); @@ -209,11 +233,10 @@ export class GitServiceImpl extends Disposable implements IGitService { // Query opened repositories const repository = gitAPI.getRepository(uri); if (repository) { - await this.waitForRepositoryState(repository); - + const repositoryContext = GitServiceImpl.repoToRepoContext(repository); const remotes = { rootUri: repository.rootUri, - remoteFetchUrls: repository.state.remotes.map(r => r.fetchUrl), + remoteFetchUrls: repositoryContext ? Array.from(getOrderedRemoteUrlsFromContext(repositoryContext)) : [], }; this.logService.trace(`[GitServiceImpl][getRepositoryFetchUrls] Remotes (open repository): ${JSON.stringify(remotes)}`); @@ -254,6 +277,32 @@ export class GitServiceImpl extends Disposable implements IGitService { } } + private async readLocalGitConfig(rootUri: URI): Promise { + const dotGitUri = URI.file(path.join(rootUri.fsPath, '.git')); + const dotGitStat = await vscode.workspace.fs.stat(dotGitUri); + let gitDirectory = dotGitUri.fsPath; + + if (dotGitStat.type === vscode.FileType.File) { + const dotGit = (await vscode.workspace.fs.readFile(dotGitUri)).toString(); + const gitDirectoryMatch = /^gitdir:\s*(?.+)\s*$/m.exec(dotGit); + if (!gitDirectoryMatch?.groups?.path) { + throw new Error(`Invalid Git directory pointer: ${dotGitUri.fsPath}`); + } + gitDirectory = path.resolve(rootUri.fsPath, gitDirectoryMatch.groups.path); + + try { + const commonDirectory = (await vscode.workspace.fs.readFile(URI.file(path.join(gitDirectory, 'commondir')))).toString().trim(); + if (commonDirectory) { + gitDirectory = path.resolve(gitDirectory, commonDirectory); + } + } catch (error) { + this.logService.trace(`[GitServiceImpl][readLocalGitConfig] No common Git directory for ${gitDirectory}: ${error.message}`); + } + } + + return (await vscode.workspace.fs.readFile(URI.file(path.join(gitDirectory, 'config')))).toString(); + } + async add(uri: URI, paths: string[]): Promise { const gitAPI = this.gitExtensionService.getExtensionApi(); const repository = gitAPI?.getRepository(uri); diff --git a/extensions/copilot/src/platform/networking/common/fetch.ts b/extensions/copilot/src/platform/networking/common/fetch.ts index 8d9a8a2c4622a9..c97c094d6aae4b 100644 --- a/extensions/copilot/src/platform/networking/common/fetch.ts +++ b/extensions/copilot/src/platform/networking/common/fetch.ts @@ -14,18 +14,47 @@ import { ChoiceLogProbs, FilterReason, openAIContextManagementCompactionType, Op export interface RequestId { headerRequestId: string; gitHubRequestId: string; + /** + * CAPI's `X-Copilot-Service-Request-Id`. Copilot API side request identifier, used to correlate + * client telemetry with CAPI service records. + */ + copilotServiceRequestId: string; completionId: string; created: number; serverExperiments: string; deploymentId: string; } +export const COPILOT_SERVICE_REQUEST_ID_HEADER = 'x-copilot-service-request-id'; + +/** + * Reads a header without relying on the casing used by the underlying fetcher. + * `name` must be lowercase. + */ +export function getHeaderIgnoreCase(headers: IHeaders, name: string): string | undefined { + const direct = headers.get(name); + if (direct) { + return direct; + } + for (const [key, value] of headers) { + if (key.toLowerCase() === name) { + return value || undefined; + } + } + return undefined; +} + +export function getCopilotServiceRequestId(headers: IHeaders): string { + return getHeaderIgnoreCase(headers, COPILOT_SERVICE_REQUEST_ID_HEADER) || ''; +} + export function getRequestId(headers: IHeaders, json?: any): RequestId { const serverExperiments = headers.get('X-Copilot-Experiment') || ''; const capiExpAssignmentContext = headers.get('x-copilot-api-exp-assignment-context') || ''; return { headerRequestId: headers.get('x-request-id') || '', gitHubRequestId: headers.get('x-github-request-id') || '', + copilotServiceRequestId: getCopilotServiceRequestId(headers), completionId: json && json.id ? json.id : '', created: json && json.created ? json.created : 0, serverExperiments: serverExperiments && capiExpAssignmentContext diff --git a/extensions/copilot/src/platform/networking/common/networking.ts b/extensions/copilot/src/platform/networking/common/networking.ts index e48ad42bc3217b..9a31baefa56cbe 100644 --- a/extensions/copilot/src/platform/networking/common/networking.ts +++ b/extensions/copilot/src/platform/networking/common/networking.ts @@ -262,8 +262,12 @@ export type IChatRequestTelemetryProperties = { associatedRequestId?: string; retryAfterError?: string; retryAfterErrorGitHubRequestId?: string; + /** CAPI service request id (X-Copilot-Service-Request-Id) of the attempt that failed and triggered this retry. */ + retryAfterErrorCopilotServiceRequestId?: string; connectivityTestError?: string; connectivityTestErrorGitHubRequestId?: string; + /** CAPI service request id (X-Copilot-Service-Request-Id) of the connectivity test request. */ + connectivityTestErrorCopilotServiceRequestId?: string; retryAfterFilterCategory?: string; /** A subtype for categorizing the request with a messageSource- eg subagent */ subType?: string; diff --git a/extensions/copilot/src/platform/networking/node/chatWebSocketManager.ts b/extensions/copilot/src/platform/networking/node/chatWebSocketManager.ts index a4cecd34959795..e1b143d36fb958 100644 --- a/extensions/copilot/src/platform/networking/node/chatWebSocketManager.ts +++ b/extensions/copilot/src/platform/networking/node/chatWebSocketManager.ts @@ -15,6 +15,7 @@ import { ConfigKey, IConfigurationService } from '../../configuration/common/con import { ICAPIClientService } from '../../endpoint/common/capiClient'; import { ILogService, collectSingleLineErrorMessage } from '../../log/common/logService'; import { ITelemetryService } from '../../telemetry/common/telemetry'; +import { getCopilotServiceRequestId } from '../common/fetch'; import { HeadersImpl, IHeaders, WebSocketConnection } from '../common/fetcherService'; import { stringifyJsonBody } from '../common/jsonBody'; import { IEndpointBody } from '../common/networking'; @@ -123,6 +124,9 @@ export interface IChatWebSocketConnection extends IDisposable { /** The GitHub request ID from response headers. */ readonly gitHubRequestId: string; + /** CAPI's service request ID from the handshake response headers. */ + readonly copilotServiceRequestId: string; + /** * The response.id from the last completed response on this connection. * Used as `previous_response_id` on subsequent requests to avoid @@ -377,6 +381,10 @@ class ChatWebSocketConnection extends Disposable implements IChatWebSocketConnec return this._responseHeaders.get('x-github-request-id') || ''; } + get copilotServiceRequestId(): string { + return getCopilotServiceRequestId(this._responseHeaders); + } + async connect(): Promise { if (this._state === ConnectionState.Open) { return; @@ -408,6 +416,7 @@ class ChatWebSocketConnection extends Disposable implements IChatWebSocketConnec conversationId: this._conversationId, initiatingRequestId: this._initiatingRequestId, gitHubRequestId: this.gitHubRequestId, + copilotServiceRequestId: this.copilotServiceRequestId, connectDurationMs, }); resolve(); @@ -428,6 +437,7 @@ class ChatWebSocketConnection extends Disposable implements IChatWebSocketConnec conversationId: this._conversationId, initiatingRequestId: this._initiatingRequestId, gitHubRequestId: this.gitHubRequestId, + copilotServiceRequestId: this.copilotServiceRequestId, error: errorMessage, connectDurationMs, responseStatusCode: this._responseStatusCode, @@ -450,6 +460,7 @@ class ChatWebSocketConnection extends Disposable implements IChatWebSocketConnec conversationId: this._conversationId, initiatingRequestId: this._initiatingRequestId, gitHubRequestId: this.gitHubRequestId, + copilotServiceRequestId: this.copilotServiceRequestId, closeCode: event.code, closeReason: closeCodeDescription, closeEventReason: event.reason, @@ -496,6 +507,7 @@ class ChatWebSocketConnection extends Disposable implements IChatWebSocketConnec hadActiveRequest: this._hadActiveRequest, requestId: this._activeRequest?.requestId, gitHubRequestId: this.gitHubRequestId, + copilotServiceRequestId: this.copilotServiceRequestId, modelId: this._activeRequest?.modelId, error: parseErrorMessage, connectionDurationMs, @@ -529,6 +541,7 @@ class ChatWebSocketConnection extends Disposable implements IChatWebSocketConnec hadActiveRequest: this._hadActiveRequest, requestId: this._activeRequest?.requestId, gitHubRequestId: this.gitHubRequestId, + copilotServiceRequestId: this.copilotServiceRequestId, modelId: this._activeRequest?.modelId, closeCode: event.code, closeReason: closeCodeDescription, @@ -558,6 +571,7 @@ class ChatWebSocketConnection extends Disposable implements IChatWebSocketConnec hadActiveRequest: this._hadActiveRequest, requestId: this._activeRequest?.requestId, gitHubRequestId: this.gitHubRequestId, + copilotServiceRequestId: this.copilotServiceRequestId, modelId: this._activeRequest?.modelId, error: errorMessage, connectionDurationMs, @@ -636,6 +650,7 @@ class ChatWebSocketConnection extends Disposable implements IChatWebSocketConnec hadActiveRequest, requestId, gitHubRequestId: this.gitHubRequestId, + copilotServiceRequestId: this.copilotServiceRequestId, modelId: options.model, requestOutcome: outcome, statefulMarkerMatched, @@ -696,6 +711,7 @@ class ChatWebSocketConnection extends Disposable implements IChatWebSocketConnec hadActiveRequest, requestId, gitHubRequestId: this.gitHubRequestId, + copilotServiceRequestId: this.copilotServiceRequestId, modelId: options.model, statefulMarkerMatched, previousResponseIdUnset, diff --git a/extensions/copilot/src/platform/networking/node/chatWebSocketTelemetry.ts b/extensions/copilot/src/platform/networking/node/chatWebSocketTelemetry.ts index 5b90640374c114..eafce5502365e1 100644 --- a/extensions/copilot/src/platform/networking/node/chatWebSocketTelemetry.ts +++ b/extensions/copilot/src/platform/networking/node/chatWebSocketTelemetry.ts @@ -9,6 +9,8 @@ interface IChatWebSocketConnectionTelemetryProperties { conversationId: string; initiatingRequestId: string; gitHubRequestId: string; + /** CAPI's X-Copilot-Service-Request-Id from the handshake response. */ + copilotServiceRequestId: string; } interface IChatWebSocketRequestTelemetryProperties extends IChatWebSocketConnectionTelemetryProperties { @@ -131,6 +133,7 @@ export class ChatWebSocketTelemetrySender { "conversationId": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Id of the conversation" }, "initiatingRequestId": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Id of the request that initiated the connection" }, "gitHubRequestId": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "GitHub request id if available" }, + "copilotServiceRequestId": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "CAPI service request id (X-Copilot-Service-Request-Id) if available" }, "connectDurationMs": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Time to establish the WebSocket connection in milliseconds", "isMeasurement": true } } */ @@ -138,6 +141,7 @@ export class ChatWebSocketTelemetrySender { conversationId: properties.conversationId, initiatingRequestId: properties.initiatingRequestId, gitHubRequestId: properties.gitHubRequestId, + copilotServiceRequestId: properties.copilotServiceRequestId, }, { connectDurationMs: properties.connectDurationMs, }); @@ -154,6 +158,7 @@ export class ChatWebSocketTelemetrySender { "conversationId": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Id of the conversation" }, "initiatingRequestId": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Id of the request that initiated the connection" }, "gitHubRequestId": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "GitHub request id if available" }, + "copilotServiceRequestId": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "CAPI service request id (X-Copilot-Service-Request-Id) if available" }, "error": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Error message for the failed connection" }, "connectDurationMs": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Time until the connection error in milliseconds", "isMeasurement": true }, "responseStatusCode": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "HTTP response status code from the failed connection attempt", "isMeasurement": true }, @@ -165,6 +170,7 @@ export class ChatWebSocketTelemetrySender { conversationId: properties.conversationId, initiatingRequestId: properties.initiatingRequestId, gitHubRequestId: properties.gitHubRequestId, + copilotServiceRequestId: properties.copilotServiceRequestId, error: properties.error, responseStatusText: properties.responseStatusText, networkError: properties.networkError, @@ -189,6 +195,7 @@ export class ChatWebSocketTelemetrySender { "hadActiveRequest": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Whether the previous request was still active when the new one began", "isMeasurement": true }, "requestId": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Id of the current turn request" }, "gitHubRequestId": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "GitHub request id if available" }, + "copilotServiceRequestId": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "CAPI service request id (X-Copilot-Service-Request-Id) if available" }, "modelId": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Model identifier from the request body" }, "closeReason": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Human-readable description of the close code" }, "closeEventReason": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Close event reason string from server" }, @@ -208,6 +215,7 @@ export class ChatWebSocketTelemetrySender { previousTurnId: properties.previousTurnId, requestId: properties.requestId, gitHubRequestId: properties.gitHubRequestId, + copilotServiceRequestId: properties.copilotServiceRequestId, modelId: properties.modelId, closeReason: properties.closeReason, closeEventReason: properties.closeEventReason, @@ -238,6 +246,7 @@ export class ChatWebSocketTelemetrySender { "hadActiveRequest": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Whether the previous request was still active when the new one began", "isMeasurement": true }, "requestId": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Id of the current turn request" }, "gitHubRequestId": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "GitHub request id if available" }, + "copilotServiceRequestId": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "CAPI service request id (X-Copilot-Service-Request-Id) if available" }, "modelId": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Model identifier from the request body" }, "error": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Error message" }, "totalSentMessageCount": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Number of messages sent over this connection", "isMeasurement": true }, @@ -254,6 +263,7 @@ export class ChatWebSocketTelemetrySender { previousTurnId: properties.previousTurnId, requestId: properties.requestId, gitHubRequestId: properties.gitHubRequestId, + copilotServiceRequestId: properties.copilotServiceRequestId, modelId: properties.modelId, error: properties.error, }, { @@ -277,6 +287,7 @@ export class ChatWebSocketTelemetrySender { "conversationId": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Id of the conversation" }, "initiatingRequestId": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Id of the request that initiated the connection" }, "gitHubRequestId": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "GitHub request id if available" }, + "copilotServiceRequestId": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "CAPI service request id (X-Copilot-Service-Request-Id) if available" }, "closeReason": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Human-readable description of the close code" }, "closeEventReason": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Close event reason string from server" }, "closeEventWasClean": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Whether the connection closed cleanly" }, @@ -288,6 +299,7 @@ export class ChatWebSocketTelemetrySender { conversationId: properties.conversationId, initiatingRequestId: properties.initiatingRequestId, gitHubRequestId: properties.gitHubRequestId, + copilotServiceRequestId: properties.copilotServiceRequestId, closeReason: properties.closeReason, closeEventReason: properties.closeEventReason, closeEventWasClean: properties.closeEventWasClean, @@ -312,6 +324,7 @@ export class ChatWebSocketTelemetrySender { "hadActiveRequest": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Whether the previous request was still active when the new one began", "isMeasurement": true }, "requestId": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Id of the current turn request" }, "gitHubRequestId": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "GitHub request id if available" }, + "copilotServiceRequestId": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "CAPI service request id (X-Copilot-Service-Request-Id) if available" }, "modelId": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Model identifier from the request body" }, "statefulMarkerMatched": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Whether the connection stateful marker matched the previous_response_id sent in the request", "isMeasurement": true }, "previousResponseIdUnset": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Whether previous_response_id was undefined in the request", "isMeasurement": true }, @@ -337,6 +350,7 @@ export class ChatWebSocketTelemetrySender { previousTurnId: properties.previousTurnId, requestId: properties.requestId, gitHubRequestId: properties.gitHubRequestId, + copilotServiceRequestId: properties.copilotServiceRequestId, modelId: properties.modelId, }, { hadActiveRequest: properties.hadActiveRequest ? 1 : 0, @@ -373,6 +387,7 @@ export class ChatWebSocketTelemetrySender { "hadActiveRequest": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Whether the previous request was still active when the new one began", "isMeasurement": true }, "requestId": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Id of the current turn request" }, "gitHubRequestId": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "GitHub request id if available" }, + "copilotServiceRequestId": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "CAPI service request id (X-Copilot-Service-Request-Id) if available" }, "modelId": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Model identifier from the request body" }, "error": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Parse error message" }, "totalSentMessageCount": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Number of messages sent over this connection", "isMeasurement": true }, @@ -390,6 +405,7 @@ export class ChatWebSocketTelemetrySender { previousTurnId: properties.previousTurnId, requestId: properties.requestId, gitHubRequestId: properties.gitHubRequestId, + copilotServiceRequestId: properties.copilotServiceRequestId, modelId: properties.modelId, error: properties.error, }, { @@ -418,6 +434,7 @@ export class ChatWebSocketTelemetrySender { "hadActiveRequest": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Whether the previous request was still active when the new one began", "isMeasurement": true }, "requestId": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Id of the current turn request" }, "gitHubRequestId": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "GitHub request id if available" }, + "copilotServiceRequestId": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "CAPI service request id (X-Copilot-Service-Request-Id) if available" }, "modelId": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Model identifier from the request body" }, "requestOutcome": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Terminal outcome of the websocket request" }, "statefulMarkerMatched": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Whether the connection stateful marker matched the previous_response_id sent in the request", "isMeasurement": true }, @@ -453,6 +470,7 @@ export class ChatWebSocketTelemetrySender { previousTurnId: properties.previousTurnId, requestId: properties.requestId, gitHubRequestId: properties.gitHubRequestId, + copilotServiceRequestId: properties.copilotServiceRequestId, modelId: properties.modelId, requestOutcome: properties.requestOutcome, closeReason: properties.closeReason, diff --git a/extensions/copilot/src/platform/networking/test/node/fetch.spec.ts b/extensions/copilot/src/platform/networking/test/node/fetch.spec.ts index cc7134ccbdfdd8..4fd26d1765f0d8 100644 --- a/extensions/copilot/src/platform/networking/test/node/fetch.spec.ts +++ b/extensions/copilot/src/platform/networking/test/node/fetch.spec.ts @@ -41,16 +41,27 @@ suite('getRequestId', () => { const headers = new HeadersImpl({ 'x-request-id': 'req-123', 'x-github-request-id': 'gh-456', + 'x-copilot-service-request-id': 'svc-abc', 'azureml-model-deployment': 'deploy-789', }); const result = getRequestId(headers, { id: 'comp-1', created: 1000 }); assert.deepStrictEqual(result, { headerRequestId: 'req-123', gitHubRequestId: 'gh-456', + copilotServiceRequestId: 'svc-abc', completionId: 'comp-1', created: 1000, serverExperiments: '', deploymentId: 'deploy-789', }); }); + + test('reads X-Copilot-Service-Request-Id regardless of header casing', () => { + const headers = new HeadersImpl({ 'X-Copilot-Service-Request-Id': 'svc-abc' }); + assert.strictEqual(getRequestId(headers).copilotServiceRequestId, 'svc-abc'); + }); + + test('missing X-Copilot-Service-Request-Id returns empty string', () => { + assert.strictEqual(getRequestId(new HeadersImpl({})).copilotServiceRequestId, ''); + }); }); diff --git a/extensions/copilot/src/platform/telemetry/common/telemetryData.ts b/extensions/copilot/src/platform/telemetry/common/telemetryData.ts index 12a0accfc386ca..d27a75b9024be9 100644 --- a/extensions/copilot/src/platform/telemetry/common/telemetryData.ts +++ b/extensions/copilot/src/platform/telemetry/common/telemetryData.ts @@ -104,6 +104,7 @@ export class TelemetryData { completionId: requestId.completionId, created: requestId.created.toString(), headerRequestId: requestId.headerRequestId, + copilotServiceRequestId: requestId.copilotServiceRequestId, serverExperiments: requestId.serverExperiments, deploymentId: requestId.deploymentId, }; diff --git a/package-lock.json b/package-lock.json index 64d963b211f625..d622b46bd0dcdf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "code-oss-dev", - "version": "1.137.0", + "version": "1.138.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "code-oss-dev", - "version": "1.137.0", + "version": "1.138.0", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -71,7 +71,7 @@ "playwright-core": "1.61.0-alpha-2026-06-04", "ssh2": "^1.16.0", "tar": "^7.5.20", - "tas-client": "0.4.3", + "tas-client": "0.4.6", "undici": "^7.28.0", "vscode-oniguruma": "1.7.0", "vscode-regexpp": "^3.1.0", @@ -113,8 +113,8 @@ "@types/yazl": "^2.4.2", "@typescript-eslint/utils": "8.66.0", "@typescript/native": "npm:typescript@^7.0.2", - "@vscode/component-explorer": "^0.2.1-94", - "@vscode/component-explorer-cli": "^0.2.1-95", + "@vscode/component-explorer": "^0.2.1-137", + "@vscode/component-explorer-cli": "^0.2.1-140", "@vscode/gulp-electron": "^1.43.0", "@vscode/l10n-dev": "0.0.35", "@vscode/telemetry-extractor": "^1.20.4", @@ -4260,9 +4260,9 @@ "license": "CC-BY-4.0" }, "node_modules/@vscode/component-explorer": { - "version": "0.2.1-94", - "resolved": "https://registry.npmjs.org/@vscode/component-explorer/-/component-explorer-0.2.1-94.tgz", - "integrity": "sha512-IQftRt3pgRhz043tkIMZ5rwZB/TjZvVU7+1tymlf6VuiIkIKev1J684BCFoFp0tZfa6D7sGCZjp1VzkV4xZGTw==", + "version": "0.2.1-137", + "resolved": "https://registry.npmjs.org/@vscode/component-explorer/-/component-explorer-0.2.1-137.tgz", + "integrity": "sha512-Xhbm4UTjLtQxdAC3EQz2rj4T5bnP/oU4QCuuRzumkkhwGRx6yN6wQ3b0FgBej/unbzNd4JNowvT73NNFURKnjA==", "dev": true, "license": "MIT", "dependencies": { @@ -4272,9 +4272,9 @@ } }, "node_modules/@vscode/component-explorer-cli": { - "version": "0.2.1-95", - "resolved": "https://registry.npmjs.org/@vscode/component-explorer-cli/-/component-explorer-cli-0.2.1-95.tgz", - "integrity": "sha512-qfmIjEPTZc8tRZ8U9PHi169wHAtcOigxsQ2QCDg/An1Vnoz0OnOtZJnKKsAWOKsqtXm7pi1wIHua3CubOdM3KQ==", + "version": "0.2.1-140", + "resolved": "https://registry.npmjs.org/@vscode/component-explorer-cli/-/component-explorer-cli-0.2.1-140.tgz", + "integrity": "sha512-4LUuPWBi9JQEmxBX7ZjW1TGaO5zCMoAeELPDsH+JX/fuRN2XgJb1OfR//HuBAx0FAWQGEH/k8mRnyTpSBlPiIQ==", "dev": true, "license": "MIT", "dependencies": { @@ -19301,9 +19301,9 @@ } }, "node_modules/tas-client": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/tas-client/-/tas-client-0.4.3.tgz", - "integrity": "sha512-6bqNgMv7ys5PL6Zqz+EoR8J5KrhAGFjodUPkcpM80DHFakKiWcjqKiID5qxsssC/E70fcgYYWPAUK7CWS29b+Q==", + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/tas-client/-/tas-client-0.4.6.tgz", + "integrity": "sha512-mmefC5nu8YZ5zGY4IyeXWnvK6YWv2YCz60/7q5banNU+R8JGZT2Q4lOS4g4xJyvnQIbqBFXzMFPsIitI8kVXqA==", "license": "MIT", "engines": { "node": ">=22" diff --git a/package.json b/package.json index 8e44d8818f12ff..25e69b231c9516 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "code-oss-dev", - "version": "1.137.0", + "version": "1.138.0", "distro": "e89f2ac3aa7f31b63c96b60b1c598aaf7e8bc076", "author": { "name": "Microsoft Corporation" @@ -161,7 +161,7 @@ "playwright-core": "1.61.0-alpha-2026-06-04", "ssh2": "^1.16.0", "tar": "^7.5.20", - "tas-client": "0.4.3", + "tas-client": "0.4.6", "undici": "^7.28.0", "vscode-oniguruma": "1.7.0", "vscode-regexpp": "^3.1.0", @@ -203,8 +203,8 @@ "@types/yazl": "^2.4.2", "@typescript-eslint/utils": "8.66.0", "@typescript/native": "npm:typescript@^7.0.2", - "@vscode/component-explorer": "^0.2.1-94", - "@vscode/component-explorer-cli": "^0.2.1-95", + "@vscode/component-explorer": "^0.2.1-137", + "@vscode/component-explorer-cli": "^0.2.1-140", "@vscode/gulp-electron": "^1.43.0", "@vscode/l10n-dev": "0.0.35", "@vscode/telemetry-extractor": "^1.20.4", diff --git a/remote/package-lock.json b/remote/package-lock.json index a17926c6a37af7..0a122e910e063c 100644 --- a/remote/package-lock.json +++ b/remote/package-lock.json @@ -50,7 +50,7 @@ "node-pty": "^1.2.0-beta.15", "ssh2": "^1.16.0", "tar": "^7.5.20", - "tas-client": "0.4.3", + "tas-client": "0.4.6", "vscode-oniguruma": "1.7.0", "vscode-regexpp": "^3.1.0", "vscode-textmate": "^9.3.2", @@ -2015,9 +2015,9 @@ } }, "node_modules/tas-client": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/tas-client/-/tas-client-0.4.3.tgz", - "integrity": "sha512-6bqNgMv7ys5PL6Zqz+EoR8J5KrhAGFjodUPkcpM80DHFakKiWcjqKiID5qxsssC/E70fcgYYWPAUK7CWS29b+Q==", + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/tas-client/-/tas-client-0.4.6.tgz", + "integrity": "sha512-mmefC5nu8YZ5zGY4IyeXWnvK6YWv2YCz60/7q5banNU+R8JGZT2Q4lOS4g4xJyvnQIbqBFXzMFPsIitI8kVXqA==", "license": "MIT", "engines": { "node": ">=22" diff --git a/remote/package.json b/remote/package.json index 309557bc0b3c9c..02ef9b706ade40 100644 --- a/remote/package.json +++ b/remote/package.json @@ -45,7 +45,7 @@ "node-pty": "^1.2.0-beta.15", "ssh2": "^1.16.0", "tar": "^7.5.20", - "tas-client": "0.4.3", + "tas-client": "0.4.6", "vscode-oniguruma": "1.7.0", "vscode-regexpp": "^3.1.0", "vscode-textmate": "^9.3.2", diff --git a/remote/web/package-lock.json b/remote/web/package-lock.json index d002d036726c8a..5079eec99229cd 100644 --- a/remote/web/package-lock.json +++ b/remote/web/package-lock.json @@ -25,7 +25,7 @@ "@xterm/xterm": "^6.1.0-beta.303", "jschardet": "3.1.4", "katex": "^0.16.22", - "tas-client": "0.4.3", + "tas-client": "0.4.6", "vscode-oniguruma": "1.7.0", "vscode-textmate": "^9.3.2" } @@ -240,9 +240,9 @@ } }, "node_modules/tas-client": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/tas-client/-/tas-client-0.4.3.tgz", - "integrity": "sha512-6bqNgMv7ys5PL6Zqz+EoR8J5KrhAGFjodUPkcpM80DHFakKiWcjqKiID5qxsssC/E70fcgYYWPAUK7CWS29b+Q==", + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/tas-client/-/tas-client-0.4.6.tgz", + "integrity": "sha512-mmefC5nu8YZ5zGY4IyeXWnvK6YWv2YCz60/7q5banNU+R8JGZT2Q4lOS4g4xJyvnQIbqBFXzMFPsIitI8kVXqA==", "license": "MIT", "engines": { "node": ">=22" diff --git a/remote/web/package.json b/remote/web/package.json index a54bdbd4ede817..9a39497437b4e6 100644 --- a/remote/web/package.json +++ b/remote/web/package.json @@ -20,7 +20,7 @@ "@xterm/xterm": "^6.1.0-beta.303", "jschardet": "3.1.4", "katex": "^0.16.22", - "tas-client": "0.4.3", + "tas-client": "0.4.6", "vscode-oniguruma": "1.7.0", "vscode-textmate": "^9.3.2" } diff --git a/src/vs/base/browser/ui/radio/radio.css b/src/vs/base/browser/ui/radio/radio.css index 9f89d689bd75e7..37519e3a0ec9cb 100644 --- a/src/vs/base/browser/ui/radio/radio.css +++ b/src/vs/base/browser/ui/radio/radio.css @@ -25,11 +25,11 @@ border-bottom-right-radius: 3px; } -.monaco-custom-radio > .monaco-button:not(.active):not(:last-child) { +.monaco-custom-radio:not(.segmented) > .monaco-button:not(.active):not(:last-child) { border-right: none; } -.monaco-custom-radio > .monaco-button.previous-active { +.monaco-custom-radio:not(.segmented) > .monaco-button.previous-active { border-left: none; } @@ -78,12 +78,14 @@ background: color-mix(in srgb, var(--vscode-foreground) 10%, transparent); } +/* Every segment carries the border box so the selected one can colour it without + * shifting the reel by a pixel as the selection moves. */ .monaco-custom-radio.segmented > .monaco-button { flex: 1 1 0; justify-content: center; height: var(--vscode-spacing-size240); padding: var(--vscode-spacing-sizeNone) var(--vscode-spacing-size60); - border: none; + border: var(--vscode-strokeThickness) solid transparent; border-radius: var(--vscode-cornerRadius-circle); color: var(--vscode-descriptionForeground); background: transparent; @@ -91,15 +93,31 @@ white-space: nowrap; } +/* Reserve the selected label's width without making unselected labels bold. */ +.monaco-custom-radio.segmented > .monaco-button > [data-label] { + display: inline-grid; + justify-items: center; +} + +.monaco-custom-radio.segmented > .monaco-button > [data-label]::after { + content: attr(data-label); + height: 0; + font-weight: var(--vscode-fontWeight-semiBold); + visibility: hidden; + pointer-events: none; +} + .monaco-custom-radio.segmented > .monaco-button:hover:not(.active) { color: var(--vscode-foreground); background: transparent; } +/* The fill only shifts the hue, so the border is what identifies the selection. */ .monaco-custom-radio.segmented > .monaco-button.active, .monaco-custom-radio.segmented > .monaco-button.active:hover { color: var(--vscode-foreground); background: color-mix(in srgb, var(--vscode-button-background) 18%, var(--vscode-menu-background)); + border-color: var(--vscode-radio-activeBorder, var(--vscode-contrastActiveBorder)); box-shadow: var(--vscode-shadow-sm); font-weight: var(--vscode-fontWeight-semiBold); } @@ -109,7 +127,6 @@ outline-offset: calc(-1 * var(--vscode-strokeThickness)); } -.hc-black .monaco-custom-radio.segmented > .monaco-button.active, -.hc-light .monaco-custom-radio.segmented > .monaco-button.active { - border: var(--vscode-strokeThickness) solid var(--vscode-radio-activeBorder, var(--vscode-contrastActiveBorder)); +.monaco-custom-radio.segmented > .monaco-button:focus:not(:focus-visible) { + outline: none; } diff --git a/src/vs/base/browser/ui/radio/radio.ts b/src/vs/base/browser/ui/radio/radio.ts index 8fb75fffbdbdf5..b2c48611a86549 100644 --- a/src/vs/base/browser/ui/radio/radio.ts +++ b/src/vs/base/browser/ui/radio/radio.ts @@ -12,7 +12,7 @@ import { StandardKeyboardEvent } from '../../keyboardEvent.js'; import { KeyCode } from '../../../common/keyCodes.js'; import { IHoverDelegate } from '../hover/hoverDelegate.js'; import { Button } from '../button/button.js'; -import { DisposableMap, DisposableStore } from '../../../common/lifecycle.js'; +import { Disposable, DisposableMap, DisposableStore, IDisposable, toDisposable } from '../../../common/lifecycle.js'; import { createInstantHoverDelegate } from '../hover/hoverDelegateFactory.js'; export interface IRadioStyles { @@ -55,6 +55,10 @@ export class Radio extends Widget { private readonly _onDidSelect = this._register(new Emitter()); readonly onDidSelect = this._onDidSelect.event; + private readonly _onDidActivate = this._register(new Emitter()); + /** Fires on click, Enter, or Space, even when the item is already selected. */ + readonly onDidActivate = this._onDidActivate.event; + readonly domNode: HTMLElement; private readonly hoverDelegate: IHoverDelegate; @@ -84,6 +88,21 @@ export class Radio extends Widget { this.setItems(opts.items); } + /** The option buttons in item order, for callers that need to measure or decorate them. */ + get optionElements(): readonly HTMLElement[] { + return this.orderedButtons.map(button => button.element); + } + /** Shows `text` on an option until the returned disposable puts the item's own label back. */ + overrideOptionLabel(index: number, text: string): IDisposable { + const button = this.orderedButtons[index]; + const item = this.items[index]; + if (!button || !item) { + return Disposable.None; + } + this.setButtonLabel(button, text); + return toDisposable(() => this.setButtonLabel(button, item.text)); + } + setItems(items: ReadonlyArray): void { this.buttons.clearAndDisposeAll(); this.orderedButtons = []; @@ -100,8 +119,10 @@ export class Radio extends Widget { })); button.element.setAttribute('role', 'radio'); button.enabled = !item.disabled; - // Button turns Enter and Space into a click, which is how `focus` mode selects. - disposables.add(button.onDidClick(() => this.selectItem(index))); + disposables.add(button.onDidClick(() => { + this.selectItem(index); + this._onDidActivate.fire(index); + })); disposables.add(addDisposableListener(button.element, EventType.KEY_DOWN, e => { const event = new StandardKeyboardEvent(e); const delta = event.equals(KeyCode.RightArrow) || event.equals(KeyCode.DownArrow) ? 1 @@ -137,8 +158,19 @@ export class Radio extends Widget { focusActiveItem(): void { const index = this.activeItem ? this.items.indexOf(this.activeItem) : -1; if (index !== -1) { - this.orderedButtons[index]?.focus(); + this.focusItem(index); + } + } + + /** Moves focus to an item without selecting it. */ + focusItem(index: number): void { + if (!this.orderedButtons[index]) { + throw new Error('Invalid Index'); + } + for (let candidate = 0; candidate < this.orderedButtons.length; candidate++) { + this.orderedButtons[candidate].element.tabIndex = candidate === index ? 0 : -1; } + this.orderedButtons[index].focus(); } private selectItem(index: number): void { @@ -167,11 +199,15 @@ export class Radio extends Widget { } } - private focusItem(index: number): void { - for (let candidate = 0; candidate < this.orderedButtons.length; candidate++) { - this.orderedButtons[candidate].element.tabIndex = candidate === index ? 0 : -1; + private setButtonLabel(button: Button, text: string): void { + button.label = text; + if (this.domNode.classList.contains('segmented')) { + for (const element of button.element.children) { + if (!element.classList.contains('codicon')) { + element.setAttribute('data-label', element.textContent ?? ''); + } + } } - this.orderedButtons[index]?.focus(); } private updateButtons(): void { @@ -183,7 +219,7 @@ export class Radio extends Widget { button.element.classList.toggle('previous-active', isPreviousActive); button.element.setAttribute('aria-checked', String(isActive)); button.element.tabIndex = isActive ? 0 : -1; - button.label = item.text; + this.setButtonLabel(button, item.text); } } diff --git a/src/vs/base/common/jsonSchema.ts b/src/vs/base/common/jsonSchema.ts index 3a674ccdd0f0b2..be69a92bf678d6 100644 --- a/src/vs/base/common/jsonSchema.ts +++ b/src/vs/base/common/jsonSchema.ts @@ -80,6 +80,7 @@ export interface IJSONSchema { patternErrorMessage?: string; deprecationMessage?: string; markdownDeprecationMessage?: string; + deprecationMessageSeverity?: 'warning' | 'info'; enumDescriptions?: string[]; markdownEnumDescriptions?: string[]; markdownDescription?: string; @@ -327,4 +328,3 @@ function traverseNodes(root: IJSONSchema, visit: (schema: IJSONSchema) => boolea next = toWalk.pop(); } } - diff --git a/src/vs/base/test/browser/ui/radio/radio.test.ts b/src/vs/base/test/browser/ui/radio/radio.test.ts new file mode 100644 index 00000000000000..9f31f87ffb0e3f --- /dev/null +++ b/src/vs/base/test/browser/ui/radio/radio.test.ts @@ -0,0 +1,264 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { getWindow } from '../../../../browser/dom.js'; +import { Radio, IRadioOptions } from '../../../../browser/ui/radio/radio.js'; +import { mainWindow } from '../../../../browser/window.js'; +import { toDisposable } from '../../../../common/lifecycle.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../common/utils.js'; + +suite('Radio', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + function createRadio(options: IRadioOptions): Radio { + const radio = disposables.add(new Radio(options)); + mainWindow.document.body.appendChild(radio.domNode); + disposables.add(toDisposable(() => radio.domNode.remove())); + radio.domNode.style.cssText = ` + position: absolute; + top: 0; + left: 0; + font-family: sans-serif; + --vscode-spacing-sizeNone: 0px; + --vscode-spacing-size20: 2px; + --vscode-spacing-size60: 6px; + --vscode-spacing-size240: 24px; + --vscode-strokeThickness: 1px; + --vscode-fontSize-label2: 11px; + --vscode-fontWeight-semiBold: 600; + `; + return radio; + } + + for (const labels of [ + ['Low', 'Medium', 'High'], + ['Low', 'Medium', 'High', 'Extra High'], + ['Minimal', 'Low', 'Medium', 'High', 'Max'], + ['None', 'Low', 'Medium', 'High', 'Extra High', 'Max'], + ['Standard', '$(zap) Fast'], + ]) { + for (const width of [200, 276]) { + test(`segmented option bounds stay fixed at ${width}px: ${labels.join(', ')}`, () => { + const radio = createRadio({ + className: 'segmented', + items: labels.map(text => ({ text })), + }); + radio.domNode.style.width = `${width}px`; + const measure = () => radio.optionElements.map(element => { + const { x, y, width, height } = element.getBoundingClientRect(); + return { x, y, width, height }; + }); + const initial = measure(); + const selections = labels.map((_, index) => { + radio.optionElements[index].click(); + return measure(); + }); + + assert.deepStrictEqual({ + laidOut: initial.every(box => box.width > 0 && box.height > 0), + selections, + }, { + laidOut: true, + selections: labels.map(() => initial), + }); + }); + } + } + + test('segmented label sizing follows temporary labels and restoration without duplicating icons', () => { + const radio = createRadio({ className: 'segmented', items: [{ text: '$(zap) Fast', ariaLabel: 'Fast' }] }); + const element = radio.optionElements[0]; + const readLabel = () => ({ + text: element.textContent, + labels: Array.from(element.querySelectorAll('[data-label]'), label => label.getAttribute('data-label')), + icons: element.querySelectorAll('.codicon').length, + ariaLabel: element.getAttribute('aria-label'), + }); + const initial = readLabel(); + const override = disposables.add(radio.overrideOptionLabel(0, '$(check) Standard')); + const temporary = readLabel(); + override.dispose(); + + assert.deepStrictEqual({ initial, temporary, restored: readLabel() }, { + initial: { text: 'Fast', labels: ['Fast'], icons: 1, ariaLabel: 'Fast' }, + temporary: { text: 'Standard', labels: ['Standard'], icons: 1, ariaLabel: 'Fast' }, + restored: { text: 'Fast', labels: ['Fast'], icons: 1, ariaLabel: 'Fast' }, + }); + }); + + test('joined radios retain shared borders and do not add label sizing', () => { + const radio = createRadio({ items: [{ text: 'One' }, { text: 'Two' }, { text: 'Three' }] }); + const borders = () => radio.optionElements.map(element => { + const style = getWindow(element).getComputedStyle(element); + return [style.borderLeftWidth, style.borderRightWidth]; + }); + const firstSelected = borders(); + radio.setActiveItem(1); + + assert.deepStrictEqual({ + firstSelected, + secondSelected: borders(), + sizingLabels: radio.domNode.querySelectorAll('[data-label]').length, + }, { + firstSelected: [['1px', '1px'], ['0px', '0px'], ['1px', '1px']], + secondSelected: [['1px', '0px'], ['1px', '1px'], ['0px', '1px']], + sizingLabels: 0, + }); + }); + + test('segmented arrows can move focus without selecting until Enter', () => { + const radio = createRadio({ + className: 'segmented', + arrowKeyBehavior: 'focus', + items: [{ text: 'Low' }, { text: 'Medium' }, { text: 'High' }], + }); + const selected: number[] = []; + disposables.add(radio.onDidSelect(index => selected.push(index))); + const [low, medium] = radio.optionElements; + low.focus(); + low.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', keyCode: 39, bubbles: true })); + const beforeEnter = { + focused: mainWindow.document.activeElement === medium, + checked: radio.optionElements.map(element => element.getAttribute('aria-checked')), + tabIndexes: radio.optionElements.map(element => element.tabIndex), + }; + medium.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', keyCode: 13, bubbles: true })); + + assert.deepStrictEqual({ + beforeEnter, + selected, + checked: radio.optionElements.map(element => element.getAttribute('aria-checked')), + }, { + beforeEnter: { focused: true, checked: ['true', 'false', 'false'], tabIndexes: [-1, 0, -1] }, + selected: [1], + checked: ['false', 'true', 'false'], + }); + }); + + test('activation fires for clicks on both selected and unselected items without changing selection events', () => { + const radio = createRadio({ items: [{ text: 'One' }, { text: 'Two' }] }); + const events: string[] = []; + disposables.add(radio.onDidSelect(index => events.push(`selected:${index}`))); + disposables.add(radio.onDidActivate(index => events.push(`activated:${index}`))); + radio.optionElements[0].click(); + radio.optionElements[1].click(); + radio.optionElements[1].click(); + radio.setActiveItem(0); + + assert.deepStrictEqual(events, ['activated:0', 'selected:1', 'activated:1', 'activated:1']); + }); + + for (const [key, keyCode] of [['Enter', 13], [' ', 32]] as const) { + test(`${key === ' ' ? 'Space' : key} activates both selected and unselected items exactly once`, () => { + const radio = createRadio({ + arrowKeyBehavior: 'focus', + items: [{ text: 'One' }, { text: 'Two' }], + }); + const events: string[] = []; + disposables.add(radio.onDidSelect(index => events.push(`selected:${index}`))); + disposables.add(radio.onDidActivate(index => events.push(`activated:${index}`))); + for (const index of [0, 1, 1]) { + radio.optionElements[index].dispatchEvent(new KeyboardEvent('keydown', { key, keyCode, bubbles: true })); + } + + assert.deepStrictEqual(events, ['activated:0', 'selected:1', 'activated:1', 'activated:1']); + }); + } + + for (const arrowKeyBehavior of ['select', 'focus'] as const) { + test(`arrows in ${arrowKeyBehavior} mode skip disabled items and never activate`, () => { + const radio = createRadio({ + arrowKeyBehavior, + items: [{ text: 'One' }, { text: 'Two', disabled: true }, { text: 'Three' }], + }); + const selected: number[] = []; + const activated: number[] = []; + disposables.add(radio.onDidSelect(index => selected.push(index))); + disposables.add(radio.onDidActivate(index => activated.push(index))); + radio.focusActiveItem(); + radio.optionElements[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', keyCode: 39, bubbles: true })); + const focusedAfterArrow = mainWindow.document.activeElement === radio.optionElements[2]; + radio.optionElements[2].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', keyCode: 39, bubbles: true })); + + assert.deepStrictEqual({ + selected, + activated, + focusedAfterArrow, + focusedAfterWrap: mainWindow.document.activeElement === radio.optionElements[0], + }, { + selected: arrowKeyBehavior === 'select' ? [2, 0] : [], + activated: [], + focusedAfterArrow: true, + focusedAfterWrap: true, + }); + }); + } + + test('disabled items and disabled controls cannot be activated', () => { + const radio = createRadio({ items: [{ text: 'One' }, { text: 'Two', disabled: true }] }); + const events: string[] = []; + disposables.add(radio.onDidSelect(index => events.push(`selected:${index}`))); + disposables.add(radio.onDidActivate(index => events.push(`activated:${index}`))); + const activate = (element: HTMLElement) => { + element.click(); + element.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', keyCode: 13, bubbles: true })); + element.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', keyCode: 32, bubbles: true })); + }; + activate(radio.optionElements[1]); + radio.setEnabled(false); + activate(radio.optionElements[0]); + + assert.deepStrictEqual(events, []); + }); + + test('restoring focus keeps one tab stop without selecting or activating', () => { + const radio = createRadio({ arrowKeyBehavior: 'focus', items: [{ text: 'One' }, { text: 'Two' }] }); + const events: number[] = []; + disposables.add(radio.onDidSelect(index => events.push(index))); + disposables.add(radio.onDidActivate(index => events.push(index))); + radio.focusItem(1); + const focusedItem = { + focused: mainWindow.document.activeElement === radio.optionElements[1], + tabIndexes: radio.optionElements.map(element => element.tabIndex), + checked: radio.optionElements.map(element => element.getAttribute('aria-checked')), + }; + radio.focusActiveItem(); + + assert.deepStrictEqual({ + focusedItem, + events, + activeFocused: mainWindow.document.activeElement === radio.optionElements[0], + tabIndexes: radio.optionElements.map(element => element.tabIndex), + }, { + focusedItem: { focused: true, tabIndexes: [-1, 0], checked: ['true', 'false'] }, + events: [], + activeFocused: true, + tabIndexes: [0, -1], + }); + }); + + test('replaced and disposed buttons no longer select or activate', () => { + const radio = createRadio({ items: [{ text: 'One' }, { text: 'Two' }] }); + const events: number[] = []; + disposables.add(radio.onDidSelect(index => events.push(index))); + disposables.add(radio.onDidActivate(index => events.push(index))); + const previousButton = radio.optionElements[1]; + radio.setItems([{ text: 'Three' }]); + previousButton.click(); + const currentButton = radio.optionElements[0]; + radio.dispose(); + currentButton.click(); + + assert.deepStrictEqual(events, []); + }); + + test('focusing an invalid item reports an error without changing the tab order', () => { + const radio = createRadio({ items: [{ text: 'One' }] }); + assert.throws(() => radio.focusItem(-1), /Invalid Index/); + assert.throws(() => radio.focusItem(1), /Invalid Index/); + assert.deepStrictEqual(radio.optionElements.map(element => element.tabIndex), [0]); + }); +}); diff --git a/src/vs/editor/browser/widget/diffEditor/diffEditorWidget.ts b/src/vs/editor/browser/widget/diffEditor/diffEditorWidget.ts index f252b68138ccce..854d67106d8389 100644 --- a/src/vs/editor/browser/widget/diffEditor/diffEditorWidget.ts +++ b/src/vs/editor/browser/widget/diffEditor/diffEditorWidget.ts @@ -633,6 +633,10 @@ export class DiffEditorWidget extends DelegatingEditor implements IDiffEditor { get renderSideBySide(): boolean { return this._options.renderSideBySide.get(); } + get renderSideBySideInAutomaticMode(): IObservable { return this._options.renderSideBySideInAutomaticMode; } + + get temporaryInlineMode(): IObservable { return this._options.temporaryInlineMode; } + resetWidthBasedLayout(): void { this._options.resetWidthBasedLayout(); } diff --git a/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.ts b/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.ts index 24f11fe4ccd1d9..525c02bbc058b3 100644 --- a/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.ts +++ b/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.ts @@ -12,7 +12,7 @@ import { URI } from '../../../../base/common/uri.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { Range } from '../../../common/core/range.js'; -import { IDiffEditorOptions } from '../../../common/config/editorOptions.js'; +import { DiffEditorViewMode, IDiffEditorOptions } from '../../../common/config/editorOptions.js'; import { IDiffEditor } from '../../../common/editorCommon.js'; import { IMultiDiffResourceId } from '../../../common/multiDiffEditor.js'; import { ICodeEditor } from '../../editorBrowser.js'; @@ -115,6 +115,15 @@ export class MultiDiffEditorWidget extends Disposable { }, undefined); } + public setViewMode(mode: DiffEditorViewMode): void { + const currentOptions = this._diffLayoutOptions.get(); + const wasAutomatic = currentOptions?.renderSideBySide === true && currentOptions.useInlineViewWhenSpaceIsLimited === true; + this.setRenderSideBySide(mode !== 'inline', { useInlineViewWhenSpaceIsLimited: mode === 'automatic' }); + if (mode === 'automatic' && !wasAutomatic) { + this.resetWidthBasedLayout(); + } + } + public toggleRenderSideBySide(): void { this.setRenderSideBySide(!(this._diffLayoutOptions.get()?.renderSideBySide ?? true)); } @@ -164,6 +173,10 @@ export class MultiDiffEditorWidget extends Disposable { return this._widgetImpl.get().getScopedInstantiationService(); } + public resetWidthBasedLayout(): void { + this._widgetImpl.get().resetWidthBasedLayout(); + } + public findDocumentDiffItem(resource: URI): IDocumentDiffItem | undefined { return this._widgetImpl.get().findDocumentDiffItem(resource); } diff --git a/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.ts b/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.ts index 707977dd82b4fc..bf914a6462a1ce 100644 --- a/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.ts +++ b/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.ts @@ -13,6 +13,7 @@ import { ContextKeyValue, IContextKeyService } from '../../../../platform/contex import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js'; import { ILogService } from '../../../../platform/log/common/log.js'; +import { bindContextKey } from '../../../../platform/observable/common/platformObservableUtils.js'; import { OffsetRange } from '../../../common/core/ranges/offsetRange.js'; import { IDiffEditorOptions } from '../../../common/config/editorOptions.js'; import { IRange } from '../../../common/core/range.js'; @@ -216,6 +217,10 @@ export class MultiDiffEditorWidgetImpl extends Disposable { )); this._contextKeyService.createKey(EditorContextKeys.inMultiDiffEditor.key, true); + this._register(bindContextKey(EditorContextKeys.diffEditorAutomaticRenderSideBySide, this._contextKeyService, reader => + this.activeControl.read(reader)?.renderSideBySideInAutomaticMode.read(reader) ?? true)); + this._register(bindContextKey(EditorContextKeys.diffEditorTemporaryInlineMode, this._contextKeyService, reader => + this.activeControl.read(reader)?.temporaryInlineMode.read(reader) ?? false)); this._lastDocStates = {}; @@ -393,6 +398,13 @@ export class MultiDiffEditorWidgetImpl extends Disposable { public getScopedInstantiationService(): IInstantiationService { return this._instantiationService; } + + public resetWidthBasedLayout(): void { + for (const item of this._viewItemsInfo.get().items) { + item.template.get()?.editor.resetWidthBasedLayout(); + } + } + public reveal(resource: IMultiDiffResourceId, options?: RevealOptions): void { const viewItems = this._viewItems.get(); const index = viewItems.findIndex( diff --git a/src/vs/editor/common/config/editorOptions.ts b/src/vs/editor/common/config/editorOptions.ts index 6e6f3605c5591c..0a283d7abc78a0 100644 --- a/src/vs/editor/common/config/editorOptions.ts +++ b/src/vs/editor/common/config/editorOptions.ts @@ -863,6 +863,8 @@ export interface IEditorOptions { */ export const MINIMAP_GUTTER_WIDTH = 8; +export type DiffEditorViewMode = 'inline' | 'sideBySide' | 'automatic'; + export interface IDiffEditorBaseOptions { /** * Allow the user to resize the diff editor split view. diff --git a/src/vs/editor/test/browser/widget/multiDiffEditorWidget.test.ts b/src/vs/editor/test/browser/widget/multiDiffEditorWidget.test.ts index 96f7eff3c30109..94f7c3bdd43acf 100644 --- a/src/vs/editor/test/browser/widget/multiDiffEditorWidget.test.ts +++ b/src/vs/editor/test/browser/widget/multiDiffEditorWidget.test.ts @@ -28,6 +28,7 @@ import { MultiDiffEditorWidget } from '../../../browser/widget/multiDiffEditor/m import { IWorkbenchUIElementFactory } from '../../../browser/widget/multiDiffEditor/workbenchUIElementFactory.js'; import { EditorOption } from '../../../common/config/editorOptions.js'; import { IDocumentDiff, IDocumentDiffProvider } from '../../../common/diff/documentDiffProvider.js'; +import { EditorContextKeys } from '../../../common/editorContextKeys.js'; import { instantiateTextModel } from '../../common/testTextModel.js'; import { TestDiffProviderFactoryService } from '../diff/testDiffProviderFactoryService.js'; import { createCodeEditorServices } from '../testCodeEditor.js'; @@ -238,7 +239,7 @@ suite('MultiDiffEditorWidget', () => { {} satisfies IWorkbenchUIElementFactory, { variant: MultiDiffEditorVariant.Standard }, ); - widget.setRenderSideBySide(true, { useInlineViewWhenSpaceIsLimited: true }); + widget.setViewMode('automatic'); widget.layout(new Dimension(800, 600)); const viewModel = widget.createViewModel(model); await waitForState(viewModel.items, items => items.length === 1); @@ -248,6 +249,7 @@ suite('MultiDiffEditorWidget', () => { try { const activeControl = widget.getActiveControl(); const renderSideBySideWhenNarrow = activeControl?.renderSideBySide; + const automaticLayoutWhenNarrow = widget.getContextKeyService().getContextKeyValue(EditorContextKeys.diffEditorAutomaticRenderSideBySide.key); widget.layout(new Dimension(1000, 600)); assert.deepStrictEqual({ configuredAccessibilitySupport: updateOptionsSpy.firstCall.args[0].accessibilitySupport, @@ -255,6 +257,8 @@ suite('MultiDiffEditorWidget', () => { configuredUseInlineViewWhenSpaceIsLimited: updateOptionsSpy.firstCall.args[0].useInlineViewWhenSpaceIsLimited, renderSideBySideWhenNarrow, renderSideBySideWhenWide: activeControl?.renderSideBySide, + automaticLayoutWhenNarrow, + automaticLayoutWhenWide: widget.getContextKeyService().getContextKeyValue(EditorContextKeys.diffEditorAutomaticRenderSideBySide.key), optionsAppliedBeforeModel: updateOptionsSpy.calledBefore(setDiffModelSpy), effectiveAccessibilitySupport: activeControl?.getModifiedEditor().getOption(EditorOption.accessibilitySupport), }, { @@ -263,6 +267,8 @@ suite('MultiDiffEditorWidget', () => { configuredUseInlineViewWhenSpaceIsLimited: true, renderSideBySideWhenNarrow: false, renderSideBySideWhenWide: true, + automaticLayoutWhenNarrow: false, + automaticLayoutWhenWide: true, optionsAppliedBeforeModel: true, effectiveAccessibilitySupport: AccessibilitySupport.Disabled, }); diff --git a/src/vs/platform/actionWidget/browser/actionList.ts b/src/vs/platform/actionWidget/browser/actionList.ts index bab38bcfb7a5a9..b59d82e3f0e657 100644 --- a/src/vs/platform/actionWidget/browser/actionList.ts +++ b/src/vs/platform/actionWidget/browser/actionList.ts @@ -58,15 +58,19 @@ export interface IActionListItemHover { */ readonly disposable?: IDisposable; /** - * When true, the row shows a chevron that opens the hover panel on click, and - * ArrowRight opens it from the keyboard. The panel still auto-shows on hover. + * Enables keyboard access and a clickable row chevron for the hover panel. + * The chevron can be hidden with {@link IActionListItemHover.showIndicator}. */ readonly expandable?: boolean; + /** Whether to show the expandable hover's row chevron. Defaults to true. */ + readonly showIndicator?: boolean; /** * CSS class set on the hover panel while this item's hover is showing, so a * consumer can style the panel without reaching for the content inside it. */ readonly panelClassName?: string; + /** Align the panel's adjoining edge with the outer action widget rather than its inset list. */ + readonly alignToParent?: boolean; } /** @@ -242,14 +246,15 @@ class SeparatorRenderer implements IListRenderer, ISeparat } } -/** - * Whether the item shows a chevron that opens its panel. Items whose panel only - * auto-shows on hover get no chevron. - */ -function hasSubmenuIndicator(item: IActionListItem): boolean { +/** Whether the row exposes a submenu or keyboard-accessible hover panel. */ +function hasExpandablePanel(item: IActionListItem): boolean { return (!!item.submenuActions?.length && !item.hover?.content) || !!item.hover?.expandable; } +function hasSubmenuIndicator(item: IActionListItem): boolean { + return hasExpandablePanel(item) && item.hover?.showIndicator !== false; +} + class ActionItemRenderer implements IListRenderer, IActionMenuTemplateData> { get templateId(): string { return ActionListItemKind.Action; } @@ -493,17 +498,10 @@ class ActionItemRenderer implements IListRenderer, IAction actionBar.push(toolbarActions, { icon: true, label: false }); } - // Show submenu indicator for items with submenu actions, or for items that - // opt into an expandable hover panel. if (hasSubmenuIndicator(element)) { data.submenuIndicator.className = 'action-list-submenu-indicator has-submenu ' + ThemeIcon.asClassName(Codicon.chevronRight); data.submenuIndicator.style.display = ''; data.submenuIndicator.style.visibility = ''; - // Names what the row opens, so a screen reader can tell that there is more - // here than the row itself. Rows are recycled, so the state is always set - // rather than only added. - data.container.setAttribute('aria-haspopup', element.hover?.expandable ? 'dialog' : 'menu'); - data.container.setAttribute('aria-expanded', 'false'); data.elementDisposables.add(dom.addDisposableListener(data.submenuIndicator, dom.EventType.CLICK, (e) => { e.stopPropagation(); this._onShowSubmenu?.(element); @@ -513,13 +511,20 @@ class ActionItemRenderer implements IListRenderer, IAction data.submenuIndicator.className = 'action-list-submenu-indicator'; data.submenuIndicator.style.display = ''; data.submenuIndicator.style.visibility = 'hidden'; - data.container.removeAttribute('aria-haspopup'); - data.container.removeAttribute('aria-expanded'); } else { data.submenuIndicator.className = 'action-list-submenu-indicator'; data.submenuIndicator.style.display = 'none'; + } + + // Keyboard and screen-reader access remain available when the chevron is hidden. + if (hasExpandablePanel(element)) { + data.container.setAttribute('aria-haspopup', element.hover?.expandable ? 'dialog' : 'menu'); + data.container.setAttribute('aria-expanded', 'false'); + } else { data.container.removeAttribute('aria-haspopup'); - data.container.removeAttribute('aria-expanded'); + if (!element.isSectionToggle) { + data.container.removeAttribute('aria-expanded'); + } } } @@ -639,6 +644,9 @@ export interface IActionListOptions { /** Optional action item id to focus when the list opens. */ readonly initialFocusItemId?: string; + /** Show immediate, persistent previews for the focused or hovered row. */ + readonly persistentHover?: boolean; + /** * Controls the gutter kept for the submenu chevron on items that have none. * - `true` (default): kept while some item shows a chevron. @@ -731,6 +739,10 @@ export class ActionListWidget extends Disposable { private _currentSubmenuWidget: ActionListWidget | undefined; private _currentSubmenuElement: IActionListItem | undefined; private _submenuPanelClassName: string | undefined; + private _layoutSubmenu: (() => void) | undefined; + private _submenuSide: 'left' | 'right' | undefined; + private _submenuPointerOrigin: { readonly x: number; readonly y: number } | undefined; + private _submenuPointerGraceDeadline: number | undefined; /** The row currently reporting `aria-expanded`, reset when its panel closes. */ private _expandedTrigger: HTMLElement | undefined; @@ -787,6 +799,9 @@ export class ActionListWidget extends Disposable { this._submenuContainer = document.createElement('div'); this._submenuContainer.className = 'action-list-submenu-panel action-widget'; this._submenuContainer.style.display = 'none'; + if (this._options?.persistentHover) { + this._submenuContainer.style.boxSizing = 'border-box'; + } // Make focusable so clicking the hover panel keeps focus inside the // tracked element instead of moving it to document.body (which would // trigger the blur handler and dismiss the widget). @@ -807,6 +822,10 @@ export class ActionListWidget extends Disposable { this._register(dom.addDisposableListener(this._submenuContainer, 'mouseenter', () => { this._cancelSubmenuHide(); + if (this._options?.persistentHover) { + this._cancelSubmenuShow(); + this._resetSubmenuPointer(); + } })); this._register(dom.addDisposableListener(this._submenuContainer, 'mouseleave', () => { this._scheduleSubmenuHide(); @@ -814,7 +833,15 @@ export class ActionListWidget extends Disposable { // A panel scheduled while crossing a row must not pop up after the pointer has left. this._register(dom.addDisposableListener(this.domNode, dom.EventType.MOUSE_LEAVE, () => { this._cancelSubmenuShow(); + this._resetSubmenuPointer(); })); + if (this._options?.persistentHover) { + this._register(dom.addDisposableListener(this.domNode, dom.EventType.MOUSE_MOVE, event => { + if (dom.isHTMLElement(event.target) && this._expandedTrigger?.contains(event.target)) { + this._updateSubmenuPointer(event); + } + })); + } this._register(toDisposable(() => { this._cancelSubmenuHide(); this._cancelSubmenuShow(); @@ -926,6 +953,9 @@ export class ActionListWidget extends Disposable { this._register(this._list.onMouseOver(e => this.onListHover(e))); this._register(this._list.onDidChangeFocus(() => this.onFocus())); this._register(this._list.onDidChangeSelection(e => this.onListSelection(e))); + if (this._options?.persistentHover) { + this._register(this._list.onDidScroll(() => this._layoutSubmenu?.())); + } this._allMenuItems = [...items]; @@ -1007,7 +1037,14 @@ export class ActionListWidget extends Disposable { text.textContent = this._options.headerText; // The banner is chrome, not an item: pointing at it dismisses a row's hover panel. - this._register(dom.addDisposableListener(this._headerContainer, dom.EventType.MOUSE_ENTER, () => this._hideSubmenu())); + this._register(dom.addDisposableListener(this._headerContainer, dom.EventType.MOUSE_ENTER, () => { + if (this._options?.persistentHover) { + this._cancelSubmenuShow(); + this._resetSubmenuPointer(); + } else { + this._hideSubmenu(); + } + })); if (this._options.headerLink) { const { label, uri } = this._options.headerLink; @@ -1091,13 +1128,29 @@ export class ActionListWidget extends Disposable { } private _toggleSection(section: string): void { - if (this._collapsedSections.has(section)) { + const expanding = this._collapsedSections.has(section); + if (expanding) { this._collapsedSections.delete(section); } else { this._collapsedSections.add(section); } this._options?.onDidToggleSection?.(section, this._collapsedSections.has(section)); this._applyFilter(); + if (expanding) { + // A popup that keeps one height cannot grow, so the new rows would sit unseen + // below the fold. + this._revealFirstItemOfSection(section); + } + } + + private _revealFirstItemOfSection(section: string): void { + for (let index = 0; index < this._list.length; index++) { + const item = this._list.element(index); + if (item.section === section && !item.isSectionToggle) { + this._list.reveal(index); + return; + } + } } private _applyOrUpdateFilter(): void { @@ -1350,6 +1403,24 @@ export class ActionListWidget extends Disposable { return undefined; } + /** Shows the checked item's hover, falling back to the focused item for persistent previews. */ + showHoverForCheckedItem(): void { + const element = this._allMenuItems.find(item => item.kind === ActionListItemKind.Action && (item.item as { checked?: boolean } | undefined)?.checked) + ?? (this._options?.persistentHover ? this.getFocusedElement() : undefined); + if (!element) { + return; + } + if (element.section && this._collapsedSections.has(element.section)) { + this._toggleSection(element.section); + } + const index = this._list.indexOf(element); + if (index >= 0) { + this._list.setFocus([index]); + this._list.reveal(index); + this._showHoverForElement(element, index); + } + } + /** * Replaces the items in the list in place, preserving the current filter, * without closing the widget. When {@link focusItemId} is provided, that item @@ -1359,6 +1430,9 @@ export class ActionListWidget extends Disposable { */ updateItems(items: readonly IActionListItem[], focusItemId?: string): void { this._allMenuItems = [...items]; + if (this._options?.persistentHover && this._currentSubmenuElement && !this._allMenuItems.includes(this._currentSubmenuElement)) { + this._hideSubmenu(); + } const previousVisibleCount = this._visibleMenuItems.length; // Re-layout only when the number of rows changed. Holding the widget still // otherwise keeps it from re-anchoring against a trigger that the same action @@ -1483,6 +1557,11 @@ export class ActionListWidget extends Disposable { * for items with a detail (second line). */ protected _getItemHeight(item: IActionListItem): number { + return this._itemHeightWith(item, this._options); + } + + /** Row heights depend on the options of the tab the items belong to, not the shown one. */ + private _itemHeightWith(item: IActionListItem, options: IActionListOptions | undefined): number { switch (item.kind) { case ActionListItemKind.Header: return this._headerLineHeight; @@ -1490,9 +1569,9 @@ export class ActionListWidget extends Disposable { return item.label ? this._actionLineHeight : this._separatorLineHeight; default: if (item.inlineToggle) { - return this._options?.inlineToggleItemHeight ?? 70; + return options?.inlineToggleItemHeight ?? 70; } - return item.detail ? (this._options?.detailItemHeight ?? 48) : this._actionLineHeight; + return item.detail ? (options?.detailItemHeight ?? 48) : this._actionLineHeight; } } @@ -1500,11 +1579,20 @@ export class ActionListWidget extends Disposable { * Computes the total height of all items (including collapsed/filtered items). */ computeFullHeight(): number { - let fullHeight = 0; - for (const item of this._allMenuItems) { - fullHeight += this._getItemHeight(item); + return this.computeHeightForItems(this._allMenuItems, undefined, this._options); + } + + /** Height the list would need for `items`, leaving out anything in a collapsed section. */ + computeHeightForItems(items: readonly IActionListItem[], collapsedSections: ReadonlySet | undefined, options: IActionListOptions | undefined): number { + let height = 0; + for (const item of items) { + // The toggle is the row that reopens its own section, so it stays on screen. + if (collapsedSections && item.section && !item.isSectionToggle && collapsedSections.has(item.section)) { + continue; + } + height += this._itemHeightWith(item, options); } - return fullHeight; + return height; } /** @@ -1534,6 +1622,7 @@ export class ActionListWidget extends Disposable { if (listParent && this._filterContainer?.parentElement === listParent) { listParent.insertBefore(this._filterContainer, this.domNode); } + this._layoutSubmenu?.(); } computeMaxWidth(minWidth: number): number { @@ -1542,8 +1631,6 @@ export class ActionListWidget extends Disposable { const rawMaxWidthCap = this._options?.maxWidth ?? Number.POSITIVE_INFINITY; const maxWidthCap = Math.max(rawMaxWidthCap, effectiveMinWidth); const clamp = (w: number) => Math.min(Math.max(w, effectiveMinWidth), maxWidthCap); - let maxWidth = effectiveMinWidth; - const totalItemCount = this._allMenuItems.length; if (totalItemCount >= 50) { return clamp(380); @@ -1556,22 +1643,32 @@ export class ActionListWidget extends Disposable { for (let i = 0; i < visibleCount; i++) { visibleItems.push(this._list.element(i)); } - - const allItems = [...this._allMenuItems]; - this._list.splice(0, visibleCount, allItems); - let allItemsHeight = 0; - for (const item of allItems) { - allItemsHeight += this._getItemHeight(item); + const height = this._list.renderHeight; + const scrollTop = this._list.scrollTop; + const focus = this._list.getFocus(); + try { + const allItems = [...this._allMenuItems]; + this._list.splice(0, visibleCount, allItems); + let allItemsHeight = 0; + for (const item of allItems) { + allItemsHeight += this._getItemHeight(item); + } + this._list.layout(allItemsHeight); + + const itemWidths = this._measureItemWidths(allItems); + return clamp(Math.max(...itemWidths)); + } finally { + this._list.splice(0, this._list.length, visibleItems); + this._list.layout(height); + this._list.scrollTop = scrollTop; + const suppressHover = this._suppressHover; + this._suppressHover = true; + try { + this._list.setFocus(focus); + } finally { + this._suppressHover = suppressHover; + } } - this._list.layout(allItemsHeight); - - const itemWidths = this._measureItemWidths(allItems); - - maxWidth = clamp(Math.max(...itemWidths)); - - // Restore visible items - this._list.splice(0, allItems.length, visibleItems); - return maxWidth; } // All items are visible, measure them directly @@ -1759,6 +1856,11 @@ export class ActionListWidget extends Disposable { // Show hover on focus change (suppress during programmatic initial focus) if (!this._suppressHover) { + if (this._options?.persistentHover) { + this._cancelSubmenuShow(); + this._resetSubmenuPointer(); + this._list.reveal(focusIndex); + } this._showHoverForElement(element, focusIndex); } } @@ -1836,9 +1938,9 @@ export class ActionListWidget extends Disposable { return; } - // Navigated to an item with no hover/submenu — fully tear down any - // previous submenu so a blank panel doesn't linger. - this._hideSubmenu(); + if (!this._options?.persistentHover) { + this._hideSubmenu(); + } } private _showSubmenuForItem(item: IActionListItem): void { @@ -1925,8 +2027,6 @@ export class ActionListWidget extends Disposable { this._submenuContainer.removeAttribute('role'); } - const anchorRect = anchor.getBoundingClientRect(); - const parentRect = this.domNode.getBoundingClientRect(); const targetWindow = dom.getWindow(this.domNode); let totalHeight = 0; @@ -2054,31 +2154,78 @@ export class ActionListWidget extends Disposable { })); } - // Position: prefer right side, fall back to left if not enough space - const viewportWidth = targetWindow.innerWidth; - const spaceRight = viewportWidth - anchorRect.right; - const spaceLeft = parentRect.left; - const panelWidth = maxWidth + 10; // account for border/padding - - const gap = 4; - if (spaceRight >= panelWidth || spaceRight >= spaceLeft) { - this._submenuContainer.style.left = `${parentRect.right - parentRect.left + gap}px`; - } else { - this._submenuContainer.style.left = `${-panelWidth - gap}px`; - } - const hoverHeaderHeight = hoverHeader ? hoverHeader.offsetHeight : 0; - const totalPanelHeight = totalHeight + hoverHeaderHeight; - const viewportHeight = targetWindow.innerHeight; - const anchorHeight = anchorRect.height; - let top = anchorRect.top - parentRect.top + (anchorHeight - totalPanelHeight) / 2; - const panelBottom = parentRect.top + top + totalPanelHeight; - if (panelBottom > viewportHeight) { - top -= (panelBottom - viewportHeight + 8); - } - if (parentRect.top + top < 0) { - top = -parentRect.top; + const layout = () => { + if (this._currentSubmenuElement !== element) { + return; + } + // Width measurement and virtualization can replace or recycle the original row. + const index = this._list.indexOf(element); + const row = index >= 0 ? this._getRowElement(index) : null; + const persistent = this._options?.persistentHover; + if (!row && (!persistent || !this._allMenuItems.includes(element))) { + this._hideSubmenu(); + return; + } + if (this._expandedTrigger !== row) { + if (this._expandedTrigger?.hasAttribute('aria-expanded')) { + this._expandedTrigger.setAttribute('aria-expanded', 'false'); + } + this._expandedTrigger = row ?? undefined; + } + row?.setAttribute('aria-expanded', 'true'); + const parentRect = this.domNode.getBoundingClientRect(); + const alignToParent = persistent || element.hover?.alignToParent; + const edgeRect = alignToParent + ? this.domNode.parentElement?.closest('.action-widget')?.getBoundingClientRect() ?? parentRect + : parentRect; + const anchorRect = row?.getBoundingClientRect() ?? edgeRect; + const zoom = alignToParent ? dom.getDomNodeZoomLevel(this.domNode) : 1; + if (persistent) { + this._submenuContainer.style.width = `${edgeRect.width / zoom}px`; + } + const panelRect = this._submenuContainer.getBoundingClientRect(); + const panelWidth = alignToParent ? panelRect.width : maxWidth + 10; + const spaceRight = targetWindow.innerWidth - (alignToParent ? edgeRect.right : anchorRect.right); + const spaceLeft = edgeRect.left; + const gap = alignToParent ? 0 : 4; + let showRight = spaceRight >= panelWidth || spaceRight >= spaceLeft; + if (persistent && this._submenuSide && (this._submenuSide === 'right' ? spaceRight : spaceLeft) >= panelWidth) { + showRight = this._submenuSide === 'right'; + } + if (persistent) { + this._submenuSide = showRight ? 'right' : 'left'; + } + const left = showRight + ? edgeRect.right - parentRect.left + gap + : edgeRect.left - parentRect.left - panelWidth - gap; + this._submenuContainer.style.left = `${left / zoom}px`; + + const panelHeight = alignToParent ? panelRect.height : totalHeight + (hoverHeader?.offsetHeight ?? 0); + let top = row + ? anchorRect.top - parentRect.top + (anchorRect.height - panelHeight) / 2 + : panelRect.top - parentRect.top; + const panelBottom = parentRect.top + top + panelHeight; + if (panelBottom > targetWindow.innerHeight) { + top -= panelBottom - targetWindow.innerHeight + 8; + } + if (parentRect.top + top < 0) { + top = -parentRect.top; + } + this._submenuContainer.style.top = `${top / zoom}px`; + }; + this._layoutSubmenu = layout; + layout(); + if ((this._options?.persistentHover || element.hover?.alignToParent) && this._currentSubmenuElement === element) { + const observer = this._submenuDisposables.add(new dom.DisposableResizeObserver('ActionListWidget.hoverPanel', layout, targetWindow)); + this._submenuDisposables.add(observer.observe(this._submenuContainer, { box: 'border-box' })); + if (this._options?.persistentHover) { + this._submenuDisposables.add(dom.addDisposableListener(targetWindow, dom.EventType.RESIZE, () => { + this._cancelSubmenuShow(); + this._resetSubmenuPointer(); + layout(); + })); + } } - this._submenuContainer.style.top = `${top}px`; } private _hideSubmenu(): void { @@ -2087,6 +2234,7 @@ export class ActionListWidget extends Disposable { this._submenuDisposables.clear(); this._currentSubmenuWidget = undefined; this._currentSubmenuElement = undefined; + this._submenuSide = undefined; this._clearSubmenuContainer(); this._submenuContainer.style.display = 'none'; } @@ -2098,6 +2246,8 @@ export class ActionListWidget extends Disposable { * which blurs the action widget and dismisses it. */ private _clearSubmenuContainer(): void { + this._layoutSubmenu = undefined; + this._resetSubmenuPointer(); if (this._submenuContainer.contains(dom.getActiveElement())) { this._list.domFocus(); } @@ -2118,6 +2268,9 @@ export class ActionListWidget extends Disposable { private _scheduleSubmenuHide(): void { this._cancelSubmenuHide(); + if (this._options?.persistentHover) { + return; + } this._submenuHideTimeout = setTimeout(() => { this._hideSubmenu(); }, 300); @@ -2130,15 +2283,45 @@ export class ActionListWidget extends Disposable { } } - private _scheduleSubmenuShow(element: IActionListItem, index: number | undefined): void { + private _scheduleSubmenuShow(element: IActionListItem, pointer: MouseEvent): void { this._cancelSubmenuShow(); - this._submenuShowTimeout = setTimeout(() => { + let delay = 500; + if (this._options?.persistentHover) { + delay = 0; + const origin = this._submenuPointerOrigin; + if (origin && pointer.clientX !== origin.x && this._currentSubmenuElement && new dom.SafeTriangle(origin.x, origin.y, this._submenuContainer).contains(pointer.clientX, pointer.clientY)) { + // Give a diagonal path into the current card a bounded grace period. + const now = Date.now(); + this._submenuPointerGraceDeadline ??= now + 200; + delay = Math.max(0, this._submenuPointerGraceDeadline - now); + } + } + const show = () => { this._submenuShowTimeout = undefined; - const rowElement = typeof index === 'number' ? this._getRowElement(index) : null; + const index = this._list.indexOf(element); + const rowElement = index >= 0 ? this._getRowElement(index) : null; if (rowElement) { this._showSubmenuForElement(element, rowElement); + if (this._options?.persistentHover) { + this._updateSubmenuPointer(pointer); + } } - }, 500); + }; + if (delay === 0) { + show(); + } else { + this._submenuShowTimeout = setTimeout(show, delay); + } + } + + private _updateSubmenuPointer(event: MouseEvent): void { + this._submenuPointerOrigin = { x: event.clientX, y: event.clientY }; + this._submenuPointerGraceDeadline = undefined; + } + + private _resetSubmenuPointer(): void { + this._submenuPointerOrigin = undefined; + this._submenuPointerGraceDeadline = undefined; } private _cancelSubmenuShow(): void { @@ -2166,22 +2349,28 @@ export class ActionListWidget extends Disposable { // Set focus immediately for responsive hover feedback const hasPanel = !!(element.submenuActions?.length || element.hover?.content); - if (hasPanel) { + const suppressHover = this._suppressHover; + if (hasPanel || this._options?.persistentHover) { this._suppressHover = true; } - this._list.setFocus(typeof e.index === 'number' ? [e.index] : []); - if (hasPanel) { - this._suppressHover = false; + try { + this._list.setFocus(typeof e.index === 'number' ? [e.index] : []); + } finally { + this._suppressHover = suppressHover; } - // Show hover/submenu panel on row hover with a delay if (hasPanel) { if (this._currentSubmenuElement === element) { this._cancelSubmenuHide(); this._cancelSubmenuShow(); + if (this._options?.persistentHover) { + this._updateSubmenuPointer(e.browserEvent); + } } else { - this._hideSubmenu(); - this._scheduleSubmenuShow(element, e.index); + if (!this._options?.persistentHover) { + this._hideSubmenu(); + } + this._scheduleSubmenuShow(element, e.browserEvent); } return; } @@ -2190,7 +2379,9 @@ export class ActionListWidget extends Disposable { this._cancelSubmenuHide(); } else { this._cancelSubmenuShow(); - this._hideSubmenu(); + if (!this._options?.persistentHover) { + this._hideSubmenu(); + } } if (this._delegate.onHover && !element.disabled && element.kind === ActionListItemKind.Action && this._currentSubmenuElement !== element) { @@ -2205,13 +2396,14 @@ export class ActionListWidget extends Disposable { } } } else if (element && element.hover?.content && typeof e.index === 'number') { - // Show hover for disabled items that have hover content (with delay) if (this._currentSubmenuElement === element) { this._cancelSubmenuHide(); this._cancelSubmenuShow(); } else { - this._hideSubmenu(); - this._scheduleSubmenuShow(element, e.index); + if (!this._options?.persistentHover) { + this._hideSubmenu(); + } + this._scheduleSubmenuShow(element, e.browserEvent); } } } @@ -2236,6 +2428,8 @@ export class ActionList extends Disposable { private _cachedMaxWidth: number | undefined; private _hasLaidOut = false; private _showAbove: boolean | undefined; + /** Height to size against instead of the current contents. Survives re-layouts. */ + private _fixedContentHeight: number | undefined; private readonly _preferredAnchorPosition: AnchorPosition | undefined; private readonly _widgetClassName: string | undefined; @@ -2320,6 +2514,10 @@ export class ActionList extends Disposable { this._widget.focus(); } + showHoverForCheckedItem(): void { + this._widget.showHoverForCheckedItem(); + } + hide(didCancel?: boolean, hideContextView = true): void { this._widget.hide(didCancel); if (hideContextView) { @@ -2359,6 +2557,11 @@ export class ActionList extends Disposable { this._widget.updateItems(items, focusItemId); } + /** Height the list would need for `items`, for a caller sizing against other contents. */ + computeHeightForItems(items: readonly IActionListItem[], collapsedSections: ReadonlySet | undefined, options: IActionListOptions | undefined): number { + return this._widget.computeHeightForItems(items, collapsedSections, options); + } + focusItemById(itemId: string): void { this._widget.focusItemById(itemId); } @@ -2379,7 +2582,7 @@ export class ActionList extends Disposable { } private computeHeight(): number { - const listHeight = this._widget.computeListHeight(); + const listHeight = this._fixedContentHeight ?? this._widget.computeListHeight(); const filterHeight = this._widget.filterContainer ? 36 : 0; const footerHeight = this._widget.footerContainer ? 32 : 0; @@ -2400,9 +2603,11 @@ export class ActionList extends Disposable { // unconstrained list fits below. Once decided, the dropdown stays // in the same position even when the visible item count changes. if (this._showAbove === undefined) { + // A pinned height decides the direction too, so a later tab cannot flip it. + const fullHeight = this._fixedContentHeight ?? this._widget.computeFullHeight(); this._showAbove = this._preferredAnchorPosition !== undefined ? this._preferredAnchorPosition === AnchorPosition.ABOVE - : (chromeHeight + this._widget.computeFullHeight() > spaceBelow && spaceAbove > spaceBelow); + : (chromeHeight + fullHeight > spaceBelow && spaceAbove > spaceBelow); } availableHeight = Math.max(0, (this._showAbove ? spaceAbove : spaceBelow) - this.computeActionWidgetVerticalChromeHeight()); } else { @@ -2424,9 +2629,12 @@ export class ActionList extends Disposable { return height - chromeHeight; } - layout(minWidth: number): number { + layout(minWidth: number, fixedContentHeight?: number): number { this._hasLaidOut = true; this._lastMinWidth = minWidth; + if (fixedContentHeight !== undefined) { + this._fixedContentHeight = fixedContentHeight; + } const listHeight = this.computeHeight(); this._widget.layout(listHeight); diff --git a/src/vs/platform/actionWidget/browser/tabbedActionListWidget.css b/src/vs/platform/actionWidget/browser/tabbedActionListWidget.css index 0e9454a42f8e14..aea65fab977ee9 100644 --- a/src/vs/platform/actionWidget/browser/tabbedActionListWidget.css +++ b/src/vs/platform/actionWidget/browser/tabbedActionListWidget.css @@ -27,6 +27,18 @@ color: var(--vscode-descriptionForeground); } +/* A closing tab holds its old label while it shrinks, so it has to clip it. */ +.action-widget .tabbed-action-list-tabbar .monaco-custom-radio > .monaco-button.label-collapsing { + overflow: hidden; +} + +.action-widget .tabbed-action-list-tabbar .monaco-custom-radio > .monaco-button.label-collapsing > span:not(.codicon) { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .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 735bce4d37466f..30758bb54adaca 100644 --- a/src/vs/platform/actionWidget/browser/tabbedActionListWidget.ts +++ b/src/vs/platform/actionWidget/browser/tabbedActionListWidget.ts @@ -10,11 +10,34 @@ import { KeyCode } from '../../../base/common/keyCodes.js'; import { Emitter } from '../../../base/common/event.js'; import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../base/common/lifecycle.js'; import { ThemeIcon } from '../../../base/common/themables.js'; +import { IAccessibilityService } from '../../accessibility/common/accessibility.js'; import { IContextViewService } from '../../contextview/browser/contextView.js'; import { IInstantiationService } from '../../instantiation/common/instantiation.js'; import { ActionList, IActionListDelegate, IActionListItem, IActionListOptions } from './actionList.js'; import './tabbedActionListWidget.css'; +/** Timing for the tab resize animation. Both tabs share it, or the strip bulges mid-way. */ +const TAB_RESIZE_ANIMATION: KeyframeAnimationOptions = { duration: 300, easing: 'cubic-bezier(0.4, 0, 0.2, 1)' }; + +/** The box a tab occupied, including the spacing that travels with its width. */ +interface ITabBox { + readonly width: number; + readonly paddingLeft: string; + readonly paddingRight: string; + readonly columnGap: string; +} + +function readTabBox(element: HTMLElement): ITabBox { + const style = dom.getComputedStyle(element); + return { + width: element.getBoundingClientRect().width, + paddingLeft: style.paddingLeft, + paddingRight: style.paddingRight, + // `normal` is the initial value and cannot be interpolated. + columnGap: style.columnGap === 'normal' ? '0px' : style.columnGap, + }; +} + /** * Result of {@link ITabbedActionListShowOptions.createActionList}. The list * options are recomputed on every tab switch so callers can vary filter @@ -89,6 +112,10 @@ export interface ITabbedActionListShowOptions { * while the popup stays open is not replayed stale on a tab switch. */ readonly widgetClassNames?: (activeTab: string) => readonly string[]; + /** Tab whose contents fix the popup's height, so switching tabs scrolls instead of resizing. */ + readonly sizingTab?: string; + /** Show the checked item's hover after the popup has been positioned. */ + readonly showCheckedItemHover?: boolean; /** Optional icon buttons rendered after the tabs. */ readonly tabBarActions?: readonly ITabBarAction[]; /** @@ -124,6 +151,13 @@ export class TabbedActionListWidget extends Disposable { private readonly _activePopup = this._register(new MutableDisposable()); private _swappingTab = false; private _refreshActiveList: (() => void) | undefined; + /** Boxes and labels from the last render, so the next one can animate from them. */ + private _previousTabBoxes: Map | undefined; + private _previousTabTexts: ReadonlyMap | undefined; + /** Initial list height from {@link ITabbedActionListShowOptions.sizingTab}. */ + private _fixedListHeight: number | undefined; + private _fixedPopupHeight: number | undefined; + private _hasMeasuredSizingTab = false; get isVisible(): boolean { return !!this._activePopup.value; @@ -132,6 +166,7 @@ export class TabbedActionListWidget extends Disposable { constructor( @IContextViewService private readonly _contextViewService: IContextViewService, @IInstantiationService private readonly _instantiationService: IInstantiationService, + @IAccessibilityService private readonly _accessibilityService: IAccessibilityService, ) { super(); } @@ -145,6 +180,13 @@ export class TabbedActionListWidget extends Disposable { if (isSwap) { this._swappingTab = true; this._activePopup.value = undefined; + } else { + // A fresh popup has nothing to animate from, and its models may have changed. + this._previousTabBoxes = undefined; + this._previousTabTexts = undefined; + this._fixedListHeight = undefined; + this._fixedPopupHeight = undefined; + this._hasMeasuredSizingTab = false; } let activeTab = options.initialTab; @@ -172,6 +214,9 @@ export class TabbedActionListWidget extends Disposable { const renderDisposables = new DisposableStore(); const widget = dom.append(container, dom.$('.action-widget')); + if (options.width !== undefined) { + widget.style.width = `${options.width}px`; + } let widgetClassNames: readonly string[] = []; const applyWidgetClassNames = () => { const next = options.widgetClassNames?.(activeTab) ?? []; @@ -214,14 +259,20 @@ export class TabbedActionListWidget extends Disposable { this.show({ ...options, initialTab: next }); }; + // Kept aside so a tab that loses its label on the next swap can hold on to it + // while it shrinks. + const tabTexts = new Map(options.tabs.map(tab => { + const label = tab.label ?? tab.id; + const iconPrefix = tab.icon ? `$(${tab.icon.id})` : ''; + const labelMode = options.tabLabels ?? 'always'; + const showsLabel = !iconPrefix || labelMode === 'always' || (labelMode === 'active' && tab.id === activeTab); + return [tab.id, showsLabel ? (iconPrefix ? `${iconPrefix} ${label}` : label) : iconPrefix] as const; + })); + const radio = renderDisposables.add(new Radio({ items: options.tabs.map(tab => { const label = tab.label ?? tab.id; - const iconPrefix = tab.icon ? `$(${tab.icon.id})` : ''; - const labelMode = options.tabLabels ?? 'always'; - const showsLabel = !iconPrefix || labelMode === 'always' || (labelMode === 'active' && tab.id === activeTab); - const text = showsLabel ? (iconPrefix ? `${iconPrefix} ${label}` : label) : iconPrefix; - return { text, tooltip: tab.tooltip ?? label, ariaLabel: label, isActive: tab.id === activeTab }; + return { text: tabTexts.get(tab.id)!, tooltip: tab.tooltip ?? label, ariaLabel: label, isActive: tab.id === activeTab }; }), })); tabStrip.appendChild(radio.domNode); @@ -252,6 +303,15 @@ export class TabbedActionListWidget extends Disposable { })); } + // Built before the active tab's list because a consumer may hold per-tab state + // while building, and the active tab has to be the one that keeps it. + const needsSizing = !this._hasMeasuredSizingTab + && options.sizingTab !== undefined + && options.tabs.some(tab => tab.id === options.sizingTab); + const sizingBuild = needsSizing && options.sizingTab !== activeTab + ? options.createActionList(options.sizingTab!) + : undefined; + const { items, listOptions } = options.createActionList(activeTab); const emptyBody = items.length === 0 ? this._renderEmptyBody(widget, options, activeTab, renderDisposables) : undefined; const list = renderDisposables.add(this._instantiationService.createInstance( @@ -268,8 +328,16 @@ export class TabbedActionListWidget extends Disposable { // Rebuilding has to ask the consumer again, since what the popup shows can // depend on state that changed while it stayed open. this._refreshActiveList = () => { + const hadFocus = dom.isAncestorOfActiveElement(widget); applyWidgetClassNames(); list.updateItems(options.createActionList(activeTab).items); + if (hadFocus && !dom.isAncestorOfActiveElement(widget)) { + if (emptyBody) { + radio.focusActiveItem(); + } else { + list.focus(); + } + } }; renderDisposables.add(toDisposable(() => { this._refreshActiveList = undefined; @@ -290,13 +358,49 @@ export class TabbedActionListWidget extends Disposable { } } + let footer: HTMLElement | undefined; if (options.renderFooter) { - const footer = dom.append(widget, dom.$('.tabbed-action-list-footer')); + footer = dom.append(widget, dom.$('.tabbed-action-list-footer')); renderDisposables.add(options.renderFooter(footer, activeTab)); } - const width = list.layout(0); - widget.style.width = `${options.width ?? width}px`; + // Measured from the sizing tab's own items, so every tab lands on the same + // height however the popup opened. + if (needsSizing) { + const sizing = sizingBuild ?? { items, listOptions }; + this._fixedListHeight = list.computeHeightForItems(sizing.items, sizing.listOptions?.collapsedByDefault, sizing.listOptions) || undefined; + this._hasMeasuredSizingTab = true; + } + + const layout = () => { + const body = emptyBody ?? list.domNode; + const chromeHeight = widget.offsetHeight - body.offsetHeight; + const contentHeight = this._fixedPopupHeight === undefined + ? this._fixedListHeight + : Math.max(0, this._fixedPopupHeight - chromeHeight); + const width = list.layout(0, contentHeight); + widget.style.width = `${options.width ?? width}px`; + if (emptyBody && this._fixedListHeight !== undefined) { + emptyBody.style.minHeight = list.domNode.style.height; + } + if (this._fixedListHeight !== undefined && this._fixedPopupHeight === undefined) { + this._fixedPopupHeight = widget.offsetHeight; + } + }; + layout(); + + if (footer) { + const observer = renderDisposables.add(new dom.DisposableResizeObserver('TabbedActionListWidget.footer', () => { + layout(); + this._contextViewService.layout(); + }, dom.getWindow(footer))); + renderDisposables.add(observer.observe(footer, { box: 'border-box' })); + } + // Boxes are read before the animation starts, so they are the resting ones. + const tabBoxes = this._measureTabBoxes(radio, options.tabs); + renderDisposables.add(this._animateTabResize(radio, options.tabs, tabBoxes, tabTexts)); + this._previousTabBoxes = tabBoxes; + this._previousTabTexts = tabTexts; if (emptyBody) { // The list is not in the DOM at all, so focusing it would drop focus // out of the popup. The active tab is the nearest thing to act on, and @@ -393,11 +497,76 @@ export class TabbedActionListWidget extends Disposable { get anchorPosition() { return listRef?.anchorPosition; }, }, undefined, false); + if (options.showCheckedItemHover) { + listRef?.showHoverForCheckedItem(); + } + if (isSwap) { this._swappingTab = false; } } + /** Boxes of the currently rendered tabs, keyed by tab id. */ + private _measureTabBoxes(radio: Radio, tabs: readonly ITabDescriptor[]): Map { + const boxes = new Map(); + const elements = radio.optionElements; + for (let index = 0; index < tabs.length; index++) { + const element = elements[index]; + if (element) { + boxes.set(tabs[index].id, readTabBox(element)); + } + } + return boxes; + } + + /** Grows and shrinks the tabs from the boxes the previous render left them in. */ + private _animateTabResize(radio: Radio, tabs: readonly ITabDescriptor[], boxes: ReadonlyMap, texts: ReadonlyMap): IDisposable { + const store = new DisposableStore(); + const previousBoxes = this._previousTabBoxes; + const previousTexts = this._previousTabTexts; + if (!previousBoxes || this._accessibilityService.isMotionReduced()) { + return store; + } + const elements = radio.optionElements; + for (let index = 0; index < tabs.length; index++) { + const element = elements[index]; + const from = previousBoxes.get(tabs[index].id); + const to = boxes.get(tabs[index].id); + // A tab that is not laid out yet measures 0, which would animate it away. + if (!element || !from || !to || to.width <= 0 || Math.abs(from.width - to.width) < 1) { + continue; + } + const animation = element.animate([ + { width: `${from.width}px`, paddingLeft: from.paddingLeft, paddingRight: from.paddingRight, columnGap: from.columnGap }, + { width: `${to.width}px`, paddingLeft: to.paddingLeft, paddingRight: to.paddingRight, columnGap: to.columnGap }, + ], TAB_RESIZE_ANIMATION); + store.add(toDisposable(() => animation.cancel())); + + // Without its old label a closing tab drops the text at once and slides a lone + // icon across the space it filled. + const previousText = previousTexts?.get(tabs[index].id); + const currentText = texts.get(tabs[index].id); + if (to.width < from.width && previousText !== undefined && previousText !== currentText) { + store.add(this._holdLabelWhileShrinking(radio, index, element, animation, previousText)); + } + } + return store; + } + + /** Keeps a closing tab's label on screen, collapsed to nothing by the time it settles. */ + private _holdLabelWhileShrinking(radio: Radio, index: number, element: HTMLElement, animation: Animation, previousText: string): IDisposable { + const store = new DisposableStore(); + store.add(radio.overrideOptionLabel(index, previousText)); + element.classList.add('label-collapsing'); + store.add(toDisposable(() => element.classList.remove('label-collapsing'))); + // The label has to reach zero width, or it still displaces the resting icon. + const labelSpan = [...element.children].find((child): child is HTMLElement => + dom.isHTMLElement(child) && !child.classList.contains('codicon')); + labelSpan?.animate([{ maxWidth: `${labelSpan.scrollWidth}px` }, { maxWidth: '0px' }], TAB_RESIZE_ANIMATION); + animation.finished.then(() => store.dispose(), () => { }); + return store; + } + hide(): void { this._activePopup.value = undefined; } diff --git a/src/vs/platform/actionWidget/test/browser/actionList.test.ts b/src/vs/platform/actionWidget/test/browser/actionList.test.ts index 83e8dc61fb5996..d4cb851c7340a6 100644 --- a/src/vs/platform/actionWidget/test/browser/actionList.test.ts +++ b/src/vs/platform/actionWidget/test/browser/actionList.test.ts @@ -26,6 +26,7 @@ import { AnchorPosition } from '../../../../base/common/layout.js'; interface ITestActionItem { readonly id: string; + readonly checked?: boolean; } function action(id: string): IActionListItem { @@ -40,6 +41,7 @@ function createActionListWidget(disposables: ReturnType[]; readonly onFilter?: (filter: string, cancellationToken: CancellationToken) => Promise[]>; readonly onHide?: () => void; + readonly onSelect?: (item: ITestActionItem) => void; readonly listOptions?: Partial; }): ActionListWidget { const instantiationService = disposables.add(new TestInstantiationService()); @@ -49,12 +51,12 @@ function createActionListWidget(disposables: ReturnType { }), - onSelect: () => { }, + onSelect: options.onSelect ?? (() => { }), onFilter: options.onFilter, } : { onHide: options.onHide ?? (() => { }), - onSelect: () => { }, + onSelect: options.onSelect ?? (() => { }), }; const widget = disposables.add(instantiationService.createInstance( @@ -164,6 +166,49 @@ function createActionList(disposables: ReturnType { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + function createPersistentPreview(side: 'left' | 'right' = 'right') { + const selected: string[] = []; + const contents = ['first', 'second', 'third'].map(label => { + const content = document.createElement('div'); + content.textContent = `Details for ${label}`; + content.style.cssText = 'width: 180px; height: 180px;'; + return content; + }); + const widget = createActionListWidget(disposables, { + items: [ + ...contents.map((content, index) => ({ + ...action(['first', 'second', 'third'][index]), + hover: { content }, + })), + action('plain'), + ], + onSelect: item => selected.push(item.id), + listOptions: { showFilter: false, persistentHover: true, headerText: 'Cache hint' }, + }); + const popup = document.createElement('div'); + popup.className = 'action-widget'; + popup.style.cssText = `position: fixed; top: 120px; left: ${side === 'left' ? mainWindow.innerWidth - 320 : 40}px; width: 260px; padding: 8px;`; + document.body.appendChild(popup); + disposables.add({ dispose: () => popup.remove() }); + popup.appendChild(widget.domNode); + widget.layout(120, 240); + const rows = () => widget.domNode.querySelectorAll('.monaco-list-row'); + const hover = (index: number, clientX?: number) => { + const row = rows()[index]; + const bounds = row.getBoundingClientRect(); + const point = { x: clientX ?? bounds.left + bounds.width / 2, y: bounds.top + bounds.height / 2 }; + row.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, clientX: point.x, clientY: point.y })); + return point; + }; + const panel = widget.domNode.querySelector('.action-list-submenu-panel')!; + const isCenteredOnRow = (index: number) => { + const rowBounds = rows()[index].getBoundingClientRect(); + const panelBounds = panel.getBoundingClientRect(); + return Math.abs(panelBounds.top + panelBounds.height / 2 - rowBounds.top - rowBounds.height / 2) < 1; + }; + return { widget, popup, panel, contents, selected, rows, hover, isCenteredOnRow }; + } + test('renders and activates a standalone toggle row', () => { let checked = false; const widget = createActionListWidget(disposables, { @@ -618,6 +663,39 @@ suite('ActionListWidget', () => { ); }); + test('hidden hover chevrons preserve keyboard access without reserving a gutter', () => { + const widget = createActionListWidget(disposables, { + items: [{ ...action('model'), hover: { content: 'Model details', expandable: true, showIndicator: false } }], + listOptions: { showFilter: false, reserveSubmenuSpace: false }, + }); + const row = widget.domNode.querySelector('.monaco-list-row.action')!; + const indicator = row.querySelector('.action-list-submenu-indicator')!; + const initial = { + indicator: indicator.style.display, + haspopup: row.getAttribute('aria-haspopup'), + expanded: row.getAttribute('aria-expanded'), + }; + widget.focus(); + widget.domNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })); + const panel = widget.domNode.querySelector('.action-list-submenu-panel')!; + const opened = { + role: panel.getAttribute('role'), + focused: document.activeElement === panel, + expanded: row.getAttribute('aria-expanded'), + }; + panel.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true })); + + assert.deepStrictEqual({ + initial, + opened, + closed: { expanded: row.getAttribute('aria-expanded'), focusReturned: widget.domNode.contains(document.activeElement) && !panel.contains(document.activeElement) }, + }, { + initial: { indicator: 'none', haspopup: 'dialog', expanded: 'false' }, + opened: { role: 'dialog', focused: true, expanded: 'true' }, + closed: { expanded: 'false', focusReturned: true }, + }); + }); + test('rebuilding the items in place re-measures only when the row count changed', () => { const widget = createActionListWidget(disposables, { items: [action('one'), action('two')] }); const layouts: string[] = []; @@ -661,6 +739,170 @@ suite('ActionListWidget', () => { assert.deepStrictEqual({ display: panel.style.display, text: panel.textContent }, { display: '', text: 'Auto routes based on your task' }); })); + test('persistent previews follow each row immediately without selecting or taking pointer focus', () => { + const { widget, panel, selected, hover, isCenteredOnRow } = createPersistentPreview(); + const outside = document.createElement('button'); + document.body.appendChild(outside); + disposables.add({ dispose: () => outside.remove() }); + outside.focus(); + + hover(0); + const first = panel.textContent; + const firstCentered = isCenteredOnRow(0); + hover(1); + const second = panel.textContent; + const secondCentered = isCenteredOnRow(1); + const pointerFocusPreserved = document.activeElement === outside; + widget.focus(); + widget.focusNext(); + + assert.deepStrictEqual({ + first, + second, + keyboard: panel.textContent, + pointerFocusPreserved, + selected, + centeredOnRows: [firstCentered, secondCentered, isCenteredOnRow(2)], + }, { + first: 'Details for first', + second: 'Details for second', + keyboard: 'Details for third', + pointerFocusPreserved: true, + selected: [], + centeredOnRows: [true, true, true], + }); + }); + + test('persistent previews start with the focused item when the tab has no checked item', () => { + const { widget, panel, selected } = createPersistentPreview(); + widget.focus(); + widget.showHoverForCheckedItem(); + + assert.deepStrictEqual({ + content: panel.textContent, + selected, + focused: widget.getFocusedElement()?.item?.id, + }, { content: 'Details for first', selected: [], focused: 'first' }); + }); + + test('persistent previews remain visible over chrome, plain rows, and pointer gaps', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { widget, panel, contents, hover } = createPersistentPreview(); + hover(0); + widget.domNode.dispatchEvent(new MouseEvent('mouseleave')); + panel.dispatchEvent(new MouseEvent('mouseenter')); + panel.dispatchEvent(new MouseEvent('mouseleave')); + widget.headerContainer!.dispatchEvent(new MouseEvent('mouseenter')); + hover(3); + await timeout(1000); + + assert.deepStrictEqual({ + display: panel.style.display, + content: panel.textContent, + sameContent: panel.firstElementChild === contents[0], + }, { display: '', content: 'Details for first', sameContent: true }); + })); + + test('persistent previews close when their source items are replaced', () => { + const { widget, panel, hover } = createPersistentPreview(); + hover(0); + widget.updateItems([action('replacement')]); + assert.deepStrictEqual({ display: panel.style.display, content: panel.textContent }, { display: 'none', content: '' }); + }); + + test('persistent previews retain their last row position during virtualization', () => { + const { widget, panel, contents, hover } = createPersistentPreview(); + hover(2); + const top = panel.getBoundingClientRect().top; + widget.layout(24, 240); + + assert.deepStrictEqual({ + display: panel.style.display, + sameContent: panel.firstElementChild === contents[2], + positionRetained: Math.abs(panel.getBoundingClientRect().top - top) < 1, + }, { display: '', sameContent: true, positionRetained: true }); + }); + + test('persistent previews keep their side and width across differently sized content', () => { + const { popup, panel, contents, hover, isCenteredOnRow } = createPersistentPreview(); + popup.style.left = `${mainWindow.innerWidth - 480}px`; + contents[0].style.width = '260px'; + contents[1].style.width = '120px'; + hover(0); + const first = panel.getBoundingClientRect(); + const firstCentered = isCenteredOnRow(0); + hover(1); + const second = panel.getBoundingClientRect(); + const parent = popup.getBoundingClientRect(); + + assert.deepStrictEqual({ + firstOnLeft: Math.abs(first.right - parent.left) < 1, + secondOnLeft: Math.abs(second.right - parent.left) < 1, + centeredOnRows: [firstCentered, isCenteredOnRow(1)], + widthUnchanged: first.width === second.width, + }, { firstOnLeft: true, secondOnLeft: true, centeredOnRows: [true, true], widthUnchanged: true }); + }); + + for (const side of ['left', 'right'] as const) { + test(`pointer travel into the ${side} preview does not switch to a crossed row`, () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { panel, hover } = createPersistentPreview(side); + const origin = hover(0); + hover(1, origin.x + (side === 'right' ? 60 : -60)); + const duringTravel = panel.textContent; + await timeout(100); + panel.dispatchEvent(new MouseEvent('mouseenter')); + await timeout(300); + const afterEntering = panel.textContent; + panel.dispatchEvent(new MouseEvent('mouseleave')); + hover(1); + + assert.deepStrictEqual({ duringTravel, afterEntering, afterReturning: panel.textContent }, { + duringTravel: 'Details for first', + afterEntering: 'Details for first', + afterReturning: 'Details for second', + }); + })); + } + + test('pointer travel grace is bounded across multiple crossed rows', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { panel, hover } = createPersistentPreview(); + const origin = hover(0); + hover(1, origin.x + 60); + await timeout(150); + hover(2, origin.x + 80); + const beforeExpiry = panel.textContent; + await timeout(60); + + assert.deepStrictEqual({ beforeExpiry, afterExpiry: panel.textContent }, { + beforeExpiry: 'Details for first', + afterExpiry: 'Details for third', + }); + })); + + test('keyboard navigation supersedes a pending pointer preview', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { widget, panel, hover } = createPersistentPreview(); + const origin = hover(0); + hover(1, origin.x + 60); + widget.focus(); + widget.focusNext(); + const immediate = panel.textContent; + await timeout(300); + + assert.deepStrictEqual({ immediate, afterDelay: panel.textContent }, { + immediate: 'Details for third', + afterDelay: 'Details for third', + }); + })); + + test('disposing a persistent preview cancels pending pointer travel', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { widget, panel, hover } = createPersistentPreview(); + const origin = hover(0); + hover(1, origin.x + 60); + widget.dispose(); + await timeout(300); + + assert.strictEqual(panel.textContent, 'Details for first'); + })); + test('does not open a row hover panel once the pointer has left the list', () => runWithFakedTimers({ useFakeTimers: true }, async () => { const widget = createActionListWidget(disposables, { items: [{ ...action('auto'), hover: { content: 'Auto routes based on your task' } }, action('other')], @@ -721,6 +963,113 @@ suite('ActionListWidget', () => { assert.strictEqual(widget.getFocusedElement()?.item?.id, 'active'); }); + test('opening the checked hover reveals a model in a collapsed section', () => { + const widget = createActionListWidget(disposables, { + items: [ + action('pinned'), + { + ...action('active'), + item: { id: 'active', checked: true }, + section: 'other', + hover: { content: 'Active model details' }, + }, + ], + listOptions: { showFilter: false, collapsedByDefault: new Set(['other']) }, + }); + widget.focus(); + widget.showHoverForCheckedItem(); + + assert.deepStrictEqual({ + focused: widget.getFocusedElement()?.item?.id, + details: widget.domNode.querySelector('.action-list-submenu-panel')?.textContent, + }, { focused: 'active', details: 'Active model details' }); + }); + + for (const activeIndex of [2, 8]) { + test(`width measurement keeps the hover anchored to visible item ${activeIndex}`, async () => { + const content = document.createElement('div'); + content.textContent = 'Model details'; + content.style.cssText = 'width: 120px; height: 80px;'; + const widget = createActionListWidget(disposables, { + items: [ + ...Array.from({ length: 10 }, (_, index) => index === activeIndex + ? { ...action('active'), item: { id: 'active', checked: true }, hover: { content, alignToParent: true } } + : action(`visible-${index}`)), + ...Array.from({ length: 20 }, (_, index) => ({ ...action(`hidden-${index}`), section: 'other' })), + ], + listOptions: { showFilter: false, collapsedByDefault: new Set(['other']) }, + }); + const popup = document.createElement('div'); + popup.className = 'action-widget'; + popup.style.cssText = 'position: fixed; top: 160px; left: 40px; width: 260px; padding: 8px;'; + document.body.appendChild(popup); + disposables.add({ dispose: () => popup.remove() }); + popup.appendChild(widget.domNode); + widget.layout(120, 240); + widget.focus(); + widget.showHoverForCheckedItem(); + + const settleLayout = () => new Promise(resolve => { + mainWindow.requestAnimationFrame(() => mainWindow.requestAnimationFrame(() => resolve())); + }); + const measure = () => { + const row = Array.from(widget.domNode.querySelectorAll('.monaco-list-row')).find(row => row.textContent === 'active'); + const rowBounds = row?.getBoundingClientRect(); + const listBounds = widget.domNode.getBoundingClientRect(); + const panelBounds = widget.domNode.querySelector('.action-list-submenu-panel')!.getBoundingClientRect(); + return { + rowVisible: !!rowBounds && rowBounds.top >= listBounds.top && rowBounds.bottom <= listBounds.bottom, + centeredOnRow: !!rowBounds && Math.abs(rowBounds.top + rowBounds.height / 2 - panelBounds.top - panelBounds.height / 2) < 1, + expanded: row?.getAttribute('aria-expanded'), + focused: widget.getFocusedElement()?.item?.id, + }; + }; + await settleLayout(); + const before = measure(); + widget.computeMaxWidth(240); + widget.layout(120, 240); + content.style.height = '100px'; + await settleLayout(); + + const expected = { rowVisible: true, centeredOnRow: true, expanded: 'true', focused: 'active' }; + assert.deepStrictEqual({ before, after: measure() }, { before: expected, after: expected }); + }); + } + + for (const side of ['left', 'right']) { + for (const zoom of [1, 1.25]) { + test(`hover aligns with the outer ${side} border at ${zoom} zoom`, () => { + const content = document.createElement('div'); + content.textContent = 'Model details'; + content.style.cssText = 'width: 120px; height: 80px;'; + const widget = createActionListWidget(disposables, { + items: [{ ...action('active'), item: { id: 'active', checked: true }, hover: { content, alignToParent: true } }], + listOptions: { showFilter: false }, + }); + const popup = document.createElement('div'); + popup.className = 'action-widget'; + popup.style.cssText = `position: fixed; top: 120px; left: ${side === 'left' ? (mainWindow.innerWidth - 320) / zoom : 0}px; width: 240px; padding: 8px; zoom: ${zoom};`; + document.body.appendChild(popup); + disposables.add({ dispose: () => popup.remove() }); + popup.appendChild(widget.domNode); + widget.focus(); + const focusedElement = document.activeElement; + + widget.showHoverForCheckedItem(); + + const panel = widget.domNode.querySelector('.action-list-submenu-panel')!; + const parentBounds = popup.getBoundingClientRect(); + const panelBounds = panel.getBoundingClientRect(); + const gap = side === 'left' ? parentBounds.left - panelBounds.right : panelBounds.left - parentBounds.right; + assert.deepStrictEqual({ + shown: panel.style.display !== 'none', + aligned: Math.abs(gap) < 1, + focusPreserved: document.activeElement === focusedElement, + }, { shown: true, aligned: true, focusPreserved: true }); + }); + } + } + test('consumes initial focus before later filtering and refocusing', () => { const widget = createActionListWidget(disposables, { items: [action('match-first'), action('match-initial'), action('other')], diff --git a/src/vs/platform/actionWidget/test/browser/tabbedActionListWidget.test.ts b/src/vs/platform/actionWidget/test/browser/tabbedActionListWidget.test.ts index 59dba6c39fefee..d24f83d437d958 100644 --- a/src/vs/platform/actionWidget/test/browser/tabbedActionListWidget.test.ts +++ b/src/vs/platform/actionWidget/test/browser/tabbedActionListWidget.test.ts @@ -17,9 +17,14 @@ import { IOpenerService } from '../../../opener/common/opener.js'; import { NullOpenerService } from '../../../opener/test/common/nullOpenerService.js'; import { ActionListItemKind, IActionListItem } from '../../browser/actionList.js'; import { TabbedActionListWidget } from '../../browser/tabbedActionListWidget.js'; +import { IAccessibilityService } from '../../../accessibility/common/accessibility.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { AnchorPosition } from '../../../../base/common/layout.js'; +import { mainWindow } from '../../../../base/browser/window.js'; interface ITestItem { readonly id: string; + readonly checked?: boolean; } function action(id: string): IActionListItem { @@ -34,6 +39,8 @@ function action(id: string): IActionListItem { class FakeContextViewService implements Partial { declare readonly _serviceBrand: undefined; + layoutCount = 0; + private _container: HTMLElement | undefined; private _activeDelegate: IContextViewDelegate | undefined; private _activeRenderDisposables: { dispose(): void } | undefined; @@ -70,19 +77,23 @@ class FakeContextViewService implements Partial { container?.remove(); } - layout(): void { /* no-op */ } getContextViewElement(): HTMLElement { return this._container ?? document.body; } + + layout(): void { + this.layoutCount++; + } } -function createWidget(disposables: DisposableStore): { widget: TabbedActionListWidget; contextView: FakeContextViewService } { +function createWidget(disposables: DisposableStore, motionReduced = true): { widget: TabbedActionListWidget; contextView: FakeContextViewService } { const instantiationService = disposables.add(new TestInstantiationService()); const contextView = new FakeContextViewService(); instantiationService.stub(IContextViewService, contextView as IContextViewService); instantiationService.set(IKeybindingService, new MockKeybindingService()); instantiationService.set(IHoverService, NullHoverService); instantiationService.set(IOpenerService, NullOpenerService); + instantiationService.stub(IAccessibilityService, { isMotionReduced: () => motionReduced } as IAccessibilityService); instantiationService.stub(ILayoutService, { getContainer: () => document.body, mainContainer: document.body, onDidChangeMainContainer: () => ({ dispose: () => { } }) } as unknown as ILayoutService); const widget = disposables.add(instantiationService.createInstance(TabbedActionListWidget)); @@ -120,6 +131,93 @@ suite('TabbedActionListWidget', () => { assert.strictEqual(widget.isVisible, false); }); + for (const scenario of [ + { showCheckedItemHover: true, checked: true, shown: true }, + { showCheckedItemHover: false, checked: true, shown: false }, + { showCheckedItemHover: true, checked: false, shown: false }, + ]) { + test(`checked hover on open: ${JSON.stringify(scenario)}`, () => { + const { widget, contextView } = createWidget(disposables); + const anchor = document.createElement('div'); + document.body.appendChild(anchor); + disposables.add({ dispose: () => anchor.remove() }); + let renders = 0; + widget.show({ + user: 'test', + anchor, + tabs: [{ id: 'Models' }], + initialTab: 'Models', + showCheckedItemHover: scenario.showCheckedItemHover, + createActionList: () => ({ + items: [action('first'), { + ...action('active'), + item: { id: 'active', checked: scenario.checked }, + hover: { + expandable: true, + content: () => { + renders++; + const content = document.createElement('div'); + content.textContent = 'Active model details'; + return content; + }, + }, + }], + }), + delegate: { onSelect: () => { }, onHide: () => { } }, + }); + const panel = contextView.getContextViewElement().querySelector('.action-list-submenu-panel')!; + assert.deepStrictEqual({ + shown: panel.style.display !== 'none', + renders, + focusInPanel: panel.contains(document.activeElement), + }, { shown: scenario.shown, renders: scenario.shown ? 1 : 0, focusInPanel: false }); + widget.hide(); + }); + } + + test('refresh keeps the popup open when rebuilding removes the focused card control', async () => { + const { widget, contextView } = createWidget(disposables); + const anchor = document.createElement('div'); + document.body.appendChild(anchor); + disposables.add({ dispose: () => anchor.remove() }); + const control = document.createElement('button'); + control.textContent = 'Configure'; + let refreshed = false; + widget.show({ + user: 'test', + anchor, + tabs: [{ id: 'Models' }], + initialTab: 'Models', + showCheckedItemHover: true, + createActionList: () => { + if (refreshed) { + control.remove(); + } + return { + items: [{ + ...action('active'), + item: { id: 'active', checked: true }, + hover: refreshed ? undefined : { content: control, expandable: true }, + }], + listOptions: { persistentHover: true }, + }; + }, + delegate: { onSelect: () => { }, onHide: () => { } }, + }); + const popup = contextView.getContextViewElement(); + control.focus(); + refreshed = true; + widget.refreshActiveList(); + await new Promise(resolve => setTimeout(resolve, 0)); + + assert.deepStrictEqual({ + visible: widget.isVisible, + focusInPopup: popup.contains(document.activeElement), + cardClosed: popup.querySelector('.action-list-submenu-panel')?.style.display === 'none', + }, { visible: true, focusInPopup: true, cardClosed: true }); + widget.hide(); + }); + test('buildItems is called with the initial tab', () => { const { widget } = createWidget(disposables); const anchor = document.createElement('div'); @@ -183,6 +281,307 @@ suite('TabbedActionListWidget', () => { ); }); + test('switching tabs animates them between their old and new widths', () => { + const { widget } = createWidget(disposables, /* motionReduced */ false); + const anchor = document.createElement('div'); + document.body.appendChild(anchor); + disposables.add({ dispose: () => anchor.remove() }); + + // Only the active tab is labelled, so a switch resizes both the tab being left + // and the one being entered. + widget.show({ + user: 'test', + anchor, + tabs: [ + { id: 'Local', label: 'Local models', icon: Codicon.deviceDesktop }, + { id: 'Remote', label: 'Remote models', icon: Codicon.cloud }, + ], + initialTab: 'Local', + tabLabels: 'active', + createActionList: () => ({ items: [action('a')] }), + delegate: { onSelect: () => { }, onHide: () => { } }, + }); + + const tabs = () => [...document.querySelectorAll('.tabbed-action-list-tabstrip .monaco-button')]; + // Keyframes serialize their width to fewer decimals than a measurement carries. + const round = (width: number) => Math.round(width * 100) / 100; + const widthsBefore = tabs().map(tab => round(tab.getBoundingClientRect().width)); + + // The widths are captured on the way in, so the press has to precede the click + // exactly as a real one does. + tabs()[1].dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })); + tabs()[1].click(); + + // Reading the widths back now would only report the animation's current frame. What + // matters is that each rebuilt tab starts from the width its predecessor had. + const startWidths = tabs().map(tab => { + const [animation] = tab.getAnimations(); + return animation + ? round(parseFloat(String((animation.effect as KeyframeEffect).getKeyframes()[0].width))) + : undefined; + }); + + assert.deepStrictEqual(startWidths, widthsBefore); + // Leave nothing mounted: later tests read the popup out of the document. + widget.hide(); + }); + + test('switching tabs does not animate when motion is reduced', () => { + const { widget } = createWidget(disposables, /* motionReduced */ true); + const anchor = document.createElement('div'); + document.body.appendChild(anchor); + disposables.add({ dispose: () => anchor.remove() }); + + widget.show({ + user: 'test', + anchor, + tabs: [ + { id: 'Local', label: 'Local models', icon: Codicon.deviceDesktop }, + { id: 'Remote', label: 'Remote models', icon: Codicon.cloud }, + ], + initialTab: 'Local', + tabLabels: 'active', + createActionList: () => ({ items: [action('a')] }), + delegate: { onSelect: () => { }, onHide: () => { } }, + }); + + const tabs = () => [...document.querySelectorAll('.tabbed-action-list-tabstrip .monaco-button')]; + tabs()[1].dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })); + tabs()[1].click(); + + assert.deepStrictEqual(tabs().filter(tab => tab.getAnimations().length > 0), []); + widget.hide(); + }); + + test('a tab that loses its label keeps it until it has finished shrinking', async () => { + const { widget } = createWidget(disposables, /* motionReduced */ false); + const anchor = document.createElement('div'); + document.body.appendChild(anchor); + disposables.add({ dispose: () => anchor.remove() }); + + widget.show({ + user: 'test', + anchor, + tabs: [ + { id: 'Local', label: 'Local models', icon: Codicon.deviceDesktop }, + { id: 'Remote', label: 'Remote models', icon: Codicon.cloud }, + ], + initialTab: 'Local', + tabLabels: 'active', + createActionList: () => ({ items: [action('a')] }), + delegate: { onSelect: () => { }, onHide: () => { } }, + }); + + const tabs = () => { + // Other tests may still have a popup mounted, and each popup also has a submenu + // panel that carries the same class, so read the newest one that has tabs. + const popups = [...document.querySelectorAll('.action-widget')] + .filter(popup => popup.querySelector('.tabbed-action-list-tabstrip')); + const popup = popups[popups.length - 1]; + return [...popup.querySelectorAll('.tabbed-action-list-tabstrip .monaco-button')]; + }; + tabs()[1].dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })); + tabs()[1].click(); + + const outgoing = tabs()[0]; + const whileShrinking = { + keepsLabel: outgoing.textContent?.includes('Local models') ?? false, + marked: outgoing.classList.contains('label-collapsing'), + }; + + // The label is put back from the animation's finish event, which is dispatched on a + // later turn than `finish()` itself. + const animations = outgoing.getAnimations(); + animations.forEach(animation => animation.finish()); + await Promise.all(animations.map(animation => animation.finished.catch(() => undefined))); + await new Promise(resolve => setTimeout(resolve, 0)); + + assert.deepStrictEqual( + { + whileShrinking, + afterShrinking: { + keepsLabel: outgoing.textContent?.includes('Local models') ?? false, + marked: outgoing.classList.contains('label-collapsing'), + }, + }, + { + whileShrinking: { keepsLabel: true, marked: true }, + afterShrinking: { keepsLabel: false, marked: false }, + }, + ); + widget.hide(); + }); + + test('the sizing tab decides the list height, whichever tab the popup opens on', () => { + const anchor = document.createElement('div'); + document.body.appendChild(anchor); + disposables.add({ dispose: () => anchor.remove() }); + + const listHeight = () => parseFloat(document.querySelector('.action-widget .actionList')?.style.height ?? '0'); + + // Opens on the short tab on purpose: the height has to come from the sizing tab + // regardless of which tab the popup happens to open on. + const heightsAcrossTabs = (sizingTab: string | undefined) => { + const { widget } = createWidget(disposables); + widget.show({ + user: 'test', + anchor, + tabs: [{ id: 'Copilot' }, { id: 'Ollama' }], + initialTab: 'Ollama', + sizingTab, + createActionList: tab => ({ + items: tab === 'Copilot' + ? ['a', 'b', 'c', 'd', 'e', 'f'].map(action) + : [action('only')], + }), + delegate: { onSelect: () => { }, onHide: () => { } }, + }); + const onShortTab = listHeight(); + document.querySelectorAll('.tabbed-action-list-tabstrip .monaco-button')[0].click(); + const onSizingTab = listHeight(); + widget.hide(); + return { onShortTab, onSizingTab }; + }; + + const unsized = heightsAcrossTabs(undefined); + const sized = heightsAcrossTabs('Copilot'); + + // Clamping depends on the room around the anchor, which differs between the two + // renders here, so compare how each tab is sized rather than the pixels. + assert.deepStrictEqual( + { + resizesWithoutASizingTab: unsized.onShortTab < unsized.onSizingTab, + shortTabTakesTheSizingTabsHeight: sized.onShortTab > unsized.onShortTab, + }, + { resizesWithoutASizingTab: true, shortTabTakesTheSizingTabsHeight: true }, + ); + }); + + test('a collapsed section in the sizing tab is left out of the fixed height', () => { + const anchor = document.createElement('div'); + document.body.appendChild(anchor); + disposables.add({ dispose: () => anchor.remove() }); + + const listHeight = () => parseFloat(document.querySelector('.action-widget .actionList')?.style.height ?? '0'); + + // The sizing tab keeps most of its models behind a collapsed "other" section, so + // only the two rows on screen should count towards the height. + const heightWithSection = (collapsed: boolean) => { + const { widget } = createWidget(disposables); + widget.show({ + user: 'test', + anchor, + tabs: [{ id: 'Copilot' }, { id: 'Ollama' }], + initialTab: 'Ollama', + sizingTab: 'Copilot', + createActionList: tab => tab === 'Copilot' + ? { + items: [ + action('promoted'), + { ...action('other-models'), isSectionToggle: true, section: 'other' }, + ...['a', 'b', 'c', 'd', 'e', 'f'].map(id => ({ ...action(id), section: 'other' })), + ], + listOptions: collapsed ? { collapsedByDefault: new Set(['other']) } : undefined, + } + : { items: [action('only')] }, + delegate: { onSelect: () => { }, onHide: () => { } }, + }); + const height = listHeight(); + widget.hide(); + return height; + }; + + assert.deepStrictEqual( + { collapsedIsShorter: heightWithSection(true) < heightWithSection(false) }, + { collapsedIsShorter: true }, + ); + }); + + for (const initialFooterHeight of [20, 80]) { + test(`resizing the footer preserves the fixed popup height when opened with a ${initialFooterHeight}px footer`, async () => { + const { widget, contextView } = createWidget(disposables); + const anchor = document.createElement('div'); + anchor.style.cssText = 'position: fixed; top: 400px; width: 120px; height: 20px;'; + document.body.appendChild(anchor); + disposables.add({ dispose: () => anchor.remove() }); + const button = document.createElement('button'); + button.textContent = 'Toggle'; + let currentFooterHeight = initialFooterHeight; + + const show = () => widget.show({ + user: 'test', + anchor, + tabs: [{ id: 'Copilot' }, { id: 'Other' }], + initialTab: 'Copilot', + sizingTab: 'Copilot', + width: 300, + createActionList: tab => ({ + items: (tab === 'Copilot' ? ['a', 'b', 'c', 'd', 'e', 'f'] : ['other']).map(action), + listOptions: { anchorPosition: AnchorPosition.ABOVE }, + }), + renderFooter: container => { + container.style.height = `${currentFooterHeight}px`; + container.appendChild(button); + return { dispose: () => button.remove() }; + }, + delegate: { onSelect: () => { }, onHide: () => { } }, + }); + show(); + + const settleLayout = () => new Promise(resolve => { + mainWindow.requestAnimationFrame(() => mainWindow.requestAnimationFrame(() => resolve())); + }); + await settleLayout(); + const popup = contextView.getContextViewElement().querySelector('.action-widget')!; + const list = popup.querySelector('.actionList')!; + const footer = popup.querySelector('.tabbed-action-list-footer')!; + const initialHeight = popup.offsetHeight; + const initialListHeight = list.offsetHeight; + button.focus(); + + const otherFooterHeight = initialFooterHeight === 20 ? 80 : 20; + const heights = [otherFooterHeight, initialFooterHeight, 100, otherFooterHeight]; + const states = []; + for (const height of heights) { + const layoutCount = contextView.layoutCount; + currentFooterHeight = height; + footer.style.height = `${height}px`; + await settleLayout(); + states.push({ + height: popup.offsetHeight, + listHeight: list.offsetHeight, + footerHeight: footer.offsetHeight, + focused: document.activeElement === button, + reanchored: contextView.layoutCount > layoutCount, + }); + } + + const tabHeights = []; + for (const index of [1, 0]) { + contextView.getContextViewElement().querySelectorAll('.tabbed-action-list-tabstrip .monaco-button')[index].click(); + await settleLayout(); + tabHeights.push(contextView.getContextViewElement().querySelector('.action-widget')!.offsetHeight); + } + widget.hide(); + show(); + await settleLayout(); + const reopenedHeight = contextView.getContextViewElement().querySelector('.action-widget')!.offsetHeight; + + assert.deepStrictEqual({ states, tabHeights, reopenedHeight }, { + states: heights.map(footerHeight => ({ + height: initialHeight, + listHeight: initialListHeight + initialFooterHeight - footerHeight, + footerHeight, + focused: true, + reanchored: true, + })), + tabHeights: [initialHeight, initialHeight], + reopenedHeight: initialHeight + currentFooterHeight - initialFooterHeight, + }); + widget.hide(); + }); + } + test('hide() then show() resets visibility cleanly', () => { const { widget } = createWidget(disposables); const anchor = document.createElement('div'); diff --git a/src/vs/platform/agentHost/common/agent.ts b/src/vs/platform/agentHost/common/agent.ts index e5214007bf01bb..24960067cc5a79 100644 --- a/src/vs/platform/agentHost/common/agent.ts +++ b/src/vs/platform/agentHost/common/agent.ts @@ -95,7 +95,7 @@ export interface IAgentHostNetworkEndpoint { export interface IAgentHostManagedSettingsSnapshot { readonly account?: string; - readonly source: 'server' | 'device' | 'client' | 'mixed' | 'none'; + readonly source: 'server' | 'device' | 'client' | 'policyHelper' | 'mixed' | 'none'; readonly serverManaged: boolean; readonly deviceManaged: boolean; readonly clientManaged?: boolean; diff --git a/src/vs/platform/agentHost/common/agentHostChatContributionsService.ts b/src/vs/platform/agentHost/common/agentHostChatContributionsService.ts index 0d4056c87cf409..fc3cad83b1918e 100644 --- a/src/vs/platform/agentHost/common/agentHostChatContributionsService.ts +++ b/src/vs/platform/agentHost/common/agentHostChatContributionsService.ts @@ -108,6 +108,8 @@ export type IncomingRequestDisposition = export interface IHydrationContext { readonly session: ProtocolURI; readonly chat: ProtocolURI; + /** Authoritative value from already-loaded host session metadata, when available. */ + readonly hasWorkspaceTransitions?: boolean; } /** diff --git a/src/vs/platform/agentHost/common/agentHostGitService.ts b/src/vs/platform/agentHost/common/agentHostGitService.ts index 3f2c4848aaec99..18276792a7a7dc 100644 --- a/src/vs/platform/agentHost/common/agentHostGitService.ts +++ b/src/vs/platform/agentHost/common/agentHostGitService.ts @@ -99,6 +99,14 @@ export interface IPullOptions { export const IAgentHostGitService = createDecorator('agentHostGitService'); +/** Error thrown when checkout would overwrite local working-tree changes. */ +export class CheckoutBlockedByLocalChangesError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = 'CheckoutBlockedByLocalChangesError'; + } +} + /** * Resolves linked checkouts to their primary worktree and caches successful mappings for every worktree reported by Git. * Resolution is serialized so concurrent requests across linked checkouts share one probe, while empty results remain retryable. @@ -256,7 +264,7 @@ export interface IAgentHostGitService { branchExists(repositoryRoot: URI, branchName: string): Promise; /** Creates a new branch and optionally checks it out while preserving the working tree. */ createBranch(workingDirectory: URI, branchName: string, options?: { readonly checkout?: boolean }): Promise; - /** Checks out an existing local branch. */ + /** Checks out an existing local branch, throwing {@link CheckoutBlockedByLocalChangesError} when local changes would be overwritten. */ checkout(workingDirectory: URI, treeish: string): Promise; /** * Returns true when the working tree has any tracked, staged, or @@ -265,6 +273,8 @@ export interface IAgentHostGitService { */ hasUncommittedChanges(workingDirectory: URI): Promise; + createStash(workingDirectory: URI, options?: { readonly message?: string; readonly includeUntracked?: boolean; readonly staged?: boolean }): Promise; + /** * Stages and commits all tracked, staged, and untracked changes in the * working tree. Mirrors the Copilot CLI session PR path, which commits diff --git a/src/vs/platform/agentHost/common/agentSdkSetup.ts b/src/vs/platform/agentHost/common/agentSdkSetup.ts index d37ecf55d4a5ab..404e8aa6962998 100644 --- a/src/vs/platform/agentHost/common/agentSdkSetup.ts +++ b/src/vs/platform/agentHost/common/agentSdkSetup.ts @@ -31,11 +31,14 @@ export function agentSdkSetupStatusKey(agent: string): string { /** * Whether the agent's SDK can be loaded without a network fetch. * + * `downloadOnUse` means the SDK is absent, but this host remembers consent and + * will fetch it only when an agent turn needs it. + * * Deliberately the *only* thing on the wire: account state is derivable from the * model list, which already flows over AHP — `ready` plus zero models means "no * account" — and publishing it too would be two sources for one truth. */ -export type AgentSdkDownloadStatus = 'notDownloaded' | 'downloading' | 'ready'; +export type AgentSdkDownloadStatus = 'notDownloaded' | 'downloadOnUse' | 'downloading' | 'ready'; /** * What an agent declares about its own setup. Capabilities, never UI: no @@ -79,7 +82,7 @@ function readOne(value: unknown, agent: string): IAgentSdkSetupInfo | undefined return undefined; } const info: Partial = value; - if (info.download !== 'notDownloaded' && info.download !== 'downloading' && info.download !== 'ready') { + if (info.download !== 'notDownloaded' && info.download !== 'downloadOnUse' && info.download !== 'downloading' && info.download !== 'ready') { return undefined; } return { @@ -120,49 +123,3 @@ export function readAgentSdkSetupInfos(state: RootState | undefined): readonly I } return infos; } - -/** - * The agents whose SDK the user has agreed to fetch, decoded from storage. A - * malformed or absent record reads as "nobody consented", which costs at worst - * one extra press of a button the user was about to press anyway. - */ -export function readConsentedSdkAgents(stored: string | undefined): ReadonlySet { - if (!stored) { - return new Set(); - } - try { - const parsed: unknown = JSON.parse(stored); - return new Set(Array.isArray(parsed) ? parsed.filter(agent => typeof agent === 'string') : []); - } catch { - return new Set(); - } -} - -export function writeConsentedSdkAgents(agents: ReadonlySet): string { - return JSON.stringify([...agents]); -} - -/** - * Which agents should be asked to fetch their SDK without being offered a - * button, given standing consent. The SDK version is pinned per build - * and the cache keyed by version, so every update invalidates it — daily on - * Insiders. Consent is to "this product downloads the Claude SDK", not to one - * tarball, so re-asking would nag people who already said yes. - * - * It does not carry to a *different* agent: the button says "we need to - * download the Codex Agent SDK", and pressing it is not permission to fetch - * Claude's. - * - * `alreadyRequested` stops a failing download retrying forever: a failed fetch - * republishes `notDownloaded`, and every status change re-runs this. A window is - * the retry unit. - */ -export function resolveConsentedSdkDownloads( - consentedAgents: ReadonlySet, - setups: readonly IAgentSdkSetupInfo[], - alreadyRequested: ReadonlySet, -): readonly string[] { - return setups - .filter(setup => setup.download === 'notDownloaded' && consentedAgents.has(setup.agent) && !alreadyRequested.has(setup.agent)) - .map(setup => setup.agent); -} diff --git a/src/vs/platform/agentHost/common/meta/agentCheckoutOperationMeta.ts b/src/vs/platform/agentHost/common/meta/agentCheckoutOperationMeta.ts index 2a6dfd36d4663e..76b07ff4b2f847 100644 --- a/src/vs/platform/agentHost/common/meta/agentCheckoutOperationMeta.ts +++ b/src/vs/platform/agentHost/common/meta/agentCheckoutOperationMeta.ts @@ -3,15 +3,30 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { hasKey } from '../../../../base/common/types.js'; + interface IHasCheckoutOperationMeta { readonly _meta?: Record; } const TREEISH_META_KEY = 'treeish'; +const PRE_CHECKOUT_ACTION_META_KEY = 'preCheckoutAction'; +const DIRTY_WORKING_TREE_ERROR_REASON = 'dirtyWorkingTree'; +const ERROR_REASON_KEY = 'reason'; + +export const enum CheckoutOperationPreAction { + Stash = 'stash', + Commit = 'commit', +} /** Creates request metadata for a Checkout changeset operation. */ -export function checkoutOperationMeta(treeish: string): Record { - return { [TREEISH_META_KEY]: treeish }; +export function checkoutOperationMeta(treeish: string, preCheckoutAction?: CheckoutOperationPreAction): Record { + return { + [TREEISH_META_KEY]: treeish, + ...(preCheckoutAction + ? { [PRE_CHECKOUT_ACTION_META_KEY]: preCheckoutAction } + : {}), + }; } /** Reads and validates the treeish requested by a Checkout changeset operation. */ @@ -23,3 +38,29 @@ export function readCheckoutOperationTreeish(source: IHasCheckoutOperationMeta): const treeish = meta[TREEISH_META_KEY]; return typeof treeish === 'string' && treeish.length > 0 ? treeish : undefined; } + +/** Reads and validates the action requested before a Checkout changeset operation. */ +export function readCheckoutOperationPreAction(source: IHasCheckoutOperationMeta): CheckoutOperationPreAction | undefined { + const meta = source._meta; + if (!meta) { + return undefined; + } + const preCheckoutAction = meta[PRE_CHECKOUT_ACTION_META_KEY]; + return preCheckoutAction === CheckoutOperationPreAction.Stash || preCheckoutAction === CheckoutOperationPreAction.Commit + ? preCheckoutAction + : undefined; +} + +/** Creates structured error data for a Checkout operation blocked by a dirty working tree. */ +export function checkoutOperationDirtyWorkingTreeErrorData(): Record { + return { [ERROR_REASON_KEY]: DIRTY_WORKING_TREE_ERROR_REASON }; +} + +function hasErrorReason(data: unknown): data is { readonly reason: unknown } { + return typeof data === 'object' && data !== null && hasKey(data, { reason: true }); +} + +/** Returns whether error data identifies a Checkout operation blocked by a dirty working tree. */ +export function isCheckoutOperationDirtyWorkingTreeErrorData(data: unknown): boolean { + return hasErrorReason(data) && data.reason === DIRTY_WORKING_TREE_ERROR_REASON; +} diff --git a/src/vs/platform/agentHost/common/meta/agentSystemNotificationMeta.ts b/src/vs/platform/agentHost/common/meta/agentSystemNotificationMeta.ts index daf53e0da977ec..922a021640ee27 100644 --- a/src/vs/platform/agentHost/common/meta/agentSystemNotificationMeta.ts +++ b/src/vs/platform/agentHost/common/meta/agentSystemNotificationMeta.ts @@ -5,6 +5,8 @@ export const enum AgentSystemNotificationKind { WorktreeCreationFailure = 'worktreeCreationFailure', + /** The session successfully changed to a requested workspace. */ + WorkspaceTransition = 'workspaceTransition', /** An automatic approval review did not finish before its deadline. */ AutomaticApprovalReviewTimedOut = 'automaticApprovalReviewTimedOut', /** An automatic approval review stopped before reaching a decision. */ @@ -21,12 +23,18 @@ export const enum AgentSystemNotificationKind { AgentMergePullRequestMerged = 'agentMergePullRequestMerged', } +export const enum AgentSystemNotificationWorkspaceKind { + Folder = 'folder', + Worktree = 'worktree', +} + export const enum AgentSystemNotificationSeverity { Warning = 'warning', } const knownKinds: ReadonlySet = new Set([ AgentSystemNotificationKind.WorktreeCreationFailure, + AgentSystemNotificationKind.WorkspaceTransition, AgentSystemNotificationKind.AutomaticApprovalReviewTimedOut, AgentSystemNotificationKind.AutomaticApprovalReviewAborted, AgentSystemNotificationKind.AutomaticApprovalReviewInterrupted, @@ -43,6 +51,14 @@ interface IHasSystemNotificationMeta { export interface IAgentSystemNotificationMeta { readonly kind?: AgentSystemNotificationKind; readonly severity?: AgentSystemNotificationSeverity; + readonly workspaceKind?: AgentSystemNotificationWorkspaceKind; + readonly workspaceName?: string; +} + +export interface IAgentWorkspaceTransitionRecord { + readonly content: string; + readonly workspaceKind: AgentSystemNotificationWorkspaceKind; + readonly workspaceName: string; } /** Reads recognized Agent Host system-notification metadata. */ @@ -52,9 +68,12 @@ export function readAgentSystemNotificationMeta(source: IHasSystemNotificationMe return {}; } const kind = meta['kind']; + const workspaceKind = meta['workspaceKind']; return { kind: typeof kind === 'string' && knownKinds.has(kind) ? kind as AgentSystemNotificationKind : undefined, severity: meta['severity'] === AgentSystemNotificationSeverity.Warning ? meta['severity'] : undefined, + workspaceKind: workspaceKind === AgentSystemNotificationWorkspaceKind.Folder || workspaceKind === AgentSystemNotificationWorkspaceKind.Worktree ? workspaceKind : undefined, + workspaceName: typeof meta['workspaceName'] === 'string' ? meta['workspaceName'] : undefined, }; } @@ -62,3 +81,33 @@ export function readAgentSystemNotificationMeta(source: IHasSystemNotificationMe export function toAgentSystemNotificationMeta(meta: IAgentSystemNotificationMeta): Record { return { ...meta }; } + +/** Serializes a durable workspace-transition boundary. */ +export function serializeAgentWorkspaceTransition(record: IAgentWorkspaceTransitionRecord): string { + return JSON.stringify(record); +} + +/** Parses and validates a durable workspace-transition boundary. */ +export function parseAgentWorkspaceTransition(value: string): IAgentWorkspaceTransitionRecord | undefined { + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + return undefined; + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return undefined; + } + const candidate = parsed as Partial; + if (typeof candidate.content !== 'string' + || typeof candidate.workspaceName !== 'string' + || (candidate.workspaceKind !== AgentSystemNotificationWorkspaceKind.Folder && candidate.workspaceKind !== AgentSystemNotificationWorkspaceKind.Worktree) + ) { + return undefined; + } + return { + content: candidate.content, + workspaceKind: candidate.workspaceKind, + workspaceName: candidate.workspaceName, + }; +} diff --git a/src/vs/platform/agentHost/common/meta/agentWorkspaceContinuationMeta.ts b/src/vs/platform/agentHost/common/meta/agentWorkspaceContinuationMeta.ts new file mode 100644 index 00000000000000..b87e029615b170 --- /dev/null +++ b/src/vs/platform/agentHost/common/meta/agentWorkspaceContinuationMeta.ts @@ -0,0 +1,21 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +const AGENT_WORKSPACE_CONTINUATION_META_KEY = 'vscode.chat.workspaceContinuation'; + +interface IHasAgentWorkspaceContinuationMeta { + readonly _meta?: Record; +} + +/** Whether the message is the internal request that resumes a turn after workspace conversion. */ +export function isAgentWorkspaceContinuationMessage(source: IHasAgentWorkspaceContinuationMeta): boolean { + // eslint-disable-next-line local/code-no-untyped-meta-access -- sanctioned first hop into the namespaced workspace-continuation slot; validated here. + return source._meta?.[AGENT_WORKSPACE_CONTINUATION_META_KEY] === true; +} + +/** Serializes the workspace-continuation marker for the open protocol bag. */ +export function toAgentWorkspaceContinuationMessageMeta(): Record { + return { [AGENT_WORKSPACE_CONTINUATION_META_KEY]: true }; +} diff --git a/src/vs/platform/agentHost/common/sessionDataService.ts b/src/vs/platform/agentHost/common/sessionDataService.ts index 42bca40ae0e64a..c977b4809f546e 100644 --- a/src/vs/platform/agentHost/common/sessionDataService.ts +++ b/src/vs/platform/agentHost/common/sessionDataService.ts @@ -180,6 +180,27 @@ export interface ISessionDatabase extends IDisposable { */ getTurnDelegations(): Promise>; + /** + * Persists the JSON-serialized successful workspace transition for a turn. + * Idempotent — last writer wins per turn. + */ + setTurnWorkspaceTransition(turnId: string, transition: string): Promise; + + /** + * Atomically persists converted session metadata and the workspace + * transition associated with its deferred continuation turn. + */ + setWorkspaceConversion(turnId: string, transition: string, metadata: Readonly>): Promise; + + /** Deletes a persisted workspace transition without deleting its owning turn. */ + deleteTurnWorkspaceTransition(turnId: string): Promise; + + /** + * Returns every persisted workspace transition, keyed by both the turn's + * own id and its provider event id when one has been recorded. + */ + getTurnWorkspaceTransitions(): Promise>; + /** * Associates a git checkpoint ref (e.g. `refs/agents//checkpoints/turn/N`) * with a turn. Idempotent — last writer wins per turn. diff --git a/src/vs/platform/agentHost/common/state/sessionState.ts b/src/vs/platform/agentHost/common/state/sessionState.ts index 50a0a133127263..a9c78879815c2a 100644 --- a/src/vs/platform/agentHost/common/state/sessionState.ts +++ b/src/vs/platform/agentHost/common/state/sessionState.ts @@ -15,6 +15,7 @@ import { decodeBase64, encodeBase64, VSBuffer } from '../../../../base/common/bu import { hasKey, type Mutable } from '../../../../base/common/types.js'; import { URI as ResourceURI } from '../../../../base/common/uri.js'; import type { IProductService } from '../../../product/common/productService.js'; +import { isAgentWorkspaceContinuationMessage } from '../meta/agentWorkspaceContinuationMeta.js'; import { readToolCallMeta } from '../meta/agentToolCallMeta.js'; import { readLegacyTurnError } from './legacyProtocolCompatibility.js'; import { @@ -357,10 +358,12 @@ export function withMessageSystemInitiatedLabel(message: Message, label: string) * * A *visible* system notification (a background-agent completion, an Agent * Merge repair prompt) is a real turn and is deliberately not matched. + * A hidden workspace-continuation request is also a real provider turn. */ export function isHostNoticeTurn(turn: { readonly message: Message }): boolean { return turn.message.origin.kind === MessageKind.SystemNotification - && (isMessageHiddenFromTranscript(turn.message) || isMessageRequestHiddenFromTranscript(turn.message)); + && (isMessageHiddenFromTranscript(turn.message) || isMessageRequestHiddenFromTranscript(turn.message)) + && !isAgentWorkspaceContinuationMessage(turn.message); } /** Returns the last turn id that can own file changes, or `undefined` if there is none. */ @@ -2012,6 +2015,12 @@ export const SESSION_META_WORKSPACELESS_KEY = 'workspaceless'; */ export const AH_META_WORKSPACELESS_DB_KEY = 'agentHost.workspaceless'; +/** Session-database marker indicating that retained turns include workspace-transition boundaries. */ +export const AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY = 'agentHost.hasWorkspaceTransitions'; + +/** Summary metadata mirror of {@link AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY}. */ +export const SESSION_META_HAS_WORKSPACE_TRANSITIONS_KEY = 'hasWorkspaceTransitions'; + /** Blocks turns for a session whose provider could not be detached from an untrusted working directory. */ export const AH_META_WORKSPACE_CONVERSION_QUARANTINED_DB_KEY = 'agentHost.workspaceConversionQuarantined'; @@ -2073,6 +2082,22 @@ export function withSessionWorkspaceless(meta: SessionSummaryMeta | undefined, w return Object.keys(next).length > 0 ? next : undefined; } +/** Whether retained turns in this session include host-owned workspace transitions. */ +export function readSessionHasWorkspaceTransitions(meta: SessionSummaryMeta | undefined): boolean { + return meta?.[SESSION_META_HAS_WORKSPACE_TRANSITIONS_KEY] === true; +} + +/** Returns summary metadata with the workspace-transition history marker updated. */ +export function withSessionHasWorkspaceTransitions(meta: SessionSummaryMeta | undefined, hasTransitions: boolean): SessionSummaryMeta | undefined { + const next: { [key: string]: unknown } = { ...meta }; + if (hasTransitions) { + next[SESSION_META_HAS_WORKSPACE_TRANSITIONS_KEY] = true; + } else { + delete next[SESSION_META_HAS_WORKSPACE_TRANSITIONS_KEY]; + } + return Object.keys(next).length > 0 ? next : undefined; +} + /** Whether the session was first discovered in a provider-native catalog. */ export function readSessionExternal(meta: SessionSummaryMeta | undefined): boolean { return meta?.[SESSION_META_EXTERNAL_KEY] === true; diff --git a/src/vs/platform/agentHost/node/agentHostChangesetService.ts b/src/vs/platform/agentHost/node/agentHostChangesetService.ts index 266bc0cfca037d..83499bb25e8d19 100644 --- a/src/vs/platform/agentHost/node/agentHostChangesetService.ts +++ b/src/vs/platform/agentHost/node/agentHostChangesetService.ts @@ -22,6 +22,7 @@ import { import { IDiffComputeService } from '../common/diffComputeService.js'; import { ISessionDatabase, ISessionDataService } from '../common/sessionDataService.js'; import type { ChangesetState, ChangesSummary } from '../common/state/protocol/state.js'; +import { addMillisecondsToTimestamp } from '../common/state/protocol/common/timestamps.js'; import { ActionType } from '../common/state/sessionActions.js'; import { ChangesetStatus, @@ -30,6 +31,7 @@ import { type URI as ProtocolURI, readSessionGitState, isDefaultChatUri, + isHostNoticeTurn, lastAttributableTurnId, SessionLifecycle, } from '../common/state/sessionState.js'; @@ -1476,13 +1478,8 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC } /** - * Returns the turn id whose checkpoint best represents the latest state of - * the session's shared working tree. For single-chat sessions this is the - * default chat's last turn. For multi-chat sessions it is the last turn of - * the most-recently-modified chat (peer-chat turn checkpoints are stored - * under the session URI keyed by their turn id). Host notice turns are - * skipped — they capture no checkpoint, so picking one would drop the - * session changeset off its git fast path. + * Returns the latest completed attributable turn across chats, whose + * checkpoint represents the session's shared working tree. */ private _latestTurnIdAcrossChats(session: ProtocolURI): string | undefined { const sessionState = this._stateManager.getSessionState(session); @@ -1496,15 +1493,21 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC } let bestTurnId: string | undefined; - let bestModifiedAt = ''; + let bestCompletedAt = ''; for (const chat of chats) { const turns = isDefaultChatUri(chat.resource) ? sessionState.turns : this._stateManager.getChatState(chat.resource)?.turns; - const lastTurnId = lastAttributableTurnId(turns); - if (lastTurnId && chat.modifiedAt >= bestModifiedAt) { - bestModifiedAt = chat.modifiedAt; - bestTurnId = lastTurnId; + const lastTurn = turns?.findLast(turn => !isHostNoticeTurn(turn)); + if (!lastTurn) { + continue; + } + const completedAt = lastTurn.startedAt + ? addMillisecondsToTimestamp(lastTurn.startedAt, lastTurn.duration ?? 0) + : chat.modifiedAt; + if (completedAt >= bestCompletedAt) { + bestCompletedAt = completedAt; + bestTurnId = lastTurn.id; } } return bestTurnId; @@ -1538,10 +1541,7 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC // Session if (kind === 'session') { - // Get session checkpoints. In multi-chat sessions the working tree - // is shared and each chat's turn checkpoints are stored under the - // session URI keyed by their turn id, so the most-recently-modified - // chat's last turn captures the full working-tree delta. + // The latest completed attributable turn captures the shared working-tree delta across chats. const latestTurnId = this._latestTurnIdAcrossChats(session); if (!latestTurnId) { return undefined; diff --git a/src/vs/platform/agentHost/node/agentHostCheckoutOperationHandler.ts b/src/vs/platform/agentHost/node/agentHostCheckoutOperationHandler.ts index 7c69a95398015b..4dbfcb54c34a9f 100644 --- a/src/vs/platform/agentHost/node/agentHostCheckoutOperationHandler.ts +++ b/src/vs/platform/agentHost/node/agentHostCheckoutOperationHandler.ts @@ -8,9 +8,9 @@ import { URI } from '../../../base/common/uri.js'; import { localize } from '../../../nls.js'; import { ILogService } from '../../log/common/log.js'; import { AGENT_HOST_CHECKOUT_CHANGESET_OPERATION_ID, type IChangesetOperationHandler } from '../common/agentHostChangesetOperationService.js'; -import { IAgentHostGitService } from '../common/agentHostGitService.js'; +import { CheckoutBlockedByLocalChangesError, IAgentHostGitService } from '../common/agentHostGitService.js'; import { ChangesetKind, parseChangesetUri } from '../common/changesetUri.js'; -import { readCheckoutOperationTreeish } from '../common/meta/agentCheckoutOperationMeta.js'; +import { CheckoutOperationPreAction, checkoutOperationDirtyWorkingTreeErrorData, readCheckoutOperationPreAction, readCheckoutOperationTreeish } from '../common/meta/agentCheckoutOperationMeta.js'; import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from '../common/state/protocol/channels-changeset/commands.js'; import { AHP_SESSION_NOT_FOUND, JsonRpcErrorCodes, ProtocolError } from '../common/state/sessionProtocol.js'; import type { SessionState } from '../common/state/sessionState.js'; @@ -53,16 +53,26 @@ export class AgentHostCheckoutOperationHandler implements IChangesetOperationHan throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, localize('agentHost.changeset.checkout.branchInvalid', "Branch '{0}' is not an existing local branch.", treeish)); } this._throwIfCancelled(token); - if (await this._gitService.hasUncommittedChanges(workingDirectory)) { - throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, localize('agentHost.changeset.checkout.dirty', "Commit or stash the current changes before checking out '{0}'.", treeish)); + const preCheckoutAction = readCheckoutOperationPreAction(params); + if (preCheckoutAction) { + await this._runPreCheckoutAction(workingDirectory, treeish, preCheckoutAction); + this._throwIfCancelled(token); } - this._throwIfCancelled(token); this._logService.info(`[AgentHostCheckoutOperationHandler] Checking out ${treeish} for session ${sessionUri}`); try { await this._gitService.checkout(workingDirectory, treeish); } catch (error) { this._throwIfCancelled(token); + + if (!preCheckoutAction && error instanceof CheckoutBlockedByLocalChangesError) { + throw new ProtocolError( + JsonRpcErrorCodes.InvalidParams, + localize('agentHost.changeset.checkout.dirty', "Your local changes would be overwritten by checkout. Commit or stash the current changes before checking out `{0}`.", treeish), + checkoutOperationDirtyWorkingTreeErrorData(), + ); + } + throw new ProtocolError(JsonRpcErrorCodes.InternalError, localize('agentHost.changeset.checkout.failed', "Failed to check out '{0}': {1}", treeish, error instanceof Error ? error.message : String(error))); } @@ -75,6 +85,28 @@ export class AgentHostCheckoutOperationHandler implements IChangesetOperationHan return { message: { markdown: localize('agentHost.changeset.checkout.checkedOut', "Checked out branch `{0}`.", treeish) } }; } + private async _runPreCheckoutAction(workingDirectory: URI, treeish: string, preCheckoutAction: CheckoutOperationPreAction): Promise { + try { + if (preCheckoutAction === CheckoutOperationPreAction.Stash) { + await this._gitService.createStash(workingDirectory, { + message: localize('agentHost.changeset.checkout.stashMessage', "WIP: Changes before checking out {0}", treeish), + includeUntracked: true, + }); + } else { + await this._gitService.commitAll(workingDirectory, localize('agentHost.changeset.checkout.commitMessage', "WIP: Save changes before checking out {0}", treeish)); + } + } catch (error) { + const action = preCheckoutAction === CheckoutOperationPreAction.Stash + ? localize('agentHost.changeset.checkout.stashAction', "stash") + : localize('agentHost.changeset.checkout.commitAction', "commit"); + + throw new ProtocolError( + JsonRpcErrorCodes.InternalError, + localize('agentHost.changeset.checkout.preActionFailed', "Failed to {0} changes before checking out '{1}': {2}", action, treeish, error instanceof Error ? error.message : String(error)), + ); + } + } + private _throwIfCancelled(token: CancellationToken): void { if (token.isCancellationRequested) { throw new ProtocolError(JsonRpcErrorCodes.InternalError, localize('agentHost.changeset.checkout.cancelled', "Checkout operation was cancelled.")); diff --git a/src/vs/platform/agentHost/node/agentHostGitService.ts b/src/vs/platform/agentHost/node/agentHostGitService.ts index cb8c9ea3e49ad3..306e6d961e2361 100644 --- a/src/vs/platform/agentHost/node/agentHostGitService.ts +++ b/src/vs/platform/agentHost/node/agentHostGitService.ts @@ -17,7 +17,7 @@ import { IFileService } from '../../files/common/files.js'; import { ILogService } from '../../log/common/log.js'; import { FileEditKind, type ISessionFileDiff, type ISessionGitState } from '../common/state/sessionState.js'; import { buildGitBlobUri } from './gitDiffContent.js'; -import { EMPTY_TREE_OBJECT, IAddWorktreeOptions, IAgentHostGitService, IBranch, IBranchDiffSafetyInfo, IRefQuery, IComputeSessionFileDiffsOptions, IDefaultBranch, IPullOptions, IPushOptions, GitRefType, IRemoteBranch, GitRef, ITag, Branch, IWorktreeFileProgress } from '../common/agentHostGitService.js'; +import { CheckoutBlockedByLocalChangesError, EMPTY_TREE_OBJECT, IAddWorktreeOptions, IAgentHostGitService, IBranch, IBranchDiffSafetyInfo, IRefQuery, IComputeSessionFileDiffsOptions, IDefaultBranch, IPullOptions, IPushOptions, GitRefType, IRemoteBranch, GitRef, ITag, Branch, IWorktreeFileProgress } from '../common/agentHostGitService.js'; import { LRUCache } from '../../../base/common/map.js'; import { firstParallel, Limiter, SequencerByKey, timeout } from '../../../base/common/async.js'; @@ -368,7 +368,17 @@ export class AgentHostGitService implements IAgentHostGitService { } async checkout(workingDirectory: URI, treeish: string): Promise { - await this._runGit(workingDirectory, ['checkout', '-q', treeish], { throwOnError: true }); + try { + await this._runGit(workingDirectory, ['checkout', '-q', treeish], { + throwOnError: true, + env: { LANG: 'C', LC_ALL: 'C' }, + }); + } catch (error) { + if (error instanceof GitCommandError && isCheckoutBlockedByLocalChanges(error.stderr)) { + throw new CheckoutBlockedByLocalChangesError(error.message, { cause: error }); + } + throw error; + } } async hasUncommittedChanges(workingDirectory: URI): Promise { @@ -376,6 +386,24 @@ export class AgentHostGitService implements IAgentHostGitService { return !!output && output.trim().length > 0; } + async createStash(workingDirectory: URI, options?: { readonly message?: string; readonly includeUntracked?: boolean; readonly staged?: boolean }): Promise { + const args = ['stash', 'push']; + + if (options?.includeUntracked) { + args.push('-u'); + } + + if (options?.staged) { + args.push('-S'); + } + + if (options?.message) { + args.push('-m', options.message); + } + + await this._runGit(workingDirectory, args, { timeout: 60_000, throwOnError: true }); + } + async commitAll(workingDirectory: URI, message: string): Promise { await this._runGit(workingDirectory, ['add', '-A', '--', ':/'], { throwOnError: true }); await this._runGit(workingDirectory, ['commit', '--no-verify', '-m', message], { timeout: 60_000, throwOnError: true }); @@ -1098,7 +1126,7 @@ export class AgentHostGitService implements IAgentHostGitService { this._logService.trace(`[agentHostGitService] > git ${args.join(' ')} failed: ${formatGitError(args, timeoutMs, didTimeOut, error, stderr)}`); } if (options?.throwOnError) { - reject(new Error(formatGitError(args, timeoutMs, didTimeOut, error, stderr), { cause: error })); + reject(new GitCommandError(formatGitError(args, timeoutMs, didTimeOut, error, stderr), stderr, error)); return; } resolve(undefined); @@ -1121,6 +1149,16 @@ export class AgentHostGitService implements IAgentHostGitService { } } +class GitCommandError extends Error { + constructor(message: string, readonly stderr: string, cause: cp.ExecFileException) { + super(message, { cause }); + } +} + +function isCheckoutBlockedByLocalChanges(stderr: string): boolean { + return /(?:local changes to the following files|untracked working tree files) would be overwritten by checkout:/i.test(stderr); +} + export function getRemoteTrackingRef(branch: string): { branchName: string; remoteBranch: string; remoteRef: string; sourceRef: string } | undefined { const pullRequestRef = /^refs\/pull\/(?\d+)\/head$/.exec(branch); const branchName = pullRequestRef?.groups diff --git a/src/vs/platform/agentHost/node/agentSdkDownloadTelemetry.ts b/src/vs/platform/agentHost/node/agentSdkDownloadTelemetry.ts index 4c35964638ce10..fed7a2d29027a9 100644 --- a/src/vs/platform/agentHost/node/agentSdkDownloadTelemetry.ts +++ b/src/vs/platform/agentHost/node/agentSdkDownloadTelemetry.ts @@ -81,7 +81,7 @@ type AgentSdkDownloadClassification = IAgentHostCopilotSkuClassification & { packageId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Which agent SDK was being fetched, e.g. claude or codex.' }; phase: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the download started, completed, or failed.' }; failureReason: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Coarse bucket for a failed download (cancelled, network, filesystem, extract, notConfigured, unsupportedTarget, unknown). Empty unless the phase is failed.' }; - explicitlyRequested: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the setup flow drove this download and showed its progress — a click, or a quiet re-fetch under standing consent — as opposed to a background fetch nobody was watching.' }; + explicitlyRequested: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the user explicitly requested this download through the setup UI, rather than it starting from an agent turn or standing consent.' }; durationMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'How long the download had been running when it reached this phase.' }; receivedBytes: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Bytes fetched by the time this phase was reached.' }; totalBytes: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Total size the server advertised, or zero when it did not.' }; @@ -92,9 +92,8 @@ type AgentSdkDownloadClassification = IAgentHostCopilotSkuClassification & { /** * Report one endpoint of a download. Callers pass only terminal and `started` * frames; the throttled `progress` frames are not counted. - * `explicitlyRequested` splits setup-driven downloads from background ones, not - * clicks from standing consent — both hold a progress interest. That split is - * the funnel's own `downloadClicked` / `consentedDownload` steps. + * `explicitlyRequested` distinguishes a setup UI request from first-use and + * standing-consent downloads. */ export function reportAgentSdkDownload( telemetryService: ITelemetryService, diff --git a/src/vs/platform/agentHost/node/agentSdkDownloader.ts b/src/vs/platform/agentHost/node/agentSdkDownloader.ts index 1d59ac95cb129c..9fb70299e9578f 100644 --- a/src/vs/platform/agentHost/node/agentSdkDownloader.ts +++ b/src/vs/platform/agentHost/node/agentSdkDownloader.ts @@ -23,6 +23,7 @@ import { IProductService } from '../../product/common/productService.js'; import { IRequestService } from '../../request/common/request.js'; import { ITelemetryService } from '../../telemetry/common/telemetry.js'; import { IRequestContext } from '../../../base/parts/request/common/request.js'; +import { IAgentHostStorageService } from './agentHostStorageService.js'; import { reportAgentSdkDownload } from './agentSdkDownloadTelemetry.js'; // #region Per-package strategy @@ -164,6 +165,12 @@ export interface IAgentSdkDownloader { */ readonly onDidDownloadProgress: Event; + /** Whether the user has accepted future download-on-use for this package. */ + hasDownloadConsent(pkg: IAgentSdkPackage): boolean; + + /** Persist that the user accepted future download-on-use for this package. */ + recordDownloadConsent(pkg: IAgentSdkPackage): Promise; + /** * Keep download progress visible for a user-initiated flow that does not * have a session progress token, such as ChatGPT sign-in. Dispose the @@ -217,6 +224,7 @@ export interface IAgentSdkDownloader { /** How long a `loadSdkRoot` failure latches before we try again. */ const LOAD_FAILURE_NEGATIVE_CACHE_MS = 30_000; +const AGENT_SDK_DOWNLOAD_CONSENT_STORAGE_KEY = 'agentSdkDownloadConsent'; /** * Minimum gap between download-progress samples. A 70-95MB tarball over a fast @@ -276,6 +284,7 @@ export class AgentSdkDownloader extends Disposable implements IAgentSdkDownloade @IFileService private readonly _fileService: IFileService, @ILogService private readonly _logService: ILogService, @ITelemetryService private readonly _telemetryService: ITelemetryService, + @IAgentHostStorageService private readonly _storageService: IAgentHostStorageService, ) { super(); } @@ -287,6 +296,22 @@ export class AgentSdkDownloader extends Disposable implements IAgentSdkDownloade return !!this._productService.agentSdks?.[pkg.id] && resolveSdkTarget(pkg) !== undefined; } + hasDownloadConsent(pkg: IAgentSdkPackage): boolean { + return this._readDownloadConsents().includes(pkg.id); + } + + async recordDownloadConsent(pkg: IAgentSdkPackage): Promise { + const consented = this._readDownloadConsents(); + if (!consented.includes(pkg.id)) { + await this._storageService.setAndFlush(AGENT_SDK_DOWNLOAD_CONSENT_STORAGE_KEY, [...consented, pkg.id]); + } + } + + private _readDownloadConsents(): readonly string[] { + const stored = this._storageService.get(AGENT_SDK_DOWNLOAD_CONSENT_STORAGE_KEY); + return Array.isArray(stored) ? stored.filter(packageId => typeof packageId === 'string') : []; + } + acquireDownloadProgressInterest(pkg: IAgentSdkPackage): IDisposable { this._explicitProgressInterest.set(pkg.id, (this._explicitProgressInterest.get(pkg.id) ?? 0) + 1); return toDisposable(() => { diff --git a/src/vs/platform/agentHost/node/agentSdkSetupChannel.ts b/src/vs/platform/agentHost/node/agentSdkSetupChannel.ts index 3e04c540b15a17..812bed928546af 100644 --- a/src/vs/platform/agentHost/node/agentSdkSetupChannel.ts +++ b/src/vs/platform/agentHost/node/agentSdkSetupChannel.ts @@ -3,11 +3,12 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { Sequencer } from '../../../base/common/async.js'; import { Disposable } from '../../../base/common/lifecycle.js'; import { ILogService } from '../../log/common/log.js'; import { AGENT_SDK_SETUP_DOWNLOAD_REQUEST_KEY, AGENT_SDK_SETUP_RELOAD_REQUEST_KEY, AgentSdkDownloadStatus, IAgentSdkSetupInfo, agentSdkSetupStatusKey, isAgentSdkSetupRequestFor } from '../common/agentSdkSetup.js'; import { IAgentConfigurationService } from './agentConfigurationService.js'; -import { IAgentSdkDownloader, IAgentSdkPackage } from './agentSdkDownloader.js'; +import { IAgentSdkDownloader, type IAgentSdkDownloadProgress, type IAgentSdkPackage } from './agentSdkDownloader.js'; /** The per-agent half of {@link AgentSdkSetupChannel}. */ export interface IAgentSdkSetupChannelAgent { @@ -21,7 +22,7 @@ export interface IAgentSdkSetupChannelAgent { /** Whether the SDK can be loaded without a network fetch. */ isSdkLocal(): Promise; - /** Fetch the SDK. Only ever called for the explicit gesture. */ + /** Fetch the SDK. */ downloadSdk(): Promise; /** Restart chat discovery, which defers itself while there is no SDK to read a catalog from. */ @@ -33,24 +34,16 @@ export interface IAgentSdkSetupChannelAgent { /** * One agent's side of the SDK setup channel: publishes whether its SDK is on - * disk, performs the download the workbench asks for, and looks again when it - * asks for that. Every agent needs the same nonce handling, latching and publish - * ordering, so only the calls in {@link IAgentSdkSetupChannelAgent} differ. + * disk, tracks first-use and explicit downloads, and handles setup requests. */ export class AgentSdkSetupChannel extends Disposable { /** Consumed request nonce per request key, so a root-config change we caused isn't re-handled. */ private readonly _lastRequests = new Map(); + private readonly _operations = new Sequencer(); - /** - * Latched while the *explicit* download runs. {@link IAgentSdkSetupChannelAgent.isSdkLocal} - * stays false throughout, so without this the channel could only ever report - * `notDownloaded` and the banner would keep offering a button for work already - * underway. Deliberately not a query on the downloader, which would also latch - * for background fetches — those are the ones the user never asked for and so - * must stay invisible. - */ - private _downloadInFlight = false; + private _downloadActivity: 'idle' | 'explicit' | 'lazy' = 'idle'; + private _downloadFailed = false; constructor( private readonly _agent: IAgentSdkSetupChannelAgent, @@ -62,36 +55,80 @@ export class AgentSdkSetupChannel extends Disposable { // The workbench addresses the agent through the root config bag. The key is // cleared as it is consumed so a later identical press still lands. this._register(this._configurationService.onDidRootConfigChange(() => this._handleRequest())); - queueMicrotask(() => { void this.publish(); }); + this._register(this._downloader.onDidDownloadProgress(progress => this._handleDownloadProgress(progress))); + this.refresh(); } - /** Publish the current status, paying for the is-local probe. */ - async publish(): Promise { - this.publishWith(await this._agent.isSdkLocal()); + /** Recompute the host's SDK status after an outside state change. */ + refresh(): void { + this._queue('failed to refresh agent SDK setup', async () => this._publish(await this._agent.isSdkLocal())); } - /** The synchronous half, for callers that have just paid for the probe. */ - publishWith(sdkIsLocal: boolean): void { - const download: AgentSdkDownloadStatus = this._downloadInFlight + private _publish(sdkIsLocal: boolean): void { + const download: AgentSdkDownloadStatus = this._downloadActivity !== 'idle' ? 'downloading' - : sdkIsLocal ? 'ready' : 'notDownloaded'; + : sdkIsLocal + ? 'ready' + : this._hasDownloadConsent() && !this._downloadFailed ? 'downloadOnUse' : 'notDownloaded'; const info: Omit = { ...this._agent.setupInfo, download }; this._configurationService.publishRootTransientValues?.({ [agentSdkSetupStatusKey(this._agent.id)]: info }); } + private _handleDownloadProgress(progress: IAgentSdkDownloadProgress): void { + if (progress.packageId !== this._agent.sdkPackage.id) { + return; + } + switch (progress.phase) { + case 'started': + if (this._downloadActivity === 'idle') { + this._downloadActivity = 'lazy'; + this._downloadFailed = false; + this._publish(false); + this._queue('failed to record agent SDK download consent', () => this._recordDownloadConsent()); + } + break; + case 'completed': + if (this._downloadActivity === 'lazy') { + this._queue('refresh after agent SDK download failed', () => this._finishLazyDownload()); + } + break; + case 'failed': + if (this._downloadActivity === 'lazy') { + this._downloadActivity = 'idle'; + this._downloadFailed = true; + this._publish(false); + } + break; + } + } + private _handleRequest(): void { const values = this._configurationService.getRootConfigValues?.() ?? {}; if (this._takeRequest(values, AGENT_SDK_SETUP_DOWNLOAD_REQUEST_KEY)) { - void this._download(); + if (this._downloadActivity === 'idle') { + this._downloadActivity = 'explicit'; + this._downloadFailed = false; + this._publish(false); + this._queue('explicit agent SDK download failed', async () => { + await this._recordDownloadConsent(); + await this._download(); + }); + } } if (this._takeRequest(values, AGENT_SDK_SETUP_RELOAD_REQUEST_KEY)) { - this._logService.info(`[AgentSdkSetup] ${this._agent.id}: reloading the agent's configuration at the user's request`); - // Nothing to publish: the SDK is already on disk either way, and what the - // banner reads is the catalog the re-look republishes. - void this._lookAgain(); + this._queue('agent SDK configuration reload failed', async () => { + this._logService.info(`[AgentSdkSetup] ${this._agent.id}: reloading the agent's configuration at the user's request`); + await this._lookAgain(); + }); } } + private _queue(failureMessage: string, operation: () => Promise): void { + void this._operations.queue(operation).catch(error => { + this._logService.error(error, `[AgentSdkSetup] ${this._agent.id}: ${failureMessage}`); + }); + } + /** Claim one request addressed to this agent, clearing the key so a repeat press still lands. */ private _takeRequest(values: Readonly>, key: string): boolean { const request = values[key]; @@ -103,28 +140,51 @@ export class AgentSdkSetupChannel extends Disposable { return true; } - /** - * The explicit download gesture. Acquiring progress interest is what makes the - * fetch visible: the downloader only emits frames for a session that asked or an - * explicitly-registered interest, and this download belongs to no session. - */ + private _hasDownloadConsent(): boolean { + return this._downloader.hasDownloadConsent(this._agent.sdkPackage); + } + + private async _recordDownloadConsent(): Promise { + try { + await this._downloader.recordDownloadConsent(this._agent.sdkPackage); + } catch (error) { + this._logService.error(error, `[AgentSdkSetup] ${this._agent.id}: failed to persist agent SDK download consent`); + } + } + + /** Download requested explicitly through the setup UI. */ private async _download(): Promise { - if (this._downloadInFlight) { + if (this._downloadActivity !== 'explicit') { return; } const progressInterest = this._downloader.acquireDownloadProgressInterest(this._agent.sdkPackage); - this._downloadInFlight = true; - this.publishWith(false); + let downloaded = false; try { this._logService.info(`[AgentSdkSetup] ${this._agent.id}: downloading the agent SDK at the user's request`); - await this._agent.downloadSdk(); - } catch (error) { - this._logService.error(error, `[AgentSdkSetup] ${this._agent.id}: agent SDK download failed`); + try { + await this._agent.downloadSdk(); + downloaded = true; + } catch (error) { + this._downloadFailed = true; + this._logService.error(error, `[AgentSdkSetup] ${this._agent.id}: agent SDK download failed`); + } + if (downloaded) { + await this._lookAgain(); + } } finally { - this._downloadInFlight = false; + this._downloadActivity = 'idle'; progressInterest.dispose(); + this._publish(downloaded); + } + } + + private async _finishLazyDownload(): Promise { + try { + await this._lookAgain(); + } finally { + this._downloadActivity = 'idle'; + this._publish(true); } - await this._lookAgain(); } /** diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index c337fa86c62038..d0318b8694ac62 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -37,7 +37,7 @@ import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } f import { AhpErrorCodes, AHP_SESSION_NOT_FOUND, ContentEncoding, JSON_RPC_INTERNAL_ERROR, ProtocolError, ResourceChangeType, ResourceType, ResourceWriteMode, type CreateResourceWatchParams, type CreateResourceWatchResult, type DirectoryEntry, type ResourceCopyParams, type ResourceCopyResult, type ResourceDeleteParams, type ResourceDeleteResult, type ResourceListResult, type ResourceMkdirParams, type ResourceMkdirResult, type ResourceMoveParams, type ResourceMoveResult, type ResourceReadResult, type ResourceResolveParams, type ResourceResolveResult, type ResourceWatchState, type ResourceWriteParams, type ResourceWriteResult, type IStateSnapshot } from '../common/state/sessionProtocol.js'; import { ChangesSummary, ChatInteractivity, ChatOriginKind, MessageAttachmentKind, type Annotation, type AnnotationEntry, type AnnotationOrigin, type AnnotationsState, type ChatOrigin, type Customization, type Message, type MessageAttachment, type MessageResourceAttachment, type TextRange } from '../common/state/protocol/state.js'; import type { ChatPendingMessageSetAction, ChatTurnStartedAction, SessionConfigChangedAction } from '../common/state/protocol/actions.js'; -import { isAhpAutomationCatalogChannel, isAhpAutomationRunChannel, ISessionGitHubState, ISessionGitState, MessageKind, ResponsePartKind, SESSION_META_GITHUB_KEY, SESSION_META_GIT_KEY, SESSION_META_MULTI_ROOT_KEY, SESSION_META_SOURCE_CONTROL_KEY, AH_META_CREATED_BY_SESSION_DB_KEY, readSessionCreationReference, readSessionSpawnDepth, withSessionSpawnDepth, withSessionCreationReference, parseSessionCreationReference, SessionLifecycle, SessionStatus, ToolCallStatus, ToolResultContentType, TurnState, AH_META_WORKSPACE_CONVERSION_QUARANTINED_DB_KEY, AH_META_WORKSPACELESS_DB_KEY, AH_META_EHCLI_ADOPTED_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_DONE_DB_KEY, AH_META_IS_READ_DB_KEY, buildChatUri, buildDefaultChatUri, buildResourceWatchChannelUri, buildSubagentChatUri, buildSubagentSessionUriPrefix, getErrorResponsePart, isAhpChatChannel, isChatReadOnly, isDefaultChatUri, isSubagentChatUri, isSubagentSession, needsSessionGitStateRefresh, parseChatUri, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseResourceWatchChannelUri, parseSessionMultiRootMetadata, parseSubagentSessionUri, readSessionExternal, readSessionGitHubState, readSessionGitState, readSessionMultiRootMetadata, readSessionSourceControlState, readSessionWorkspaceless, withMessageRequestHiddenFromTranscript, withSessionExternal, withSessionGitHubState, withSessionGitState, withSessionMultiRootMetadata, withSessionSourceControlState, withSessionStatusFlag, withSessionWorkspaceless, withSessionEhcliAdopted, withSessionEhcliLastMigratedTurn, AH_META_EHCLI_LAST_TURN_DB_KEY, withSessionFolderPickerDecision, readSessionFolderPickerDecision, parseSessionFolderPickerDecision, SESSION_META_FOLDER_PICKER_KEY, readSessionEhcliAdoptable, type ISessionSourceControlState, type SessionConfigState, type SessionSummary, type ToolResultSubagentContent, type Turn } from '../common/state/sessionState.js'; +import { isAhpAutomationCatalogChannel, isAhpAutomationRunChannel, ISessionGitHubState, ISessionGitState, MessageKind, ResponsePartKind, SESSION_META_GITHUB_KEY, SESSION_META_GIT_KEY, SESSION_META_MULTI_ROOT_KEY, SESSION_META_SOURCE_CONTROL_KEY, AH_META_CREATED_BY_SESSION_DB_KEY, readSessionCreationReference, readSessionSpawnDepth, withSessionSpawnDepth, withSessionCreationReference, parseSessionCreationReference, SessionLifecycle, SessionStatus, ToolCallStatus, ToolResultContentType, TurnState, AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY, AH_META_WORKSPACE_CONVERSION_QUARANTINED_DB_KEY, AH_META_WORKSPACELESS_DB_KEY, AH_META_EHCLI_ADOPTED_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_DONE_DB_KEY, AH_META_IS_READ_DB_KEY, buildChatUri, buildDefaultChatUri, buildResourceWatchChannelUri, buildSubagentChatUri, buildSubagentSessionUriPrefix, getErrorResponsePart, isAhpChatChannel, isChatReadOnly, isDefaultChatUri, isSubagentChatUri, isSubagentSession, needsSessionGitStateRefresh, parseChatUri, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseResourceWatchChannelUri, parseSessionMultiRootMetadata, parseSubagentSessionUri, readSessionExternal, readSessionGitHubState, readSessionGitState, readSessionHasWorkspaceTransitions, readSessionMultiRootMetadata, readSessionSourceControlState, readSessionWorkspaceless, withMessageRequestHiddenFromTranscript, withSessionExternal, withSessionGitHubState, withSessionGitState, withSessionHasWorkspaceTransitions, withSessionMultiRootMetadata, withSessionSourceControlState, withSessionStatusFlag, withSessionWorkspaceless, withSessionEhcliAdopted, withSessionEhcliLastMigratedTurn, AH_META_EHCLI_LAST_TURN_DB_KEY, withSessionFolderPickerDecision, readSessionFolderPickerDecision, parseSessionFolderPickerDecision, SESSION_META_FOLDER_PICKER_KEY, readSessionEhcliAdoptable, type ISessionSourceControlState, type SessionConfigState, type SessionSummary, type ToolResultSubagentContent, type Turn } from '../common/state/sessionState.js'; import { readToolCallMeta } from '../common/meta/agentToolCallMeta.js'; import { isHostSnapshotAttachment, toHostSnapshotAttachmentMeta } from '../common/meta/agentSnapshotAttachmentMeta.js'; import { readEphemeralSessionMeta, withEphemeralSessionMeta } from '../common/meta/agentEphemeralSessionMeta.js'; @@ -2215,8 +2215,8 @@ export class AgentService extends Disposable implements IAgentService { const sessionStr = s.session.toString(); const changesetKeys = this._changesetCoordinator.getListMetadataKeys(sessionStr); const metadataKeys: Record = changesetKeys - ? { customTitle: true, [AH_META_IS_READ_DB_KEY]: true, [AH_META_IS_ARCHIVED_DB_KEY]: true, [AH_META_IS_DONE_DB_KEY]: true, [AH_META_CREATED_BY_SESSION_DB_KEY]: true, [AH_META_DEV_CONTAINER_WORKTREE_DB_KEY]: true, [AH_META_WORKSPACELESS_DB_KEY]: true, [AH_META_EHCLI_ADOPTED_DB_KEY]: true, [AH_META_EHCLI_LAST_TURN_DB_KEY]: true, [SESSION_META_MULTI_ROOT_KEY]: true, [SESSION_META_FOLDER_PICKER_KEY]: true, [SESSION_ARTIFACTS_KEY]: true, [CHAT_BACKING_METADATA_KEY]: true, [WORKTREE_META_REPOSITORY_ROOT]: true, ...GIT_DB_METADATA_KEYS, ...changesetKeys } - : { customTitle: true, [AH_META_IS_READ_DB_KEY]: true, [AH_META_IS_ARCHIVED_DB_KEY]: true, [AH_META_IS_DONE_DB_KEY]: true, [AH_META_CREATED_BY_SESSION_DB_KEY]: true, [AH_META_DEV_CONTAINER_WORKTREE_DB_KEY]: true, [AH_META_WORKSPACELESS_DB_KEY]: true, [AH_META_EHCLI_ADOPTED_DB_KEY]: true, [AH_META_EHCLI_LAST_TURN_DB_KEY]: true, [SESSION_META_MULTI_ROOT_KEY]: true, [SESSION_META_FOLDER_PICKER_KEY]: true, [SESSION_ARTIFACTS_KEY]: true, [CHAT_BACKING_METADATA_KEY]: true, [WORKTREE_META_REPOSITORY_ROOT]: true, ...GIT_DB_METADATA_KEYS }; + ? { customTitle: true, [AH_META_IS_READ_DB_KEY]: true, [AH_META_IS_ARCHIVED_DB_KEY]: true, [AH_META_IS_DONE_DB_KEY]: true, [AH_META_CREATED_BY_SESSION_DB_KEY]: true, [AH_META_DEV_CONTAINER_WORKTREE_DB_KEY]: true, [AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY]: true, [AH_META_WORKSPACELESS_DB_KEY]: true, [AH_META_EHCLI_ADOPTED_DB_KEY]: true, [AH_META_EHCLI_LAST_TURN_DB_KEY]: true, [SESSION_META_MULTI_ROOT_KEY]: true, [SESSION_META_FOLDER_PICKER_KEY]: true, [SESSION_ARTIFACTS_KEY]: true, [CHAT_BACKING_METADATA_KEY]: true, [WORKTREE_META_REPOSITORY_ROOT]: true, ...GIT_DB_METADATA_KEYS, ...changesetKeys } + : { customTitle: true, [AH_META_IS_READ_DB_KEY]: true, [AH_META_IS_ARCHIVED_DB_KEY]: true, [AH_META_IS_DONE_DB_KEY]: true, [AH_META_CREATED_BY_SESSION_DB_KEY]: true, [AH_META_DEV_CONTAINER_WORKTREE_DB_KEY]: true, [AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY]: true, [AH_META_WORKSPACELESS_DB_KEY]: true, [AH_META_EHCLI_ADOPTED_DB_KEY]: true, [AH_META_EHCLI_LAST_TURN_DB_KEY]: true, [SESSION_META_MULTI_ROOT_KEY]: true, [SESSION_META_FOLDER_PICKER_KEY]: true, [SESSION_ARTIFACTS_KEY]: true, [CHAT_BACKING_METADATA_KEY]: true, [WORKTREE_META_REPOSITORY_ROOT]: true, ...GIT_DB_METADATA_KEYS }; const m = await ref.object.getMetadataObject(metadataKeys); // This session is an internal peer-chat backing (e.g. a // Claude peer chat's SDK session, enumerated by the agent's @@ -2242,6 +2242,9 @@ export class AgentService extends Disposable implements IAgentService { if (creationReference) { updated = { ...updated, _meta: withSessionCreationReference(updated._meta, creationReference) }; } + if (m[AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY] !== undefined) { + updated = { ...updated, _meta: withSessionHasWorkspaceTransitions(updated._meta, m[AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY] === 'true') }; + } if (m[AH_META_DEV_CONTAINER_WORKTREE_DB_KEY]) { try { const metadata = readAgentDevContainerWorktreeMetadata({ @@ -3489,12 +3492,12 @@ export class AgentService extends Disposable implements IAgentService { * exactly that window; every other caller relies on the exhaustive origin * {@link _chatContext} stamps. */ - private async _getChatMessages(provider: IAgent, chat: URI, session: URI, origin?: ChatOrigin): Promise { + private async _getChatMessages(provider: IAgent, chat: URI, session: URI, origin?: ChatOrigin, hasWorkspaceTransitions?: boolean): Promise { const context = { ...this._chatContext(session, chat), ...(origin ? { origin } : {}) }; this._logService.trace(`[AgentService] getChatMessages start: chat=${chat.toString()}`); const providerTurns = await provider.chats.getMessages(chat, context); this._logService.trace(`[AgentService] getChatMessages: provider returned ${providerTurns.length} turn(s) for chat=${chat.toString()}`); - return this._chatContributions.hydrateTurns({ session: session.toString(), chat: chat.toString() }, providerTurns); + return this._chatContributions.hydrateTurns({ session: session.toString(), chat: chat.toString(), hasWorkspaceTransitions }, providerTurns); } /** @@ -5497,17 +5500,6 @@ export class AgentService extends Disposable implements IAgentService { if (providerData === undefined && materializedDefaultChat?.providerData === undefined) { this._logService.warn(`[AgentService] Restoring default chat ${defaultChatUri.toString()} with no persisted or recovered provider backing (agent=${agent.id})`); } - let turns: readonly Turn[]; - try { - turns = await this._getChatMessages(agent, defaultChatUri, session); - } catch (err) { - if (err instanceof ProtocolError) { - throw err; - } - const message = err instanceof Error ? err.message : String(err); - throw new ProtocolError(JSON_RPC_INTERNAL_ERROR, `Failed to restore session ${sessionStr}: ${message}`); - } - // Check for persisted metadata in the session database let title = meta.summary ?? 'Session'; let isRead: boolean | undefined; @@ -5517,6 +5509,7 @@ export class AgentService extends Disposable implements IAgentService { let gitMetadata: Record | undefined; let changesetMetadata: Record | undefined; let sessionMetadata: Record | undefined; + let sessionMetadataRead = false; const ref = this._sessionDataService.tryOpenDatabase?.(session); if (ref) { try { @@ -5529,6 +5522,7 @@ export class AgentService extends Disposable implements IAgentService { [AH_META_IS_ARCHIVED_DB_KEY]: true, [AH_META_IS_DONE_DB_KEY]: true, configValues: true, + [AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY]: true, [AH_META_WORKSPACELESS_DB_KEY]: true, [AH_META_EHCLI_ADOPTED_DB_KEY]: true, [AH_META_EHCLI_LAST_TURN_DB_KEY]: true, @@ -5540,6 +5534,7 @@ export class AgentService extends Disposable implements IAgentService { ...GIT_DB_METADATA_KEYS, ...CHANGESET_DB_METADATA_KEYS, }); + sessionMetadataRead = true; if (m.customTitle) { title = m.customTitle; } @@ -5594,6 +5589,9 @@ export class AgentService extends Disposable implements IAgentService { if (m[AH_META_WORKSPACELESS_DB_KEY] !== undefined) { sessionMetadata = withSessionWorkspaceless(sessionMetadata, m[AH_META_WORKSPACELESS_DB_KEY] === 'true'); } + if (m[AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY] !== undefined) { + sessionMetadata = withSessionHasWorkspaceTransitions(sessionMetadata, m[AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY] === 'true'); + } if (m[AH_META_EHCLI_ADOPTED_DB_KEY] !== undefined) { sessionMetadata = withSessionEhcliAdopted(sessionMetadata, m[AH_META_EHCLI_ADOPTED_DB_KEY] === 'true'); } @@ -5630,6 +5628,8 @@ export class AgentService extends Disposable implements IAgentService { } finally { db.dispose(); } + } else { + sessionMetadataRead = true; } } catch { // Best-effort: fall back to agent-provided metadata @@ -5637,6 +5637,17 @@ export class AgentService extends Disposable implements IAgentService { } this._logService.trace(`[AgentService] restore: persisted session metadata read for ${sessionStr}`); + let turns: readonly Turn[]; + try { + turns = await this._getChatMessages(agent, defaultChatUri, session, undefined, sessionMetadataRead ? readSessionHasWorkspaceTransitions(sessionMetadata) : undefined); + } catch (err) { + if (err instanceof ProtocolError) { + throw err; + } + const message = err instanceof Error ? err.message : String(err); + throw new ProtocolError(JSON_RPC_INTERNAL_ERROR, `Failed to restore session ${sessionStr}: ${message}`); + } + // Encode isRead/isArchived as status bitmask flags let status: SessionStatus = SessionStatus.Idle; if (isRead) { diff --git a/src/vs/platform/agentHost/node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionContribution.ts b/src/vs/platform/agentHost/node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionContribution.ts index 4e23de05aa6007..70e2c57a3529da 100644 --- a/src/vs/platform/agentHost/node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionContribution.ts +++ b/src/vs/platform/agentHost/node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionContribution.ts @@ -4,8 +4,15 @@ *--------------------------------------------------------------------------------------------*/ import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { URI } from '../../../../../base/common/uri.js'; import { localize } from '../../../../../nls.js'; -import type { IAgentHostChatContribution, IAgentHostChatContributionContext, IIncomingRequest, IncomingRequestDisposition, ITurnEnd } from '../../../common/agentHostChatContributionsService.js'; +import { ILogService } from '../../../../log/common/log.js'; +import type { IAgentHostChatContribution, IAgentHostChatContributionContext, IHydrationContext, IIncomingRequest, IncomingRequestDisposition, ITurnEnd } from '../../../common/agentHostChatContributionsService.js'; +import { parseAgentWorkspaceTransition, AgentSystemNotificationKind, readAgentSystemNotificationMeta, toAgentSystemNotificationMeta } from '../../../common/meta/agentSystemNotificationMeta.js'; +import { toAgentWorkspaceContinuationMessageMeta } from '../../../common/meta/agentWorkspaceContinuationMeta.js'; +import { ISessionDataService } from '../../../common/sessionDataService.js'; +import { chatStorageUri, readSessionHasWorkspaceTransitions, ResponsePartKind, withMessageRequestHiddenFromTranscript, type Turn } from '../../../common/state/sessionState.js'; +import { AgentHostStateManager, IAgentHostStateManager } from '../../agentHostStateManager.js'; import { ISessionWorkspaceConversionService } from './sessionWorkspaceConversionService.js'; /** Finalizes requested workspace conversions after a turn and blocks new turns while conversion is pending. */ @@ -17,6 +24,9 @@ export class SessionWorkspaceConversionContribution extends Disposable implement constructor( protected readonly _context: IAgentHostChatContributionContext, @ISessionWorkspaceConversionService private readonly _conversionService: ISessionWorkspaceConversionService, + @IAgentHostStateManager private readonly _stateManager: AgentHostStateManager, + @ISessionDataService private readonly _sessionDataService: ISessionDataService, + @ILogService private readonly _logService: ILogService, ) { super(); } @@ -42,4 +52,78 @@ export class SessionWorkspaceConversionContribution extends Disposable implement stage: 'validation', }; } + + async onHydrateTurns(context: IHydrationContext, turns: readonly Turn[]): Promise { + if (turns.length === 0) { + return turns; + } + if (context.hasWorkspaceTransitions === false) { + return turns; + } + if (context.hasWorkspaceTransitions !== true) { + const summary = this._stateManager.getSessionSummary(context.session); + if (summary && !readSessionHasWorkspaceTransitions(summary._meta)) { + return turns; + } + } + const storage = chatStorageUri(URI.parse(context.chat)); + if (!storage) { + return turns; + } + const database = await this._sessionDataService.tryOpenDatabase(storage); + if (!database) { + return turns; + } + let transitions: Map; + try { + transitions = await database.object.getTurnWorkspaceTransitions(); + } catch (error) { + this._logService.warn(`[SessionWorkspaceConversionContribution] Failed to restore workspace transitions for ${storage.toString()}`, error); + return turns; + } finally { + database.dispose(); + } + if (transitions.size === 0) { + return turns; + } + return turns.map(turn => { + const rawTransition = transitions.get(turn.id); + if (!rawTransition) { + return turn; + } + const transition = parseAgentWorkspaceTransition(rawTransition); + if (!transition) { + return turn; + } + const transitionPart = { + kind: ResponsePartKind.SystemNotification, + content: transition.content, + _meta: toAgentSystemNotificationMeta({ + kind: AgentSystemNotificationKind.WorkspaceTransition, + workspaceKind: transition.workspaceKind, + workspaceName: transition.workspaceName, + }), + } as const; + const existingIndex = turn.responseParts.findIndex(part => + part.kind === ResponsePartKind.SystemNotification + && readAgentSystemNotificationMeta(part).kind === AgentSystemNotificationKind.WorkspaceTransition + ); + const responseParts = existingIndex < 0 + ? [transitionPart, ...turn.responseParts] + : existingIndex === 0 + ? turn.responseParts + : [turn.responseParts[existingIndex], ...turn.responseParts.slice(0, existingIndex), ...turn.responseParts.slice(existingIndex + 1)]; + return { + ...turn, + message: withMessageRequestHiddenFromTranscript({ + ...turn.message, + _meta: { + ...turn.message._meta, + ...toAgentWorkspaceContinuationMessageMeta(), + }, + }, true), + responseParts, + }; + }); + } } diff --git a/src/vs/platform/agentHost/node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionService.ts b/src/vs/platform/agentHost/node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionService.ts index 604699116fd22f..5502497dbc6b64 100644 --- a/src/vs/platform/agentHost/node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionService.ts +++ b/src/vs/platform/agentHost/node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionService.ts @@ -7,18 +7,22 @@ import { toErrorMessage } from '../../../../../base/common/errorMessage.js'; import { Disposable } from '../../../../../base/common/lifecycle.js'; import { Schemas } from '../../../../../base/common/network.js'; import { equals } from '../../../../../base/common/objects.js'; -import { isEqual } from '../../../../../base/common/resources.js'; +import { basename, isEqual } from '../../../../../base/common/resources.js'; import { URI } from '../../../../../base/common/uri.js'; import { localize } from '../../../../../nls.js'; import { createDecorator } from '../../../../instantiation/common/instantiation.js'; import { ILogService } from '../../../../log/common/log.js'; import { AgentSession, AgentWorkingDirectoryChangedError, type IAgent, type IAgentSessionProjectInfo } from '../../../common/agent.js'; +import { AgentHostGlobalAutoApproveEnabledConfigKey, platformRootSchema, platformSessionSchema } from '../../../common/agentHostSchema.js'; +import { toAgentWorkspaceContinuationMessageMeta } from '../../../common/meta/agentWorkspaceContinuationMeta.js'; import { ISessionDataService } from '../../../common/sessionDataService.js'; import { SessionConfigKey } from '../../../common/sessionConfigKeys.js'; +import { AgentSystemNotificationKind, AgentSystemNotificationWorkspaceKind, serializeAgentWorkspaceTransition, type IAgentSystemNotificationMeta, type IAgentWorkspaceTransitionRecord, toAgentSystemNotificationMeta } from '../../../common/meta/agentSystemNotificationMeta.js'; import { ActionType } from '../../../common/state/sessionActions.js'; -import { AH_META_WORKSPACE_CONVERSION_QUARANTINED_DB_KEY, AH_META_WORKSPACELESS_DB_KEY, buildDefaultChatUri, isDefaultChatUri, MessageKind, parseChatUri, readSessionWorkspaceless, ResponsePartKind, SessionStatus, withMessageSystemInitiatedLabel, withSessionWorkspaceless, type ISessionWithDefaultChat, type SessionConfigState, type URI as ProtocolURI } from '../../../common/state/sessionState.js'; +import { AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY, AH_META_WORKSPACE_CONVERSION_QUARANTINED_DB_KEY, AH_META_WORKSPACELESS_DB_KEY, buildDefaultChatUri, chatStorageUri, isDefaultChatUri, MessageKind, parseChatUri, readSessionWorkspaceless, ResponsePartKind, SessionStatus, withMessageRequestHiddenFromTranscript, withMessageSystemInitiatedLabel, withSessionHasWorkspaceTransitions, withSessionWorkspaceless, type ISessionWithDefaultChat, type SessionConfigState, type URI as ProtocolURI } from '../../../common/state/sessionState.js'; import { AgentHostStateManager, IAgentHostStateManager } from '../../agentHostStateManager.js'; import { IAgentHostClientConnectionService } from '../../agentHostClientConnectionService.js'; +import { IAgentConfigurationService } from '../../agentConfigurationService.js'; import { IAgentHostProviderService } from '../../agentHostProviderService.js'; import { IAgentHostTurnService, type IDeferredAgentHostTurn } from '../../agentHostTurnService.js'; import { IAgentHostServerToolService } from '../../shared/agentServerToolHost.js'; @@ -33,6 +37,8 @@ interface IPendingSessionWorkspaceConversion { readonly prompt: string | undefined; phase: 'requested' | 'converting'; resolvedWorkingDirectory?: URI; + transition?: IAgentWorkspaceTransitionRecord; + transitionPersisted?: boolean; } interface IResolvedWorkspace { @@ -70,6 +76,7 @@ export class SessionWorkspaceConversionService extends Disposable implements ISe @IAgentHostProviderService private readonly _providerService: IAgentHostProviderService, @ISessionDataService private readonly _sessionDataService: ISessionDataService, @IAgentHostWorktreeIsolation private readonly _worktreeIsolation: IAgentHostWorktreeIsolation, + @IAgentConfigurationService private readonly _configurationService: IAgentConfigurationService, @IAgentHostClientConnectionService private readonly _clientConnections: IAgentHostClientConnectionService, @IAgentHostTurnService private readonly _turnService: IAgentHostTurnService, @IAgentHostServerToolService private readonly _serverToolHost: IAgentHostServerToolService, @@ -121,30 +128,46 @@ export class SessionWorkspaceConversionService extends Disposable implements ISe let continuation: IDeferredAgentHostTurn | undefined; try { continuation = this._beginContinuation(pending); - pending.resolvedWorkingDirectory = await this._convert(pending.chat, pending.workspaceFolder, pending.isolation, pending.initiatingClientId, pending.prompt); + pending.transition = this._createWorkspaceTransition(pending); + pending.resolvedWorkingDirectory = await this._convert(pending, continuation); this._pending.delete(chat); - this._continueConversion(continuation, pending, true); + await this._continueConversion(continuation, pending, true); } catch (error) { - this._logService.error(`[SessionWorkspaceConversionService] Failed to convert ${pending.chat.toString()}: ${toErrorMessage(error)}`); - if (error instanceof UnsafeProviderWorkingDirectoryError) { + let conversionError = error; + if (pending.transitionPersisted && continuation) { + try { + await this._discardPersistedWorkspaceTransition(pending, continuation); + } catch (discardError) { + conversionError = new UnsafeProviderWorkingDirectoryError(`${toErrorMessage(error)}; failed to discard the workspace transition: ${toErrorMessage(discardError)}`); + } + } + this._logService.error(`[SessionWorkspaceConversionService] Failed to convert ${pending.chat.toString()}: ${toErrorMessage(conversionError)}`); + if (conversionError instanceof UnsafeProviderWorkingDirectoryError) { this._pending.delete(chat); this._quarantined.add(chat); - this._failConversion(continuation, pending, error); + this._failConversion(continuation, pending, conversionError); } else { this._pending.delete(chat); - this._continueConversion(continuation, pending, false, error); + await this._continueConversion(continuation, pending, false, conversionError); } } } - private async _convert(chat: URI, workspaceFolder: URI, isolation: boolean, initiatingClientId: string, prompt?: string): Promise { + private async _convert(pending: IPendingSessionWorkspaceConversion, continuation: IDeferredAgentHostTurn): Promise { + const { chat, workspaceFolder, isolation, initiatingClientId, prompt, transition } = pending; + if (!transition) { + throw new Error('Cannot convert a session without a workspace transition.'); + } const { session, state, previousWorkingDirectory } = this._validateConversion(chat, workspaceFolder); const provider = this._providerService.getProviderForSession(session); if (!provider?.agentHostCapabilities.workspaceConversion) { throw new Error(`Provider does not support changing the working directory: ${AgentSession.provider(session) ?? '(unknown)'}`); } - await this._requireWorkspaceTrust(initiatingClientId, workspaceFolder); - const resolvedWorkspace = await this._resolveWorkspace(session, chat, workspaceFolder, isolation, initiatingClientId, prompt, state.config?.values); + const sessionKey = session.toString(); + const workspaceTrustRequired = this._configurationService.getRootValue(platformRootSchema, AgentHostGlobalAutoApproveEnabledConfigKey) !== true + && (this._configurationService.getEffectiveValue(sessionKey, platformSessionSchema, SessionConfigKey.AutoApprove) ?? 'default') !== 'autoApprove'; + await this._requireWorkspaceTrust(workspaceTrustRequired, initiatingClientId, workspaceFolder); + const resolvedWorkspace = await this._resolveWorkspace(session, chat, workspaceFolder, isolation, workspaceTrustRequired, initiatingClientId, prompt, state.config?.values); let authoritativeWorkingDirectory = resolvedWorkspace.workingDirectory; let providerAlignmentError: AgentWorkingDirectoryChangedError | undefined; try { @@ -162,7 +185,7 @@ export class SessionWorkspaceConversionService extends Disposable implements ISe } if (!isEqual(authoritativeWorkingDirectory, resolvedWorkspace.workingDirectory)) { try { - await this._requireWorkspaceTrust(initiatingClientId, authoritativeWorkingDirectory); + await this._requireWorkspaceTrust(workspaceTrustRequired, initiatingClientId, authoritativeWorkingDirectory); } catch (error) { const finalizationErrors = [error]; const disposal = await this._disposeUnsafeProviderChat(provider, chat, session); @@ -201,13 +224,19 @@ export class SessionWorkspaceConversionService extends Disposable implements ISe ? { ...convertedState.config?.values, ...configPatch } : undefined; let persistenceError: unknown; + const persistTransition = this._stateManager.getActiveTurnId(chat.toString()) === continuation.turnId; const database = this._sessionDataService.openDatabase(session); try { const metadata = { [AH_META_WORKSPACELESS_DB_KEY]: 'false' }; if (configValues) { Object.assign(metadata, { configValues: JSON.stringify(configValues) }); } - await database.object.setMetadataValues(metadata); + if (persistTransition) { + await database.object.setWorkspaceConversion(continuation.turnId, serializeAgentWorkspaceTransition(transition), metadata); + pending.transitionPersisted = true; + } else { + await database.object.setMetadataValues(metadata); + } } catch (error) { persistenceError = error; } finally { @@ -241,7 +270,10 @@ export class SessionWorkspaceConversionService extends Disposable implements ISe displayName: resolvedWorkspace.project.displayName, }); } - this._stateManager.setSessionMeta(session.toString(), withSessionWorkspaceless(finalState._meta, false)); + this._stateManager.setSessionMeta( + session.toString(), + withSessionHasWorkspaceTransitions(withSessionWorkspaceless(finalState._meta, false), persistTransition), + ); this._stateManager.dispatchServerAction(session.toString(), { type: ActionType.SessionWorkingDirectoryReplaced, directory: previousWorkingDirectory, @@ -271,7 +303,10 @@ export class SessionWorkspaceConversionService extends Disposable implements ISe return authoritativeWorkingDirectory; } - private async _requireWorkspaceTrust(clientId: string, workspace: URI, trustedParent?: URI): Promise { + private async _requireWorkspaceTrust(required: boolean, clientId: string, workspace: URI, trustedParent?: URI): Promise { + if (!required) { + return; + } const trusted = await this._clientConnections.requestWorkspaceTrust(clientId, { workspace: workspace.toString(), ...(trustedParent ? { trustedParent: trustedParent.toString() } : {}), @@ -317,6 +352,7 @@ export class SessionWorkspaceConversionService extends Disposable implements ISe chat: URI, workspaceFolder: URI, isolation: boolean, + workspaceTrustRequired: boolean, initiatingClientId: string, prompt: string | undefined, currentConfig: Record | undefined, @@ -355,9 +391,9 @@ export class SessionWorkspaceConversionService extends Disposable implements ISe prompt, onWillCreate: async metadata => { if (!isEqual(metadata.repositoryRoot, workspaceFolder)) { - await this._requireWorkspaceTrust(initiatingClientId, metadata.repositoryRoot); + await this._requireWorkspaceTrust(workspaceTrustRequired, initiatingClientId, metadata.repositoryRoot); } - await this._requireWorkspaceTrust(initiatingClientId, metadata.worktreePath, metadata.repositoryRoot); + await this._requireWorkspaceTrust(workspaceTrustRequired, initiatingClientId, metadata.worktreePath, metadata.repositoryRoot); }, }); if (!workingDirectory || isEqual(workingDirectory, workspaceFolder)) { @@ -460,14 +496,24 @@ export class SessionWorkspaceConversionService extends Disposable implements ISe } private _beginContinuation(pending: IPendingSessionWorkspaceConversion): IDeferredAgentHostTurn { - const continuation = this._turnService.beginDeferredTurnMessage(pending.chat, withMessageSystemInitiatedLabel({ + const continuation = this._turnService.beginDeferredTurnMessage(pending.chat, withMessageRequestHiddenFromTranscript(withMessageSystemInitiatedLabel({ text: localize('agentHost.continueInWorkspaceMessage', "Continue in the requested workspace."), origin: { kind: MessageKind.SystemNotification }, - }, localize('agentHost.continueInWorkspaceLabel', "Continue in Requested Workspace"))); + _meta: toAgentWorkspaceContinuationMessageMeta(), + }, localize('agentHost.continueInWorkspaceLabel', "Continue in Requested Workspace")), true)); return continuation; } - private _continueConversion(continuation: IDeferredAgentHostTurn | undefined, pending: IPendingSessionWorkspaceConversion, converted: boolean, error?: unknown): void { + private _createWorkspaceTransition(pending: IPendingSessionWorkspaceConversion): IAgentWorkspaceTransitionRecord { + const workspaceName = basename(pending.workspaceFolder) || pending.workspaceFolder.path; + return { + content: localize('agentHost.workspaceTransitionLabel', "Now working in {0}", workspaceName), + workspaceKind: pending.isolation ? AgentSystemNotificationWorkspaceKind.Worktree : AgentSystemNotificationWorkspaceKind.Folder, + workspaceName, + }; + } + + private async _continueConversion(continuation: IDeferredAgentHostTurn | undefined, pending: IPendingSessionWorkspaceConversion, converted: boolean, error?: unknown): Promise { if (!continuation) { this._logService.error(`[SessionWorkspaceConversionService] Cannot continue workspace conversion for ${pending.chat.toString()} because its deferred turn did not start.`); return; @@ -479,17 +525,64 @@ export class SessionWorkspaceConversionService extends Disposable implements ISe const label = converted ? localize('agentHost.workspaceSetLabel', "Workspace Set") : localize('agentHost.workspaceSetupFailedLabel', "Workspace Setup Failed"); - this._publishConversionOutcome(pending.chat, continuation, label); + if (converted && pending.transitionPersisted && pending.transition) { + this._publishConversionOutcome( + pending.chat, + continuation, + pending.transition.content, + { + kind: AgentSystemNotificationKind.WorkspaceTransition, + workspaceKind: pending.transition.workspaceKind, + workspaceName: pending.transition.workspaceName, + }, + ); + } else { + this._publishConversionOutcome(pending.chat, continuation, label); + } try { if (!this._turnService.continueDeferredTurnMessage(pending.chat, continuation, withMessageSystemInitiatedLabel({ text, origin: { kind: MessageKind.SystemNotification }, }, label))) { + await this._discardPersistedWorkspaceTransition(pending, continuation); this._logService.info(`[SessionWorkspaceConversionService] The deferred workspace conversion turn for ${pending.chat.toString()} ended before it could continue.`); } } catch (continuationError) { - this._logService.error(`[SessionWorkspaceConversionService] Failed to start the conversion continuation for ${pending.chat.toString()}: ${toErrorMessage(continuationError)}`); - this._failConversion(continuation, pending, continuationError instanceof Error ? continuationError : new Error(toErrorMessage(continuationError)), false); + let failure = continuationError; + try { + await this._discardPersistedWorkspaceTransition(pending, continuation); + } catch (discardError) { + failure = new Error(`${toErrorMessage(continuationError)}; failed to discard the workspace transition: ${toErrorMessage(discardError)}`); + } + this._logService.error(`[SessionWorkspaceConversionService] Failed to start the conversion continuation for ${pending.chat.toString()}: ${toErrorMessage(failure)}`); + this._failConversion(continuation, pending, failure instanceof Error ? failure : new Error(toErrorMessage(failure)), false); + } + } + + private async _discardPersistedWorkspaceTransition(pending: IPendingSessionWorkspaceConversion, continuation: IDeferredAgentHostTurn): Promise { + if (!pending.transitionPersisted) { + return; + } + await this._discardWorkspaceTransition(pending.chat, continuation.turnId); + pending.transitionPersisted = false; + } + + private async _discardWorkspaceTransition(chat: URI, turnId: string): Promise { + const storage = chatStorageUri(chat); + if (!storage) { + throw new Error(`Cannot discard workspace transition for invalid chat resource: ${chat.toString()}`); + } + const database = this._sessionDataService.openDatabase(storage); + try { + await database.object.deleteTurnWorkspaceTransition(turnId); + const hasTransitions = await database.object.getMetadata(AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY) === 'true'; + const session = parseChatUri(chat)?.session; + if (session) { + const state = this._stateManager.getSessionState(session); + this._stateManager.setSessionMeta(session, withSessionHasWorkspaceTransitions(state?._meta, hasTransitions)); + } + } finally { + database.dispose(); } } @@ -509,7 +602,7 @@ export class SessionWorkspaceConversionService extends Disposable implements ISe } } - private _publishConversionOutcome(chat: URI, continuation: IDeferredAgentHostTurn, label: string): void { + private _publishConversionOutcome(chat: URI, continuation: IDeferredAgentHostTurn, label: string, meta?: IAgentSystemNotificationMeta): void { if (this._stateManager.getActiveTurnId(chat.toString()) !== continuation.turnId) { return; } @@ -519,6 +612,7 @@ export class SessionWorkspaceConversionService extends Disposable implements ISe part: { kind: ResponsePartKind.SystemNotification, content: label, + ...(meta ? { _meta: toAgentSystemNotificationMeta(meta) } : {}), }, }); } diff --git a/src/vs/platform/agentHost/node/claude/claudeAgent.ts b/src/vs/platform/agentHost/node/claude/claudeAgent.ts index a8baa4bcf574a1..f1949bd1b03b07 100644 --- a/src/vs/platform/agentHost/node/claude/claudeAgent.ts +++ b/src/vs/platform/agentHost/node/claude/claudeAgent.ts @@ -28,7 +28,7 @@ import { AgentHostAutoApprovePolicyRestrictedConfigKey, AgentHostClaudeMultiRoot import { ClaudePermissionMode, ClaudeSessionConfigKey, narrowClaudePermissionMode } from '../../common/claudeSessionConfigKeys.js'; import { createClaudeThinkingLevelSchema, isClaudeEffortLevel } from '../../common/claudeModelConfig.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; -import { AgentChatMigrationDeferred, type AgentChatMigrationResult, AgentProvider, AgentSession, AgentSignal, CLAUDE_AGENT_PROVIDER_ID, IActiveClient, IAgent, IAgentChatContext, IAgentChatDataChange, IAgentChatMetadata, IAgentChats, IAgentChatConfigCompletionsParams, IAgentCreateChatOptions, IAgentCreateChatResult, IAgentDescriptor, IAgentDiscoveredChat, IAgentMaterializeChatEvent, IAgentModelInfo, IAgentResolveChatConfigParams, IAgentSessionProjectInfo, IAgentSpawnChatEvent, IAgentSpawnedChatParent, SubagentChatSignal, resolveAgentChatContext, resolveAgentHostCustomizations, resolveAgentHostInstructions, resolveSubagentChatParent } from '../../common/agent.js'; +import { AgentChatMigrationDeferred, type AgentChatMigrationResult, AgentProvider, AgentSession, AgentSignal, CLAUDE_AGENT_PROVIDER_ID, IActiveClient, IAgent, IAgentChatContext, IAgentChatDataChange, IAgentChatMetadata, type IAgentChatMetadataOptions, IAgentChats, IAgentChatConfigCompletionsParams, IAgentCreateChatOptions, IAgentCreateChatResult, IAgentDescriptor, IAgentDiscoveredChat, IAgentMaterializeChatEvent, IAgentModelInfo, IAgentResolveChatConfigParams, IAgentSessionProjectInfo, IAgentSpawnChatEvent, IAgentSpawnedChatParent, SubagentChatSignal, resolveAgentChatContext, resolveAgentHostCustomizations, resolveAgentHostInstructions, resolveSubagentChatParent } from '../../common/agent.js'; import { ensureWorkspacelessScratchDir } from '../workspacelessScratchDir.js'; import { ActionType } from '../../common/state/sessionActions.js'; import type { ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../common/state/protocol/commands.js'; @@ -953,10 +953,9 @@ export class ClaudeAgent extends Disposable implements IAgent { this._logService.info(`[Claude] Models refreshed (merged). Count: ${merged.length}, ${merged.map(m => m.name).join(', ')}`); this._models.set(merged, undefined); } - // Last, never first: this is a free republish of "is the SDK on disk" (some - // other path may have fetched it), but announcing `ready` before the catalog - // lands is exactly how the window renders "no account found". - this._sdkSetupChannel.publishWith(canAttemptNative); + // Last, never first: announcing `ready` before the catalog lands is exactly + // how the window renders "no account found". + this._sdkSetupChannel.refresh(); } /** @@ -1920,14 +1919,10 @@ export class ClaudeAgent extends Disposable implements IAgent { } private async _readChatMessages(context: IResolvedClaudeChatContext): Promise { - // Don't trigger a cold SDK download just to reconstruct a transcript - // during restore (the renderer subscribes to the last-active session - // on startup). Mirrors `listSessions` / `getConversationMetadata`: when the - // SDK isn't local yet, defer with an empty transcript. The download - // fires (with host-level progress) once the user sends the first - // message, after which the transcript re-hydrates on the next restore. + // Normal restore activates the SDK while reading metadata before reaching + // this path. Direct reads remain passive. if (!(await this._sdkService.canLoadWithoutDownload())) { - this._logService.info('[Claude] SDK not downloaded yet; deferring session messages until a session triggers the download'); + this._logService.info('[Claude] SDK not downloaded yet; deferring passive session messages'); return []; } if (context.spawnedFrom) { @@ -2139,13 +2134,11 @@ export class ClaudeAgent extends Disposable implements IAgent { * the SDK lookup propagate (the caller is doing a single targeted fetch and * should learn that the SDK module is broken). */ - async getChatMetadata(chat: URI, context: URI | IAgentChatContext, providerData?: string): Promise { - // Don't trigger a cold SDK download just to hydrate metadata during - // restore (the renderer subscribes to the last-active session on - // startup). When the SDK isn't local yet, defer; the download fires - // once the user sends the first message. - if (!(await this._sdkService.canLoadWithoutDownload())) { - this._logService.info('[Claude] SDK not downloaded yet; deferring chat metadata until a session triggers the download'); + async getChatMetadata(chat: URI, context: URI | IAgentChatContext, providerData?: string, options?: IAgentChatMetadataOptions): Promise { + if (options?.activation === 'restore') { + await this._sdkService.ensureAvailable(); + } else if (!(await this._sdkService.canLoadWithoutDownload())) { + this._logService.info('[Claude] SDK not downloaded yet; deferring passive chat metadata'); return undefined; } const { configurationResource } = resolveAgentChatContext(context, chat); diff --git a/src/vs/platform/agentHost/node/claude/claudeAgentSdkService.ts b/src/vs/platform/agentHost/node/claude/claudeAgentSdkService.ts index d53bc976c3879a..1a214ef5d2f20f 100644 --- a/src/vs/platform/agentHost/node/claude/claudeAgentSdkService.ts +++ b/src/vs/platform/agentHost/node/claude/claudeAgentSdkService.ts @@ -76,8 +76,9 @@ export interface IClaudeAgentSdkService { canLoadWithoutDownload(): Promise; /** * Downloads the SDK if it isn't local yet, without loading the module. This - * is the explicit gesture: background callers gate on - * {@link canLoadWithoutDownload} instead and do without. + * is reserved for user-initiated activation, such as an explicit download + * or restoring chat history. Background callers gate on + * {@link canLoadWithoutDownload} instead. */ ensureAvailable(): Promise; diff --git a/src/vs/platform/agentHost/node/codex/codexAgent.ts b/src/vs/platform/agentHost/node/codex/codexAgent.ts index aa72e8a1cea3b7..a8e35fab4dde2e 100644 --- a/src/vs/platform/agentHost/node/codex/codexAgent.ts +++ b/src/vs/platform/agentHost/node/codex/codexAgent.ts @@ -1930,15 +1930,14 @@ export class CodexAgent extends Disposable implements IAgent { } // A fresh refresh or the retry itself supersedes the pending timer. this._modelRefreshRetry.clear(); - const [copilotError, sdkReady] = await Promise.all([this._refreshCopilotModels(), this._refreshCodexModels()]); + const [copilotError] = await Promise.all([this._refreshCopilotModels(), this._refreshCodexModels()]); if (generation !== this._modelCatalogGeneration || this._isShuttingDown || this._store.isDisposed) { return; } this._models.set([...this._copilotModels, ...this._codexModels], undefined); - // Last, never first: also the freshest answer to "is the SDK here" (a - // download that landed elsewhere surfaces here), but announcing `ready` - // before the catalog lands is how the window renders "no account found". - this._sdkSetupChannel.publishWith(sdkReady); + // Last, never first: announcing `ready` before the catalog lands is how the + // window renders "no account found". + this._sdkSetupChannel.refresh(); if (!copilotError) { return; @@ -2037,39 +2036,36 @@ export class CodexAgent extends Disposable implements IAgent { } } - private async _refreshCodexModels(): Promise { - // Outside the `try` so a throw still reports what we had established about - // the SDK, rather than a `false` the caller would publish as "not downloaded". - let sdkReady = false; + private async _refreshCodexModels(): Promise { try { // A refresh must never be what pulls the SDK down — the download is an // explicit gesture now — so with no local SDK this reports the honest // empty catalog and the banner offers it. A live connection already // proves the SDK is on disk, so it short-circuits the stat. - sdkReady = this._connection.kind !== 'idle' || await this._isSdkResolvableWithoutDownload(); + const sdkReady = this._connection.kind !== 'idle' || await this._isSdkResolvableWithoutDownload(); if (!sdkReady) { this._codexModels = []; - return sdkReady; + return; } // Account/model enumeration belongs to a selected Codex session. Ambient // model refreshes may still publish SDK readiness and Copilot models, but // must not turn the startup account probe into a persistent connection. if (!this._activated && this._connection.kind === 'idle') { this._codexModels = []; - return sdkReady; + return; } const connection = await this._ensureConnection(); const account = await this._refreshAccount(connection.client, false); if (!this._isCurrentConnection(connection)) { - return sdkReady; + return; } if (account.status === 'signedOut' || account.status === 'error') { this._codexModels = []; - return sdkReady; + return; } const configResponse = await connection.client.request<'config/read', ConfigReadResponse>('config/read', { includeLayers: false }); if (!this._isCurrentConnection(connection)) { - return sdkReady; + return; } const modelProvider = configResponse.config.model_provider ?? CODEX_OPENAI_MODEL_PROVIDER; const usesChatGPTSubscription = modelProvider === CODEX_OPENAI_MODEL_PROVIDER && account.status === 'signedIn' && account.authType === 'chatgpt'; @@ -2079,7 +2075,7 @@ export class CodexAgent extends Disposable implements IAgent { do { const response: ModelListResponse = await connection.client.request<'model/list', ModelListResponse>('model/list', { cursor, limit: 100, includeHidden: false }); if (!this._isCurrentConnection(connection)) { - return sdkReady; + return; } data.push(...response.data); cursor = response.nextCursor; @@ -2105,7 +2101,6 @@ export class CodexAgent extends Disposable implements IAgent { // Keep the last known-good catalog; a transient periodic failure must // not make every model disappear. } - return sdkReady; } // #endregion diff --git a/src/vs/platform/agentHost/node/devContainerAgentHostService.ts b/src/vs/platform/agentHost/node/devContainerAgentHostService.ts index ba324285429240..61b02d7f3f912d 100644 --- a/src/vs/platform/agentHost/node/devContainerAgentHostService.ts +++ b/src/vs/platform/agentHost/node/devContainerAgentHostService.ts @@ -5,6 +5,8 @@ import type WebSocket from 'ws'; import { spawn, ChildProcessWithoutNullStreams } from 'child_process'; +import { createHash, randomUUID } from 'crypto'; +import { lstat, rename, rm, stat, writeFile } from 'fs/promises'; import { Duplex } from 'stream'; import { CancellationToken, CancellationTokenSource } from '../../../base/common/cancellation.js'; import { CancellationError } from '../../../base/common/errors.js'; @@ -20,6 +22,7 @@ import { IProductService } from '../../product/common/productService.js'; import { ITelemetryService } from '../../telemetry/common/telemetry.js'; import { IConfigurationService } from '../../configuration/common/configuration.js'; import { INativeEnvironmentService } from '../../environment/common/environment.js'; +import { IRequestService } from '../../request/common/request.js'; import { getResolvedShellEnv } from '../../shell/node/shellEnv.js'; import { IDevContainerAgentHostConfig, IDevContainerAgentHostConnectResult, IDevContainerAgentHostMainService } from '../common/devContainerAgentHost.js'; import { IRelayMessage } from '../common/relayTransport.js'; @@ -38,6 +41,7 @@ import { waitForNewStandaloneEndpoint, } from './sshRemoteAgentHostHelpers.js'; import { ensureRemoteAgentHostCliInstalled } from './remoteAgentHostCliInstaller.js'; +import { prepareOwnerOnlyDirectory } from './localAgentHostMetadata.js'; const LOG_PREFIX = '[DevContainerAgentHost]'; const DETECT_MUSL_COMMAND = 'if [ -e /etc/alpine-release ]; then printf musl; elif command -v ldd >/dev/null 2>&1; then case "$(ldd --version 2>&1)" in *musl*) printf musl;; esac; fi'; @@ -100,6 +104,7 @@ export class DevContainerAgentHostMainService extends Disposable implements IDev private readonly _connectionTokenSources = new Map(); private _nativeRequire: NodeJS.Require | undefined; private _shellEnvironment: Promise | undefined; + private _devContainerEnvironment: Promise | undefined; private _dockerAvailable: Promise | undefined; constructor( @@ -108,6 +113,7 @@ export class DevContainerAgentHostMainService extends Disposable implements IDev @ITelemetryService private readonly _telemetryService: ITelemetryService, @IConfigurationService private readonly _configurationService: IConfigurationService, @INativeEnvironmentService private readonly _environmentService: INativeEnvironmentService, + @IRequestService private readonly _requestService: IRequestService, ) { super(); } @@ -235,7 +241,7 @@ export class DevContainerAgentHostMainService extends Disposable implements IDev throw new CancellationError(); } const [environment, nativeRequire] = await Promise.all([ - this._resolveShellEnvironment(), + this._resolveDevContainerEnvironment(), this._getNativeRequire(), ]); if (token.isCancellationRequested) { @@ -316,7 +322,7 @@ export class DevContainerAgentHostMainService extends Disposable implements IDev } protected async _runDevContainer(connectionId: string, args: readonly string[], token: CancellationToken): Promise<{ stdout: string; stderr: string; code: number }> { - const environment = await this._resolveShellEnvironment(); + const environment = await this._resolveDevContainerEnvironment(); return new Promise((resolve, reject) => { if (token.isCancellationRequested) { reject(new CancellationError()); @@ -388,6 +394,88 @@ export class DevContainerAgentHostMainService extends Disposable implements IDev return this._shellEnvironment; } + protected _resolveDevContainerEnvironment(): Promise { + this._devContainerEnvironment ??= this._doResolveDevContainerEnvironment(); + return this._devContainerEnvironment; + } + + private async _doResolveDevContainerEnvironment(): Promise { + const environment = await this._resolveShellEnvironment(); + if (environment.NODE_EXTRA_CA_CERTS && await this._isFile(environment.NODE_EXTRA_CA_CERTS)) { + return environment; + } + if (this._configurationService.getValue('http.systemCertificates') === false) { + return environment; + } + const certificates = await this._requestService.loadCertificates(); + if (certificates.length === 0) { + return environment; + } + return { + ...environment, + NODE_EXTRA_CA_CERTS: await this._writeCertificatesFile(certificates), + }; + } + + protected async _isFile(path: string): Promise { + try { + return (await stat(path)).isFile(); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'ENOENT' || code === 'ENOTDIR') { + return false; + } + throw error; + } + } + + protected async _writeCertificatesFile(certificates: readonly string[]): Promise { + const content = certificates.join(process.platform === 'win32' ? '\r\n' : '\n'); + const hash = createHash('sha256').update(content).digest('hex'); + const directory = this._getCertificatesDirectory(); + await prepareOwnerOnlyDirectory(directory); + const path = join(directory, `certificates-${hash}.pem`); + if (await this._isSecureCacheFile(path)) { + return path; + } + const temporaryPath = `${path}-${randomUUID()}`; + await writeFile(temporaryPath, content, { mode: 0o600 }); + try { + await this._renameCertificateFile(temporaryPath, path); + } catch (error) { + if (!await this._isSecureCacheFile(path)) { + await rm(temporaryPath, { force: true }); + throw error; + } + await rm(temporaryPath, { force: true }); + } + return path; + } + + protected _getCertificatesDirectory(): string { + const owner = process.getuid?.().toString() + ?? createHash('sha256').update(this._environmentService.userDataPath).digest('hex').slice(0, 12); + return join(this._environmentService.tmpDir.fsPath, `vscode-dev-container-${owner}`); + } + + protected async _isSecureCacheFile(path: string): Promise { + try { + const fileStat = await lstat(path); + return fileStat.isFile() && !fileStat.isSymbolicLink() + && (!process.getuid || fileStat.uid === process.getuid()); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'ENOENT' || code === 'ENOTDIR') { + return false; + } + throw error; + } + } + + protected _renameCertificateFile(from: string, to: string): Promise { + return rename(from, to); + } + protected _reportOutput(connectionId: string, data: string): void { this._onDidOutput.fire({ connectionId, data }); } diff --git a/src/vs/platform/agentHost/node/localAgentHostMetadata.ts b/src/vs/platform/agentHost/node/localAgentHostMetadata.ts index f2268baeb7c37d..5db5d843d22523 100644 --- a/src/vs/platform/agentHost/node/localAgentHostMetadata.ts +++ b/src/vs/platform/agentHost/node/localAgentHostMetadata.ts @@ -52,18 +52,18 @@ export async function prepareLocalAgentHostEndpointMetadataDirectory(userDataPat await prepareOwnerOnlyDirectory(getEntriesDirectory(userDataPath)); } -async function prepareOwnerOnlyDirectory(directory: string): Promise { +export async function prepareOwnerOnlyDirectory(directory: string): Promise { await fs.promises.mkdir(directory, { recursive: true, mode: 0o700 }); const stat = await fs.promises.lstat(directory); if (!stat.isDirectory() || stat.isSymbolicLink()) { - throw new Error(`Local agent host endpoint directory is not a directory: ${directory}`); + throw new Error(`Owner-only path is not a directory: ${directory}`); } if (process.platform === 'win32') { await applyWindowsOwnerOnlyAcl(directory); } else { if (process.getuid && stat.uid !== process.getuid()) { - throw new Error(`Local agent host endpoint directory is not owned by the current user: ${directory}`); + throw new Error(`Directory is not owned by the current user: ${directory}`); } await fs.promises.chmod(directory, 0o700); } diff --git a/src/vs/platform/agentHost/node/sessionDatabase.ts b/src/vs/platform/agentHost/node/sessionDatabase.ts index a7e62bddae8c24..4e5c8f8c026ec4 100644 --- a/src/vs/platform/agentHost/node/sessionDatabase.ts +++ b/src/vs/platform/agentHost/node/sessionDatabase.ts @@ -9,7 +9,7 @@ import type { Database, RunResult } from '@vscode/sqlite3'; import type { IFileEditContent, IFileEditRecord, ILocalTurnRecord, IReviewedFileRecord, ISessionDatabase } from '../common/sessionDataService.js'; import { dirname } from '../../../base/common/path.js'; import { URI } from '../../../base/common/uri.js'; -import type { Message } from '../common/state/sessionState.js'; +import { AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY, type Message } from '../common/state/sessionState.js'; /** * A single numbered migration. Migrations are applied in order of @@ -142,6 +142,22 @@ export const sessionDatabaseMigrations: readonly ISessionDatabaseMigration[] = [ delegation TEXT NOT NULL )`, }, + { + version: 11, + // Workspace-transition boundaries are host-owned turn data. Keeping + // them as children of `turns` makes every truncate/delete path bound + // their lifetime to the provider history they enrich. + sql: `CREATE TABLE IF NOT EXISTS turn_workspace_transition ( + turn_id TEXT PRIMARY KEY NOT NULL REFERENCES turns(id) ON DELETE CASCADE, + transition TEXT NOT NULL + )`, + }, + { + version: 12, + sql: `INSERT OR REPLACE INTO session_metadata (key, value) + SELECT '${AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY}', 'true' + WHERE EXISTS (SELECT 1 FROM turn_workspace_transition)`, + }, ]; // ---- Promise wrappers around callback-based @vscode/sqlite3 API ----------- @@ -257,12 +273,12 @@ export class SessionDatabase implements ISessionDatabase { private readonly _fileEditSequencer = new SequencerByKey(); private readonly _metadataSequencer = new Sequencer(); - private readonly _transactionSequencer = new Sequencer(); + private readonly _mutationSequencer = new Sequencer(); /** - * Serializes every `turn_usage` access — writes, prunes, the fork remap, and the restore read - * alike. `@vscode/sqlite3` runs in parallelized mode (see {@link _metadataSequencer}), so a - * fire-and-forget `setTurnUsage` submitted before a truncation can otherwise complete *after* + * Serializes turn-child writes, prunes, fork remaps, and restore reads. + * `@vscode/sqlite3` runs in parallelized mode (see {@link _metadataSequencer}), so a + * fire-and-forget child write submitted before a truncation can otherwise complete *after* * it and resurrect a row the truncation was meant to remove, and a read can otherwise overtake * a write it was submitted after. Mutations must go through {@link _mutateTurnUsage} rather * than queueing on this directly, so they are tracked for {@link whenIdle}. @@ -270,11 +286,60 @@ export class SessionDatabase implements ISessionDatabase { private readonly _turnUsageSequencer = new Sequencer(); /** - * Runs a mutation that touches `turn_usage`, tracked for {@link whenIdle} - * and serialized against every other such mutation. + * Queues a mutation on the shared connection. Transaction bodies call + * their statements directly after acquiring this sequencer to avoid + * reentrant acquisition. + */ + protected _queueMutation(operation: (db: Database) => Promise): Promise { + return this._mutationSequencer.queue(async () => operation(await this._ensureDb())); + } + + private _mutate(operation: (db: Database) => Promise): Promise { + return this._track(() => this._queueMutation(operation)); + } + + private _queueTransaction(operation: (db: Database) => Promise): Promise { + return this._queueMutation(async db => { + await dbExec(db, 'BEGIN TRANSACTION'); + try { + const result = await operation(db); + await dbExec(db, 'COMMIT'); + return result; + } catch (err) { + await dbExec(db, 'ROLLBACK'); + throw err; + } + }); + } + + /** + * Runs a mutation that touches sequenced turn data, tracked for + * {@link whenIdle} and serialized against every other such mutation. */ private _mutateTurnUsage(operation: (db: Database) => Promise): Promise { - return this._track(() => this._turnUsageSequencer.queue(async () => operation(await this._ensureDb()))); + return this._track(() => this._turnUsageSequencer.queue(() => this._queueMutation(operation))); + } + + /** + * Runs an atomic mutation touching metadata and sequenced turn data. + * Always acquire sequencers in metadata, turn-data, mutation order. + */ + protected _mutateMetadataAndTurnUsage(operation: (db: Database) => Promise): Promise { + return this._track(() => this._metadataSequencer.queue(() => + this._turnUsageSequencer.queue(() => + this._queueTransaction(operation) + ) + )); + } + + private async _deleteWorkspaceTransitionMarkerIfEmpty(db: Database): Promise { + await dbRun( + db, + `DELETE FROM session_metadata + WHERE key = ? + AND NOT EXISTS (SELECT 1 FROM turn_workspace_transition)`, + [AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY], + ); } /** @@ -348,22 +413,21 @@ export class SessionDatabase implements ISessionDatabase { // ---- Turns ---------------------------------------------------------- createTurn(turnId: string): Promise { - return this._track(async () => { - const db = await this._ensureDb(); + return this._mutate(async db => { await dbRun(db, 'INSERT OR IGNORE INTO turns (id) VALUES (?)', [turnId]); }); } deleteTurn(turnId: string): Promise { - return this._mutateTurnUsage(async db => { - // File edits and turn usage cascade-delete via their foreign keys. + return this._mutateMetadataAndTurnUsage(async db => { + // Turn-owned file edits, usage, and workspace transitions cascade-delete. await dbRun(db, 'DELETE FROM turns WHERE id = ?', [turnId]); + await this._deleteWorkspaceTransitionMarkerIfEmpty(db); }); } setTurnEventId(turnId: string, eventId: string): Promise { - return this._track(async () => { - const db = await this._ensureDb(); + return this._mutate(async db => { await dbRun(db, 'INSERT OR IGNORE INTO turns (id) VALUES (?)', [turnId]); // Only set the event ID if not already set — steering messages // trigger additional user.message events within the same turn, @@ -444,8 +508,7 @@ export class SessionDatabase implements ISessionDatabase { } setTurnDelegation(turnId: string, delegation: string): Promise { - return this._track(async () => { - const db = await this._ensureDb(); + return this._mutate(async db => { await dbRun(db, 'INSERT OR IGNORE INTO turns (id) VALUES (?)', [turnId]); await dbRun(db, 'INSERT OR REPLACE INTO turn_delegation (turn_id, delegation) VALUES (?, ?)', [turnId, delegation]); }); @@ -472,9 +535,50 @@ export class SessionDatabase implements ISessionDatabase { return result; } + setTurnWorkspaceTransition(turnId: string, transition: string): Promise { + return this.setWorkspaceConversion(turnId, transition, {}); + } + + setWorkspaceConversion(turnId: string, transition: string, metadata: Readonly>): Promise { + return this._mutateMetadataAndTurnUsage(async db => { + await dbRun(db, 'INSERT OR IGNORE INTO turns (id) VALUES (?)', [turnId]); + await dbRun(db, 'INSERT OR REPLACE INTO turn_workspace_transition (turn_id, transition) VALUES (?, ?)', [turnId, transition]); + for (const [key, value] of Object.entries({ ...metadata, [AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY]: 'true' })) { + await dbRun(db, 'INSERT OR REPLACE INTO session_metadata (key, value) VALUES (?, ?)', [key, value]); + } + }); + } + + deleteTurnWorkspaceTransition(turnId: string): Promise { + return this._mutateMetadataAndTurnUsage(async db => { + await dbRun(db, 'DELETE FROM turn_workspace_transition WHERE turn_id = ?', [turnId]); + await this._deleteWorkspaceTransitionMarkerIfEmpty(db); + }); + } + + async getTurnWorkspaceTransitions(): Promise> { + return this._turnUsageSequencer.queue(() => this._queueMutation(async db => { + const rows = await dbAll( + db, + `SELECT w.turn_id AS turn_id, t.event_id AS event_id, w.transition AS transition + FROM turn_workspace_transition w LEFT JOIN turns t ON t.id = w.turn_id`, + [], + ); + const result = new Map(); + for (const row of rows) { + const transition = row.transition as string; + result.set(row.turn_id as string, transition); + const eventId = row.event_id as string | null; + if (eventId) { + result.set(eventId, transition); + } + } + return result; + })); + } + setTurnCheckpointRef(turnId: string, ref: string): Promise { - return this._track(async () => { - const db = await this._ensureDb(); + return this._mutate(async db => { await dbRun(db, 'INSERT OR IGNORE INTO turns (id) VALUES (?)', [turnId]); await dbRun(db, 'UPDATE turns SET checkpoint_ref = ? WHERE id = ?', [ref, turnId]); }); @@ -506,40 +610,41 @@ export class SessionDatabase implements ISessionDatabase { } truncateFromTurn(turnId: string): Promise { - return this._mutateTurnUsage(async db => { + return this._mutateMetadataAndTurnUsage(async db => { // Delete the target turn and all turns inserted after it (by rowid order). - // File edits and turn usage cascade-delete via their foreign keys. + // Turn-owned child records cascade-delete via their foreign keys. await dbRun(db, `DELETE FROM turns WHERE rowid >= (SELECT rowid FROM turns WHERE id = ?)`, [turnId], ); + await this._deleteWorkspaceTransitionMarkerIfEmpty(db); }); } deleteTurnsAfter(turnId: string): Promise { - return this._mutateTurnUsage(async db => { + return this._mutateMetadataAndTurnUsage(async db => { // Delete all turns inserted after the given turn (by rowid order), - // keeping the given turn itself. File edits and turn usage - // cascade-delete via their foreign keys. + // keeping the given turn itself. Turn-owned child records cascade-delete. await dbRun(db, `DELETE FROM turns WHERE rowid > (SELECT rowid FROM turns WHERE id = ?)`, [turnId], ); + await this._deleteWorkspaceTransitionMarkerIfEmpty(db); }); } deleteAllTurns(): Promise { - return this._mutateTurnUsage(async db => { - // File edits and turn usage cascade-delete via their foreign keys. + return this._mutateMetadataAndTurnUsage(async db => { + // Turn-owned child records cascade-delete via their foreign keys. await dbExec(db, 'DELETE FROM turns'); + await this._deleteWorkspaceTransitionMarkerIfEmpty(db); }); } // ---- Local (host-injected) turns ------------------------------------ insertLocalTurn(record: ILocalTurnRecord): Promise { - return this._track(async () => { - const db = await this._ensureDb(); + return this._mutate(async db => { await dbRun(db, 'INSERT OR REPLACE INTO local_turns (turn_id, chat_uri, anchor_turn_id, seq, payload) VALUES (?, ?, ?, ?, ?)', [record.turnId, record.chatUri, record.anchorTurnId ?? null, record.seq, record.payload], @@ -560,21 +665,21 @@ export class SessionDatabase implements ISessionDatabase { } deleteLocalTurns(turnIds: readonly string[]): Promise { - return this._track(async () => { + return this._track(() => { if (turnIds.length === 0) { - return; + return Promise.resolve(); } - const db = await this._ensureDb(); - const placeholders = turnIds.map(() => '?').join(','); - await dbRun(db, `DELETE FROM local_turns WHERE turn_id IN (${placeholders})`, [...turnIds]); + return this._queueMutation(async db => { + const placeholders = turnIds.map(() => '?').join(','); + await dbRun(db, `DELETE FROM local_turns WHERE turn_id IN (${placeholders})`, [...turnIds]); + }); }); } // ---- File edits ----------------------------------------------------- storeFileEdit(edit: IFileEditRecord & IFileEditContent): Promise { - return this._track(() => this._fileEditSequencer.queue(edit.filePath, async () => { - const db = await this._ensureDb(); + return this._track(() => this._fileEditSequencer.queue(edit.filePath, () => this._queueMutation(async db => { // Ensure the turn exists — lazily insert since the turn record // may not have been created by an explicit createTurn() call. await dbRun(db, 'INSERT OR IGNORE INTO turns (id) VALUES (?)', [edit.turnId]); @@ -595,7 +700,7 @@ export class SessionDatabase implements ISessionDatabase { edit.removedLines ?? null, ], ); - })); + }))); } async getFileEdits(toolCallIds: string[]): Promise { @@ -712,72 +817,54 @@ export class SessionDatabase implements ISessionDatabase { } setMetadata(key: string, value: string): Promise { - return this._track(() => this._metadataSequencer.queue(async () => { - const db = await this._ensureDb(); + return this._track(() => this._metadataSequencer.queue(() => this._queueMutation(async db => { await dbRun(db, 'INSERT OR REPLACE INTO session_metadata (key, value) VALUES (?, ?)', [key, value]); - })); + }))); } setMetadataValues(values: Readonly>): Promise { - return this._track(() => this._metadataSequencer.queue(async () => { - const db = await this._ensureDb(); - await this._transactionSequencer.queue(async () => { - await dbExec(db, 'BEGIN TRANSACTION'); - try { - for (const [key, value] of Object.entries(values)) { - await dbRun(db, 'INSERT OR REPLACE INTO session_metadata (key, value) VALUES (?, ?)', [key, value]); - } - await dbExec(db, 'COMMIT'); - } catch (err) { - await dbExec(db, 'ROLLBACK'); - throw err; + return this._track(() => this._metadataSequencer.queue(() => + this._queueTransaction(async db => { + for (const [key, value] of Object.entries(values)) { + await dbRun(db, 'INSERT OR REPLACE INTO session_metadata (key, value) VALUES (?, ?)', [key, value]); } - }); - })); + }) + )); } deleteMetadata(keys: readonly string[]): Promise { - return this._track(() => this._metadataSequencer.queue(async () => { + return this._track(() => this._metadataSequencer.queue(() => { if (keys.length === 0) { - return; + return Promise.resolve(); } - const db = await this._ensureDb(); - const placeholders = keys.map(() => '?').join(','); - await dbRun(db, `DELETE FROM session_metadata WHERE key IN (${placeholders})`, [...keys]); + return this._queueMutation(async db => { + const placeholders = keys.map(() => '?').join(','); + await dbRun(db, `DELETE FROM session_metadata WHERE key IN (${placeholders})`, [...keys]); + }); })); } setMetadataValuesIfAbsent(key: string, values: Readonly>, copies: Readonly> = {}): Promise { - return this._track(() => this._metadataSequencer.queue(async () => { - const db = await this._ensureDb(); - return this._transactionSequencer.queue(async () => { - await dbExec(db, 'BEGIN TRANSACTION'); - try { - const existing = await dbGet(db, 'SELECT 1 FROM session_metadata WHERE key = ?', [key]); - if (existing) { - await dbExec(db, 'COMMIT'); - return false; - } - for (const [targetKey, value] of Object.entries(values)) { - await dbRun(db, 'INSERT OR REPLACE INTO session_metadata (key, value) VALUES (?, ?)', [targetKey, value]); - } - for (const [targetKey, sourceKey] of Object.entries(copies)) { - await dbRun(db, 'INSERT OR REPLACE INTO session_metadata (key, value) SELECT ?, value FROM session_metadata WHERE key = ?', [targetKey, sourceKey]); - } - await dbExec(db, 'COMMIT'); - return true; - } catch (err) { - await dbExec(db, 'ROLLBACK'); - throw err; + return this._track(() => this._metadataSequencer.queue(() => + this._queueTransaction(async db => { + const existing = await dbGet(db, 'SELECT 1 FROM session_metadata WHERE key = ?', [key]); + if (existing) { + return false; } - }); - })); + for (const [targetKey, value] of Object.entries(values)) { + await dbRun(db, 'INSERT OR REPLACE INTO session_metadata (key, value) VALUES (?, ?)', [targetKey, value]); + } + for (const [targetKey, sourceKey] of Object.entries(copies)) { + await dbRun(db, 'INSERT OR REPLACE INTO session_metadata (key, value) SELECT ?, value FROM session_metadata WHERE key = ?', [targetKey, sourceKey]); + } + return true; + }) + )); } setChatDraft(chat: URI, draft: Message | undefined): Promise { const chatUri = chat.toString(); - return this._track(async () => { - const db = await this._ensureDb(); + return this._mutate(async db => { if (!draft) { await dbRun(db, 'DELETE FROM chat_drafts WHERE chat_uri = ?', [chatUri]); return; @@ -802,15 +889,13 @@ export class SessionDatabase implements ISessionDatabase { // ---- Reviewed files ------------------------------------------------- markFileReviewed(uri: URI, nonce: string): Promise { - return this._track(async () => { - const db = await this._ensureDb(); + return this._mutate(async db => { await dbRun(db, 'INSERT OR IGNORE INTO reviewed_files (uri, nonce) VALUES (?, ?)', [uri.toString(), nonce]); }); } unmarkFileReviewed(uri: URI, nonce: string): Promise { - return this._track(async () => { - const db = await this._ensureDb(); + return this._mutate(async db => { await dbRun(db, 'DELETE FROM reviewed_files WHERE uri = ? AND nonce = ?', [uri.toString(), nonce]); }); } @@ -834,63 +919,56 @@ export class SessionDatabase implements ISessionDatabase { } remapTurnIds(mapping: ReadonlyMap, eventIds?: ReadonlyMap): Promise { - // Mutates `turn_usage`, so it must serialize with every other such - // mutation — a usage write racing the fork transaction would otherwise - // land against either the old or the new turn id unpredictably. - return this._mutateTurnUsage(async db => { - await this._transactionSequencer.queue(async () => { - // Defer FK checks to commit time so we can update turns.id and - // file_edits.turn_id in any order without mid-statement violations. - // This pragma auto-resets after the transaction ends. - await dbExec(db, 'PRAGMA defer_foreign_keys = ON'); - await dbExec(db, 'BEGIN TRANSACTION'); - try { - // Delete turns not present in the mapping (e.g. turns beyond - // the fork point). File edits cascade-delete via FK. - const oldIds = [...mapping.keys()]; - if (oldIds.length > 0) { - const placeholders = oldIds.map(() => '?').join(','); - await dbRun(db, - `DELETE FROM turns WHERE id NOT IN (${placeholders})`, - oldIds, - ); - } - - // Remap the remaining turn IDs to their new values - for (const [oldId, newId] of mapping) { - await dbRun(db, 'UPDATE turns SET id = ? WHERE id = ?', [newId, oldId]); - await dbRun(db, 'UPDATE file_edits SET turn_id = ? WHERE turn_id = ?', [newId, oldId]); - } - for (const [turnId, eventId] of eventIds ?? []) { - await dbRun(db, 'UPDATE turns SET event_id = ? WHERE id = ?', [eventId, turnId]); - } - - if (oldIds.length > 0) { - const placeholders = oldIds.map(() => '?').join(','); - await dbRun(db, - `DELETE FROM local_turns WHERE turn_id NOT IN (${placeholders})`, - oldIds, - ); - } - for (const [oldId, newId] of mapping) { - await dbRun(db, 'UPDATE local_turns SET turn_id = ? WHERE turn_id = ?', [newId, oldId]); - await dbRun(db, 'UPDATE local_turns SET anchor_turn_id = ? WHERE anchor_turn_id = ?', [newId, oldId]); - } - - // Rows past the fork point were already removed by the `turns` - // delete above, via the same cascade as file edits. The surviving - // ids still need remapping (the FK cascades deletes, not updates), - // or the forked session would restore with no gauge and zero cost. - for (const [oldId, newId] of mapping) { - await dbRun(db, 'UPDATE turn_usage SET turn_id = ? WHERE turn_id = ?', [newId, oldId]); - await dbRun(db, 'UPDATE turn_delegation SET turn_id = ? WHERE turn_id = ?', [newId, oldId]); - } - await dbExec(db, 'COMMIT'); - } catch (err) { - await dbExec(db, 'ROLLBACK'); - throw err; - } - }); + // Mutates turn-owned child data, so it must serialize with every other + // such mutation or a write racing the fork transaction could land + // against either the old or the new turn id unpredictably. + return this._mutateMetadataAndTurnUsage(async db => { + // Defer FK checks to commit time so we can update turns.id and + // file_edits.turn_id in any order without mid-statement violations. + // This pragma auto-resets after the transaction ends. + await dbExec(db, 'PRAGMA defer_foreign_keys = ON'); + // Delete turns not present in the mapping (e.g. turns beyond + // the fork point). File edits cascade-delete via FK. + const oldIds = [...mapping.keys()]; + if (oldIds.length > 0) { + const placeholders = oldIds.map(() => '?').join(','); + await dbRun(db, + `DELETE FROM turns WHERE id NOT IN (${placeholders})`, + oldIds, + ); + } + + // Remap the remaining turn IDs to their new values + for (const [oldId, newId] of mapping) { + await dbRun(db, 'UPDATE turns SET id = ? WHERE id = ?', [newId, oldId]); + await dbRun(db, 'UPDATE file_edits SET turn_id = ? WHERE turn_id = ?', [newId, oldId]); + } + for (const [turnId, eventId] of eventIds ?? []) { + await dbRun(db, 'UPDATE turns SET event_id = ? WHERE id = ?', [eventId, turnId]); + } + + if (oldIds.length > 0) { + const placeholders = oldIds.map(() => '?').join(','); + await dbRun(db, + `DELETE FROM local_turns WHERE turn_id NOT IN (${placeholders})`, + oldIds, + ); + } + for (const [oldId, newId] of mapping) { + await dbRun(db, 'UPDATE local_turns SET turn_id = ? WHERE turn_id = ?', [newId, oldId]); + await dbRun(db, 'UPDATE local_turns SET anchor_turn_id = ? WHERE anchor_turn_id = ?', [newId, oldId]); + } + + // Rows past the fork point were already removed by the `turns` + // delete above, via the same cascade as file edits. The surviving + // ids still need remapping (the FK cascades deletes, not updates), + // or the forked session would restore with no gauge and zero cost. + for (const [oldId, newId] of mapping) { + await dbRun(db, 'UPDATE turn_usage SET turn_id = ? WHERE turn_id = ?', [newId, oldId]); + await dbRun(db, 'UPDATE turn_delegation SET turn_id = ? WHERE turn_id = ?', [newId, oldId]); + await dbRun(db, 'UPDATE turn_workspace_transition SET turn_id = ? WHERE turn_id = ?', [newId, oldId]); + } + await this._deleteWorkspaceTransitionMarkerIfEmpty(db); }); } @@ -907,9 +985,10 @@ export class SessionDatabase implements ISessionDatabase { } } - async vacuumInto(targetPath: string) { - const db = await this._ensureDb(); - await dbRun(db, 'VACUUM INTO ?', [targetPath]); + vacuumInto(targetPath: string): Promise { + return this._mutate(async db => { + await dbRun(db, 'VACUUM INTO ?', [targetPath]); + }); } /** diff --git a/src/vs/platform/agentHost/test/common/agentSdkSetup.test.ts b/src/vs/platform/agentHost/test/common/agentSdkSetup.test.ts index 581fd741f65d1c..7b94a7b55cf54f 100644 --- a/src/vs/platform/agentHost/test/common/agentSdkSetup.test.ts +++ b/src/vs/platform/agentHost/test/common/agentSdkSetup.test.ts @@ -5,7 +5,7 @@ import * as assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { agentSdkSetupStatusKey, isAgentSdkSetupRequestFor, readAgentSdkSetupInfos, readConsentedSdkAgents, resolveConsentedSdkDownloads, writeConsentedSdkAgents, type IAgentSdkSetupInfo } from '../../common/agentSdkSetup.js'; +import { agentSdkSetupStatusKey, isAgentSdkSetupRequestFor, readAgentSdkSetupInfos } from '../../common/agentSdkSetup.js'; suite('Agent SDK setup channel', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -15,11 +15,11 @@ suite('Agent SDK setup channel', () => { agents: [], _meta: { [agentSdkSetupStatusKey('claude')]: { download: 'ready', setupDocsUrl: 'https://example.test/claude' }, - [agentSdkSetupStatusKey('codex')]: { download: 'notDownloaded', signInProviderName: 'ChatGPT' }, + [agentSdkSetupStatusKey('codex')]: { download: 'downloadOnUse', signInProviderName: 'ChatGPT' }, }, }), [ { agent: 'claude', download: 'ready', setupDocsUrl: 'https://example.test/claude', signInProviderName: undefined }, - { agent: 'codex', download: 'notDownloaded', setupDocsUrl: undefined, signInProviderName: 'ChatGPT' }, + { agent: 'codex', download: 'downloadOnUse', setupDocsUrl: undefined, signInProviderName: 'ChatGPT' }, ]); }); @@ -74,53 +74,4 @@ suite('Agent SDK setup channel', () => { assert.strictEqual(isAgentSdkSetupRequestFor('claude', 'claude'), false); }); - suite('standing consent', () => { - const claude: IAgentSdkSetupInfo = { agent: 'claude', download: 'notDownloaded' }; - const codex: IAgentSdkSetupInfo = { agent: 'codex', download: 'notDownloaded' }; - const none: ReadonlySet = new Set(); - const both: ReadonlySet = new Set(['claude', 'codex']); - - test('a consented user whose cache a version bump invalidated re-downloads with no gate', () => { - assert.deepStrictEqual(resolveConsentedSdkDownloads(both, [claude, codex], none), ['claude', 'codex']); - }); - - test('a user who never consented still sees the offer', () => { - assert.deepStrictEqual(resolveConsentedSdkDownloads(new Set(), [claude, codex], none), []); - }); - - test('consenting to one agent is not consent to fetch another', () => { - // The button that records this says "download the Codex Agent SDK". - assert.deepStrictEqual(resolveConsentedSdkDownloads(new Set(['codex']), [claude, codex], none), ['codex']); - }); - - test('an SDK already on disk, or already fetching, is left alone', () => { - assert.deepStrictEqual(resolveConsentedSdkDownloads(both, [ - { ...claude, download: 'ready' }, - { ...codex, download: 'downloading' }, - ], none), []); - }); - - test('a download that failed is not retried until the next window', () => { - // The failure republishes `notDownloaded`, and every status change re-runs - // this — without the guard that is an unbounded retry loop. - assert.deepStrictEqual(resolveConsentedSdkDownloads(both, [claude, codex], new Set(['claude'])), ['codex']); - }); - - test('the consent record survives a round trip, and a corrupt one consents to nobody', () => { - assert.deepStrictEqual({ - roundTrip: [...readConsentedSdkAgents(writeConsentedSdkAgents(both))], - absent: [...readConsentedSdkAgents(undefined)], - corrupt: [...readConsentedSdkAgents('{not json')], - wrongShape: [...readConsentedSdkAgents('{"claude":true}')], - // A stray non-string entry drops out rather than poisoning the set. - mixed: [...readConsentedSdkAgents('["claude",7]')], - }, { - roundTrip: ['claude', 'codex'], - absent: [], - corrupt: [], - wrongShape: [], - mixed: ['claude'], - }); - }); - }); }); diff --git a/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts b/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts index 8d75939af585e7..910ab8f64a591b 100644 --- a/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts +++ b/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts @@ -11,7 +11,7 @@ import type { IDetailedDiffResult, IDiffComputeService, IDiffCountResult } from import type { IFileEditContent, IFileEditRecord, ILocalTurnRecord, IReviewedFileRecord, ISessionDatabase, ISessionDataService } from '../../common/sessionDataService.js'; import type { IAgentHostCheckpointService } from '../../common/agentHostCheckpointService.js'; import type { IAgentHostGitStateService } from '../../common/agentHostGitStateService.js'; -import type { ISessionGitHubState, Message } from '../../common/state/sessionState.js'; +import { AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY, type ISessionGitHubState, type Message } from '../../common/state/sessionState.js'; export class TestSessionDatabase implements ISessionDatabase { private readonly _edits: (IFileEditRecord & IFileEditContent)[] = []; @@ -21,10 +21,12 @@ export class TestSessionDatabase implements ISessionDatabase { private readonly _localTurns = new Map(); private readonly _turnUsages = new Map(); private readonly _turnDelegations = new Map(); + private readonly _turnWorkspaceTransitions = new Map(); private readonly _turnEventIds = new Map(); getAllFileEditsCalls = 0; getFileEditsByTurnCalls = 0; + getTurnWorkspaceTransitionsCalls = 0; deleteTurnsAfterCalls: string[] = []; deleteAllTurnsCalls = 0; setTurnEventIdCalls: Array<{ turnId: string; eventId: string }> = []; @@ -38,12 +40,14 @@ export class TestSessionDatabase implements ISessionDatabase { async deleteTurn(turnId: string): Promise { this._turnDelegations.delete(turnId); + this._turnWorkspaceTransitions.delete(turnId); this._turnEventIds.delete(turnId); for (let i = this._edits.length - 1; i >= 0; i--) { if (this._edits[i].turnId === turnId) { this._edits.splice(i, 1); } } + this._deleteWorkspaceTransitionMarkerIfEmpty(); } async storeFileEdit(edit: IFileEditRecord & IFileEditContent): Promise { @@ -171,6 +175,36 @@ export class TestSessionDatabase implements ISessionDatabase { return result; } + async setTurnWorkspaceTransition(turnId: string, transition: string): Promise { + this._turnWorkspaceTransitions.set(turnId, transition); + this._metadata.set(AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY, 'true'); + } + + async setWorkspaceConversion(turnId: string, transition: string, metadata: Readonly>): Promise { + for (const [key, value] of Object.entries(metadata)) { + this._metadata.set(key, value); + } + this._turnWorkspaceTransitions.set(turnId, transition); + this._metadata.set(AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY, 'true'); + } + + async deleteTurnWorkspaceTransition(turnId: string): Promise { + this._turnWorkspaceTransitions.delete(turnId); + this._deleteWorkspaceTransitionMarkerIfEmpty(); + } + + async getTurnWorkspaceTransitions(): Promise> { + this.getTurnWorkspaceTransitionsCalls++; + const result = new Map(this._turnWorkspaceTransitions); + for (const [turnId, eventId] of this._turnEventIds) { + const transition = this._turnWorkspaceTransitions.get(turnId); + if (transition) { + result.set(eventId, transition); + } + } + return result; + } + async truncateFromTurn(_turnId: string): Promise { } async deleteTurnsAfter(turnId: string): Promise { @@ -181,6 +215,8 @@ export class TestSessionDatabase implements ISessionDatabase { this.deleteAllTurnsCalls++; this._edits.length = 0; this._turnDelegations.clear(); + this._turnWorkspaceTransitions.clear(); + this._metadata.delete(AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY); this._turnEventIds.clear(); } @@ -203,18 +239,35 @@ export class TestSessionDatabase implements ISessionDatabase { this._turnDelegations.delete(turnId); } } + for (const turnId of [...this._turnWorkspaceTransitions.keys()]) { + if (!mapping.has(turnId)) { + this._turnWorkspaceTransitions.delete(turnId); + } + } for (const [oldId, newId] of mapping) { const delegation = this._turnDelegations.get(oldId); if (delegation) { this._turnDelegations.delete(oldId); this._turnDelegations.set(newId, delegation); } + const transition = this._turnWorkspaceTransitions.get(oldId); + if (transition) { + this._turnWorkspaceTransitions.delete(oldId); + this._turnWorkspaceTransitions.set(newId, transition); + } const eventId = eventIds?.get(newId) ?? this._turnEventIds.get(oldId); this._turnEventIds.delete(oldId); if (eventId) { this._turnEventIds.set(newId, eventId); } } + this._deleteWorkspaceTransitionMarkerIfEmpty(); + } + + private _deleteWorkspaceTransitionMarkerIfEmpty(): void { + if (this._turnWorkspaceTransitions.size === 0) { + this._metadata.delete(AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY); + } } async markFileReviewed(uri: URI, nonce: string): Promise { @@ -359,6 +412,7 @@ export function createNoopGitService(): import('../../common/agentHostGitService createBranch: async () => { }, checkout: async () => { }, hasUncommittedChanges: async () => false, + createStash: async () => { }, commitAll: async () => { }, mergeBranch: async () => '', restore: async () => { }, diff --git a/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts b/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts index 5c0ab8761915d4..dc4a6c5ee03839 100644 --- a/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts @@ -15,8 +15,9 @@ import { AgentSession } from '../../common/agent.js'; import { AgentHostClientType } from '../../common/agentHostClientInfo.js'; import { AgentHostClientConnectionKind, AgentHostLaunchKind, AgentHostTransportKind } from '../../common/agentHostTelemetry.js'; import { buildBranchChangesetUri, buildDefaultChangesetCatalog, buildSessionChangesetUri, buildTurnChangesetUri, buildUncommittedChangesetUri } from '../../common/changesetUri.js'; +import { toAgentWorkspaceContinuationMessageMeta } from '../../common/meta/agentWorkspaceContinuationMeta.js'; import { ActionEnvelope, ActionType } from '../../common/state/sessionActions.js'; -import { ChangesetStatus, FileEditKind, MessageKind, SessionStatus, buildDefaultChatUri, withMessageRequestHiddenFromTranscript, withSessionGitState, type Changeset, type ISessionFileDiff } from '../../common/state/sessionState.js'; +import { ChangesetStatus, FileEditKind, MessageKind, SessionStatus, buildChatUri, buildDefaultChatUri, withMessageRequestHiddenFromTranscript, withSessionGitState, type Changeset, type ISessionFileDiff } from '../../common/state/sessionState.js'; import { AgentHostChangesetService } from '../../node/agentHostChangesetService.js'; import { NullAgentHostWorktreeIsolation } from '../../node/shared/worktreeIsolation.js'; import { META_CHANGES_SUMMARY } from '../../common/agentHostChangesetService.js'; @@ -1826,11 +1827,11 @@ suite('AgentHostChangesetService - multi-root turn changeset', () => { }); /** - * A host notice turn captures no checkpoint, so picking it as the session's - * latest turn drops the session changeset onto the edit tracker, which - * cannot see terminal-tool edits. + * A workspace continuation is a real provider turn even though its request + * row is hidden; a later pure host notice must not replace it as the latest + * attributable turn. */ - test('session changeset keeps its git fast path when the chat ends on a host notice turn', async () => { + test('session changeset attributes a hidden workspace continuation before a host notice', async () => { const git = createNoopGitService(); git.getRepositoryRoot = async wd => URI.parse(wd.toString()); const diffCalls: Array<{ fromRef: string; toRef: string }> = []; @@ -1841,15 +1842,22 @@ suite('AgentHostChangesetService - multi-root turn changeset', () => { const checkpoint: IAgentHostCheckpointService = { ...NULL_CHECKPOINT_SERVICE, getBaselineCheckpoint: async () => 'baseline', - // Mirrors production: only the agent's turn has a checkpoint. getTurnCheckpointPair: async (_session: URI, turnId: string) => - turnId === 'agent-turn' ? { parent: 'agent~p', current: 'agent~c' } : undefined, + turnId === 'continuation-turn' ? { parent: 'continuation~p', current: 'continuation~c' } : undefined, }; const { svc, stateManager } = build({ workingDirectories: ['file:///wd'], git, checkpoint }); const chat = buildDefaultChatUri(sessionStr); for (const turn of [ { id: 'agent-turn', message: { text: 'Edit edited.ts', origin: { kind: MessageKind.User } } }, + { + id: 'continuation-turn', + message: withMessageRequestHiddenFromTranscript({ + text: 'Continue in the requested workspace.', + origin: { kind: MessageKind.SystemNotification }, + _meta: toAgentWorkspaceContinuationMessageMeta(), + }, true), + }, { id: 'notice-turn', message: withMessageRequestHiddenFromTranscript( @@ -1869,11 +1877,83 @@ suite('AgentHostChangesetService - multi-root turn changeset', () => { diffCalls, files: stateManager.getChangesetState(buildSessionChangesetUri(sessionStr))?.files.map(file => file.id), }, { - diffCalls: [{ fromRef: 'baseline', toRef: 'agent~c' }], + diffCalls: [{ fromRef: 'baseline', toRef: 'continuation~c' }], files: [URI.file('/wd/edited.ts').toString()], }); }); + for (const hidden of [true, false]) { + test(`session changeset selects ${hidden ? 'the peer checkpoint after a host notice' : 'a newer real Agent Merge repair checkpoint'}`, async () => { + const mainDiff = gitDiff('/wd/a.ts'); + const peerDiff = gitDiff('/wd/b.ts'); + const repairDiff = gitDiff('/wd/c.ts'); + const diffsByCheckpoint = new Map([ + ['main-checkpoint', [mainDiff]], + ['peer-checkpoint', [mainDiff, peerDiff]], + ['repair-checkpoint', [mainDiff, peerDiff, repairDiff]], + ]); + const git = createNoopGitService(); + const diffCalls: Array<{ fromRef: string; toRef: string }> = []; + git.computeFileDiffsBetweenRefs = async (_wd, opts) => { + diffCalls.push({ fromRef: opts.fromRef, toRef: opts.toRef }); + return diffsByCheckpoint.get(opts.toRef); + }; + const checkpoints = new Map([ + ['main-edit', 'main-checkpoint'], + ['peer-edit', 'peer-checkpoint'], + ['repair', 'repair-checkpoint'], + ]); + const checkpoint: IAgentHostCheckpointService = { + ...NULL_CHECKPOINT_SERVICE, + getBaselineCheckpoint: async () => 'baseline', + getTurnCheckpointPair: async (_session, turnId) => { + const current = checkpoints.get(turnId); + return current ? { parent: 'baseline', current } : undefined; + }, + }; + const { svc, stateManager } = build({ workingDirectories: ['file:///wd'], git, checkpoint }); + const mainChat = buildDefaultChatUri(sessionStr); + const peerChat = buildChatUri(sessionStr, 'peer-1'); + stateManager.addChat(sessionStr, peerChat); + + for (const [chat, turnId, startedAt] of [ + [mainChat, 'main-edit', '2026-09-05T10:00:00.000Z'], + [peerChat, 'peer-edit', '2026-09-05T10:01:00.000Z'], + ]) { + stateManager.dispatchServerAction(chat, { type: ActionType.ChatTurnStarted, turnId, startedAt, message: { text: 'Edit a file', origin: { kind: MessageKind.User } } }); + stateManager.dispatchServerAction(chat, { type: ActionType.ChatTurnComplete, turnId, duration: 1000 }); + } + + const changesetUri = buildSessionChangesetUri(sessionStr); + svc.refreshSessionChangeset(sessionStr); + await waitForChangesetReady(stateManager, changesetUri); + + stateManager.dispatchServerAction(mainChat, { + type: ActionType.ChatTurnStarted, + turnId: hidden ? 'notice' : 'repair', + startedAt: '2026-09-05T10:02:00.000Z', + message: withMessageRequestHiddenFromTranscript({ + text: hidden ? 'Agent Merge is enabled.' : 'Fix the failing checks.', + origin: { kind: MessageKind.SystemNotification }, + }, hidden), + }); + stateManager.dispatchServerAction(mainChat, { type: ActionType.ChatTurnComplete, turnId: hidden ? 'notice' : 'repair', duration: 1000 }); + svc.refreshSessionChangeset(sessionStr); + await waitForChangesetReady(stateManager, changesetUri); + + assert.deepStrictEqual({ + diffCalls, + files: stateManager.getChangesetState(changesetUri)?.files.map(file => file.id), + }, { + diffCalls: [ + { fromRef: 'baseline', toRef: 'peer-checkpoint' }, + { fromRef: 'baseline', toRef: hidden ? 'peer-checkpoint' : 'repair-checkpoint' }, + ], + files: (hidden ? ['/wd/a.ts', '/wd/b.ts'] : ['/wd/a.ts', '/wd/b.ts', '/wd/c.ts']).map(path => URI.file(path).toString()), + }); + }); + } + /** * All-folder branch summary (AC-3). In a multi-folder session the * `summary.changes` chip must reflect EVERY folder's branch delta, computed diff --git a/src/vs/platform/agentHost/test/node/agentHostCheckoutOperationHandler.test.ts b/src/vs/platform/agentHost/test/node/agentHostCheckoutOperationHandler.test.ts index 2f288fcea81294..a847205eec3b47 100644 --- a/src/vs/platform/agentHost/test/node/agentHostCheckoutOperationHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostCheckoutOperationHandler.test.ts @@ -9,9 +9,9 @@ import { URI } from '../../../../base/common/uri.js'; import { mock } from '../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { NullLogService } from '../../../log/common/log.js'; -import { IAgentHostGitService } from '../../common/agentHostGitService.js'; +import { CheckoutBlockedByLocalChangesError, IAgentHostGitService } from '../../common/agentHostGitService.js'; import { buildUncommittedChangesetUri } from '../../common/changesetUri.js'; -import { checkoutOperationMeta } from '../../common/meta/agentCheckoutOperationMeta.js'; +import { CheckoutOperationPreAction, checkoutOperationMeta } from '../../common/meta/agentCheckoutOperationMeta.js'; import { JsonRpcErrorCodes, ProtocolError } from '../../common/state/sessionProtocol.js'; import { SessionStatus } from '../../common/state/sessionState.js'; import { AgentHostCheckoutOperationHandler } from '../../node/agentHostCheckoutOperationHandler.js'; @@ -20,7 +20,7 @@ import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; suite('AgentHostCheckoutOperationHandler', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); - test('checks out the selected branch from a clean working directory', async () => { + test('checks out the selected branch without probing the working directory', async () => { const session = URI.parse('agent:/session'); const workingDirectory = URI.file('/repo'); const stateManager = disposables.add(new AgentHostStateManager(new NullLogService())); @@ -39,7 +39,7 @@ suite('AgentHostCheckoutOperationHandler', () => { override async hasUncommittedChanges(resource: URI): Promise { gitCalls.push(`hasUncommittedChanges:${resource.toString()}`); - return false; + return true; } override async branchExists(resource: URI, branchName: string): Promise { @@ -94,7 +94,6 @@ suite('AgentHostCheckoutOperationHandler', () => { }, { gitCalls: [ `branchExists:${workingDirectory.toString()}:dev`, - `hasUncommittedChanges:${workingDirectory.toString()}`, `checkout:${workingDirectory.toString()}:dev`, `branchExists:${workingDirectory.toString()}:missing`, ], @@ -104,4 +103,151 @@ suite('AgentHostCheckoutOperationHandler', () => { missingBranchErrorCode: JsonRpcErrorCodes.InvalidParams, }); }); + + test('requires and performs a pre-checkout action after checkout fails for a dirty working directory', async () => { + const session = URI.parse('agent:/session'); + const workingDirectory = URI.file('/repo'); + const stateManager = disposables.add(new AgentHostStateManager(new NullLogService())); + stateManager.createSession({ + resource: session.toString(), + provider: 'copilot', + title: 'Session', + status: SessionStatus.Idle, + createdAt: new Date(1).toISOString(), + modifiedAt: new Date(1).toISOString(), + workingDirectories: [workingDirectory.toString()], + }); + const gitCalls: string[] = []; + const gitService = new class extends mock() { + declare readonly _serviceBrand: undefined; + dirty = true; + + override async branchExists(resource: URI, branchName: string): Promise { + gitCalls.push(`branchExists:${resource.toString()}:${branchName}`); + return true; + } + + override async createStash(resource: URI, options?: { readonly message?: string; readonly includeUntracked?: boolean; readonly staged?: boolean }): Promise { + gitCalls.push(`createStash:${resource.toString()}:${options?.message}:${options?.includeUntracked}:${options?.staged}`); + this.dirty = false; + } + + override async commitAll(resource: URI, message: string): Promise { + gitCalls.push(`commitAll:${resource.toString()}:${message}`); + this.dirty = false; + } + + override async checkout(resource: URI, treeish: string): Promise { + gitCalls.push(`checkout:${resource.toString()}:${treeish}`); + if (this.dirty) { + throw new CheckoutBlockedByLocalChangesError('Local changes would be overwritten by checkout.'); + } + } + }(); + const refreshedSessions: string[] = []; + const handler = new AgentHostCheckoutOperationHandler( + sessionKey => stateManager.getSessionState(sessionKey), + async sessionKey => { refreshedSessions.push(sessionKey); }, + gitService, + new NullLogService(), + ); + const invoke = (preCheckoutAction?: CheckoutOperationPreAction) => handler.invoke({ + channel: buildUncommittedChangesetUri(session.toString()), + operationId: AgentHostCheckoutOperationHandler.OPERATION_CHECKOUT, + _meta: checkoutOperationMeta('dev', preCheckoutAction), + }, CancellationToken.None); + + let dirtyError: ProtocolError | undefined; + try { + await invoke(); + } catch (error) { + dirtyError = error as ProtocolError; + } + const stashResult = await invoke(CheckoutOperationPreAction.Stash); + gitService.dirty = true; + const commitResult = await invoke(CheckoutOperationPreAction.Commit); + + assert.deepStrictEqual({ + dirtyError: { + code: dirtyError?.code, + data: dirtyError?.data, + }, + gitCalls, + refreshedSessions, + messages: [stashResult.message, commitResult.message], + }, { + dirtyError: { + code: JsonRpcErrorCodes.InvalidParams, + data: { reason: 'dirtyWorkingTree' }, + }, + gitCalls: [ + `branchExists:${workingDirectory.toString()}:dev`, + `checkout:${workingDirectory.toString()}:dev`, + `branchExists:${workingDirectory.toString()}:dev`, + `createStash:${workingDirectory.toString()}:WIP: Changes before checking out dev:true:undefined`, + `checkout:${workingDirectory.toString()}:dev`, + `branchExists:${workingDirectory.toString()}:dev`, + `commitAll:${workingDirectory.toString()}:WIP: Save changes before checking out dev`, + `checkout:${workingDirectory.toString()}:dev`, + ], + refreshedSessions: [session.toString(), session.toString()], + messages: [ + { markdown: 'Checked out branch `dev`.' }, + { markdown: 'Checked out branch `dev`.' }, + ], + }); + }); + + test('does not classify unrelated checkout failures as dirty working tree conflicts', async () => { + const session = URI.parse('agent:/session'); + const workingDirectory = URI.file('/repo'); + const stateManager = disposables.add(new AgentHostStateManager(new NullLogService())); + stateManager.createSession({ + resource: session.toString(), + provider: 'copilot', + title: 'Session', + status: SessionStatus.Idle, + createdAt: new Date(1).toISOString(), + modifiedAt: new Date(1).toISOString(), + workingDirectories: [workingDirectory.toString()], + }); + const gitService = new class extends mock() { + declare readonly _serviceBrand: undefined; + + override async branchExists(): Promise { + return true; + } + + override async checkout(): Promise { + throw new Error('Branch \'dev\' is already checked out in another worktree.'); + } + }(); + const handler = new AgentHostCheckoutOperationHandler( + sessionKey => stateManager.getSessionState(sessionKey), + () => { }, + gitService, + new NullLogService(), + ); + + let checkoutError: ProtocolError | undefined; + try { + await handler.invoke({ + channel: buildUncommittedChangesetUri(session.toString()), + operationId: AgentHostCheckoutOperationHandler.OPERATION_CHECKOUT, + _meta: checkoutOperationMeta('dev'), + }, CancellationToken.None); + } catch (error) { + checkoutError = error as ProtocolError; + } + + assert.deepStrictEqual({ + code: checkoutError?.code, + data: checkoutError?.data, + message: checkoutError?.message, + }, { + code: JsonRpcErrorCodes.InternalError, + data: undefined, + message: 'Failed to check out \'dev\': Branch \'dev\' is already checked out in another worktree.', + }); + }); }); diff --git a/src/vs/platform/agentHost/test/node/agentHostCommitOperationHandler.test.ts b/src/vs/platform/agentHost/test/node/agentHostCommitOperationHandler.test.ts index 7f48a5a9655792..b54f0f2fdedbf4 100644 --- a/src/vs/platform/agentHost/test/node/agentHostCommitOperationHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostCommitOperationHandler.test.ts @@ -53,6 +53,7 @@ class TestGitService implements IAgentHostGitService { this.calls.push('hasUncommittedChanges'); return this.uncommitted; } + async createStash(): Promise { } async commitAll(_workingDirectory: URI, message: string): Promise { this.calls.push(`commitAll:${message}`); this.uncommitted = false; diff --git a/src/vs/platform/agentHost/test/node/agentHostDiscardChangesOperationHandler.test.ts b/src/vs/platform/agentHost/test/node/agentHostDiscardChangesOperationHandler.test.ts index 604faab4b7c31b..eac90b81084b7d 100644 --- a/src/vs/platform/agentHost/test/node/agentHostDiscardChangesOperationHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostDiscardChangesOperationHandler.test.ts @@ -38,6 +38,7 @@ class TestGitService implements IAgentHostGitService { async createBranch(): Promise { } async checkout(): Promise { } async hasUncommittedChanges(): Promise { return true; } + async createStash(): Promise { } async commitAll(): Promise { } async mergeBranch(): Promise { return ''; } async restore(workingDirectory: URI, paths: readonly string[], options?: { readonly staged?: boolean; readonly ref?: string }): Promise { diff --git a/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts b/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts index 86693e21fa5902..2c8996f7d5f37a 100644 --- a/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts @@ -27,6 +27,7 @@ import { FileService } from '../../../files/common/fileService.js'; import { Schemas } from '../../../../base/common/network.js'; import { DiskFileSystemProvider } from '../../../files/node/diskFileSystemProvider.js'; import { DisposableStore } from '../../../../base/common/lifecycle.js'; +import { CheckoutBlockedByLocalChangesError } from '../../common/agentHostGitService.js'; import { AgentHostGitService } from '../../node/agentHostGitService.js'; class TestLogService extends NullLogService { @@ -609,6 +610,32 @@ suite('AgentHostGitService - worktree helpers (real git)', () => { assert.strictEqual(cp.execFileSync('git', ['branch', '--show-current'], { cwd: dir, env, encoding: 'utf8' }).trim(), 'dev'); }); + (hasGit ? test : test.skip)('checkout identifies local changes that would be overwritten', async () => { + const dir = initRepo(); + const fs = await import('fs/promises'); + await fs.writeFile(join(dir, 'tracked.txt'), 'main'); + cp.execFileSync('git', ['add', 'tracked.txt'], { cwd: dir, env, stdio: 'pipe' }); + cp.execFileSync('git', ['commit', '-q', '-m', 'add tracked'], { cwd: dir, env, stdio: 'pipe' }); + cp.execFileSync('git', ['checkout', '-q', '-b', 'dev'], { cwd: dir, env, stdio: 'pipe' }); + await fs.writeFile(join(dir, 'tracked.txt'), 'dev'); + cp.execFileSync('git', ['commit', '-q', '-am', 'change tracked'], { cwd: dir, env, stdio: 'pipe' }); + cp.execFileSync('git', ['checkout', '-q', 'main'], { cwd: dir, env, stdio: 'pipe' }); + await fs.writeFile(join(dir, 'tracked.txt'), 'local'); + + await assert.rejects( + svc!.checkout(URI.file(dir), 'dev'), + error => error instanceof CheckoutBlockedByLocalChangesError, + ); + + assert.deepStrictEqual({ + branch: cp.execFileSync('git', ['branch', '--show-current'], { cwd: dir, env, encoding: 'utf8' }).trim(), + content: await fs.readFile(join(dir, 'tracked.txt'), 'utf8'), + }, { + branch: 'main', + content: 'local', + }); + }); + (hasGit ? test : test.skip)('hasUncommittedChanges flips with untracked and committed work', async () => { const dir = initRepo(); assert.strictEqual(await svc!.hasUncommittedChanges(URI.file(dir)), false); @@ -665,6 +692,56 @@ suite('AgentHostGitService - worktree helpers (real git)', () => { }); }); + (hasGit ? test : test.skip)('createStash stashes tracked, staged and untracked changes', async () => { + const dir = initRepo(); + const fs = await import('fs/promises'); + await fs.writeFile(join(dir, 'tracked.txt'), 'before'); + cp.execFileSync('git', ['add', 'tracked.txt'], { cwd: dir, env, stdio: 'pipe' }); + cp.execFileSync('git', ['commit', '-q', '-m', 'add tracked'], { cwd: dir, env, stdio: 'pipe' }); + + await fs.writeFile(join(dir, 'tracked.txt'), 'after'); + await fs.writeFile(join(dir, 'staged.txt'), 'staged'); + cp.execFileSync('git', ['add', 'staged.txt'], { cwd: dir, env, stdio: 'pipe' }); + await fs.writeFile(join(dir, 'untracked.txt'), 'untracked'); + + await svc!.createStash(URI.file(dir), { message: 'stash all changes', includeUntracked: true }); + + const status = cp.execFileSync('git', ['status', '--porcelain'], { cwd: dir, env, encoding: 'utf8' }).trim(); + const stash = cp.execFileSync('git', ['stash', 'list', '--format=%s'], { cwd: dir, env, encoding: 'utf8' }).trim(); + const stashedFiles = cp.execFileSync('git', ['stash', 'show', '--name-only', '--include-untracked', 'stash@{0}'], { cwd: dir, env, encoding: 'utf8' }).trim().split(/\r?\n/g).sort(); + + assert.deepStrictEqual({ status, stash, stashedFiles }, { + status: '', + stash: 'On main: stash all changes', + stashedFiles: ['staged.txt', 'tracked.txt', 'untracked.txt'], + }); + }); + + (hasGit ? test : test.skip)('createStash can stash only staged changes', async () => { + const dir = initRepo(); + const fs = await import('fs/promises'); + await fs.writeFile(join(dir, 'tracked.txt'), 'before'); + cp.execFileSync('git', ['add', 'tracked.txt'], { cwd: dir, env, stdio: 'pipe' }); + cp.execFileSync('git', ['commit', '-q', '-m', 'add tracked'], { cwd: dir, env, stdio: 'pipe' }); + + await fs.writeFile(join(dir, 'tracked.txt'), 'after'); + await fs.writeFile(join(dir, 'staged.txt'), 'staged'); + cp.execFileSync('git', ['add', 'staged.txt'], { cwd: dir, env, stdio: 'pipe' }); + await fs.writeFile(join(dir, 'untracked.txt'), 'untracked'); + + await svc!.createStash(URI.file(dir), { message: 'staged changes', staged: true }); + + const status = cp.execFileSync('git', ['status', '--porcelain'], { cwd: dir, env, encoding: 'utf8' }).trimEnd().split(/\r?\n/g).sort(); + const stash = cp.execFileSync('git', ['stash', 'list', '--format=%s'], { cwd: dir, env, encoding: 'utf8' }).trim(); + const stashedFiles = cp.execFileSync('git', ['stash', 'show', '--name-only', 'stash@{0}'], { cwd: dir, env, encoding: 'utf8' }).trim().split(/\r?\n/g).sort(); + + assert.deepStrictEqual({ status, stash, stashedFiles }, { + status: [' M tracked.txt', '?? untracked.txt'], + stash: 'On main: staged changes', + stashedFiles: ['staged.txt'], + }); + }); + (hasGit ? test : test.skip)('mergeBranch merges into the current branch', async () => { const dir = initRepo(); const fs = await import('fs/promises'); diff --git a/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts b/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts index 44ae250540c6b9..74e6b5f0fcb853 100644 --- a/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts @@ -103,6 +103,7 @@ class TestGitService implements IAgentHostGitService { this.calls.push('hasUncommittedChanges'); return this.uncommitted; } + async createStash(): Promise { } async commitAll(_workingDirectory: URI, message: string): Promise { this.calls.push(`commitAll:${message}`); this.uncommitted = false; diff --git a/src/vs/platform/agentHost/test/node/agentSdkDownloader.test.ts b/src/vs/platform/agentHost/test/node/agentSdkDownloader.test.ts index b97ae40414277e..7944b665e18b72 100644 --- a/src/vs/platform/agentHost/test/node/agentSdkDownloader.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSdkDownloader.test.ts @@ -12,6 +12,7 @@ import { CancellationTokenSource } from '../../../../base/common/cancellation.js import { DisposableStore } from '../../../../base/common/lifecycle.js'; import { Schemas } from '../../../../base/common/network.js'; import * as path from '../../../../base/common/path.js'; +import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { TestConfigurationService } from '../../../configuration/test/common/testConfigurationService.js'; import { FileService } from '../../../files/common/fileService.js'; @@ -22,6 +23,7 @@ import { NullTelemetryService, NullTelemetryServiceShape } from '../../../teleme import type { ITelemetryService } from '../../../telemetry/common/telemetry.js'; import { RequestService } from '../../../request/node/requestService.js'; import { AgentSdkDownloader, resolveSdkTarget, type IAgentSdkPackage, type IAgentSdkDownloadProgress } from '../../node/agentSdkDownloader.js'; +import { AgentHostStorageService, type IAgentHostStorageService } from '../../node/agentHostStorageService.js'; import { ClaudeSdkPackage } from '../../node/claude/claudeAgentSdkService.js'; import { AgentHostClaudeSdkRootEnvVar } from '../../common/agentService.js'; import type { INativeEnvironmentService } from '../../../environment/common/environment.js'; @@ -244,11 +246,16 @@ suite('AgentSdkDownloader', () => { * explicitly. Pass `productConfig: null` to omit the agentSdks block * entirely (the "no product config" case). */ - function makeDownloader(productConfig?: { version?: string; urlTemplate?: string } | null, telemetryService: ITelemetryService = NullTelemetryService) { + function makeDownloader( + productConfig?: { version?: string; urlTemplate?: string } | null, + telemetryService: ITelemetryService = NullTelemetryService, + storageService?: IAgentHostStorageService, + ) { const config = productConfig === null ? undefined : { version: productConfig?.version ?? '1.0.0', urlTemplate: productConfig?.urlTemplate ?? `http://127.0.0.1:${server.port}/sdk-{sdkTarget}.tgz`, }; + const storage = storageService ?? disposables.add(new AgentHostStorageService(undefined, new NullLogService())); return disposables.add(new AgentSdkDownloader( makeEnvService(userDataPath), makeProductService(config), @@ -256,6 +263,7 @@ suite('AgentSdkDownloader', () => { makeFileService(disposables), new NullLogService(), telemetryService, + storage, )); } @@ -390,11 +398,39 @@ suite('AgentSdkDownloader', () => { test('loadSdkRoot: cache hit returns immediately without re-downloading', async () => { const downloader = makeDownloader(); await downloader.loadSdkRoot(ClaudeSdkPackage, newToken()); - assert.strictEqual(server.requestCount, 1); // Second call hits the cache. await downloader.loadSdkRoot(ClaudeSdkPackage, newToken()); - assert.strictEqual(server.requestCount, 1, 'cache hit should not re-download'); + assert.deepStrictEqual({ + requests: server.requestCount, + downloadConsent: downloader.hasDownloadConsent(ClaudeSdkPackage), + }, { + requests: 1, + downloadConsent: false, + }); + }); + + test('download consent persists in host storage without an SDK cache', async () => { + const storageResource = URI.file(path.join(userDataPath, 'agent-host-storage.json')); + const firstStorage = disposables.add(new AgentHostStorageService(storageResource, new NullLogService())); + const first = makeDownloader(undefined, NullTelemetryService, firstStorage); + await first.recordDownloadConsent(ClaudeSdkPackage); + + const restartedStorage = disposables.add(new AgentHostStorageService(storageResource, new NullLogService())); + const restarted = makeDownloader(undefined, NullTelemetryService, restartedStorage); + const otherPackage: IAgentSdkPackage = { ...ClaudeSdkPackage, id: 'codex' }; + + assert.deepStrictEqual({ + first: first.hasDownloadConsent(ClaudeSdkPackage), + restarted: restarted.hasDownloadConsent(ClaudeSdkPackage), + otherPackage: restarted.hasDownloadConsent(otherPackage), + sdkLocal: await restarted.isSdkResolvableWithoutDownload(ClaudeSdkPackage), + }, { + first: true, + restarted: true, + otherPackage: false, + sdkLocal: false, + }); }); test('loadSdkRoot: cache dir includes sdkTarget so Universal launches stay separate', async () => { diff --git a/src/vs/platform/agentHost/test/node/agentSdkSetupChannel.test.ts b/src/vs/platform/agentHost/test/node/agentSdkSetupChannel.test.ts new file mode 100644 index 00000000000000..28e6e64d97361e --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentSdkSetupChannel.test.ts @@ -0,0 +1,276 @@ +/*--------------------------------------------------------------------------------------------- + * 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 } from '../../../../base/common/event.js'; +import { IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { mock } from '../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { NullLogService } from '../../../log/common/log.js'; +import { AGENT_SDK_SETUP_DOWNLOAD_REQUEST_KEY, agentSdkSetupStatusKey, type AgentSdkDownloadStatus } from '../../common/agentSdkSetup.js'; +import { IAgentConfigurationService } from '../../node/agentConfigurationService.js'; +import { AgentSdkSetupChannel, type IAgentSdkSetupChannelAgent } from '../../node/agentSdkSetupChannel.js'; +import { IAgentSdkDownloader, type IAgentSdkDownloadProgress, type IAgentSdkPackage } from '../../node/agentSdkDownloader.js'; + +const sdkPackage: IAgentSdkPackage = { + id: 'claude', + displayName: 'Claude', + devOverrideEnvVar: 'TEST_CLAUDE_SDK_ROOT', + hasSeparateMuslLinuxPackage: true, +}; + +class TestConfigurationService extends mock() implements IDisposable { + private readonly _onDidRootConfigChange = new Emitter(); + override readonly onDidRootConfigChange = this._onDidRootConfigChange.event; + readonly statuses: AgentSdkDownloadStatus[] = []; + readonly values: Record = {}; + + override getRootConfigValues(): Readonly> { + return this.values; + } + + override updateRootConfig(patch: Record): void { + Object.assign(this.values, patch); + this._onDidRootConfigChange.fire(); + } + + override publishRootTransientValues(patch: Readonly>): void { + const value = patch[agentSdkSetupStatusKey('claude')]; + if (typeof value !== 'object' || value === null) { + return; + } + const download: unknown = Reflect.get(value, 'download'); + if (download === 'notDownloaded' || download === 'downloadOnUse' || download === 'downloading' || download === 'ready') { + this.statuses.push(download); + } + } + + dispose(): void { + this._onDidRootConfigChange.dispose(); + } +} + +suite('AgentSdkSetupChannel', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + function progress(phase: IAgentSdkDownloadProgress['phase'], packageId = 'claude'): IAgentSdkDownloadProgress { + return { + downloadId: 'download-1', + packageId, + displayName: 'Claude', + phase, + receivedBytes: 0, + totalBytes: undefined, + explicitlyRequested: false, + }; + } + + function createChannel(options: { + configuration?: TestConfigurationService; + downloadConsent?: boolean; + downloadSdk?: () => Promise; + isSdkLocal?: () => Promise; + } = {}): { + channel: AgentSdkSetupChannel; + configuration: TestConfigurationService; + downloads: Emitter; + lookedAgain: string[]; + downloadCalls: () => number; + downloadConsents: () => readonly string[]; + progressInterests: string[]; + } { + const configuration = options.configuration ?? store.add(new TestConfigurationService()); + const downloads = store.add(new Emitter()); + const lookedAgain: string[] = []; + const downloadConsents = new Set(options.downloadConsent ? ['claude'] : []); + const progressInterests: string[] = []; + let downloadCalls = 0; + const downloader = new class extends mock() { + override readonly onDidDownloadProgress = downloads.event; + override hasDownloadConsent = (pkg: IAgentSdkPackage) => downloadConsents.has(pkg.id); + override recordDownloadConsent = async (pkg: IAgentSdkPackage) => { downloadConsents.add(pkg.id); }; + override acquireDownloadProgressInterest = () => { + progressInterests.push('claude'); + return toDisposable(() => { }); + }; + }(); + const agent: IAgentSdkSetupChannelAgent = { + id: 'claude', + sdkPackage, + setupInfo: {}, + isSdkLocal: options.isSdkLocal ?? (async () => false), + downloadSdk: async () => { + downloadCalls++; + await options.downloadSdk?.(); + }, + restartChatDiscovery: () => { lookedAgain.push('discovery'); }, + refreshModels: async () => { lookedAgain.push('models'); }, + }; + const channel = store.add(new AgentSdkSetupChannel(agent, configuration, downloader, new NullLogService())); + return { channel, configuration, downloads, lookedAgain, downloadCalls: () => downloadCalls, downloadConsents: () => [...downloadConsents], progressInterests }; + } + + test('a first-turn download replaces the offer with downloading and then ready', async () => { + const { configuration, downloads, lookedAgain, downloadConsents } = createChannel(); + await timeout(0); + + downloads.fire(progress('started')); + downloads.fire(progress('completed')); + await timeout(0); + + assert.deepStrictEqual({ + statuses: configuration.statuses, + lookedAgain, + downloadConsents: downloadConsents(), + }, { + statuses: ['notDownloaded', 'downloading', 'ready'], + lookedAgain: ['discovery', 'models'], + downloadConsents: ['claude'], + }); + }); + + test('a failed first-turn download returns to the offer', async () => { + const { configuration, downloads, downloadConsents } = createChannel(); + await timeout(0); + downloads.fire(progress('started')); + downloads.fire(progress('failed')); + await timeout(0); + + assert.deepStrictEqual({ + statuses: configuration.statuses, + downloadConsents: downloadConsents(), + }, { + statuses: ['notDownloaded', 'downloading', 'notDownloaded'], + downloadConsents: ['claude'], + }); + }); + + test('another agent download does not change this agent status', async () => { + const { configuration, downloads, downloadConsents } = createChannel(); + await timeout(0); + + downloads.fire(progress('started', 'codex')); + + assert.deepStrictEqual({ + statuses: configuration.statuses, + downloadConsents: downloadConsents(), + }, { + statuses: ['notDownloaded'], + downloadConsents: [], + }); + }); + + test('standing consent waits for SDK use instead of downloading on host startup', async () => { + const ctx = createChannel({ downloadConsent: true }); + await timeout(0); + + assert.deepStrictEqual({ + statuses: ctx.configuration.statuses, + downloads: ctx.downloadCalls(), + progressInterests: ctx.progressInterests, + lookedAgain: ctx.lookedAgain, + }, { + statuses: ['downloadOnUse'], + downloads: 0, + progressInterests: [], + lookedAgain: [], + }); + }); + + test('an explicit request records host consent and surfaces progress', async () => { + const ctx = createChannel(); + await timeout(0); + + ctx.configuration.updateRootConfig({ + [AGENT_SDK_SETUP_DOWNLOAD_REQUEST_KEY]: { agent: 'claude', request: 'request-1' }, + }); + await timeout(0); + + assert.deepStrictEqual({ + downloadConsents: ctx.downloadConsents(), + downloads: ctx.downloadCalls(), + progressInterests: ctx.progressInterests, + statuses: ctx.configuration.statuses, + }, { + downloadConsents: ['claude'], + downloads: 1, + progressInterests: ['claude'], + statuses: ['notDownloaded', 'downloading', 'ready'], + }); + }); + + test('a late startup probe cannot overwrite a completed lazy download', async () => { + const sdkIsLocal = new DeferredPromise(); + const ctx = createChannel({ isSdkLocal: () => sdkIsLocal.p }); + await timeout(0); + + ctx.downloads.fire(progress('started')); + ctx.downloads.fire(progress('completed')); + await timeout(0); + const whileStartupProbeIsPending = [...ctx.configuration.statuses]; + + await sdkIsLocal.complete(false); + await timeout(0); + + assert.deepStrictEqual({ + whileStartupProbeIsPending, + statuses: ctx.configuration.statuses, + lookedAgain: ctx.lookedAgain, + }, { + whileStartupProbeIsPending: ['downloading'], + statuses: ['downloading', 'downloading', 'ready'], + lookedAgain: ['discovery', 'models'], + }); + }); + + test('an explicit request racing lazy completion does not start a second download', async () => { + const ctx = createChannel(); + await timeout(0); + + ctx.downloads.fire(progress('started')); + ctx.downloads.fire(progress('completed')); + ctx.configuration.updateRootConfig({ + [AGENT_SDK_SETUP_DOWNLOAD_REQUEST_KEY]: { agent: 'claude', request: 'request-1' }, + }); + await timeout(0); + + assert.deepStrictEqual({ + downloads: ctx.downloadCalls(), + progressInterests: ctx.progressInterests, + statuses: ctx.configuration.statuses, + }, { + downloads: 0, + progressInterests: [], + statuses: ['notDownloaded', 'downloading', 'ready'], + }); + }); + + test('repeated explicit requests while another operation is running queue one download', async () => { + const sdkIsLocal = new DeferredPromise(); + const ctx = createChannel({ isSdkLocal: () => sdkIsLocal.p }); + await timeout(0); + + ctx.configuration.updateRootConfig({ + [AGENT_SDK_SETUP_DOWNLOAD_REQUEST_KEY]: { agent: 'claude', request: 'request-1' }, + }); + ctx.configuration.updateRootConfig({ + [AGENT_SDK_SETUP_DOWNLOAD_REQUEST_KEY]: { agent: 'claude', request: 'request-2' }, + }); + await sdkIsLocal.complete(false); + await timeout(0); + + assert.deepStrictEqual({ + downloads: ctx.downloadCalls(), + progressInterests: ctx.progressInterests, + statuses: ctx.configuration.statuses, + }, { + downloads: 1, + progressInterests: ['claude'], + statuses: ['downloading', 'downloading', 'ready'], + }); + }); + +}); diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 32a44e14179dce..a0d1b7b94fe90f 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -6655,6 +6655,7 @@ suite('AgentService (node dispatcher)', () => { createBranch: async () => { }, checkout: async () => { }, hasUncommittedChanges: async () => false, + createStash: async () => { }, commitAll: async () => { }, mergeBranch: async () => '', restore: async () => { }, @@ -6764,6 +6765,7 @@ suite('AgentService (node dispatcher)', () => { createBranch: async () => { }, checkout: async () => { }, hasUncommittedChanges: async () => false, + createStash: async () => { }, commitAll: async () => { }, mergeBranch: async () => '', hasUpstream: async () => false, diff --git a/src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts b/src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts index d1a035c5738382..ce31d1b79d0c8f 100644 --- a/src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts @@ -69,10 +69,12 @@ import { createNoopCustomizationEnablementService } from './testCustomizationEna import { IAgentHostAuthenticationService } from '../../node/agentHostAuthenticationService.js'; import { ClaudeAgent } from '../../node/claude/claudeAgent.js'; import { IClaudeAgentSdkService } from '../../node/claude/claudeAgentSdkService.js'; +import { IAgentSdkDownloader } from '../../node/agentSdkDownloader.js'; import { IAgentPluginManager } from '../../common/agentPluginManager.js'; import { ClaudeProxyService, IClaudeProxyService } from '../../node/claude/claudeProxyService.js'; import { ICopilotApiService, type ICopilotApiServiceRequestOptions } from '../../node/shared/copilotApiService.js'; import { createNoopGitService, createSessionDataService } from '../common/sessionTestHelpers.js'; +import { RecordingAgentSdkDownloader } from './testAgentSdkDownloader.js'; import { makeContentBlockStartText, makeContentBlockStartToolUse, @@ -721,6 +723,7 @@ suite('ClaudeAgent integration (proxy-backed)', function () { [IClaudeProxyService, realProxy], [ISessionDataService, createSessionDataService()], [IClaudeAgentSdkService, sdk], + [IAgentSdkDownloader, new RecordingAgentSdkDownloader()], [IAgentPluginManager, { _serviceBrand: undefined, basePath: URI.from({ scheme: 'inmemory', path: '/agentPlugins' }), @@ -860,6 +863,7 @@ suite('ClaudeAgent integration (proxy-backed)', function () { [IClaudeProxyService, realProxy], [ISessionDataService, createSessionDataService()], [IClaudeAgentSdkService, sdk], + [IAgentSdkDownloader, new RecordingAgentSdkDownloader()], [IAgentPluginManager, { _serviceBrand: undefined, basePath: URI.from({ scheme: 'inmemory', path: '/agentPlugins' }), @@ -941,6 +945,7 @@ suite('ClaudeAgent integration (proxy-backed)', function () { [IClaudeProxyService, realProxy], [ISessionDataService, createSessionDataService()], [IClaudeAgentSdkService, sdk], + [IAgentSdkDownloader, new RecordingAgentSdkDownloader()], [IAgentPluginManager, { _serviceBrand: undefined, basePath: URI.from({ scheme: 'inmemory', path: '/agentPlugins' }), diff --git a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts index 42012b007a6a26..fde567e90763e7 100644 --- a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts @@ -5228,18 +5228,10 @@ suite('ClaudeAgent', () => { }); }); - test('neither restore nor cold discovery pulls the SDK down', async () => { - // Regression: when a materialized Claude session is restored on - // startup (the renderer subscribes to the last-active session), the - // host's restore path calls `getChatMetadata` -> `getSessionInfo` - // and `chats.getMessages`, both of which dynamically import the SDK. - // Before the fix that eagerly triggered a cold SDK download (with no - // progress interest registered, so no notification) purely from - // preselecting/restoring Claude — the download must only start on the - // first user message. Discovery used to be exempt and fetch in the - // background; it no longer is, since the download is the user's call. + test('restoring chat history downloads a cold SDK while passive metadata and discovery stay cold', async () => { const sdk = new FakeClaudeAgentSdkService(); sdk.canLoadWithoutDownloadResult = false; + sdk.ensureAvailableGate = Promise.resolve().then(() => { sdk.canLoadWithoutDownloadResult = true; }); sdk.sessionList = [ { sessionId: 'materialized', summary: 'Materialized Session', lastModified: 5000, createdAt: 4900, cwd: '/work' }, ]; @@ -5264,26 +5256,27 @@ suite('ClaudeAgent', () => { const sessionUri = AgentSession.uri('claude', 'materialized'); const chat = defaultChatUri(sessionUri); - const metadata = await agent.getChatMetadata(chat, chatContext(chat)); + const passiveMetadata = await agent.getChatMetadata(chat, chatContext(chat)); + const restoredMetadata = await agent.getChatMetadata(chat, chatContext(chat), undefined, { activation: 'restore' }); await bindDefaultChat(agent, sessionUri); const messages = await agent.chats.getMessages(defaultChatUri(sessionUri), chatContext(defaultChatUri(sessionUri))); await timeout(0); assert.deepStrictEqual({ - metadata, - messages, - // Nothing reachable from restore or discovery may touch the SDK - // while it is absent, whether to read it or to fetch it. + passiveMetadata, + restoredSummary: restoredMetadata?.summary, + messageCount: messages.length, getSessionInfoCalls: sdk.getSessionInfoCalls, getSessionMessagesCalls: sdk.getSessionMessagesCalls, availabilityRequests: sdk.ensureAvailableCalls, discoveredChats, }, { - metadata: undefined, - messages: [], - getSessionInfoCalls: [], - getSessionMessagesCalls: [], - availabilityRequests: 0, + passiveMetadata: undefined, + restoredSummary: 'Materialized Session', + messageCount: 2, + getSessionInfoCalls: ['materialized'], + getSessionMessagesCalls: [{ sessionId: 'materialized', options: { includeSystemMessages: true } }], + availabilityRequests: 1, discoveredChats: [], }); }); diff --git a/src/vs/platform/agentHost/test/node/codex/codexCreateChat.test.ts b/src/vs/platform/agentHost/test/node/codex/codexCreateChat.test.ts index 61fd1575cb4b39..ec9b6ae269245a 100644 --- a/src/vs/platform/agentHost/test/node/codex/codexCreateChat.test.ts +++ b/src/vs/platform/agentHost/test/node/codex/codexCreateChat.test.ts @@ -195,6 +195,8 @@ async function createAgent(disposables: Pick, options: I instantiationService.stub(IAgentSdkDownloader, { _serviceBrand: undefined, onDidDownloadProgress: Event.None, + hasDownloadConsent: () => false, + recordDownloadConsent: async () => { }, acquireDownloadProgressInterest: () => toDisposable(() => { }), loadSdkRoot: async () => { throw new Error('test stub: downloader.loadSdkRoot should not be called'); }, isAvailable: () => true, diff --git a/src/vs/platform/agentHost/test/node/codex/codexModelRefresh.test.ts b/src/vs/platform/agentHost/test/node/codex/codexModelRefresh.test.ts index cf746e617444d1..5401c9463de24b 100644 --- a/src/vs/platform/agentHost/test/node/codex/codexModelRefresh.test.ts +++ b/src/vs/platform/agentHost/test/node/codex/codexModelRefresh.test.ts @@ -229,7 +229,6 @@ suite('CodexAgent model refresh', () => { await firstRefreshStarted.complete(); await releaseFirstRefresh.p; } - return false; }; agent['_activate'](); diff --git a/src/vs/platform/agentHost/test/node/codex/codexPrewarmEviction.test.ts b/src/vs/platform/agentHost/test/node/codex/codexPrewarmEviction.test.ts index bed7b96fff06f1..d7075670036f88 100644 --- a/src/vs/platform/agentHost/test/node/codex/codexPrewarmEviction.test.ts +++ b/src/vs/platform/agentHost/test/node/codex/codexPrewarmEviction.test.ts @@ -57,6 +57,7 @@ import type { SelectedCapabilityRoot } from '../../../node/codex/protocol/genera import { createSessionDataService, RecordingCheckpointService, TestSessionDatabase } from '../../common/sessionTestHelpers.js'; import { createNoopCustomizationEnablementService } from '../testCustomizationEnablementService.js'; import { createTestAgentHostProxyResolver } from '../agentServiceTestUtils.js'; +import { RecordingAgentSdkDownloader } from '../testAgentSdkDownloader.js'; interface ITestWireRequest { readonly id: number; @@ -219,11 +220,7 @@ async function createAgent(disposables: Pick, options: I instantiationService.stub(IAgentHostCustomizationEnablementService, options.customizationEnablementService ?? createNoopCustomizationEnablementService()); instantiationService.stub(IAgentHostGitHubEndpointService, createTestGitHubEndpointService()); instantiationService.stub(IAgentHostProxyResolver, createTestAgentHostProxyResolver()); - instantiationService.stub(IAgentSdkDownloader, { - _serviceBrand: undefined, - isAvailable: () => true, - isSdkResolvableWithoutDownload: async () => true, - }); + instantiationService.stub(IAgentSdkDownloader, new RecordingAgentSdkDownloader()); instantiationService.stub(IAgentHostCheckpointService, options.checkpointService ?? NULL_CHECKPOINT_SERVICE); instantiationService.stub(IAgentHostOTelService, { _serviceBrand: undefined, diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index bb6887b953d88f..76ac7faf33b797 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -295,6 +295,7 @@ class TestAgentHostGitService implements IAgentHostGitService { async hasUncommittedChanges(workingDirectory: URI): Promise { return this.dirtyWorkingDirectories.has(workingDirectory.fsPath); } + async createStash(): Promise { } async commitAll(): Promise { } async mergeBranch(): Promise { return ''; } async restore(): Promise { } diff --git a/src/vs/platform/agentHost/test/node/copilotGitProject.test.ts b/src/vs/platform/agentHost/test/node/copilotGitProject.test.ts index c91f53e04aadbb..39049fb4d3b7a3 100644 --- a/src/vs/platform/agentHost/test/node/copilotGitProject.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotGitProject.test.ts @@ -34,6 +34,7 @@ class TestAgentHostGitService implements IAgentHostGitService { async createBranch(): Promise { } async checkout(): Promise { } async hasUncommittedChanges(): Promise { return false; } + async createStash(): Promise { } async commitAll(): Promise { } async mergeBranch(): Promise { return ''; } async restore(): Promise { } diff --git a/src/vs/platform/agentHost/test/node/devContainerAgentHostService.test.ts b/src/vs/platform/agentHost/test/node/devContainerAgentHostService.test.ts index 65711f1e35263f..b85cce7333a54e 100644 --- a/src/vs/platform/agentHost/test/node/devContainerAgentHostService.test.ts +++ b/src/vs/platform/agentHost/test/node/devContainerAgentHostService.test.ts @@ -6,7 +6,11 @@ import assert from 'assert'; import { spawnSync } from 'child_process'; import { existsSync } from 'fs'; +import { lstat, mkdir, mkdtemp, readFile, readdir, rm, symlink } from 'fs/promises'; +import { tmpdir } from 'os'; import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { DeferredPromise } from '../../../../base/common/async.js'; +import { join } from '../../../../base/common/path.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { mock } from '../../../../base/test/common/mock.js'; import { IProductService } from '../../../product/common/productService.js'; @@ -14,6 +18,8 @@ import { NullLogService } from '../../../log/common/log.js'; import { NullTelemetryService } from '../../../telemetry/common/telemetryUtils.js'; import { TestConfigurationService } from '../../../configuration/test/common/testConfigurationService.js'; import { INativeEnvironmentService } from '../../../environment/common/environment.js'; +import { IRequestService } from '../../../request/common/request.js'; +import { URI } from '../../../../base/common/uri.js'; import { DevContainerAgentHostMainService, getDevContainerCliPath, IDevContainerRelay, parseDevContainerUpResult } from '../../node/devContainerAgentHostService.js'; import { ISshExec } from '../../node/sshRemoteAgentHostHelpers.js'; @@ -34,12 +40,24 @@ class TestDevContainerAgentHostMainService extends DevContainerAgentHostMainServ readonly relay = new TestRelay(); readonly execCommands: string[] = []; relayCommand: string | undefined; + loadedCertificates = 0; + writtenCertificates: readonly string[] | undefined; + forceConcurrentRenameCollision = false; + private _renameCalls = 0; + private readonly _firstRenameStarted = new DeferredPromise(); + private readonly _secondRenameFinished = new DeferredPromise(); constructor( private readonly _libc = '', private readonly _forceCliInstall = false, private readonly _shellEnvironmentError?: Error, + private readonly _testShellEnvironment: typeof process.env = process.env, + systemCertificates = true, + _certificates: readonly string[] = [], + private readonly _existingCertificateFiles: ReadonlySet = new Set(), + testTmpDir = '/tmp', ) { + const configurationService = new TestConfigurationService({ 'http.systemCertificates': systemCertificates }); super( new NullLogService(), new class extends mock() { @@ -48,9 +66,16 @@ class TestDevContainerAgentHostMainService extends DevContainerAgentHostMainServ override readonly commit = undefined; }(), NullTelemetryService, - new TestConfigurationService(), + configurationService, new class extends mock() { override readonly args = Object.create(null); + override readonly tmpDir = URI.file(testTmpDir); + override readonly userDataPath = '/user-data'; + }(), + new class extends mock() { + override async loadCertificates(): Promise { + return [..._certificates]; + } }(), ); } @@ -59,13 +84,55 @@ class TestDevContainerAgentHostMainService extends DevContainerAgentHostMainServ if (this._shellEnvironmentError) { return Promise.reject(this._shellEnvironmentError); } - return Promise.resolve(process.env); + return Promise.resolve(this._testShellEnvironment); } resolveShellEnvironment(): Promise { return this._resolveShellEnvironment(); } + resolveDevContainerEnvironment(): Promise { + return this._resolveDevContainerEnvironment(); + } + + protected override _isFile(path: string): Promise { + return Promise.resolve(this._existingCertificateFiles.has(path)); + } + + protected override _writeCertificatesFile(certificates: readonly string[]): Promise { + this.loadedCertificates++; + this.writtenCertificates = certificates; + return Promise.resolve('/tmp/vscode-dev-container/certificates.pem'); + } + + writeCertificatesFile(certificates: readonly string[]): Promise { + return super._writeCertificatesFile(certificates); + } + + getCertificatesDirectory(): string { + return this._getCertificatesDirectory(); + } + + protected override async _renameCertificateFile(from: string, to: string): Promise { + if (!this.forceConcurrentRenameCollision) { + return super._renameCertificateFile(from, to); + } + this._renameCalls++; + if (this._renameCalls === 1) { + this._firstRenameStarted.complete(); + await this._secondRenameFinished.p; + const error = new Error('Target already exists') as NodeJS.ErrnoException; + error.code = 'EEXIST'; + throw error; + } + await this._firstRenameStarted.p; + try { + await super._renameCertificateFile(from, to); + } finally { + this._secondRenameFinished.complete(); + } + } + protected override _runDevContainer(connectionId: string, args: readonly string[]): Promise<{ stdout: string; stderr: string; code: number }> { assert.deepStrictEqual(args, ['up', '--workspace-folder', '/workspace']); this._reportOutput(connectionId, 'Starting Dev Container\n'); @@ -156,6 +223,81 @@ suite('Dev Container Agent Host Main Service', () => { }); }); + test('configures Dev Container CLI certificates like Remote Containers', async () => { + const suppliedCertificatePath = '/custom/certificates.pem'; + const supplied = store.add(new TestDevContainerAgentHostMainService('', false, undefined, { + ...process.env, + NODE_EXTRA_CA_CERTS: suppliedCertificatePath, + }, true, ['ignored'], new Set([suppliedCertificatePath]))); + const disabled = store.add(new TestDevContainerAgentHostMainService('', false, undefined, process.env, false, ['ignored'])); + const loaded = store.add(new TestDevContainerAgentHostMainService('', false, undefined, process.env, true, ['CERT A', 'CERT B'])); + + assert.deepStrictEqual({ + supplied: (await supplied.resolveDevContainerEnvironment()).NODE_EXTRA_CA_CERTS, + suppliedLoads: supplied.loadedCertificates, + disabled: (await disabled.resolveDevContainerEnvironment()).NODE_EXTRA_CA_CERTS, + disabledLoads: disabled.loadedCertificates, + loaded: (await loaded.resolveDevContainerEnvironment()).NODE_EXTRA_CA_CERTS, + loadedCertificates: loaded.writtenCertificates, + }, { + supplied: suppliedCertificatePath, + suppliedLoads: 0, + disabled: process.env.NODE_EXTRA_CA_CERTS, + disabledLoads: 0, + loaded: '/tmp/vscode-dev-container/certificates.pem', + loadedCertificates: ['CERT A', 'CERT B'], + }); + }); + + test('writes certificates to an owner-only cache and handles a concurrent writer', async () => { + const testTmpDir = await mkdtemp(join(tmpdir(), 'vscode-dev-container-certificates-')); + try { + const service = store.add(new TestDevContainerAgentHostMainService('', false, undefined, process.env, true, [], new Set(), testTmpDir)); + service.forceConcurrentRenameCollision = true; + const paths = await Promise.all([ + service.writeCertificatesFile(['CERT A', 'CERT B']), + service.writeCertificatesFile(['CERT A', 'CERT B']), + ]); + const directory = service.getCertificatesDirectory(); + const directoryStat = await lstat(directory); + const fileStat = await lstat(paths[0]); + + assert.deepStrictEqual({ + samePath: paths[0] === paths[1], + content: await readFile(paths[0], 'utf8'), + entries: await readdir(directory), + directoryIsSymlink: directoryStat.isSymbolicLink(), + directoryMode: process.platform === 'win32' ? undefined : directoryStat.mode & 0o777, + fileIsSymlink: fileStat.isSymbolicLink(), + fileMode: process.platform === 'win32' ? undefined : fileStat.mode & 0o777, + }, { + samePath: true, + content: `CERT A${process.platform === 'win32' ? '\r\n' : '\n'}CERT B`, + entries: [paths[0].slice(directory.length + 1)], + directoryIsSymlink: false, + directoryMode: process.platform === 'win32' ? undefined : 0o700, + fileIsSymlink: false, + fileMode: process.platform === 'win32' ? undefined : 0o600, + }); + } finally { + await rm(testTmpDir, { recursive: true, force: true }); + } + }); + + (process.platform === 'win32' ? test.skip : test)('rejects a symlinked certificate cache directory', async () => { + const testTmpDir = await mkdtemp(join(tmpdir(), 'vscode-dev-container-certificates-')); + try { + const service = store.add(new TestDevContainerAgentHostMainService('', false, undefined, process.env, true, [], new Set(), testTmpDir)); + const target = join(testTmpDir, 'target'); + await mkdir(target); + await symlink(target, service.getCertificatesDirectory()); + + await assert.rejects(service.writeCertificatesFile(['CERT']), /Owner-only path is not a directory/); + } finally { + await rm(testTmpDir, { recursive: true, force: true }); + } + }); + test('uses the inherited environment when shell environment resolution fails', async () => { const service = store.add(new TestDevContainerAgentHostMainService('', false, new Error('shell environment timeout'))); diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/managementExtensionsSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/managementExtensionsSuite.ts index 2ecdf7a840856a..63108b649af5e5 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/managementExtensionsSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/managementExtensionsSuite.ts @@ -433,7 +433,7 @@ export function defineManagementExtensionTests(context: IAgentHostE2ETestContext hasProvider: provider !== undefined, hasSnapshot: provider?.snapshot !== undefined, hasError: provider?.error !== undefined, - sourceIsValid: provider?.snapshot !== undefined && ['server', 'device', 'client', 'mixed', 'none'].includes(provider.snapshot.source), + sourceIsValid: provider?.snapshot !== undefined && ['server', 'device', 'client', 'policyHelper', 'mixed', 'none'].includes(provider.snapshot.source), managedKeysAreArray: Array.isArray(provider?.snapshot?.managedKeys), }, { hasProvider: true, diff --git a/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts b/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts index c1313b937fb882..99869b6535568c 100644 --- a/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts +++ b/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts @@ -6,10 +6,11 @@ import assert from 'assert'; import { tmpdir } from 'os'; import * as fs from 'fs/promises'; +import { DeferredPromise } from '../../../../base/common/async.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { DisposableStore } from '../../../../base/common/lifecycle.js'; import { SessionDatabase, runMigrations, sessionDatabaseMigrations, type ISessionDatabaseMigration } from '../../node/sessionDatabase.js'; -import { FileEditKind, MessageKind } from '../../common/state/sessionState.js'; +import { AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY, AH_META_WORKSPACELESS_DB_KEY, FileEditKind, MessageKind } from '../../common/state/sessionState.js'; import type { IReviewedFileRecord } from '../../common/sessionDataService.js'; import type { Database } from '@vscode/sqlite3'; import { generateUuid } from '../../../../base/common/uuid.js'; @@ -58,6 +59,9 @@ suite('SessionDatabase', () => { * Database instance, enabling reopen tests with :memory: databases. */ class TestableSessionDatabase extends SessionDatabase { + private nextMutationGate: { readonly started: DeferredPromise; readonly release: DeferredPromise } | undefined; + private nextMetadataAndTurnUsageMutationGate: { readonly started: DeferredPromise; readonly release: DeferredPromise } | undefined; + static override async open(path: string, migrations: readonly ISessionDatabaseMigration[] = sessionDatabaseMigrations): Promise { const inst = new TestableSessionDatabase(path, migrations); await inst._ensureDb(); @@ -78,6 +82,62 @@ suite('SessionDatabase', () => { }); } + async waitForRaw(): Promise { + const rawDb = await this._ensureDb(); + await new Promise(resolve => rawDb.wait(() => resolve())); + } + + async hasRawTurn(turnId: string): Promise { + const rawDb = await this._ensureDb(); + return new Promise((resolve, reject) => { + rawDb.get('SELECT 1 AS present FROM turns WHERE id = ?', [turnId], (err: Error | null, row: Record | undefined) => { + if (err) { + reject(err); + return; + } + resolve(row?.present === 1); + }); + }); + } + + blockNextMutation(): { readonly started: DeferredPromise; readonly release: DeferredPromise } { + const gate = { + started: new DeferredPromise(), + release: new DeferredPromise(), + }; + this.nextMutationGate = gate; + return gate; + } + + protected override _queueMutation(operation: (db: Database) => Promise): Promise { + const gate = this.nextMutationGate; + this.nextMutationGate = undefined; + return super._queueMutation(gate ? async db => { + gate.started.complete(); + await gate.release.p; + return operation(db); + } : operation); + } + + blockNextMetadataAndTurnUsageMutation(): { readonly started: DeferredPromise; readonly release: DeferredPromise } { + const gate = { + started: new DeferredPromise(), + release: new DeferredPromise(), + }; + this.nextMetadataAndTurnUsageMutationGate = gate; + return gate; + } + + protected override _mutateMetadataAndTurnUsage(operation: (db: Database) => Promise): Promise { + const gate = this.nextMetadataAndTurnUsageMutationGate; + this.nextMetadataAndTurnUsageMutationGate = undefined; + return super._mutateMetadataAndTurnUsage(gate ? async db => { + gate.started.complete(); + await gate.release.p; + await operation(db); + } : operation); + } + /** Extract the raw db connection; this instance becomes inert. */ async ejectDb(): Promise { const rawDb = await this._ensureDb(); @@ -660,6 +720,209 @@ suite('SessionDatabase', () => { }); }); + // ---- Workspace transitions ----------------------------------------- + + suite('workspace transitions', () => { + + test('restores transitions by host or provider turn id', async () => { + db = disposables.add(await SessionDatabase.open(':memory:')); + await db.setTurnWorkspaceTransition('host-turn', '{"content":"Now working in vscode"}'); + await db.setTurnEventId('host-turn', 'provider-turn'); + + assert.deepStrictEqual({ + transitions: [...(await db.getTurnWorkspaceTransitions()).entries()], + hasTransitions: await db.getMetadata(AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY), + }, { + transitions: [ + ['host-turn', '{"content":"Now working in vscode"}'], + ['provider-turn', '{"content":"Now working in vscode"}'], + ], + hasTransitions: 'true', + }); + }); + + test('transition reads wait for earlier turn event ID mappings', async () => { + const testDatabase = disposables.add(await TestableSessionDatabase.open(':memory:')); + await testDatabase.setTurnWorkspaceTransition('host-turn', '{"content":"Now working in vscode"}'); + const gate = testDatabase.blockNextMutation(); + const eventIdWrite = testDatabase.setTurnEventId('host-turn', 'provider-turn'); + await gate.started.p; + + const transitionsRead = testDatabase.getTurnWorkspaceTransitions(); + let readSettled = false; + void transitionsRead.then( + () => readSettled = true, + () => readSettled = true, + ); + await new Promise(resolve => setImmediate(resolve)); + await testDatabase.waitForRaw(); + const readSettledBeforeWrite = readSettled; + gate.release.complete(); + const transitions = await transitionsRead; + await eventIdWrite; + + assert.deepStrictEqual({ + readSettledBeforeWrite, + transitions: [...transitions.entries()], + }, { + readSettledBeforeWrite: false, + transitions: [ + ['host-turn', '{"content":"Now working in vscode"}'], + ['provider-turn', '{"content":"Now working in vscode"}'], + ], + }); + }); + + test('truncation and remapping bound transitions to their owning turns', async () => { + db = disposables.add(await SessionDatabase.open(':memory:')); + await db.setTurnWorkspaceTransition('old-1', '{"content":"Now working in one"}'); + await db.setTurnWorkspaceTransition('old-2', '{"content":"Now working in two"}'); + + await db.remapTurnIds(new Map([['old-1', 'new-1']])); + await db.deleteTurnsAfter('new-1'); + + assert.deepStrictEqual([...(await db.getTurnWorkspaceTransitions()).entries()], [ + ['new-1', '{"content":"Now working in one"}'], + ]); + }); + + test('migration v11 creates the workspace transition table', async () => { + db = disposables.add(await SessionDatabase.open(':memory:')); + const tables = await db.getAllTables(); + assert.ok(tables.includes('turn_workspace_transition')); + }); + + test('migration v12 backfills the transition history marker', async () => { + const v11Migrations = sessionDatabaseMigrations.filter(migration => migration.version <= 11); + const v11Database = await TestableSessionDatabase.open(':memory:', v11Migrations); + await v11Database.setTurnWorkspaceTransition('turn-1', '{"content":"Now working in vscode"}'); + await v11Database.deleteMetadata([AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY]); + const rawDb = await v11Database.ejectDb(); + + db = disposables.add(await TestableSessionDatabase.fromDb(rawDb)); + + assert.strictEqual(await db.getMetadata(AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY), 'true'); + }); + + test('deleting the last transition clears the history marker', async () => { + db = disposables.add(await SessionDatabase.open(':memory:')); + await db.setTurnWorkspaceTransition('turn-1', '{"content":"Now working in vscode"}'); + + await db.deleteTurn('turn-1'); + + assert.deepStrictEqual({ + transitions: [...(await db.getTurnWorkspaceTransitions()).entries()], + hasTransitions: await db.getMetadata(AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY), + }, { + transitions: [], + hasTransitions: undefined, + }); + }); + + test('workspace conversion metadata rolls back when transition persistence fails', async () => { + const testDatabase = disposables.add(await TestableSessionDatabase.open(':memory:')); + await testDatabase.runRaw(`CREATE TRIGGER fail_workspace_transition_insert + BEFORE INSERT ON turn_workspace_transition + BEGIN + SELECT RAISE(ABORT, 'transition write failed'); + END`); + + await assert.rejects(() => testDatabase.setWorkspaceConversion( + 'turn-1', + '{"content":"Now working in vscode"}', + { [AH_META_WORKSPACELESS_DB_KEY]: 'false' }, + ), /transition write failed/); + + assert.deepStrictEqual({ + transitions: [...(await testDatabase.getTurnWorkspaceTransitions()).entries()], + hasTransitions: await testDatabase.getMetadata(AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY), + workspaceless: await testDatabase.getMetadata(AH_META_WORKSPACELESS_DB_KEY), + }, { + transitions: [], + hasTransitions: undefined, + workspaceless: undefined, + }); + }); + + test('failed workspace conversion excludes concurrent unrelated mutations', async () => { + const testDatabase = disposables.add(await TestableSessionDatabase.open(':memory:')); + await testDatabase.runRaw(`CREATE TRIGGER fail_workspace_transition_insert + BEFORE INSERT ON turn_workspace_transition + BEGIN + SELECT RAISE(ABORT, 'transition write failed'); + END`); + const gate = testDatabase.blockNextMetadataAndTurnUsageMutation(); + const conversionFailure = assert.rejects(() => testDatabase.setWorkspaceConversion( + 'turn-1', + '{"content":"Now working in vscode"}', + { [AH_META_WORKSPACELESS_DB_KEY]: 'false' }, + ), /transition write failed/); + await gate.started.p; + + const unrelatedTurnWrite = testDatabase.createTurn('unrelated-turn'); + const unrelatedMetadataWrite = testDatabase.setMetadata('unrelated', 'persists'); + let unrelatedTurnSettled = false; + let unrelatedMetadataSettled = false; + void unrelatedTurnWrite.then( + () => unrelatedTurnSettled = true, + () => unrelatedTurnSettled = true, + ); + void unrelatedMetadataWrite.then( + () => unrelatedMetadataSettled = true, + () => unrelatedMetadataSettled = true, + ); + await new Promise(resolve => setImmediate(resolve)); + await testDatabase.waitForRaw(); + const unrelatedTurnInsideConversion = await testDatabase.hasRawTurn('unrelated-turn'); + const unrelatedInsideConversion = await testDatabase.getMetadata('unrelated'); + const unrelatedTurnSettledInsideConversion = unrelatedTurnSettled; + const unrelatedMetadataSettledInsideConversion = unrelatedMetadataSettled; + gate.release.complete(); + await Promise.all([conversionFailure, unrelatedTurnWrite, unrelatedMetadataWrite]); + + assert.deepStrictEqual({ + unrelatedTurnSettledInsideConversion, + unrelatedMetadataSettledInsideConversion, + unrelatedTurnInsideConversion, + unrelatedInsideConversion, + unrelatedTurn: await testDatabase.hasRawTurn('unrelated-turn'), + unrelated: await testDatabase.getMetadata('unrelated'), + workspaceless: await testDatabase.getMetadata(AH_META_WORKSPACELESS_DB_KEY), + hasTransitions: await testDatabase.getMetadata(AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY), + }, { + unrelatedTurnSettledInsideConversion: false, + unrelatedMetadataSettledInsideConversion: false, + unrelatedTurnInsideConversion: false, + unrelatedInsideConversion: undefined, + unrelatedTurn: true, + unrelated: 'persists', + workspaceless: undefined, + hasTransitions: undefined, + }); + }); + + test('marker cleanup failure rolls back turn deletion', async () => { + const testDatabase = disposables.add(await TestableSessionDatabase.open(':memory:')); + await testDatabase.setTurnWorkspaceTransition('turn-1', '{"content":"Now working in vscode"}'); + await testDatabase.runRaw(`CREATE TRIGGER fail_workspace_transition_marker_delete + BEFORE DELETE ON session_metadata + WHEN OLD.key = '${AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY}' + BEGIN + SELECT RAISE(ABORT, 'marker cleanup failed'); + END`); + + await assert.rejects(() => testDatabase.deleteTurn('turn-1'), /marker cleanup failed/); + + assert.deepStrictEqual({ + transitions: [...(await testDatabase.getTurnWorkspaceTransitions()).entries()], + hasTransitions: await testDatabase.getMetadata(AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY), + }, { + transitions: [['turn-1', '{"content":"Now working in vscode"}']], + hasTransitions: 'true', + }); + }); + }); + // ---- Turn checkpoint refs ------------------------------------------- suite('turn checkpoint refs', () => { diff --git a/src/vs/platform/agentHost/test/node/sessionWorkspaceConversion.test.ts b/src/vs/platform/agentHost/test/node/sessionWorkspaceConversion.test.ts index 43caf85bbeaf1a..803bb7e115e203 100644 --- a/src/vs/platform/agentHost/test/node/sessionWorkspaceConversion.test.ts +++ b/src/vs/platform/agentHost/test/node/sessionWorkspaceConversion.test.ts @@ -4,20 +4,31 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import * as fs from 'fs'; +import { tmpdir } from 'os'; +import { join } from '../../../../base/common/path.js'; import { DeferredPromise, timeout } from '../../../../base/common/async.js'; +import { generateUuid } from '../../../../base/common/uuid.js'; import { URI } from '../../../../base/common/uri.js'; import { mock } from '../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { NullLogService } from '../../../log/common/log.js'; import { AgentWorkingDirectoryChangedError, type IAgent } from '../../common/agent.js'; -import { schemaProperty } from '../../common/agentHostSchema.js'; +import type { IAgentHostChatContributionContext } from '../../common/agentHostChatContributionsService.js'; +import { AgentHostGlobalAutoApproveEnabledConfigKey, platformSessionSchema, schemaProperty } from '../../common/agentHostSchema.js'; +import { AgentSystemNotificationKind, AgentSystemNotificationWorkspaceKind, readAgentSystemNotificationMeta, serializeAgentWorkspaceTransition } from '../../common/meta/agentSystemNotificationMeta.js'; +import { isAgentWorkspaceContinuationMessage } from '../../common/meta/agentWorkspaceContinuationMeta.js'; +import type { ISessionDatabase } from '../../common/sessionDataService.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { ActionType } from '../../common/state/sessionActions.js'; -import { AH_META_WORKSPACE_CONVERSION_QUARANTINED_DB_KEY, AH_META_WORKSPACELESS_DB_KEY, buildDefaultChatUri, createErrorResponsePart, customizationId, CustomizationLoadStatus, CustomizationType, isMessageHiddenFromTranscript, MessageKind, readMessageSystemInitiatedLabel, readSessionWorkspaceless, ResponsePartKind, SessionStatus, withSessionWorkspaceless, type ErrorInfo, type Message } from '../../common/state/sessionState.js'; +import { AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY, AH_META_WORKSPACE_CONVERSION_QUARANTINED_DB_KEY, AH_META_WORKSPACELESS_DB_KEY, buildChatUri, buildDefaultChatUri, createErrorResponsePart, customizationId, CustomizationLoadStatus, CustomizationType, isHostNoticeTurn, isMessageHiddenFromTranscript, isMessageRequestHiddenFromTranscript, MessageKind, readMessageSystemInitiatedLabel, readSessionHasWorkspaceTransitions, readSessionWorkspaceless, ResponsePartKind, SessionStatus, TurnState, withSessionHasWorkspaceTransitions, withSessionWorkspaceless, type ErrorInfo, type Message, type Turn } from '../../common/state/sessionState.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; import type { IAgentHostClientConnectionService } from '../../node/agentHostClientConnectionService.js'; import type { IAgentHostTurnService, IDeferredAgentHostTurn } from '../../node/agentHostTurnService.js'; -import { SessionWorkspaceConversionService } from '../../node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionService.js'; +import { AgentConfigurationService } from '../../node/agentConfigurationService.js'; +import { SessionDatabase } from '../../node/sessionDatabase.js'; +import { SessionWorkspaceConversionContribution } from '../../node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionContribution.js'; +import { SessionWorkspaceConversionService, type ISessionWorkspaceConversionService } from '../../node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionService.js'; import type { IAgentHostServerToolService } from '../../node/shared/agentServerToolHost.js'; import { NullAgentHostWorktreeIsolation, type IIsolationConfigContribution, type IResolveIsolationConfigRequest, type IResolveWorkingDirectoryRequest, type ISessionWorktree } from '../../node/shared/worktreeIsolation.js'; import { createSessionDataService, TestSessionDatabase } from '../common/sessionTestHelpers.js'; @@ -95,9 +106,18 @@ class GatedConversionDatabase extends TestSessionDatabase { readonly releaseWrite = new DeferredPromise(); override async setMetadataValues(values: Readonly>): Promise { + await this._waitForRelease(); + await super.setMetadataValues(values); + } + + override async setWorkspaceConversion(turnId: string, transition: string, metadata: Readonly>): Promise { + await this._waitForRelease(); + await super.setWorkspaceConversion(turnId, transition, metadata); + } + + private async _waitForRelease(): Promise { this.writeStarted.complete(); await this.releaseWrite.p; - await super.setMetadataValues(values); } } @@ -107,10 +127,11 @@ suite('SessionWorkspaceConversionService', () => { function createHarness( worktreeIsolation = new NullAgentHostWorktreeIsolation(), requestWorkspaceTrust: IAgentHostClientConnectionService['requestWorkspaceTrust'] = async () => true, - database = new TestSessionDatabase(), + database: ISessionDatabase = new TestSessionDatabase(), ) { const logService = new NullLogService(); const stateManager = disposables.add(new AgentHostStateManager(logService)); + const configurationService = disposables.add(new AgentConfigurationService(stateManager, logService)); const sessionDataService = createSessionDataService(database); const agent = new MockAgent('copilot', { multipleChats: { fork: true } }, { workspaceConversion: true }); disposables.add({ dispose: () => agent.dispose() }); @@ -123,6 +144,7 @@ suite('SessionWorkspaceConversionService', () => { } }(); const continuations: { chat: string; message: Message }[] = []; + const outcomeKindsAtContinuation: Array> = []; const deferredContinuations: { chat: string; message: Message; turnId: string }[] = []; const failedContinuations: { chat: string; error: ErrorInfo; turnId: string }[] = []; let deferredTurnCounter = 0; @@ -143,6 +165,10 @@ suite('SessionWorkspaceConversionService', () => { if (stateManager.getActiveTurnId(targetChat.toString()) !== turn.turnId) { return false; } + const activeTurn = stateManager.getChatState(targetChat.toString())?.activeTurn; + outcomeKindsAtContinuation.push(activeTurn?.responseParts.flatMap(part => + part.kind === ResponsePartKind.SystemNotification ? [readAgentSystemNotificationMeta(part).kind] : [] + ) ?? []); continuations.push({ chat: targetChat.toString(), message }); return true; } @@ -167,7 +193,7 @@ suite('SessionWorkspaceConversionService', () => { refreshedServerTools.push(targetSession); } }(); - const service = disposables.add(new SessionWorkspaceConversionService(stateManager, providerService, sessionDataService, worktreeIsolation, clientConnections, turnService, serverToolHost, logService)); + const service = disposables.add(new SessionWorkspaceConversionService(stateManager, providerService, sessionDataService, worktreeIsolation, configurationService, clientConnections, turnService, serverToolHost, logService)); const session = URI.parse('copilot:/workspace-less'); const chat = URI.parse(buildDefaultChatUri(session)); const scratch = URI.file('/tmp/copilot-scratch/workspace-less'); @@ -181,7 +207,14 @@ suite('SessionWorkspaceConversionService', () => { workingDirectories: [scratch.toString()], _meta: withSessionWorkspaceless(undefined, true), }); - return { service, stateManager, database, agent, session, chat, scratch, continuations, deferredContinuations, failedContinuations, trustRequests, refreshedServerTools }; + return { service, stateManager, configurationService, sessionDataService, database, agent, session, chat, scratch, continuations, outcomeKindsAtContinuation, deferredContinuations, failedContinuations, trustRequests, refreshedServerTools }; + } + + function setSessionConfig(harness: ReturnType, values: Record): void { + harness.stateManager.setSessionConfig(harness.session.toString(), { + schema: platformSessionSchema.toProtocol(), + values, + }); } function startTurn(stateManager: AgentHostStateManager, chat: URI, turnId = 'turn-1'): void { @@ -205,6 +238,20 @@ suite('SessionWorkspaceConversionService', () => { return harness.service.updateSessionWorkspace(harness.chat.toString(), 'turn-1'); } + function createHydrationStateManager(session: URI, hasWorkspaceTransitions: boolean): AgentHostStateManager { + const stateManager = disposables.add(new AgentHostStateManager(new NullLogService())); + stateManager.createSession({ + resource: session.toString(), + provider: 'copilot', + title: hasWorkspaceTransitions ? 'Converted Session' : 'Normal Session', + status: SessionStatus.Idle, + createdAt: new Date(0).toISOString(), + modifiedAt: new Date(0).toISOString(), + _meta: withSessionHasWorkspaceTransitions(undefined, hasWorkspaceTransitions), + }); + return stateManager; + } + test('keeps a visible continuation in progress while converting after the invoking turn', async () => { const trustDecision = new DeferredPromise(); const harness = createHarness(new NullAgentHostWorktreeIsolation(), () => trustDecision.p); @@ -256,6 +303,9 @@ suite('SessionWorkspaceConversionService', () => { deferredContinuations: harness.deferredContinuations.map(entry => ({ chat: entry.chat, hidden: isMessageHiddenFromTranscript(entry.message), + requestHidden: isMessageRequestHiddenFromTranscript(entry.message), + workspaceContinuation: isAgentWorkspaceContinuationMessage(entry.message), + hostNotice: isHostNoticeTurn({ message: entry.message }), label: readMessageSystemInitiatedLabel(entry.message), origin: entry.message.origin.kind, text: entry.message.text, @@ -273,9 +323,12 @@ suite('SessionWorkspaceConversionService', () => { deferredContinuations: [{ chat: harness.chat.toString(), hidden: false, + requestHidden: true, + workspaceContinuation: true, + hostNotice: false, label: 'Continue in Requested Workspace', origin: MessageKind.SystemNotification, - text: 'Continue in the requested workspace.', + text: '\nContinue in the requested workspace.', turnId: 'continuation-1', }], continuations: [], @@ -293,13 +346,19 @@ suite('SessionWorkspaceConversionService', () => { pending: harness.service.isPending(harness.chat.toString()), workingDirectories: state?.workingDirectories, workspaceless: readSessionWorkspaceless(state?._meta), + hasWorkspaceTransitions: readSessionHasWorkspaceTransitions(state?._meta), persistedWorkspaceless: await harness.database.getMetadata(AH_META_WORKSPACELESS_DB_KEY), + persistedHasWorkspaceTransitions: await harness.database.getMetadata(AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY), refreshedServerTools: harness.refreshedServerTools, stateWhenCustomizationsRefreshed, customizations: state?.customizations, activity: harness.stateManager.getChatState(harness.chat.toString())?.activity, activeTurnId: harness.stateManager.getActiveTurnId(harness.chat.toString()), - outcomeNotifications: activeTurn?.responseParts.flatMap(part => part.kind === ResponsePartKind.SystemNotification ? [part.content] : []), + outcomeNotifications: activeTurn?.responseParts.flatMap(part => part.kind === ResponsePartKind.SystemNotification ? [{ + content: part.content, + meta: readAgentSystemNotificationMeta(part), + }] : []), + outcomeKindsAtContinuation: harness.outcomeKindsAtContinuation, continuations: harness.continuations.map(entry => ({ chat: entry.chat, hidden: isMessageHiddenFromTranscript(entry.message), @@ -320,7 +379,9 @@ suite('SessionWorkspaceConversionService', () => { pending: false, workingDirectories: ['file:///workspace/project'], workspaceless: false, + hasWorkspaceTransitions: true, persistedWorkspaceless: 'false', + persistedHasWorkspaceTransitions: 'true', refreshedServerTools: [harness.session.toString()], stateWhenCustomizationsRefreshed: { workingDirectories: ['file:///workspace/project'], @@ -329,7 +390,16 @@ suite('SessionWorkspaceConversionService', () => { customizations: [customization], activity: undefined, activeTurnId: 'continuation-1', - outcomeNotifications: ['Workspace Set'], + outcomeNotifications: [{ + content: 'Now working in project', + meta: { + kind: AgentSystemNotificationKind.WorkspaceTransition, + severity: undefined, + workspaceKind: AgentSystemNotificationWorkspaceKind.Folder, + workspaceName: 'project', + }, + }], + outcomeKindsAtContinuation: [[AgentSystemNotificationKind.WorkspaceTransition]], continuations: [{ chat: harness.chat.toString(), hidden: false, @@ -340,6 +410,233 @@ suite('SessionWorkspaceConversionService', () => { }); }); + test('skips transition storage for a normal loaded session', async () => { + const session = URI.parse('copilot:/normal-session'); + const database = new TestSessionDatabase(); + const baseSessionDataService = createSessionDataService(database); + let databaseOpenCalls = 0; + const sessionDataService = { + ...baseSessionDataService, + tryOpenDatabase: async (resource: URI) => { + databaseOpenCalls++; + return baseSessionDataService.tryOpenDatabase(resource); + }, + }; + const contribution = disposables.add(new SessionWorkspaceConversionContribution( + new class extends mock() { }(), + new class extends mock() { }(), + createHydrationStateManager(session, false), + sessionDataService, + new NullLogService(), + )); + const turns: Turn[] = [{ + id: 'turn-1', + message: { text: 'Implement the feature', origin: { kind: MessageKind.User } }, + responseParts: [{ kind: ResponsePartKind.Markdown, id: 'response-1', content: 'Done' }], + usage: undefined, + state: TurnState.Complete, + }]; + + const hydrated = await contribution.onHydrateTurns({ + session: session.toString(), + chat: buildDefaultChatUri(session), + }, turns); + + assert.deepStrictEqual({ + sameTurns: hydrated === turns, + databaseOpenCalls, + transitionQueryCalls: database.getTurnWorkspaceTransitionsCalls, + }, { + sameTurns: true, + databaseOpenCalls: 0, + transitionQueryCalls: 0, + }); + }); + + test('restores one durable transition before provider output after service restart', async () => { + const temporaryDirectory = await fs.promises.mkdtemp(join(tmpdir(), `workspace-transition-${generateUuid()}-`)); + const databasePath = join(temporaryDirectory, 'session.db'); + let conversionDatabase: SessionDatabase | undefined; + let restoredDatabase: SessionDatabase | undefined; + try { + conversionDatabase = await SessionDatabase.open(databasePath); + const harness = createHarness(new NullAgentHostWorktreeIsolation(), async () => true, conversionDatabase); + const workspaceFolder = URI.file('/workspace/project'); + harness.agent.setWorkingDirectory = async () => { }; + startTurn(harness.stateManager, harness.chat); + await harness.database.setMetadata(AH_META_WORKSPACELESS_DB_KEY, 'true'); + harness.service.requestSessionWorkspaceUpdate(harness.chat, 'turn-1', workspaceFolder, false, 'client-1'); + completeTurn(harness.stateManager, harness.chat); + + await updateSessionWorkspace(harness); + await harness.database.setTurnEventId('continuation-1', 'provider-continuation'); + harness.service.dispose(); + harness.stateManager.dispose(); + await conversionDatabase.close(); + conversionDatabase = undefined; + + restoredDatabase = await SessionDatabase.open(databasePath); + const restoredStateManager = createHydrationStateManager(harness.session, true); + const restoredContribution = disposables.add(new SessionWorkspaceConversionContribution( + new class extends mock() { }(), + new class extends mock() { }(), + restoredStateManager, + createSessionDataService(restoredDatabase), + new NullLogService(), + )); + const providerTurns: Turn[] = [{ + id: 'provider-continuation', + message: { + text: 'Continue the original task in the converted workspace.', + origin: { kind: MessageKind.SystemNotification }, + }, + responseParts: [{ + kind: ResponsePartKind.Markdown, + id: 'provider-response', + content: 'Provider continued work', + }], + usage: undefined, + state: TurnState.Complete, + }]; + const restoredOnce = await restoredContribution.onHydrateTurns({ + session: harness.session.toString(), + chat: harness.chat.toString(), + }, providerTurns); + const restoredTwice = await restoredContribution.onHydrateTurns({ + session: harness.session.toString(), + chat: harness.chat.toString(), + }, restoredOnce); + const restoredTurn = restoredTwice[0]; + + assert.deepStrictEqual({ + requestHidden: isMessageRequestHiddenFromTranscript(restoredTurn.message), + workspaceContinuation: isAgentWorkspaceContinuationMessage(restoredTurn.message), + responseParts: restoredTurn.responseParts.map(part => part.kind === ResponsePartKind.SystemNotification ? { + kind: part.kind, + content: part.content, + meta: readAgentSystemNotificationMeta(part), + } : { + kind: part.kind, + content: part.kind === ResponsePartKind.Markdown ? part.content : undefined, + }), + persistedTransitions: [...(await restoredDatabase.getTurnWorkspaceTransitions()).keys()], + }, { + requestHidden: true, + workspaceContinuation: true, + responseParts: [{ + kind: ResponsePartKind.SystemNotification, + content: 'Now working in project', + meta: { + kind: AgentSystemNotificationKind.WorkspaceTransition, + severity: undefined, + workspaceKind: AgentSystemNotificationWorkspaceKind.Folder, + workspaceName: 'project', + }, + }, { + kind: ResponsePartKind.Markdown, + content: 'Provider continued work', + }], + persistedTransitions: ['continuation-1', 'provider-continuation'], + }); + } finally { + await Promise.all([conversionDatabase?.close(), restoredDatabase?.close()]); + await fs.promises.rm(temporaryDirectory, { recursive: true, force: true }); + } + }); + + test('restores every persisted workspace conversion at its own turn boundary', async () => { + const database = new TestSessionDatabase(); + const baseSessionDataService = createSessionDataService(database); + let databaseOpenCalls = 0; + const sessionDataService = { + ...baseSessionDataService, + tryOpenDatabase: async (resource: URI) => { + databaseOpenCalls++; + return baseSessionDataService.tryOpenDatabase(resource); + }, + }; + await database.setTurnWorkspaceTransition('turn-1', serializeAgentWorkspaceTransition({ + content: 'Now working in first', + workspaceKind: AgentSystemNotificationWorkspaceKind.Folder, + workspaceName: 'first', + })); + await database.setTurnWorkspaceTransition('turn-2', serializeAgentWorkspaceTransition({ + content: 'Now working in second', + workspaceKind: AgentSystemNotificationWorkspaceKind.Worktree, + workspaceName: 'second', + })); + const contribution = disposables.add(new SessionWorkspaceConversionContribution( + new class extends mock() { }(), + new class extends mock() { }(), + createHydrationStateManager(URI.parse('copilot:/workspace-less'), true), + sessionDataService, + new NullLogService(), + )); + const turns = ['turn-1', 'turn-2'].map((id): Turn => ({ + id, + message: { text: 'Continue work', origin: { kind: MessageKind.SystemNotification } }, + responseParts: [{ kind: ResponsePartKind.Markdown, id: `${id}-response`, content: `${id} output` }], + usage: undefined, + state: TurnState.Complete, + })); + + const restored = await contribution.onHydrateTurns({ + session: 'copilot:/workspace-less', + chat: buildDefaultChatUri('copilot:/workspace-less'), + }, turns); + + assert.deepStrictEqual({ + responseParts: restored.map(turn => turn.responseParts.map(part => + part.kind === ResponsePartKind.SystemNotification ? part.content : part.kind + )), + databaseOpenCalls, + transitionQueryCalls: database.getTurnWorkspaceTransitionsCalls, + }, { + responseParts: [ + ['Now working in first', ResponsePartKind.Markdown], + ['Now working in second', ResponsePartKind.Markdown], + ], + databaseOpenCalls: 1, + transitionQueryCalls: 1, + }); + }); + + test('hydrates a persisted workspace transition for a non-default chat', async () => { + const session = URI.parse('copilot:/workspace-less'); + const database = new TestSessionDatabase(); + await database.setTurnWorkspaceTransition('turn-1', serializeAgentWorkspaceTransition({ + content: 'Now working in project', + workspaceKind: AgentSystemNotificationWorkspaceKind.Folder, + workspaceName: 'project', + })); + const contribution = disposables.add(new SessionWorkspaceConversionContribution( + new class extends mock() { }(), + new class extends mock() { }(), + createHydrationStateManager(session, true), + createSessionDataService(database), + new NullLogService(), + )); + const turns: Turn[] = [{ + id: 'turn-1', + message: { text: 'Continue work', origin: { kind: MessageKind.SystemNotification } }, + responseParts: [{ kind: ResponsePartKind.Markdown, id: 'response-1', content: 'Provider output' }], + usage: undefined, + state: TurnState.Complete, + }]; + + const restored = await contribution.onHydrateTurns({ + session: session.toString(), + chat: buildChatUri(session, 'peer-chat'), + }, turns); + + assert.deepStrictEqual(restored[0].responseParts.map(part => + part.kind === ResponsePartKind.SystemNotification ? part.content : part.kind + ), [ + 'Now working in project', + ResponsePartKind.Markdown, + ]); + }); + test('creates an isolated worktree and sets it as the workspace', async () => { const worktreeIsolation = new TestWorktreeIsolation(URI.file('/workspace/project.worktrees/implement-feature')); const harness = createHarness(worktreeIsolation); @@ -365,6 +662,7 @@ suite('SessionWorkspaceConversionService', () => { const state = harness.stateManager.getSessionState(harness.session.toString()); const summary = harness.stateManager.getSessionSummary(harness.session.toString()); + const activeTurn = harness.stateManager.getChatState(harness.chat.toString())?.activeTurn; assert.deepStrictEqual({ worktreeRequests: worktreeIsolation.requests.map(request => ({ session: request.sessionUri.toString(), @@ -382,6 +680,10 @@ suite('SessionWorkspaceConversionService', () => { isolation: state?.config?.values[SessionConfigKey.Isolation], branch: state?.config?.values[SessionConfigKey.Branch], persistedConfig: JSON.parse((await harness.database.getMetadata('configValues')) ?? '{}'), + outcomeNotifications: activeTurn?.responseParts.flatMap(part => part.kind === ResponsePartKind.SystemNotification ? [{ + content: part.content, + meta: readAgentSystemNotificationMeta(part), + }] : []), continuationText: harness.continuations[0]?.message.text, }, { worktreeRequests: [{ @@ -416,10 +718,87 @@ suite('SessionWorkspaceConversionService', () => { [SessionConfigKey.Isolation]: 'worktree', [SessionConfigKey.Branch]: 'main', }, + outcomeNotifications: [{ + content: 'Now working in project', + meta: { + kind: AgentSystemNotificationKind.WorkspaceTransition, + severity: undefined, + workspaceKind: AgentSystemNotificationWorkspaceKind.Worktree, + workspaceName: 'project', + }, + }], continuationText: `The current session is now attached to ${worktreeIsolation.worktree.fsPath} in an isolated worktree. Continue the user's original task in this workspace. Do not request another session or workspace conversion.`, }); }); + test('does not request workspace trust in Allow All mode', async () => { + const harness = createHarness(new NullAgentHostWorktreeIsolation(), async () => false); + const workspaceFolder = URI.file('/workspace/project'); + harness.agent.setWorkingDirectory = async () => { }; + setSessionConfig(harness, { [SessionConfigKey.AutoApprove]: 'autoApprove' }); + startTurn(harness.stateManager, harness.chat); + harness.service.requestSessionWorkspaceUpdate(harness.chat, 'turn-1', workspaceFolder, false, 'client-1'); + completeTurn(harness.stateManager, harness.chat); + + await updateSessionWorkspace(harness); + + assert.deepStrictEqual({ + trustRequests: harness.trustRequests, + workingDirectories: harness.stateManager.getSessionState(harness.session.toString())?.workingDirectories, + }, { + trustRequests: [], + workingDirectories: [workspaceFolder.toString()], + }); + }); + + test('does not request workspace, repository, or worktree trust when global auto-approve is enabled', async () => { + const workspaceFolder = URI.file('/workspace/project/packages/app'); + const repository = URI.file('/workspace/project'); + const worktreeIsolation = new TestWorktreeIsolation(URI.file('/workspace/project.worktrees/implement-feature'), repository); + const harness = createHarness(worktreeIsolation, async () => false); + harness.agent.setWorkingDirectory = async () => { }; + harness.configurationService.updateRootConfig({ [AgentHostGlobalAutoApproveEnabledConfigKey]: true }); + startTurn(harness.stateManager, harness.chat); + harness.service.requestSessionWorkspaceUpdate(harness.chat, 'turn-1', workspaceFolder, true, 'client-1'); + completeTurn(harness.stateManager, harness.chat); + + await updateSessionWorkspace(harness); + + assert.deepStrictEqual({ + trustRequests: harness.trustRequests, + createdWorktrees: worktreeIsolation.createdWorktrees, + workingDirectories: harness.stateManager.getSessionState(harness.session.toString())?.workingDirectories, + }, { + trustRequests: [], + createdWorktrees: [worktreeIsolation.worktree], + workingDirectories: [worktreeIsolation.worktree.toString()], + }); + }); + + test('still requests workspace trust outside Allow All mode', async () => { + const trustRequests: ReturnType['trustRequests'][] = []; + for (const values of [ + { [SessionConfigKey.AutoApprove]: 'default' }, + { [SessionConfigKey.AutoApprove]: 'assisted' }, + { [SessionConfigKey.AutoApprove]: 'default', [SessionConfigKey.Mode]: 'autopilot' }, + ]) { + const harness = createHarness(new NullAgentHostWorktreeIsolation(), async () => false); + setSessionConfig(harness, values); + startTurn(harness.stateManager, harness.chat); + harness.service.requestSessionWorkspaceUpdate(harness.chat, 'turn-1', URI.file('/workspace/project'), false, 'client-1'); + completeTurn(harness.stateManager, harness.chat); + + await updateSessionWorkspace(harness); + trustRequests.push(harness.trustRequests); + } + + assert.deepStrictEqual(trustRequests, [ + [{ clientId: 'client-1', workspace: 'file:///workspace/project' }], + [{ clientId: 'client-1', workspace: 'file:///workspace/project' }], + [{ clientId: 'client-1', workspace: 'file:///workspace/project' }], + ]); + }); + test('keeps the session workspace-less when workspace trust is declined', async () => { const harness = createHarness(new NullAgentHostWorktreeIsolation(), async () => false); const workspaceFolder = URI.file('/workspace/project'); @@ -442,6 +821,7 @@ suite('SessionWorkspaceConversionService', () => { workingDirectories: state?.workingDirectories, workspaceless: readSessionWorkspaceless(state?._meta), persistedWorkspaceless: await harness.database.getMetadata(AH_META_WORKSPACELESS_DB_KEY), + persistedTransitions: [...(await harness.database.getTurnWorkspaceTransitions()).values()], continuation: harness.continuations.map(entry => ({ label: readMessageSystemInitiatedLabel(entry.message), text: entry.message.text, @@ -455,6 +835,7 @@ suite('SessionWorkspaceConversionService', () => { workingDirectories: [harness.scratch.toString()], workspaceless: true, persistedWorkspaceless: 'true', + persistedTransitions: [], continuation: [{ label: 'Workspace Setup Failed', text: `The requested workspace setup did not complete successfully: Workspace trust was not granted for '${workspaceFolder.fsPath}'. Do not run the user's task. Tell the user that workspace setup failed and include this error.`, @@ -585,19 +966,33 @@ suite('SessionWorkspaceConversionService', () => { workingDirectories: state?.workingDirectories, workspaceless: readSessionWorkspaceless(state?._meta), continuationHidden: harness.continuations[0] ? isMessageHiddenFromTranscript(harness.continuations[0].message) : undefined, + continuationRequestHidden: harness.continuations[0] ? isMessageRequestHiddenFromTranscript(harness.continuations[0].message) : undefined, continuationLabel: harness.continuations[0] ? readMessageSystemInitiatedLabel(harness.continuations[0].message) : undefined, continuationOrigin: harness.continuations[0]?.message.origin.kind, continuationText: harness.continuations[0]?.message.text, - outcomeNotifications: activeTurn?.responseParts.flatMap(part => part.kind === ResponsePartKind.SystemNotification ? [part.content] : []), + deferredRequestHidden: activeTurn ? isMessageRequestHiddenFromTranscript(activeTurn.message) : undefined, + deferredRequestIsWorkspaceContinuation: activeTurn ? isAgentWorkspaceContinuationMessage(activeTurn.message) : undefined, + resumedTurnIsHostNotice: activeTurn ? isHostNoticeTurn(activeTurn) : undefined, + outcomeNotifications: activeTurn?.responseParts.flatMap(part => part.kind === ResponsePartKind.SystemNotification ? [{ + content: part.content, + kind: readAgentSystemNotificationMeta(part).kind, + }] : []), }, { pending: false, workingDirectories: [harness.scratch.toString()], workspaceless: true, continuationHidden: false, + continuationRequestHidden: false, continuationLabel: 'Workspace Setup Failed', continuationOrigin: MessageKind.SystemNotification, continuationText: 'The requested workspace setup did not complete successfully: provider failed. Do not run the user\'s task. Tell the user that workspace setup failed and include this error.', - outcomeNotifications: ['Workspace Setup Failed'], + deferredRequestHidden: true, + deferredRequestIsWorkspaceContinuation: true, + resumedTurnIsHostNotice: false, + outcomeNotifications: [{ + content: 'Workspace Setup Failed', + kind: undefined, + }], }); }); @@ -620,11 +1015,13 @@ suite('SessionWorkspaceConversionService', () => { workspaceless: readSessionWorkspaceless(state?._meta), persistedWorkspaceless: await harness.database.getMetadata(AH_META_WORKSPACELESS_DB_KEY), continuationText: harness.continuations[0]?.message.text, + failedContinuations: harness.failedContinuations, }, { workingDirectories: ['file:///workspace/authoritative'], workspaceless: false, persistedWorkspaceless: 'false', continuationText: `The requested workspace setup did not complete successfully: The workspace changed to '${authoritative.fsPath}', but conversion did not complete cleanly: SDK returned a different directory. Do not run the user's task. Tell the user that workspace setup failed and include this error.`, + failedContinuations: [], }); }); @@ -692,8 +1089,8 @@ suite('SessionWorkspaceConversionService', () => { }); test('does not continue a setup turn that the user cancelled during conversion', async () => { - const trustDecision = new DeferredPromise(); - const harness = createHarness(new NullAgentHostWorktreeIsolation(), () => trustDecision.p); + const database = new GatedConversionDatabase(); + const harness = createHarness(new NullAgentHostWorktreeIsolation(), async () => true, database); const provider: IAgent = harness.agent; provider.setWorkingDirectory = async () => { }; startTurn(harness.stateManager, harness.chat); @@ -701,19 +1098,22 @@ suite('SessionWorkspaceConversionService', () => { completeTurn(harness.stateManager, harness.chat); const conversion = updateSessionWorkspace(harness); - await Promise.resolve(); + await database.writeStarted.p; harness.stateManager.dispatchServerAction(harness.chat.toString(), { type: ActionType.ChatTurnCancelled, turnId: 'continuation-1', duration: 1, }); - trustDecision.complete(true); + database.releaseWrite.complete(); await conversion; const state = harness.stateManager.getSessionState(harness.session.toString()); assert.deepStrictEqual({ workingDirectories: state?.workingDirectories, workspaceless: readSessionWorkspaceless(state?._meta), + hasWorkspaceTransitions: readSessionHasWorkspaceTransitions(state?._meta), + persistedTransitions: [...(await harness.database.getTurnWorkspaceTransitions()).entries()], + persistedHasWorkspaceTransitions: await harness.database.getMetadata(AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY), continuations: harness.continuations, failedContinuations: harness.failedContinuations, activity: harness.stateManager.getChatState(harness.chat.toString())?.activity, @@ -721,6 +1121,9 @@ suite('SessionWorkspaceConversionService', () => { }, { workingDirectories: ['file:///workspace/project'], workspaceless: false, + hasWorkspaceTransitions: false, + persistedTransitions: [], + persistedHasWorkspaceTransitions: undefined, continuations: [], failedContinuations: [], activity: undefined, @@ -757,8 +1160,8 @@ suite('SessionWorkspaceConversionService', () => { test('atomically persists conversion metadata or quarantines before publishing state', async () => { class FailingConversionDatabase extends TestSessionDatabase { - override async setMetadataValues(): Promise { - throw new Error('atomic commit failed'); + override async setWorkspaceConversion(): Promise { + throw new Error('transition write failed'); } } const database = new FailingConversionDatabase(); @@ -775,21 +1178,27 @@ suite('SessionWorkspaceConversionService', () => { assert.deepStrictEqual({ pending: harness.service.isPending(harness.chat.toString()), persistedQuarantine: await database.getMetadata(AH_META_WORKSPACE_CONVERSION_QUARANTINED_DB_KEY), + persistedWorkspaceless: await database.getMetadata(AH_META_WORKSPACELESS_DB_KEY), workingDirectories: state?.workingDirectories, workspaceless: readSessionWorkspaceless(state?._meta), + persistedTransitions: [...(await database.getTurnWorkspaceTransitions()).entries()], + persistedHasWorkspaceTransitions: await database.getMetadata(AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY), continuations: harness.continuations, }, { pending: true, persistedQuarantine: 'true', + persistedWorkspaceless: undefined, workingDirectories: [harness.scratch.toString()], workspaceless: true, + persistedTransitions: [], + persistedHasWorkspaceTransitions: undefined, continuations: [], }); }); test('keeps the session quarantined in memory when durable quarantine persistence fails', async () => { class FailingQuarantineDatabase extends TestSessionDatabase { - override async setMetadataValues(): Promise { + override async setWorkspaceConversion(): Promise { throw new Error('atomic commit failed'); } @@ -966,9 +1375,11 @@ suite('SessionWorkspaceConversionService', () => { assert.deepStrictEqual({ pending: harness.service.isPending(harness.chat.toString()), continuations: harness.continuations, + deferredContinuations: harness.deferredContinuations, }, { pending: false, continuations: [], + deferredContinuations: [], }); }); }); diff --git a/src/vs/platform/agentHost/test/node/testAgentSdkDownloader.ts b/src/vs/platform/agentHost/test/node/testAgentSdkDownloader.ts index ecb6b3ff81c244..0eb34a6023b48d 100644 --- a/src/vs/platform/agentHost/test/node/testAgentSdkDownloader.ts +++ b/src/vs/platform/agentHost/test/node/testAgentSdkDownloader.ts @@ -37,6 +37,7 @@ export class RecordingAgentSdkDownloader implements IAgentSdkDownloader { /** Whether the SDK is already on disk. Defaults to {@link available}. */ resolvableWithoutDownload: boolean | undefined; + readonly downloadConsents = new Set(); /** What `loadSdkRoot` resolves to. Unset means "no download was expected here". */ loadSdkRootResult: (() => Promise) | undefined; @@ -53,6 +54,14 @@ export class RecordingAgentSdkDownloader implements IAgentSdkDownloader { return this.available; } + hasDownloadConsent(pkg: IAgentSdkPackage): boolean { + return this.downloadConsents.has(pkg.id); + } + + async recordDownloadConsent(pkg: IAgentSdkPackage): Promise { + this.downloadConsents.add(pkg.id); + } + async isSdkResolvableWithoutDownload(): Promise { return this.resolvableWithoutDownload ?? this.available; } diff --git a/src/vs/platform/localTranscription/node/localTranscriptionService.ts b/src/vs/platform/localTranscription/node/localTranscriptionService.ts index 4f03794a170db9..4d0573695d659b 100644 --- a/src/vs/platform/localTranscription/node/localTranscriptionService.ts +++ b/src/vs/platform/localTranscription/node/localTranscriptionService.ts @@ -27,6 +27,40 @@ const BITS_PER_SAMPLE = 16; /** Application name reported to Foundry Local for logs/telemetry and its data dir. */ const FOUNDRY_APP_NAME = 'vscode-dictation'; +/** + * Timeouts guarding every await onto the native Foundry Local SDK that has no + * `AbortSignal` of its own (catalog lookup, model load, a single audio append, + * the result stream, and session teardown). Without these, a stalled network + * connection or a wedged native call leaves the corresponding promise + * unresolved forever — and since `start`/`pushAudio`/`stop`/`cancel` all await + * these calls (directly or via the serialized append chain / stream consumer), + * the renderer would wait indefinitely with no error ever surfacing to the + * user. Model *download* already reports progress and is not included here; + * a stalled download is instead caught by its own inactivity timeout inside + * `ensureFoundryLocalRuntime`. + */ +const MODEL_CATALOG_TIMEOUT_MS = 30_000; +const MODEL_LOAD_TIMEOUT_MS = 120_000; +const SESSION_START_TIMEOUT_MS = 15_000; +const APPEND_TIMEOUT_MS = 15_000; +const STREAM_INACTIVITY_TIMEOUT_MS = 60_000; +const SESSION_TEARDOWN_TIMEOUT_MS = 15_000; + +/** + * Race `promise` against a `ms` timeout, rejecting with `message` if it does + * not settle in time. Does not cancel `promise` itself (the native SDK gives us + * no way to do that for these calls) — it only unblocks whichever caller is + * awaiting it, so a wedged native call fails fast and visibly instead of + * hanging every dependent operation forever. + */ +function withTimeout(promise: Promise, ms: number, message: string): Promise { + let timer: ReturnType; + const timedOut = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(message)), ms); + }); + return Promise.race([promise, timedOut]).finally(() => clearTimeout(timer)); +} + /** * Directory holding the on-demand Foundry Local native runtime (addon + core * libraries). Derived as a sibling of the model cache dir so both live under VS @@ -57,6 +91,9 @@ type LiveAudioTranscriptionResponse = import('foundry-local-sdk').LiveAudioTrans */ function classifyModelError(message: string): string { const text = message.toLowerCase(); + if (/\btimed out\b/.test(text)) { + return 'timeout'; + } if (/\b(404|not found|no such file|does not exist|could not locate|repository not found|unknown model)\b/.test(text)) { return 'notFound'; } @@ -394,7 +431,7 @@ export class LocalTranscriptionService extends Disposable implements ILocalTrans if (language) { session.settings.language = language; } - await session.start(); + await withTimeout(session.start(), SESSION_START_TIMEOUT_MS, `Foundry Local session start timed out after ${SESSION_START_TIMEOUT_MS}ms.`); if (generation !== this._generation) { // A newer session replaced this one while it was opening; discard. @@ -441,13 +478,18 @@ export class LocalTranscriptionService extends Disposable implements ILocalTrans * completed, preserving capture order. Returns a promise that rejects if this * particular append fails (for callers that must surface it); the internal * chain continues regardless so ordering is preserved for later chunks. + * + * Guarded with a timeout: every later chunk (and `stop()`, which awaits the + * whole chain) is serialized behind this one call, so a single wedged native + * `append()` would otherwise hang the entire rest of the recording — and + * Stop — forever. */ private _enqueueAppend(session: LiveAudioTranscriptionSession, generation: number, chunk: Uint8Array): Promise { const result = this._appendChain.then(() => { if (generation !== this._generation || this._session !== session) { return; // superseded/reset; drop stale append } - return session.append(chunk); + return withTimeout(session.append(chunk), APPEND_TIMEOUT_MS, `Foundry Local audio append timed out after ${APPEND_TIMEOUT_MS}ms.`); }); this._appendChain = result.catch(() => { /* keep the chain alive after a failed append */ }); return result; @@ -503,7 +545,11 @@ export class LocalTranscriptionService extends Disposable implements ILocalTrans }); } - const model = await this._manager.catalog.getModel(modelId); + const model = await withTimeout( + this._manager.catalog.getModel(modelId), + MODEL_CATALOG_TIMEOUT_MS, + `Foundry Local model catalog lookup timed out after ${MODEL_CATALOG_TIMEOUT_MS}ms.` + ); let didDownload = false; if (!model.isCached) { @@ -526,11 +572,18 @@ export class LocalTranscriptionService extends Disposable implements ILocalTrans } // model.load() has no AbortSignal; check cancellation before starting it. + // It also reports no progress, so guard it with a hard timeout — + // otherwise a wedged native load (e.g. a driver hiccup) would hang + // this promise, and every caller awaiting it, forever. if (cts.token.isCancellationRequested) { throw new Error('cancelled'); } this._setStatus({ state: LocalTranscriptionModelState.Loading }); - await model.load(); + await withTimeout( + model.load(), + MODEL_LOAD_TIMEOUT_MS, + `Foundry Local model load timed out after ${MODEL_LOAD_TIMEOUT_MS}ms.` + ); this._model = model; this._setStatus({ state: LocalTranscriptionModelState.Ready, downloaded: didDownload }); @@ -558,10 +611,24 @@ export class LocalTranscriptionService extends Disposable implements ILocalTrans * non-final result is the interim tail of the segment currently being spoken. * Each update fires the full cumulative transcript so the renderer can shimmer * the interim tail and solidify finalized text. + * + * Each `next()` call is guarded with an inactivity timeout: this loop is what + * `stop()` awaits (via `_consumePromise`) before returning, so a native stream + * that stops yielding results and never completes — without ever throwing — + * would otherwise hang Stop forever with no error. */ private async _consume(session: LiveAudioTranscriptionSession, generation: number): Promise { + const iterator = session.getStream()[Symbol.asyncIterator](); try { - for await (const result of session.getStream()) { + while (true) { + const { value: result, done } = await withTimeout( + iterator.next(), + STREAM_INACTIVITY_TIMEOUT_MS, + `Foundry Local transcription stream stalled for ${STREAM_INACTIVITY_TIMEOUT_MS}ms.` + ); + if (done) { + break; + } if (generation !== this._generation) { break; } @@ -580,15 +647,24 @@ export class LocalTranscriptionService extends Disposable implements ILocalTrans } } } catch (err) { - // A native streaming/push failure terminates the stream. If it happened - // while recording (not during our own teardown), record it and surface - // an error status so the renderer tears the session down and informs the - // user; stop() also rethrows it rather than reporting a false success. + // A native streaming/push failure (or the inactivity timeout above) + // terminates the stream. If it happened while recording (not during our + // own teardown), record it and surface an error status so the renderer + // tears the session down and informs the user; stop() also rethrows it + // rather than reporting a false success. if (generation === this._generation && this._sessionActive) { const error = err instanceof Error ? err : new Error(String(err)); this._runtimeError = error; - this._setStatus({ state: LocalTranscriptionModelState.Error, error: error.message, errorCode: 'runtime' }); + const errorCode = /\btimed out\b/.test(error.message) ? 'timeout' : 'runtime'; + this._setStatus({ state: LocalTranscriptionModelState.Error, error: error.message, errorCode }); } + } finally { + // Mirror `for await...of`'s implicit cleanup on early break/throw so a + // stream we abandoned after a timeout doesn't keep the native generator + // (and whatever native resources back it) alive indefinitely. + try { + await iterator.return?.(undefined); + } catch { /* best-effort */ } } } @@ -669,13 +745,16 @@ export class LocalTranscriptionService extends Disposable implements ILocalTrans // Drain every queued append (buffered backlog + live chunks) so the // final captured audio reaches native core before we stop — otherwise // `stop()` can complete the stream while the tail append is still - // pending, truncating the transcript. + // pending, truncating the transcript. The append chain itself is + // already timeout-guarded per chunk, so this can't hang. try { await this._appendChain; } catch { /* individual append failures already surfaced */ } // `stop()` drains any buffered audio, emits final results into the // stream, then completes it — so the consumer loop ends after this. - await session.stop(); + // Guarded so a wedged native stop can't hang the user's Stop click + // forever; `_disposeSession` still tears the session down afterwards. + await withTimeout(session.stop(), SESSION_TEARDOWN_TIMEOUT_MS, `Foundry Local session stop timed out after ${SESSION_TEARDOWN_TIMEOUT_MS}ms.`); } catch { // Best-effort: fall through to whatever transcript we accumulated. } @@ -713,6 +792,13 @@ export class LocalTranscriptionService extends Disposable implements ILocalTrans this._resetSessionState(); } + /** + * Tear down the active session and wait for its stream consumer to drain. + * Called from `stop()`, `cancel()`, and disposal — all paths a user or the + * window shutdown can be waiting on — so `session.dispose()` is guarded with + * a timeout the same way `session.stop()` is, to avoid hanging forever on a + * wedged native teardown. + */ private async _disposeSession(): Promise { const session = this._session; this._session = undefined; @@ -720,7 +806,7 @@ export class LocalTranscriptionService extends Disposable implements ILocalTrans this._consumePromise = undefined; if (session) { try { - await session.dispose(); + await withTimeout(session.dispose(), SESSION_TEARDOWN_TIMEOUT_MS, `Foundry Local session dispose timed out after ${SESSION_TEARDOWN_TIMEOUT_MS}ms.`); } catch { /* best-effort teardown */ } } if (consume) { diff --git a/src/vs/platform/policy/common/copilotManagedSettings.ts b/src/vs/platform/policy/common/copilotManagedSettings.ts index da7352e8f19245..f928defff092f0 100644 --- a/src/vs/platform/policy/common/copilotManagedSettings.ts +++ b/src/vs/platform/policy/common/copilotManagedSettings.ts @@ -386,9 +386,9 @@ export interface IManagedSettingsContribution { export interface IManagedSettingResolution { /** The effective (winning) value applied for the key. */ readonly value: ManagedSettingValue; - /** The channel whose value won (always the first {@link contributions} entry's channel). */ + /** The highest-precedence channel supplying the effective value. */ readonly source: ManagedSettingsChannel; - /** Every channel that supplied this key, in precedence order (winner first, overridden after). */ + /** Every channel that supplied this key, in delivery precedence order. */ readonly contributions: readonly IManagedSettingsContribution[]; } @@ -408,8 +408,8 @@ export interface IManagedSettingsPick { * Precedence (highest first): native MDM → server-delivered → file on disk. Unlike a single * authoritative source, the channels *are* merged key-by-key: for each key the highest-precedence * channel that supplies it wins, but a key that the higher channels never set is still filled in by - * a lower channel. A value an admin locks via native MDM therefore cannot be overwritten by the - * server or a file, while keys those higher channels leave unset remain available to lower ones. + * a lower channel. The runtime-owned `sandbox.enabled` control is the exception: any managed + * `true` wins, so harness selection cannot discard a sandbox requirement from another channel. * * The parameter order matches the precedence so call sites read top-to-bottom. Centralizing the * resolution here (rather than inlining it at each call site) keeps policy evaluation @@ -420,8 +420,7 @@ export interface IManagedSettingsPick { export function pickManagedSettings(nativeMdm: ManagedSettingsData | undefined, server: ManagedSettingsData | undefined, file: ManagedSettingsData | undefined): IManagedSettingsPick { const bags: Record = { nativeMdm, server, file }; - // Walk channels highest-precedence first: the first channel to supply a key wins, and later - // channels are appended as overridden contributions for provenance. + // Preserve delivery order for provenance even when a sandbox requirement wins from a later channel. const resolutions = new Map(); for (const channel of MANAGED_SETTINGS_CHANNELS) { const bag = bags[channel]; @@ -438,6 +437,10 @@ export function pickManagedSettings(nativeMdm: ManagedSettingsData | undefined, const existing = resolutions.get(key); if (existing) { existing.contributions.push({ channel, value }); + if (key === COPILOT_SANDBOX_ENABLED_KEY && value === true && existing.value !== true) { + existing.value = value; + existing.source = channel; + } } else { resolutions.set(key, { value, source: channel, contributions: [{ channel, value }] }); } diff --git a/src/vs/platform/policy/test/common/copilotManagedSettings.test.ts b/src/vs/platform/policy/test/common/copilotManagedSettings.test.ts index 1c509b8f0671bc..beb7ee54419059 100644 --- a/src/vs/platform/policy/test/common/copilotManagedSettings.test.ts +++ b/src/vs/platform/policy/test/common/copilotManagedSettings.test.ts @@ -7,7 +7,7 @@ import assert from 'assert'; import { IStringDictionary } from '../../../../base/common/collections.js'; import { IPolicyData } from '../../../../base/common/defaultAccount.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { collectManagedSettingsDefinitions, COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY, COPILOT_MODEL_KEY, COPILOT_TOP_LEVEL_MODEL_KEY, hasManagedSettingsDefinitions, managedModelValue, managedSettingsDisabledValue, managedSettingValue, projectManagedSettings, pickManagedSettings, resolveForceRemoteSettingsRefresh } from '../../common/copilotManagedSettings.js'; +import { collectManagedSettingsDefinitions, COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY, COPILOT_MODEL_KEY, COPILOT_SANDBOX_ENABLED_KEY, COPILOT_TOP_LEVEL_MODEL_KEY, hasManagedSettingsDefinitions, managedModelValue, managedSettingsDisabledValue, managedSettingValue, projectManagedSettings, pickManagedSettings, resolveForceRemoteSettingsRefresh } from '../../common/copilotManagedSettings.js'; import { PolicyDefinition } from '../../common/policy.js'; suite('Copilot managed settings projection', () => { @@ -182,6 +182,53 @@ suite('Copilot managed settings per-key precedence (pickManagedSettings)', () => ensureNoDisposablesAreLeakedInTestSuite(); + test('managed sandbox enablement is force-on-wins across every channel combination', () => { + const key = COPILOT_SANDBOX_ENABLED_KEY; + const values = [undefined, false, true]; + for (const native of values) { + for (const server of values) { + for (const file of values) { + const pick = pickManagedSettings( + native === undefined ? undefined : { [key]: native }, + server === undefined ? undefined : { [key]: server }, + file === undefined ? undefined : { [key]: file }, + ); + const enabled = [native, server, file].includes(true); + assert.deepStrictEqual(pick.values[key], enabled ? true : native ?? server ?? file, JSON.stringify({ native, server, file })); + } + } + } + }); + + test('sandbox force-on reports the restrictive source while retaining all contributions', () => { + const key = COPILOT_SANDBOX_ENABLED_KEY; + const pick = pickManagedSettings({ [key]: false }, { [key]: true }, { [key]: true }); + assert.deepStrictEqual(pick, { + values: { [key]: true }, + resolutions: new Map([[key, { + value: true, + source: 'server', + contributions: [ + { channel: 'nativeMdm', value: false }, + { channel: 'server', value: true }, + { channel: 'file', value: true }, + ], + }]]), + activeSources: ['server'], + }); + }); + + test('malformed higher-precedence sandbox values cannot mask a managed force-on', () => { + const key = COPILOT_SANDBOX_ENABLED_KEY; + assert.deepStrictEqual({ + string: pickManagedSettings({ [key]: 'false' }, { [key]: true }, undefined).values[key], + number: pickManagedSettings({ [key]: 0 }, { [key]: false }, { [key]: true }).values[key], + }, { + string: true, + number: true, + }); + }); + test('distinct keys each win from their highest-precedence channel; a lower channel fills a gap the higher ones leave', () => { // The headline per-key behavior: `shared` is contested by all three (native wins) while // `nativeOnly`/`serverOnly`/`fileOnly` are each supplied by a single channel and all survive. diff --git a/src/vs/sessions/AI_CUSTOMIZATIONS.md b/src/vs/sessions/AI_CUSTOMIZATIONS.md index a0b71ccb8b4e3c..d72de3fd207631 100644 --- a/src/vs/sessions/AI_CUSTOMIZATIONS.md +++ b/src/vs/sessions/AI_CUSTOMIZATIONS.md @@ -52,7 +52,7 @@ Core workbench registrations may expose Local, Copilot CLI, and Claude harnesses ### `ICustomizationMigrationService` -This shared workbench service computes customization migrations for an explicit chat session. File migrations include source URIs and migratable-configuration metadata for flows that need source type and storage; MCP migrations report known servers' binary harness compatibility together with discovery and policy-coverage state. The service also produces a localized, harness-specific hint summarizing available file migrations for UI consumers. +This shared workbench service computes customization migrations for an explicit chat session. File migrations include source URIs and migratable-configuration metadata for flows that need source type and storage; MCP migrations report known servers' binary harness compatibility together with discovery and policy-coverage state. The service also produces a localized, harness-specific hint with navigation metadata so UI consumers can open the relevant file migration or MCP server surface. ### `IHarnessDescriptor` @@ -93,7 +93,7 @@ Prompt-based items use the prompts service adapter. MCP servers, tools, plugins, In the Agents Window, the customization harness and project root track `ISessionsService.activeSession`. Opening the editor synchronizes it with the currently active session, and switching the active session can update the editor's harness and project context. A transient project-root override takes precedence while it is set. -The management-editor command may select a section, target a session type, and reveal a URI-addressable customization. Operations that migrate files bind destination resolution and confirmation to their initiating session and stop if the active session changes. +The management-editor command may select a section, target a session type, reveal a URI-addressable customization, and open migration mode for a targeted migration category. Operations that migrate files bind destination resolution and confirmation to their initiating session and stop if the active session changes. Provider-backed items retain provider identity through the shared contract. Shared widgets must not import or branch on provider implementations. diff --git a/src/vs/sessions/browser/menus.ts b/src/vs/sessions/browser/menus.ts index 5cf11c63ce30d7..49de5a94e17d68 100644 --- a/src/vs/sessions/browser/menus.ts +++ b/src/vs/sessions/browser/menus.ts @@ -60,6 +60,7 @@ export const Menus = { SessionsEditorHeaderPrimary: new MenuId('SessionsEditorHeaderPrimary'), SessionsEditorHeaderLayout: new MenuId('SessionsEditorHeaderLayout'), SessionsEditorTitle: new MenuId('SessionsEditorTitle'), + SessionsDiffEditorView: new MenuId('SessionsDiffEditorView'), SessionsEditorTabsBarContext: new MenuId('SessionsEditorTabsBarContext'), SessionsEditorTabsBarAddTab: new MenuId('SessionsEditorTabsBarAddTab'), /** diff --git a/src/vs/sessions/browser/parts/chatGroupView.ts b/src/vs/sessions/browser/parts/chatGroupView.ts index 548207bcddec9a..46eecd46948e19 100644 --- a/src/vs/sessions/browser/parts/chatGroupView.ts +++ b/src/vs/sessions/browser/parts/chatGroupView.ts @@ -230,30 +230,41 @@ export class ChatGroupView extends Disposable implements ISerializableView { if (archived) { const action = getChatSessionArchiveActionPresentation(this._archiveActionWording.read(reader)).unarchive; return { - message: localize('sessionReadOnlyBanner.archived', "Archived sessions are read-only."), - action: { - label: action.title.value, - run: () => this._commandService.executeCommand(UNARCHIVE_SESSION_COMMAND_ID, context.session), + archived: true, + content: { + message: localize('sessionReadOnlyBanner.archived', "Archived sessions are read-only."), + action: { + label: action.title.value, + run: () => this._commandService.executeCommand(UNARCHIVE_SESSION_COMMAND_ID, context.session), + }, }, }; } - return { message: localize('sessionReadOnlyBanner.message', "This chat is read-only") }; + return { archived: false, content: { message: localize('sessionReadOnlyBanner.message', "This chat is read-only") } }; }); const surface = derived(reader => { const readOnly = readOnlyContent.read(reader); - if (readOnly) { - return { banner: readOnly, recovery: undefined }; + if (readOnly?.archived) { + return { banner: readOnly.content, recovery: undefined }; } + // Keep the banner while history loads to avoid flashing the centered recovery state. const view = currentView.read(reader); - const recovery = view?.hasVisibleTranscriptContent.read(reader) + const transcriptSettled = view === undefined || !view.isLoadingTranscript.read(reader); + const recovery = !transcriptSettled || view?.hasVisibleTranscriptContent.read(reader) ? undefined : this._connection.recoveryContent.read(reader); if (recovery) { return { banner: undefined, recovery }; } - return { banner: this._connection.bannerContent.read(reader), recovery: undefined }; + // Explain connection-related read-only state before falling back to the generic notice. + const connectionBanner = this._connection.bannerContent.read(reader); + if (connectionBanner) { + return { banner: connectionBanner, recovery: undefined }; + } + + return { banner: readOnly?.content, recovery: undefined }; }); this._contextDisposables.add(autorun(reader => { @@ -351,6 +362,10 @@ export class ChatGroupView extends Disposable implements ISerializableView { this._currentView.value?.selectWorkspace(folderUri, options); } + selectNoWorkspace(): void { + this._currentView.value?.selectNoWorkspace(); + } + prefillInput(text: string): void { this._currentView.value?.prefillInput(text); } diff --git a/src/vs/sessions/browser/parts/chatGroupsView.ts b/src/vs/sessions/browser/parts/chatGroupsView.ts index a5e7cb15321682..f9a13edff26860 100644 --- a/src/vs/sessions/browser/parts/chatGroupsView.ts +++ b/src/vs/sessions/browser/parts/chatGroupsView.ts @@ -809,6 +809,10 @@ export class ChatGroupsView extends Themable { this._activeGroup?.view.selectWorkspace(folderUri, options); } + selectNoWorkspace(): void { + this._activeGroup?.view.selectNoWorkspace(); + } + prefillInput(text: string): void { this._activeGroup?.view.prefillInput(text); } diff --git a/src/vs/sessions/browser/parts/chatView.ts b/src/vs/sessions/browser/parts/chatView.ts index f1638f696d91c3..75c02ac4875ab7 100644 --- a/src/vs/sessions/browser/parts/chatView.ts +++ b/src/vs/sessions/browser/parts/chatView.ts @@ -72,6 +72,14 @@ export abstract class AbstractChatView extends Disposable implements ISerializab */ readonly hasVisibleTranscriptContent: IObservable = constObservable(false); + /** + * Whether this view is still resolving its chat model, during which + * {@link hasVisibleTranscriptContent} is not yet meaningful — it reads `false` for a transcript + * that simply has not arrived yet as well as for one that does not exist. Views that never load + * a model report `false`, since for them the answer is already final. + */ + readonly isLoadingTranscript: IObservable = constObservable(false); + /** * Show the given chat in this view. The default implementation is a * no-op; subclasses that host a chat widget (e.g. `ChatView`) override @@ -90,6 +98,10 @@ export abstract class AbstractChatView extends Disposable implements ISerializab // no-op by default } + selectNoWorkspace(): void { + // no-op by default + } + /** * Prefill the input with the given text. The default implementation is * a no-op; subclasses that host an input widget (e.g. `NewChatView`) diff --git a/src/vs/sessions/browser/parts/media/remoteHostUnavailableEmptyState.css b/src/vs/sessions/browser/parts/media/remoteHostUnavailableEmptyState.css index 719e7ecc78bfdc..f9b4273c6264f6 100644 --- a/src/vs/sessions/browser/parts/media/remoteHostUnavailableEmptyState.css +++ b/src/vs/sessions/browser/parts/media/remoteHostUnavailableEmptyState.css @@ -18,6 +18,7 @@ } .remote-host-unavailable-empty-state.hidden, +.remote-host-unavailable-empty-state-description.hidden, .remote-host-unavailable-empty-state-progress.hidden, .remote-host-unavailable-empty-state-action.hidden, .remote-host-unavailable-empty-state-auto-connect.hidden { diff --git a/src/vs/sessions/browser/parts/remoteHostUnavailableEmptyState.ts b/src/vs/sessions/browser/parts/remoteHostUnavailableEmptyState.ts index ffcb92b8a7f15d..8f5125f8ec8ba2 100644 --- a/src/vs/sessions/browser/parts/remoteHostUnavailableEmptyState.ts +++ b/src/vs/sessions/browser/parts/remoteHostUnavailableEmptyState.ts @@ -15,7 +15,8 @@ import { defaultButtonStyles, defaultCheckboxStyles } from '../../../platform/th export interface IRemoteHostUnavailableEmptyStateContent { readonly title: string; - readonly description: string; + /** Omitted when the title already says everything, leaving the title and action to speak. */ + readonly description?: string; readonly progress?: string; readonly action?: { readonly label: string; @@ -59,7 +60,7 @@ export class RemoteHostUnavailableEmptyState extends Disposable { icon.appendChild(renderIcon(Codicon.debugDisconnect)); this._title = dom.append(this.domNode, dom.$('h2.remote-host-unavailable-empty-state-title')); - this._description = dom.append(this.domNode, dom.$('p.remote-host-unavailable-empty-state-description')); + this._description = dom.append(this.domNode, dom.$('p.remote-host-unavailable-empty-state-description.hidden')); this._progress = dom.append(this.domNode, dom.$('p.remote-host-unavailable-empty-state-progress.hidden')); // Connect progress changes while the user waits (waiting → download // percentage), so announce it politely rather than leaving it silent. @@ -85,6 +86,8 @@ export class RemoteHostUnavailableEmptyState extends Disposable { this._autoConnectListener.clear(); this.domNode.classList.toggle('hidden', !content); if (!content) { + this._description.textContent = ''; + this._description.classList.add('hidden'); this._progress.textContent = ''; this._progress.classList.add('hidden'); this._actionContainer.classList.add('hidden'); @@ -98,7 +101,8 @@ export class RemoteHostUnavailableEmptyState extends Disposable { this.domNode.setAttribute('aria-label', content.title); this._title.textContent = content.title; - this._description.textContent = content.description; + this._description.textContent = content.description ?? ''; + this._description.classList.toggle('hidden', !content.description); this._progress.textContent = content.progress ?? ''; this._progress.classList.toggle('hidden', !content.progress); if (!content.action) { diff --git a/src/vs/sessions/browser/parts/sessionRemoteConnection.ts b/src/vs/sessions/browser/parts/sessionRemoteConnection.ts index e9b094ecb2ac3a..9a8a79ffe66a7a 100644 --- a/src/vs/sessions/browser/parts/sessionRemoteConnection.ts +++ b/src/vs/sessions/browser/parts/sessionRemoteConnection.ts @@ -10,9 +10,10 @@ import { Disposable, MutableDisposable } from '../../../base/common/lifecycle.js import { autorun, derived, derivedObservableWithCache, IObservable, IReader, observableSignal, observableValue, transaction } from '../../../base/common/observable.js'; import { localize } from '../../../nls.js'; import { ILogService } from '../../../platform/log/common/log.js'; -import { isAgentHostProvider } from '../../common/agentHostSessionsProvider.js'; +import { IAgentHostConnectionLabels, isAgentHostProvider } from '../../common/agentHostSessionsProvider.js'; import { SessionRemoteConnectionFailureReason, SessionRemoteConnectionStatus } from '../../services/sessions/common/session.js'; import { IActiveSession } from '../../services/sessions/common/sessionsManagement.js'; +import { ISessionsProvider } from '../../services/sessions/common/sessionsProvider.js'; import { ISessionsProvidersService } from '../../services/sessions/browser/sessionsProvidersService.js'; import { IRemoteHostUnavailableEmptyStateContent } from './remoteHostUnavailableEmptyState.js'; import { ISessionReadOnlyBannerContent } from './sessionReadOnlyBanner.js'; @@ -63,6 +64,13 @@ export class SessionRemoteConnection extends Disposable { * `connect()` joins an in-flight dial rather than starting a second one. */ private readonly _autoConnected = observableValue(this, undefined); + /** + * The session a connect has already been started for, however it ended. Distinguishes a host + * that has never been dialled — where the action reads as "Connect" — from one that was tried + * and did not come up, where it reads as "Retry". Latched separately from {@link _attempt}, + * which clears once an attempt settles and so cannot answer "has this been tried at all". + */ + private readonly _connectAttempted = observableValue(this, undefined); /** * Whether the host should be started without waiting for a click. Gated on a * stopped host the provider can start, and on not having tried yet — one @@ -170,6 +178,7 @@ export class SessionRemoteConnection extends Disposable { this._session.set(session, tx); this._attempt.set(undefined, tx); this._autoConnected.set(undefined, tx); + this._connectAttempted.set(undefined, tx); }); } @@ -197,7 +206,10 @@ export class SessionRemoteConnection extends Disposable { const statusBefore = session.remoteConnectionStatus?.get(); this._logService.info(`[SessionRemoteConnection] connect: starting ${provider.remoteAddress ?? provider.id}, statusBefore=${statusBefore?.kind}`); - this._attempt.set({ kind: 'active', session, message: undefined, statusBefore }, undefined); + transaction(tx => { + this._attempt.set({ kind: 'active', session, message: undefined, statusBefore }, tx); + this._connectAttempted.set(session, tx); + }); const remoteAddress = provider.remoteAddress; if (remoteAddress && provider.onDidReportConnectProgress) { this._progressListener.value = provider.onDidReportConnectProgress(progress => { @@ -255,7 +267,26 @@ export class SessionRemoteConnection extends Disposable { : status; } - private _getRemoteHostConnectProgress(session: IActiveSession, status: SessionRemoteConnectionStatus | undefined, reader: IReader): string | undefined { + private _getConnectionLabels(provider: ISessionsProvider | undefined): IAgentHostConnectionLabels { + if (provider && isAgentHostProvider(provider) && provider.connectionLabels) { + return provider.connectionLabels; + } + const hostLabel = provider?.label ?? localize('sessionRemoteHost.unknown', "The remote host"); + return { + unavailableTitle: localize('sessionRemoteHost.disconnectedTitle', "Cannot Connect to {0}", hostLabel), + unavailableDescription: localize('sessionRemoteHost.disconnected', "Cannot reach {0}.", hostLabel), + unavailable: localize('sessionRemoteHost.disconnected', "Cannot reach {0}.", hostLabel), + connectingTitle: localize('sessionRemoteHost.connectingTitle', "Connecting to {0}", hostLabel), + connectingDescription: localize('sessionRemoteHost.startingDescription', "Starting {0}.", hostLabel), + connecting: localize('sessionRemoteHost.waitingForConnection', "Waiting for agent host connection..."), + reconnecting: localize('sessionRemoteHost.reconnecting', "Reconnecting to {0}...", hostLabel), + reconnectingIn: seconds => localize('sessionRemoteHost.reconnectingIn', "Reconnecting to {0} in {1}s", hostLabel, seconds), + incompatibleTitle: localize('sessionRemoteHost.incompatibleTitle', "Cannot Connect to {0}", hostLabel), + incompatible: localize('sessionRemoteHost.incompatibleDescription', "{0} is incompatible with this version of Visual Studio Code.", hostLabel), + }; + } + + private _getRemoteHostConnectProgress(session: IActiveSession, status: SessionRemoteConnectionStatus | undefined, labels: IAgentHostConnectionLabels, reader: IReader): string | undefined { const attempt = this._attempt.read(reader); if (attempt?.kind !== 'active' || attempt.session !== session || status?.kind === 'connected') { return undefined; @@ -271,9 +302,7 @@ export class SessionRemoteConnection extends Disposable { const settling = status?.kind === 'connecting' || status?.kind === 'reconnecting' || isSameRemoteConnectionStatus(status, attempt.statusBefore); - return settling - ? localize('sessionRemoteHost.waitingForConnection', "Waiting for agent host connection...") - : undefined; + return settling ? labels.connecting : undefined; } private _getRemoteHostUnavailableContent(reader: IReader): IRemoteHostUnavailableEmptyStateContent | undefined { @@ -285,16 +314,17 @@ export class SessionRemoteConnection extends Disposable { const status = this._getEffectiveStatus(reader); const provider = this._sessionsProvidersService.getProvider(session.providerId); const hostLabel = provider?.label ?? localize('sessionRemoteHost.unknown', "The remote host"); + const labels = this._getConnectionLabels(provider); if (status?.kind === 'connected') { return undefined; } if (status?.kind === 'incompatible') { return { - title: localize('sessionRemoteHost.incompatibleTitle', "Cannot Connect to {0}", hostLabel), - description: localize('sessionRemoteHost.incompatibleDescription', "{0} is incompatible with this version of Visual Studio Code.", hostLabel), + title: labels.incompatibleTitle, + description: labels.incompatible, }; } - const progressMessage = this._getRemoteHostConnectProgress(session, status, reader); + const progressMessage = this._getRemoteHostConnectProgress(session, status, labels, reader); const autoConnectPending = this._autoConnectPending.read(reader); const canStartHost = !!provider && isAgentHostProvider(provider) && !!provider.connect; const autoConnect = canStartHost && provider.autoConnect @@ -310,11 +340,12 @@ export class SessionRemoteConnection extends Disposable { && attempt.session === session && attempt.statusBefore?.kind === 'disconnected' && attempt.statusBefore.reason === SessionRemoteConnectionFailureReason.HostNotRunning); - if (progressMessage || autoConnectPending) { + // Include externally started connects; reconnecting keeps its delayed countdown banner. + if (progressMessage || autoConnectPending || status?.kind === 'connecting') { return { - title: localize('sessionRemoteHost.connectingTitle', "Connecting to {0}", hostLabel), - description: localize('sessionRemoteHost.startingDescription', "Starting {0}.", hostLabel), - progress: progressMessage ?? localize('sessionRemoteHost.waitingForConnection', "Waiting for agent host connection..."), + title: labels.connectingTitle, + description: labels.connectingDescription, + progress: progressMessage ?? labels.connecting, autoConnect: startedFromStoppedHost ? autoConnect : undefined, }; } @@ -335,14 +366,14 @@ export class SessionRemoteConnection extends Disposable { }; } return { - title: localize('sessionRemoteHost.disconnectedTitle', "Cannot Connect to {0}", hostLabel), - description: localize('sessionRemoteHost.disconnected', "Cannot reach {0}.", hostLabel), + title: labels.unavailableTitle, + description: labels.unavailableDescription, // An unreachable host is often transient — a dropped tunnel, a sleeping // machine — so offer a manual retry even though there is nothing local // to start and so nothing to do automatically. action: canStartHost ? { - label: localize('sessionRemoteHost.retry', "Retry"), + label: this._connectActionLabel(session, reader), run: () => this.connect(), } : undefined, @@ -358,17 +389,18 @@ export class SessionRemoteConnection extends Disposable { const status = this._getEffectiveStatus(reader); const provider = this._sessionsProvidersService.getProvider(session.providerId); const hostLabel = provider?.label ?? localize('sessionRemoteHost.unknown', "The remote host"); - const progressMessage = this._getRemoteHostConnectProgress(session, status, reader); + const labels = this._getConnectionLabels(provider); + const progressMessage = this._getRemoteHostConnectProgress(session, status, labels, reader); // Mirror the recovery state: while an automatic start is pending the // action must not appear, even for the frame before the attempt registers. - if (progressMessage || this._autoConnectPending.read(reader)) { + if (progressMessage || this._autoConnectPending.read(reader) || status?.kind === 'connecting') { return { icon: Codicon.sync, - message: progressMessage ?? localize('sessionRemoteHost.waitingForConnection', "Waiting for agent host connection..."), + message: progressMessage ?? labels.connecting, }; } - if (!status || status.kind === 'connected' || status.kind === 'connecting') { + if (!status || status.kind === 'connected') { return undefined; } @@ -377,7 +409,7 @@ export class SessionRemoteConnection extends Disposable { // so this banner is the only place the incompatibility can be explained. return { icon: Codicon.debugDisconnect, - message: localize('sessionRemoteHost.incompatibleDescription', "{0} is incompatible with this version of Visual Studio Code.", hostLabel), + message: labels.incompatible, }; } @@ -386,13 +418,11 @@ export class SessionRemoteConnection extends Disposable { return this._reconnectingBannerVisible.read(reader) ? { icon: Codicon.sync, - message: seconds === undefined - ? localize('sessionRemoteHost.reconnecting', "Reconnecting to {0}...", hostLabel) - : localize('sessionRemoteHost.reconnectingIn', "Reconnecting to {0} in {1}s", hostLabel, seconds), + message: seconds === undefined ? labels.reconnecting : labels.reconnectingIn(seconds), // The banner is a live region, so the per-second countdown would // otherwise queue an announcement every tick. Keep the spoken // text stable and let only the visible text count down. - ariaLabel: localize('sessionRemoteHost.reconnecting', "Reconnecting to {0}...", hostLabel), + ariaLabel: labels.reconnecting, action: seconds !== undefined && provider && isAgentHostProvider(provider) && provider.reconnectNow ? { label: localize('sessionRemoteHost.tryNow', "Try Now"), @@ -416,15 +446,27 @@ export class SessionRemoteConnection extends Disposable { } return { icon: Codicon.debugDisconnect, - message: localize('sessionRemoteHost.disconnected', "Cannot reach {0}.", hostLabel), + message: labels.unavailable, // A dropped tunnel or a sleeping machine is usually transient, so a // manual retry is worth offering even with nothing local to start. action: provider && isAgentHostProvider(provider) && provider.connect ? { - label: localize('sessionRemoteHost.retry', "Retry"), + label: this._connectActionLabel(session, reader), run: () => this.connect(), } : undefined, }; } + + /** + * The wording for the action that establishes the connection. A host that has never been + * dialled from this view is offered a plain "Connect": presenting it as a retry would imply an + * attempt the user never made, and for hosts that must be resumed rather than merely reached, + * the first dial is a deliberate choice rather than a recovery. + */ + private _connectActionLabel(session: IActiveSession, reader: IReader): string { + return this._connectAttempted.read(reader) === session + ? localize('sessionRemoteHost.retry', "Retry") + : localize('sessionRemoteHost.connect', "Connect"); + } } diff --git a/src/vs/sessions/browser/parts/sessionView.ts b/src/vs/sessions/browser/parts/sessionView.ts index 637668bf2ea6a5..d59d580d74c435 100644 --- a/src/vs/sessions/browser/parts/sessionView.ts +++ b/src/vs/sessions/browser/parts/sessionView.ts @@ -298,6 +298,11 @@ export class SessionView extends Disposable implements ISerializableView { standaloneView ? standaloneView.selectWorkspace(folderUri, options) : this._groupsView.selectWorkspace(folderUri, options); } + selectNoWorkspace(): void { + const standaloneView = this._standaloneView.value; + standaloneView ? standaloneView.selectNoWorkspace() : this._groupsView.selectNoWorkspace(); + } + /** Opens the given chat in a group beside the active one ("open to the side"). */ openChatToSide(resource: URI): Promise { return this._groupsView.openChatInNewGroup(resource); diff --git a/src/vs/sessions/common/agentHostSessionsProvider.ts b/src/vs/sessions/common/agentHostSessionsProvider.ts index fa99397f2e590e..03431dea0596e0 100644 --- a/src/vs/sessions/common/agentHostSessionsProvider.ts +++ b/src/vs/sessions/common/agentHostSessionsProvider.ts @@ -46,6 +46,20 @@ export interface IAgentHostAutoConnect { setEnabled(enabled: boolean): void; } +/** Localized labels shared by connection banners and recovery screens. */ +export interface IAgentHostConnectionLabels { + readonly unavailableTitle: string; + readonly unavailableDescription?: string; + readonly unavailable: string; + readonly connectingTitle: string; + readonly connectingDescription?: string; + readonly connecting: string; + readonly reconnecting: string; + reconnectingIn(seconds: number): string; + readonly incompatibleTitle: string; + readonly incompatible: string; +} + /** * Declares that a provider is one of many interchangeable members of a single * user-facing host. Members collapse into one `IAgentHostFilterEntry` that @@ -153,6 +167,9 @@ export interface IAgentHostSessionsProvider extends ISessionsProvider { */ readonly autoConnect?: IAgentHostAutoConnect; + /** Optional labels for providers whose display name does not name the host. */ + readonly connectionLabels?: IAgentHostConnectionLabels; + /** * When `true`, the workspace picker keeps this provider's browse * action(s) enabled even while {@link connectionStatus} reports diff --git a/src/vs/sessions/common/customizations.ts b/src/vs/sessions/common/customizations.ts new file mode 100644 index 00000000000000..d1e7f332aa2f69 --- /dev/null +++ b/src/vs/sessions/common/customizations.ts @@ -0,0 +1,6 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export const OPEN_CUSTOMIZATIONS_COMMAND_ID = 'sessions.customization.overview'; diff --git a/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeView.contribution.ts b/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeView.contribution.ts index d546bf0974f2a3..261d2bf3c91ebe 100644 --- a/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeView.contribution.ts +++ b/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeView.contribution.ts @@ -21,9 +21,13 @@ import { IPromptsService, PromptsStorage } from '../../../../workbench/contrib/c import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; import { KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; -import { SessionsView, SessionsViewId } from '../../sessions/browser/views/sessionsView.js'; import { IsSessionsWindowContext } from '../../../../workbench/common/contextkeys.js'; import { TerminalContextKeys } from '../../../../workbench/contrib/terminal/common/terminalContextKey.js'; +import { OPEN_CUSTOMIZATIONS_COMMAND_ID } from '../../../common/customizations.js'; +import { SessionsView, SessionsViewId } from '../../sessions/browser/views/sessionsView.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { ChatConfiguration } from '../../../../workbench/contrib/chat/common/constants.js'; +import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; //#region Utilities @@ -292,20 +296,23 @@ registerAction2(class extends Action2 { constructor() { super({ id: FOCUS_AI_CUSTOMIZATION_VIEW_ID, - title: localize2('focusCustomizations', "Focus Chat Customizations"), + title: localize2('openCustomizations', "Open Chat Customizations"), category: AI_CUSTOMIZATION_CATEGORY, - precondition: IsSessionsWindowContext, + precondition: ContextKeyExpr.and(IsSessionsWindowContext, ChatContextKeys.enabled), f1: true, keybinding: { weight: KeybindingWeight.WorkbenchContrib, primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyC, - when: ContextKeyExpr.and(IsSessionsWindowContext, TerminalContextKeys.focus.negate()), + when: ContextKeyExpr.and(IsSessionsWindowContext, ChatContextKeys.enabled, TerminalContextKeys.focus.negate()), }, }); } async run(accessor: ServicesAccessor): Promise { - const viewsService = accessor.get(IViewsService); - const sessionsView = await viewsService.openView(SessionsViewId, false); + if (accessor.get(IConfigurationService).getValue(ChatConfiguration.CustomizationEntryPoints)) { + await accessor.get(ICommandService).executeCommand(OPEN_CUSTOMIZATIONS_COMMAND_ID); + return; + } + const sessionsView = await accessor.get(IViewsService).openView(SessionsViewId, false); sessionsView?.focusCustomizations(); } }); diff --git a/src/vs/sessions/contrib/changes/browser/changesViewActions.ts b/src/vs/sessions/contrib/changes/browser/changesViewActions.ts index 6d1edb789dcb4b..cdb2a6554237e2 100644 --- a/src/vs/sessions/contrib/changes/browser/changesViewActions.ts +++ b/src/vs/sessions/contrib/changes/browser/changesViewActions.ts @@ -8,19 +8,23 @@ import { Disposable } from '../../../../base/common/lifecycle.js'; import { observableFromEvent } from '../../../../base/common/observable.js'; import { isEqual } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; +import { EditorContextKeys } from '../../../../editor/common/editorContextKeys.js'; import { localize, localize2 } from '../../../../nls.js'; +import { Categories } from '../../../../platform/action/common/actionCommonCategories.js'; import { Action2, IAction2Options, MenuId, MenuRegistry, registerAction2 } from '../../../../platform/actions/common/actions.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { ContextKeyExpr, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { bindContextKey } from '../../../../platform/observable/common/platformObservableUtils.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; -import { TOGGLE_DIFF_SIDE_BY_SIDE } from '../../../../workbench/browser/parts/editor/diffEditorCommands.js'; +import { DIFF_VIEW_MODE_INLINE_TEMPORARY, SET_DIFF_VIEW_MODE_AUTOMATIC, SET_DIFF_VIEW_MODE_INLINE, SET_DIFF_VIEW_MODE_SIDE_BY_SIDE, TOGGLE_DIFF_SIDE_BY_SIDE } from '../../../../workbench/browser/parts/editor/diffEditorCommands.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { ActiveEditorContext, AuxiliaryBarVisibleContext, IsAuxiliaryWindowContext, IsSessionsWindowContext, IsTopRightEditorGroupContext, MainEditorAreaVisibleContext, TextCompareEditorActiveContext } from '../../../../workbench/common/contextkeys.js'; import { DiffEditorInput } from '../../../../workbench/common/editor/diffEditorInput.js'; +import { OpenMultiDiffEditorLayoutDebugAction } from '../../../../workbench/contrib/multiDiffEditor/browser/actions.js'; import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js'; import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; +import { MultiDiffEditor } from '../../../../workbench/contrib/multiDiffEditor/browser/multiDiffEditor.js'; import { Menus } from '../../../browser/menus.js'; import { CustomViewVisibleContext, SessionHasChangesContext, SessionIsCreatedContext, SinglePaneDiffEditorInputActiveContext, SinglePaneLayoutEnabledContext } from '../../../common/contextkeys.js'; import { logChangesViewViewModeChange } from '../../../common/sessionsTelemetry.js'; @@ -28,7 +32,7 @@ import { ISessionsService } from '../../../services/sessions/browser/sessionsSer import { OPEN_PULL_REQUEST_ACTION_ID } from '../../github/common/types.js'; import { ActiveSessionContextKeys, CHANGES_VIEW_ID, ChangesContextKeys, ChangesViewMode, SESSIONS_CHANGES_OPEN_SINGLE_FILE_DIFF_SETTING } from '../common/changes.js'; import { IChangesViewService } from '../common/changesViewService.js'; -import { SessionsDiffRenderSideBySideContext } from '../../editor/common/diffEditorOptionsService.js'; +import { SessionsDiffViewModeContext } from '../../editor/common/diffEditorOptionsService.js'; import { CHANGES_HEADER_ACTIONS_ID } from './changesView.js'; import { SessionChangesEditor } from './sessionChangesEditor.js'; @@ -122,10 +126,9 @@ class OpenPullRequestAction extends Action2 { registerAction2(OpenPullRequestAction); -const singlePaneChangesEditorActive = ContextKeyExpr.and( +const agentsChangesEditorActive = ContextKeyExpr.and( IsSessionsWindowContext, - ActiveEditorContext.isEqualTo(SessionChangesEditor.ID), - SinglePaneLayoutEnabledContext + ActiveEditorContext.isEqualTo(SessionChangesEditor.ID) ); const singlePaneFileDiffEditorActive = ContextKeyExpr.and( @@ -134,12 +137,34 @@ const singlePaneFileDiffEditorActive = ContextKeyExpr.and( SinglePaneLayoutEnabledContext ); -const singlePaneTextDiffEditorActive = ContextKeyExpr.and( +const agentsTextDiffEditorActive = ContextKeyExpr.and( IsSessionsWindowContext, - TextCompareEditorActiveContext, - SinglePaneLayoutEnabledContext + TextCompareEditorActiveContext +); + +const agentsMultiDiffEditorActive = ContextKeyExpr.and( + IsSessionsWindowContext, + ActiveEditorContext.isEqualTo(MultiDiffEditor.ID) +); + +const agentsDiffEditorActive = ContextKeyExpr.or( + agentsChangesEditorActive, + agentsTextDiffEditorActive, + agentsMultiDiffEditorActive ); +const singlePaneChangesEditorActive = ContextKeyExpr.and(agentsChangesEditorActive, SinglePaneLayoutEnabledContext); +const singlePaneTextDiffEditorActive = ContextKeyExpr.and(agentsTextDiffEditorActive, SinglePaneLayoutEnabledContext); + +MenuRegistry.appendMenuItem(MenuId.CommandPalette, { + command: { + id: OpenMultiDiffEditorLayoutDebugAction.ID, + title: OpenMultiDiffEditorLayoutDebugAction.TITLE, + category: Categories.Developer, + }, + when: singlePaneChangesEditorActive +}); + // Title-bar (tab-row) gate that does NOT require the editor content area to be // visible, so session-level title actions (e.g. Create Pull Request) stay available // when the editor area is closed but the docked tab bar is still shown. @@ -166,8 +191,19 @@ const singlePaneTextDiffEditorTitle = ContextKeyExpr.and( IsTopRightEditorGroupContext ); +const singlePaneMultiDiffEditorTitle = ContextKeyExpr.and( + agentsMultiDiffEditorActive, + SinglePaneLayoutEnabledContext, + IsAuxiliaryWindowContext.toNegated(), + IsTopRightEditorGroupContext +); + const singlePaneDiffEditorTitleVisible = ContextKeyExpr.and( - ContextKeyExpr.or(singlePaneChangesEditorTitle, singlePaneTextDiffEditorTitle), + ContextKeyExpr.or( + singlePaneChangesEditorTitle, + singlePaneTextDiffEditorTitle, + singlePaneMultiDiffEditorTitle + ), MainEditorAreaVisibleContext ); @@ -323,19 +359,66 @@ registerAction2(ExpandAllSessionChangesDiffsAction); // user's keybinding for it carries over here (issue #324765). The sessions override of // IDiffEditorCommandsService updates the Changes editor's own preferred layout. -// The action changes the preferred layout. Side by side still falls back to inline -// when the editor is narrow, so the label must not promise an immediate layout. -MenuRegistry.appendMenuItem(Menus.SessionsEditorHeaderLayout, { +MenuRegistry.appendMenuItem(Menus.SessionsEditorTitle, { + submenu: Menus.SessionsDiffEditorView, + title: localize('diffView', "Diff View"), + group: '1_diff', + order: 10, + when: singlePaneDiffEditorTitleVisible, +}); +MenuRegistry.appendMenuItem(MenuId.EditorTitle, { + submenu: Menus.SessionsDiffEditorView, + title: localize('diffView', "Diff View"), + group: '1_diff', + order: 10, + when: ContextKeyExpr.and(agentsDiffEditorActive, SinglePaneLayoutEnabledContext.negate()), +}); +MenuRegistry.appendMenuItem(Menus.SessionsDiffEditorView, { command: { - id: TOGGLE_DIFF_SIDE_BY_SIDE, - title: localize('alwaysShowInlineDiff', "Always Show Inline Diff"), - tooltip: localize('alwaysShowInlineDiff.tooltip', "Always uses inline layout."), - icon: Codicon.diffSidebyside, - toggled: SessionsDiffRenderSideBySideContext.negate(), + id: SET_DIFF_VIEW_MODE_INLINE, + title: localize('diffView.inline', "Inline"), + toggled: SessionsDiffViewModeContext.isEqualTo('inline'), + }, + group: '1_view', + order: 1, +}); +MenuRegistry.appendMenuItem(Menus.SessionsDiffEditorView, { + command: { + id: SET_DIFF_VIEW_MODE_SIDE_BY_SIDE, + title: localize('diffView.sideBySide', "Side by Side"), + toggled: SessionsDiffViewModeContext.isEqualTo('sideBySide'), + }, + group: '1_view', + order: 2, +}); +for (const [title, when] of [ + [localize('diffView.automaticSideBySide', "Automatic (Currently Side by Side)"), EditorContextKeys.diffEditorAutomaticRenderSideBySide], + [localize('diffView.automaticInline', "Automatic (Currently Inline)"), EditorContextKeys.diffEditorAutomaticRenderSideBySide.toNegated()], +] as const) { + MenuRegistry.appendMenuItem(Menus.SessionsDiffEditorView, { + command: { + id: SET_DIFF_VIEW_MODE_AUTOMATIC, + title, + toggled: ContextKeyExpr.and( + SessionsDiffViewModeContext.isEqualTo('automatic'), + EditorContextKeys.diffEditorTemporaryInlineMode.toNegated(), + ), + }, + group: '1_view', + order: 3, + when, + }); +} +MenuRegistry.appendMenuItem(Menus.SessionsDiffEditorView, { + command: { + id: DIFF_VIEW_MODE_INLINE_TEMPORARY, + title: localize('diffView.inlineTemporary', "Inline (Temporary)"), + toggled: EditorContextKeys.diffEditorTemporaryInlineMode, + precondition: ContextKeyExpr.false(), }, - group: 'secondary/1_diff', - order: 20, - when: singlePaneDiffEditorTitleVisible + group: '1_view', + order: 4, + when: EditorContextKeys.diffEditorTemporaryInlineMode, }); // Discoverable in the command palette while a Changes diff editor is visible. @@ -345,7 +428,7 @@ MenuRegistry.appendMenuItem(MenuId.CommandPalette, { title: localize2('togglePreferredDiffView', "Toggle Preferred Diff View"), category: localize2('changes', "Changes"), }, - when: singlePaneDiffEditorTitleVisible + when: agentsDiffEditorActive }); class OpenChangesAction extends Action2 { diff --git a/src/vs/sessions/contrib/changes/browser/sessionChangesEditor.ts b/src/vs/sessions/contrib/changes/browser/sessionChangesEditor.ts index f6699a9159688d..ce4196da12586d 100644 --- a/src/vs/sessions/contrib/changes/browser/sessionChangesEditor.ts +++ b/src/vs/sessions/contrib/changes/browser/sessionChangesEditor.ts @@ -178,6 +178,10 @@ export class SessionChangesEditor extends AbstractEditorWithViewState this._onDidChangeControl.fire())); this.widget.setPaddingBottom(CHANGES_LIST_BOTTOM_PADDING_PX); this._register(autorun(reader => { - this.widget?.setRenderSideBySide(this.diffEditorOptionsService.renderSideBySide.read(reader), { useInlineViewWhenSpaceIsLimited: true }); + this.widget?.setViewMode(this.diffEditorOptionsService.viewMode.read(reader)); })); } @@ -293,6 +298,14 @@ export class SessionChangesEditor extends AbstractEditorWithViewState; + readonly activeSessionChangeCountObs: IObservable; private readonly _onDidChangeDecorations = this._register(new Emitter()); - private _decoratedChangeCount = 0; private _decoratedResource: URI | undefined; + private _decoratedChangeCount: number | undefined; constructor( @IEditorService private readonly editorService: IEditorService, @@ -47,10 +49,22 @@ export class SessionChangesService extends Disposable implements ISessionChanges @IAgentWorkbenchLayoutService private readonly layoutService: IAgentWorkbenchLayoutService, @IChangesViewService private readonly changesViewService: IChangesViewService, @IDecorationsService decorationsService: IDecorationsService, + @ISessionsService sessionsService: ISessionsService, ) { super(); - this.activeSessionChangeCountObs = derived(this, reader => changesViewService.activeSessionChangesObs.read(reader).length); + this.activeSessionChangeCountObs = derived(this, reader => { + const activeSession = sessionsService.activeSession.read(reader); + if (activeSession?.status.read(reader) !== SessionStatus.Untitled) { + return undefined; + } + + if (changesViewService.activeSessionChangesetObs.read(reader)?.id !== UNCOMMITTED_CHANGES_CHANGESET_ID) { + return undefined; + } + + return changesViewService.activeSessionChangesObs.read(reader).length; + }); if (!layoutService.isSinglePaneLayoutEnabled) { return; @@ -145,7 +159,7 @@ export class SessionChangesService extends Disposable implements ISessionChanges } private _provideDecoration(resource: URI): IDecorationData | undefined { - if (this._decoratedChangeCount === 0 || !isEqual(resource, this._decoratedResource)) { + if (!this._decoratedChangeCount || !isEqual(resource, this._decoratedResource)) { return undefined; } diff --git a/src/vs/sessions/contrib/changes/browser/sessionsChangesAccessibilityHelp.ts b/src/vs/sessions/contrib/changes/browser/sessionsChangesAccessibilityHelp.ts index bb1bd3d93546ff..03cad75b35830d 100644 --- a/src/vs/sessions/contrib/changes/browser/sessionsChangesAccessibilityHelp.ts +++ b/src/vs/sessions/contrib/changes/browser/sessionsChangesAccessibilityHelp.ts @@ -36,8 +36,8 @@ export class SessionsChangesAccessibilityHelp implements IAccessibleViewImplemen content.push(localize('sessionsChanges.viewMode', "The Changes view can show files as a tree or a flat list. Use the view's toolbar actions to switch between Tree and List modes.")); content.push(localize('sessionsChanges.operations', "When available, the Changes toolbar or editor title bar also provides actions to commit, merge, sync, or create a pull request. When Agent Merge is the primary action, activate it to toggle Agent Merge and use its dropdown to configure it. Use Tab and Shift+Tab to move between the file list and toolbar actions.")); content.push(layoutService.isSinglePaneLayoutEnabled - ? localize('sessionsChanges.diffView.singlePane', "File diffs can prefer side-by-side or inline layout. Unless screen reader optimized mode is enabled, side-by-side diffs automatically use inline layout when space is limited. Use Always Show Inline Diff in the editor header's More Actions menu, or use the Toggle Preferred Diff View command to switch the preference{0}.", '') - : localize('sessionsChanges.diffView.classic', "File diffs can use side-by-side or inline layout. Use Inline View in the editor title area's More Actions menu, or use the Toggle Inline View command to switch the layout{0}.", '')); + ? localize('sessionsChanges.diffView.singlePane', "Use Diff View in the editor title area's More Actions menu to select inline, side-by-side, or automatic layout. The Toggle Preferred Diff View command switches between inline and automatic layout{0}.", '') + : localize('sessionsChanges.diffView.classic', "Use Diff View in the editor title area's More Actions menu to select inline, side-by-side, or automatic layout. The Toggle Preferred Diff View command switches between inline and automatic layout{0}.", '')); return new AccessibleContentProvider( AccessibleViewProviderId.SessionsChanges, diff --git a/src/vs/sessions/contrib/changes/common/sessionChangesService.ts b/src/vs/sessions/contrib/changes/common/sessionChangesService.ts index 2d01b7e0b5bc6b..f89639c4baa836 100644 --- a/src/vs/sessions/contrib/changes/common/sessionChangesService.ts +++ b/src/vs/sessions/contrib/changes/common/sessionChangesService.ts @@ -23,7 +23,7 @@ export interface ISessionChangesEditorOptions extends IMultiDiffEditorOptions { /** Owns the identity and presentation state of a session's Changes editor. */ export interface ISessionChangesService { readonly _serviceBrand: undefined; - readonly activeSessionChangeCountObs: IObservable; + readonly activeSessionChangeCountObs: IObservable; /** Builds the multi-diff source URI that identifies a session's Changes editor. */ getChangesEditorResource(sessionResource: URI): URI; diff --git a/src/vs/sessions/contrib/changes/test/browser/changesViewActions.test.ts b/src/vs/sessions/contrib/changes/test/browser/changesViewActions.test.ts index 454f6d49b384c1..3a0fbadc5e615f 100644 --- a/src/vs/sessions/contrib/changes/test/browser/changesViewActions.test.ts +++ b/src/vs/sessions/contrib/changes/test/browser/changesViewActions.test.ts @@ -9,17 +9,17 @@ import { constObservable } from '../../../../../base/common/observable.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { isIMenuItem, MenuId, MenuRegistry } from '../../../../../platform/actions/common/actions.js'; -import { isICommandActionToggleInfo } from '../../../../../platform/action/common/action.js'; +import { isIMenuItem, isISubmenuItem, MenuId, MenuRegistry } from '../../../../../platform/actions/common/actions.js'; import { CommandsRegistry, ICommandService } from '../../../../../platform/commands/common/commands.js'; import { Context } from '../../../../../platform/contextkey/browser/contextKeyService.js'; import { ContextKeyExpression } from '../../../../../platform/contextkey/common/contextkey.js'; import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { EditorContextKeys } from '../../../../../editor/common/editorContextKeys.js'; -import { SessionsDiffRenderSideBySideContext } from '../../../editor/common/diffEditorOptionsService.js'; +import { SessionsDiffViewModeContext } from '../../../editor/common/diffEditorOptionsService.js'; import { ActiveEditorContext, AuxiliaryBarVisibleContext, IsAuxiliaryWindowContext, IsSessionsWindowContext, IsTopRightEditorGroupContext, MainEditorAreaVisibleContext, TextCompareEditorActiveContext } from '../../../../../workbench/common/contextkeys.js'; import { ChatPetAchievementId, ChatPetAchievementIds } from '../../../../../workbench/contrib/chat/browser/chatPetAchievements.js'; import { IChatPetService } from '../../../../../workbench/contrib/chat/browser/chatPetService.js'; +import { OpenMultiDiffEditorLayoutDebugAction } from '../../../../../workbench/contrib/multiDiffEditor/browser/actions.js'; import { IViewsService } from '../../../../../workbench/services/views/common/viewsService.js'; import { Menus } from '../../../../browser/menus.js'; import { IAgentWorkbenchLayoutService } from '../../../../browser/workbench.js'; @@ -28,6 +28,7 @@ import { IActiveSession } from '../../../../services/sessions/common/sessionsMan import { ChangesContextKeys, ChangesViewMode } from '../../common/changes.js'; import { CustomViewVisibleContext, IsPhoneLayoutContext, SessionHasChangesContext, SessionHasWorkspaceContext, SessionIsCreatedContext, SinglePaneDiffEditorInputActiveContext, SinglePaneLayoutEnabledContext } from '../../../../common/contextkeys.js'; import { SessionChangesEditor } from '../../browser/sessionChangesEditor.js'; +import { MultiDiffEditor } from '../../../../../workbench/contrib/multiDiffEditor/browser/multiDiffEditor.js'; import { CHANGES_HEADER_ACTIONS_ID, unlockChatPetCreatePullRequestAchievement } from '../../browser/changesView.js'; import { SessionsChangesAccessibilityHelp } from '../../browser/sessionsChangesAccessibilityHelp.js'; import '../../browser/changesViewActions.js'; @@ -189,86 +190,120 @@ suite('Changes View Actions', () => { }); }); - test('always show inline diff is contributed to the editor header layout overflow menu for multi-file and single-file diffs', () => { - const item = MenuRegistry.getMenuItems(Menus.SessionsEditorHeaderLayout) + test('Diff View submenu is contributed for text and multi-diff editors in both Agents layouts', () => { + const getSubmenu = (menuId: MenuId) => MenuRegistry.getMenuItems(menuId) + .filter(isISubmenuItem) + .find(item => item.submenu === Menus.SessionsDiffEditorView); + const singlePane = getSubmenu(Menus.SessionsEditorTitle); + const classic = getSubmenu(MenuId.EditorTitle); + + assert.ok(singlePane); + assert.ok(classic); + const singlePaneWhen = singlePane.when?.serialize() ?? ''; + const classicWhen = classic.when?.serialize() ?? ''; + assert.deepStrictEqual({ + singlePaneTitle: typeof singlePane.title === 'string' ? singlePane.title : singlePane.title.value, + singlePaneGroup: singlePane.group, + singlePaneHasTextDiffGate: singlePaneWhen.includes(TextCompareEditorActiveContext.key), + singlePaneHasChangesGate: singlePaneWhen.includes(SessionChangesEditor.ID), + singlePaneHasMultiDiffGate: singlePaneWhen.includes(MultiDiffEditor.ID), + singlePaneHasLayoutGate: singlePaneWhen.includes(SinglePaneLayoutEnabledContext.key), + classicTitle: typeof classic.title === 'string' ? classic.title : classic.title.value, + classicHasSessionsGate: classicWhen.includes(IsSessionsWindowContext.key), + classicHasTextDiffGate: classicWhen.includes(TextCompareEditorActiveContext.key), + classicHasChangesGate: classicWhen.includes(SessionChangesEditor.ID), + classicHasMultiDiffGate: classicWhen.includes(MultiDiffEditor.ID), + classicHasLayoutGate: classicWhen.includes(SinglePaneLayoutEnabledContext.key), + }, { + singlePaneTitle: 'Diff View', + singlePaneGroup: '1_diff', + singlePaneHasTextDiffGate: true, + singlePaneHasChangesGate: true, + singlePaneHasMultiDiffGate: true, + singlePaneHasLayoutGate: true, + classicTitle: 'Diff View', + classicHasSessionsGate: true, + classicHasTextDiffGate: true, + classicHasChangesGate: true, + classicHasMultiDiffGate: true, + classicHasLayoutGate: true, + }); + }); + + test('Agents Diff View submenu exposes inline, side-by-side, and automatic modes', () => { + const items = MenuRegistry.getMenuItems(Menus.SessionsDiffEditorView) .filter(isIMenuItem) - .find(item => item.command.id === 'toggle.diff.renderSideBySide'); + .map(item => ({ + id: item.command.id, + title: typeof item.command.title === 'string' ? item.command.title : item.command.title.value, + group: item.group, + order: item.order, + })); + const modeContext = new Context(1, null); + modeContext.setValue(SessionsDiffViewModeContext.key, 'sideBySide'); - assert.ok(item, 'expected the preferred diff view action in the editor header layout overflow menu'); + assert.deepStrictEqual({ + items, + selectedMode: modeContext.getValue(SessionsDiffViewModeContext.key), + }, { + items: [ + { id: 'diffEditor.setViewMode.inline', title: 'Inline', group: '1_view', order: 1 }, + { id: 'diffEditor.setViewMode.sideBySide', title: 'Side by Side', group: '1_view', order: 2 }, + { id: 'diffEditor.setViewMode.automatic', title: 'Automatic (Currently Side by Side)', group: '1_view', order: 3 }, + { id: 'diffEditor.setViewMode.automatic', title: 'Automatic (Currently Inline)', group: '1_view', order: 3 }, + { id: 'diffEditor.viewMode.inlineTemporary', title: 'Inline (Temporary)', group: '1_view', order: 4 }, + ], + selectedMode: 'sideBySide', + }); + }); + + test('preferred diff view is contributed to the command palette (Changes category)', () => { + const item = MenuRegistry.getMenuItems(MenuId.CommandPalette) + .filter(isIMenuItem) + .find(item => item.command.id === 'toggle.diff.renderSideBySide' && item.command.category !== undefined && (typeof item.command.category === 'string' ? item.command.category : item.command.category.value) === 'Changes'); + + assert.ok(item, 'expected the toggle inline view action in the command palette'); const when = item.when?.serialize() ?? ''; - const toggled = item.command.toggled; - const toggledCondition = isICommandActionToggleInfo(toggled) ? toggled.condition : toggled; - const nonTextDiffContext = new Context(1, null); - nonTextDiffContext.setValue(IsSessionsWindowContext.key, true); - nonTextDiffContext.setValue(SinglePaneDiffEditorInputActiveContext.key, true); - nonTextDiffContext.setValue(SinglePaneLayoutEnabledContext.key, true); - nonTextDiffContext.setValue(IsAuxiliaryWindowContext.key, false); - nonTextDiffContext.setValue(IsTopRightEditorGroupContext.key, true); - nonTextDiffContext.setValue(MainEditorAreaVisibleContext.key, true); - const toggleContext = new Context(1, null); - toggleContext.setValue(SessionsDiffRenderSideBySideContext.key, true); - const toggledWhenSideBySide = toggledCondition?.evaluate(toggleContext); - toggleContext.setValue(SessionsDiffRenderSideBySideContext.key, false); assert.deepStrictEqual({ id: item.command.id, title: typeof item.command.title === 'string' ? item.command.title : item.command.title.value, - group: item.group, - order: item.order, - icon: ThemeIcon.isThemeIcon(item.command.icon) ? item.command.icon.id : undefined, - tooltip: typeof item.command.tooltip === 'string' ? item.command.tooltip : item.command.tooltip?.value, - hasStateSpecificTitle: isICommandActionToggleInfo(toggled), - toggledWhenSideBySide, - toggledWhenInline: toggledCondition?.evaluate(toggleContext), + category: item.command.category && typeof item.command.category !== 'string' ? item.command.category.value : item.command.category, hasSessionsWindowGate: when.includes(IsSessionsWindowContext.key), hasActiveEditorGate: when.includes(ActiveEditorContext.key) && when.includes(SessionChangesEditor.ID), hasTextCompareEditorGate: when.includes(TextCompareEditorActiveContext.key), - hasSinglePaneConfigGate: when.includes(SinglePaneLayoutEnabledContext.key), - hasEditorAreaVisibleGate: when.includes(MainEditorAreaVisibleContext.key), - matchesNonTextDiffContext: item.when?.evaluate(nonTextDiffContext) ?? false, + hasMultiDiffEditorGate: when.includes(MultiDiffEditor.ID), }, { id: 'toggle.diff.renderSideBySide', - title: 'Always Show Inline Diff', - group: 'secondary/1_diff', - order: 20, - icon: Codicon.diffSidebyside.id, - tooltip: 'Always uses inline layout.', - hasStateSpecificTitle: false, - toggledWhenSideBySide: false, - toggledWhenInline: true, + title: 'Toggle Preferred Diff View', + category: 'Changes', hasSessionsWindowGate: true, hasActiveEditorGate: true, hasTextCompareEditorGate: true, - hasSinglePaneConfigGate: true, - hasEditorAreaVisibleGate: true, - matchesNonTextDiffContext: false, + hasMultiDiffEditorGate: true, }); }); - test('preferred diff view is contributed to the command palette (Changes category)', () => { + test('multi-diff layout debug state is contributed to the command palette for the Changes editor', () => { const item = MenuRegistry.getMenuItems(MenuId.CommandPalette) .filter(isIMenuItem) - .find(item => item.command.id === 'toggle.diff.renderSideBySide' && item.command.category !== undefined && (typeof item.command.category === 'string' ? item.command.category : item.command.category.value) === 'Changes'); + .find(item => item.command.id === OpenMultiDiffEditorLayoutDebugAction.ID && item.when?.serialize().includes(SessionChangesEditor.ID)); - assert.ok(item, 'expected the toggle inline view action in the command palette'); + assert.ok(item, 'expected the multi-diff layout debug action in the command palette'); const when = item.when?.serialize() ?? ''; assert.deepStrictEqual({ - id: item.command.id, title: typeof item.command.title === 'string' ? item.command.title : item.command.title.value, category: item.command.category && typeof item.command.category !== 'string' ? item.command.category.value : item.command.category, hasSessionsWindowGate: when.includes(IsSessionsWindowContext.key), hasActiveEditorGate: when.includes(ActiveEditorContext.key) && when.includes(SessionChangesEditor.ID), - hasTextCompareEditorGate: when.includes(TextCompareEditorActiveContext.key), hasSinglePaneConfigGate: when.includes(SinglePaneLayoutEnabledContext.key), - hasEditorAreaVisibleGate: when.includes(MainEditorAreaVisibleContext.key), + hasSharedPrecondition: !!item.command.precondition, }, { - id: 'toggle.diff.renderSideBySide', - title: 'Toggle Preferred Diff View', - category: 'Changes', + title: 'Open Multi Diff Editor Layout Debug State', + category: 'Developer', hasSessionsWindowGate: true, hasActiveEditorGate: true, - hasTextCompareEditorGate: true, hasSinglePaneConfigGate: true, - hasEditorAreaVisibleGate: true, + hasSharedPrecondition: false, }); }); @@ -286,11 +321,11 @@ suite('Changes View Actions', () => { } test('Changes accessibility help describes the single-pane diff action', () => { - assert.strictEqual(getChangesAccessibilityHelp(true).includes('Use Always Show Inline Diff in the editor header\'s More Actions menu'), true); + assert.strictEqual(getChangesAccessibilityHelp(true).includes('Use Diff View in the editor title area\'s More Actions menu'), true); }); test('Changes accessibility help describes the classic diff action', () => { - assert.strictEqual(getChangesAccessibilityHelp(false).includes('Use Inline View in the editor title area\'s More Actions menu'), true); + assert.strictEqual(getChangesAccessibilityHelp(false).includes('Use Diff View in the editor title area\'s More Actions menu'), true); }); test('view mode toggles are moved to the editor header layout overflow for non-text single-file diffs', () => { diff --git a/src/vs/sessions/contrib/changes/test/browser/sessionChangesService.test.ts b/src/vs/sessions/contrib/changes/test/browser/sessionChangesService.test.ts index be3088ecb7e7e8..1cd7d2bc3a0363 100644 --- a/src/vs/sessions/contrib/changes/test/browser/sessionChangesService.test.ts +++ b/src/vs/sessions/contrib/changes/test/browser/sessionChangesService.test.ts @@ -22,7 +22,9 @@ import { IEditorGroup } from '../../../../../workbench/services/editor/common/ed import { IEditorService } from '../../../../../workbench/services/editor/common/editorService.js'; import { IWorkbenchLayoutService } from '../../../../../workbench/services/layout/browser/layoutService.js'; import { IAgentWorkbenchLayoutService } from '../../../../browser/workbench.js'; -import { ISessionChangeset, ISessionFileChange, TURN_CHANGES_CHANGESET_ID } from '../../../../services/sessions/common/session.js'; +import { ISessionChangeset, ISessionFileChange, SessionStatus, TURN_CHANGES_CHANGESET_ID, UNCOMMITTED_CHANGES_CHANGESET_ID } from '../../../../services/sessions/common/session.js'; +import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; +import { IActiveSession } from '../../../../services/sessions/common/sessionsManagement.js'; import { SessionChangesEditorInput } from '../../browser/sessionChangesEditorInput.js'; import { ISessionChangesService, SessionChangesService } from '../../browser/sessionChangesService.js'; import { IChangesViewService } from '../../common/changesViewService.js'; @@ -31,6 +33,9 @@ suite('SessionChangesService', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); const noActiveSessionResource = constObservable(undefined); const noChanges = constObservable([]); + const noActiveSession = new class extends mock() { + override readonly activeSession = constObservable(undefined); + }; const emptyDecorationsService = new class extends mock() { override registerDecorationsProvider() { return Disposable.None; @@ -102,6 +107,7 @@ suite('SessionChangesService', () => { layoutService, changesViewService, emptyDecorationsService, + noActiveSession, )); instantiationService.stub(ISessionChangesService, service); @@ -145,6 +151,7 @@ suite('SessionChangesService', () => { layoutService, changesViewService, emptyDecorationsService, + noActiveSession, )); const sessionResource = URI.parse('agent-host:test-session'); @@ -214,7 +221,7 @@ suite('SessionChangesService', () => { }(); instantiationService.stub(IWorkbenchLayoutService, layoutService); instantiationService.stub(IChangesViewService, changesViewService); - const service = disposables.add(new SessionChangesService(editorService, instantiationService, layoutService, changesViewService, emptyDecorationsService)); + const service = disposables.add(new SessionChangesService(editorService, instantiationService, layoutService, changesViewService, emptyDecorationsService, noActiveSession)); instantiationService.stub(ISessionChangesService, service); await service.openChangesEditor(URI.parse('agent-host:test-session'), { @@ -223,14 +230,25 @@ suite('SessionChangesService', () => { assert.deepStrictEqual(selections, ['turn:request']); }); - test('registers one decoration provider across repeated Changes editor opens', async () => { + test('decorates only an untitled session with the uncommitted changeset selected', async () => { const sessionResource = URI.parse('agent-host:test-session'); const activeSessionResource = observableValue('activeSessionResource', undefined); + const sessionStatus = observableValue('sessionStatus', SessionStatus.Untitled); + const activeSession = observableValue('activeSession', upcastPartial({ + status: sessionStatus, + })); + const activeChangeset = observableValue('activeChangeset', upcastPartial({ + id: UNCOMMITTED_CHANGES_CHANGESET_ID, + })); const changes = observableValue('changes', []); const changesViewService = new class extends mock() { override readonly activeSessionResourceObs = activeSessionResource; + override readonly activeSessionChangesetObs = activeChangeset; override readonly activeSessionChangesObs = changes; }; + const sessionsService = new class extends mock() { + override readonly activeSession = activeSession; + }; const providers: IDecorationsProvider[] = []; const decorationsService = new class extends mock() { override registerDecorationsProvider(provider: IDecorationsProvider) { @@ -264,6 +282,7 @@ suite('SessionChangesService', () => { layoutService, changesViewService, decorationsService, + sessionsService, )); instantiationService.stub(ISessionChangesService, service); activeSessionResource.set(sessionResource, undefined); @@ -277,18 +296,28 @@ suite('SessionChangesService', () => { await service.openChangesEditor(sessionResource); await service.openChangesEditor(sessionResource); + const editorResource = service.getChangesEditorResource(sessionResource); + const newSessionDecoration = providers[0].provideDecorations(editorResource, CancellationToken.None); + sessionStatus.set(SessionStatus.Completed, undefined); + const createdSessionCount = service.activeSessionChangeCountObs.get(); + sessionStatus.set(SessionStatus.Untitled, undefined); + activeChangeset.set(upcastPartial({ id: TURN_CHANGES_CHANGESET_ID }), undefined); + const turnChangesCount = service.activeSessionChangeCountObs.get(); + assert.deepStrictEqual({ providerCount: providers.length, - changeCount: service.activeSessionChangeCountObs.get(), - decoration: providers[0].provideDecorations(service.getChangesEditorResource(sessionResource), CancellationToken.None), + newSessionDecoration, + createdSessionCount, + turnChangesCount, }, { providerCount: 1, - changeCount: 1, - decoration: { + newSessionDecoration: { weight: 100, letter: '1', tooltip: '1 file', }, + createdSessionCount: undefined, + turnChangesCount: undefined, }); }); }); diff --git a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts index 9959c2f7ff4962..54ccec5e8d0d0c 100644 --- a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts @@ -15,6 +15,9 @@ import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextke import { ConfigurationScope, Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; import { IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js'; import { IQuickInputService, IQuickPickItem, QuickPickInput } from '../../../../platform/quickinput/common/quickInput.js'; +import product from '../../../../platform/product/common/product.js'; +import { Registry } from '../../../../platform/registry/common/platform.js'; +import { Extensions as WorkbenchConfigurationExtensions, IConfigurationMigrationRegistry } from '../../../../workbench/common/configuration.js'; import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; import { ISessionsManagementService, inheritableSessionTarget } from '../../../services/sessions/common/sessionsManagement.js'; @@ -45,9 +48,8 @@ import { OpenSessionLinkOpenerContribution } from './openSessionLinkOpener.contr import { WorktreeCreatedTaskDispatcher, AGENT_HOST_RUN_WORKTREE_CREATED_TASKS_SETTING } from './worktreeCreatedTaskDispatcher.js'; import { AGENT_SESSIONS_SCOPED_INPUT_HISTORY_SETTING } from './sessionsChatHistory.js'; import '../../sessions/browser/mobile/mobileOverlayContribution.js'; -import { Registry } from '../../../../platform/registry/common/platform.js'; import { EditorAreaFocusContext, IsSessionsWindowContext, SideBarVisibleContext } from '../../../../workbench/common/contextkeys.js'; -import { NEW_SESSION_ACTION_ID } from '../common/constants.js'; +import { NEW_SESSION_ACTION_ID, UNIFIED_WORKSPACE_PICKER_SETTING } from '../common/constants.js'; import { SessionsChatBackgroundAvailableContext, SessionsChatBackgroundImageConfiguredContext, SessionsTitleBarNewSessionEnabledContext, SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js'; import { Menus } from '../../../browser/menus.js'; import { ISessionsChatViewStateService, SessionsChatViewStateService } from './chatViewStateService.js'; @@ -55,6 +57,7 @@ import { SessionsChatResponseFileChangesService } from './sessionTurnChanges.js' import { IChatResponseFileChangesService } from '../../../../workbench/contrib/chat/browser/chatResponseFileChangesService.js'; import { SessionsChatPetAchievementContribution } from './chatPetAchievements.js'; import { AGENT_SESSIONS_CHAT_BACKGROUND_CODICONS_PRESET, AGENT_SESSIONS_PREFERRED_DARK_CHAT_BACKGROUND_IMAGE_LAYOUT_SETTING, AGENT_SESSIONS_PREFERRED_DARK_CHAT_BACKGROUND_IMAGE_SETTING, AGENT_SESSIONS_PREFERRED_LIGHT_CHAT_BACKGROUND_IMAGE_LAYOUT_SETTING, AGENT_SESSIONS_PREFERRED_LIGHT_CHAT_BACKGROUND_IMAGE_SETTING, chatBackgroundImageLayoutValues, ChatBackgroundImageLayout, ISessionsChatBackgroundService, SessionsChatBackgroundService } from '../../../services/chatBackground/browser/chatBackgroundService.js'; +import { LEGACY_UNIFIED_WORKSPACE_PICKER_SETTING, unifiedWorkspacePickerConfigurationMigration } from './unifiedWorkspacePickerConfiguration.js'; const CHANGE_AGENT_SESSIONS_CHAT_BACKGROUND_COMMAND_ID = 'workbench.action.chat.changeAgentSessionsBackground'; const CHANGE_AGENT_SESSIONS_CHAT_BACKGROUND_LAYOUT_COMMAND_ID = 'workbench.action.chat.changeAgentSessionsBackgroundLayout'; @@ -368,6 +371,20 @@ AccessibleViewRegistry.register(new SessionsChatAccessibilityHelp()); // register configuration Registry.as(ConfigurationExtensions.Configuration).registerConfiguration({ properties: { + [LEGACY_UNIFIED_WORKSPACE_PICKER_SETTING]: { + type: 'boolean', + default: product.quality !== 'stable', + scope: ConfigurationScope.APPLICATION, + deprecationMessage: localize('chat.agentSessions.consolidatedRemoteWorkspaces.deprecated', "Deprecated. Use the unified workspace picker setting instead."), + }, + [UNIFIED_WORKSPACE_PICKER_SETTING]: { + type: 'boolean', + default: product.quality !== 'stable', + scope: ConfigurationScope.APPLICATION, + description: localize('sessions.chat.unifiedWorkspacePicker.enabled', "Controls whether the Agents Window uses the unified workspace picker, which combines GitHub and remote workspaces, provides search, and, when supported, allows creating sessions with no workspace."), + tags: ['experimental'], + experiment: { mode: 'auto' }, + }, [AGENT_HOST_RUN_WORKTREE_CREATED_TASKS_SETTING]: { type: 'boolean', default: true, @@ -418,3 +435,5 @@ Registry.as(ConfigurationExtensions.Configuration).regis }, }, }); + +Registry.as(WorkbenchConfigurationExtensions.ConfigurationMigration).registerConfigurationMigrations([unifiedWorkspacePickerConfigurationMigration]); diff --git a/src/vs/sessions/contrib/chat/browser/chatView.ts b/src/vs/sessions/contrib/chat/browser/chatView.ts index 1dae88cd3a7fa2..a44de4ec665b3d 100644 --- a/src/vs/sessions/contrib/chat/browser/chatView.ts +++ b/src/vs/sessions/contrib/chat/browser/chatView.ts @@ -110,6 +110,12 @@ export class NewChatView extends AbstractChatView { } } + override selectNoWorkspace(): void { + if (this._widget instanceof NewChatWidget) { + this._widget.selectNoWorkspace(); + } + } + override prefillInput(text: string): void { if (this._widget instanceof NewChatWidget) { this._widget.prefillInput(text); @@ -175,6 +181,7 @@ export class ChatView extends AbstractChatView { private readonly _currentChatResourceObs = observableValue(this, undefined); private readonly _currentSessionObs = observableValue(this, undefined); override readonly hasVisibleTranscriptContent = observableValue(this, false); + override readonly isLoadingTranscript = observableValue(this, false); private _historyKey: string | undefined; /** Whether this view currently represents the active session. */ @@ -464,6 +471,15 @@ export class ChatView extends AbstractChatView { this._loadChat(resource, this._currentSessionObs.get()); } + /** + * Drives the widget's loading affordance and the observable mirror of it together, so a + * consumer reading {@link isLoadingTranscript} can never disagree with what the widget shows. + */ + private _setLoading(isLoading: boolean): void { + this._widget.setLoading(isLoading); + this.isLoadingTranscript.set(isLoading, undefined); + } + private _loadChat(resource: URI, session: ISession | undefined, previousChatResource?: URI, previousSession?: ISession): void { // Cancel any in-flight load for the previous chat and start a fresh one. this._loadCts.value?.cancel(); @@ -473,7 +489,7 @@ export class ChatView extends AbstractChatView { const cts = new CancellationTokenSource(); this._loadCts.value = cts; const token = cts.token; - this._widget.setLoading(true); + this._setLoading(true); // Capture the input draft before the load window opens so text typed // during loading is preserved when the model binds. See #325323. @@ -488,7 +504,7 @@ export class ChatView extends AbstractChatView { this.sessionOpenTelemetryService.modelBindFailed(session.resource, resource); } if (isCurrentChat && isCurrentLoad) { - this._widget.setLoading(false); + this._setLoading(false); } this.logService.trace(`[ChatView] setChat abandoned uri=${resource.toString()}`); return; @@ -501,7 +517,7 @@ export class ChatView extends AbstractChatView { if (widgetViewState) { this._widget.restoreViewState(widgetViewState); } - this._widget.setLoading(false); + this._setLoading(false); if (session) { this.sessionOpenTelemetryService.modelBound(session.resource, resource); } @@ -520,7 +536,7 @@ export class ChatView extends AbstractChatView { if (isEqual(this._currentChatResource, resource) && this._loadCts.value === cts) { // might have changed while we were waiting, only reset if it is still the same this._currentChatResource = undefined; this._currentChatResourceObs.set(undefined, undefined); - this._widget.setLoading(false); + this._setLoading(false); } if (!token.isCancellationRequested && this._loadCts.value === cts && session) { this.sessionOpenTelemetryService.modelBindFailed(session.resource, resource); diff --git a/src/vs/sessions/contrib/chat/browser/media/chatInput.css b/src/vs/sessions/contrib/chat/browser/media/chatInput.css index e76a8b47a69b8e..1e83b178f923ce 100644 --- a/src/vs/sessions/contrib/chat/browser/media/chatInput.css +++ b/src/vs/sessions/contrib/chat/browser/media/chatInput.css @@ -145,10 +145,6 @@ background-color: var(--vscode-agentsChatInput-background) !important; } -.sessions-chat-editor .monaco-editor .mtk1 { - color: var(--vscode-input-foreground); -} - /* Inline decoration highlights for the new-session input editor: slash commands, * onboarding prompt placeholders, `#file:` variable references, and agent-host * completion references. */ diff --git a/src/vs/sessions/contrib/chat/browser/media/chatView.css b/src/vs/sessions/contrib/chat/browser/media/chatView.css index c62c86613a223c..cc7a5aff4d6d94 100644 --- a/src/vs/sessions/contrib/chat/browser/media/chatView.css +++ b/src/vs/sessions/contrib/chat/browser/media/chatView.css @@ -38,17 +38,6 @@ overflow: hidden; } -.monaco-workbench.agent-sessions-workbench .part.sessionspart.has-chat-background .chat-view .sessions-chat-widget:not(.new-chat-in-session) .new-chat-widget-content { - box-sizing: border-box; - padding: var(--vscode-spacing-size120); - background: color-mix(in srgb, var(--session-view-background) 86%, transparent); - -webkit-backdrop-filter: blur(var(--vscode-spacing-size160)) saturate(1.08); - backdrop-filter: blur(var(--vscode-spacing-size160)) saturate(1.08); - border: var(--vscode-strokeThickness) solid color-mix(in srgb, var(--vscode-editorWidget-border, var(--vscode-widget-border)) 70%, transparent); - border-radius: var(--vscode-cornerRadius-large); - box-shadow: 0 var(--vscode-spacing-size80) var(--vscode-spacing-size240) color-mix(in srgb, var(--vscode-widget-shadow) 18%, transparent); -} - .monaco-workbench.agent-sessions-workbench .part.sessionspart.has-chat-background .chat-view .interactive-session .chat-secondary-toolbar .action-label, .monaco-workbench.agent-sessions-workbench .part.sessionspart.has-chat-background .chat-view .interactive-session .chat-context-usage-widget { background-color: var(--vscode-chat-list-background, var(--vscode-button-secondaryBackground)); @@ -73,6 +62,13 @@ background-image: linear-gradient(var(--vscode-chat-requestBubbleHoverBackground), var(--vscode-chat-requestBubbleHoverBackground)); } +/* The edit input takes the place of the request bubble, so it needs the same opaque base under the translucent bubble tint. */ +.agent-sessions-workbench .part.sessionspart.has-chat-background .chat-view .interactive-session .interactive-input-part.editing .chat-input-container, +.agent-sessions-workbench .part.sessionspart.has-chat-background .chat-view .interactive-session .interactive-request.editing .interactive-input-part .chat-input-container { + background-color: var(--session-view-background); + background-image: linear-gradient(var(--vscode-chat-requestBubbleBackground), var(--vscode-chat-requestBubbleBackground)); +} + .agent-sessions-workbench .interactive-session { max-width: none; } diff --git a/src/vs/sessions/contrib/chat/browser/media/chatWidget.css b/src/vs/sessions/contrib/chat/browser/media/chatWidget.css index 8ff7de17eda668..6bdb1605e46c74 100644 --- a/src/vs/sessions/contrib/chat/browser/media/chatWidget.css +++ b/src/vs/sessions/contrib/chat/browser/media/chatWidget.css @@ -204,12 +204,32 @@ /* Match VS Code chat-secondary-toolbar sizing for action items in the bottom row * (e.g. session control pickers and repo config pickers) so they align with the - * model picker font in the input toolbar. */ + * model picker font in the input toolbar. Each item carries its own opaque + * surface so a chat background cannot show through it. */ .new-chat-widget-container .new-chat-bottom-container .action-label { height: 16px; padding: var(--vscode-spacing-size40) var(--vscode-spacing-size80); font-size: var(--vscode-fontSize-label2, 11px); color: var(--vscode-icon-foreground); + background-color: var(--session-view-background); +} + +/* Over a chat background these chips sit straight on the wallpaper with nothing + * to separate their opaque surface from it, so give them a frame — the same move + * the in-session secondary toolbar makes in chatView.css. High-contrast themes + * take `contrastBorder`, since `commandCenter.inactiveBorder` stays translucent + * there. */ +.monaco-workbench.agent-sessions-workbench .part.sessionspart.has-chat-background .new-chat-widget-container .new-chat-bottom-container .action-label { + border: var(--vscode-strokeThickness) solid var(--vscode-contrastBorder, var(--vscode-commandCenter-inactiveBorder, var(--vscode-widget-border))); + border-radius: var(--vscode-cornerRadius-small); +} + +/* Outranks the shared action-bar hover in workbench style.css, which would + * otherwise swap the opaque surface for a translucent one. The tint goes on the + * background-image layer so it composites over that surface. */ +.monaco-workbench.agent-sessions-workbench .new-chat-widget-container .new-chat-bottom-container .action-label:not(.disabled):hover { + background-color: var(--session-view-background); + background-image: linear-gradient(var(--vscode-toolbar-hoverBackground), var(--vscode-toolbar-hoverBackground)); } .new-chat-widget-container .new-chat-bottom-container .action-label > .codicon { @@ -279,6 +299,7 @@ gap: var(--vscode-spacing-size80); min-height: var(--vscode-spacing-size240); padding: var(--vscode-spacing-size20) var(--vscode-spacing-size120); + background-color: var(--session-view-background); border: var(--vscode-strokeThickness) solid var(--vscode-commandCenter-inactiveBorder, var(--vscode-widget-border)); border-radius: var(--vscode-cornerRadius-circle); box-sizing: border-box; @@ -287,9 +308,13 @@ line-height: var(--vscode-spacing-size160); } +/* Re-assert the opaque surface, which the generic picker-slot hover below would + * otherwise replace, and keep the tint on the background-image layer so it + * composites over that surface. */ .sessions-chat-picker-slot.sessions-workspace-category-picker-slot .action-label:hover, .sessions-chat-picker-slot.sessions-workspace-category-picker-slot .action-label[aria-expanded='true'] { - background-color: var(--vscode-toolbar-hoverBackground); + background-color: var(--session-view-background); + background-image: linear-gradient(var(--vscode-toolbar-hoverBackground), var(--vscode-toolbar-hoverBackground)); border-color: var(--vscode-commandCenter-activeBorder, var(--vscode-focusBorder)); color: var(--vscode-foreground); } @@ -307,10 +332,27 @@ font-size: var(--vscode-codiconFontSize-compact); } +.sessions-workspace-category-picker > .sessions-customize-trigger-slot { + margin-left: auto; +} + .action-widget .sessions-new-chat-picker-list .monaco-list-row.action .codicon { font-size: var(--vscode-codiconFontSize); } +.sessions-customize-migration-indicator { + display: none; + flex-shrink: 0; + width: var(--vscode-spacing-size80); + height: var(--vscode-spacing-size80); + border-radius: var(--vscode-cornerRadius-circle); + background-color: var(--vscode-notificationsWarningIcon-foreground); +} + +.sessions-customize-trigger.has-migrations .sessions-customize-migration-indicator { + display: block; +} + .sessions-workspace-category-picker .sessions-chat-dropdown-label { margin-left: 0; } diff --git a/src/vs/sessions/contrib/chat/browser/newChatInput.ts b/src/vs/sessions/contrib/chat/browser/newChatInput.ts index 03955581cd7814..99829456b7ed81 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatInput.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatInput.ts @@ -20,7 +20,6 @@ import type { IManagedHoverContent } from '../../../../base/browser/ui/hover/hov import { IMenuEntryActionViewItemOptions, MenuEntryActionViewItem } from '../../../../platform/actions/browser/menuEntryActionViewItem.js'; import { CodeEditorWidget, ICodeEditorWidgetOptions } from '../../../../editor/browser/widget/codeEditor/codeEditorWidget.js'; import { EditorExtensionsRegistry } from '../../../../editor/browser/editorExtensions.js'; -import { ICodeEditorService } from '../../../../editor/browser/services/codeEditorService.js'; import { IEditorConstructionOptions } from '../../../../editor/browser/config/editorConfiguration.js'; import { ITextModel } from '../../../../editor/common/model.js'; import { IModelService } from '../../../../editor/common/services/model.js'; @@ -30,7 +29,6 @@ import { EditorOptions } from '../../../../editor/common/config/editorOptions.js import { SuggestController } from '../../../../editor/contrib/suggest/browser/suggestController.js'; import { SnippetController2 } from '../../../../editor/contrib/snippet/browser/snippetController2.js'; import { CopyPasteController } from '../../../../editor/contrib/dropOrPasteInto/browser/copyPasteController.js'; -import { LinkDetector } from '../../../../editor/contrib/links/browser/links.js'; import { PlaceholderTextContribution } from '../../../../editor/contrib/placeholderText/browser/placeholderTextContribution.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { ContextKeyExpr, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; @@ -109,9 +107,8 @@ import { IChatStatusItemService } from '../../../../workbench/contrib/chat/brows import { handleTerminalCommandPaste, isTerminalCommandInput } from '../../../../workbench/contrib/chat/browser/chatTerminalCommandPaste.js'; import { compactCodiconsIn } from '../../../../workbench/contrib/chat/browser/chatIcons.js'; import { IChatPasteTargetService } from '../../../../workbench/contrib/chat/browser/chat.js'; -import { ChatDynamicVariableModel } from '../../../../workbench/contrib/chat/browser/attachments/chatDynamicVariables.js'; +import { getGitHubIssueOrPullRequestAttachments } from '../../../../workbench/contrib/chat/browser/widget/input/editor/chatPasteProviders.js'; import { NewChatInputPasteTarget } from './newChatInputPasteTarget.js'; -import { registerChatInputReferenceDecorationType } from '../../../../workbench/contrib/chat/browser/widget/input/editor/chatInputReferenceDecorations.js'; import { getChatSessionType } from '../../../../workbench/contrib/chat/common/model/chatUri.js'; import { ChatSpeechToTextState, DictationSettingId, IChatSpeechToTextService } from '../../../../workbench/contrib/chat/browser/speechToText/chatSpeechToTextService.js'; import { setupDictationMicGlow } from '../../../../workbench/contrib/chat/browser/speechToText/dictationMicGlow.js'; @@ -134,6 +131,7 @@ import { animatePromptTyping, IPromptTypingAnimation } from './promptTypingAnima import { PromptTemplatePlaceholderController } from './promptTemplatePlaceholder.js'; import { INewSessionComposer, INewSessionPromptOptionsController, NEW_SESSION_PROMPT_TYPING_DURATION_MS, NewSessionPromptOptionsState, NewSessionWorkspacePreselectionSource } from './newSessionComposerService.js'; import { NewSessionPromptOptionsWidget } from './newSessionPromptOptions.js'; +import { isInputGitHubContext, toInputGitHubContextMetadata } from '../common/newChatContextIds.js'; const OPEN_OTEL_SETTINGS_COMMAND = 'github.copilot.chat.otel.openSettings'; @@ -213,13 +211,16 @@ KeybindingsRegistry.registerKeybindingRule({ interface IDraftState { inputText: string; attachments: readonly IChatRequestVariableEntry[]; - contrib?: Record; } export function hasSendableNewChatContent(query: string, attachments: readonly IChatRequestVariableEntry[], hasAdditionalSendContent = false): boolean { return !!query.trim() || attachments.some(isExplicitFileOrImageVariableEntry) || hasAdditionalSendContent; } +function getInputGitHubContextAttachments(input: string): readonly IChatRequestVariableEntry[] { + return getGitHubIssueOrPullRequestAttachments(input, toInputGitHubContextMetadata()); +} + class NewChatInputStatusActionViewItem extends MenuEntryActionViewItem { private readonly hoverContentDisposables = this._register(new MutableDisposable()); private _container: HTMLElement | undefined; @@ -448,7 +449,6 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation // Input private _editor!: CodeEditorWidget; private _editorContainer!: HTMLElement; - private _dynamicVariableModel!: ChatDynamicVariableModel; private _sessionControlsContainer: HTMLElement | undefined; private readonly _promptTemplatePlaceholder = this._register(new MutableDisposable()); private readonly _promptOptionsWidget = this._register(new MutableDisposable()); @@ -456,6 +456,7 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation private _promptOptionsState: NewSessionPromptOptionsState | undefined; private _promptOptionsController: INewSessionPromptOptionsController | undefined; private _promptOptionsDismissed = false; + private _promptOptionsSelected = false; // Send button private _sendButton: Button | undefined; @@ -524,7 +525,6 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation @IModelService private readonly modelService: IModelService, @ITextModelService private readonly textModelService: ITextModelService, @IChatPasteTargetService private readonly chatPasteTargetService: IChatPasteTargetService, - @ICodeEditorService private readonly codeEditorService: ICodeEditorService, @IConfigurationService private readonly configurationService: IConfigurationService, @IContextKeyService private readonly contextKeyService: IContextKeyService, @ILogService private readonly logService: ILogService, @@ -551,7 +551,6 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation @ILanguageModelsService private readonly languageModelsService: ILanguageModelsService, ) { super(); - this._register(registerChatInputReferenceDecorationType(this.codeEditorService)); this._modelSelection = this._register(this.instantiationService.createInstance(SessionModelSelection, this.options.session)); this._canSendRequest = derived(this, reader => { if (this.options.canSubmitWithoutSession?.read(reader)) { @@ -679,6 +678,9 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation this._promptOptionsWidget.value = this.instantiationService.createInstance(NewSessionPromptOptionsWidget, chatInputContainer, { selectOption: async (option, expectedInput, animate) => { + // Mark the selection as it begins: the widget only reports it once the prompt + // animation completes, and a streamed update in between would clear it. + this._promptOptionsSelected = true; this.focus(); const inserted = animate ? await this.animatePrompt(option.prompt, NEW_SESSION_PROMPT_TYPING_DURATION_MS, option.placeholder, CancellationToken.None, expectedInput) @@ -896,6 +898,7 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation const editorOptions: IEditorConstructionOptions = { ...getSimpleEditorOptions(this.configurationService), readOnly: false, + // Match the workbench chat input so the post-paste selector is offered. pasteAs: EditorOptions.pasteAs.defaultValue, ariaLabel: this._getAriaLabel(), placeholder: this.options.placeholder ?? getRandomChatInputPlaceholder(), @@ -932,7 +935,6 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation SnippetController2.ID, PlaceholderTextContribution.ID, CopyPasteController.ID, - LinkDetector.ID, ]), }; @@ -1068,26 +1070,17 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation AgentHostInputCompletionHandler, this._editor, this._contextAttachments, )); - const contextAttachments = this._contextAttachments; - this._dynamicVariableModel = this._register(this._scopedInstantiationService.createInstance(ChatDynamicVariableModel, { - inputEditor: this._editor, - get attachments() { return contextAttachments.attachments; }, - onDidChangeActiveInputEditor: Event.None, - onDidChangeAttachments: contextAttachments.onDidChangeContext, - refreshParsedInput: () => { }, - })); - this._register(this._dynamicVariableModel.onDidChangeReferences(() => this._updateDraftState())); this._register(this.chatPasteTargetService.registerTarget(textModel.uri, new NewChatInputPasteTarget( this._editor, this._contextAttachments, this._agentHostInputCompletionHandler, - this._dynamicVariableModel, () => this._getTerminalCommandPrefix(), () => this.options.session.get()?.resource, textModel.uri, ))); this._register(this._editor.onDidChangeModelContent(() => { + this._syncInputGitHubContext(); this._updateDraftState(); this._updateSendButtonState(); this._updateEditorFontFamily(); @@ -1480,14 +1473,9 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation } private _updateDraftState(): void { - const contrib: Record = {}; - if (this._dynamicVariableModel?.variables.length) { - this._dynamicVariableModel.getInputState(contrib); - } this._draftState = { inputText: this._editor?.getModel()?.getValue() ?? '', attachments: [...this._contextAttachments.attachments], - ...(Object.keys(contrib).length ? { contrib } : {}), }; } @@ -1499,13 +1487,32 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation this.saveState(); } + private _syncInputGitHubContext(): void { + const inputAttachments = getInputGitHubContextAttachments(this._editor?.getValue() ?? ''); + const inputAttachmentIds = new Set(inputAttachments.map(attachment => attachment.id)); + const attachments = this._contextAttachments.attachments.filter(attachment => + !isInputGitHubContext(attachment) || inputAttachmentIds.has(attachment.id) + ); + const attachmentIds = new Set(attachments.map(attachment => attachment.id)); + for (const attachment of inputAttachments) { + if (!attachmentIds.has(attachment.id)) { + attachments.push(attachment); + attachmentIds.add(attachment.id); + } + } + if (attachments.length !== this._contextAttachments.attachments.length + || attachments.some((attachment, index) => attachment !== this._contextAttachments.attachments[index])) { + this._contextAttachments.setAttachments(attachments); + } + } + private _toHistoryEntry(draft: IDraftState): IChatModelInputState { return { ...draft, mode: { id: ChatModeKind.Agent, kind: ChatModeKind.Agent }, selectedModel: undefined, selections: [], - contrib: draft.contrib ?? {}, + contrib: {}, }; } @@ -1515,7 +1522,6 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation if (entry) { this._editor?.getModel()?.setValue(inputText); this._contextAttachments.setAttachments(entry.attachments); - this._dynamicVariableModel.setInputState(entry.contrib); } aria.status(inputText); if (previous) { @@ -1538,11 +1544,10 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation private async _send(background = false): Promise { const rawQuery = this._editor.getModel()?.getValue() ?? ''; - const displayQuery = rawQuery.trim(); + const query = rawQuery.trim(); const queryOffset = rawQuery.length - rawQuery.trimStart().length; - const query = this._dynamicVariableModel?.getPromptText(displayQuery, queryOffset) ?? displayQuery; const hasAdditionalSendContent = this.options.hasAdditionalSendContent?.get() ?? false; - if (!hasSendableNewChatContent(displayQuery, this._contextAttachments.attachments, hasAdditionalSendContent) || this._sending) { + if (!hasSendableNewChatContent(query, this._contextAttachments.attachments, hasAdditionalSendContent) || this._sending) { return false; } @@ -1568,7 +1573,7 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation return true; } - const attachments = this._agentHostInputCompletionHandler?.getAttachmentsForSend(displayQuery, queryOffset) ?? [...this._contextAttachments.attachments]; + const attachments = this._agentHostInputCompletionHandler?.getAttachmentsForSend(query, queryOffset) ?? [...this._contextAttachments.attachments]; const attachedContext = attachments.length > 0 ? attachments : undefined; @@ -1638,7 +1643,7 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation if (draft.attachments?.length) { this._contextAttachments.setAttachments(draft.attachments.map(IChatRequestVariableEntry.fromExport)); } - this._dynamicVariableModel.setInputState(draft.contrib ?? {}); + this._syncInputGitHubContext(); } this._updateSendButtonState(); } @@ -1761,6 +1766,7 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation this._cancelPromptOptionsRefresh(false); this._promptOptionsController = controller; this._promptOptionsDismissed = false; + this._promptOptionsSelected = false; } preparePromptOptionsRefresh(): boolean { @@ -1768,6 +1774,7 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation return false; } this._cancelPromptOptionsRefresh(); + this._promptOptionsSelected = false; this.showPromptOptions({ kind: 'loading' }); return true; } @@ -1808,9 +1815,17 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation } const cts = new CancellationTokenSource(token); this._promptOptionsRefresh.value = cts; + // Options resolved after a first paint must not replace ones the user is already working with: + // re-rendering clears the selection and moves focus out of the options. + const canRerender = () => !this._promptOptionsSelected && !this._promptOptionsWidget.value?.hasFocusedOption(); let state: NewSessionPromptOptionsState; try { - state = await controller.resolve(cts.token); + state = await controller.resolve(cts.token, progressState => { + if (this._promptOptionsRefresh.value !== cts || cts.token.isCancellationRequested || !canRerender()) { + return false; + } + return this.showPromptOptions(progressState); + }); } catch (error) { if (this._promptOptionsRefresh.value === cts) { this._promptOptionsRefresh.clear(); @@ -1830,6 +1845,9 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation return false; } this._promptOptionsRefresh.clear(); + if (!canRerender()) { + return true; + } return this.showPromptOptions(state); } diff --git a/src/vs/sessions/contrib/chat/browser/newChatInputPasteTarget.ts b/src/vs/sessions/contrib/chat/browser/newChatInputPasteTarget.ts index 1b46c3a5806979..136486deca0f94 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatInputPasteTarget.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatInputPasteTarget.ts @@ -3,11 +3,10 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { URI } from '../../../../base/common/uri.js'; import { ICodeEditor } from '../../../../editor/browser/editorBrowser.js'; +import { URI } from '../../../../base/common/uri.js'; import { IRange, Range } from '../../../../editor/common/core/range.js'; import { OffsetRange } from '../../../../editor/common/core/ranges/offsetRange.js'; -import { ChatDynamicVariableModel } from '../../../../workbench/contrib/chat/browser/attachments/chatDynamicVariables.js'; import { IChatPasteTarget } from '../../../../workbench/contrib/chat/browser/chat.js'; import { isTerminalCommandPaste } from '../../../../workbench/contrib/chat/browser/chatTerminalCommandPaste.js'; import { IChatRequestVariableEntry } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; @@ -15,22 +14,24 @@ import { IDynamicVariable } from '../../../../workbench/contrib/chat/common/atta import { AgentHostInputCompletionHandler } from './agentHostInputCompletions.js'; import { INewChatAttachments } from './newChatContextAttachments.js'; -type PasteCompletionHandler = Pick; - -/** Adapts the Agents composer to the shared Chat paste providers. */ +/** + * Exposes the Agents window composer to the shared chat paste pipeline. Inline + * references are tracked by the completion handler, which owns their decorations + * and outbound ranges. + */ export class NewChatInputPasteTarget implements IChatPasteTarget { constructor( private readonly editor: ICodeEditor, private readonly contextAttachments: INewChatAttachments, - private readonly completionHandler: PasteCompletionHandler, - private readonly dynamicVariableModel: ChatDynamicVariableModel, + private readonly completionHandler: AgentHostInputCompletionHandler, private readonly getTerminalCommandPrefix: () => string | undefined, private readonly getSessionResource: () => URI | undefined, private readonly inputUri: URI, ) { } get sessionResource(): URI { + // A composer draft has no session yet, so its input scopes the cleanup. return this.getSessionResource() ?? this.inputUri; } @@ -39,7 +40,7 @@ export class NewChatInputPasteTarget implements IChatPasteTarget { } get inlineReferences(): readonly IDynamicVariable[] { - return this.dynamicVariableModel.variables; + return []; } addAttachments(entries: readonly IChatRequestVariableEntry[]): void { @@ -58,12 +59,9 @@ export class NewChatInputPasteTarget implements IChatPasteTarget { this.completionHandler.acceptCompletion({ ...entry, range: offsetRange }, text, offsetRange); } - addInlineReference(reference: IDynamicVariable, expectedText?: string, expectedRangeOffset?: number): void { - this.dynamicVariableModel.addReference(reference, expectedText, expectedRangeOffset); - } - - removeInlineReference(reference: IDynamicVariable): void { - this.dynamicVariableModel.removeReference(reference); + addInlineReference(_reference: IDynamicVariable): void { + // The composer has no dynamic-variable model; references that are not + // backed by an attachment are not surfaced here. } isTerminalCommandPaste(text: string, range: IRange): boolean { diff --git a/src/vs/sessions/contrib/chat/browser/newChatWidget.ts b/src/vs/sessions/contrib/chat/browser/newChatWidget.ts index 429fd585751acc..53d3d0b5800605 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatWidget.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatWidget.ts @@ -10,7 +10,7 @@ import { Action } from '../../../../base/common/actions.js'; import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; import { Event } from '../../../../base/common/event.js'; import { Disposable, DisposableMap, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; -import { constObservable, derived, derivedObservableWithCache, autorun, IObservable, observableFromEvent, observableSignalFromEvent, observableValue } from '../../../../base/common/observable.js'; +import { constObservable, derived, derivedObservableWithCache, autorun, IObservable, observableFromEvent, observableSignalFromEvent } from '../../../../base/common/observable.js'; import { isWeb } from '../../../../base/common/platform.js'; import { basename } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; @@ -55,6 +55,13 @@ import { TOTAL_SESSIONS_KEY } from '../../sessions/browser/sessionsLifecycleTrac import { INewSessionComposerService, NewSessionWorkspacePreselectionSource } from './newSessionComposerService.js'; import { Menus } from '../../../browser/menus.js'; import { getAdditionalFolderContextId, getAdditionalRepositoryContextId } from '../common/newChatContextIds.js'; +import { UNIFIED_WORKSPACE_PICKER_SETTING } from '../common/constants.js'; +import { ICustomizationMigrationAvailabilityService } from '../../../../workbench/contrib/chat/browser/aiCustomization/customizationMigrationAvailabilityService.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { IHoverService } from '../../../../platform/hover/browser/hover.js'; +import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; +import { OPEN_CUSTOMIZATIONS_COMMAND_ID } from '../../../common/customizations.js'; +import { onUnexpectedError } from '../../../../base/common/errors.js'; // #region --- New Chat Widget --- @@ -89,8 +96,8 @@ export class NewChatWidget extends Disposable { /** Whether the active draft is a workspace-less quick chat. */ private readonly _isQuickChatComposer: IObservable; private readonly _isWorkspacePickerQuickChat: IObservable; - private readonly _workspacePickerQuickChatSessionId = observableValue(this, undefined); private readonly _useConsolidatedRemoteWorkspaces: IObservable; + private readonly _useCustomizationEntryPoints: IObservable; /** Draft comments shared by every uncreated new-session composer. */ private readonly _feedbackItems: IObservable; @@ -128,6 +135,9 @@ export class NewChatWidget extends Disposable { @IDefaultAccountService private readonly defaultAccountService: IDefaultAccountService, @IStorageService private readonly storageService: IStorageService, @INewSessionComposerService newSessionComposerService: INewSessionComposerService, + @ICustomizationMigrationAvailabilityService private readonly customizationMigrationAvailabilityService: ICustomizationMigrationAvailabilityService, + @ICommandService private readonly commandService: ICommandService, + @IHoverService private readonly hoverService: IHoverService, ) { super(); this._workspacePickerVisibleKey = SessionWorkspacePickerVisibleContext.bindTo(contextKeyService); @@ -150,15 +160,20 @@ export class NewChatWidget extends Disposable { const session = this._session.read(reader); return session?.isQuickChat?.read(reader) ?? false; }); - this._isWorkspacePickerQuickChat = derived(this, reader => { - const session = this._session.read(reader); - return !!session?.isQuickChat?.read(reader) && session.sessionId === this._workspacePickerQuickChatSessionId.read(reader); - }); this._useConsolidatedRemoteWorkspaces = observableFromEvent( this, - Event.filter(this.configurationService.onDidChangeConfiguration, event => event.affectsConfiguration(ChatConfiguration.ConsolidatedRemoteWorkspaces)), - () => this.configurationService.getValue(ChatConfiguration.ConsolidatedRemoteWorkspaces), + Event.filter(this.configurationService.onDidChangeConfiguration, event => event.affectsConfiguration(UNIFIED_WORKSPACE_PICKER_SETTING)), + () => this.configurationService.getValue(UNIFIED_WORKSPACE_PICKER_SETTING), ); + this._useCustomizationEntryPoints = observableFromEvent( + this, + Event.filter(this.configurationService.onDidChangeConfiguration, event => event.affectsConfiguration(ChatConfiguration.CustomizationEntryPoints)), + () => this.configurationService.getValue(ChatConfiguration.CustomizationEntryPoints), + ); + this._isWorkspacePickerQuickChat = derived(this, reader => { + const session = this._session.read(reader); + return this._useConsolidatedRemoteWorkspaces.read(reader) && !!session?.isQuickChat?.read(reader); + }); // On web (vscode.dev / insiders.vscode.dev), use {@link WebWorkspacePicker} // which scopes recents to the active host and renders as a bottom @@ -321,6 +336,7 @@ export class NewChatWidget extends Disposable { await this._onWorkspaceSelected(this._workspacePicker.selectedFolderUri); this._newChatInput.focus(); })); + this._register(this.sessionsManagementService.onDidChangeSessionTypes(() => this._restoreNoWorkspaceDraft())); this._register(this.configurationService.onDidChangeConfiguration(e => { if (!e.affectsConfiguration('chat.tips.enabled')) { @@ -434,6 +450,7 @@ export class NewChatWidget extends Disposable { if (!isWeb) { this._quickChatHeaderPickerHost = dom.append(chatWidgetContent, dom.$('.new-session-quick-chat-header.sessions-workspace-category-picker')); + this._register(this._renderCustomizeTrigger(this._quickChatHeaderPickerHost)); } this._renderFeedbackBanner(chatWidgetContent); @@ -500,11 +517,8 @@ export class NewChatWidget extends Disposable { // from a new-session draft when navigating back from another session). this._seedWorkspaceDraft(); - // Re-seed the workspace draft when the composer swaps out of quick-chat - // mode (e.g. Cmd+N discards a quick chat, leaving the reused composer - // session-less): without an active session the session-type picker has no - // folder types and hides itself, so restore the last folder to match a - // freshly-opened new-session composer. + // Re-seed the selected target when the composer swaps out of quick-chat + // mode without another active draft. if (!isWeb) { let wasQuickChat = this._isQuickChatComposer.get(); this._register(autorun(reader => { @@ -539,11 +553,24 @@ export class NewChatWidget extends Disposable { */ private _seedWorkspaceDraft(): void { const restoredFolderUri = this._workspacePicker.selectedFolderUri; - if (!this._syncWorkspacePickerFromActiveSession() && restoredFolderUri) { + if (this._syncWorkspacePickerFromActiveSession()) { + return; + } + if (!this._restoreNoWorkspaceDraft() && restoredFolderUri) { void this._createNewSession(restoredFolderUri); } } + private _restoreNoWorkspaceDraft(): boolean { + if (this._session.get() || !this._workspacePicker.isNoWorkspaceSelected()) { + return false; + } + if (this.sessionsManagementService.isQuickChatTargetAvailable()) { + this.selectNoWorkspace(); + } + return true; + } + /** * If a new-session draft was restored by {@link openNewSession}, sync * the workspace picker to match the session's workspace. The picker may @@ -725,16 +752,15 @@ export class NewChatWidget extends Disposable { return this._isQuickChatComposer.get() ? undefined : this._workspacePicker.selectedFolderUri; } - private _selectNoWorkspace(): void { + selectNoWorkspace(): void { this._pendingPreferredUpgrade.clear(); this._newSessionCreation.clear(); - this._openQuickChat(undefined, true); + this._workspacePicker.selectNoWorkspace(); + this._openQuickChat(); } - private _openQuickChat(options?: ICreateNewSessionOptions, keepWorkspacePickerVisible = this._isWorkspacePickerQuickChat.get()): IActiveSession | undefined { - const session = this.sessionsService.openQuickChat(options); - this._workspacePickerQuickChatSessionId.set(keepWorkspacePickerVisible ? session?.sessionId : undefined, undefined); - return session; + private _openQuickChat(options?: ICreateNewSessionOptions): IActiveSession | undefined { + return this.sessionsService.openQuickChat(options); } private _getNoWorkspaceOption(): IWorkspacePickerNoWorkspaceOption | undefined { @@ -747,7 +773,7 @@ export class NewChatWidget extends Disposable { return { description: localize('newSessionWorkspacePicker.noWorkspaceDescription', "Start without a backing workspace"), isSelected: isWorkspacePickerQuickChat, - select: () => this._selectNoWorkspace(), + select: () => this.selectNoWorkspace(), }; } @@ -765,21 +791,71 @@ export class NewChatWidget extends Disposable { workspaceTrigger, ]); this._renderSessionTypePicker(row, false); + const customizeTrigger = this._renderCustomizeTrigger(row); this._workspacePickerRow = row; return toDisposable(() => { + customizeTrigger.dispose(); if (this._workspacePickerRow === row) { this._workspacePickerRow = undefined; } }); } + private _renderCustomizeTrigger(container: HTMLElement): IDisposable { + const store = new DisposableStore(); + const slot = dom.append(container, dom.$('.sessions-chat-picker-slot.sessions-workspace-category-picker-slot')); + slot.classList.add('sessions-customize-trigger-slot'); + const trigger = dom.append(slot, dom.$('a.action-label.sessions-customize-trigger')); + trigger.tabIndex = 0; + trigger.role = 'button'; + const icon = dom.append(trigger, renderIcon(Codicon.tools)); + icon.setAttribute('aria-hidden', 'true'); + const label = dom.append(trigger, dom.$('span.sessions-chat-dropdown-label')); + label.textContent = localize('newSessionCustomize', "Customize"); + const indicator = dom.append(trigger, dom.$('span.sessions-customize-migration-indicator')); + indicator.setAttribute('aria-hidden', 'true'); + + const run = () => this.commandService.executeCommand(OPEN_CUSTOMIZATIONS_COMMAND_ID).catch(onUnexpectedError); + store.add(dom.addDisposableListener(trigger, dom.EventType.CLICK, event => { + dom.EventHelper.stop(event, true); + run(); + })); + store.add(dom.addDisposableListener(trigger, dom.EventType.KEY_DOWN, event => { + if (event.key === 'Enter' || event.key === ' ') { + dom.EventHelper.stop(event, true); + run(); + } + })); + store.add(this.hoverService.setupDelayedHover(trigger, { + content: localize('newSessionCustomizeTooltip', "Open customization overview"), + })); + store.add(autorun(reader => { + const enabled = this._useCustomizationEntryPoints.read(reader); + slot.style.display = enabled ? '' : 'none'; + if (!enabled) { + trigger.classList.remove('has-migrations'); + return; + } + const hasMigrations = this.customizationMigrationAvailabilityService.candidateCount.read(reader) > 0; + trigger.classList.toggle('has-migrations', hasMigrations); + trigger.setAttribute('aria-label', hasMigrations + ? localize('newSessionCustomizeMigrationsAriaLabel', "Customize, migrations available") + : localize('newSessionCustomizeAriaLabel', "Customize")); + })); + store.add(toDisposable(() => slot.remove())); + return store; + } + private _renderSessionTypePicker(container: HTMLElement, isQuickChat: boolean): void { this._newChatInput.sessionTypePicker.render(container, { className: 'sessions-chat-session-type-picker sessions-workspace-category-picker-slot', }); const sessionTypePicker = container.lastElementChild; - if (!isQuickChat && sessionTypePicker && container.firstElementChild !== sessionTypePicker) { - container.insertBefore(sessionTypePicker, container.firstElementChild?.nextSibling ?? null); + if (isQuickChat && sessionTypePicker) { + container.prepend(sessionTypePicker); + } else if (sessionTypePicker) { + const workspaceTrigger = container.firstElementChild; + workspaceTrigger?.after(sessionTypePicker); } } diff --git a/src/vs/sessions/contrib/chat/browser/newSessionComposerService.ts b/src/vs/sessions/contrib/chat/browser/newSessionComposerService.ts index 48bcdd3639a6f2..122d5e9c4d4d8f 100644 --- a/src/vs/sessions/contrib/chat/browser/newSessionComposerService.ts +++ b/src/vs/sessions/contrib/chat/browser/newSessionComposerService.ts @@ -36,8 +36,23 @@ export const enum NewSessionWorkspacePreselectionSource { Unknown = 'unknown', } +/** + * Reports an intermediate prompt-option state while {@link INewSessionPromptOptionsController.resolve} + * is still running, so options can be rendered before the controller has settled on its final set. + * + * Returns whether the state was rendered. Implementers may refuse a late update, for example while + * the user is already acting on the options on screen. + */ +export type NewSessionPromptOptionsProgress = (state: NewSessionPromptOptionsState) => boolean; + export interface INewSessionPromptOptionsController { - resolve(token: CancellationToken): Promise; + /** + * Resolves the final prompt options. Single-shot per refresh. + * + * @param progress Optional sink for intermediate states. Implementers may ignore it; callers that + * pass it must tolerate it never being invoked and must apply the returned state regardless. + */ + resolve(token: CancellationToken, progress?: NewSessionPromptOptionsProgress): Promise; onDidSelectOption(option: INewSessionPromptOption): void; onDidClose(): void; } diff --git a/src/vs/sessions/contrib/chat/browser/newSessionPromptOptions.ts b/src/vs/sessions/contrib/chat/browser/newSessionPromptOptions.ts index 1122701c7344e8..f9fbd8f69c58df 100644 --- a/src/vs/sessions/contrib/chat/browser/newSessionPromptOptions.ts +++ b/src/vs/sessions/contrib/chat/browser/newSessionPromptOptions.ts @@ -122,6 +122,11 @@ export class NewSessionPromptOptionsWidget extends Disposable { this._updateButtons(); } + /** Whether focus is inside the rendered options, where re-rendering would move focus to the body. */ + hasFocusedOption(): boolean { + return dom.isAncestorOfActiveElement(this._optionsContainer); + } + shouldClearInputForRefresh(): boolean { const selectedOption = this._buttons.find(candidate => candidate.option.id === this._selectedOptionId)?.option; return this._selecting || this._inputValue.length === 0 || matchesGeneratedPrompt(selectedOption, this._inputValue); diff --git a/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts b/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts index 0507b237ab0df9..a02bbd78b13ec7 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts @@ -17,7 +17,7 @@ import { IConfigurationService } from '../../../../platform/configuration/common import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import { ChatInputPills, StandardChatInputPillSources } from '../../../../workbench/contrib/chat/browser/chatInputPills.js'; -import { diffStatsEqual, EMPTY_DIFF_STATS, IDiffStats, observeTurnStatusPillsEnabled } from '../../../../workbench/contrib/chat/browser/widget/chatTurnPills.js'; +import { diffStatsEqual, EMPTY_DIFF_STATS, IDiffStats } from '../../../../workbench/contrib/chat/browser/widget/chatTurnPills.js'; import { SessionArtifacts, sessionArtifactLocation } from './sessionArtifacts.js'; import { SessionCustomizations } from './sessionCustomizations.js'; import { localize } from '../../../../nls.js'; @@ -259,8 +259,8 @@ export class SessionChatInputToolbar extends Disposable { return computeSessionInputPillStats(this._session.read(reader), changesStatsCache, reader); }); - const turnStatusPillsEnabled = observeTurnStatusPillsEnabled(this._configurationService); - this._browsers = this._register(instantiationService.createInstance(SessionBrowsersControl, this._session, this._chat, turnStatusPillsEnabled, derived(reader => visibility.isVisible(SessionChatPillKind.Browsers, reader)))); + const pillsEnabled = constObservable(true); + this._browsers = this._register(instantiationService.createInstance(SessionBrowsersControl, this._session, this._chat, pillsEnabled, derived(reader => visibility.isVisible(SessionChatPillKind.Browsers, reader)))); // The browsers pill already offers the pages it lists, so the artifacts and // references pills leave those websites out. @@ -273,8 +273,7 @@ export class SessionChatInputToolbar extends Disposable { const sessionCustomizations = this._register(instantiationService.createInstance(SessionCustomizations, this._chat, this._session)); this._customizationSections = sessionCustomizations.sections; - const pillsEnabled = derived(reader => this._debugData.read(reader) !== undefined || turnStatusPillsEnabled.read(reader)); - this._backgroundActivities = this._register(instantiationService.createInstance(SessionBackgroundActivitiesControl, this._session, this._chat, turnStatusPillsEnabled, constObservable(true))); + this._backgroundActivities = this._register(instantiationService.createInstance(SessionBackgroundActivitiesControl, this._session, this._chat, pillsEnabled, constObservable(true))); const gitHubInfo = derived(this, reader => { const session = this._session.read(reader); const workspace = session?.workspace.read(reader); @@ -292,7 +291,7 @@ export class SessionChatInputToolbar extends Disposable { const reference = reader.store.add(gitHubService.createIssueModelReference(ref.owner, ref.repo, ref.number)); return { ref, issue: reference.object.issue.read(reader) }; })); - const issuesActive = derived(this, reader => pillsEnabled.read(reader) && visibility.isVisible(SessionChatPillKind.Issues, reader)); + const issuesActive = derived(this, reader => visibility.isVisible(SessionChatPillKind.Issues, reader)); this._register(autorun(reader => { if (!issuesActive.read(reader)) { return; diff --git a/src/vs/sessions/contrib/chat/browser/sessionTurnChanges.ts b/src/vs/sessions/contrib/chat/browser/sessionTurnChanges.ts index 54c008c97a6173..78b05ed50f8583 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionTurnChanges.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionTurnChanges.ts @@ -7,7 +7,7 @@ import { constObservable, derived, derivedObservableWithCache, IObservable, IRea import { extUriBiasedIgnorePathCase, isEqual } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; import { localize } from '../../../../nls.js'; -import { AbstractChatResponseFileChangesService, IChatResponseFileChangesOpenContext, IChatResponseFileChangesStats } from '../../../../workbench/contrib/chat/browser/chatResponseFileChangesService.js'; +import { AbstractChatResponseFileChangesService, AUTHORITATIVE_EMPTY_CHAT_RESPONSE_FILE_CHANGES, IChatResponseFileChangesOpenContext, IChatResponseFileChangesStats } from '../../../../workbench/contrib/chat/browser/chatResponseFileChangesService.js'; import { IEditSessionEntryDiff } from '../../../../workbench/contrib/chat/common/editing/chatEditingService.js'; import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js'; import { IAgentWorkbenchLayoutService } from '../../../browser/workbench.js'; @@ -54,17 +54,19 @@ export class SessionsChatResponseFileChangesService extends AbstractChatResponse const requestChanges = this.getChangesForRequest(sessionResource, requestId); return derivedObservableWithCache(this, (reader, lastValue) => { + const requestDiffs = requestChanges?.read(reader) ?? []; const readRequestStats = (): IChatResponseFileChangesStats => { - const changes = requestChanges?.read(reader) ?? []; let insertions = 0, deletions = 0; - for (const change of changes) { + for (const change of requestDiffs) { insertions += change.added; deletions += change.removed; } - return { files: changes.length, insertions, deletions }; + return { files: requestDiffs.length, insertions, deletions }; }; let stats: IChatResponseFileChangesStats; - if (!isEqual(this._changesViewService.activeSessionResourceObs.read(reader), owner.session.resource) + if (requestDiffs === AUTHORITATIVE_EMPTY_CHAT_RESPONSE_FILE_CHANGES) { + stats = readRequestStats(); + } else if (!isEqual(this._changesViewService.activeSessionResourceObs.read(reader), owner.session.resource) || !this._isMostRecentChat(owner.session, owner.chat, reader)) { stats = readRequestStats(); } else { diff --git a/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts b/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts index 4c29f38ab44a4c..92d9eba42bb0e6 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts @@ -7,7 +7,7 @@ import * as dom from '../../../../base/browser/dom.js'; import * as touch from '../../../../base/browser/touch.js'; import { status } from '../../../../base/browser/ui/aria/aria.js'; import { CountBadge } from '../../../../base/browser/ui/countBadge/countBadge.js'; -import { IAction, toAction } from '../../../../base/common/actions.js'; +import { IAction, SubmenuAction, toAction } from '../../../../base/common/actions.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { onUnexpectedError } from '../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../base/common/event.js'; @@ -47,15 +47,13 @@ import { markOnboardingTarget } from '../../../../workbench/contrib/onboarding/b import { NewSessionWorkspacePreselectionSource } from './newSessionComposerService.js'; import { type IResolvedFolderWorkspace, SessionWorkspaceFallback } from './sessionWorkspaceFallback.js'; import { IChatRequestVariableEntry } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; -import { ChatConfiguration } from '../../../../workbench/contrib/chat/common/constants.js'; import { ADDITIONAL_FOLDER_CONTEXT_ID_PREFIX, ADDITIONAL_REPOSITORY_CONTEXT_ID_PREFIX, getAdditionalFolderContextId, getAdditionalRepositoryContextId } from '../common/newChatContextIds.js'; +import { UNIFIED_WORKSPACE_PICKER_SETTING } from '../common/constants.js'; export type { IResolvedFolderWorkspace } from './sessionWorkspaceFallback.js'; const FILTER_THRESHOLD = 10; -export const AGENT_SESSIONS_CONSOLIDATED_REMOTE_WORKSPACES_SETTING = ChatConfiguration.ConsolidatedRemoteWorkspaces; - /** * Fixed picker width when the categorical tab bar is shown. Keeps the tab * row and the list aligned and prevents horizontal jitter when switching @@ -666,11 +664,11 @@ export class WorkspacePicker extends Disposable { } /** - * Subclasses may opt out of the categorical tab bar (e.g. when scoped to - * a single host). + * The consolidated workspace picker uses one unified list. Category-specific + * entry points still scope the list through `_setDirectPickerFilter`. */ protected _showTabs(): boolean { - return true; + return !this._useConsolidatedRemoteWorkspaces(); } protected _getAvailableTabs(): ITabDescriptor[] { @@ -730,7 +728,7 @@ export class WorkspacePicker extends Disposable { const showFilter = isConsolidatedWorkspacePicker || items.filter(i => i.kind === ActionListItemKind.Action).length > FILTER_THRESHOLD; return showFilter - ? { className: 'sessions-new-chat-picker-list', showFilter: true, focusFilterOnOpen: isConsolidatedWorkspacePicker, filterPlaceholder: localize('workspacePicker.filter', "Search Workspaces..."), reserveSubmenuSpace: false, inlineDescription: true, showGroupTitleOnFirstItem: true, minWidth: pickerWidth, maxWidth: pickerWidth, hideDefaultKeybindingTooltip: true } + ? { className: 'sessions-new-chat-picker-list', showFilter: true, focusFilterOnOpen: isConsolidatedWorkspacePicker, filterPlaceholder: isConsolidatedWorkspacePicker ? localize('workspacePicker.filter', "Search") : undefined, reserveSubmenuSpace: false, inlineDescription: true, showGroupTitleOnFirstItem: true, minWidth: pickerWidth, maxWidth: pickerWidth, hideDefaultKeybindingTooltip: true } : { className: 'sessions-new-chat-picker-list', reserveSubmenuSpace: false, inlineDescription: true, showGroupTitleOnFirstItem: true, minWidth: pickerWidth, maxWidth: pickerWidth, hideDefaultKeybindingTooltip: true }; } @@ -852,13 +850,13 @@ export class WorkspacePicker extends Disposable { if (item.attachAsContext) { if (action?.group === SESSION_WORKSPACE_GROUP_GITHUB && action.attachesContext !== true - && this._attachAdditionalRepository(selection.workspace, selection.providerId)) { - return true; + && action.supportsContextAttachment === true) { + return this._attachAdditionalRepository(selection.workspace, selection.providerId); } if (action === this._localBrowseAction) { - this._attachAdditionalFolder(folderUri, selection.providerId); - return true; + return this._attachAdditionalFolder(folderUri, selection.providerId); } + return false; } const relatedWorkspace = this._findRelatedLocalWorkspace(selection.workspace); this._selectFolder( @@ -1040,6 +1038,22 @@ export class WorkspacePicker extends Disposable { this._onDidSelectWorkspace.fire(undefined); } + isNoWorkspaceSelected(): boolean { + return !!this.options.getNoWorkspaceOption && this.recentWorkspacesService.isNoWorkspaceChecked(); + } + + selectNoWorkspace(): void { + this._selectionGeneration++; + this._sessionRestoreGeneration++; + this._hidePicker(); + this._userHasPicked = true; + this._connectionStatusWatch.clear(); + this._applySelection(undefined); + this.recentWorkspacesService.checkNoWorkspace(); + this._updateTriggerLabel(); + this._onDidChangeSelection.fire(); + } + /** * Clears the selection if it matches the given URI. */ @@ -1238,7 +1252,7 @@ export class WorkspacePicker extends Disposable { } protected _useConsolidatedRemoteWorkspaces(): boolean { - return this.configurationService.getValue(AGENT_SESSIONS_CONSOLIDATED_REMOTE_WORKSPACES_SETTING); + return this.configurationService.getValue(UNIFIED_WORKSPACE_PICKER_SETTING); } private _getTabGroup(group: string | undefined): string | undefined { @@ -1314,21 +1328,40 @@ export class WorkspacePicker extends Disposable { const availableTabs = this._getAvailableTabs(); const activeGroup = this._activeTab ?? (availableTabs.length === 1 ? availableTabs[0].id : undefined); let workspaceGroupAction = this.options.getWorkspaceGroupAction?.(activeGroup); + let workspaceGroupActionGroup = activeGroup; + if (!workspaceGroupAction && activeGroup === undefined) { + workspaceGroupAction = this.options.getWorkspaceGroupAction?.(SESSION_WORKSPACE_GROUP_GITHUB); + workspaceGroupActionGroup = workspaceGroupAction ? SESSION_WORKSPACE_GROUP_GITHUB : undefined; + } if (!workspaceGroupAction && activeGroup === SESSION_WORKSPACE_GROUP_REMOTE && this._useConsolidatedRemoteWorkspaces()) { const gitHubGroupAction = this.options.getWorkspaceGroupAction?.(SESSION_WORKSPACE_GROUP_GITHUB); workspaceGroupAction = gitHubGroupAction ? { ...gitHubGroupAction, hideWorkspaceItems: false } : undefined; + workspaceGroupActionGroup = workspaceGroupAction ? SESSION_WORKSPACE_GROUP_GITHUB : undefined; } const tabFilter = this._isTabFiltered() ? (w: IResolvedFolderWorkspace) => this._isGroupInActiveTab(w.workspace.group) : undefined; + const useRemoteSubmenu = this._useConsolidatedRemoteWorkspaces() && this._directPickerGroup === undefined; + const remotePickerItem: { + folderUri?: URI; + providerId?: string; + browseAction?: ISessionWorkspaceBrowseAction; + run?: () => void; + } = {}; + const remoteSubmenuActions: IAction[] = []; + const setRemotePickerItem = (item: IWorkspacePickerItem): void => { + remotePickerItem.folderUri = item.folderUri; + remotePickerItem.providerId = item.providerId; + remotePickerItem.browseAction = item.browseAction; + remotePickerItem.run = item.run; + }; // Own recents first, then VS Code recents (merged and deduplicated by the service) - const recentWorkspaces = workspaceGroupAction?.hideWorkspaceItems + const recentWorkspaces = this._directPickerAttachesContext === true ? [] - : this._directPickerAttachesContext === true - ? [] - : this._getRecentWorkspaces() - .filter(w => providerIds.has(w.providerId)) - .filter(w => !tabFilter || tabFilter(w)); + : this._getRecentWorkspaces() + .filter(w => providerIds.has(w.providerId)) + .filter(w => !tabFilter || tabFilter(w)) + .filter(w => !workspaceGroupAction?.hideWorkspaceItems || w.workspace.group !== workspaceGroupActionGroup); // Build flat list in recency order (no source grouping) for (const { workspace, providerId } of recentWorkspaces) { @@ -1341,6 +1374,22 @@ export class WorkspacePicker extends Disposable { || (repositoryId !== undefined && repositoryId === this._getCurrentRepositoryId()); const attached = this._additionalFolderSelections.has(this.uriIdentityService.extUri.getComparisonKey(folderUri)) || (repositoryId !== undefined && this._additionalRepositorySelections.has(repositoryId)); + if (useRemoteSubmenu && workspace.group === SESSION_WORKSPACE_GROUP_REMOTE) { + const unavailable = this._isProviderUnavailable(providerId); + const submenuAction = toAction({ + id: `workspacePicker.remote.workspace.${providerId}.${remoteSubmenuActions.length}`, + label: workspace.label, + tooltip: typeof workspace.description === 'string' ? workspace.description : undefined, + enabled: !unavailable, + run: () => setRemotePickerItem({ folderUri, providerId }), + }); + Object.assign(submenuAction, { + icon: workspace.icon, + onRemove: () => this._removeRecentWorkspace(folderUri), + }); + remoteSubmenuActions.push(submenuAction); + continue; + } items.push({ kind: ActionListItemKind.Action, label: workspace.label, @@ -1353,11 +1402,16 @@ export class WorkspacePicker extends Disposable { } // Browse actions from all providers (filtered to the active tab) - const allBrowseActions = workspaceGroupAction?.hideWorkspaceItems ? [] : this._getAllBrowseActions(); - // Remote providers with connection status — shown as dynamic rows - // in the Manage submenu on the Remote tab. + const remoteAgentHostsEnabled = this.configurationService.getValue(RemoteAgentHostsEnabledSettingId); + const allBrowseActions = this._getAllBrowseActions() + .map((action, index) => ({ action, index })) + .filter(({ action }) => remoteAgentHostsEnabled || action.group !== SESSION_WORKSPACE_GROUP_REMOTE) + .filter(({ action }) => !workspaceGroupAction?.hideWorkspaceItems || action.group !== workspaceGroupActionGroup); + // Remote providers with connection status — shown as dynamic rows in + // the Remote tab or the unified picker. const remoteProviders = allProviders.filter(isAgentHostProvider).filter(p => p.connectionStatus !== undefined); - const includeRemoteProviders = this._activeTab === SESSION_WORKSPACE_GROUP_REMOTE; + const includeRemoteProviders = remoteAgentHostsEnabled + && (activeGroup === undefined || activeGroup === SESSION_WORKSPACE_GROUP_REMOTE); if (items.length > 0 && (workspaceGroupAction || allBrowseActions.length > 0)) { items.push({ kind: ActionListItemKind.Separator, label: '' }); } @@ -1375,51 +1429,70 @@ export class WorkspacePicker extends Disposable { // Render each browse action individually. Within a tab, actions are // already constrained to a single category, so cross-provider // merging is no longer meaningful. - allBrowseActions.forEach((action, index) => { - const provider = allProviders.find(p => p.id === action.providerId); - const agentHostProvider = provider && isAgentHostProvider(provider) ? provider : undefined; - const connectionStatus = agentHostProvider?.connectionStatus?.get(); - // `incompatible` always disables the action — the user can't fix - // a protocol mismatch by clicking. Otherwise, if the provider - // supports connect-on-demand (e.g. WSL boots the distro on first - // browse), keep the action live even while disconnected. - const isIncompatible = RemoteAgentHostConnectionStatus.isIncompatible(connectionStatus); - const isUnavailable = isIncompatible - || (!!connectionStatus - && !RemoteAgentHostConnectionStatus.isConnected(connectionStatus) - && !agentHostProvider?.canConnectOnDemand); + let hasRepositoryAttachmentAction = false; + allBrowseActions.forEach(({ action, index }) => { + const actionIcon = action === this._localBrowseAction && this._useConsolidatedRemoteWorkspaces() + ? Codicon.folder + : action.icon; + const actionLabel = action === this._localBrowseAction + && this._useConsolidatedRemoteWorkspaces() + && this._directPickerAttachesContext !== true + ? localize('workspacePicker.openFolder', "Open Folder") + : this._useConsolidatedRemoteWorkspaces() + ? action.label.replace(/(?:\.\.\.|…)$/, '') + : action.label; + const isUnavailable = this._isProviderUnavailable(action.providerId); + if (useRemoteSubmenu && action.group === SESSION_WORKSPACE_GROUP_REMOTE) { + const submenuAction = toAction({ + id: `workspacePicker.remote.browse.${action.providerId}.${index}`, + label: actionLabel, + tooltip: action.description, + enabled: !isUnavailable, + run: () => setRemotePickerItem({ browseAction: action }), + }); + Object.assign(submenuAction, { icon: actionIcon }); + remoteSubmenuActions.push(submenuAction); + return; + } const isRepositoryAction = action.group === SESSION_WORKSPACE_GROUP_GITHUB && action.attachesContext !== true; items.push({ kind: ActionListItemKind.Action, - label: action.label, + label: actionLabel, description: action.description, - group: { title: '', icon: action.icon }, + group: { title: '', icon: actionIcon }, disabled: isUnavailable, item: { browseActionIndex: index }, }); const canAttachFolder = action === this._localBrowseAction - && this._selectedFolderUri - && this._getActivePickerGroup() === SESSION_WORKSPACE_GROUP_LOCAL; + && !!this._selectedFolderUri + && this._getActivePickerGroup() === SESSION_WORKSPACE_GROUP_LOCAL + && (!this._useConsolidatedRemoteWorkspaces() || this._directPickerAttachesContext === true); const canAttachRepository = isRepositoryAction - && this._selectedFolderUri - && this._isGroupInActiveTab(SESSION_WORKSPACE_GROUP_GITHUB); + && action.supportsContextAttachment === true + && !!this._selectedFolderUri + && this._isGroupInActiveTab(SESSION_WORKSPACE_GROUP_GITHUB) + && !hasRepositoryAttachmentAction; if (canAttachFolder || canAttachRepository) { + hasRepositoryAttachmentAction ||= canAttachRepository; items.push({ kind: ActionListItemKind.Action, label: canAttachFolder - ? localize('workspacePicker.attachFolder', "Attach Folder...") - : localize('workspacePicker.attachRepository', "Attach Repository..."), + ? this._useConsolidatedRemoteWorkspaces() + ? localize('workspacePicker.attachFolder', "Attach Folder") + : localize('workspacePicker.attachFolderWithEllipsis', "Attach Folder...") + : this._useConsolidatedRemoteWorkspaces() + ? localize('workspacePicker.attachRepository', "Attach Repository") + : localize('workspacePicker.attachRepositoryWithEllipsis', "Attach Repository..."), description: action.description, - group: { title: '', icon: action.icon }, + group: { title: '', icon: actionIcon }, disabled: isUnavailable, item: { browseActionIndex: index, attachAsContext: true }, }); } }); - // Inline "Manage" entries: dynamic remote provider rows (scoped to - // the Remote tab) + menu-contributed actions (filtered by the - // `sessionWorkspacePickerGroup` context key). + // Inline "Manage" entries: dynamic remote provider rows + menu-contributed + // actions filtered by the `sessionWorkspacePickerGroup` context key. const manageActions: IAction[] = []; if (includeRemoteProviders) { for (const provider of remoteProviders) { @@ -1447,20 +1520,64 @@ export class WorkspacePicker extends Disposable { await removeRemoteHost(provider, this.remoteAgentHostService, this.configurationService); }; } - manageActions.push(action); + if (useRemoteSubmenu) { + const submenuAction = toAction({ + id: action.id, + label: action.label, + tooltip: action.tooltip, + enabled: action.enabled, + run: () => setRemotePickerItem({ run: () => action.run() }), + }); + Object.assign(submenuAction, { + icon: extended.icon, + hoverContent: extended.hoverContent, + onRemove: extended.onRemove, + }); + remoteSubmenuActions.push(submenuAction); + } else { + manageActions.push(action); + } } } - const menuActions = this.menuService.getMenuActions(Menus.SessionWorkspaceManage, this.contextKeyService, { renderShortTitle: true }); + const menuContextKeyService = useRemoteSubmenu + ? this.contextKeyService.createOverlay([[SessionWorkspacePickerGroupContext.key, SESSION_WORKSPACE_GROUP_REMOTE]]) + : this.contextKeyService; + const menuActions = this.menuService.getMenuActions(Menus.SessionWorkspaceManage, menuContextKeyService, { renderShortTitle: true }); for (const [, actions] of menuActions) { for (const menuAction of actions) { if (menuAction instanceof MenuItemAction) { const icon = ThemeIcon.isThemeIcon(menuAction.item.icon) ? menuAction.item.icon : undefined; - manageActions.push(Object.assign(menuAction, { icon })); + if (useRemoteSubmenu) { + const submenuAction = toAction({ + id: menuAction.id, + label: menuAction.label, + tooltip: menuAction.tooltip, + enabled: menuAction.enabled, + run: () => setRemotePickerItem({ run: () => menuAction.run() }), + }); + Object.assign(submenuAction, { icon }); + remoteSubmenuActions.push(submenuAction); + } else { + manageActions.push(Object.assign(menuAction, { icon })); + } } } } + if (remoteSubmenuActions.length > 0) { + if (items.length > 0 && items[items.length - 1].kind !== ActionListItemKind.Separator) { + items.push({ kind: ActionListItemKind.Separator, label: '' }); + } + items.push({ + kind: ActionListItemKind.Action, + label: localize('workspacePicker.remote', "Remote"), + group: { title: '', icon: Codicon.remote }, + item: remotePickerItem, + submenuActions: [new SubmenuAction('workspacePicker.remote.options', '', remoteSubmenuActions)], + }); + } + if (manageActions.length > 0) { if (items.length > 0 && items[items.length - 1].kind !== ActionListItemKind.Separator) { items.push({ kind: ActionListItemKind.Separator, label: '' }); @@ -1495,9 +1612,9 @@ export class WorkspacePicker extends Disposable { const noWorkspace: IActionListItem = { kind: ActionListItemKind.Action, - label: localize('workspacePicker.noWorkspace', "No workspace"), - description: noWorkspaceOption.description, - group: { title: '', icon: Codicon.commentDiscussion }, + label: this._getNoWorkspaceLabel(), + description: this._useConsolidatedRemoteWorkspaces() ? undefined : noWorkspaceOption.description, + group: { title: '', icon: this._useConsolidatedRemoteWorkspaces() ? Codicon.comment : Codicon.commentDiscussion }, item: { checked: noWorkspaceOption.isSelected || undefined, run: () => { @@ -1516,6 +1633,12 @@ export class WorkspacePicker extends Disposable { return this.options.getNoWorkspaceOption?.(); } + private _getNoWorkspaceLabel(): string { + return this._useConsolidatedRemoteWorkspaces() + ? localize('workspacePicker.startFromScratch', "Start from Scratch") + : localize('workspacePicker.noWorkspace', "No workspace"); + } + private _showRemoteHostOptionsDelayed(provider: IAgentHostSessionsProvider): void { // Defer one tick so the action widget fully tears down (focus/DOM cleanup) // before the QuickPick opens and claims focus. @@ -1569,7 +1692,7 @@ export class WorkspacePicker extends Disposable { trigger.parentElement?.toggleAttribute('hidden', hideForSelectedWorkspace || hideForMissingWorkspace || hideForMissingGitHubRepository); trigger.classList.toggle('selected', noWorkspaceSelected || (reflectsWorkspace && workspace !== undefined) || isSelectedCategory || badgeCount > 0 || relatedGitHubInfo !== undefined); const icon = noWorkspaceSelected - ? Codicon.commentDiscussion + ? this._useConsolidatedRemoteWorkspaces() ? Codicon.comment : Codicon.commentDiscussion : (reflectsWorkspace ? workspace?.icon : undefined) ?? (relatedGitHubInfo ? Codicon.repo : (isSelectedCategory && workspace ? workspace.icon : options.icon)); if (!icon || (options.hideIconWhenAttached === true && badgeCount > 0)) { @@ -1583,7 +1706,7 @@ export class WorkspacePicker extends Disposable { contents.icon.className = ThemeIcon.asClassName(icon); } const label = noWorkspaceSelected - ? localize('workspacePicker.noWorkspace', "No workspace") + ? this._getNoWorkspaceLabel() : (reflectsWorkspace ? workspace?.label : undefined) ?? (relatedGitHubInfo ? `${relatedGitHubInfo.owner}/${relatedGitHubInfo.repo}` : (isSelectedCategory && workspace ? workspace.label : options.label)); trigger.setAttribute('aria-label', badgeCount > 0 @@ -1700,6 +1823,10 @@ export class WorkspacePicker extends Disposable { } private _restoreSelectedWorkspace(): IRestoredWorkspaceSelection | undefined { + if (this.isNoWorkspaceSelected()) { + return undefined; + } + // Try the checked entry first const checked = this._restoreCheckedWorkspace(); if (checked && this._canRestoreProviderWorkspace(checked.providerId)) { @@ -1815,7 +1942,7 @@ export class WorkspacePicker extends Disposable { } private _canRestoreWorkspace(): boolean { - return this.options.canRestoreWorkspace?.() ?? true; + return !this.isNoWorkspaceSelected() && (this.options.canRestoreWorkspace?.() ?? true); } /** @@ -1908,11 +2035,11 @@ export class WorkspacePicker extends Disposable { // -- Recent workspaces (sessions' own history) -- protected _getRecentWorkspaces(): IResolvedFolderWorkspace[] { - return this.recentWorkspacesService.getRecentWorkspaces(); + return this.recentWorkspacesService.getRecentWorkspaces(true, this._useConsolidatedRemoteWorkspaces()); } protected _removeRecentWorkspace(folderUri: URI): void { - this.recentWorkspacesService.removeRecentWorkspace(folderUri); + this.recentWorkspacesService.removeRecentWorkspace(folderUri, this._useConsolidatedRemoteWorkspaces()); // Clear current selection if it was the removed workspace if (this._isSelectedFolder(folderUri)) { diff --git a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts index 75b92c4c4a82ca..a694beac78ed33 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts @@ -17,6 +17,8 @@ import { ISessionsPartService } from '../../../services/sessions/browser/session import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; import { REPLACE_PROMPT_TEMPLATE_PLACEHOLDER_COMMAND_ID } from './promptTemplatePlaceholder.js'; import { ARCHIVE_SESSION_COMMAND_ID, FOCUS_ACTIVE_SESSION_COMMAND_ID, FOCUS_NEXT_CHAT_GROUP_COMMAND_ID, FOCUS_PREVIOUS_CHAT_GROUP_COMMAND_ID, MOVE_CHAT_TO_NEXT_GROUP_COMMAND_ID, MOVE_CHAT_TO_PREVIOUS_GROUP_COMMAND_ID, RENAME_CHAT_COMMAND_ID, RENAME_SESSION_COMMAND_ID, SPLIT_CHAT_GROUP_DOWN_COMMAND_ID, SPLIT_CHAT_GROUP_RIGHT_COMMAND_ID } from '../../../common/sessionCommands.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { ChatConfiguration } from '../../../../workbench/contrib/chat/common/constants.js'; export class SessionsChatAccessibilityHelp implements IAccessibleViewImplementation { readonly priority = 120; readonly name = 'sessionsChat'; @@ -49,7 +51,7 @@ export class SessionsChatAccessibilityHelp implements IAccessibleViewImplementat content.push(localize('sessionsChat.githubReferences', "Pull request and issue pills above the chat input open their GitHub item in the GitHub Pull Requests extension when it is available. Pills that represent several items open a keyboard-accessible picker.")); content.push(localize('sessionsChat.failingChecksPullRequest', "When a session pull request has failing checks, use Reveal in its banner item to open that pull request, or use Fix Checks to ask the agent to address the failures.")); content.push(localize('sessionsChat.pickFolderQuickPick', "To choose a folder from a searchable list instead, use the New Session in Folder command{0}.", '')); - content.push(localize('sessionsChat.quickChat', "To start a workspace-less quick chat, use the New Quick Chat command{0} or the plus button on the Chats section in the sessions list. A quick chat has no workspace, so the workspace picker does not apply and the Toggle Side Panel command is disabled.", '')); + content.push(localize('sessionsChat.quickChat', "To start a workspace-less quick chat, use the New Quick Chat command{0} or the plus button on the Chats section in the sessions list. When consolidated remote workspaces are enabled, this opens the new-session composer, where you can choose No workspace. Otherwise, it starts a workspace-less chat directly. The Toggle Side Panel command is disabled for quick chats.", '')); content.push(localize('sessionsChat.mobileConfig', "On mobile, the mode and model pickers appear as tappable chips below the input. Tap a chip to open a bottom sheet where you can change the selection.")); content.push(localize('sessionsChat.history', "Use up and down arrows to navigate your request history in the input box.")); content.push(localize('sessionsChat.background', "Outside high contrast themes, use Set Background to choose no background, the built-in theme-aware Codicons pattern, a new image, or one of the five most recently selected images. Use Change Background Layout to choose whether an image repeats, stretches, or appears at an edge or corner for the current color theme. Moving through the layout picker previews each option; select one to save it, or press Escape to restore the previous layout. Both commands are available from the Command Palette and by right-clicking empty chat space. Change Background Layout is shown only for images. Background customization is unavailable while a high contrast theme is active.")); @@ -86,7 +88,9 @@ export class SessionsChatAccessibilityHelp implements IAccessibleViewImplementat content.push(localize('sessionsChat.openPullRequest', "When the session is associated with GitHub pull requests, a status pill above the chat input shows the pull request number or count. Activate it to open a single pull request or choose from the associated pull requests{0}.", '')); content.push(localize('sessionsChat.filesView', "Focus the Files Explorer view{0}.", '')); content.push(localize('sessionsChat.sessionsView', "Focus the Chat Sessions view{0}.", '')); - content.push(localize('sessionsChat.customizations', "Focus the Chat Customizations view{0}.", ``)); + content.push(accessor.get(IConfigurationService).getValue(ChatConfiguration.CustomizationEntryPoints) + ? localize('sessionsChat.openCustomizations', "Open the Chat Customizations overview{0}.", ``) + : localize('sessionsChat.focusCustomizations', "Focus the Chat Customizations view{0}.", ``)); content.push(localize('sessionsChat.toggleSidePanel', "Toggle the side panel (the editor area together with the auxiliary bar) open or closed{0}.", '')); return new AccessibleContentProvider( diff --git a/src/vs/sessions/contrib/chat/browser/unifiedWorkspacePickerConfiguration.ts b/src/vs/sessions/contrib/chat/browser/unifiedWorkspacePickerConfiguration.ts new file mode 100644 index 00000000000000..0700a988a9d1fb --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/unifiedWorkspacePickerConfiguration.ts @@ -0,0 +1,21 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ConfigurationKeyValuePairs, ConfigurationMigration } from '../../../../workbench/common/configuration.js'; +import { UNIFIED_WORKSPACE_PICKER_SETTING } from '../common/constants.js'; + +export const LEGACY_UNIFIED_WORKSPACE_PICKER_SETTING = 'chat.agentSessions.consolidatedRemoteWorkspaces'; + +export const unifiedWorkspacePickerConfigurationMigration: ConfigurationMigration = { + key: LEGACY_UNIFIED_WORKSPACE_PICKER_SETTING, + includeApplication: true, + migrateFn: (value, accessor) => { + const pairs: ConfigurationKeyValuePairs = [[LEGACY_UNIFIED_WORKSPACE_PICKER_SETTING, { value: undefined }]]; + if (accessor(UNIFIED_WORKSPACE_PICKER_SETTING) === undefined) { + pairs.push([UNIFIED_WORKSPACE_PICKER_SETTING, { value }]); + } + return pairs; + }, +}; diff --git a/src/vs/sessions/contrib/chat/common/constants.ts b/src/vs/sessions/contrib/chat/common/constants.ts index 84a4e7e035871e..f7e9856c5e3dfb 100644 --- a/src/vs/sessions/contrib/chat/common/constants.ts +++ b/src/vs/sessions/contrib/chat/common/constants.ts @@ -4,3 +4,4 @@ *--------------------------------------------------------------------------------------------*/ export const NEW_SESSION_ACTION_ID = 'workbench.action.sessions.newChat'; +export const UNIFIED_WORKSPACE_PICKER_SETTING = 'sessions.chat.unifiedWorkspacePicker.enabled'; diff --git a/src/vs/sessions/contrib/chat/common/newChatContextIds.ts b/src/vs/sessions/contrib/chat/common/newChatContextIds.ts index 5174ec7e7b8995..381ca4d51cebf2 100644 --- a/src/vs/sessions/contrib/chat/common/newChatContextIds.ts +++ b/src/vs/sessions/contrib/chat/common/newChatContextIds.ts @@ -7,6 +7,11 @@ import { URI } from '../../../../base/common/uri.js'; export const ADDITIONAL_FOLDER_CONTEXT_ID_PREFIX = 'sessions-additional-folder:'; export const ADDITIONAL_REPOSITORY_CONTEXT_ID_PREFIX = 'sessions-additional-repository:'; +const INPUT_GITHUB_CONTEXT_METADATA_KEY = 'sessionsInputGitHubContext'; + +interface IHasInputGitHubContextMetadata { + readonly _meta?: Record; +} export function getAdditionalFolderContextId(uri: URI): string { return `${ADDITIONAL_FOLDER_CONTEXT_ID_PREFIX}${uri.toString()}`; @@ -19,3 +24,12 @@ export function getAdditionalRepositoryContextId(uri: URI): string { export function isAdditionalWorkspaceContextId(id: string): boolean { return id.startsWith(ADDITIONAL_FOLDER_CONTEXT_ID_PREFIX) || id.startsWith(ADDITIONAL_REPOSITORY_CONTEXT_ID_PREFIX); } + +export function isInputGitHubContext(source: IHasInputGitHubContextMetadata): boolean { + // eslint-disable-next-line local/code-no-untyped-meta-access -- sanctioned first hop into the input GitHub context slot. + return source._meta?.[INPUT_GITHUB_CONTEXT_METADATA_KEY] === true; +} + +export function toInputGitHubContextMetadata(): Record { + return { [INPUT_GITHUB_CONTEXT_METADATA_KEY]: true }; +} diff --git a/src/vs/sessions/contrib/chat/test/browser/chatInput.fixture.ts b/src/vs/sessions/contrib/chat/test/browser/chatInput.fixture.ts index fe8f8a08662beb..aa99621792052d 100644 --- a/src/vs/sessions/contrib/chat/test/browser/chatInput.fixture.ts +++ b/src/vs/sessions/contrib/chat/test/browser/chatInput.fixture.ts @@ -102,7 +102,7 @@ export default defineThemedFixtureGroup({ path: 'sessions/chat/input/' }, { }) }), ResponsiveModelResizeCycleCompact: defineComponentFixture({ - labels: { kind: 'screenshot', blocksCi: true }, + labels: { kind: 'screenshot' }, expectedVisualDescriptions: ['The Agents active-session chat input shows its compact model codicon centered with equal padding inside a 22-pixel square control while the model configuration remains visible.'], virtualTime: { enabled: false }, render: context => renderChatInput(sessionsWindowContext(context), { diff --git a/src/vs/sessions/contrib/chat/test/browser/chatView.test.ts b/src/vs/sessions/contrib/chat/test/browser/chatView.test.ts index e5a1b5916e6477..04bcde0de80665 100644 --- a/src/vs/sessions/contrib/chat/test/browser/chatView.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/chatView.test.ts @@ -504,6 +504,47 @@ suite('Sessions - Chat View', () => { }); }); + test('keeps the request edit input opaque over the chat background', () => { + const workbench = dom.$('.monaco-workbench.agent-sessions-workbench'); + workbench.style.setProperty('--session-view-background', '#202020'); + workbench.style.setProperty('--vscode-chat-requestBubbleBackground', 'rgba(255, 255, 255, 0.3)'); + const appendEditInput = (part: HTMLElement) => { + const chatView = dom.append(part, dom.$('.chat-view')); + const session = dom.append(chatView, dom.$('.interactive-session')); + const request = dom.append(session, dom.$('.interactive-item-container.interactive-request.editing')); + const editContainer = dom.append(request, dom.$('.chat-edit-input-container')); + const inlineInputPart = dom.append(editContainer, dom.$('.interactive-input-part')); + const composerInputPart = dom.append(session, dom.$('.interactive-input-part.editing')); + return { + inline: dom.append(inlineInputPart, dom.$('.chat-input-container')), + composer: dom.append(composerInputPart, dom.$('.chat-input-container')), + }; + }; + const background = appendEditInput(dom.append(workbench, dom.$('.part.sessionspart.has-chat-background'))); + const plain = appendEditInput(dom.append(workbench, dom.$('.part.sessionspart'))); + dom.getWindow(workbench).document.body.appendChild(workbench); + disposables.add(toDisposable(() => workbench.remove())); + + const inlineStyle = dom.getWindow(background.inline).getComputedStyle(background.inline); + const composerStyle = dom.getWindow(background.composer).getComputedStyle(background.composer); + const plainInlineStyle = dom.getWindow(plain.inline).getComputedStyle(plain.inline); + assert.deepStrictEqual({ + inlineBackgroundColor: inlineStyle.backgroundColor, + inlineBackgroundImage: inlineStyle.backgroundImage, + composerBackgroundColor: composerStyle.backgroundColor, + composerBackgroundImage: composerStyle.backgroundImage, + plainInlineBackgroundColor: plainInlineStyle.backgroundColor, + plainInlineBackgroundImage: plainInlineStyle.backgroundImage, + }, { + inlineBackgroundColor: 'rgb(32, 32, 32)', + inlineBackgroundImage: 'linear-gradient(rgba(255, 255, 255, 0.3), rgba(255, 255, 255, 0.3))', + composerBackgroundColor: 'rgb(32, 32, 32)', + composerBackgroundImage: 'linear-gradient(rgba(255, 255, 255, 0.3), rgba(255, 255, 255, 0.3))', + plainInlineBackgroundColor: 'rgba(255, 255, 255, 0.3)', + plainInlineBackgroundImage: 'none', + }); + }); + test('applies a borderless translucent side fade to the complete assistant response', () => { const workbench = dom.$('.monaco-workbench.agent-sessions-workbench'); workbench.style.setProperty('--session-view-background', '#ffffff'); @@ -564,39 +605,48 @@ suite('Sessions - Chat View', () => { workbench.style.setProperty('--vscode-button-secondaryBackground', 'rgba(0, 0, 0, 0.08)'); workbench.style.setProperty('--vscode-button-secondaryBorder', '#808080'); workbench.style.setProperty('--vscode-button-secondaryForeground', '#202020'); - workbench.style.setProperty('--vscode-cornerRadius-large', '8px'); + workbench.style.setProperty('--vscode-commandCenter-inactiveBorder', '#606060'); workbench.style.setProperty('--vscode-cornerRadius-small', '4px'); - workbench.style.setProperty('--vscode-spacing-size120', '12px'); workbench.style.setProperty('--vscode-strokeThickness', '1px'); const part = dom.append(workbench, dom.$('.part.sessionspart.has-chat-background')); const chatView = dom.append(part, dom.$('.chat-view')); const newChatWidget = dom.append(chatView, dom.$('.sessions-chat-widget')); const newChatContent = dom.append(newChatWidget, dom.$('.new-chat-widget-content')); - const inSessionWidget = dom.append(chatView, dom.$('.sessions-chat-widget.new-chat-in-session')); - const inSessionContent = dom.append(inSessionWidget, dom.$('.new-chat-widget-content')); + const newChatContainer = dom.append(newChatWidget, dom.$('.new-chat-widget-container')); + const bottomContainer = dom.append(newChatContainer, dom.$('.new-chat-bottom-container')); + const bottomAction = dom.append(bottomContainer, dom.$('.action-label')); + const workspacePickerSlot = dom.append(newChatContainer, dom.$('.sessions-chat-picker-slot.sessions-workspace-category-picker-slot')); + const workspacePill = dom.append(workspacePickerSlot, dom.$('.action-label')); const session = dom.append(chatView, dom.$('.interactive-session')); const secondaryToolbar = dom.append(session, dom.$('.chat-secondary-toolbar')); const secondaryAction = dom.append(secondaryToolbar, dom.$('.action-label')); const contextUsage = dom.append(secondaryToolbar, dom.$('.chat-context-usage-widget')); const plainPart = dom.append(workbench, dom.$('.part.sessionspart')); const plainChatView = dom.append(plainPart, dom.$('.chat-view')); - const plainNewChatWidget = dom.append(plainChatView, dom.$('.sessions-chat-widget')); - const plainNewChatContent = dom.append(plainNewChatWidget, dom.$('.new-chat-widget-content')); const plainSession = dom.append(plainChatView, dom.$('.interactive-session')); const plainSecondaryToolbar = dom.append(plainSession, dom.$('.chat-secondary-toolbar')); const plainSecondaryAction = dom.append(plainSecondaryToolbar, dom.$('.action-label')); const plainContextUsage = dom.append(plainSecondaryToolbar, dom.$('.chat-context-usage-widget')); + const plainNewChatWidget = dom.append(plainChatView, dom.$('.sessions-chat-widget')); + const plainNewChatContainer = dom.append(plainNewChatWidget, dom.$('.new-chat-widget-container')); + const plainBottomContainer = dom.append(plainNewChatContainer, dom.$('.new-chat-bottom-container')); + const plainBottomAction = dom.append(plainBottomContainer, dom.$('.action-label')); dom.getWindow(workbench).document.body.appendChild(workbench); disposables.add(toDisposable(() => workbench.remove())); const newChatStyle = dom.getWindow(newChatContent).getComputedStyle(newChatContent); + const bottomActionStyle = dom.getWindow(bottomAction).getComputedStyle(bottomAction); + const workspacePillStyle = dom.getWindow(workspacePill).getComputedStyle(workspacePill); const secondaryActionStyle = dom.getWindow(secondaryAction).getComputedStyle(secondaryAction); const contextUsageStyle = dom.getWindow(contextUsage).getComputedStyle(contextUsage); assert.deepStrictEqual({ newChatBackgroundColor: newChatStyle.backgroundColor, - newChatBorderRadius: newChatStyle.borderRadius, newChatPadding: newChatStyle.padding, - inSessionBackgroundColor: dom.getWindow(inSessionContent).getComputedStyle(inSessionContent).backgroundColor, + bottomActionBackgroundColor: bottomActionStyle.backgroundColor, + bottomActionBorderColor: bottomActionStyle.borderColor, + bottomActionBorderStyle: bottomActionStyle.borderStyle, + bottomActionBorderRadius: bottomActionStyle.borderRadius, + workspacePillBackgroundColor: workspacePillStyle.backgroundColor, secondaryActionBackgroundColor: secondaryActionStyle.backgroundColor, secondaryActionBackgroundImage: secondaryActionStyle.backgroundImage, secondaryActionBorderColor: secondaryActionStyle.borderColor, @@ -604,17 +654,20 @@ suite('Sessions - Chat View', () => { contextUsageBackgroundColor: contextUsageStyle.backgroundColor, contextUsageBackgroundImage: contextUsageStyle.backgroundImage, contextUsageBorderRadius: contextUsageStyle.borderRadius, - plainNewChatBackgroundColor: dom.getWindow(plainNewChatContent).getComputedStyle(plainNewChatContent).backgroundColor, - plainNewChatPadding: dom.getWindow(plainNewChatContent).getComputedStyle(plainNewChatContent).padding, plainSecondaryActionBackgroundColor: dom.getWindow(plainSecondaryAction).getComputedStyle(plainSecondaryAction).backgroundColor, plainSecondaryActionBorderStyle: dom.getWindow(plainSecondaryAction).getComputedStyle(plainSecondaryAction).borderStyle, plainContextUsageBackgroundColor: dom.getWindow(plainContextUsage).getComputedStyle(plainContextUsage).backgroundColor, plainContextUsageBorderStyle: dom.getWindow(plainContextUsage).getComputedStyle(plainContextUsage).borderStyle, + plainBottomActionBackgroundColor: dom.getWindow(plainBottomAction).getComputedStyle(plainBottomAction).backgroundColor, + plainBottomActionBorderStyle: dom.getWindow(plainBottomAction).getComputedStyle(plainBottomAction).borderStyle, }, { - newChatBackgroundColor: 'color(srgb 1 1 1 / 0.86)', - newChatBorderRadius: '8px', - newChatPadding: '12px', - inSessionBackgroundColor: 'rgba(0, 0, 0, 0)', + newChatBackgroundColor: 'rgba(0, 0, 0, 0)', + newChatPadding: '0px', + bottomActionBackgroundColor: 'rgb(255, 255, 255)', + bottomActionBorderColor: 'rgb(96, 96, 96)', + bottomActionBorderStyle: 'solid', + bottomActionBorderRadius: '4px', + workspacePillBackgroundColor: 'rgb(255, 255, 255)', secondaryActionBackgroundColor: 'rgb(255, 255, 255)', secondaryActionBackgroundImage: 'linear-gradient(rgba(0, 0, 0, 0.08), rgba(0, 0, 0, 0.08))', secondaryActionBorderColor: 'rgb(128, 128, 128)', @@ -622,12 +675,12 @@ suite('Sessions - Chat View', () => { contextUsageBackgroundColor: 'rgb(255, 255, 255)', contextUsageBackgroundImage: 'linear-gradient(rgba(0, 0, 0, 0.08), rgba(0, 0, 0, 0.08))', contextUsageBorderRadius: '4px', - plainNewChatBackgroundColor: 'rgba(0, 0, 0, 0)', - plainNewChatPadding: '0px', plainSecondaryActionBackgroundColor: 'rgba(0, 0, 0, 0)', plainSecondaryActionBorderStyle: 'none', plainContextUsageBackgroundColor: 'rgba(0, 0, 0, 0)', plainContextUsageBorderStyle: 'none', + plainBottomActionBackgroundColor: 'rgb(255, 255, 255)', + plainBottomActionBorderStyle: 'none', }); }); diff --git a/src/vs/sessions/contrib/chat/test/browser/newChatInput.test.ts b/src/vs/sessions/contrib/chat/test/browser/newChatInput.test.ts index d78cea3115ac2c..5aa8077b290532 100644 --- a/src/vs/sessions/contrib/chat/test/browser/newChatInput.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/newChatInput.test.ts @@ -20,7 +20,6 @@ import { FileKind } from '../../../../../platform/files/common/files.js'; import { ColorScheme } from '../../../../../platform/theme/common/theme.js'; import { FileThemeIcon, FolderThemeIcon } from '../../../../../platform/theme/common/themeService.js'; import { IFileLabelOptions } from '../../../../../workbench/browser/labels.js'; -import { ChatDynamicVariableModel } from '../../../../../workbench/contrib/chat/browser/attachments/chatDynamicVariables.js'; import { hasSendableNewChatContent, NewChatInputWidget } from '../../browser/newChatInput.js'; import { ChatPasteAttachmentMetadata, IChatRequestVariableEntry, toPasteVariableEntry } from '../../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; import { NewChatContextAttachments } from '../../browser/newChatContextAttachments.js'; @@ -38,12 +37,14 @@ interface IInputModelReferenceHarness { } const holdInputModelReference = Reflect.get(NewChatInputWidget.prototype, '_holdInputModelReference') as (this: IInputModelReferenceHarness, uri: URI, model: ITextModel) => void; -const getDraftState = Reflect.get(NewChatInputWidget.prototype, '_getDraftState') as (this: IDraftStateHarness) => { inputText: string; attachments: readonly IChatRequestVariableEntry[]; contrib?: Record } | undefined; +const getDraftState = Reflect.get(NewChatInputWidget.prototype, '_getDraftState') as (this: IDraftStateHarness) => { inputText: string; attachments: readonly IChatRequestVariableEntry[] } | undefined; const restoreState = Reflect.get(NewChatInputWidget.prototype, '_restoreState') as (this: IRestoreStateHarness) => void; const saveState = Reflect.get(NewChatInputWidget.prototype, 'saveState') as (this: IDraftStateHarness) => void; const clearDraftState = Reflect.get(NewChatInputWidget.prototype, '_clearDraftState') as (this: IDraftStateHarness) => void; const updateDraftState = Reflect.get(NewChatInputWidget.prototype, '_updateDraftState') as (this: IUpdateDraftStateHarness) => void; const updateAndSaveDraftState = Reflect.get(NewChatInputWidget.prototype, '_updateAndSaveDraftState') as (this: IUpdateAndSaveDraftStateHarness) => void; +const syncInputGitHubContext = Reflect.get(NewChatInputWidget.prototype, '_syncInputGitHubContext') as (this: ISyncInputGitHubContextHarness) => void; +const attachTextContext = Reflect.get(NewChatInputWidget.prototype, 'attachTextContext') as (this: IAttachTextContextHarness, name: string, content: string, icon: ThemeIcon, id: string) => void; const updateSendButtonState = Reflect.get(NewChatInputWidget.prototype, '_updateSendButtonState') as (this: IUpdateSendButtonStateHarness) => void; const setInputEditorFocused = Reflect.get(NewChatInputWidget.prototype, '_setInputEditorFocused') as (container: HTMLElement, focused: boolean) => void; const updateAttachmentRendering = Reflect.get(NewChatContextAttachments.prototype, '_updateRendering') as (this: IAttachmentRenderingHarness) => void; @@ -54,20 +55,18 @@ interface IDraftStateHarness { get(key: string, scope: unknown): string | undefined; store(key: string, value: string, scope: unknown, target: unknown): void; }; - _draftState?: { inputText: string; attachments: readonly IChatRequestVariableEntry[]; contrib?: Record }; + _draftState?: { inputText: string; attachments: readonly IChatRequestVariableEntry[] }; } interface IRestoreStateHarness { - _getDraftState(): { inputText: string; attachments: readonly IChatRequestVariableEntry[]; contrib?: Record } | undefined; + _getDraftState(): { inputText: string; attachments: readonly IChatRequestVariableEntry[] } | undefined; readonly _editor: { getModel(): { setValue(value: string): void } | null; }; readonly _contextAttachments: { setAttachments(entries: readonly IChatRequestVariableEntry[]): void; }; - readonly _dynamicVariableModel: { - setInputState(contrib: Readonly>): void; - }; + _syncInputGitHubContext(): void; _updateSendButtonState(): void; } @@ -78,10 +77,6 @@ interface IUpdateDraftStateHarness extends IDraftStateHarness { readonly _contextAttachments: { readonly attachments: readonly IChatRequestVariableEntry[]; }; - readonly _dynamicVariableModel?: { - readonly variables: readonly object[]; - getInputState(contrib: Record): void; - }; } interface IUpdateAndSaveDraftStateHarness extends IUpdateDraftStateHarness { @@ -90,6 +85,24 @@ interface IUpdateAndSaveDraftStateHarness extends IUpdateDraftStateHarness { saveState(): void; } +interface ISyncInputGitHubContextHarness { + readonly _editor: { + getValue(): string; + }; + readonly _contextAttachments: { + attachments: readonly IChatRequestVariableEntry[]; + setAttachments(entries: readonly IChatRequestVariableEntry[]): void; + }; +} + +interface IAttachTextContextHarness { + readonly _contextAttachments: { + attachments: readonly IChatRequestVariableEntry[]; + addAttachments(...entries: IChatRequestVariableEntry[]): void; + setAttachments(entries: readonly IChatRequestVariableEntry[]): void; + }; +} + interface IUpdateSendButtonStateHarness { readonly _sendButton: { enabled: boolean } | undefined; readonly _sending: boolean; @@ -176,54 +189,54 @@ suite('NewChatInputWidget', () => { focused: { input: true, stack: true }, blurred: { input: false, stack: false }, }); + }); - test('keeps the input model alive until reference acquisition settles during disposal', async () => { - const referenceDeferred = new DeferredPromise>(); - let modelDisposed = false; - let referenceDisposed = false; - const errors: { message: string; error: Error }[] = []; - const model = new class extends mock() { - override dispose(): void { - modelDisposed = true; - } - }(); - const resolvedModel = new class extends mock() { - override readonly textEditorModel = model; - }(); - const harness = disposables.add(new InputModelReferenceHarness( - { - createModelReference: () => referenceDeferred.p, - }, - { - error: (message, error) => errors.push({ message, error }), - }, - )); + test('keeps the input model alive until reference acquisition settles during disposal', async () => { + const referenceDeferred = new DeferredPromise>(); + let modelDisposed = false; + let referenceDisposed = false; + const errors: { message: string; error: Error }[] = []; + const model = new class extends mock() { + override dispose(): void { + modelDisposed = true; + } + }(); + const resolvedModel = new class extends mock() { + override readonly textEditorModel = model; + }(); + const harness = disposables.add(new InputModelReferenceHarness( + { + createModelReference: () => referenceDeferred.p, + }, + { + error: (message, error) => errors.push({ message, error }), + }, + )); - holdInputModelReference.call(harness, URI.from({ scheme: Schemas.sessionsChatInput, path: 'input-test' }), model); - harness.dispose(); - const disposedBeforeReferenceSettled = modelDisposed; + holdInputModelReference.call(harness, URI.from({ scheme: Schemas.sessionsChatInput, path: 'input-test' }), model); + harness.dispose(); + const disposedBeforeReferenceSettled = modelDisposed; - referenceDeferred.complete({ - object: resolvedModel, - dispose: () => { - referenceDisposed = true; - model.dispose(); - }, - }); - await referenceDeferred.p; - await Promise.resolve(); + referenceDeferred.complete({ + object: resolvedModel, + dispose: () => { + referenceDisposed = true; + model.dispose(); + }, + }); + await referenceDeferred.p; + await Promise.resolve(); - assert.deepStrictEqual({ - disposedBeforeReferenceSettled, - modelDisposed, - referenceDisposed, - errors, - }, { - disposedBeforeReferenceSettled: false, - modelDisposed: true, - referenceDisposed: true, - errors: [], - }); + assert.deepStrictEqual({ + disposedBeforeReferenceSettled, + modelDisposed, + referenceDisposed, + errors, + }, { + disposedBeforeReferenceSettled: false, + modelDisposed: true, + referenceDisposed: true, + errors: [], }); }); @@ -287,7 +300,7 @@ suite('NewChatInputWidget', () => { _getDraftState: () => draft, _editor: { getModel: () => ({ setValue: value => restored.inputText = value }) }, _contextAttachments: { setAttachments: entries => restored.attachments = entries }, - _dynamicVariableModel: { setInputState: () => { } }, + _syncInputGitHubContext: () => { }, _updateSendButtonState: () => { }, }); @@ -332,52 +345,6 @@ suite('NewChatInputWidget', () => { }); }); - test('persists and restores inline reference contribution state', () => { - let stored: string | undefined; - const storageService: IDraftStateHarness['storageService'] = { - get: () => stored, - store: (_key, value) => stored = value, - }; - const url = 'https://github.com/microsoft/vscode/issues/334061'; - const contribution = [{ - id: url, - range: { startLineNumber: 1, startColumn: 1, endLineNumber: 1, endColumn: 25 }, - promptText: url, - data: URI.parse(url), - }]; - const saveHarness: IUpdateAndSaveDraftStateHarness = { - storageService, - _sending: false, - _editor: { getModel: () => ({ getValue: () => 'microsoft/vscode#334061' }) }, - _contextAttachments: { attachments: [] }, - _dynamicVariableModel: { - variables: contribution, - getInputState: contrib => contrib[ChatDynamicVariableModel.ID] = contribution, - }, - _updateDraftState() { - updateDraftState.call(this); - }, - saveState() { - saveState.call(this); - }, - }; - updateAndSaveDraftState.call(saveHarness); - - let restoredContrib: Readonly> | undefined; - const draft = getDraftState.call({ storageService }); - restoreState.call({ - _getDraftState: () => draft, - _editor: { getModel: () => ({ setValue: () => { } }) }, - _contextAttachments: { setAttachments: () => { } }, - _dynamicVariableModel: { setInputState: contrib => restoredContrib = contrib }, - _updateSendButtonState: () => { }, - }); - - assert.deepStrictEqual(restoredContrib, { - [ChatDynamicVariableModel.ID]: contribution, - }); - }); - test('does not re-persist a sent prompt when attachments clear during send', () => { let stored: string | undefined; let editorValue = 'Fix this'; @@ -439,9 +406,9 @@ suite('NewChatInputWidget', () => { attachments: [], setAttachments: () => { }, }, - _dynamicVariableModel: { setInputState: () => { } }, options: {}, _canSendRequest: { get: () => true }, + _syncInputGitHubContext: () => { }, _updateSendButtonState() { updateSendButtonState.call(this); }, @@ -452,6 +419,89 @@ suite('NewChatInputWidget', () => { assert.strictEqual(sendButton.enabled, true); }); + test('synchronizes GitHub context attachments with issue and pull request links in the input', () => { + let input = 'Fix https://github.com/microsoft/vscode/issues/333845 and review https://www.github.com/microsoft/vscode/pull/333575#discussion.'; + const manualAttachment = toPasteVariableEntry('Manually attached', 'Manual context', { + id: 'github-context:https://github.com/microsoft/vscode/issues/1', + }); + let attachments: readonly IChatRequestVariableEntry[] = [manualAttachment]; + const harness: ISyncInputGitHubContextHarness = { + _editor: { getValue: () => input }, + _contextAttachments: { + get attachments() { return attachments; }, + setAttachments: entries => attachments = entries, + }, + }; + const snapshot = () => attachments.map(attachment => ({ + id: attachment.id, + name: attachment.name, + icon: ThemeIcon.isThemeIcon(attachment.icon) ? attachment.icon.id : undefined, + })); + + syncInputGitHubContext.call(harness); + const withLinks = snapshot(); + input = 'Review https://github.com/microsoft/vscode/pull/333575.'; + syncInputGitHubContext.call(harness); + const afterRemovingIssueLink = snapshot(); + input = ''; + syncInputGitHubContext.call(harness); + + assert.deepStrictEqual({ + withLinks, + afterRemovingIssueLink, + afterRemovingAllLinks: snapshot(), + }, { + withLinks: [ + { id: manualAttachment.id, name: 'Manually attached', icon: undefined }, + { id: 'github-context:https://github.com/microsoft/vscode/issues/333845', name: 'microsoft/vscode#333845', icon: Codicon.issues.id }, + { id: 'github-context:https://github.com/microsoft/vscode/pull/333575', name: 'microsoft/vscode#333575', icon: Codicon.gitPullRequest.id }, + ], + afterRemovingIssueLink: [ + { id: manualAttachment.id, name: 'Manually attached', icon: undefined }, + { id: 'github-context:https://github.com/microsoft/vscode/pull/333575', name: 'microsoft/vscode#333575', icon: Codicon.gitPullRequest.id }, + ], + afterRemovingAllLinks: [ + { id: manualAttachment.id, name: 'Manually attached', icon: undefined }, + ], + }); + }); + + test('preserves pasted GitHub context after the same target is explicitly attached', () => { + const uri = 'https://github.com/microsoft/vscode/issues/333845'; + let input = `Fix ${uri}`; + let attachments: readonly IChatRequestVariableEntry[] = []; + const contextAttachments = { + get attachments() { return attachments; }, + addAttachments: (...entries: IChatRequestVariableEntry[]) => attachments = [...attachments, ...entries], + setAttachments: (entries: readonly IChatRequestVariableEntry[]) => attachments = entries, + }; + const syncHarness: ISyncInputGitHubContextHarness = { + _editor: { getValue: () => input }, + _contextAttachments: contextAttachments, + }; + + syncInputGitHubContext.call(syncHarness); + attachTextContext.call( + { _contextAttachments: contextAttachments }, + 'microsoft/vscode#333845', + `GitHub context: ${uri}`, + Codicon.issues, + `github-context:${uri}`, + ); + input = ''; + syncInputGitHubContext.call(syncHarness); + + assert.deepStrictEqual(attachments.map(attachment => ({ + id: attachment.id, + name: attachment.name, + icon: ThemeIcon.isThemeIcon(attachment.icon) ? attachment.icon.id : undefined, + })), [{ + id: `github-context:${uri}`, + name: 'microsoft/vscode#333845', + icon: Codicon.issues.id, + }]); + }); + test('renders GitHub context pills as openable with a keyboard-reachable remove button', async () => { const container = document.createElement('div'); const entry = toPasteVariableEntry('microsoft/vscode#332825', 'GitHub context: https://github.com/microsoft/vscode/pull/332825', { diff --git a/src/vs/sessions/contrib/chat/test/browser/newChatInputPaste.test.ts b/src/vs/sessions/contrib/chat/test/browser/newChatInputPaste.test.ts index 2177a38e162ff7..cb91a2f50919a4 100644 --- a/src/vs/sessions/contrib/chat/test/browser/newChatInputPaste.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/newChatInputPaste.test.ts @@ -6,146 +6,203 @@ import assert from 'assert'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { createStringDataTransferItem, VSDataTransfer } from '../../../../../base/common/dataTransfer.js'; -import { Emitter, Event } from '../../../../../base/common/event.js'; -import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { Emitter } from '../../../../../base/common/event.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; import { Mimes } from '../../../../../base/common/mime.js'; import { Schemas } from '../../../../../base/common/network.js'; +import { observableValue } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { ICodeEditorService } from '../../../../../editor/browser/services/codeEditorService.js'; import { Range } from '../../../../../editor/common/core/range.js'; import { DocumentPasteTriggerKind, ICustomEdit } from '../../../../../editor/common/languages.js'; import { IModelService } from '../../../../../editor/common/services/model.js'; -import { createTestCodeEditor } from '../../../../../editor/test/browser/testCodeEditor.js'; -import { TestCodeEditorService } from '../../../../../editor/test/browser/editorTestServices.js'; import { createTextModel } from '../../../../../editor/test/common/testTextModel.js'; -import { IConfigurationOverrides, IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { withTestCodeEditor } from '../../../../../editor/test/browser/testCodeEditor.js'; import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js'; -import { ILabelService } from '../../../../../platform/label/common/label.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; -import { TestThemeService } from '../../../../../platform/theme/test/common/testThemeService.js'; -import { ChatDynamicVariableModel } from '../../../../../workbench/contrib/chat/browser/attachments/chatDynamicVariables.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IChatPasteTarget, IChatPasteTargetService } from '../../../../../workbench/contrib/chat/browser/chat.js'; -import { registerChatInputReferenceDecorationType } from '../../../../../workbench/contrib/chat/browser/widget/input/editor/chatInputReferenceDecorations.js'; import { PasteTextProvider, pastedTextArtifactDefaultMinLength } from '../../../../../workbench/contrib/chat/browser/widget/input/editor/chatPasteProviders.js'; -import { IChatRequestVariableEntry } from '../../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; -import { ChatConfiguration } from '../../../../../workbench/contrib/chat/common/constants.js'; +import { IChatRequestVariableEntry, isPastedTextArtifact } from '../../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; +import { IChatSessionsService } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { IActiveSession } from '../../../../services/sessions/common/sessionsManagement.js'; +import { ISessionContext } from '../../../../services/sessions/browser/sessionContext.js'; +import { AgentHostInputCompletionHandler } from '../../browser/agentHostInputCompletions.js'; import { INewChatAttachments } from '../../browser/newChatContextAttachments.js'; import { NewChatInputPasteTarget } from '../../browser/newChatInputPasteTarget.js'; -class TestAttachments extends Disposable implements INewChatAttachments { - private readonly _onDidChangeContext = this._register(new Emitter()); +/** Minimal stand-in for the composer's attachment store, without the pill UI. */ +class TestAttachments implements INewChatAttachments { + + private readonly _onDidChangeContext = new Emitter(); readonly onDidChangeContext = this._onDidChangeContext.event; - readonly attachments: IChatRequestVariableEntry[] = []; + + private readonly _attachments: IChatRequestVariableEntry[] = []; + + get attachments(): readonly IChatRequestVariableEntry[] { + return this._attachments; + } setAttachments(entries: readonly IChatRequestVariableEntry[]): void { - this.attachments.splice(0, this.attachments.length, ...entries); + this._attachments.length = 0; + this._attachments.push(...entries); this._onDidChangeContext.fire(); } addAttachments(...entries: IChatRequestVariableEntry[]): void { - this.attachments.push(...entries); + for (const entry of entries) { + if (!this._attachments.some(e => e.id === entry.id)) { + this._attachments.push(entry); + } + } this._onDidChangeContext.fire(); } removeAttachment(id: string): void { - const index = this.attachments.findIndex(entry => entry.id === id); - if (index !== -1) { - this.attachments.splice(index, 1); + const index = this._attachments.findIndex(e => e.id === id); + if (index >= 0) { + this._attachments.splice(index, 1); this._onDidChangeContext.fire(); } } + + dispose(): void { + this._onDidChangeContext.dispose(); + } } suite('NewChatInputPasteTarget', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); - test('formats GitHub links without attaching them as context', async () => { - const uri = URI.from({ scheme: Schemas.sessionsChatInput, path: 'paste-test' }); - const textModel = store.add(createTextModel('', null, undefined, uri)); - const codeEditorService = store.add(new TestCodeEditorService(new TestThemeService())); - store.add(registerChatInputReferenceDecorationType(codeEditorService)); - const editor = store.add(createTestCodeEditor(textModel, { - serviceCollection: new ServiceCollection([ICodeEditorService, codeEditorService]), - })); - const attachments = store.add(new TestAttachments()); - const dynamicVariableModel = store.add(new ChatDynamicVariableModel({ - inputEditor: editor, - get attachments() { return attachments.attachments; }, - onDidChangeActiveInputEditor: Event.None, - onDidChangeAttachments: attachments.onDidChangeContext, - refreshParsedInput: () => { }, - }, new class extends mock() { - override getUriLabel(resource: URI): string { return resource.toString(); } - })); - const target = new NewChatInputPasteTarget( - editor, - attachments, - { acceptCompletion: () => { }, forgetReference: () => { } }, - dynamicVariableModel, - () => undefined, - () => undefined, - uri, + /** + * Drives one long paste through the shared paste pipeline against the Agents + * composer and returns a snapshot after each stage of the edit's lifecycle. + * + * The paste edit is applied the way the bulk edit service applies it: the + * inserted text is one undo element and the attachment is a second, so undo + * runs the custom edit first and the text edit second, and redo the reverse. + */ + async function runPasteLifecycle(pastedText: string, act?: (attachments: INewChatAttachments) => void) { + const snapshots: { stage: string; value: string; attachments: string[]; code: string | undefined; sent: { name: string; text: string }[] }[] = []; + + const model = store.add(createTextModel('', null, undefined, URI.from({ scheme: Schemas.sessionsChatInput, path: 'input-test' }))); + const services = new ServiceCollection( + [ISessionContext, { _serviceBrand: undefined, session: observableValue('session', undefined) }], + [IChatSessionsService, new class extends mock() { }], ); - const pasteTargetService = new class extends mock() { - override getTarget(resource: URI): IChatPasteTarget | undefined { - return resource.toString() === uri.toString() ? target : undefined; + await withTestCodeEditor(model, { serviceCollection: services }, async (editor, _viewModel, instantiationService) => { + const local = new DisposableStore(); + try { + const attachments = local.add(new TestAttachments()); + const completionHandler = local.add(instantiationService.createInstance(AgentHostInputCompletionHandler, editor, attachments)); + const target = new NewChatInputPasteTarget( + editor, + attachments, + completionHandler, + () => undefined, + () => undefined, + model.uri, + ); + const pasteTargetService = new class extends mock() { + override getTarget(uri: URI): IChatPasteTarget | undefined { + return uri.toString() === model.uri.toString() ? target : undefined; + } + }; + const provider = new PasteTextProvider( + pasteTargetService, + new class extends mock() { }, + new class extends mock() { }, + new class extends mock() { + override getValue(): T { return pastedTextArtifactDefaultMinLength as T; } + }, + ); + + const transfer = new VSDataTransfer(); + transfer.append(Mimes.text, createStringDataTransferItem(pastedText)); + const session = await provider.provideDocumentPasteEdits( + model, [new Range(1, 1, 1, 1)], transfer, { triggerKind: DocumentPasteTriggerKind.Automatic }, CancellationToken.None); + const edit = session?.edits[0]; + const customEdit = edit?.additionalEdit?.edits[0] as ICustomEdit | undefined; + assert.ok(edit && customEdit, 'a long paste should produce an attachment edit'); + + const snapshot = (stage: string) => { + const attachment = attachments.attachments.at(0); + // What `_send` collects: the trimmed message plus the attachments + // resolved against it, so the range each one reports is the slice + // of the outgoing message its reference occupies. + const rawValue = model.getValue(); + const message = rawValue.trim(); + const sent = completionHandler.getAttachmentsForSend(message, rawValue.length - rawValue.trimStart().length); + snapshots.push({ + stage, + value: rawValue, + attachments: attachments.attachments.map(a => a.name), + code: attachment && isPastedTextArtifact(attachment) ? attachment.code : undefined, + sent: sent.map(entry => ({ + name: entry.name, + text: entry.range ? message.slice(entry.range.start, entry.range.endExclusive) : '', + })), + }); + }; + + editor.executeEdits('test.paste', [{ range: new Range(1, 1, 1, 1), text: edit.insertText as string }]); + model.pushStackElement(); + await customEdit.redo(); + snapshot('paste'); + + await customEdit.undo(); + model.undo(); + snapshot('undo'); + + model.redo(); + await customEdit.redo(); + snapshot('redo'); + + act?.(attachments); + snapshot('afterAct'); + } finally { + local.dispose(); } - }; - const provider = new PasteTextProvider( - pasteTargetService, - new class extends mock() { }, - new class extends mock() { }, - new class extends mock() { - override getValue(): T; - override getValue(section: string): T; - override getValue(overrides: IConfigurationOverrides): T; - override getValue(section: string, overrides: IConfigurationOverrides): T; - override getValue(sectionOrOverrides?: string | IConfigurationOverrides): T { - return (sectionOrOverrides === ChatConfiguration.PasteGitHubLinksAsReferences - ? true - : pastedTextArtifactDefaultMinLength) as T; - } - }, - ); - const issueUrl = 'https://github.com/microsoft/vscode/issues/334061#issuecomment-1'; - const transfer = new VSDataTransfer(); - transfer.append(Mimes.text, createStringDataTransferItem(issueUrl)); - const session = await provider.provideDocumentPasteEdits( - textModel, - [new Range(1, 1, 1, 1)], - transfer, - { triggerKind: DocumentPasteTriggerKind.Automatic }, - CancellationToken.None, - ); - const edit = session?.edits[0]; - assert.ok(edit); - editor.executeEdits('test.paste', [{ range: new Range(1, 1, 1, 1), text: edit.insertText as string }]); - await (edit.additionalEdit?.edits[0] as ICustomEdit).redo(); - - const longTextTransfer = new VSDataTransfer(); - longTextTransfer.append(Mimes.text, createStringDataTransferItem(`${'x'.repeat(1200)}\n`.repeat(10))); - const longTextSession = await provider.provideDocumentPasteEdits( - textModel, - [new Range(1, 1, 1, 1)], - longTextTransfer, - { triggerKind: DocumentPasteTriggerKind.Automatic }, - CancellationToken.None, - ); + }); + + return snapshots; + } + + test('keeps the attachment and its inline reference consistent across undo and redo', async () => { + const pastedText = `${'x'.repeat(1200)}\n`.repeat(10); + const snapshots = await runPasteLifecycle(pastedText); + + const attached = { attachments: ['Pasted text #1'], codeIsPreserved: true, sent: [{ name: 'Pasted text #1', text: '#attachment:Pasted text #1' }] }; + const detached = { attachments: [], codeIsPreserved: undefined, sent: [] }; + + assert.deepStrictEqual(snapshots.map(({ stage, value, attachments, code, sent }) => ({ + stage, + value, + attachments, + codeIsPreserved: code === undefined ? undefined : code === pastedText, + sent, + })), [ + { stage: 'paste', value: '#attachment:Pasted text #1 ', ...attached }, + { stage: 'undo', value: '', ...detached }, + { stage: 'redo', value: '#attachment:Pasted text #1 ', ...attached }, + { stage: 'afterAct', value: '#attachment:Pasted text #1 ', ...attached }, + ]); + }); + + test('removing the attachment takes its inline reference out of the input', async () => { + const pastedText = `${'x'.repeat(1200)}\n`.repeat(10); + const snapshots = await runPasteLifecycle(pastedText, attachments => { + attachments.removeAttachment(attachments.attachments[0].id); + }); - assert.deepStrictEqual({ - displayText: editor.getValue(), - promptText: dynamicVariableModel.getPromptText(editor.getValue().trim()), - attachments: attachments.attachments, - referenceCount: target.inlineReferences.length, - longTextSession, - }, { - displayText: 'microsoft/vscode#334061 ', - promptText: issueUrl, + assert.deepStrictEqual(snapshots.at(-1), { + stage: 'afterAct', + value: '', attachments: [], - referenceCount: 1, - longTextSession: undefined, + code: undefined, + sent: [], }); }); }); diff --git a/src/vs/sessions/contrib/chat/test/browser/newChatWidget.fixture.ts b/src/vs/sessions/contrib/chat/test/browser/newChatWidget.fixture.ts index a8a3794a8c2061..3407b211f6b8e7 100644 --- a/src/vs/sessions/contrib/chat/test/browser/newChatWidget.fixture.ts +++ b/src/vs/sessions/contrib/chat/test/browser/newChatWidget.fixture.ts @@ -8,6 +8,7 @@ import { assert } from '../../../../../base/common/assert.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { Event } from '../../../../../base/common/event.js'; import { MarkdownString } from '../../../../../base/common/htmlContent.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; import { constObservable, observableValue } from '../../../../../base/common/observable.js'; import { extUri } from '../../../../../base/common/resources.js'; import { mock } from '../../../../../base/test/common/mock.js'; @@ -28,6 +29,7 @@ import { ITtsPlaybackService } from '../../../../../workbench/contrib/chat/brows import { IVoiceSessionController } from '../../../../../workbench/contrib/chat/browser/voiceClient/voiceSessionController.js'; import { IChatWidgetService } from '../../../../../workbench/contrib/chat/browser/chat.js'; import { IVoiceInputModeService, VoiceInputMode } from '../../../../../workbench/contrib/chat/browser/voiceInputMode/voiceInputMode.js'; +import { ICustomizationMigrationAvailabilityService } from '../../../../../workbench/contrib/chat/browser/aiCustomization/customizationMigrationAvailabilityService.js'; import { IAICustomizationWorkspaceService } from '../../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; import { ICustomizationHarnessService } from '../../../../../workbench/contrib/chat/common/customizationHarnessService.js'; import { IChatRequestVariableEntry, toPasteVariableEntry } from '../../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; @@ -43,6 +45,7 @@ import { activeSessionViewBackground } from '../../../../common/theme.js'; import { Menus } from '../../../../browser/menus.js'; import { AgentHostFilterConnectionStatus, IAgentHostFilterService } from '../../../../services/agentHostFilter/common/agentHostFilter.js'; import { ISessionsChatBackgroundService } from '../../../../services/chatBackground/browser/chatBackgroundService.js'; +import { SessionsChatBackgroundRenderer } from '../../../../services/chatBackground/browser/chatBackgroundRenderer.js'; import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; import { ISessionsRecentWorkspacesService } from '../../../../services/sessions/browser/sessionsRecentWorkspacesService.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; @@ -83,6 +86,8 @@ interface INewChatWidgetFixtureOptions { readonly withAutoModel?: boolean; readonly primaryToolbarWidth?: number; readonly phoneLayout?: boolean; + readonly migrationCount?: number; + readonly withChatBackground?: boolean; } class AutoModelFixtureMenuService extends FixtureMenuService { @@ -119,6 +124,24 @@ class AutoModelFixtureMenuService extends FixtureMenuService { } } +/** + * Wraps the composer in the `.part.sessionspart` host the Agents window uses and + * paints the real codicon wallpaper into it, so the fixture shows the composer + * the way it reads once a chat background is set. + */ +function createChatBackgroundPart(container: HTMLElement, disposableStore: DisposableStore): HTMLElement { + const part = dom.append(container, dom.$('.part.sessionspart')); + part.style.position = 'relative'; + part.style.width = '100%'; + part.style.height = '100%'; + // The part carries the opaque base, as it does in the Agents window, so the + // session view above it can stay transparent and let the wallpaper through. + part.style.backgroundColor = asCssVariable(activeSessionViewBackground); + const renderer = disposableStore.add(new SessionsChatBackgroundRenderer(part)); + renderer.setBackground({ kind: 'codicons' }); + return part; +} + /** * Renders the whole new-session composer (`NewChatView` → `NewChatWidget`) inside * a `.session-view` so the draft-comments banner sits above the input the way it @@ -148,6 +171,8 @@ async function renderNewChatWidget(context: ComponentFixtureContext, options: IN withAutoModel = false, primaryToolbarWidth, phoneLayout = false, + migrationCount = 0, + withChatBackground = false, } = options; const feedbackItems: readonly IAgentFeedback[] = Array.from({ length: commentCount }, (_, index) => ({ id: `feedback-${index}`, @@ -220,6 +245,8 @@ async function renderNewChatWidget(context: ComponentFixtureContext, options: IN override addRecentWorkspace(): void { } override removeRecentWorkspace(): void { } override clearCheckedWorkspace(): void { } + override isNoWorkspaceChecked(): boolean { return false; } + override checkNoWorkspace(): void { } }()); reg.defineInstance(IRemoteAgentHostService, new class extends mock() { }()); reg.defineInstance(IAgentHostFilterService, new class extends mock() { @@ -268,6 +295,10 @@ async function renderNewChatWidget(context: ComponentFixtureContext, options: IN override readonly onDidChangeSlashCommands = Event.None; override async getSlashCommands() { return []; } }()); + reg.defineInstance(ICustomizationMigrationAvailabilityService, { + _serviceBrand: undefined, + candidateCount: observableValue('customizationMigrationCount', migrationCount), + }); reg.defineInstance(INewChatVoiceTargetService, disposableStore.add(new NewChatVoiceTargetService( sessionsService, new class extends mock() { @@ -321,10 +352,12 @@ async function renderNewChatWidget(context: ComponentFixtureContext, options: IN container.classList.add('monaco-workbench', 'agent-sessions-workbench'); container.classList.toggle('phone-layout', phoneLayout); - const sessionView = dom.append(container, dom.$('.session-view.is-active')); + const sessionView = dom.append(withChatBackground ? createChatBackgroundPart(container, disposableStore) : container, dom.$('.session-view.is-active')); sessionView.style.width = '100%'; sessionView.style.height = '100%'; - sessionView.style.backgroundColor = asCssVariable(activeSessionViewBackground); + if (!withChatBackground) { + sessionView.style.backgroundColor = asCssVariable(activeSessionViewBackground); + } sessionView.style.setProperty('--session-view-background', asCssVariable(activeSessionViewBackground)); const sessionViewContent = dom.append(sessionView, dom.$('.session-view-content')); sessionViewContent.style.width = '100%'; @@ -398,8 +431,14 @@ async function renderNewChatWidget(context: ComponentFixtureContext, options: IN export default defineThemedFixtureGroup({ path: 'sessions/chat/newWidget/' }, { NewSessionDefault: defineComponentFixture({ - labels: { kind: 'screenshot' }, - render: context => renderNewChatWidget(context, { withWorkspace: true }), + labels: { kind: 'screenshot', blocksCi: true }, + expectedVisualDescriptions: ['The new-session composer shows Copilot, microsoft/vscode, and Issue/PR pills aligned to the left above the chat input. Customize is aligned separately to the right edge of the input, with a yellow migration indicator and no chevron.'], + render: context => renderNewChatWidget(context, { withWorkspace: true, migrationCount: 3 }), + }), + NewSessionChatBackground: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: true }, + expectedVisualDescriptions: ['The new-session composer sits directly on the codicon wallpaper with no card behind it. The workspace pills, the input area and the bottom-row controls each carry their own opaque surface and a thin border, and the wallpaper shows through the gaps between them.'], + render: context => renderNewChatWidget(context, { withWorkspace: true, withAutoModel: true, withChatBackground: true }), }), NewSessionAutoModel: defineComponentFixture({ labels: { kind: 'screenshot', blocksCi: true }, @@ -413,17 +452,17 @@ export default defineThemedFixtureGroup({ path: 'sessions/chat/newWidget/' }, { }), NewSessionWorkspacePicker: defineComponentFixture({ labels: { kind: 'screenshot', blocksCi: true }, - expectedVisualDescriptions: ['The new-session composer shows Copilot, microsoft/vscode, and Issue/PR pills. The microsoft/vscode workspace pill has the active treatment after opening the workspace picker. Pill and dropdown labels use the same body text size, and their leading icons use the same base icon size.'], + expectedVisualDescriptions: ['The new-session composer shows Copilot, microsoft/vscode, and Issue/PR pills aligned to the left, with Customize aligned to the right edge of the input. The microsoft/vscode workspace pill has the active treatment after opening the workspace picker. Pill and dropdown labels use the same body text size, and their leading icons use the same base icon size.'], render: context => renderNewChatWidget(context, { withWorkspace: true, openWorkspacePicker: true }), }), NewSessionGitHubContextPicker: defineComponentFixture({ labels: { kind: 'screenshot', blocksCi: true }, - expectedVisualDescriptions: ['The new-session composer shows Copilot, microsoft/vscode, and Issue/PR pills. The Issue/PR pill has the active treatment after opening its picker.'], + expectedVisualDescriptions: ['The new-session composer shows Copilot, microsoft/vscode, and Issue/PR pills aligned to the left, with Customize aligned to the right edge of the input. The Issue/PR pill has the active treatment after opening its picker.'], render: context => renderNewChatWidget(context, { withWorkspace: true, openGitHubContextPicker: true }), }), NewSessionAttachedContext: defineComponentFixture({ labels: { kind: 'screenshot', blocksCi: true }, - expectedVisualDescriptions: ['The new-session workspace row shows Copilot, microsoft/vscode with a count badge showing 2, and Issue/PR with a count badge showing 1. The composer attachment row shows removable docs, microsoft/typescript, and microsoft/vscode#333053 context pills with compact dismiss icons. The input expands upward for the attachment row while its bottom controls remain aligned with the default new-session composer. The folder icon is fully visible without cropping, and the GitHub issue pill includes an issue icon.'], + expectedVisualDescriptions: ['The new-session workspace row shows Copilot, microsoft/vscode with a count badge showing 2, and Issue/PR with a count badge showing 1 aligned to the left, with Customize aligned to the right edge of the input. The composer attachment row shows removable docs, microsoft/typescript, and microsoft/vscode#333053 context pills with compact dismiss icons. The input expands upward for the attachment row while its bottom controls remain aligned with the default new-session composer. The folder icon is fully visible without cropping, and the GitHub issue pill includes an issue icon.'], render: context => renderNewChatWidget(context, { withWorkspace: true, withAttachedContext: true }), }), NewSessionPhoneAttachedContext: defineComponentFixture({ @@ -433,7 +472,7 @@ export default defineThemedFixtureGroup({ path: 'sessions/chat/newWidget/' }, { }), NewSessionRemoteWorkspace: defineComponentFixture({ labels: { kind: 'screenshot', blocksCi: true }, - expectedVisualDescriptions: ['The new-session composer shows Copilot and devbox · microsoft/vscode pills. No Issue/PR pill is visible because the remote workspace has no associated GitHub repository metadata.'], + expectedVisualDescriptions: ['The new-session composer shows Copilot and devbox · microsoft/vscode pills aligned to the left, with Customize aligned to the right edge of the input. No Issue/PR pill is visible because the remote workspace has no associated GitHub repository metadata.'], render: context => renderNewChatWidget(context, { withRemoteWorkspace: true }), }), NewSessionNarrow: defineComponentFixture({ diff --git a/src/vs/sessions/contrib/chat/test/browser/newChatWidget.test.ts b/src/vs/sessions/contrib/chat/test/browser/newChatWidget.test.ts index af7c9b19282792..fcf3aed830c70a 100644 --- a/src/vs/sessions/contrib/chat/test/browser/newChatWidget.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/newChatWidget.test.ts @@ -15,6 +15,7 @@ import { URI } from '../../../../../base/common/uri.js'; import { upcastPartial } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { ISession, ISessionWorkspace } from '../../../../services/sessions/common/session.js'; +import { IActiveSession } from '../../../../services/sessions/common/sessionsManagement.js'; import { ISendRequestOptions } from '../../../../services/sessions/common/sessionsProvider.js'; import { IOpenNewSessionOptions, IOpenNewSessionResult } from '../../../../services/sessions/browser/sessionsService.js'; import { IPreferredSessionType } from '../../browser/sessionTypePicker.js'; @@ -24,6 +25,7 @@ import { Codicon } from '../../../../../base/common/codicons.js'; import { getAdditionalFolderContextId, getAdditionalRepositoryContextId } from '../../common/newChatContextIds.js'; import { LOCAL_AGENT_HOST_PROVIDER_ID } from '../../../../common/agentHostSessionsProvider.js'; import { IWorkspacePickerNoWorkspaceOption } from '../../browser/sessionWorkspacePicker.js'; +import { OPEN_CUSTOMIZATIONS_COMMAND_ID } from '../../../../common/customizations.js'; /** The part of the active session `_recreateOnProviderChange` actually reads. */ interface IActiveDraft { @@ -141,26 +143,26 @@ interface IRenderSessionTypePickerHarness { interface IRenderWorkspacePickerHarness extends IRenderSessionTypePickerHarness { readonly _workspacePickerVisibleKey: { set(value: boolean): void }; readonly _workspacePicker: { - renderCategoryTriggers(container: HTMLElement, triggers: readonly { readonly label?: string; readonly tooltip?: string; readonly icon?: { readonly id: string }; readonly attachesContext?: boolean }[]): HTMLElement; + renderCategoryTriggers(container: HTMLElement, triggers: readonly { readonly label?: string; readonly tooltip?: string; readonly icon?: { readonly id: string }; readonly attachesContext?: boolean; readonly reflectsWorkspace?: boolean }[]): HTMLElement; }; _renderSessionTypePicker(container: HTMLElement, isQuickChat: boolean): void; + _renderCustomizeTrigger(container: HTMLElement): IDisposable; _workspacePickerRow: HTMLElement | undefined; } interface ISelectNoWorkspaceHarness { readonly _pendingPreferredUpgrade: MutableDisposable; readonly _newSessionCreation: MutableDisposable; - readonly _isWorkspacePickerQuickChat: IObservable; - readonly _workspacePickerQuickChatSessionId: ReturnType>; + readonly _workspacePicker: { selectNoWorkspace(): void }; readonly sessionsService: { openQuickChat(): { readonly sessionId: string } }; - _openQuickChat(options?: undefined, keepWorkspacePickerVisible?: boolean): { readonly sessionId: string } | undefined; + _openQuickChat(options?: undefined): { readonly sessionId: string } | undefined; } interface INoWorkspaceOptionHarness { readonly _useConsolidatedRemoteWorkspaces: IObservable; readonly _isWorkspacePickerQuickChat: IObservable; readonly sessionsManagementService: { isQuickChatTargetAvailable(): boolean }; - _selectNoWorkspace(): void; + selectNoWorkspace(): void; } interface IWorkspaceRootsHarness { @@ -168,12 +170,28 @@ interface IWorkspaceRootsHarness { readonly _workspacePicker: { readonly selectedFolderUri: URI | undefined }; } +interface IRestoreNoWorkspaceDraftHarness { + readonly _session: IObservable; + readonly _workspacePicker: { isNoWorkspaceSelected(): boolean }; + readonly sessionsManagementService: { isQuickChatTargetAvailable(): boolean }; + selectNoWorkspace(): void; +} + +interface IRenderCustomizeTriggerHarness { + readonly _useCustomizationEntryPoints: IObservable; + readonly customizationMigrationAvailabilityService: { readonly candidateCount: IObservable }; + readonly commandService: { executeCommand(commandId: string): Promise }; + readonly hoverService: { setupDelayedHover(): IDisposable }; +} + const renderWorkspacePicker = Reflect.get(NewChatWidget.prototype, '_renderWorkspacePicker') as (this: IRenderWorkspacePickerHarness, container: HTMLElement) => IDisposable; const renderSessionTypePicker = Reflect.get(NewChatWidget.prototype, '_renderSessionTypePicker') as (this: IRenderSessionTypePickerHarness, container: HTMLElement, isQuickChat: boolean) => void; -const selectNoWorkspace = Reflect.get(NewChatWidget.prototype, '_selectNoWorkspace') as (this: ISelectNoWorkspaceHarness) => void; +const selectNoWorkspace = NewChatWidget.prototype.selectNoWorkspace as (this: ISelectNoWorkspaceHarness) => void; const openQuickChat = Reflect.get(NewChatWidget.prototype, '_openQuickChat') as ISelectNoWorkspaceHarness['_openQuickChat']; const getNoWorkspaceOption = Reflect.get(NewChatWidget.prototype, '_getNoWorkspaceOption') as (this: INoWorkspaceOptionHarness) => IWorkspacePickerNoWorkspaceOption | undefined; const getWorkspaceRoots = Reflect.get(NewChatWidget.prototype, '_getWorkspaceRoots') as (this: IWorkspaceRootsHarness, session: ISession) => readonly URI[]; +const restoreNoWorkspaceDraft = Reflect.get(NewChatWidget.prototype, '_restoreNoWorkspaceDraft') as (this: IRestoreNoWorkspaceDraftHarness) => boolean; +const renderCustomizeTrigger = Reflect.get(NewChatWidget.prototype, '_renderCustomizeTrigger') as (this: IRenderCustomizeTriggerHarness, container: HTMLElement) => IDisposable; function createHarness( pendingPreferredUpgrade: MutableDisposable, @@ -205,10 +223,11 @@ function createHarness( suite('NewChatWidget', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); - test('workspace row hosts the workspace picker before the multiple-harness and context pickers', () => { + test('workspace row hosts the workspace picker before the harness and customize triggers', () => { const container = document.createElement('div'); const harnessLabels = ['Copilot', 'Claude']; const workspaceTriggers: { readonly tooltip: string | undefined; readonly icon: string | undefined; readonly attachesContext: boolean | undefined }[] = []; + let sessionTypePicker: HTMLElement | undefined; const harness: IRenderWorkspacePickerHarness = { _workspacePickerVisibleKey: { set: () => { } }, _workspacePicker: { @@ -218,6 +237,7 @@ suite('NewChatWidget', () => { for (const trigger of triggers) { const item = document.createElement('div'); item.textContent = trigger.label ?? 'More'; + item.classList.toggle('sessions-workspace-picker-trigger', trigger.reflectsWorkspace === true); row.appendChild(item); workspaceTriggers.push({ tooltip: trigger.tooltip, icon: trigger.icon?.id, attachesContext: trigger.attachesContext }); } @@ -230,18 +250,27 @@ suite('NewChatWidget', () => { if (harnessLabels.length <= 1) { return; } + sessionTypePicker?.remove(); const item = document.createElement('div'); item.className = options?.className ?? ''; item.textContent = harnessLabels[0]; target.appendChild(item); + sessionTypePicker = item; }, }, }, _renderSessionTypePicker: (target, isQuickChat) => renderSessionTypePicker.call(harness, target, isQuickChat), + _renderCustomizeTrigger: target => { + const item = document.createElement('div'); + item.textContent = 'Customize'; + target.appendChild(item); + return toDisposable(() => item.remove()); + }, _workspacePickerRow: undefined, }; disposables.add(renderWorkspacePicker.call(harness, container)); + harness._renderSessionTypePicker(harness._workspacePickerRow!, false); assert.deepStrictEqual( Array.from(harness._workspacePickerRow?.children ?? [], element => ({ @@ -249,8 +278,9 @@ suite('NewChatWidget', () => { className: element.className, })), [ - { label: 'Workspace', className: '' }, + { label: 'Workspace', className: 'sessions-workspace-picker-trigger' }, { label: 'Copilot', className: 'sessions-chat-session-type-picker sessions-workspace-category-picker-slot' }, + { label: 'Customize', className: '' }, ], ); assert.deepStrictEqual(workspaceTriggers, [ @@ -258,9 +288,76 @@ suite('NewChatWidget', () => { ]); }); + test('Customize trigger opens the overview and reflects migration availability', () => { + const enabled = observableValue('customizationEntryPointsEnabled', true); + const migrationCount = observableValue('migrationCount', 0); + const commands: string[] = []; + const harness: IRenderCustomizeTriggerHarness = { + _useCustomizationEntryPoints: enabled, + customizationMigrationAvailabilityService: { candidateCount: migrationCount }, + commandService: { + async executeCommand(commandId: string): Promise { + commands.push(commandId); + }, + }, + hoverService: { + setupDelayedHover: () => toDisposable(() => { }), + }, + }; + const container = document.createElement('div'); + disposables.add(renderCustomizeTrigger.call(harness, container)); + const trigger = container.querySelector('.sessions-customize-trigger'); + assert.ok(trigger); + const initial = { + ariaLabel: trigger.getAttribute('aria-label'), + hasMigrations: trigger.classList.contains('has-migrations'), + hasChevron: !!trigger.querySelector('.sessions-chat-dropdown-chevron'), + icon: trigger.querySelector('.codicon')?.classList.contains('codicon-tools'), + role: trigger.getAttribute('role'), + tabIndex: trigger.tabIndex, + rightAligned: trigger.parentElement?.classList.contains('sessions-customize-trigger-slot'), + }; + + enabled.set(false, undefined); + const disabledDisplay = trigger.parentElement?.style.display; + enabled.set(true, undefined); + migrationCount.set(2, undefined); + trigger.click(); + + assert.deepStrictEqual({ + initial, + updated: { + ariaLabel: trigger.getAttribute('aria-label'), + hasMigrations: trigger.classList.contains('has-migrations'), + commands, + disabledDisplay, + }, + }, { + initial: { + ariaLabel: 'Customize', + hasMigrations: false, + hasChevron: false, + icon: true, + role: 'button', + tabIndex: 0, + rightAligned: true, + }, + updated: { + ariaLabel: 'Customize, migrations available', + hasMigrations: true, + commands: [OPEN_CUSTOMIZATIONS_COMMAND_ID], + disabledDisplay: 'none', + }, + }); + }); + test('restores workspace, harness, context DOM and tab order after quick chat', () => { const workspaceRow = document.createElement('div'); const quickChatHeader = document.createElement('div'); + const customize = document.createElement('a'); + customize.tabIndex = 0; + customize.textContent = 'Customize'; + quickChatHeader.appendChild(customize); for (const label of ['Workspace', 'Issue/PR']) { const item = document.createElement('a'); item.tabIndex = 0; @@ -290,16 +387,19 @@ suite('NewChatWidget', () => { renderSessionTypePicker.call(harness, value ? quickChatHeader : workspaceRow, value); })); isQuickChat.set(true, undefined); + const quickChatOrder = Array.from(quickChatHeader.children, element => element.textContent); isQuickChat.set(false, undefined); assert.deepStrictEqual({ domOrder: Array.from(workspaceRow.children, element => element.textContent), tabOrder: Array.from(workspaceRow.querySelectorAll('[tabindex="0"]'), element => element.textContent), + quickChatOrder, quickChatHeader: Array.from(quickChatHeader.children, element => element.textContent), }, { domOrder: ['Workspace', 'Copilot', 'Issue/PR'], tabOrder: ['Workspace', 'Copilot', 'Issue/PR'], - quickChatHeader: [], + quickChatOrder: ['Copilot', 'Customize'], + quickChatHeader: ['Customize'], }); }); @@ -307,54 +407,62 @@ suite('NewChatWidget', () => { let pendingUpgradeDisposed = false; let sessionCreationDisposed = false; let quickChatOpenCount = 0; + let noWorkspaceSelectCount = 0; const pendingPreferredUpgrade = disposables.add(new MutableDisposable()); const newSessionCreation = disposables.add(new MutableDisposable()); - const workspacePickerQuickChatSessionId = observableValue('workspacePickerQuickChatSessionId', undefined); pendingPreferredUpgrade.value = toDisposable(() => pendingUpgradeDisposed = true); newSessionCreation.value = toDisposable(() => sessionCreationDisposed = true); const harness: ISelectNoWorkspaceHarness = { _pendingPreferredUpgrade: pendingPreferredUpgrade, _newSessionCreation: newSessionCreation, - _isWorkspacePickerQuickChat: constObservable(false), - _workspacePickerQuickChatSessionId: workspacePickerQuickChatSessionId, + _workspacePicker: { selectNoWorkspace: () => noWorkspaceSelectCount++ }, sessionsService: { openQuickChat: () => { quickChatOpenCount++; return { sessionId: 'quick-chat' }; }, }, - _openQuickChat: (options, keepWorkspacePickerVisible) => openQuickChat.call(harness, options, keepWorkspacePickerVisible), + _openQuickChat: options => openQuickChat.call(harness, options), }; selectNoWorkspace.call(harness); assert.deepStrictEqual({ pendingUpgradeDisposed, sessionCreationDisposed, + noWorkspaceSelectCount, quickChatOpenCount, - workspacePickerQuickChatSessionId: workspacePickerQuickChatSessionId.get(), }, { pendingUpgradeDisposed: true, sessionCreationDisposed: true, + noWorkspaceSelectCount: 1, quickChatOpenCount: 1, - workspacePickerQuickChatSessionId: 'quick-chat', }); }); - test('ordinary quick chats do not retain the workspace picker', () => { - const workspacePickerQuickChatSessionId = observableValue('workspacePickerQuickChatSessionId', 'previous-quick-chat'); - const harness: ISelectNoWorkspaceHarness = { - _pendingPreferredUpgrade: disposables.add(new MutableDisposable()), - _newSessionCreation: disposables.add(new MutableDisposable()), - _isWorkspacePickerQuickChat: constObservable(false), - _workspacePickerQuickChatSessionId: workspacePickerQuickChatSessionId, - sessionsService: { openQuickChat: () => ({ sessionId: 'ordinary-quick-chat' }) }, - _openQuickChat: (options, keepWorkspacePickerVisible) => openQuickChat.call(harness, options, keepWorkspacePickerVisible), + test('restores a pending No workspace selection when quick chats become available', () => { + let quickChatAvailable = false; + let selectNoWorkspaceCalls = 0; + const harness: IRestoreNoWorkspaceDraftHarness = { + _session: constObservable(undefined), + _workspacePicker: { isNoWorkspaceSelected: () => true }, + sessionsManagementService: { isQuickChatTargetAvailable: () => quickChatAvailable }, + selectNoWorkspace: () => selectNoWorkspaceCalls++, }; - openQuickChat.call(harness); + const pending = restoreNoWorkspaceDraft.call(harness); + quickChatAvailable = true; + const restored = restoreNoWorkspaceDraft.call(harness); - assert.strictEqual(workspacePickerQuickChatSessionId.get(), undefined); + assert.deepStrictEqual({ + pending, + restored, + selectNoWorkspaceCalls, + }, { + pending: true, + restored: true, + selectNoWorkspaceCalls: 1, + }); }); test('offers No workspace only when enabled and quick chats are available', () => { @@ -370,7 +478,7 @@ suite('NewChatWidget', () => { _useConsolidatedRemoteWorkspaces: constObservable(testCase.enabled), _isWorkspacePickerQuickChat: constObservable(testCase.isWorkspacePickerQuickChat), sessionsManagementService: { isQuickChatTargetAvailable: () => testCase.available }, - _selectNoWorkspace: () => { }, + selectNoWorkspace: () => { }, }); return option && { description: option.description, isSelected: option.isSelected }; }); diff --git a/src/vs/sessions/contrib/chat/test/browser/newSessionPromptOptions.test.ts b/src/vs/sessions/contrib/chat/test/browser/newSessionPromptOptions.test.ts index 7b159838fd275b..a3809d93d712da 100644 --- a/src/vs/sessions/contrib/chat/test/browser/newSessionPromptOptions.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/newSessionPromptOptions.test.ts @@ -13,12 +13,14 @@ import { mock } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; import { NewChatInputWidget } from '../../browser/newChatInput.js'; -import { INewSessionPromptOption, INewSessionPromptOptionsController, NewSessionPromptOptionsState } from '../../browser/newSessionComposerService.js'; +import { INewSessionPromptOption, INewSessionPromptOptionsController, NewSessionPromptOptionsProgress, NewSessionPromptOptionsState } from '../../browser/newSessionComposerService.js'; import { NewSessionPromptOptionsWidget } from '../../browser/newSessionPromptOptions.js'; interface IPromptOptionsRefreshHarness { readonly _promptOptionsRefresh: MutableDisposable; readonly _promptOptionsController: INewSessionPromptOptionsController; + _promptOptionsSelected: boolean; + readonly _promptOptionsWidget: { readonly value: { hasFocusedOption(): boolean } | undefined }; preparePromptOptionsRefresh(): boolean; showPromptOptions(state: NewSessionPromptOptionsState | undefined): boolean; } @@ -234,6 +236,8 @@ suite('NewSessionPromptOptionsWidget', () => { const refresh = disposables.add(new MutableDisposable()); const harness: IPromptOptionsRefreshHarness = { _promptOptionsRefresh: refresh, + _promptOptionsSelected: false, + _promptOptionsWidget: { value: undefined }, _promptOptionsController: { resolve: token => { tokens.push(token); @@ -272,6 +276,79 @@ suite('NewSessionPromptOptionsWidget', () => { }); }); + test('starts selectOption before reporting the selection so consumers can guard the in-flight window', async () => { + const container = document.createElement('div'); + const events: string[] = []; + const inserting = new DeferredPromise(); + const widget = disposables.add(new NewSessionPromptOptionsWidget(container, { + selectOption: async () => { + events.push('selectOption'); + return inserting.p; + }, + onDidSelectOption: () => events.push('onDidSelectOption'), + onDidClose: () => undefined, + }, new TestHoverService())); + + widget.setState({ kind: 'resolved', options: [option('feature', 'Implement a feature')] }); + widget.element.querySelector('.monaco-button.new-session-prompt-option')?.click(); + await timeout(0); + const duringInsertion = [...events]; + inserting.complete(true); + await timeout(0); + + assert.deepStrictEqual({ duringInsertion, afterInsertion: events }, { + duringInsertion: ['selectOption'], + afterInsertion: ['selectOption', 'onDidSelectOption'], + }); + }); + + test('applies streamed prompt options until the user acts on them', async () => { + const result = new DeferredPromise(); + const states: NewSessionPromptOptionsState[] = []; + const refresh = disposables.add(new MutableDisposable()); + let reportProgress: NewSessionPromptOptionsProgress | undefined; + const harness: IPromptOptionsRefreshHarness = { + _promptOptionsRefresh: refresh, + _promptOptionsSelected: false, + _promptOptionsWidget: { value: undefined }, + _promptOptionsController: { + resolve: (_token, progress) => { + reportProgress = progress; + return result.p; + }, + onDidSelectOption: () => undefined, + onDidClose: () => undefined, + }, + preparePromptOptionsRefresh: () => { + states.push({ kind: 'loading' }); + return true; + }, + showPromptOptions: state => { + if (state) { + states.push(state); + } + return true; + }, + }; + + const refreshing = refreshPromptOptions.call(harness); + const applied = [ + reportProgress?.({ kind: 'resolved', options: [option('feature', 'Implement a feature')] }), + (harness._promptOptionsSelected = true, reportProgress?.({ kind: 'resolved', options: [option('bug', 'Fix a bug')] })), + ]; + result.complete({ kind: 'resolved', options: [option('ci', 'Fix CI')] }); + + assert.deepStrictEqual({ + shown: await refreshing, + applied, + states: states.map(state => state.kind === 'loading' ? 'loading' : state.options[0].id), + }, { + shown: true, + applied: [true, false], + states: ['loading', 'feature'], + }); + }); + test('replaces a generated prompt immediately', () => { let value = 'old prompt'; let placeholder: string | undefined; @@ -312,6 +389,8 @@ suite('NewSessionPromptOptionsWidget', () => { const refresh = disposables.add(new MutableDisposable()); const harness: IPromptOptionsRefreshHarness = { _promptOptionsRefresh: refresh, + _promptOptionsSelected: false, + _promptOptionsWidget: { value: undefined }, _promptOptionsController: { resolve: () => result.p, onDidSelectOption: () => undefined, @@ -346,6 +425,8 @@ suite('NewSessionPromptOptionsWidget', () => { let resolveCount = 0; const harness: IPromptOptionsRefreshHarness = { _promptOptionsRefresh: disposables.add(new MutableDisposable()), + _promptOptionsSelected: false, + _promptOptionsWidget: { value: undefined }, _promptOptionsController: { resolve: async () => { resolveCount++; diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionTurnChanges.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionTurnChanges.test.ts index 0655251be01a89..bc0d07f682b84a 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionTurnChanges.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionTurnChanges.test.ts @@ -8,6 +8,7 @@ import { autorun, constObservable, IObservable, observableValue, transaction } f import { URI } from '../../../../../base/common/uri.js'; import { mock, upcastPartial } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { AUTHORITATIVE_EMPTY_CHAT_RESPONSE_FILE_CHANGES } from '../../../../../workbench/contrib/chat/browser/chatResponseFileChangesService.js'; import { isIChatSessionFileChange2 } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { IEditorService } from '../../../../../workbench/services/editor/common/editorService.js'; import { IAgentWorkbenchLayoutService } from '../../../../browser/workbench.js'; @@ -404,6 +405,52 @@ suite('SessionTurnChanges', () => { assert.strictEqual(openCount, 1); }); + test('keeps an Agent Merge notice empty instead of using the previous turn changeset stats', () => { + const chatResource = URI.parse('chat:session'); + const chat = upcastPartial({ + resource: chatResource, + updatedAt: constObservable(new Date('2026-08-13T10:00:00Z')), + }); + const session = upcastPartial({ + resource: URI.parse('agent-host:session'), + chats: constObservable([chat]), + mainChat: constObservable(chat), + }); + const changeset = upcastPartial({ + id: TURN_CHANGES_CHANGESET_ID, + isEnabled: constObservable(true), + isLoadingChanges: constObservable(false), + changes: constObservable(Array.from({ length: 10 }, (_, index) => ({ + uri: URI.file(`/workspace/file-${index}.ts`), + modifiedUri: URI.file(`/workspace/file-${index}.ts`), + insertions: 1, + deletions: 0, + }))), + }); + const service = disposables.add(new SessionsChatResponseFileChangesService( + new class extends mock() { }(), + new class extends mock() { + override getSessionForChatResource() { + return { session, chat }; + } + }(), + new class extends mock() { }(), + new class extends mock() { }(), + new class extends mock() { }(), + new class extends mock() { + override readonly activeSessionResourceObs = constObservable(session.resource); + override readonly activeSessionChangesetsObs = constObservable([changeset]); + }(), + )); + disposables.add(service.registerProvider('chat', { + getChangesForRequest: () => constObservable(AUTHORITATIVE_EMPTY_CHAT_RESPONSE_FILE_CHANGES), + })); + + const stats = service.getChangeStatsForRequest(chatResource, 'agent-merge-notice', { isLastTurn: true }); + + assert.deepStrictEqual(stats?.get(), { files: 0, insertions: 0, deletions: 0 }); + }); + test('reads current-turn stats from the Changes view service', () => { const chatResource = URI.parse('chat:session'); const chat = upcastPartial({ diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts index c682f75aec1612..9773798cdd3160 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { SubmenuAction } from '../../../../../base/common/actions.js'; import { DeferredPromise, timeout } from '../../../../../base/common/async.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; @@ -34,7 +35,8 @@ import { ISendRequestOptions, ISessionChangeEvent, ISessionsProvider } from '../ import { AgentHostFilterConnectionStatus, IAgentHostFilterEntry } from '../../../../services/agentHostFilter/common/agentHostFilter.js'; import { IAgentHostSessionsProvider } from '../../../../common/agentHostSessionsProvider.js'; import { ISession, ISessionWorkspace, ISessionWorkspaceBrowseAction, SessionStatus, SESSION_WORKSPACE_GROUP_GITHUB, SESSION_WORKSPACE_GROUP_LOCAL, SESSION_WORKSPACE_GROUP_REMOTE } from '../../../../services/sessions/common/session.js'; -import { AGENT_SESSIONS_CONSOLIDATED_REMOTE_WORKSPACES_SETTING, IWorkspacePickerItem, IWorkspacePickerOptions, WorkspacePicker } from '../../browser/sessionWorkspacePicker.js'; +import { IWorkspacePickerItem, IWorkspacePickerOptions, WorkspacePicker } from '../../browser/sessionWorkspacePicker.js'; +import { UNIFIED_WORKSPACE_PICKER_SETTING } from '../../common/constants.js'; import { WebWorkspacePicker } from '../../browser/webWorkspacePicker.js'; import { NewSessionWorkspacePreselectionSource } from '../../browser/newSessionComposerService.js'; import { ISessionsRecentWorkspacesService, SessionsRecentWorkspacesService } from '../../../../services/sessions/browser/sessionsRecentWorkspacesService.js'; @@ -462,21 +464,41 @@ suite('WorkspacePicker - Connection Status', () => { ], }); providersService.setProviders([tunnelWithOneSession, tunnelWithTwoSessions, tunnelWithoutActiveSessions, sshWithActiveSession, wslWithActiveSessions]); - const picker = createTestablePicker(disposables, providersService, true, { restoreFromSessions: false }); - picker.selectTab(SESSION_WORKSPACE_GROUP_REMOTE); + const tabbedPicker = createTestablePicker(disposables, providersService, true, { restoreFromSessions: false }); + tabbedPicker.selectTab(SESSION_WORKSPACE_GROUP_REMOTE); + const unifiedPicker = createTestablePicker(disposables, providersService, true, { restoreFromSessions: false }, undefined, undefined, true); + const getRemoteItems = (picker: TestablePicker) => picker.getItems() + .filter(item => item.label?.startsWith('Provider agenthost-')) + .map(item => ({ label: item.label, description: item.description, ariaLabel: item.item?.ariaLabel })); + const unifiedRemoteItem = unifiedPicker.getItems().find(item => item.label === 'Remote'); + const unifiedRemoteActions = unifiedRemoteItem?.submenuActions?.[0]; - assert.deepStrictEqual( - picker.getItems() - .filter(item => item.label?.startsWith('Provider agenthost-')) - .map(item => ({ label: item.label, description: item.description, ariaLabel: item.item?.ariaLabel })), - [ + assert.deepStrictEqual({ + tabbed: getRemoteItems(tabbedPicker), + unifiedTopLevel: getRemoteItems(unifiedPicker), + unifiedSubmenu: unifiedRemoteActions instanceof SubmenuAction + ? unifiedRemoteActions.actions.map(action => ({ + label: action.label, + icon: (action as { icon?: ThemeIcon }).icon?.id, + })) + : undefined, + }, { + tabbed: [ { label: 'Provider agenthost-tunnel-one', description: 'Online · 1 active session', ariaLabel: 'Provider agenthost-tunnel-one, Online · 1 active session' }, { label: 'Provider agenthost-tunnel-two', description: 'Online · 2 active sessions', ariaLabel: 'Provider agenthost-tunnel-two, Online · 2 active sessions' }, { label: 'Provider agenthost-tunnel-idle', description: 'Online', ariaLabel: 'Provider agenthost-tunnel-idle, Online' }, { label: 'Provider agenthost-ssh', description: 'Online · 1 active session', ariaLabel: 'Provider agenthost-ssh, Online · 1 active session' }, { label: 'Provider agenthost-wsl', description: 'Online · 2 active sessions', ariaLabel: 'Provider agenthost-wsl, Online · 2 active sessions' }, ], - ); + unifiedTopLevel: [], + unifiedSubmenu: [ + { label: 'Provider agenthost-tunnel-one', icon: Codicon.cloud.id }, + { label: 'Provider agenthost-tunnel-two', icon: Codicon.cloud.id }, + { label: 'Provider agenthost-tunnel-idle', icon: Codicon.cloud.id }, + { label: 'Provider agenthost-ssh', icon: Codicon.remote.id }, + { label: 'Provider agenthost-wsl', icon: Codicon.remote.id }, + ], + }); }); test('restore picks checked entry even when remote is disconnected (before grace period)', () => { @@ -829,6 +851,60 @@ suite('WorkspacePicker - Connection Status', () => { ); }); + test('collapses a worktree recent onto its existing parent repository', async () => { + const provider = createMockProvider('provider'); + providersService.setProviders([provider]); + const worktreeUri = URI.file('/code/vscode.worktrees/feature'); + const repositoryUri = URI.file('/code/vscode'); + const storage = disposables.add(new TestStorageService()); + seedStorage(storage, [ + { uri: worktreeUri, providerId: 'provider', checked: false }, + { uri: repositoryUri, providerId: 'provider', checked: false }, + ]); + const workspacesService = { + getRecentlyOpened: async () => ({ workspaces: [], files: [] }), + onDidChangeRecentlyOpened: Event.None, + } as unknown as IWorkspacesService; + const recentWorkspacesService = await createResolvedRecentWorkspacesService(disposables, storage, providersService, workspacesService); + + assert.deepStrictEqual({ + legacy: recentWorkspacesService.getRecentWorkspaces().map(recent => recent.workspace.uri.toString()), + unified: recentWorkspacesService.getRecentWorkspaces(true, true).map(recent => recent.workspace.uri.toString()), + }, { + legacy: [worktreeUri, repositoryUri].map(uri => uri.toString()), + unified: [repositoryUri.toString()], + }); + }); + + test('removing a collapsed repository recent also removes its worktree aliases', async () => { + const provider = createMockProvider('provider'); + providersService.setProviders([provider]); + const worktreeUri = URI.file('/code/vscode.worktrees/feature'); + const repositoryUri = URI.file('/code/vscode'); + const storage = disposables.add(new TestStorageService()); + seedStorage(storage, [ + { uri: worktreeUri, providerId: 'provider', checked: false }, + { uri: repositoryUri, providerId: 'provider', checked: false }, + ]); + const removed: string[] = []; + const workspacesService = { + getRecentlyOpened: async () => ({ workspaces: [], files: [] }), + onDidChangeRecentlyOpened: Event.None, + removeRecentlyOpened: (uris: URI[]) => removed.push(...uris.map(uri => uri.toString())), + } as unknown as IWorkspacesService; + const recentWorkspacesService = await createResolvedRecentWorkspacesService(disposables, storage, providersService, workspacesService); + + recentWorkspacesService.removeRecentWorkspace(repositoryUri, true); + + assert.deepStrictEqual({ + recents: recentWorkspacesService.getRecentWorkspaces(true, true).map(recent => recent.workspace.uri.toString()), + removed, + }, { + recents: [], + removed: [repositoryUri.toString()], + }); + }); + test('restore never preselects a worktree folder', async () => { const localProvider = createMockProvider('local-1'); providersService.setProviders([localProvider]); @@ -2379,6 +2455,7 @@ suite('WorkspacePicker - Category Triggers', () => { icon: Codicon.repo, providerId: 'default-copilot', attachesContext: false, + supportsContextAttachment: true, run: async () => { // Mirrors the real popup: the action widget hides while the // repository quick pick is open, resetting the direct filter. @@ -2979,6 +3056,10 @@ suite('AutomationsWorkspacePicker', () => { /** Minimal subclass that exposes the protected `_getAvailableTabs` for testing. */ class TestablePicker extends WorkspacePicker { + usesTabs(): boolean { + return this._showTabs(); + } + getAvailableTabs(): string[] { return this._getAvailableTabs().map(t => t.id); } @@ -3011,11 +3092,27 @@ class TestablePicker extends WorkspacePicker { return this._buildListOptions(this.getItems(), undefined).focusFilterOnOpen === true; } + filterPlaceholder(): string | undefined { + return this._buildListOptions(this.getItems(), undefined).filterPlaceholder; + } + async select(label: string): Promise { const entry = this.getItems().find(candidate => candidate.label === label); assert.ok(entry?.item, `Expected picker item '${label}'`); await this._dispatchPickerItem(entry.item); } + + async selectSubmenu(parentLabel: string, childLabel: string): Promise { + const parent = this.getItems().find(candidate => candidate.label === parentLabel); + const submenu = parent?.submenuActions?.[0]; + const child = submenu instanceof SubmenuAction + ? submenu.actions.find(candidate => candidate.label === childLabel) + : parent?.submenuActions?.find(candidate => candidate.label === childLabel); + assert.ok(parent?.item, `Expected picker item '${parentLabel}'`); + assert.ok(child, `Expected submenu item '${childLabel}'`); + await child.run(); + await this._dispatchPickerItem(parent.item); + } } function makeBrowseAction(providerId: string, group: string | undefined, label = 'browse'): ISessionWorkspaceBrowseAction { @@ -3050,7 +3147,7 @@ function createTestablePicker( instantiationService.stub(IOutputService, {}); instantiationService.stub(IConfigurationService, new TestConfigurationService({ [RemoteAgentHostsEnabledSettingId]: remoteAgentHostsEnabled, - [AGENT_SESSIONS_CONSOLIDATED_REMOTE_WORKSPACES_SETTING]: consolidatedRemoteWorkspaces, + [UNIFIED_WORKSPACE_PICKER_SETTING]: consolidatedRemoteWorkspaces, })); instantiationService.stub(ICommandService, commandService); instantiationService.stub(IFileDialogService, {}); @@ -3137,10 +3234,16 @@ suite('WorkspacePicker - Tab discovery', () => { createMockProvider('local', { browseActions: [makeBrowseAction('local', SESSION_WORKSPACE_GROUP_LOCAL)] }), ]); const picker = createTestablePicker(disposables, providersService); - assert.deepStrictEqual(picker.getAvailableTabs(), [SESSION_WORKSPACE_GROUP_LOCAL, 'Cloud', SESSION_WORKSPACE_GROUP_REMOTE]); + assert.deepStrictEqual({ + usesTabs: picker.usesTabs(), + tabs: picker.getAvailableTabs(), + }, { + usesTabs: true, + tabs: [SESSION_WORKSPACE_GROUP_LOCAL, 'Cloud', SESSION_WORKSPACE_GROUP_REMOTE], + }); }); - test('combines GitHub and Remote groups with search when enabled', () => { + test('shows local, GitHub, and Remote actions together with search when enabled', () => { providersService.setProviders([ createMockProvider('remote', { browseActions: [makeBrowseAction('remote', SESSION_WORKSPACE_GROUP_REMOTE, 'Select Remote...')] }), createMockProvider('github', { browseActions: [{ ...makeBrowseAction('github', SESSION_WORKSPACE_GROUP_GITHUB, 'Repository...'), attachesContext: false }] }), @@ -3149,22 +3252,92 @@ suite('WorkspacePicker - Tab discovery', () => { const picker = createTestablePicker(disposables, providersService, true, {}, undefined, undefined, true); picker.selectWorkspaceActions(); - picker.selectTab(SESSION_WORKSPACE_GROUP_REMOTE); assert.deepStrictEqual({ + usesTabs: picker.usesTabs(), tabs: picker.getAvailableTabs(), items: picker.getItemLabels(), + itemIcons: picker.getItems() + .filter(item => item.kind === ActionListItemKind.Action) + .map(item => item.group?.icon?.id), showsFilter: picker.showsFilter(), focusesFilter: picker.focusesFilter(), + filterPlaceholder: picker.filterPlaceholder(), }, { + usesTabs: false, tabs: [SESSION_WORKSPACE_GROUP_LOCAL, SESSION_WORKSPACE_GROUP_REMOTE], - items: ['Select Remote...', 'Repository...'], + items: ['Open Folder', 'Repository', 'Remote'], + itemIcons: ['folder', 'folder', 'remote'], showsFilter: true, focusesFilter: true, + filterPlaceholder: 'Search', }); }); - test('selects No workspace through the consolidated picker', async () => { + test('keeps unified action dispatch stable when GitHub actions are hidden', async () => { + const selectedActions: string[] = []; + const providers = [ + createMockProvider('remote', { + browseActions: [{ + ...makeBrowseAction('remote', SESSION_WORKSPACE_GROUP_REMOTE, 'Select Remote...'), + run: async () => { + selectedActions.push('remote'); + return undefined; + }, + }], + }), + { + ...createMockProvider('local', { + browseActions: [makeBrowseAction('local', SESSION_WORKSPACE_GROUP_GITHUB, 'Repository...')], + }), + supportsLocalWorkspaces: true, + }, + ]; + providersService.setProviders(providers); + const options: IWorkspacePickerOptions = { + getWorkspaceGroupAction: group => group === SESSION_WORKSPACE_GROUP_GITHUB ? { + label: 'Sign in to GitHub', + icon: Codicon.signIn, + commandId: AGENTIC_SIGN_IN_COMMAND_ID, + hideWorkspaceItems: true, + } : undefined, + }; + const picker = createTestablePicker(disposables, providersService, true, options, undefined, undefined, true); + + await picker.selectSubmenu('Remote', 'Select Remote'); + + assert.deepStrictEqual({ + items: picker.getItemLabels(), + selectedActions, + }, { + items: ['Sign in to GitHub', 'Open Folder', 'Remote'], + selectedActions: ['remote'], + }); + }); + + test('hides Remote actions in the unified picker when remote hosts are disabled', () => { + providersService.setProviders([ + createMockProvider('remote', { browseActions: [makeBrowseAction('remote', SESSION_WORKSPACE_GROUP_REMOTE, 'Select Remote...')] }), + { ...createMockProvider('local'), supportsLocalWorkspaces: true }, + ]); + const picker = createTestablePicker(disposables, providersService, false, {}, undefined, undefined, true); + + assert.deepStrictEqual(picker.getItemLabels(), ['Open Folder']); + }); + + test('does not offer Attach Folder in the consolidated execution workspace picker', () => { + providersService.setProviders([ + { ...createMockProvider('local'), supportsLocalWorkspaces: true }, + ]); + const picker = createTestablePicker(disposables, providersService, true, {}, undefined, undefined, true); + picker.setSelectedWorkspace(URI.file('/local/project'), { fireEvent: false, persist: false }); + + picker.selectWorkspaceActions(); + + assert.deepStrictEqual(picker.getItemLabels(), ['Open Folder']); + }); + + test('selects Start from Scratch through the consolidated picker', async () => { let noWorkspaceSelected = false; const picker = createTestablePicker(disposables, providersService, true, { getNoWorkspaceOption: () => ({ @@ -3185,11 +3358,12 @@ suite('WorkspacePicker - Tab discovery', () => { items: picker.getItems().filter(item => item.kind === ActionListItemKind.Action).map(item => ({ label: item.label, description: item.description, + icon: item.group?.icon?.id, checked: item.item?.checked, })), triggerLabel: container.querySelector('.sessions-chat-dropdown-label')?.textContent, }; - await picker.select('No workspace'); + await picker.select('Start from Scratch'); assert.deepStrictEqual({ before, @@ -3197,6 +3371,7 @@ suite('WorkspacePicker - Tab discovery', () => { items: picker.getItems().filter(item => item.kind === ActionListItemKind.Action).map(item => ({ label: item.label, description: item.description, + icon: item.group?.icon?.id, checked: item.item?.checked, })), triggerLabel: container.querySelector('.sessions-chat-dropdown-label')?.textContent, @@ -3205,24 +3380,60 @@ suite('WorkspacePicker - Tab discovery', () => { }, { before: { items: [{ - label: 'No workspace', - description: 'Start without a backing workspace', + label: 'Start from Scratch', + description: undefined, + icon: 'comment', checked: undefined, }], triggerLabel: 'Workspace', }, after: { items: [{ - label: 'No workspace', - description: 'Start without a backing workspace', + label: 'Start from Scratch', + description: undefined, + icon: 'comment', checked: true, }], - triggerLabel: 'No workspace', - triggerAriaLabel: 'Workspace: No workspace', + triggerLabel: 'Start from Scratch', + triggerAriaLabel: 'Workspace: Start from Scratch', }, }); }); + test('persists Start from Scratch as the checked selection until a workspace is selected', () => { + const storage = disposables.add(new TestStorageService()); + const localProvider = createMockProvider('local-1'); + providersService.setProviders([localProvider]); + let noWorkspaceAvailable = true; + const options: IWorkspacePickerOptions = { + getNoWorkspaceOption: () => noWorkspaceAvailable ? ({ + description: 'Start without a backing workspace', + isSelected: false, + select: () => { }, + }) : undefined, + }; + const firstPicker = createTestablePicker(disposables, providersService, true, options, undefined, storage, true); + firstPicker.selectNoWorkspace(); + const firstPickerNoWorkspace = firstPicker.isNoWorkspaceSelected(); + noWorkspaceAvailable = false; + const restoredPicker = createTestablePicker(disposables, providersService, true, options, undefined, storage, true); + const restoredNoWorkspace = restoredPicker.isNoWorkspaceSelected(); + + restoredPicker.setSelectedWorkspace(URI.file('/local/project'), { fireEvent: false }); + + assert.deepStrictEqual({ + firstPickerNoWorkspace, + restoredNoWorkspace, + afterWorkspaceSelection: restoredPicker.isNoWorkspaceSelected(), + selectedFolder: restoredPicker.selectedFolderUri?.path, + }, { + firstPickerNoWorkspace: true, + restoredNoWorkspace: true, + afterWorkspaceSelection: false, + selectedFolder: '/local/project', + }); + }); + test('keeps GitHub context actions separate when groups are combined', () => { providersService.setProviders([ createMockProvider('github', { @@ -3240,7 +3451,7 @@ suite('WorkspacePicker - Tab discovery', () => { items: picker.getItemLabels(), showsFilter: picker.showsFilter(), }, { - items: ['Issue...'], + items: ['Issue'], showsFilter: false, }); }); @@ -3301,7 +3512,7 @@ suite('WorkspacePicker - Tab discovery', () => { test('filters context actions and allows repository attachment with a sole consolidated tab', () => { const baseProvider = createMockProvider('github', { browseActions: [ - { ...makeBrowseAction('github', SESSION_WORKSPACE_GROUP_GITHUB, 'Repository...'), attachesContext: false }, + { ...makeBrowseAction('github', SESSION_WORKSPACE_GROUP_GITHUB, 'Repository...'), attachesContext: false, supportsContextAttachment: true }, { ...makeBrowseAction('github', SESSION_WORKSPACE_GROUP_GITHUB, 'Issue...'), attachesContext: true }, ], }); @@ -3322,7 +3533,51 @@ suite('WorkspacePicker - Tab discovery', () => { items: picker.getItemLabels(), }, { tabs: [SESSION_WORKSPACE_GROUP_REMOTE], - items: ['Repository...', 'Attach Repository...'], + items: ['Repository', 'Attach Repository'], + }); + }); + + test('uses the attachable repository action for repository context', async () => { + const actionRuns: string[] = []; + const provider = createMockProvider('github', { + browseActions: [ + { + ...makeBrowseAction('github', SESSION_WORKSPACE_GROUP_GITHUB, 'Add GitHub Repository...'), + attachesContext: false, + run: async () => { + actionRuns.push('add'); + return { ...provider.resolveWorkspace(URI.file('/github/local'))!, group: SESSION_WORKSPACE_GROUP_LOCAL }; + }, + }, + { ...makeBrowseAction('github', SESSION_WORKSPACE_GROUP_GITHUB, 'Clone Repository...'), attachesContext: false }, + { + ...makeBrowseAction('github', SESSION_WORKSPACE_GROUP_GITHUB, 'Use Repository in Cloud...'), + attachesContext: false, + supportsContextAttachment: true, + run: async () => { + actionRuns.push('cloud'); + return { ...provider.resolveWorkspace(URI.file('/github/cloud'))!, group: SESSION_WORKSPACE_GROUP_GITHUB }; + }, + }, + ], + }); + providersService.setProviders([provider]); + const picker = createTestablePicker(disposables, providersService, true, {}, undefined, undefined, true); + picker.setSelectedWorkspace(URI.file('/microsoft/vscode'), { fireEvent: false, persist: false }); + + picker.selectWorkspaceActions(); + + const items = picker.getItemLabels(); + await picker.select('Attach Repository'); + + assert.deepStrictEqual({ items, actionRuns }, { + items: [ + 'Add GitHub Repository', + 'Clone Repository', + 'Use Repository in Cloud', + 'Attach Repository', + ], + actionRuns: ['cloud'], }); }); @@ -3331,14 +3586,15 @@ suite('WorkspacePicker - Tab discovery', () => { const remoteUri = URI.parse('vscode-remote://host/remote-project'); const gitHubUri = URI.parse('vscode-vfs://github/microsoft/vscode/HEAD'); seedStorage(storage, [ - { uri: remoteUri, providerId: 'remote', checked: false }, + { uri: remoteUri, providerId: 'agenthost-menu', checked: false }, { uri: gitHubUri, providerId: 'github', checked: false }, ]); - const remoteProvider = createMockProvider('remote', { - browseActions: [makeBrowseAction('remote', SESSION_WORKSPACE_GROUP_REMOTE, 'Select Remote...')], + const remoteProvider = createMockProvider('agenthost-menu', { + connectionStatus: observableValue('remoteStatus', RemoteAgentHostConnectionStatus.disconnected), + browseActions: [makeBrowseAction('agenthost-menu', SESSION_WORKSPACE_GROUP_REMOTE, 'Select Remote...')], }); const gitHubProvider = createMockProvider('github', { - browseActions: [{ ...makeBrowseAction('github', SESSION_WORKSPACE_GROUP_GITHUB, 'Repository...'), attachesContext: false }], + browseActions: [{ ...makeBrowseAction('github', SESSION_WORKSPACE_GROUP_GITHUB, 'Repository...'), attachesContext: false, supportsContextAttachment: true }], }); providersService.setProviders([ { @@ -3368,15 +3624,30 @@ suite('WorkspacePicker - Tab discovery', () => { picker.selectWorkspaceActions(); picker.selectTab(SESSION_WORKSPACE_GROUP_REMOTE); + const remoteItem = picker.getItems().find(item => item.label === 'Remote'); + const remoteActions = remoteItem?.submenuActions?.[0]; - assert.deepStrictEqual(picker.getItemLabels(), [ - 'remote-project', - 'microsoft/vscode/HEAD', - 'Sign in to GitHub', - 'Select Remote...', - 'Repository...', - 'Attach Repository...', - ]); + assert.deepStrictEqual({ + items: picker.getItemLabels(), + remoteItems: remoteActions instanceof SubmenuAction ? remoteActions.actions.map(action => ({ + label: action.label, + enabled: action.enabled, + removable: typeof (action as { onRemove?: () => void }).onRemove === 'function', + })) : undefined, + }, { + items: [ + 'microsoft/vscode/HEAD', + 'Sign in to GitHub', + 'Repository', + 'Attach Repository', + 'Remote', + ], + remoteItems: [ + { label: 'remote-project', enabled: false, removable: true }, + { label: 'Select Remote', enabled: false, removable: false }, + { label: 'Provider agenthost-menu', enabled: true, removable: false }, + ], + }); }); test('deduplicates groups contributed by multiple providers / actions', () => { @@ -3544,7 +3815,7 @@ suite('WorkspacePicker - Tab discovery', () => { seedStorage(storage, [{ uri: recentUri, providerId: 'p1', checked: false }]); const baseProvider = createMockProvider('p1', { browseActions: [ - { ...makeBrowseAction('p1', SESSION_WORKSPACE_GROUP_GITHUB, 'Repository...'), attachesContext: false }, + { ...makeBrowseAction('p1', SESSION_WORKSPACE_GROUP_GITHUB, 'Repository...'), attachesContext: false, supportsContextAttachment: true }, { ...makeBrowseAction('p1', SESSION_WORKSPACE_GROUP_GITHUB, 'Issue...'), attachesContext: true }, { ...makeBrowseAction('p1', SESSION_WORKSPACE_GROUP_GITHUB, 'Pull Request...'), attachesContext: true }, ], @@ -3592,7 +3863,7 @@ suite('WorkspacePicker - Tab discovery', () => { providersService.setProviders([{ ...createMockProvider('p1', { browseActions: [ - { ...makeBrowseAction('p1', SESSION_WORKSPACE_GROUP_GITHUB, 'Repository...'), attachesContext: false }, + { ...makeBrowseAction('p1', SESSION_WORKSPACE_GROUP_GITHUB, 'Repository...'), attachesContext: false, supportsContextAttachment: true }, { ...makeBrowseAction('p1', SESSION_WORKSPACE_GROUP_GITHUB, 'Issue...'), attachesContext: true }, ], }), diff --git a/src/vs/sessions/contrib/chat/test/browser/unifiedWorkspacePickerConfiguration.test.ts b/src/vs/sessions/contrib/chat/test/browser/unifiedWorkspacePickerConfiguration.test.ts new file mode 100644 index 00000000000000..ae9cbd04a73f12 --- /dev/null +++ b/src/vs/sessions/contrib/chat/test/browser/unifiedWorkspacePickerConfiguration.test.ts @@ -0,0 +1,36 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { LEGACY_UNIFIED_WORKSPACE_PICKER_SETTING, unifiedWorkspacePickerConfigurationMigration } from '../../browser/unifiedWorkspacePickerConfiguration.js'; +import { UNIFIED_WORKSPACE_PICKER_SETTING } from '../../common/constants.js'; + +suite('UnifiedWorkspacePickerConfiguration', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('migrates application values without overwriting the new setting', async () => { + const copiedValue = await unifiedWorkspacePickerConfigurationMigration.migrateFn(true, () => undefined); + const preservedValue = await unifiedWorkspacePickerConfigurationMigration.migrateFn(false, key => key === UNIFIED_WORKSPACE_PICKER_SETTING ? true : undefined); + + assert.deepStrictEqual({ + key: unifiedWorkspacePickerConfigurationMigration.key, + includeApplication: unifiedWorkspacePickerConfigurationMigration.includeApplication, + copiedValue, + preservedValue, + }, { + key: LEGACY_UNIFIED_WORKSPACE_PICKER_SETTING, + includeApplication: true, + copiedValue: [ + [LEGACY_UNIFIED_WORKSPACE_PICKER_SETTING, { value: undefined }], + [UNIFIED_WORKSPACE_PICKER_SETTING, { value: true }], + ], + preservedValue: [ + [LEGACY_UNIFIED_WORKSPACE_PICKER_SETTING, { value: undefined }], + ], + }); + }); +}); diff --git a/src/vs/sessions/contrib/editor/browser/diffEditor.sessions.contribution.ts b/src/vs/sessions/contrib/editor/browser/diffEditor.sessions.contribution.ts index 79c7e05be4ce2c..044697d9a8bfeb 100644 --- a/src/vs/sessions/contrib/editor/browser/diffEditor.sessions.contribution.ts +++ b/src/vs/sessions/contrib/editor/browser/diffEditor.sessions.contribution.ts @@ -8,6 +8,7 @@ import { isEqual } from '../../../../base/common/resources.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; import { autorun } from '../../../../base/common/observable.js'; import { isDiffEditor } from '../../../../editor/browser/editorBrowser.js'; +import { DiffEditorViewMode } from '../../../../editor/common/config/editorOptions.js'; import { ITextResourceConfigurationService } from '../../../../editor/common/services/textResourceConfiguration.js'; import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; @@ -15,6 +16,7 @@ import { DiffEditorCommandsService, IDiffEditorCommandsService } from '../../../ import { TextDiffEditor } from '../../../../workbench/browser/parts/editor/textDiffEditor.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js'; +import { MultiDiffEditor } from '../../../../workbench/contrib/multiDiffEditor/browser/multiDiffEditor.js'; import { SessionChangesEditor } from '../../changes/browser/sessionChangesEditor.js'; import { IDiffEditorOptionsService } from '../common/diffEditorOptionsService.js'; import { DiffEditorOptionsService } from './diffEditorOptionsService.js'; @@ -33,8 +35,12 @@ export class SessionsDiffEditorCommandsService extends DiffEditorCommandsService override async toggleRenderSideBySide(args: unknown[]): Promise { const resource = args[0] instanceof URI ? args[0] : undefined; - if (resource || !(this.editorService.activeEditorPane instanceof SessionChangesEditor)) { + if (resource || !(this.editorService.activeEditorPane instanceof SessionChangesEditor || this.editorService.activeEditorPane instanceof MultiDiffEditor)) { for (const pane of [this.editorService.activeEditorPane, ...this.editorService.visibleEditorPanes]) { + if (pane instanceof MultiDiffEditor) { + this.diffEditorOptionsService.toggleRenderSideBySide(); + return; + } if (!(pane instanceof TextDiffEditor)) { continue; } @@ -54,7 +60,7 @@ export class SessionsDiffEditorCommandsService extends DiffEditorCommandsService } } - if (this.editorService.activeEditorPane instanceof SessionChangesEditor) { + if (this.editorService.activeEditorPane instanceof SessionChangesEditor || this.editorService.activeEditorPane instanceof MultiDiffEditor) { this.diffEditorOptionsService.toggleRenderSideBySide(); return; } @@ -66,6 +72,38 @@ export class SessionsDiffEditorCommandsService extends DiffEditorCommandsService return super.toggleRenderSideBySide(args); } + + override async setViewMode(args: unknown[], mode: DiffEditorViewMode): Promise { + const resource = args[0] instanceof URI ? args[0] : undefined; + const activeEditorPane = this.editorService.activeEditorPane; + if (activeEditorPane instanceof SessionChangesEditor || activeEditorPane instanceof MultiDiffEditor) { + this.diffEditorOptionsService.setViewMode(mode); + if (mode === 'automatic') { + activeEditorPane.resetDiffEditorWidthBasedLayout(); + } + return; + } + + for (const pane of [this.editorService.activeEditorPane, ...this.editorService.visibleEditorPanes]) { + if (!(pane instanceof TextDiffEditor)) { + continue; + } + const control = pane.getControl(); + if (!isDiffEditor(control)) { + continue; + } + const modifiedResource = control.getModifiedEditor().getModel()?.uri; + if (!resource || modifiedResource && isEqual(resource, modifiedResource)) { + this.diffEditorOptionsService.setViewMode(mode); + if (mode === 'automatic') { + control.resetWidthBasedLayout(); + } + return; + } + } + + return super.setViewMode(args, mode); + } } export class SessionsDiffEditorLayoutContribution extends Disposable implements IWorkbenchContribution { @@ -80,19 +118,24 @@ export class SessionsDiffEditorLayoutContribution extends Disposable implements this._register(this.editorService.onDidActiveEditorChange(() => this.applyLayout())); this._register(this.editorService.onDidVisibleEditorsChange(() => this.applyLayout())); this._register(autorun(reader => { - this.diffEditorOptionsService.renderSideBySide.read(reader); + this.diffEditorOptionsService.viewMode.read(reader); this.applyLayout(); })); } private applyLayout(): void { - const renderSideBySide = this.diffEditorOptionsService.renderSideBySide.get(); + const viewMode = this.diffEditorOptionsService.viewMode.get(); for (const pane of new Set([this.editorService.activeEditorPane, ...this.editorService.visibleEditorPanes])) { if (pane instanceof TextDiffEditor) { const control = pane.getControl(); if (isDiffEditor(control)) { - control.updateOptions({ renderSideBySide, useInlineViewWhenSpaceIsLimited: true }); + control.updateOptions({ + renderSideBySide: viewMode !== 'inline', + useInlineViewWhenSpaceIsLimited: viewMode === 'automatic', + }); } + } else if (pane instanceof MultiDiffEditor) { + pane.setDiffEditorViewMode(viewMode); } } } diff --git a/src/vs/sessions/contrib/editor/browser/diffEditorOptionsService.ts b/src/vs/sessions/contrib/editor/browser/diffEditorOptionsService.ts index 024d24cd0b59b9..2826ed45c659cc 100644 --- a/src/vs/sessions/contrib/editor/browser/diffEditorOptionsService.ts +++ b/src/vs/sessions/contrib/editor/browser/diffEditorOptionsService.ts @@ -5,17 +5,20 @@ import { Disposable } from '../../../../base/common/lifecycle.js'; import { observableValue } from '../../../../base/common/observable.js'; +import { DiffEditorViewMode } from '../../../../editor/common/config/editorOptions.js'; import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { bindContextKey } from '../../../../platform/observable/common/platformObservableUtils.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; -import { IDiffEditorOptionsService, SessionsDiffRenderSideBySideContext } from '../common/diffEditorOptionsService.js'; +import { IDiffEditorOptionsService, SessionsDiffViewModeContext } from '../common/diffEditorOptionsService.js'; -const PREFERRED_RENDER_SIDE_BY_SIDE_STORAGE_KEY = 'sessions.diffEditor.renderSideBySide'; +const VIEW_MODE_STORAGE_KEY = 'sessions.diffEditor.viewMode'; +const LEGACY_RENDER_SIDE_BY_SIDE_STORAGE_KEY = 'sessions.diffEditor.renderSideBySide'; export class DiffEditorOptionsService extends Disposable implements IDiffEditorOptionsService { declare readonly _serviceBrand: undefined; + readonly viewMode; readonly renderSideBySide; constructor( @@ -23,13 +26,25 @@ export class DiffEditorOptionsService extends Disposable implements IDiffEditorO @IContextKeyService contextKeyService: IContextKeyService, ) { super(); - this.renderSideBySide = observableValue(this, storageService.getBoolean(PREFERRED_RENDER_SIDE_BY_SIDE_STORAGE_KEY, StorageScope.PROFILE, true)); - this._register(bindContextKey(SessionsDiffRenderSideBySideContext, contextKeyService, reader => this.renderSideBySide.read(reader))); + const storedViewMode = storageService.get(VIEW_MODE_STORAGE_KEY, StorageScope.PROFILE); + const legacyRenderSideBySide = storageService.getBoolean(LEGACY_RENDER_SIDE_BY_SIDE_STORAGE_KEY, StorageScope.PROFILE); + this.viewMode = observableValue(this, isDiffEditorViewMode(storedViewMode) + ? storedViewMode + : legacyRenderSideBySide === false ? 'inline' : 'automatic'); + this.renderSideBySide = this.viewMode.map(this, mode => mode !== 'inline'); + this._register(bindContextKey(SessionsDiffViewModeContext, contextKeyService, reader => this.viewMode.read(reader))); + } + + setViewMode(mode: DiffEditorViewMode): void { + this.viewMode.set(mode, undefined); + this.storageService.store(VIEW_MODE_STORAGE_KEY, mode, StorageScope.PROFILE, StorageTarget.USER); } toggleRenderSideBySide(): void { - const renderSideBySide = !this.renderSideBySide.get(); - this.renderSideBySide.set(renderSideBySide, undefined); - this.storageService.store(PREFERRED_RENDER_SIDE_BY_SIDE_STORAGE_KEY, renderSideBySide, StorageScope.PROFILE, StorageTarget.USER); + this.setViewMode(this.viewMode.get() === 'inline' ? 'automatic' : 'inline'); } } + +function isDiffEditorViewMode(value: string | undefined): value is DiffEditorViewMode { + return value === 'inline' || value === 'sideBySide' || value === 'automatic'; +} diff --git a/src/vs/sessions/contrib/editor/common/diffEditorOptionsService.ts b/src/vs/sessions/contrib/editor/common/diffEditorOptionsService.ts index 1f1c1120f7cde5..ed48da8f2f0f24 100644 --- a/src/vs/sessions/contrib/editor/common/diffEditorOptionsService.ts +++ b/src/vs/sessions/contrib/editor/common/diffEditorOptionsService.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { IObservable } from '../../../../base/common/observable.js'; +import { DiffEditorViewMode } from '../../../../editor/common/config/editorOptions.js'; import { localize } from '../../../../nls.js'; import { RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; @@ -12,8 +13,10 @@ export const IDiffEditorOptionsService = createDecorator; readonly renderSideBySide: IObservable; + setViewMode(mode: DiffEditorViewMode): void; toggleRenderSideBySide(): void; } -export const SessionsDiffRenderSideBySideContext = new RawContextKey('sessionsDiffRenderSideBySide', true, localize('sessionsDiffRenderSideBySide', "Whether Agents window diffs prefer side-by-side layout")); +export const SessionsDiffViewModeContext = new RawContextKey('sessionsDiffViewMode', 'automatic', localize('sessionsDiffViewMode', "The preferred layout mode for diffs in the Agents window")); diff --git a/src/vs/sessions/contrib/editor/test/browser/diffEditor.sessions.contribution.test.ts b/src/vs/sessions/contrib/editor/test/browser/diffEditor.sessions.contribution.test.ts index 526adffd295442..d7804583bd0a7d 100644 --- a/src/vs/sessions/contrib/editor/test/browser/diffEditor.sessions.contribution.test.ts +++ b/src/vs/sessions/contrib/editor/test/browser/diffEditor.sessions.contribution.test.ts @@ -16,7 +16,7 @@ import { IEditorPane, IVisibleEditorPane } from '../../../../../workbench/common import { SessionChangesEditor } from '../../../changes/browser/sessionChangesEditor.js'; import { SessionsDiffEditorCommandsService, SessionsDiffEditorLayoutContribution } from '../../browser/diffEditor.sessions.contribution.js'; import { TextDiffEditor } from '../../../../../workbench/browser/parts/editor/textDiffEditor.js'; -import { IDiffEditorOptions } from '../../../../../editor/common/config/editorOptions.js'; +import { DiffEditorViewMode, IDiffEditorOptions } from '../../../../../editor/common/config/editorOptions.js'; import { ICodeEditor, IDiffEditor } from '../../../../../editor/browser/editorBrowser.js'; import { EditorType } from '../../../../../editor/common/editorCommon.js'; import { IDiffEditorOptionsService } from '../../common/diffEditorOptionsService.js'; @@ -25,7 +25,7 @@ suite('SessionsDiffEditorCommandsService', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); - function createService(activeEditorPane: IEditorPane | undefined, visibleEditorPanes: readonly IVisibleEditorPane[] = []): { service: SessionsDiffEditorCommandsService; getToggleCount(): number } { + function createService(activeEditorPane: IEditorPane | undefined, visibleEditorPanes: readonly IVisibleEditorPane[] = []): { service: SessionsDiffEditorCommandsService; getToggleCount(): number; setModes: DiffEditorViewMode[] } { const editorService = new class extends mock() { override get activeEditorPane() { return activeEditorPane as IVisibleEditorPane | undefined; } override get activeEditor() { return undefined; } @@ -37,12 +37,14 @@ suite('SessionsDiffEditorCommandsService', () => { override getContextKeyValue(): T | undefined { return undefined; } }; let toggleCount = 0; + const setModes: DiffEditorViewMode[] = []; const diffEditorOptionsService = new class extends mock() { override toggleRenderSideBySide(): void { toggleCount++; } + override setViewMode(mode: DiffEditorViewMode): void { setModes.push(mode); } }; const service = new SessionsDiffEditorCommandsService(editorService, textResourceConfigurationService, contextKeyService, diffEditorOptionsService); - return { service, getToggleCount: () => toggleCount }; + return { service, getToggleCount: () => toggleCount, setModes }; } function createTextDiffEditor(resource: URI, renderSideBySide: boolean, controlUpdates: IDiffEditorOptions[]): TextDiffEditor { @@ -72,6 +74,15 @@ suite('SessionsDiffEditorCommandsService', () => { assert.strictEqual(getToggleCount(), 1); }); + test('sets an explicit shared view mode from the Changes editor', async () => { + const changesEditor = Object.create(SessionChangesEditor.prototype) as IEditorPane; + const { service, setModes } = createService(changesEditor); + + await service.setViewMode([], 'sideBySide'); + + assert.deepStrictEqual(setModes, ['sideBySide']); + }); + test('toggles the shared preference when a narrow single-file diff is effectively inline', async () => { const resource = URI.file('/workspace/file.ts'); const controlUpdates: IDiffEditorOptions[] = []; @@ -123,7 +134,7 @@ suite('SessionsDiffEditorCommandsService', () => { }); }); - test('applies the shared responsive preference to all visible text diffs', () => { + test('applies the shared view mode to all visible text diffs', () => { const activeControlUpdates: IDiffEditorOptions[] = []; const visibleControlUpdates: IDiffEditorOptions[] = []; const activeEditor = createTextDiffEditor(URI.file('/workspace/active.ts'), false, activeControlUpdates); @@ -134,13 +145,15 @@ suite('SessionsDiffEditorCommandsService', () => { override get activeEditorPane() { return activeEditor as IVisibleEditorPane; } override get visibleEditorPanes() { return [visibleEditor as IVisibleEditorPane]; } }; - const renderSideBySide = observableValue('test', true); + const viewMode = observableValue('test', 'automatic'); const diffEditorOptionsService = new class extends mock() { - override readonly renderSideBySide = renderSideBySide; + override readonly viewMode = viewMode; + override readonly renderSideBySide = viewMode.map(this, mode => mode !== 'inline'); }; disposables.add(new SessionsDiffEditorLayoutContribution(editorService, diffEditorOptionsService)); - renderSideBySide.set(false, undefined); + viewMode.set('sideBySide', undefined); + viewMode.set('inline', undefined); assert.deepStrictEqual({ activeControlUpdates, @@ -148,11 +161,13 @@ suite('SessionsDiffEditorCommandsService', () => { }, { activeControlUpdates: [ { renderSideBySide: true, useInlineViewWhenSpaceIsLimited: true }, - { renderSideBySide: false, useInlineViewWhenSpaceIsLimited: true }, + { renderSideBySide: true, useInlineViewWhenSpaceIsLimited: false }, + { renderSideBySide: false, useInlineViewWhenSpaceIsLimited: false }, ], visibleControlUpdates: [ { renderSideBySide: true, useInlineViewWhenSpaceIsLimited: true }, - { renderSideBySide: false, useInlineViewWhenSpaceIsLimited: true }, + { renderSideBySide: true, useInlineViewWhenSpaceIsLimited: false }, + { renderSideBySide: false, useInlineViewWhenSpaceIsLimited: false }, ], }); }); diff --git a/src/vs/sessions/contrib/editor/test/browser/diffEditorOptionsService.test.ts b/src/vs/sessions/contrib/editor/test/browser/diffEditorOptionsService.test.ts index 86db04f84c08c3..9067679d8a8cc6 100644 --- a/src/vs/sessions/contrib/editor/test/browser/diffEditorOptionsService.test.ts +++ b/src/vs/sessions/contrib/editor/test/browser/diffEditorOptionsService.test.ts @@ -6,40 +6,64 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { MockContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js'; -import { InMemoryStorageService, StorageScope } from '../../../../../platform/storage/common/storage.js'; -import { SessionsDiffRenderSideBySideContext } from '../../common/diffEditorOptionsService.js'; +import { InMemoryStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; +import { SessionsDiffViewModeContext } from '../../common/diffEditorOptionsService.js'; import { DiffEditorOptionsService } from '../../browser/diffEditorOptionsService.js'; suite('DiffEditorOptionsService', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); - test('defaults to responsive side by side and persists the shared preference', () => { + test('defaults to automatic and persists explicit modes', () => { const storageService = disposables.add(new InMemoryStorageService()); const contextKeyService = disposables.add(new MockContextKeyService()); const service = disposables.add(new DiffEditorOptionsService(storageService, contextKeyService)); const initial = { + viewMode: service.viewMode.get(), renderSideBySide: service.renderSideBySide.get(), - contextValue: contextKeyService.getContextKeyValue(SessionsDiffRenderSideBySideContext.key), - storedValue: storageService.getBoolean('sessions.diffEditor.renderSideBySide', StorageScope.PROFILE), + contextValue: contextKeyService.getContextKeyValue(SessionsDiffViewModeContext.key), + storedValue: storageService.get('sessions.diffEditor.viewMode', StorageScope.PROFILE), }; - service.toggleRenderSideBySide(); + service.setViewMode('sideBySide'); assert.deepStrictEqual({ initial, + viewMode: service.viewMode.get(), renderSideBySide: service.renderSideBySide.get(), - contextValue: contextKeyService.getContextKeyValue(SessionsDiffRenderSideBySideContext.key), - storedValue: storageService.getBoolean('sessions.diffEditor.renderSideBySide', StorageScope.PROFILE), + contextValue: contextKeyService.getContextKeyValue(SessionsDiffViewModeContext.key), + storedValue: storageService.get('sessions.diffEditor.viewMode', StorageScope.PROFILE), }, { initial: { + viewMode: 'automatic', renderSideBySide: true, - contextValue: true, + contextValue: 'automatic', storedValue: undefined, }, - renderSideBySide: false, - contextValue: false, - storedValue: false, + viewMode: 'sideBySide', + renderSideBySide: true, + contextValue: 'sideBySide', + storedValue: 'sideBySide', + }); + }); + + test('migrates the legacy inline preference and toggles back to automatic', () => { + const storageService = disposables.add(new InMemoryStorageService()); + storageService.store('sessions.diffEditor.renderSideBySide', false, StorageScope.PROFILE, StorageTarget.USER); + const contextKeyService = disposables.add(new MockContextKeyService()); + const service = disposables.add(new DiffEditorOptionsService(storageService, contextKeyService)); + + const migratedViewMode = service.viewMode.get(); + service.toggleRenderSideBySide(); + + assert.deepStrictEqual({ + migratedViewMode, + viewMode: service.viewMode.get(), + storedValue: storageService.get('sessions.diffEditor.viewMode', StorageScope.PROFILE), + }, { + migratedViewMode: 'inline', + viewMode: 'automatic', + storedValue: 'automatic', }); }); }); diff --git a/src/vs/sessions/contrib/layout/test/browser/layoutControllerTestUtils.ts b/src/vs/sessions/contrib/layout/test/browser/layoutControllerTestUtils.ts index 10ed3cf7cbcc19..c0f7a43c27c4cb 100644 --- a/src/vs/sessions/contrib/layout/test/browser/layoutControllerTestUtils.ts +++ b/src/vs/sessions/contrib/layout/test/browser/layoutControllerTestUtils.ts @@ -358,6 +358,8 @@ export function createTestHarness(store: DisposableStore, options: ICreateOption override readonly activeSessionChangesObs = constObservable([]); }, new class extends mock() { override registerDecorationsProvider() { return toDisposable(() => { }); } + }, new class extends mock() { + override readonly activeSession = constObservable(undefined); })), contextKeyService, }; diff --git a/src/vs/sessions/contrib/onboardingTours/browser/newSessionViewV3Prompt.ts b/src/vs/sessions/contrib/onboardingTours/browser/newSessionViewV3Prompt.ts index 51374b8bfcc7b2..c3ac9ac2dae7aa 100644 --- a/src/vs/sessions/contrib/onboardingTours/browser/newSessionViewV3Prompt.ts +++ b/src/vs/sessions/contrib/onboardingTours/browser/newSessionViewV3Prompt.ts @@ -10,6 +10,7 @@ import { CancellationError, isCancellationError } from '../../../../base/common/ import { DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { autorun } from '../../../../base/common/observable.js'; import { format } from '../../../../base/common/strings.js'; +import { generateUuid } from '../../../../base/common/uuid.js'; import { localize } from '../../../../nls.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IFileService } from '../../../../platform/files/common/files.js'; @@ -22,7 +23,7 @@ import { IWorkbenchAssignmentService } from '../../../../workbench/services/assi import { isAgentHostProviderId } from '../../../common/agentHostSessionsProvider.js'; import { IActiveSession } from '../../../services/sessions/common/sessionsManagement.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; -import { INewSessionComposer, INewSessionComposerService, INewSessionPromptOption, INewSessionPromptOptionsController, NEW_SESSION_PROMPT_TYPING_DURATION_MS, NewSessionPromptOptionsState } from '../../chat/browser/newSessionComposerService.js'; +import { INewSessionComposer, INewSessionComposerService, INewSessionPromptOption, INewSessionPromptOptionsController, NEW_SESSION_PROMPT_TYPING_DURATION_MS, NewSessionPromptOptionsProgress, NewSessionPromptOptionsState } from '../../chat/browser/newSessionComposerService.js'; import { getGitHubRepositoryFromUri } from '../../github/common/utils.js'; import { GitHubAuthenticationError } from '../../github/browser/githubApiClient.js'; import { IGitHubRecentIssue, IGitHubRecentPullRequest, IGitHubRecentUserWork } from '../../github/browser/fetchers/githubRecentUserWorkFetcher.js'; @@ -31,11 +32,18 @@ import { computeIssueIcon, computePullRequestIcon, GitHubIssueState, GitHubPullR import { resolveGitHubRepositoryFromGitConfig } from './gitHubRepositoryResolver.js'; import { NEW_SESSION_VIEW_V3_GITHUB_PROMPT_VARIATION, NEW_SESSION_VIEW_V3_OPTIONS_VARIATION, NEW_SESSION_VIEW_V3_PROMPT_VARIATION, NEW_SESSION_VIEW_V3_TOUR_ID, NEW_SESSION_VIEW_V3_VARIATION_TREATMENT } from './tours/newSessionViewV3Tour.js'; +/** + * Per-stage budgets are sized so the longest dependent chain of timed lookups fits inside `totalMs`: + * the issue and pull request chains run concurrently, so the worst case is + * max(issue 4_000 + 1_500 = 5_500, pull request 4_000 + 3_000 = 7_000) = 7_000ms, leaving ~3_000ms + * for repository resolution and rendering. Repository resolution has no budget of its own, and a + * workspace change restarts the whole chain, so both are bounded only by the `totalMs` race. + */ const DEFAULT_GITHUB_LOOKUP_TIMEOUTS = { totalMs: 10_000, - summaryMs: 5_000, - linkageMs: 2_500, - reviewMs: 4_000, + summaryMs: 4_000, + linkageMs: 1_500, + reviewMs: 3_000, }; const LOG_PREFIX = '[NewSessionViewV3Prompt]'; const PROMPT_TEMPLATE_TREATMENT = 'onb.newSessionViewV3.promptTemplate'; @@ -46,7 +54,25 @@ const PROMPT_OPTION_COUNT = 3; export type NewSessionViewV3ConfiguredVariation = 'prompt' | 'githubPrompt' | 'options' | 'unknown'; export type NewSessionViewV3EffectiveStrategy = 'prompt' | 'options' | 'githubMergeConflict' | 'githubCiFailure' | 'githubReviewComments' | 'githubIssue'; -export type NewSessionViewV3FallbackReason = 'none' | 'unsupportedVariation' | 'noRepository' | 'noAuthentication' | 'timeout' | 'requestFailed' | 'noCandidate'; +export type NewSessionViewV3FallbackReason = 'none' | 'unsupportedVariation' | 'noRepository' | 'noAuthentication' | 'timeout' | 'requestFailed' | 'noCandidate' | 'noComposer'; + +/** The dependent GitHub lookup stages that share the total lookup budget. */ +type NewSessionViewV3LookupStage = 'repository' | 'issueSummary' | 'issueLinkage' | 'prSummary' | 'prReview'; + +/** The privacy-safe outcome of a single GitHub lookup stage. */ +type NewSessionViewV3StageOutcome = 'notRun' | 'skipped' | 'success' | 'cancelled' | 'requestFailed' | 'timeout' | 'noAuthentication'; + +/** The stage reported as having consumed the budget when the total lookup times out. */ +type NewSessionViewV3TimedOutStage = NewSessionViewV3LookupStage | 'none' | 'unknown'; + +/** + * Stages ordered by how late they run in the lookup. When several stages are still in flight as the + * total budget expires, the last pending stage in this order is reported as the one that consumed it. + */ +const LOOKUP_STAGE_ORDER: readonly NewSessionViewV3LookupStage[] = ['repository', 'issueSummary', 'prSummary', 'issueLinkage', 'prReview']; + +/** Outcome severity, lowest first. Stages that run more than once report their most severe outcome. */ +const LOOKUP_STAGE_OUTCOME_SEVERITY: readonly NewSessionViewV3StageOutcome[] = ['notRun', 'skipped', 'success', 'cancelled', 'requestFailed', 'timeout', 'noAuthentication']; interface INewSessionViewV3PromptPlan { readonly prompt: string; @@ -62,9 +88,48 @@ interface INewSessionViewV3GitHubCandidate { readonly strategy: Exclude; } +/** + * Privacy-safe counts, durations and per-stage outcomes describing a single GitHub prompt-option + * lookup. Only bounded categories and measurements are captured; never issue, pull request or + * repository content. + */ +interface INewSessionViewV3LookupTelemetry { + readonly gitHubOptionCount: number; + readonly candidatesFound: number; + readonly lookupDurationMs: number; + readonly repositoryMs: number; + readonly issueSummaryMs: number; + readonly issueLinkageMs: number; + readonly prSummaryMs: number; + readonly prReviewMs: number; + readonly issueSummaryOutcome: NewSessionViewV3StageOutcome; + readonly issueLinkageOutcome: NewSessionViewV3StageOutcome; + readonly prSummaryOutcome: NewSessionViewV3StageOutcome; + readonly prReviewOutcome: NewSessionViewV3StageOutcome; + readonly timedOutStage: NewSessionViewV3TimedOutStage; +} + +/** Reported for variations that never run a GitHub prompt-option lookup. */ +const NO_LOOKUP_TELEMETRY: INewSessionViewV3LookupTelemetry = { + gitHubOptionCount: 0, + candidatesFound: 0, + lookupDurationMs: 0, + repositoryMs: 0, + issueSummaryMs: 0, + issueLinkageMs: 0, + prSummaryMs: 0, + prReviewMs: 0, + issueSummaryOutcome: 'notRun', + issueLinkageOutcome: 'notRun', + prSummaryOutcome: 'notRun', + prReviewOutcome: 'notRun', + timedOutStage: 'none', +}; + interface INewSessionViewV3PromptOptionsPlan { readonly options: readonly INewSessionPromptOption[]; readonly fallbackReason: NewSessionViewV3FallbackReason; + readonly lookup: INewSessionViewV3LookupTelemetry; } interface INewSessionViewV3RepositoryContext { @@ -123,9 +188,72 @@ interface IGitHubLookupTimeouts { readonly reviewMs: number; } +/** + * Collects per-stage timings and outcomes for one GitHub prompt-option lookup so the strategy + * telemetry can report where the shared budget went instead of a single collapsed fallback reason. + */ +class GitHubLookupDiagnostics { + private readonly _startTime = Date.now(); + private readonly _durations = new Map(); + private readonly _outcomes = new Map(); + private readonly _pending = new Map(); + private _timedOutStage: NewSessionViewV3TimedOutStage = 'none'; + + /** Marks a stage as in flight so it can be blamed if the total budget expires. */ + begin(stage: NewSessionViewV3LookupStage): void { + this._pending.set(stage, (this._pending.get(stage) ?? 0) + 1); + } + + /** Records the outcome of a stage, keeping the longest duration and most severe outcome. */ + record(stage: NewSessionViewV3LookupStage, outcome: NewSessionViewV3StageOutcome, durationMs: number): void { + const pending = (this._pending.get(stage) ?? 1) - 1; + if (pending > 0) { + this._pending.set(stage, pending); + } else { + this._pending.delete(stage); + } + this._durations.set(stage, Math.max(this._durations.get(stage) ?? 0, durationMs)); + const previous = this._outcomes.get(stage) ?? 'notRun'; + if (LOOKUP_STAGE_OUTCOME_SEVERITY.indexOf(outcome) > LOOKUP_STAGE_OUTCOME_SEVERITY.indexOf(previous)) { + this._outcomes.set(stage, outcome); + } else if (!this._outcomes.has(stage)) { + this._outcomes.set(stage, previous); + } + } + + /** Blames the stage that was still running when the total budget expired. */ + markTimedOut(): void { + this._timedOutStage = [...LOOKUP_STAGE_ORDER].reverse().find(stage => this._pending.has(stage)) ?? 'unknown'; + } + + summarize(candidatesFound: number, gitHubOptionCount: number): INewSessionViewV3LookupTelemetry { + return { + gitHubOptionCount, + candidatesFound, + lookupDurationMs: Date.now() - this._startTime, + repositoryMs: this._durations.get('repository') ?? 0, + issueSummaryMs: this._durations.get('issueSummary') ?? 0, + issueLinkageMs: this._durations.get('issueLinkage') ?? 0, + prSummaryMs: this._durations.get('prSummary') ?? 0, + prReviewMs: this._durations.get('prReview') ?? 0, + issueSummaryOutcome: this._outcomes.get('issueSummary') ?? 'notRun', + issueLinkageOutcome: this._outcomes.get('issueLinkage') ?? 'notRun', + prSummaryOutcome: this._outcomes.get('prSummary') ?? 'notRun', + prReviewOutcome: this._outcomes.get('prReview') ?? 'notRun', + timedOutStage: this._timedOutStage, + }; + } +} + export class NewSessionViewV3PromptRunner { private readonly _gitHubLookupTimeouts: IGitHubLookupTimeouts; + /** Joins the strategy and interaction events belonging to the same prompt impression. */ + private _impressionId = generateUuid(); + + /** The prompt options most recently handed to the composer, used for interaction telemetry. */ + private _renderedPromptOptions: readonly INewSessionPromptOption[] = []; + constructor( private readonly _assignmentService: IWorkbenchAssignmentService, private readonly _configurationService: IConfigurationService, @@ -143,6 +271,8 @@ export class NewSessionViewV3PromptRunner { async run(token: CancellationToken): Promise { this._logService.info(`${LOG_PREFIX} Starting V3 prompt resolution.`); + this._impressionId = generateUuid(); + this._renderedPromptOptions = []; const configuredVariation = await this._resolveConfiguredVariation(); if (token.isCancellationRequested) { this._logService.trace(`${LOG_PREFIX} Prompt resolution was cancelled after resolving the configured variation.`); @@ -207,13 +337,31 @@ export class NewSessionViewV3PromptRunner { const composer = this._getActiveComposer(); if (!composer) { this._logService.warn(`${LOG_PREFIX} Skipping prompt options because no active new-session composer is available.`); - this._reportStrategy(configuredVariation, 'options', 'noCandidate', false); + this._reportStrategy(configuredVariation, 'options', 'noComposer', false); return false; } let latestPlan: INewSessionViewV3PromptOptionsPlan | undefined; - const resolveOptions = async (refreshToken: CancellationToken): Promise => { - latestPlan = await this._resolveGitHubPromptOptionsWithFallback(refreshToken); + const resolveOptions = async (refreshToken: CancellationToken, progress?: NewSessionPromptOptionsProgress): Promise => { + // The composer resolves again on every refresh, for example when the selected workspace + // changes, so the rendered set must be reset per resolve rather than per run. + this._renderedPromptOptions = []; + const publishOptions = (options: readonly INewSessionPromptOption[]) => { + if (!this._hasRenderedPromptOptionsChanged(options)) { + return; + } + // Only record what the composer rendered: it refuses late updates while the user is + // already acting on the options on screen, and interaction telemetry must describe + // the options they actually saw. + if (progress?.({ kind: 'resolved', options }) !== false) { + this._renderedPromptOptions = options; + } + }; + // Paint the standard options before the GitHub lookup starts so the widget never waits on + // it, then stream personalized options into their slots as each lookup stage completes. + publishOptions(this._createPromptOptions([])); + latestPlan = await this._resolveGitHubPromptOptionsWithFallback(refreshToken, publishOptions); + publishOptions(latestPlan.options); return { kind: 'resolved', options: latestPlan.options }; }; if (composer.setPromptOptionsController && composer.refreshPromptOptions) { @@ -227,13 +375,13 @@ export class NewSessionViewV3PromptRunner { const shown = await composer.refreshPromptOptions(token); const fallbackReason = configuredFallbackReason ?? latestPlan?.fallbackReason ?? (token.isCancellationRequested ? 'requestFailed' : 'noCandidate'); this._logService.info(`${LOG_PREFIX} Prompt options completed with shown=${shown} and fallback reason '${fallbackReason}'.`); - this._reportStrategy(configuredVariation, 'options', fallbackReason, shown); + this._reportStrategy(configuredVariation, 'options', fallbackReason, shown, latestPlan?.lookup); return shown; } if (!composer.showPromptOptions({ kind: 'loading' })) { this._logService.warn(`${LOG_PREFIX} Skipping prompt options because the active new-session composer cannot show them.`); - this._reportStrategy(configuredVariation, 'options', 'noCandidate', false); + this._reportStrategy(configuredVariation, 'options', 'noComposer', false); return false; } this._logService.info(`${LOG_PREFIX} Showing prompt option loading skeletons.`); @@ -241,17 +389,26 @@ export class NewSessionViewV3PromptRunner { if (token.isCancellationRequested || this._newSessionComposerService.activeComposer.get() !== composer || this._sessionsService.activeSession.get()?.isCreated.get()) { composer.showPromptOptions(undefined); this._logService.trace(`${LOG_PREFIX} Prompt option resolution was cancelled or its composer is no longer active.`); - this._reportStrategy(configuredVariation, 'options', configuredFallbackReason ?? latestPlan?.fallbackReason ?? 'requestFailed', false); + this._reportStrategy(configuredVariation, 'options', configuredFallbackReason ?? latestPlan?.fallbackReason ?? 'requestFailed', false, latestPlan?.lookup); return false; } const shown = composer.showPromptOptions(state); const fallbackReason = configuredFallbackReason ?? latestPlan?.fallbackReason ?? 'noCandidate'; this._logService.info(`${LOG_PREFIX} Prompt options completed with shown=${shown} and fallback reason '${fallbackReason}'.`); - this._reportStrategy(configuredVariation, 'options', fallbackReason, shown); + this._reportStrategy(configuredVariation, 'options', fallbackReason, shown, latestPlan?.lookup); return shown; } + /** + * Whether the given prompt options differ from the ones last rendered, so unchanged refinements + * never re-render the widget. + */ + private _hasRenderedPromptOptionsChanged(options: readonly INewSessionPromptOption[]): boolean { + return this._renderedPromptOptions.length !== options.length + || options.some((option, index) => this._renderedPromptOptions[index]?.id !== option.id); + } + private _getActiveComposer(): INewSessionComposer | undefined { const activeSession = this._sessionsService.activeSession.get(); if (activeSession?.isCreated.get()) { @@ -260,23 +417,28 @@ export class NewSessionViewV3PromptRunner { return this._newSessionComposerService.activeComposer.get(); } - private async _resolveGitHubPromptOptionsWithFallback(token: CancellationToken): Promise { + private async _resolveGitHubPromptOptionsWithFallback(token: CancellationToken, reportOptions?: (options: readonly INewSessionPromptOption[]) => void): Promise { this._logService.info(`${LOG_PREFIX} Starting GitHub prompt option lookup with a ${this._gitHubLookupTimeouts.totalMs}ms total timeout.`); const operationCts = new CancellationTokenSource(token); + const diagnostics = new GitHubLookupDiagnostics(); let latestProgress: IGitHubPromptOptionsProgress | undefined; let timedOut = false; const createTimeoutPlan = () => { const candidates = latestProgress && this._isCurrentRepositoryContext(latestProgress.context) ? [...latestProgress.issueCandidates, ...latestProgress.pullRequestCandidates] : []; - return this._createPromptOptionsPlan(candidates.slice(0, PROMPT_OPTION_COUNT), candidates.length === PROMPT_OPTION_COUNT ? 'none' : 'timeout'); + return this._createPromptOptionsPlan(candidates, candidates.length === PROMPT_OPTION_COUNT ? 'none' : 'timeout', diagnostics); }; try { const result = await raceTimeout( - this._resolveGitHubPromptOptions(operationCts.token, progress => latestProgress = progress), + this._resolveGitHubPromptOptions(operationCts.token, diagnostics, progress => { + latestProgress = progress; + reportOptions?.(this._createPromptOptions([...progress.issueCandidates, ...progress.pullRequestCandidates])); + }), this._gitHubLookupTimeouts.totalMs, () => { timedOut = true; + diagnostics.markTimedOut(); this._logService.warn(`${LOG_PREFIX} GitHub prompt option lookup timed out after ${this._gitHubLookupTimeouts.totalMs}ms; filling with standard options.`); operationCts.cancel(); }, @@ -285,34 +447,37 @@ export class NewSessionViewV3PromptRunner { return createTimeoutPlan(); } if (result.kind === 'fallback') { - return this._createPromptOptionsPlan([], result.reason); + return this._createPromptOptionsPlan([], result.reason, diagnostics); } - const candidates = [...result.issueCandidates, ...result.pullRequestCandidates].slice(0, PROMPT_OPTION_COUNT); - const fallbackReason = candidates.length === PROMPT_OPTION_COUNT ? 'none' : getLookupFallbackReason(result.failures); - return this._createPromptOptionsPlan(candidates, fallbackReason); + const candidates = [...result.issueCandidates, ...result.pullRequestCandidates]; + const fallbackReason = candidates.slice(0, PROMPT_OPTION_COUNT).length === PROMPT_OPTION_COUNT ? 'none' : getLookupFallbackReason(result.failures); + return this._createPromptOptionsPlan(candidates, fallbackReason, diagnostics); } catch (error) { if (isCancellationError(error) && timedOut) { return createTimeoutPlan(); } if (isCancellationError(error) && token.isCancellationRequested) { this._logService.trace(`${LOG_PREFIX} GitHub prompt option lookup was cancelled by the onboarding flow.`); - return this._createPromptOptionsPlan([], 'requestFailed'); + return this._createPromptOptionsPlan([], 'requestFailed', diagnostics); } if (error instanceof GitHubAuthenticationError) { this._logService.warn(`${LOG_PREFIX} No existing GitHub authentication session is available; filling with standard options without requesting sign-in.`); - return this._createPromptOptionsPlan([], 'noAuthentication'); + return this._createPromptOptionsPlan([], 'noAuthentication', diagnostics); } this._logService.error(`${LOG_PREFIX} GitHub prompt option lookup failed; filling with standard options.`, error); - return this._createPromptOptionsPlan([], 'requestFailed'); + return this._createPromptOptionsPlan([], 'requestFailed', diagnostics); } finally { operationCts.dispose(); } } - private async _resolveGitHubPromptOptions(token: CancellationToken, reportProgress: (progress: IGitHubPromptOptionsProgress) => void): Promise { + private async _resolveGitHubPromptOptions(token: CancellationToken, diagnostics: GitHubLookupDiagnostics, reportProgress: (progress: IGitHubPromptOptionsProgress) => void): Promise { while (!token.isCancellationRequested) { + diagnostics.begin('repository'); + const repositoryStartTime = Date.now(); const context = await this._resolveGitHubRepository(token); + diagnostics.record('repository', context ? 'success' : 'skipped', Date.now() - repositoryStartTime); if (!context) { this._logService.warn(`${LOG_PREFIX} Could not resolve a GitHub repository for prompt options.`); return { kind: 'fallback', reason: 'noRepository' }; @@ -336,12 +501,15 @@ export class NewSessionViewV3PromptRunner { }; publishProgress(); const resolveIssues = async () => { - issueResult = await this._resolveIssuePromptOptionCandidates(owner, repo, lookupCts.token); + issueResult = await this._resolveIssuePromptOptionCandidates(owner, repo, lookupCts.token, diagnostics, candidates => { + issueResult = { candidates, failures: [] }; + publishProgress(); + }); publishProgress(); return issueResult; }; const resolvePullRequests = async () => { - pullRequestResult = await this._resolvePullRequestPromptOptionCandidates(owner, repo, lookupCts.token, candidates => { + pullRequestResult = await this._resolvePullRequestPromptOptionCandidates(owner, repo, lookupCts.token, diagnostics, candidates => { pullRequestResult = { candidates, failures: [] }; publishProgress(); }); @@ -369,22 +537,25 @@ export class NewSessionViewV3PromptRunner { return { kind: 'fallback', reason: 'noRepository' }; } - private async _resolveIssuePromptOptionCandidates(owner: string, repo: string, token: CancellationToken): Promise { - const outcome = await this._resolveIssueCandidates(owner, repo, token); + private async _resolveIssuePromptOptionCandidates( + owner: string, + repo: string, + token: CancellationToken, + diagnostics?: GitHubLookupDiagnostics, + reportCandidates: (candidates: readonly INewSessionViewV3GitHubCandidate[]) => void = () => undefined, + ): Promise { + const outcome = await this._resolveIssueCandidates(owner, repo, token, diagnostics, issues => reportCandidates(toIssuePromptOptionCandidates(issues))); if (outcome.kind === 'failure') { return { candidates: [], failures: [outcome.reason] }; } - const candidates = [...outcome.value] - .sort(compareUpdatedAtDescending) - .slice(0, 2) - .map(issue => ({ number: issue.number, title: issue.title, url: issue.url, strategy: 'githubIssue' as const })); - return { candidates, failures: [] }; + return { candidates: toIssuePromptOptionCandidates(outcome.value), failures: [] }; } private async _resolvePullRequestPromptOptionCandidates( owner: string, repo: string, token: CancellationToken, + diagnostics?: GitHubLookupDiagnostics, reportCandidates: (candidates: readonly INewSessionViewV3GitHubCandidate[]) => void = () => undefined, ): Promise { const summary = await this._runGitHubLookup( @@ -392,6 +563,8 @@ export class NewSessionViewV3PromptRunner { this._gitHubLookupTimeouts.summaryMs, token, lookupToken => this._gitHubService.getRecentAuthoredPullRequests(owner, repo, lookupToken), + 'prSummary', + diagnostics, ); if (summary.kind === 'failure') { return { candidates: [], failures: [summary.reason] }; @@ -413,7 +586,10 @@ export class NewSessionViewV3PromptRunner { reportCandidates(stableCandidates); } - const reviewLookup = await this._resolveReviewCandidates(owner, repo, reviewPullRequests, token); + // Review-thread enrichment is a post-render refinement: candidates whose position can no longer + // change were already published above, so this stage can only ever delay the `githubReviewComments` + // option, never the merge-conflict, CI or issue options that are already known. + const reviewLookup = await this._resolveReviewCandidates(owner, repo, reviewPullRequests, token, diagnostics); const candidates = getCandidatesInPullRequestOrder( pullRequests, [...directCandidates.map(entry => entry.candidate), ...reviewLookup.candidates], @@ -551,22 +727,37 @@ export class NewSessionViewV3PromptRunner { return { kind: 'fallback', reason: 'noRepository' }; } - private async _resolveIssueCandidates(owner: string, repo: string, token: CancellationToken): Promise> { + private async _resolveIssueCandidates( + owner: string, + repo: string, + token: CancellationToken, + diagnostics?: GitHubLookupDiagnostics, + reportIssues?: (issues: readonly IGitHubRecentIssue[]) => void, + ): Promise> { const issues = await this._runGitHubLookup( 'assigned issue summaries', this._gitHubLookupTimeouts.summaryMs, token, lookupToken => this._gitHubService.getRecentAssignedIssues(owner, repo, lookupToken), + 'issueSummary', + diagnostics, ); if (issues.kind === 'failure' || issues.value.length === 0) { + diagnostics?.record('issueLinkage', 'skipped', 0); return issues; } + // Publish the issues that are already in hand before refining them: linkage only removes issues + // that already have a linked pull request, so a slow linkage call must never hide them. + reportIssues?.(issues.value); + const linkedIssues = await this._runGitHubLookup( 'issue pull request linkage', this._gitHubLookupTimeouts.linkageMs, token, lookupToken => this._gitHubService.getIssuesWithLinkedPullRequests(owner, repo, issues.value.map(issue => issue.number), lookupToken), + 'issueLinkage', + diagnostics, ); if (linkedIssues.kind === 'success') { const unlinkedIssues = issues.value.filter(issue => !linkedIssues.value.has(issue.number)); @@ -586,10 +777,12 @@ export class NewSessionViewV3PromptRunner { repo: string, pullRequests: readonly IGitHubRecentPullRequest[], token: CancellationToken, + diagnostics?: GitHubLookupDiagnostics, ): Promise { const eligiblePullRequests = pullRequests.filter(pullRequest => !!pullRequest.latestCommitAt); if (eligiblePullRequests.length === 0) { this._logService.info(`${LOG_PREFIX} No pull requests have a latest commit timestamp, so review-thread lookup is unnecessary.`); + diagnostics?.record('prReview', 'skipped', 0); return { candidates: [], failures: [] }; } @@ -600,6 +793,8 @@ export class NewSessionViewV3PromptRunner { this._gitHubLookupTimeouts.reviewMs, token, lookupToken => this._gitHubService.getPullRequestReviewThreads(owner, repo, pullRequest.number, lookupToken), + 'prReview', + diagnostics, ); if (outcome.kind === 'success') { const completedPullRequest = { ...pullRequest, reviewThreads: outcome.value }; @@ -630,11 +825,23 @@ export class NewSessionViewV3PromptRunner { timeoutMs: number, token: CancellationToken, lookup: (token: CancellationToken) => Promise, + stage?: NewSessionViewV3LookupStage, + diagnostics?: GitHubLookupDiagnostics, ): Promise> { const lookupCts = new CancellationTokenSource(token); const startTime = Date.now(); let timedOut = false; this._logService.trace(`${LOG_PREFIX} Starting ${label} lookup with a ${timeoutMs}ms timeout.`); + const record = (outcome: NewSessionViewV3StageOutcome): number => { + const durationMs = Date.now() - startTime; + if (stage) { + diagnostics?.record(stage, outcome, durationMs); + } + return durationMs; + }; + if (stage) { + diagnostics?.begin(stage); + } try { const value = await raceTimeout( lookup(lookupCts.token), @@ -646,23 +853,27 @@ export class NewSessionViewV3PromptRunner { }, ); if (timedOut || value === undefined) { + record('timeout'); return { kind: 'failure', reason: 'timeout' }; } - this._logService.info(`${LOG_PREFIX} ${capitalize(label)} lookup completed in ${Date.now() - startTime}ms.`); + this._logService.info(`${LOG_PREFIX} ${capitalize(label)} lookup completed in ${record('success')}ms.`); return { kind: 'success', value }; } catch (error) { if (timedOut) { + record('timeout'); return { kind: 'failure', reason: 'timeout' }; } if (error instanceof GitHubAuthenticationError) { this._logService.warn(`${LOG_PREFIX} ${capitalize(label)} lookup could not run because no existing GitHub authentication session is available.`); + record('noAuthentication'); return { kind: 'failure', reason: 'noAuthentication' }; } if (isCancellationError(error) && token.isCancellationRequested) { this._logService.trace(`${LOG_PREFIX} ${capitalize(label)} lookup was cancelled.`); + record('cancelled'); return { kind: 'failure', reason: 'cancelled' }; } - this._logService.error(`${LOG_PREFIX} ${capitalize(label)} lookup failed after ${Date.now() - startTime}ms.`, error); + this._logService.error(`${LOG_PREFIX} ${capitalize(label)} lookup failed after ${record('requestFailed')}ms.`, error); return { kind: 'failure', reason: 'requestFailed' }; } finally { lookupCts.dispose(); @@ -834,15 +1045,22 @@ export class NewSessionViewV3PromptRunner { }; } - private _createPromptOptionsPlan(candidates: readonly INewSessionViewV3GitHubCandidate[], fallbackReason: NewSessionViewV3FallbackReason): INewSessionViewV3PromptOptionsPlan { - const gitHubOptions = candidates.slice(0, PROMPT_OPTION_COUNT).map(candidate => this._createGitHubPromptOption(candidate)); - const standardOptions = this._createStandardPromptOptions(); + private _createPromptOptionsPlan(candidates: readonly INewSessionViewV3GitHubCandidate[], fallbackReason: NewSessionViewV3FallbackReason, diagnostics: GitHubLookupDiagnostics): INewSessionViewV3PromptOptionsPlan { + const options = this._createPromptOptions(candidates); return { - options: [...gitHubOptions, ...standardOptions.slice(0, PROMPT_OPTION_COUNT - gitHubOptions.length)], + options, fallbackReason, + lookup: diagnostics.summarize(candidates.length, Math.min(candidates.length, PROMPT_OPTION_COUNT)), }; } + /** Fills the fixed number of prompt slots with the given GitHub candidates, padded with standard options. */ + private _createPromptOptions(candidates: readonly INewSessionViewV3GitHubCandidate[]): readonly INewSessionPromptOption[] { + const gitHubOptions = candidates.slice(0, PROMPT_OPTION_COUNT).map(candidate => this._createGitHubPromptOption(candidate)); + const standardOptions = this._createStandardPromptOptions(); + return [...gitHubOptions, ...standardOptions.slice(0, PROMPT_OPTION_COUNT - gitHubOptions.length)]; + } + private _createGitHubPromptOption(candidate: INewSessionViewV3GitHubCandidate): INewSessionPromptOption { const plan = this._createGitHubPrompt(candidate); const title = candidate.strategy === 'githubIssue' @@ -933,49 +1151,88 @@ export class NewSessionViewV3PromptRunner { return composer.animatePrompt(prompt, NEW_SESSION_PROMPT_TYPING_DURATION_MS, taskPlaceholder, token); } - private _reportStrategy(configuredVariation: NewSessionViewV3ConfiguredVariation, effectiveStrategy: NewSessionViewV3EffectiveStrategy, fallbackReason: NewSessionViewV3FallbackReason, shown: boolean): void { + private _reportStrategy(configuredVariation: NewSessionViewV3ConfiguredVariation, effectiveStrategy: NewSessionViewV3EffectiveStrategy, fallbackReason: NewSessionViewV3FallbackReason, shown: boolean, lookup: INewSessionViewV3LookupTelemetry = NO_LOOKUP_TELEMETRY): void { type OnboardingPromptStrategyEvent = { scenarioId: string; + impressionId: string; configuredVariation: string; effectiveStrategy: string; fallbackReason: string; shown: boolean; + gitHubOptionCount: number; + candidatesFound: number; + lookupDurationMs: number; + repositoryMs: number; + issueSummaryMs: number; + issueLinkageMs: number; + prSummaryMs: number; + prReviewMs: number; + issueSummaryOutcome: string; + issueLinkageOutcome: string; + prSummaryOutcome: string; + prReviewOutcome: string; + timedOutStage: string; }; type OnboardingPromptStrategyClassification = { owner: 'benibenj'; - comment: 'Reports which prompt experience an onboarding tour selected without collecting prompt or repository content.'; + comment: 'Reports which prompt experience an onboarding tour selected, and where the GitHub personalization budget went, without collecting prompt or repository content.'; scenarioId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The id of the onboarding scenario that ran.' }; + impressionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'A random identifier generated for this prompt impression, used only to join this event with the interaction events of the same impression.' }; configuredVariation: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The configured prompt experience, reduced to a known category.' }; effectiveStrategy: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The effective prompt or prompt-option strategy selected for the tour.' }; fallbackReason: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The categorical reason GitHub personalization fell back to a default prompt or standard options.' }; shown: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the selected prompt or prompt-option widget was shown.' }; + gitHubOptionCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'How many of the rendered prompt-option slots were personalized GitHub items, from zero to the number of slots.' }; + candidatesFound: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'How many GitHub candidates the lookup found before they were truncated to the number of prompt-option slots.' }; + lookupDurationMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Total time in milliseconds spent in the GitHub prompt-option lookup, including repository resolution.' }; + repositoryMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Time in milliseconds spent resolving the GitHub repository of the selected workspace, or zero when that stage did not run.' }; + issueSummaryMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Time in milliseconds spent fetching assigned issue summaries, or zero when that stage did not run.' }; + issueLinkageMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Time in milliseconds spent checking which assigned issues already have linked pull requests, or zero when that stage did not run.' }; + prSummaryMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Time in milliseconds spent fetching authored pull request summaries, or zero when that stage did not run.' }; + prReviewMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Longest time in milliseconds spent fetching pull request review threads, or zero when that stage did not run.' }; + issueSummaryOutcome: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The categorical outcome of the assigned issue summary stage.' }; + issueLinkageOutcome: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The categorical outcome of the issue pull request linkage stage.' }; + prSummaryOutcome: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The categorical outcome of the authored pull request summary stage.' }; + prReviewOutcome: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The most severe categorical outcome of the pull request review-thread stage.' }; + timedOutStage: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The categorical lookup stage that was still running when the total lookup budget expired, or none.' }; }; this._telemetryService.publicLog2('onboarding.promptStrategy', { scenarioId: NEW_SESSION_VIEW_V3_TOUR_ID, + impressionId: this._impressionId, configuredVariation, effectiveStrategy, fallbackReason, shown, + ...lookup, }); } private _reportPromptOptionInteraction(interaction: 'selected' | 'closed', option?: INewSessionPromptOption): void { type OnboardingPromptOptionInteractionEvent = { scenarioId: string; + impressionId: string; interaction: string; option: string; + optionIndex: number; + optionKindsShown: string; }; type OnboardingPromptOptionInteractionClassification = { owner: 'benibenj'; comment: 'Reports privacy-safe interactions with V3 onboarding prompt options without collecting prompt or repository content.'; scenarioId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The id of the onboarding scenario that showed the prompt options.' }; + impressionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'A random identifier generated for this prompt impression, used only to join this event with the strategy event of the same impression.' }; interaction: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether an option was selected or the prompt-option widget was closed.' }; option: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The categorical prompt option selected, or none when the widget was closed.' }; + optionIndex: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The zero-based slot position of the selected option, or -1 when the widget was closed or the option is no longer rendered.' }; + optionKindsShown: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The comma separated categorical kinds of the prompt options that were rendered, in slot order.' }; }; this._telemetryService.publicLog2('onboarding.promptOptionInteraction', { scenarioId: NEW_SESSION_VIEW_V3_TOUR_ID, + impressionId: this._impressionId, interaction, option: option ? getPromptOptionTelemetryKind(option) : 'none', + optionIndex: option ? this._renderedPromptOptions.findIndex(candidate => candidate.id === option.id) : -1, + optionKindsShown: this._renderedPromptOptions.map(getPromptOptionTelemetryKind).join(','), }); } } @@ -1050,6 +1307,13 @@ function toCandidate(pullRequest: IGitHubRecentPullRequest, strategy: 'githubMer return { number: pullRequest.number, title: pullRequest.title, url: pullRequest.url, strategy }; } +function toIssuePromptOptionCandidates(issues: readonly IGitHubRecentIssue[]): INewSessionViewV3GitHubCandidate[] { + return [...issues] + .sort(compareUpdatedAtDescending) + .slice(0, 2) + .map(issue => ({ number: issue.number, title: issue.title, url: issue.url, strategy: 'githubIssue' as const })); +} + function getPromptOptionTelemetryKind(option: INewSessionPromptOption): 'implementFeature' | 'fixBug' | 'fixCI' | 'githubIssue' | 'githubPRConflicts' | 'githubPRCI' | 'githubPRComments' | 'unknown' { switch (option.id.split(':', 1)[0]) { case 'standard': diff --git a/src/vs/sessions/contrib/onboardingTours/test/browser/newSessionViewV3Prompt.test.ts b/src/vs/sessions/contrib/onboardingTours/test/browser/newSessionViewV3Prompt.test.ts index 4866fa16f8b17b..e472c4450e503f 100644 --- a/src/vs/sessions/contrib/onboardingTours/test/browser/newSessionViewV3Prompt.test.ts +++ b/src/vs/sessions/contrib/onboardingTours/test/browser/newSessionViewV3Prompt.test.ts @@ -39,12 +39,15 @@ class TestAssignmentService extends NullWorkbenchAssignmentService { } } +type TestTelemetryData = { readonly [key: string]: string | number | boolean | undefined }; +type TestTelemetryEvent = { readonly name: string; readonly data: TestTelemetryData | undefined }; + class TestTelemetryService extends NullTelemetryServiceShape { - readonly events: { readonly name: string; readonly data: object | undefined }[] = []; + readonly events: TestTelemetryEvent[] = []; override publicLog2(name?: string, data?: object): void { if (name) { - this.events.push({ name, data }); + this.events.push({ name, data: data as TestTelemetryData | undefined }); } } } @@ -61,6 +64,34 @@ type TestGitHubRequest = | { readonly kind: 'reviews'; readonly owner: string; readonly repo: string; readonly pullRequestNumber: number } | { readonly kind: 'issueLinkage'; readonly owner: string; readonly repo: string; readonly issueNumbers: readonly number[] }; +/** The stage duration fields, which {@link summarizeTelemetry} normalizes because they vary per run. */ +const LOOKUP_DURATIONS = { + lookupDurationMs: '', + repositoryMs: '', + issueSummaryMs: '', + issueLinkageMs: '', + prSummaryMs: '', + prReviewMs: '', +}; + +/** Reported by the variations that never run a GitHub prompt-option lookup. */ +const NO_LOOKUP_TELEMETRY = { + ...LOOKUP_DURATIONS, + gitHubOptionCount: 0, + candidatesFound: 0, + issueSummaryOutcome: 'notRun', + issueLinkageOutcome: 'notRun', + prSummaryOutcome: 'notRun', + prReviewOutcome: 'notRun', + timedOutStage: 'none', +}; + +const STANDARD_OPTIONS = [ + { title: 'Implement a feature', description: 'Describe what you want to build', icon: { id: 'lightbulb-sparkle-autofix', color: undefined } }, + { title: 'Fix a bug', description: 'Describe the unexpected behavior', icon: { id: 'bug', color: undefined } }, + { title: 'Fix CI', description: 'Describe a failing check or paste a link', icon: { id: 'run-errors', color: undefined } }, +]; + suite('NewSessionViewV3Prompt', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -122,23 +153,26 @@ suite('NewSessionViewV3Prompt', () => { animation: undefined, states: [ { kind: 'loading' }, - { - kind: 'resolved', - options: [ - { title: 'Implement a feature', description: 'Describe what you want to build', icon: { id: 'lightbulb-sparkle-autofix', color: undefined } }, - { title: 'Fix a bug', description: 'Describe the unexpected behavior', icon: { id: 'bug', color: undefined } }, - { title: 'Fix CI', description: 'Describe a failing check or paste a link', icon: { id: 'run-errors', color: undefined } }, - ], - }, + { kind: 'resolved', options: STANDARD_OPTIONS }, + { kind: 'resolved', options: STANDARD_OPTIONS }, ], telemetry: [{ name: 'onboarding.promptStrategy', data: { scenarioId: NEW_SESSION_VIEW_V3_TOUR_ID, + impressionId: '', configuredVariation: 'options', effectiveStrategy: 'options', fallbackReason: 'noCandidate', shown: true, + ...LOOKUP_DURATIONS, + gitHubOptionCount: 0, + candidatesFound: 0, + issueSummaryOutcome: 'success', + issueLinkageOutcome: 'skipped', + prSummaryOutcome: 'success', + prReviewOutcome: 'skipped', + timedOutStage: 'none', }, }], }); @@ -191,10 +225,12 @@ suite('NewSessionViewV3Prompt', () => { name: 'onboarding.promptStrategy', data: { scenarioId: NEW_SESSION_VIEW_V3_TOUR_ID, + impressionId: '', configuredVariation: 'githubPrompt', effectiveStrategy: 'githubCiFailure', fallbackReason: 'none', shown: true, + ...NO_LOOKUP_TELEMETRY, }, }], }); @@ -224,10 +260,12 @@ suite('NewSessionViewV3Prompt', () => { name: 'onboarding.promptStrategy', data: { scenarioId: NEW_SESSION_VIEW_V3_TOUR_ID, + impressionId: '', configuredVariation: 'githubPrompt', effectiveStrategy: 'githubMergeConflict', fallbackReason: 'none', shown: true, + ...NO_LOOKUP_TELEMETRY, }, }], }); @@ -251,10 +289,12 @@ suite('NewSessionViewV3Prompt', () => { name: 'onboarding.promptStrategy', data: { scenarioId: NEW_SESSION_VIEW_V3_TOUR_ID, + impressionId: '', configuredVariation: 'githubPrompt', effectiveStrategy: 'prompt', fallbackReason: 'noAuthentication', shown: true, + ...NO_LOOKUP_TELEMETRY, }, }], }); @@ -286,6 +326,23 @@ suite('NewSessionViewV3Prompt', () => { animation: undefined, states: [ { kind: 'loading' }, + { kind: 'resolved', options: STANDARD_OPTIONS }, + { + kind: 'resolved', + options: [ + { title: 'Tackle issue #14', description: 'Newest assigned issue', icon: { id: 'issue-opened', color: 'charts.green' } }, + { title: 'Tackle issue #12', description: 'Older assigned issue', icon: { id: 'issue-opened', color: 'charts.green' } }, + STANDARD_OPTIONS[0], + ], + }, + { + kind: 'resolved', + options: [ + { title: 'Tackle issue #14', description: 'Newest assigned issue', icon: { id: 'issue-opened', color: 'charts.green' } }, + { title: 'Tackle issue #12', description: 'Older assigned issue', icon: { id: 'issue-opened', color: 'charts.green' } }, + { title: 'Resolve conflicts #20', description: 'Conflicted PR', icon: { id: 'git-pull-request-error', color: 'charts.orange' } }, + ], + }, { kind: 'resolved', options: [ @@ -299,10 +356,19 @@ suite('NewSessionViewV3Prompt', () => { name: 'onboarding.promptStrategy', data: { scenarioId: NEW_SESSION_VIEW_V3_TOUR_ID, + impressionId: '', configuredVariation: 'options', effectiveStrategy: 'options', fallbackReason: 'none', shown: true, + ...LOOKUP_DURATIONS, + gitHubOptionCount: 3, + candidatesFound: 4, + issueSummaryOutcome: 'success', + issueLinkageOutcome: 'success', + prSummaryOutcome: 'success', + prReviewOutcome: 'skipped', + timedOutStage: 'none', }, }], }); @@ -327,37 +393,69 @@ suite('NewSessionViewV3Prompt', () => { name: 'onboarding.promptOptionInteraction', data: { scenarioId: NEW_SESSION_VIEW_V3_TOUR_ID, + impressionId: '', interaction: 'selected', option: 'githubIssue', + optionIndex: 0, + optionKindsShown: 'githubIssue,githubPRCI,githubPRComments', }, }, { name: 'onboarding.promptOptionInteraction', data: { scenarioId: NEW_SESSION_VIEW_V3_TOUR_ID, + impressionId: '', interaction: 'selected', option: 'githubPRCI', + optionIndex: 1, + optionKindsShown: 'githubIssue,githubPRCI,githubPRComments', }, }, { name: 'onboarding.promptOptionInteraction', data: { scenarioId: NEW_SESSION_VIEW_V3_TOUR_ID, + impressionId: '', interaction: 'selected', option: 'githubPRComments', + optionIndex: 2, + optionKindsShown: 'githubIssue,githubPRCI,githubPRComments', }, }, { name: 'onboarding.promptOptionInteraction', data: { scenarioId: NEW_SESSION_VIEW_V3_TOUR_ID, + impressionId: '', interaction: 'closed', option: 'none', + optionIndex: -1, + optionKindsShown: 'githubIssue,githubPRCI,githubPRComments', }, }, ]); }); + test('joins the strategy and interaction events of one impression', async () => { + const result = await runPrompt( + { 'onb.newSessionViewV3.variation': 'options' }, + {}, + { pullRequests: [], issues: [issue('Ready issue', '2026-08-07T13:00:00Z', 7)] }, + { promptOptionInteractions: [0, 'close'] }, + ); + const impressionIds = getImpressionIds(result.rawTelemetry); + + assert.deepStrictEqual({ + eventNames: result.rawTelemetry.map(event => event.name), + sharedImpressionIds: new Set(impressionIds).size, + isGuid: /^[0-9a-f-]{36}$/.test(impressionIds[0]), + }, { + eventNames: ['onboarding.promptStrategy', 'onboarding.promptOptionInteraction', 'onboarding.promptOptionInteraction'], + sharedImpressionIds: 1, + isGuid: true, + }); + }); + test('fills missing prompt options from the fixed standard order after a partial timeout', async () => { const result = await runPrompt( { 'onb.newSessionViewV3.variation': 'options' }, @@ -372,12 +470,21 @@ suite('NewSessionViewV3Prompt', () => { }, { states: [ { kind: 'loading' }, + { kind: 'resolved', options: STANDARD_OPTIONS }, + { + kind: 'resolved', + options: [ + { title: 'Tackle issue #7', description: 'Ready issue', icon: { id: 'issue-opened', color: 'charts.green' } }, + STANDARD_OPTIONS[0], + STANDARD_OPTIONS[1], + ], + }, { kind: 'resolved', options: [ { title: 'Tackle issue #7', description: 'Ready issue', icon: { id: 'issue-opened', color: 'charts.green' } }, - { title: 'Implement a feature', description: 'Describe what you want to build', icon: { id: 'lightbulb-sparkle-autofix', color: undefined } }, - { title: 'Fix a bug', description: 'Describe the unexpected behavior', icon: { id: 'bug', color: undefined } }, + STANDARD_OPTIONS[0], + STANDARD_OPTIONS[1], ], }, ], @@ -385,15 +492,193 @@ suite('NewSessionViewV3Prompt', () => { name: 'onboarding.promptStrategy', data: { scenarioId: NEW_SESSION_VIEW_V3_TOUR_ID, + impressionId: '', + configuredVariation: 'options', + effectiveStrategy: 'options', + fallbackReason: 'timeout', + shown: true, + ...LOOKUP_DURATIONS, + gitHubOptionCount: 1, + candidatesFound: 1, + issueSummaryOutcome: 'success', + issueLinkageOutcome: 'success', + prSummaryOutcome: 'timeout', + prReviewOutcome: 'notRun', + timedOutStage: 'none', + }, + }], + }); + }); + + test('renders standard prompt options before the GitHub lookup and streams partial results into their slots', async () => { + const result = await runPrompt( + { 'onb.newSessionViewV3.variation': 'options' }, + {}, + { + issues: [issue('Assigned issue', '2026-08-07T14:00:00Z', 14)], + pullRequests: [ + pullRequest('CI is failing', '2026-08-07T13:00:00Z', 'FAILURE', undefined, undefined, 21), + pullRequest('Awaiting review threads', '2026-08-07T12:00:00Z', undefined, '2026-08-07T09:00:00Z', '2026-08-07T10:00:00Z', 22), + ], + }, + { reviewLookupNeverResolves: true }, + ); + const issueOption = { title: 'Tackle issue #14', description: 'Assigned issue', icon: { id: 'issue-opened', color: 'charts.green' } }; + const ciOption = { title: 'Fix CI #21', description: 'CI is failing', icon: { id: 'git-pull-request-error', color: 'charts.orange' } }; + + assert.deepStrictEqual({ + states: summarizePromptOptionStates(result.promptOptionStates), + telemetry: result.telemetry, + }, { + states: [ + { kind: 'loading' }, + { kind: 'resolved', options: STANDARD_OPTIONS }, + { kind: 'resolved', options: [issueOption, STANDARD_OPTIONS[0], STANDARD_OPTIONS[1]] }, + { kind: 'resolved', options: [issueOption, ciOption, STANDARD_OPTIONS[0]] }, + { kind: 'resolved', options: [issueOption, ciOption, STANDARD_OPTIONS[0]] }, + ], + telemetry: [{ + name: 'onboarding.promptStrategy', + data: { + scenarioId: NEW_SESSION_VIEW_V3_TOUR_ID, + impressionId: '', configuredVariation: 'options', effectiveStrategy: 'options', fallbackReason: 'timeout', shown: true, + ...LOOKUP_DURATIONS, + gitHubOptionCount: 2, + candidatesFound: 2, + issueSummaryOutcome: 'success', + issueLinkageOutcome: 'success', + prSummaryOutcome: 'success', + prReviewOutcome: 'timeout', + timedOutStage: 'none', + }, + }], + }); + }); + + test('repaints standard prompt options when the composer resolves again after a workspace change', async () => { + const result = await runPrompt( + { 'onb.newSessionViewV3.variation': 'options' }, + {}, + { pullRequests: [], issues: [] }, + { refreshPromptOptionsAgain: true }, + ); + + assert.deepStrictEqual(summarizePromptOptionStates(result.promptOptionStates), [ + { kind: 'loading' }, + { kind: 'resolved', options: STANDARD_OPTIONS }, + { kind: 'resolved', options: STANDARD_OPTIONS }, + { kind: 'loading' }, + { kind: 'resolved', options: STANDARD_OPTIONS }, + { kind: 'resolved', options: STANDARD_OPTIONS }, + ]); + }); + + test('reports the options the composer rendered when it refuses later updates', async () => { + const result = await runPrompt( + { 'onb.newSessionViewV3.variation': 'options' }, + {}, + { pullRequests: [], issues: [issue('Assigned issue', '2026-08-07T14:00:00Z', 14)] }, + { suppressPromptOptionUpdatesAfter: 1, promptOptionInteractions: [0] }, + ); + + assert.deepStrictEqual({ + states: summarizePromptOptionStates(result.promptOptionStates), + interaction: result.telemetry.filter(event => event.name === 'onboarding.promptOptionInteraction'), + }, { + states: [ + { kind: 'loading' }, + { kind: 'resolved', options: STANDARD_OPTIONS }, + ], + interaction: [{ + name: 'onboarding.promptOptionInteraction', + data: { + scenarioId: NEW_SESSION_VIEW_V3_TOUR_ID, + impressionId: '', + interaction: 'selected', + option: 'implementFeature', + optionIndex: 0, + optionKindsShown: 'implementFeature,fixBug,fixCI', + }, + }], + }); + }); + + test('publishes assigned issues before the linkage lookup can hide them', async () => { + const result = await runPrompt( + { 'onb.newSessionViewV3.variation': 'options' }, + {}, + { pullRequests: [], issues: [issue('Unknown linkage', '2026-08-07T13:00:00Z', 9)] }, + { issueLinkageLookupNeverResolves: true }, + ); + const issueOption = { title: 'Tackle issue #9', description: 'Unknown linkage', icon: { id: 'issue-opened', color: 'charts.green' } }; + + assert.deepStrictEqual({ + states: summarizePromptOptionStates(result.promptOptionStates), + telemetry: result.telemetry, + }, { + states: [ + { kind: 'loading' }, + { kind: 'resolved', options: STANDARD_OPTIONS }, + { kind: 'resolved', options: [issueOption, STANDARD_OPTIONS[0], STANDARD_OPTIONS[1]] }, + { kind: 'resolved', options: [issueOption, STANDARD_OPTIONS[0], STANDARD_OPTIONS[1]] }, + ], + telemetry: [{ + name: 'onboarding.promptStrategy', + data: { + scenarioId: NEW_SESSION_VIEW_V3_TOUR_ID, + impressionId: '', + configuredVariation: 'options', + effectiveStrategy: 'options', + fallbackReason: 'noCandidate', + shown: true, + ...LOOKUP_DURATIONS, + gitHubOptionCount: 1, + candidatesFound: 1, + issueSummaryOutcome: 'success', + issueLinkageOutcome: 'timeout', + prSummaryOutcome: 'success', + prReviewOutcome: 'skipped', + timedOutStage: 'none', }, }], }); }); + test('reports the stage that consumed the total lookup budget', async () => { + const result = await runPrompt( + { 'onb.newSessionViewV3.variation': 'options' }, + {}, + { pullRequests: [], issues: [issue('Ready issue', '2026-08-07T13:00:00Z', 7)] }, + { pullRequestLookupNeverResolves: true, totalMs: 10, summaryMs: 30 }, + ); + // Let the pull request lookup that outlived the total budget unwind and dispose its sources. + await timeout(80); + + assert.deepStrictEqual(result.telemetry, [{ + name: 'onboarding.promptStrategy', + data: { + scenarioId: NEW_SESSION_VIEW_V3_TOUR_ID, + impressionId: '', + configuredVariation: 'options', + effectiveStrategy: 'options', + fallbackReason: 'timeout', + shown: true, + ...LOOKUP_DURATIONS, + gitHubOptionCount: 1, + candidatesFound: 1, + issueSummaryOutcome: 'success', + issueLinkageOutcome: 'success', + prSummaryOutcome: 'notRun', + prReviewOutcome: 'notRun', + timedOutStage: 'prSummary', + }, + }]); + }); + test('uses all standard prompt options when GitHub authentication is unavailable', async () => { const result = await runPrompt( { 'onb.newSessionViewV3.variation': 'options' }, @@ -407,23 +692,26 @@ suite('NewSessionViewV3Prompt', () => { }, { states: [ { kind: 'loading' }, - { - kind: 'resolved', - options: [ - { title: 'Implement a feature', description: 'Describe what you want to build', icon: { id: 'lightbulb-sparkle-autofix', color: undefined } }, - { title: 'Fix a bug', description: 'Describe the unexpected behavior', icon: { id: 'bug', color: undefined } }, - { title: 'Fix CI', description: 'Describe a failing check or paste a link', icon: { id: 'run-errors', color: undefined } }, - ], - }, + { kind: 'resolved', options: STANDARD_OPTIONS }, + { kind: 'resolved', options: STANDARD_OPTIONS }, ], telemetry: [{ name: 'onboarding.promptStrategy', data: { scenarioId: NEW_SESSION_VIEW_V3_TOUR_ID, + impressionId: '', configuredVariation: 'options', effectiveStrategy: 'options', fallbackReason: 'noAuthentication', shown: true, + ...LOOKUP_DURATIONS, + gitHubOptionCount: 0, + candidatesFound: 0, + issueSummaryOutcome: 'noAuthentication', + issueLinkageOutcome: 'skipped', + prSummaryOutcome: 'noAuthentication', + prReviewOutcome: 'notRun', + timedOutStage: 'none', }, }], }); @@ -450,10 +738,12 @@ suite('NewSessionViewV3Prompt', () => { name: 'onboarding.promptStrategy', data: { scenarioId: NEW_SESSION_VIEW_V3_TOUR_ID, + impressionId: '', configuredVariation: 'githubPrompt', effectiveStrategy: 'githubIssue', fallbackReason: 'none', shown: true, + ...NO_LOOKUP_TELEMETRY, }, }], }); @@ -682,17 +972,27 @@ async function runPrompt( readonly enterpriseHost?: string; readonly pullRequestLookupNeverResolves?: boolean; readonly issueLinkageLookupNeverResolves?: boolean; + readonly reviewLookupNeverResolves?: boolean; + readonly totalMs?: number; + readonly summaryMs?: number; + readonly suppressPromptOptionUpdatesAfter?: number; + readonly refreshPromptOptionsAgain?: boolean; readonly promptOptionInteractions?: readonly (number | 'close')[]; } = {}, ): Promise<{ readonly animation: { readonly prompt: string; readonly durationMs: number; readonly placeholder: string } | undefined; readonly promptOptionStates: readonly NewSessionPromptOptionsState[]; - readonly telemetry: readonly { readonly name: string; readonly data: object | undefined }[]; + readonly telemetry: readonly TestTelemetryEvent[]; + readonly rawTelemetry: readonly TestTelemetryEvent[]; readonly gitHubRequests: readonly TestGitHubRequest[]; }> { let animation: { prompt: string; durationMs: number; placeholder: string } | undefined; const promptOptionStates: NewSessionPromptOptionsState[] = []; let promptOptionsController: INewSessionPromptOptionsController | undefined; + let acceptedUpdates = 0; + // Mimics the composer refusing late updates once the user acts on the options on screen. + const suppressUpdate = () => options.suppressPromptOptionUpdatesAfter !== undefined + && acceptedUpdates >= options.suppressPromptOptionUpdatesAfter; const workspaceUri = options.workspaceUri ?? URI.file('C:\\repo'); const workspace = createWorkspace(workspaceUri, 'r', options.includeGitHubInfo !== false); const activeSession = createSession(workspace); @@ -718,8 +1018,17 @@ async function runPrompt( return false; } promptOptionStates.push({ kind: 'loading' }); - const state = await controller.resolve(token); - promptOptionStates.push(state); + const state = await controller.resolve(token, progressState => { + if (suppressUpdate()) { + return false; + } + acceptedUpdates++; + promptOptionStates.push(progressState); + return true; + }); + if (!suppressUpdate()) { + promptOptionStates.push(state); + } return true; }, }); @@ -746,6 +1055,9 @@ async function runPrompt( } override async getPullRequestReviewThreads(owner: string, repo: string, pullRequestNumber: number) { this.requests.push({ kind: 'reviews', owner, repo, pullRequestNumber }); + if (options.reviewLookupNeverResolves) { + return new Promise(() => { }); + } if (gitHubResult instanceof Error) { throw gitHubResult; } @@ -787,10 +1099,14 @@ async function runPrompt( gitHubService, telemetryService, new NullLogService(), - { totalMs: 100, summaryMs: 20, linkageMs: 20, reviewMs: 20 }, + { totalMs: options.totalMs ?? 100, summaryMs: options.summaryMs ?? 20, linkageMs: 20, reviewMs: 20 }, ); await runner.run(CancellationToken.None); + if (options.refreshPromptOptionsAgain) { + // The composer resolves again on refresh, for example when the selected workspace changes. + await composerService.activeComposer.get().refreshPromptOptions(CancellationToken.None); + } if (options.promptOptionInteractions?.length) { const controller = promptOptionsController; const resolvedState = [...promptOptionStates].reverse().find(state => state.kind === 'resolved'); @@ -809,7 +1125,7 @@ async function runPrompt( controller.onDidSelectOption(option); } } - return { animation, promptOptionStates, telemetry: telemetryService.events, gitHubRequests: gitHubService.requests }; + return { animation, promptOptionStates, telemetry: summarizeTelemetry(telemetryService.events), rawTelemetry: telemetryService.events, gitHubRequests: gitHubService.requests }; } function pullRequest(title: string, updatedAt: string, statusCheckRollupState?: string, latestCommitAt?: string, latestCommentAt?: string, number = 1, hasMergeConflicts = false) { @@ -877,3 +1193,23 @@ function summarizePromptOptionStates(states: readonly NewSessionPromptOptionsSta })), }); } + +/** Replaces the values that legitimately vary between runs so telemetry can be asserted as a snapshot. */ +function summarizeTelemetry(events: readonly TestTelemetryEvent[]): TestTelemetryEvent[] { + return events.map(event => ({ + name: event.name, + data: event.data && Object.fromEntries(Object.entries(event.data).map(([key, value]) => { + if (key === 'impressionId') { + return [key, typeof value === 'string' && value.length > 0 ? '' : value]; + } + if (key.endsWith('Ms') && typeof value === 'number') { + return [key, '']; + } + return [key, value]; + })), + })); +} + +function getImpressionIds(events: readonly TestTelemetryEvent[]): string[] { + return events.map(event => String(event.data?.impressionId ?? '')); +} diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionConfigPicker.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionConfigPicker.ts index 1ce1e12b80d852..cdd02a72e7aa11 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionConfigPicker.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionConfigPicker.ts @@ -61,13 +61,15 @@ import { AgentHostPermissionPickerActionItem } from './agentHostPermissionPicker import { AgentHostPermissionPickerDelegate, isWellKnownAutoApproveSchema, isWellKnownClaudePermissionModeSchema, isWellKnownCodexApprovalsSchema, isWellKnownModeSchema } from './agentHostPermissionPickerDelegate.js'; import { SessionConfigKey } from '../../../../../platform/agentHost/common/sessionConfigKeys.js'; import { AGENT_HOST_CHECKOUT_CHANGESET_OPERATION_ID } from '../../../../../platform/agentHost/common/agentHostChangesetOperationService.js'; -import { checkoutOperationMeta } from '../../../../../platform/agentHost/common/meta/agentCheckoutOperationMeta.js'; +import { CheckoutOperationPreAction, checkoutOperationMeta, isCheckoutOperationDirtyWorkingTreeErrorData } from '../../../../../platform/agentHost/common/meta/agentCheckoutOperationMeta.js'; +import { ProtocolError } from '../../../../../platform/agentHost/common/state/sessionProtocol.js'; import { AgentHostClaudePermissionModePicker } from './agentHostClaudePermissionModePicker.js'; import { ClaudeSessionConfigKey } from '../../../../../platform/agentHost/common/claudeSessionConfigKeys.js'; import { AgentHostCodexApprovalsPicker } from './agentHostCodexApprovalsPicker.js'; import { isAutoApproveValuePolicyRestricted } from '../../../../../workbench/contrib/chat/common/agentHostConfigPolicy.js'; import { CodexSessionConfigKey } from '../../../../../platform/agentHost/common/codexSessionConfigKeys.js'; import { type ISessionChangeset, UNCOMMITTED_CHANGES_CHANGESET_ID } from '../../../../services/sessions/common/session.js'; +import { MarkdownString } from '../../../../../base/common/htmlContent.js'; const IsActiveSessionRemoteAgentHost = ContextKeyExpr.regex(SessionProviderIdContext.key, REMOTE_AGENT_HOST_PROVIDER_RE); const IsActiveSessionLocalAgentHost = ContextKeyExpr.equals(SessionProviderIdContext.key, LOCAL_AGENT_HOST_PROVIDER_ID); @@ -664,29 +666,62 @@ export class AgentHostSessionConfigPicker extends Disposable { throw new Error('Branch checkout is not available for this session.'); } - const confirmedValue = provider.getSessionConfig(sessionId)?.values[property]; - try { + const checkedOut = await this._invokeCheckoutOperation(changeset, treeish); + if (checkedOut) { await provider.setSessionConfigValue(sessionId, property, value); - await changeset.invokeOperation( - AGENT_HOST_CHECKOUT_CHANGESET_OPERATION_ID, - undefined, - checkoutOperationMeta(treeish), - ); - } catch (checkoutError) { - if (provider.getSessionConfig(sessionId)?.values[property] === value) { - try { - await provider.setSessionConfigValue(sessionId, property, confirmedValue); - } catch (rollbackError) { - throw new AggregateError([checkoutError, rollbackError], 'Checkout failed and the branch configuration could not be restored.'); - } - } - throw checkoutError; } }); provider.trackSessionConfigOperation(sessionId, configOperation); await configOperation; } + private async _invokeCheckoutOperation(changeset: ISessionChangeset, treeish: string): Promise { + let errorMessage: string | undefined; + try { + await changeset.invokeOperation( + AGENT_HOST_CHECKOUT_CHANGESET_OPERATION_ID, + undefined, + checkoutOperationMeta(treeish), + ); + + return true; + } catch (error) { + if (!(error instanceof ProtocolError) || !isCheckoutOperationDirtyWorkingTreeErrorData(error.data)) { + throw error; + } + + errorMessage = error instanceof Error ? error.message : String(error); + } + + const { result } = await this._dialogService.prompt({ + type: 'warning', + message: localize('agentHostSessionConfigPicker.checkoutDirty', "Your local changes would be overwritten when checking out '{0}'.", treeish), + detail: new MarkdownString(errorMessage), + buttons: [ + { + label: localize('agentHostSessionConfigPicker.checkoutStash', "Stash & Checkout"), + run: () => CheckoutOperationPreAction.Stash, + }, + { + label: localize('agentHostSessionConfigPicker.checkoutCommit', "Commit & Checkout"), + run: () => CheckoutOperationPreAction.Commit, + }, + ], + cancelButton: true, + }); + if (!result) { + return false; + } + + await changeset.invokeOperation( + AGENT_HOST_CHECKOUT_CHANGESET_OPERATION_ID, + undefined, + checkoutOperationMeta(treeish, result), + ); + + return true; + } + protected _requiresBranchCheckout(provider: IAgentHostSessionsProvider, sessionId: string, property: string): boolean { return property === SessionConfigKey.Branch && provider.getCreateSessionConfig(sessionId) !== undefined diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/media/agentHostSessionConfigPicker.css b/src/vs/sessions/contrib/providers/agentHost/browser/media/agentHostSessionConfigPicker.css index 30b8392e393e37..35fd8e410aea53 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/media/agentHostSessionConfigPicker.css +++ b/src/vs/sessions/contrib/providers/agentHost/browser/media/agentHostSessionConfigPicker.css @@ -6,6 +6,8 @@ .sessions-chat-agent-host-config { display: flex; align-items: center; + /* Each config control carries its own surface, so they need a gap. */ + gap: var(--vscode-spacing-size20); min-width: 0; } diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHost/agentHostSessionConfigPicker.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHost/agentHostSessionConfigPicker.test.ts index 282739eb26a00c..f5292bc8b92e56 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHost/agentHostSessionConfigPicker.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHost/agentHostSessionConfigPicker.test.ts @@ -16,12 +16,14 @@ import { isIMenuItem, MenuId, MenuRegistry } from '../../../../../../../platform import { ActionListItemKind, IActionListDelegate, IActionListItem } from '../../../../../../../platform/actionWidget/browser/actionList.js'; import { IActionWidgetService } from '../../../../../../../platform/actionWidget/browser/actionWidget.js'; import { AGENT_HOST_CHECKOUT_CHANGESET_OPERATION_ID } from '../../../../../../../platform/agentHost/common/agentHostChangesetOperationService.js'; +import { checkoutOperationDirtyWorkingTreeErrorData } from '../../../../../../../platform/agentHost/common/meta/agentCheckoutOperationMeta.js'; import { SessionConfigKey } from '../../../../../../../platform/agentHost/common/sessionConfigKeys.js'; +import { JsonRpcErrorCodes, ProtocolError } from '../../../../../../../platform/agentHost/common/state/sessionProtocol.js'; import { ResolveSessionConfigResult, SessionConfigPropertySchema, SessionConfigValueItem } from '../../../../../../../platform/agentHost/common/state/protocol/commands.js'; import { IConfigurationService } from '../../../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../../../platform/configuration/test/common/testConfigurationService.js'; import { IContextKeyService } from '../../../../../../../platform/contextkey/common/contextkey.js'; -import { IDialogService } from '../../../../../../../platform/dialogs/common/dialogs.js'; +import { IDialogService, type IPrompt, type IPromptResult } from '../../../../../../../platform/dialogs/common/dialogs.js'; import { IHoverService } from '../../../../../../../platform/hover/browser/hover.js'; import { TestInstantiationService } from '../../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { IStorageService } from '../../../../../../../platform/storage/common/storage.js'; @@ -231,8 +233,13 @@ class CapturingActionWidgetHolder { function setupServices( store: Pick, 'add'>, - options?: { devContainerWorktreeEnabled?: boolean }, - onCheckout?: () => Promise, + options?: { + devContainerWorktreeEnabled?: boolean; + checkoutDialogChoice?: 'Stash & Checkout' | 'Commit & Checkout'; + checkoutDialogShown?: DeferredPromise; + checkoutDialogRelease?: DeferredPromise; + }, + onCheckout?: (_meta?: Record) => Promise, ) { const emitter = store.add(new Emitter()); const branchSelectionEvents: string[] = []; @@ -253,7 +260,21 @@ function setupServices( instantiationService.stub(IConfigurationService, new TestConfigurationService({ [DevContainerWorktreeEnabledSettingId]: options?.devContainerWorktreeEnabled ?? false, })); - instantiationService.stub(IDialogService, new (class extends mock() { })()); + const checkoutDialogs: { message: string; buttons: readonly string[]; cancelButton: boolean; alignment: string | undefined }[] = []; + instantiationService.stub(IDialogService, { + prompt: async (prompt: IPrompt): Promise> => { + checkoutDialogs.push({ + message: prompt.message, + buttons: prompt.buttons?.map(button => button.label) ?? [], + cancelButton: prompt.cancelButton === true, + alignment: typeof prompt.custom === 'object' ? prompt.custom.alignment : undefined, + }); + options?.checkoutDialogShown?.complete(); + await options?.checkoutDialogRelease?.p; + const button = prompt.buttons?.find(button => button.label === options?.checkoutDialogChoice); + return { result: button ? await button.run({ checkboxChecked: undefined }) : undefined }; + }, + } as Partial as IDialogService); instantiationService.stub(IStorageService, new (class extends mock() { })()); instantiationService.stub(IContextKeyService, new (class extends mock() { override readonly onDidChangeContext = Event.None; @@ -301,7 +322,7 @@ function setupServices( override async invokeOperation(operationId: string, _target?: ISessionChangesetOperationTarget, _meta?: Record): Promise { checkoutInvocations.push({ operationId, _meta }); branchSelectionEvents.push('checkout'); - await onCheckout?.(); + await onCheckout?.(_meta); } }(); const changesetsObs = observableValue('changesets', [uncommittedChangeset]); @@ -313,7 +334,7 @@ function setupServices( override readonly changesets = changesetsObs; }(); const sessionObs = observableValue('activeSession', activeSession); - return { instantiationService, provider, sessionObs, workspaceObs, changesetsObs, uncommittedChangeset, actionWidget, checkoutInvocations, branchSelectionEvents }; + return { instantiationService, provider, sessionObs, workspaceObs, changesetsObs, uncommittedChangeset, actionWidget, checkoutInvocations, branchSelectionEvents, checkoutDialogs }; } /** Create and render a fresh picker instance, as the toolbar does on a rebuild. */ @@ -658,7 +679,7 @@ suite('Agent Host Session Config Picker', () => { operationId: AGENT_HOST_CHECKOUT_CHANGESET_OPERATION_ID, _meta: { treeish: 'dev' }, }], - branchSelectionEvents: ['set:dev', 'checkout'], + branchSelectionEvents: ['checkout', 'set:dev'], }); }); @@ -693,7 +714,7 @@ suite('Agent Host Session Config Picker', () => { }); }); - test('failed folder branch checkout restores the previous configuration value', async () => { + test('failed folder branch checkout keeps the previous configuration value', async () => { const services = setupServices(store, {}, async () => { throw new Error('Checkout failed'); }); @@ -716,15 +737,131 @@ suite('Agent Host Session Config Picker', () => { branchSelectionEvents: services.branchSelectionEvents, }, { branch: 'main', - configUpdates: [ - { sessionId: SESSION_ID, property: SessionConfigKey.Branch, value: 'featureA' }, - { sessionId: SESSION_ID, property: SessionConfigKey.Branch, value: 'main' }, - ], + configUpdates: [], checkoutInvocations: [{ operationId: AGENT_HOST_CHECKOUT_CHANGESET_OPERATION_ID, _meta: { treeish: 'featureA' }, }], - branchSelectionEvents: ['set:featureA', 'checkout', 'set:main'], + branchSelectionEvents: ['checkout'], + }); + }); + + test('dirty folder branch checkout offers stash, commit, and cancel actions', async () => { + const outcomes: { + choice: 'Stash' | 'Commit' | 'Cancel'; + branch: unknown; + checkoutInvocations: readonly { operationId: string; _meta: Record | undefined }[]; + dialogs: readonly { message: string; buttons: readonly string[]; cancelButton: boolean; alignment: string | undefined }[]; + }[] = []; + + for (const choice of ['Stash & Checkout', 'Commit & Checkout', undefined] as const) { + const services = setupServices(store, { checkoutDialogChoice: choice }, async meta => { + if (!meta?.preCheckoutAction) { + throw new ProtocolError( + JsonRpcErrorCodes.InvalidParams, + 'Dirty working tree', + checkoutOperationDirtyWorkingTreeErrorData(), + ); + } + }); + services.provider.config = makeDynamicBranchConfig('main', 'folder'); + const picker = store.add(services.instantiationService.createInstance(AlwaysRenderConfigPicker, services.sessionObs)); + + await picker.setSessionConfigValueForTest(services.provider, SessionConfigKey.Branch, 'dev'); + outcomes.push({ + choice: choice?.startsWith('Stash') ? 'Stash' : choice?.startsWith('Commit') ? 'Commit' : 'Cancel', + branch: services.provider.config.values[SessionConfigKey.Branch], + checkoutInvocations: services.checkoutInvocations, + dialogs: services.checkoutDialogs, + }); + } + + const dialog = { + message: 'Your local changes would be overwritten when checking out \'dev\'.', + buttons: ['Stash & Checkout', 'Commit & Checkout'], + cancelButton: true, + alignment: undefined, + }; + assert.deepStrictEqual(outcomes, [ + { + choice: 'Stash', + branch: 'dev', + checkoutInvocations: [ + { operationId: AGENT_HOST_CHECKOUT_CHANGESET_OPERATION_ID, _meta: { treeish: 'dev' } }, + { operationId: AGENT_HOST_CHECKOUT_CHANGESET_OPERATION_ID, _meta: { treeish: 'dev', preCheckoutAction: 'stash' } }, + ], + dialogs: [dialog], + }, + { + choice: 'Commit', + branch: 'dev', + checkoutInvocations: [ + { operationId: AGENT_HOST_CHECKOUT_CHANGESET_OPERATION_ID, _meta: { treeish: 'dev' } }, + { operationId: AGENT_HOST_CHECKOUT_CHANGESET_OPERATION_ID, _meta: { treeish: 'dev', preCheckoutAction: 'commit' } }, + ], + dialogs: [dialog], + }, + { + choice: 'Cancel', + branch: 'main', + checkoutInvocations: [ + { operationId: AGENT_HOST_CHECKOUT_CHANGESET_OPERATION_ID, _meta: { treeish: 'dev' } }, + ], + dialogs: [dialog], + }, + ]); + }); + + test('keeps the confirmed branch label while the dirty checkout dialog is open', async () => { + const checkoutDialogShown = new DeferredPromise(); + const checkoutDialogRelease = new DeferredPromise(); + const services = setupServices(store, { + checkoutDialogChoice: 'Commit & Checkout', + checkoutDialogShown, + checkoutDialogRelease, + }, async meta => { + if (!meta?.preCheckoutAction) { + throw new ProtocolError( + JsonRpcErrorCodes.InvalidParams, + 'Dirty working tree', + checkoutOperationDirtyWorkingTreeErrorData(), + ); + } + }); + services.provider.config = makeDynamicBranchConfig('main', 'folder'); + const picker = store.add(services.instantiationService.createInstance(AlwaysRenderConfigPicker, services.sessionObs)); + const container = document.createElement('div'); + picker.render(container); + + const checkout = picker.setSessionConfigValueForTest(services.provider, SessionConfigKey.Branch, 'dev'); + await checkoutDialogShown.p; + const whilePrompting = { + label: branchLabel(container), + branch: services.provider.config.values[SessionConfigKey.Branch], + configUpdates: [...services.provider.setSessionConfigValueArguments], + }; + + checkoutDialogRelease.complete(); + await checkout; + + assert.deepStrictEqual({ + whilePrompting, + afterCheckout: { + label: branchLabel(container), + branch: services.provider.config.values[SessionConfigKey.Branch], + configUpdates: services.provider.setSessionConfigValueArguments, + }, + }, { + whilePrompting: { + label: 'main', + branch: 'main', + configUpdates: [], + }, + afterCheckout: { + label: 'dev', + branch: 'dev', + configUpdates: [{ sessionId: SESSION_ID, property: SessionConfigKey.Branch, value: 'dev' }], + }, }); }); @@ -762,11 +899,9 @@ suite('Agent Host Session Config Picker', () => { configUpdates: services.provider.setSessionConfigValueArguments, checkoutInvocations: services.checkoutInvocations, }, { - label: 'featureA', - branch: 'featureA', - configUpdates: [ - { sessionId: SESSION_ID, property: SessionConfigKey.Branch, value: 'featureA' }, - ], + label: 'main', + branch: 'main', + configUpdates: [], checkoutInvocations: [{ operationId: AGENT_HOST_CHECKOUT_CHANGESET_OPERATION_ID, _meta: { treeish: 'featureA' }, @@ -793,7 +928,7 @@ suite('Agent Host Session Config Picker', () => { _meta: { treeish: 'main' }, }], }); - assert.deepStrictEqual(services.branchSelectionEvents, ['set:featureA', 'checkout', 'set:main', 'checkout']); + assert.deepStrictEqual(services.branchSelectionEvents, ['checkout', 'set:featureA', 'checkout', 'set:main']); }); test('serializes interleaved branch and isolation selections before deciding checkout', async () => { @@ -828,7 +963,7 @@ suite('Agent Host Session Config Picker', () => { }); }); - test('rolls back failed queued checkouts to the last checked-out branch', async () => { + test('keeps the last checked-out branch while queued checkouts fail', async () => { const firstCheckoutStarted = new DeferredPromise(); const releaseFirstCheckout = new DeferredPromise(); const secondCheckoutStarted = new DeferredPromise(); @@ -857,7 +992,7 @@ suite('Agent Host Session Config Picker', () => { await firstCheckoutStarted.p; services.actionWidget.delegate?.onSelect({ value: 'featureB', label: 'featureB' }); await new Promise(resolve => setTimeout(resolve)); - assert.strictEqual(branchLabel(container), 'featureA'); + assert.strictEqual(branchLabel(container), 'main'); releaseFirstCheckout.complete(); await secondCheckoutStarted.p; @@ -869,13 +1004,8 @@ suite('Agent Host Session Config Picker', () => { branchSelectionEvents: services.branchSelectionEvents, }, { branch: 'main', - configUpdates: [ - { sessionId: SESSION_ID, property: SessionConfigKey.Branch, value: 'featureA' }, - { sessionId: SESSION_ID, property: SessionConfigKey.Branch, value: 'main' }, - { sessionId: SESSION_ID, property: SessionConfigKey.Branch, value: 'featureB' }, - { sessionId: SESSION_ID, property: SessionConfigKey.Branch, value: 'main' }, - ], - branchSelectionEvents: ['set:featureA', 'checkout', 'set:main', 'set:featureB', 'checkout', 'set:main'], + configUpdates: [], + branchSelectionEvents: ['checkout', 'checkout'], }); }); diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts index 4357c9a561d250..35e435ee60adaa 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts @@ -6,13 +6,14 @@ import { Emitter, Event } from '../../../../../base/common/event.js'; import { raceCancellationError, raceTimeout } from '../../../../../base/common/async.js'; import { Codicon } from '../../../../../base/common/codicons.js'; -import { CancellationError } from '../../../../../base/common/errors.js'; +import { CancellationError, isCancellationError } from '../../../../../base/common/errors.js'; import { IMarkdownString, MarkdownString, markdownStringEqual } from '../../../../../base/common/htmlContent.js'; import { Disposable, DisposableStore, IDisposable, DisposableMap, MutableDisposable } from '../../../../../base/common/lifecycle.js'; import { Schemas } from '../../../../../base/common/network.js'; import { autorun, constObservable, derived, derivedOpts, IObservable, IObservableSignal, IReader, ISettableObservable, ITransaction, observableFromPromise, observableSignal, observableValue, observableValueOpts, runOnChange, transaction } from '../../../../../base/common/observable.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; +import { isWeb } from '../../../../../base/common/platform.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; @@ -24,11 +25,12 @@ import { AgentSessionProviders, AgentSessionTarget } from '../../../../../workbe import { IChatService, IChatSendRequestOptions } from '../../../../../workbench/contrib/chat/common/chatService/chatService.js'; import { IChatResponseModel } from '../../../../../workbench/contrib/chat/common/model/chatModel.js'; import { ChatSessionStatus, IChatSessionsService, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, SessionType } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js'; -import { ChatModelSource, ISession, IChat, ISessionGitRepository, ISessionFolder, ISessionWorkspace, ISideChatSelection, SessionStatus, GITHUB_REMOTE_FILE_SCHEME, IGitHubInfo, ISessionType, ISessionWorkspaceBrowseAction, ISessionFileChange, sessionFileChangesEqual, gitHubInfoEqual, sessionWorkspaceEqual, toSessionId, SESSION_WORKSPACE_GROUP_LOCAL, SESSION_WORKSPACE_GROUP_GITHUB, ISessionChangeset, IChatCheckpoints, ChatInteractivity, SessionTypeAuthRequirement } from '../../../../services/sessions/common/session.js'; +import { ChatModelSource, ISession, IChat, ISessionGitRepository, ISessionFolder, ISessionWorkspace, ISideChatSelection, SessionStatus, GITHUB_REMOTE_FILE_SCHEME, IGitHubInfo, ISessionType, ISessionWorkspaceBrowseAction, ISessionFileChange, sessionFileChangesEqual, gitHubInfoEqual, sessionWorkspaceEqual, toSessionId, SESSION_WORKSPACE_GROUP_LOCAL, SESSION_WORKSPACE_GROUP_GITHUB, ISessionChangeset, IChatCheckpoints, ChatInteractivity, SessionTypeAuthRequirement, ISessionChangesSummary } from '../../../../services/sessions/common/session.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind, ChatPermissionLevel, isChatPermissionLevel } from '../../../../../workbench/contrib/chat/common/constants.js'; -import { basename, dirname, isEqual, isEqualOrParent } from '../../../../../base/common/resources.js'; +import { basename, dirname, isEqual } from '../../../../../base/common/resources.js'; import { IDeleteChatOptions, ISendRequestOptions, ISessionChangeEvent, ISessionModelPickerOptions, ISessionModelsSnapshot, ISessionsProvider } from '../../../../services/sessions/common/sessionsProvider.js'; import { ISessionOptionGroup } from '../../../chat/browser/newSession.js'; +import { UNIFIED_WORKSPACE_PICKER_SETTING } from '../../../chat/common/constants.js'; import { ILanguageModelToolsService } from '../../../../../workbench/contrib/chat/common/tools/languageModelToolsService.js'; import { ChatMode, IChatMode, IChatModeService, isBuiltinChatMode } from '../../../../../workbench/contrib/chat/common/chatModes.js'; import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js'; @@ -59,6 +61,7 @@ import { IAgentHostEnablementService } from '../../../../../platform/agentHost/c import { isCloudSandboxEnabled } from '../../../../../platform/agentHost/common/cloudSandboxAgentHost.js'; import { getWorkbenchContribution } from '../../../../../workbench/common/contributions.js'; import { CloudSandboxAgentHostContribution, type ICloudSandboxProvisionedSession } from '../../remoteAgentHost/browser/cloudSandboxAgentHostContribution.js'; +import { IPathService } from '../../../../../workbench/services/path/common/pathService.js'; /** Copilot Cloud session type - cloud-hosted agent. */ export const CopilotCloudSessionType: ISessionType = { @@ -99,6 +102,8 @@ export interface ICopilotChatSession { readonly updatedAt: IObservable; /** Current session status. */ readonly status: IObservable; + /** Summary of file changes produced by the session. */ + readonly changesSummary?: IObservable; /** File changes produced by the session. */ readonly changes: IObservable; /** Currently selected model identifier. */ @@ -182,6 +187,10 @@ function isNewSession(session: ICopilotChatSession): session is NewSession { return session instanceof CopilotCLISession || session instanceof RemoteNewSession; } +function isChangesSummary(changes: IAgentSession['changes']): changes is { readonly files: number; readonly insertions: number; readonly deletions: number } { + return !!changes && !Array.isArray(changes); +} + /** * Builds an {@link IChat} snapshot from an {@link ICopilotChatSession}. Used to * seed the chat's own `mainChat` observable. @@ -669,7 +678,7 @@ export class RemoteNewSession extends Disposable implements ICopilotChatSession /** * The repository this session targets, as `owner/repo`. A GitHub workspace root carries a ref - * (`///HEAD`, see {@link CopilotChatSessionsProvider._browseForRepo}), so this + * (`///HEAD`, see {@link CopilotChatSessionsProvider._browseForCloudRepo}), so this * takes only the first two path segments rather than the whole path. */ get repoNwo(): string | undefined { @@ -900,23 +909,13 @@ function githubRemoteRepoLabel(uri: URI): string | undefined { return parts.length >= 2 ? `${parts[0]}/${parts[1]}` : undefined; } -async function resolveGitHubRepositoryId(folder: ISessionFolder, gitService: Pick): Promise { +function resolveGitHubRepositoryId(folder: ISessionFolder): string | undefined { const gitHubInfo = folder.gitRepository?.gitHubInfo.get(); if (gitHubInfo) { return `${gitHubInfo.owner}/${gitHubInfo.repo}`; } - const remoteRepositoryId = githubRemoteRepoLabel(folder.root); - if (remoteRepositoryId || folder.root.scheme !== Schemas.file) { - return remoteRepositoryId; - } - - const repository = await gitService.openRepository(folder.gitRepository?.uri ?? folder.root); - if (!repository || !isEqualOrParent(folder.root, repository.rootUri)) { - return undefined; - } - const repositoryInfo = repository && getGitHubRemoteInfo(repository.state.get()); - return repositoryInfo ? `${repositoryInfo.owner}/${repositoryInfo.repo}` : undefined; + return githubRemoteRepoLabel(folder.root); } /** @@ -946,6 +945,9 @@ class AgentSessionAdapter implements ICopilotChatSession { private readonly _changes: ReturnType>; readonly changes: IObservable; + private readonly _changesSummary: ReturnType>; + readonly changesSummary: IObservable; + private readonly _checkpoints: ReturnType>; readonly checkpoints: IObservable; @@ -1067,6 +1069,9 @@ class AgentSessionAdapter implements ICopilotChatSession { this._changes = observableValueOpts({ owner: this, equalsFn: sessionFileChangesEqual }, this._extractChanges(session)); this.changes = this._changes; + this._changesSummary = observableValueOpts({ owner: this, equalsFn: structuralEquals }, this._extractChangesSummary(session)); + this.changesSummary = this._changesSummary; + this._checkpoints = observableValueOpts({ owner: this, equalsFn: structuralEquals }, this._extractCheckpoints(session)); this.checkpoints = this._checkpoints; @@ -1123,6 +1128,7 @@ class AgentSessionAdapter implements ICopilotChatSession { changed = setIfChanged(this._updatedAt, new Date(updatedTime), tx, dateEquals) || changed; changed = setIfChanged(this._status, toSessionStatus(session.status), tx) || changed; changed = setIfChanged(this._changes, this._extractChanges(session), tx, sessionFileChangesEqual) || changed; + changed = setIfChanged(this._changesSummary, this._extractChangesSummary(session), tx, structuralEquals) || changed; changed = setIfChanged(this._checkpoints, this._extractCheckpoints(session), tx, structuralEquals) || changed; changed = setIfChanged(this._isArchived, session.isArchived(), tx) || changed; changed = setIfChanged(this._isRead, session.isRead(), tx) || changed; @@ -1272,22 +1278,18 @@ class AgentSessionAdapter implements ICopilotChatSession { } private _extractChanges(session: IAgentSession): readonly ISessionFileChange[] { - if (!session.changes) { - return []; - } - if (Array.isArray(session.changes)) { - return session.changes as ISessionFileChange[]; - } - // Summary object — create a synthetic entry for total insertions/deletions - const summary = session.changes as { readonly files: number; readonly insertions: number; readonly deletions: number }; - if (summary.insertions > 0 || summary.deletions > 0) { - return [{ - modifiedUri: URI.parse('summary://changes'), - insertions: summary.insertions, - deletions: summary.deletions, - }]; + return session.changes && !isChangesSummary(session.changes) ? session.changes : []; + } + + private _extractChangesSummary(session: IAgentSession): ISessionChangesSummary | undefined { + if (!isChangesSummary(session.changes)) { + return undefined; } - return []; + return { + files: session.changes.files, + additions: session.changes.insertions, + deletions: session.changes.deletions, + }; } private _extractCheckpoints(session: IAgentSession): IChatCheckpoints | undefined { @@ -1508,7 +1510,6 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions return !this.agentHostEnablementService.enabled.get(); } - readonly browseActions: readonly ISessionWorkspaceBrowseAction[]; readonly supportsLocalWorkspaces = true; constructor( @@ -1530,6 +1531,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions @IChatModeService private readonly chatModeService: IChatModeService, @IUriIdentityService private readonly uriIdentityService: IUriIdentityService, @IGitService private readonly gitService: IGitService, + @IPathService private readonly pathService: IPathService, ) { super(); @@ -1540,15 +1542,59 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions this._refreshSessionCache(); })); - this.browseActions = [ - { + // Forward session changes from the underlying model + this._register(this.agentSessionsService.model.onDidChangeSessions(() => { + this._refreshSessionCache(); + })); + + this._registerGroupMembershipFanOut(); + this._ensureSessionCache(); + } + + get browseActions(): readonly ISessionWorkspaceBrowseAction[] { + const useConsolidatedRemoteWorkspaces = this.configurationService.getValue(UNIFIED_WORKSPACE_PICKER_SETTING); + const repositoryActions: ISessionWorkspaceBrowseAction[] = useConsolidatedRemoteWorkspaces + ? [ + ...(!isWeb && this.pathService.defaultUriScheme === Schemas.file ? [ + { + label: localize('addGitHubRepository', "Add GitHub Repository..."), + group: SESSION_WORKSPACE_GROUP_GITHUB, + icon: Codicon.github, + providerId: this.id, + attachesContext: false, + run: () => this._browseForGitHubRepo(), + }, + { + label: localize('cloneRepository', "Clone Repository..."), + group: SESSION_WORKSPACE_GROUP_GITHUB, + icon: Codicon.link, + providerId: this.id, + attachesContext: false, + run: () => this._cloneRepository(), + }, + ] satisfies ISessionWorkspaceBrowseAction[] : []), + { + label: localize('useRepositoryInCloud', "Use Repository in Cloud..."), + group: SESSION_WORKSPACE_GROUP_GITHUB, + icon: Codicon.cloud, + providerId: this.id, + attachesContext: false, + supportsContextAttachment: true, + run: () => this._browseForCloudRepo(), + }, + ] + : [{ label: localize('repository', "Repository..."), group: SESSION_WORKSPACE_GROUP_GITHUB, icon: Codicon.library, providerId: this.id, attachesContext: false, - run: () => this._browseForRepo(), - }, + supportsContextAttachment: true, + run: () => this._browseForCloudRepo(), + }]; + + return [ + ...repositoryActions, { label: localize('issue', "Issue..."), group: SESSION_WORKSPACE_GROUP_GITHUB, @@ -1560,20 +1606,12 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions { label: localize('pullRequest', "Pull Request..."), group: SESSION_WORKSPACE_GROUP_GITHUB, - icon: Codicon.gitPullRequest, + icon: useConsolidatedRemoteWorkspaces ? Codicon.github : Codicon.gitPullRequest, providerId: this.id, attachesContext: true, - run: workspace => this._browseForGitHubContext(OPEN_PULL_REQUEST_COMMAND, Codicon.gitPullRequest, workspace), + run: workspace => this._browseForGitHubContext(OPEN_PULL_REQUEST_COMMAND, useConsolidatedRemoteWorkspaces ? Codicon.github : Codicon.gitPullRequest, workspace), }, ]; - - // Forward session changes from the underlying model - this._register(this.agentSessionsService.model.onDidChangeSessions(() => { - this._refreshSessionCache(); - })); - - this._registerGroupMembershipFanOut(); - this._ensureSessionCache(); } // -- Sessions -- @@ -2732,47 +2770,78 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions // -- Private -- - private async _browseForRepo(): Promise { + private async _browseForGitHubRepo(): Promise { const repoId = await this.commandService.executeCommand(OPEN_REPO_COMMAND); - if (repoId) { - const uri = URI.from({ scheme: GITHUB_REMOTE_FILE_SCHEME, authority: 'github', path: `/${repoId}/HEAD` }); - const folder: ISessionFolder = { - root: uri, - workingDirectory: uri, - name: basename(uri), - description: undefined, - gitRepository: undefined, - }; - return { - uri: URI.parse(`https://github.com/${repoId}`), - label: this._labelFromUri(uri), - icon: this._iconFromUri(uri), - group: SESSION_WORKSPACE_GROUP_GITHUB, - folders: [folder], - requiresWorkspaceTrust: false, - isVirtualWorkspace: true, - }; + if (!repoId) { + return undefined; + } + return this._cloneRepository(`https://github.com/${repoId}.git`); + } + + private async _cloneRepository(url?: string): Promise { + try { + const repositoryPath = await this.commandService.executeCommand( + 'git.clone', + url, + undefined, + { postCloneAction: 'none' }, + ); + if (repositoryPath?.endsWith('.code-workspace')) { + this.notificationService.error(localize('cloneRepository.workspaceFile', "The selected clone is a workspace file. Choose Clone again to select a repository folder.")); + return undefined; + } + return repositoryPath ? this.resolveWorkspace(URI.file(repositoryPath)) : undefined; + } catch (error) { + if (!isCancellationError(error)) { + this.notificationService.error(error); + } + return undefined; } - return undefined; + } + + private async _browseForCloudRepo(): Promise { + const repoId = await this.commandService.executeCommand(OPEN_REPO_COMMAND); + if (!repoId) { + return undefined; + } + const uri = URI.from({ scheme: GITHUB_REMOTE_FILE_SCHEME, authority: 'github', path: `/${repoId}/HEAD` }); + const folder: ISessionFolder = { + root: uri, + workingDirectory: uri, + name: basename(uri), + description: undefined, + gitRepository: undefined, + }; + return { + uri: URI.parse(`https://github.com/${repoId}`), + label: this._labelFromUri(uri), + icon: this._iconFromUri(uri), + group: SESSION_WORKSPACE_GROUP_GITHUB, + folders: [folder], + requiresWorkspaceTrust: false, + isVirtualWorkspace: true, + }; } private async _browseForGitHubContext(commandId: string, icon: ThemeIcon, currentWorkspace: ISessionWorkspace | undefined): Promise { const repositoryIds = new Set(); for (const folder of currentWorkspace?.folders ?? []) { - const repositoryId = await resolveGitHubRepositoryId(folder, this.gitService); + const repositoryId = resolveGitHubRepositoryId(folder); if (repositoryId) { repositoryIds.add(repositoryId); } } - const repoId = repositoryIds.size === 1 + const repository = repositoryIds.size === 1 ? repositoryIds.values().next().value - : await this.commandService.executeCommand(OPEN_REPO_COMMAND); - if (!repoId) { + : currentWorkspace?.folders.length === 1 && currentWorkspace.folders[0].root.scheme === Schemas.file + ? currentWorkspace.folders[0].root + : await this.commandService.executeCommand(OPEN_REPO_COMMAND); + if (!repository) { return undefined; } - const selection = await this.commandService.executeCommand(commandId, repoId); + const selection = await this.commandService.executeCommand(commandId, repository); if (!selection) { return undefined; } @@ -3357,6 +3426,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions updatedAt: chatsObs.map((chats, reader) => this._latestDate(chats, c => c.updatedAt.read(reader))!), status: chatsObs.map((chats, reader) => this._aggregateStatus(chats, reader)), changesets: this._createChangesets(primaryChat.sessionType, primaryChat.workspace, chatsObs), + changesSummary: primaryChat.changesSummary, changes: primaryChat.changes, modelId: primaryChat.modelId, mode: primaryChat.mode, @@ -3399,6 +3469,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions updatedAt: chat.updatedAt, status: chat.status, changesets, + changesSummary: chat.changesSummary, changes: chat.changes, modelId: chat.modelId, mode: chat.mode, diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts index 9f75811203a094..5cf1ee5a28091b 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts @@ -11,6 +11,8 @@ import { DisposableStore, IDisposable, ImmortalReference, toDisposable } from '. import { ThemeIcon } from '../../../../../../base/common/themables.js'; import { URI } from '../../../../../../base/common/uri.js'; import { generateUuid } from '../../../../../../base/common/uuid.js'; +import { Schemas } from '../../../../../../base/common/network.js'; +import { isWeb } from '../../../../../../base/common/platform.js'; import { mock, upcastPartial } from '../../../../../../base/test/common/mock.js'; import { autorun, constObservable, ISettableObservable, observableValue } from '../../../../../../base/common/observable.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; @@ -36,12 +38,13 @@ import { IChatResponseModel } from '../../../../../../workbench/contrib/chat/com import { IChatAgentData } from '../../../../../../workbench/contrib/chat/common/participants/chatAgents.js'; import { IGitRepository, IGitService } from '../../../../../../workbench/contrib/git/common/gitService.js'; import { ISessionChangeEvent } from '../../../../../services/sessions/common/sessionsProvider.js'; -import { ChatModelSource, GITHUB_REMOTE_FILE_SCHEME, IChat, ISession, ISessionWorkspace, SESSION_WORKSPACE_GROUP_GITHUB, SESSION_WORKSPACE_GROUP_LOCAL, SessionStatus } from '../../../../../services/sessions/common/session.js'; +import { ChatModelSource, GITHUB_REMOTE_FILE_SCHEME, IChat, ISession, ISessionChangesSummary, ISessionFileChange, ISessionWorkspace, SESSION_WORKSPACE_GROUP_GITHUB, SESSION_WORKSPACE_GROUP_LOCAL, SessionStatus } from '../../../../../services/sessions/common/session.js'; import { CloudSandboxEnabledSettingId, type ICloudSandboxCreateSessionRequest } from '../../../../../../platform/agentHost/common/cloudSandboxAgentHost.js'; import { RemoteAgentHostsEnabledSettingId } from '../../../../../../platform/agentHost/common/remoteAgentHostService.js'; import { CloudSandboxAgentHostContribution, type ICloudSandboxProvisionedSession } from '../../../remoteAgentHost/browser/cloudSandboxAgentHostContribution.js'; import { CloudSandboxSessionsProvider } from '../../../remoteAgentHost/browser/cloudSandboxSessionsProvider.js'; import { ChatConfiguration, ChatPermissionLevel } from '../../../../../../workbench/contrib/chat/common/constants.js'; +import { UNIFIED_WORKSPACE_PICKER_SETTING } from '../../../../chat/common/constants.js'; import { CopilotChatSessionsProvider, COPILOT_PROVIDER_ID, CopilotCloudSessionType, ICopilotChatSession } from '../../browser/copilotChatSessionsProvider.js'; import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; import { INotificationService } from '../../../../../../platform/notification/common/notification.js'; @@ -63,7 +66,15 @@ import { computePullRequestIcon, GitHubPullRequestState, IGitHubPullRequest } fr interface IGitHubContextBrowseHarness { readonly commandService: Pick; - readonly gitService: Pick; +} + +interface IGitHubRepositoryBrowseHarness { + readonly commandService: Pick; + readonly notificationService: Pick; + resolveWorkspace(uri: URI): ISessionWorkspace | undefined; + _labelFromUri(uri: URI): string; + _iconFromUri(uri: URI): ThemeIcon; + _cloneRepository?(url?: string): Promise; } const browseForGitHubContext = Reflect.get(CopilotChatSessionsProvider.prototype, '_browseForGitHubContext') as ( @@ -73,6 +84,19 @@ const browseForGitHubContext = Reflect.get(CopilotChatSessionsProvider.prototype currentWorkspace: ISessionWorkspace | undefined, ) => Promise; +const browseForGitHubRepo = Reflect.get(CopilotChatSessionsProvider.prototype, '_browseForGitHubRepo') as ( + this: IGitHubRepositoryBrowseHarness, +) => Promise; + +const cloneRepository = Reflect.get(CopilotChatSessionsProvider.prototype, '_cloneRepository') as ( + this: IGitHubRepositoryBrowseHarness, + url?: string, +) => Promise; + +const browseForCloudRepo = Reflect.get(CopilotChatSessionsProvider.prototype, '_browseForCloudRepo') as ( + this: IGitHubRepositoryBrowseHarness, +) => Promise; + function createMockAgentSession(resource: URI, opts?: { providerType?: string; title?: string; @@ -80,6 +104,7 @@ function createMockAgentSession(resource: URI, opts?: { read?: boolean; createdAt?: number; status?: ChatSessionStatus; + changes?: IAgentSession['changes']; metadata?: Record; onSetRead?: () => void; }): IAgentSession { @@ -94,6 +119,7 @@ function createMockAgentSession(resource: URI, opts?: { override readonly status = opts?.status ?? ChatSessionStatus.Completed; override readonly icon = Codicon.copilot; override readonly timing = { created: opts?.createdAt ?? Date.now(), lastRequestStarted: undefined, lastRequestEnded: undefined }; + override readonly changes = opts?.changes; override readonly metadata = opts?.metadata ?? { repositoryPath: '/test/repo' }; override isArchived(): boolean { return archived; } override setArchived(value: boolean): void { archived = value; } @@ -165,6 +191,7 @@ interface IExecutedCommand { interface ICreateProviderOptions { readonly multiChatEnabled?: boolean; + readonly consolidatedRemoteWorkspaces?: boolean; readonly agentHostEnabled?: boolean; readonly commandExecutions?: IExecutedCommand[]; readonly getOptionGroups?: () => IChatSessionProviderOptionGroup[] | undefined; @@ -172,6 +199,7 @@ interface ICreateProviderOptions { readonly gitHubService?: IGitHubService; readonly gitService?: IGitService; readonly pullRequestIconCache?: IPullRequestIconCache; + readonly pathService?: IPathService; } function isCommandSessionItem(item: unknown): item is { readonly resource: URI; readonly label?: string } { @@ -262,6 +290,7 @@ function createProviderWithConfig( const configService = new TestConfigurationService(); configService.setUserConfiguration('sessions.github.copilot.multiChatSessions', opts?.multiChatEnabled ?? true); + configService.setUserConfiguration(UNIFIED_WORKSPACE_PICKER_SETTING, opts?.consolidatedRemoteWorkspaces ?? false); const agentHostEnabled = observableValue('agentHostEnabled', opts?.agentHostEnabled ?? true); instantiationService.stub(IConfigurationService, configService); @@ -326,7 +355,7 @@ function createProviderWithConfig( instantiationService.stub(IInstantiationService, instantiationService); const labelService = new MockLabelService(); instantiationService.stub(ILabelService, labelService); - instantiationService.stub(IPathService, new TestPathService(URI.file('/home/test'))); + instantiationService.stub(IPathService, opts?.pathService ?? new TestPathService(URI.file('/home/test'))); instantiationService.stub(IUriIdentityService, { extUri }); instantiationService.stub(IGitService, opts?.gitService ?? { repositories: [], openRepository: async () => undefined }); instantiationService.stub(IGitHubService, opts?.gitHubService ?? new TestGitHubService()); @@ -458,6 +487,202 @@ suite('CopilotChatSessionsProvider', () => { assert.strictEqual(provider.sessionTypes.length, 1); }); + test('offers local repository acquisition separately from Cloud', () => { + const localProvider = createProvider(disposables, model, { consolidatedRemoteWorkspaces: true }); + const remoteProvider = createProvider(disposables, model, { + consolidatedRemoteWorkspaces: true, + pathService: new TestPathService(URI.file('/home/test'), Schemas.vscodeRemote), + }); + + assert.deepStrictEqual({ + local: localProvider.browseActions.map(action => ({ label: action.label, icon: action.icon.id })), + remote: remoteProvider.browseActions.map(action => ({ label: action.label, icon: action.icon.id })), + }, { + local: [ + ...isWeb ? [] : [ + { label: 'Add GitHub Repository...', icon: 'github' }, + { label: 'Clone Repository...', icon: 'link' }, + ], + { label: 'Use Repository in Cloud...', icon: 'cloud' }, + { label: 'Issue...', icon: 'issues' }, + { label: 'Pull Request...', icon: 'github' }, + ], + remote: [ + { label: 'Use Repository in Cloud...', icon: 'cloud' }, + { label: 'Issue...', icon: 'issues' }, + { label: 'Pull Request...', icon: 'github' }, + ], + }); + }); + + test('preserves the legacy repository action when unified workspaces are disabled', () => { + const provider = createProvider(disposables, model); + + assert.deepStrictEqual(provider.browseActions.map(action => ({ label: action.label, icon: action.icon.id })), [ + { label: 'Repository...', icon: 'library' }, + { label: 'Issue...', icon: 'issues' }, + { label: 'Pull Request...', icon: 'git-pull-request' }, + ]); + }); + + test('updates repository actions when unified workspaces setting changes', () => { + const { provider, configService } = createProviderWithConfig(disposables, model); + const legacyActions = provider.browseActions.map(action => ({ label: action.label, icon: action.icon.id })); + + configService.setUserConfiguration(UNIFIED_WORKSPACE_PICKER_SETTING, true); + const unifiedActions = provider.browseActions.map(action => ({ label: action.label, icon: action.icon.id })); + + assert.deepStrictEqual({ + legacyActions, + unifiedActions, + }, { + legacyActions: [ + { label: 'Repository...', icon: 'library' }, + { label: 'Issue...', icon: 'issues' }, + { label: 'Pull Request...', icon: 'git-pull-request' }, + ], + unifiedActions: [ + ...isWeb ? [] : [ + { label: 'Add GitHub Repository...', icon: 'github' }, + { label: 'Clone Repository...', icon: 'link' }, + ], + { label: 'Use Repository in Cloud...', icon: 'cloud' }, + { label: 'Issue...', icon: 'issues' }, + { label: 'Pull Request...', icon: 'github' }, + ], + }); + }); + + test('adds a selected GitHub repository by cloning it locally', async () => { + const calls: { commandId: string; args: unknown[] }[] = []; + const harness: IGitHubRepositoryBrowseHarness = { + commandService: new class extends mock() { + override async executeCommand(commandId: string, ...args: unknown[]): Promise { + calls.push({ commandId, args }); + return (commandId === 'git.clone' ? '/repos/vscode' : 'microsoft/vscode') as T; + } + }(), + notificationService: upcastPartial({ error: () => undefined }), + resolveWorkspace: uri => ({ + uri, + label: 'vscode', + icon: Codicon.folder, + group: SESSION_WORKSPACE_GROUP_LOCAL, + folders: [{ root: uri, workingDirectory: uri, name: 'vscode', description: undefined, gitRepository: undefined }], + requiresWorkspaceTrust: true, + isVirtualWorkspace: false, + }), + _labelFromUri: () => 'vscode', + _iconFromUri: () => Codicon.repo, + _cloneRepository: url => cloneRepository.call(harness, url), + }; + + const workspace = await browseForGitHubRepo.call(harness); + + assert.deepStrictEqual({ + calls, + workspace: workspace && { + uri: workspace.uri.toString(), + group: workspace.group, + isVirtualWorkspace: workspace.isVirtualWorkspace, + }, + }, { + calls: [ + { commandId: 'github.copilot.chat.cloudSessions.openRepository', args: [] }, + { + commandId: 'git.clone', + args: [ + 'https://github.com/microsoft/vscode.git', + undefined, + { postCloneAction: 'none' }, + ], + }, + ], + workspace: { + uri: URI.file('/repos/vscode').toString(), + group: SESSION_WORKSPACE_GROUP_LOCAL, + isVirtualWorkspace: false, + }, + }); + }); + + test('clones a repository URL without opening the GitHub picker', async () => { + const calls: { commandId: string; args: unknown[] }[] = []; + const harness: IGitHubRepositoryBrowseHarness = { + commandService: new class extends mock() { + override async executeCommand(commandId: string, ...args: unknown[]): Promise { + calls.push({ commandId, args }); + return '/repos/vscode' as T; + } + }(), + notificationService: upcastPartial({ error: () => undefined }), + resolveWorkspace: uri => ({ + uri, + label: 'vscode', + icon: Codicon.folder, + group: SESSION_WORKSPACE_GROUP_LOCAL, + folders: [{ root: uri, workingDirectory: uri, name: 'vscode', description: undefined, gitRepository: undefined }], + requiresWorkspaceTrust: true, + isVirtualWorkspace: false, + }), + _labelFromUri: () => 'vscode', + _iconFromUri: () => Codicon.repo, + }; + + const workspace = await cloneRepository.call(harness); + + assert.deepStrictEqual({ + calls, + workspace: workspace?.uri.toString(), + }, { + calls: [ + { + commandId: 'git.clone', + args: [undefined, undefined, { postCloneAction: 'none' }], + }, + ], + workspace: URI.file('/repos/vscode').toString(), + }); + }); + + test('keeps Cloud as an explicit repository action', async () => { + const calls: { commandId: string; args: unknown[] }[] = []; + const harness: IGitHubRepositoryBrowseHarness = { + commandService: new class extends mock() { + override async executeCommand(commandId: string, ...args: unknown[]): Promise { + calls.push({ commandId, args }); + return 'microsoft/vscode' as T; + } + }(), + notificationService: upcastPartial({ error: () => undefined }), + resolveWorkspace: () => undefined, + _labelFromUri: () => 'microsoft/vscode', + _iconFromUri: () => Codicon.repo, + }; + + const workspace = await browseForCloudRepo.call(harness); + + assert.deepStrictEqual({ + calls, + workspace: workspace && { + uri: workspace.uri.toString(), + root: workspace.folders[0].root.toString(), + group: workspace.group, + isVirtualWorkspace: workspace.isVirtualWorkspace, + }, + }, { + calls: [ + { commandId: 'github.copilot.chat.cloudSessions.openRepository', args: [] }, + ], + workspace: { + uri: 'https://github.com/microsoft/vscode', + root: 'github-remote-file://github/microsoft/vscode/HEAD', + group: SESSION_WORKSPACE_GROUP_GITHUB, + isVirtualWorkspace: true, + }, + }); + }); + test('scopes issue and pull request browsing to a selected GitHub repository', async () => { const calls: { commandId: string; repoId: unknown }[] = []; const harness: IGitHubContextBrowseHarness = { @@ -471,7 +696,6 @@ suite('CopilotChatSessionsProvider', () => { } as T; } }(), - gitService: upcastPartial({ openRepository: async () => undefined }), }; const repositoryRoot = URI.from({ scheme: GITHUB_REMOTE_FILE_SCHEME, @@ -527,7 +751,6 @@ suite('CopilotChatSessionsProvider', () => { } as T; } }(), - gitService: upcastPartial({ openRepository: async () => undefined }), }; const repositoryRoot = (repositoryId: string) => URI.from({ scheme: GITHUB_REMOTE_FILE_SCHEME, @@ -572,20 +795,15 @@ suite('CopilotChatSessionsProvider', () => { }); }); - test('resolves an initially unknown GitHub remote before browsing context', async () => { + test('uses the selected folder without waiting for unresolved local GitHub metadata', async () => { const calls: { commandId: string; repoId: unknown }[] = []; - const repositoryState = observableValue('repositoryState', { - HEAD: undefined, - remotes: [{ name: 'origin', fetchUrl: 'https://github.com/microsoft/vscode.git', pushUrl: undefined, isReadOnly: false }], - mergeChanges: [], - indexChanges: [], - workingTreeChanges: [], - untrackedChanges: [], - }); const harness: IGitHubContextBrowseHarness = { commandService: new class extends mock() { override async executeCommand(commandId: string, repoId?: unknown): Promise { calls.push({ commandId, repoId }); + if (commandId === 'github.copilot.chat.cloudSessions.openRepository') { + return 'microsoft/vscode' as T; + } return { repoId: 'microsoft/vscode', url: 'https://github.com/microsoft/vscode/issues/1', @@ -593,9 +811,6 @@ suite('CopilotChatSessionsProvider', () => { } as T; } }(), - gitService: upcastPartial({ - openRepository: async () => upcastPartial({ rootUri: root, state: repositoryState }), - }), }; const root = URI.file('/test/vscode'); const workspace: ISessionWorkspace = { @@ -620,21 +835,15 @@ suite('CopilotChatSessionsProvider', () => { calls, issue: { uri: issue?.uri.toString(), label: issue?.label }, }, { - calls: [{ commandId: 'openIssue', repoId: 'microsoft/vscode' }], + calls: [ + { commandId: 'openIssue', repoId: root }, + ], issue: { uri: 'https://github.com/microsoft/vscode/issues/1', label: 'microsoft/vscode#1' }, }); }); - test('selects a repository when the selected folder has no matching Git root', async () => { + test('passes the selected folder through when it has no matching Git root', async () => { const calls: { commandId: string; repoId: unknown }[] = []; - const staleRepositoryState = observableValue('staleRepositoryState', { - HEAD: undefined, - remotes: [{ name: 'origin', fetchUrl: 'https://github.com/microsoft/old.git', pushUrl: undefined, isReadOnly: false }], - mergeChanges: [], - indexChanges: [], - workingTreeChanges: [], - untrackedChanges: [], - }); const harness: IGitHubContextBrowseHarness = { commandService: new class extends mock() { override async executeCommand(commandId: string, repoId?: unknown): Promise { @@ -649,12 +858,6 @@ suite('CopilotChatSessionsProvider', () => { } as T; } }(), - gitService: upcastPartial({ - openRepository: async () => upcastPartial({ - rootUri: URI.file('/test/old-repository'), - state: staleRepositoryState, - }), - }), }; const root = URI.file('/test/new-folder'); const workspace: ISessionWorkspace = { @@ -670,8 +873,7 @@ suite('CopilotChatSessionsProvider', () => { await browseForGitHubContext.call(harness, 'openIssue', Codicon.issues, workspace); assert.deepStrictEqual(calls, [ - { commandId: 'github.copilot.chat.cloudSessions.openRepository', repoId: undefined }, - { commandId: 'openIssue', repoId: 'microsoft/vscode' }, + { commandId: 'openIssue', repoId: root }, ]); }); @@ -740,6 +942,38 @@ suite('CopilotChatSessionsProvider', () => { assert.strictEqual(sessions.length, 2); }); + test('adapts and atomically refreshes aggregate change metadata without synthetic file changes', () => { + const resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/session' }); + model.addSession(createMockAgentSession(resource, { + changes: { files: 2, insertions: 12, deletions: 4 }, + })); + + const provider = createProvider(disposables, model); + const session = provider.getSessions()[0]; + const observed: { readonly changes: readonly ISessionFileChange[]; readonly changesSummary: ISessionChangesSummary | undefined }[] = []; + disposables.add(autorun(reader => { + observed.push({ + changes: session.changes.read(reader), + changesSummary: session.changesSummary?.read(reader), + }); + })); + + model.replaceSession(createMockAgentSession(resource, { + changes: { files: 3, insertions: 20, deletions: 6 }, + })); + + assert.deepStrictEqual(observed, [ + { + changes: [], + changesSummary: { files: 2, additions: 12, deletions: 4 }, + }, + { + changes: [], + changesSummary: { files: 3, additions: 20, deletions: 6 }, + }, + ]); + }); + test('getSessions does not emit session changes while reading the initial cache', () => { const resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/session' }); model.addSession(createMockAgentSession(resource)); diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostContribution.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostContribution.ts index aecff61bf5893f..b0820787decfd5 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostContribution.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostContribution.ts @@ -44,7 +44,7 @@ import { IWorkbenchContribution } from '../../../../../workbench/common/contribu import { ChatSessionsExtensions, IAsyncChatSessionActivationRegistry, IChatSessionsService } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { CloudSandboxReadOnlySessionHandler } from './cloudSandboxReadOnlySessionHandler.js'; import { IAgentHostFilterService } from '../../../../services/agentHostFilter/common/agentHostFilter.js'; -import { IAgentHostGroup } from '../../../../common/agentHostSessionsProvider.js'; +import { IAgentHostConnectionLabels, IAgentHostGroup } from '../../../../common/agentHostSessionsProvider.js'; import { ISession } from '../../../../services/sessions/common/session.js'; import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; import { IAgentHostSessionSchemeAlias } from '../../../../../platform/agentHost/common/agentHostConnectionsService.js'; @@ -78,6 +78,18 @@ const CLOUD_SANDBOX_HOST_GROUP: IAgentHostGroup = { connectable: false, }; +/** Names the environment rather than the task used as the provider's display name. */ +const CLOUD_SANDBOX_CONNECTION_LABELS: IAgentHostConnectionLabels = { + unavailableTitle: localize('cloudSandbox.offlineTitle', "Environment Offline"), + unavailable: localize('cloudSandbox.offline', "Environment offline."), + connectingTitle: localize('cloudSandbox.connectingTitle', "Connecting to the Environment"), + connecting: localize('cloudSandbox.connecting', "Connecting..."), + reconnecting: localize('cloudSandbox.reconnecting', "Reconnecting..."), + reconnectingIn: seconds => localize('cloudSandbox.reconnectingIn', "Reconnecting in {0}s", seconds), + incompatibleTitle: localize('cloudSandbox.incompatibleTitle', "Cannot Connect to the Environment"), + incompatible: localize('cloudSandbox.incompatible', "This environment is incompatible with this version of Visual Studio Code."), +}; + /** A discovered sandbox environment we can create a provider for. */ interface ICloudSandboxEnvironment { readonly environmentId: string; @@ -134,8 +146,6 @@ export class CloudSandboxAgentHostContribution extends Disposable implements IWo * Disposed when the environment becomes reachable again. */ private readonly _readOnlyHandlers = this._register(new DisposableMap()); - /** Live handler instances, so an already-open session can be settled read-only in place. */ - private readonly _readOnlyInstances = new Map(); /** * Cancelled when the feature is disabled (or the contribution is disposed), so in-flight * discovery and connects abort instead of committing state after teardown has run. @@ -453,59 +463,45 @@ export class CloudSandboxAgentHostContribution extends Disposable implements IWo return authority ? byAuthority.get(authority) : undefined; } - /** - * Async-activation hook for a sandbox session type: establish the relay connection on demand, - * then resolve once the host advertises the agent backing this session type (its content - * provider is registered), so the chat can load. Returns false if the environment is unknown, - * the connection fails, or the agent never appears. - */ - private async _waitForActivation(sessionType: string): Promise { + /** Opens an online environment through its host, or an offline session from persisted history. */ + protected async _waitForActivation(sessionType: string): Promise { const address = this._findAddressForSessionType(sessionType); const env = address ? this._environments.get(address) : undefined; - if (!address || !env) { + const provider = address ? this._providerInstances.get(address) : undefined; + if (!address || !env || !provider) { return false; } - // Both start before any `await` so they overlap: `/connect` blocks on the compute resume and - // can occupy its whole budget while the transcript already sits ready. - const connecting = this.connect({ environmentId: env.environmentId, sessionId: env.sessionId, name: env.name }); - // Settled into a value so the race below can inspect it without an unhandled rejection. - const connectOutcome = connecting.then(() => undefined, (error: unknown) => error ?? new Error('connect failed')); - const prefetchedHistory = this._prefetchHistoryIfDormant(env); - - if (prefetchedHistory) { - // Whichever lands first decides what the user sees. The connect keeps running either - // way: if it lands later, `onDidChangeConnections` drops the stand-in. - const historyFirst = await Promise.race([ - connectOutcome.then(() => undefined), - prefetchedHistory, - ]); - if (historyFirst && this._isEnabled() && !this._enabledCts.token.isCancellationRequested) { - this._logService.info(`${LOG_PREFIX} History for ${address} arrived before the connect settled; opening it now.`); - const opened = this._activateReadOnly(sessionType, address, env, prefetchedHistory); - // On screen but undecided: a failed connect disables the composer in place. - void connectOutcome.then(connectError => { - if (connectError !== undefined && this._isEnabled() && !this._enabledCts.token.isCancellationRequested) { - this._logService.info(`${LOG_PREFIX} Connect for ${address} failed after the session opened; settling it read-only.`); - this._settleReadOnly(sessionType, address); - } - }); - return opened; + const token = this._enabledCts.token; + const isCurrentActivation = () => { + const current = !token.isCancellationRequested + && this._isEnabled() + && this._environments.has(address) + && this._providerInstances.get(address) === provider; + if (!current) { + this._logService.trace(`${LOG_PREFIX} Abandoning activation for ${address} after teardown.`); } + return current; + }; + + // Without a task there is no history fallback, so connecting is the only way to open it. + const shouldConnect = !env.taskId || await this._isEnvironmentOnline(env, token); + if (!isCurrentActivation()) { + return false; + } + if (!shouldConnect) { + this._logService.info(`${LOG_PREFIX} Environment for ${address} is not online; serving history and leaving the connect to the user.`); + return this._activateReadOnly(sessionType, address, env, this._fetchTaskHistory(env, token)); } - const connectError = await connectOutcome; + const connectError = await this + .connect({ environmentId: env.environmentId, sessionId: env.sessionId, name: env.name }) + .then(() => undefined, (error: unknown) => error ?? new Error('connect failed')); + if (!isCurrentActivation()) { + return false; + } if (connectError !== undefined) { this._logService.warn(`${LOG_PREFIX} connect-on-open failed for ${address}: ${connectError instanceof Error ? connectError.message : String(connectError)}`); - // Serve history whatever the reason: `/connect` fails in several ways for a deleted - // sandbox, so gating on any one of them would leave the rest with no history. - if (this._isEnabled() && !this._enabledCts.token.isCancellationRequested) { - const opened = this._activateReadOnly(sessionType, address, env, prefetchedHistory); - if (opened) { - this._settleReadOnly(sessionType, address); - } - return opened; - } - return false; + return this._activateReadOnly(sessionType, address, env, this._fetchTaskHistory(env, token)); } const authority = agentHostAuthority(address); while (true) { @@ -524,41 +520,38 @@ export class CloudSandboxAgentHostContribution extends Disposable implements IWo } } - /** - * Persisted history for an environment that is not currently online, or `undefined` when it is - * online, has no task, or the read failed. - * - * `status` cannot predict whether a dormant environment will wake — suspended and deleted both - * read `offline` — but it does say cheaply that this open is on the slow path. Never rejects. - */ - private _prefetchHistoryIfDormant(env: ICloudSandboxEnvironment): Promise | undefined { + /** An unreadable record must not trigger an automatic resume. */ + private async _isEnvironmentOnline(env: ICloudSandboxEnvironment, token: CancellationToken): Promise { + try { + const record = await this._apiService.getEnvironment(env.environmentId, token); + return record.status === 'online'; + } catch (error) { + this._logService.trace(`${LOG_PREFIX} Could not read the state of ${env.environmentId}; treating it as not online: ${error instanceof Error ? error.message : String(error)}`); + return false; + } + } + + /** Reads history from Mission Control without connecting to the sandbox. */ + private _fetchTaskHistory(env: ICloudSandboxEnvironment, token: CancellationToken): Promise | undefined { const taskId = env.taskId; if (!taskId) { return undefined; } - const token = this._enabledCts.token; - return (async () => { - try { - const record = await this._apiService.getEnvironment(env.environmentId, token); - if (record.status === 'online') { - return undefined; - } - this._logService.trace(`${LOG_PREFIX} Environment ${env.environmentId} is '${record.status}'; prefetching history in case the connect does not land.`); - return await this._apiService.getSessionHistory(taskId, token); - } catch (error) { - this._logService.trace(`${LOG_PREFIX} History prefetch for ${env.environmentId} did not complete: ${error instanceof Error ? error.message : String(error)}`); - return undefined; - } - })(); + return this._apiService.getSessionHistory(taskId, token).catch((error: unknown) => { + this._logService.trace(`${LOG_PREFIX} History read for ${env.environmentId} did not complete: ${error instanceof Error ? error.message : String(error)}`); + return undefined; + }); } /** - * Register a content provider that serves this session from replayed history. + * Register a content provider that serves this session from replayed history, read-only. * - * Deliberately does *not* mark the session read-only: this also runs while a connect is in - * flight and the environment may yet wake — callers settle it via {@link _settleReadOnly}. - * Returns `true` once registered, which is what lets `canResolveChatSession` proceed, or `false` - * when there is no task to read history from. + * Only ever registered when the environment is not connected — dormant, or a connect that just + * failed — so the transcript is real but there is nothing to send to. A connect that later + * lands drops this stand-in and hands the session to the live handler. + * + * Returns `true` once registered, which is what lets `canResolveChatSession` proceed, or + * `false` when there is no task to read history from. */ private _activateReadOnly(sessionType: string, address: string, env: ICloudSandboxEnvironment, prefetchedHistory?: Promise): boolean { if (this._readOnlyHandlers.has(sessionType)) { @@ -583,37 +576,19 @@ export class CloudSandboxAgentHostContribution extends Disposable implements IWo connectionAuthority: agentHostAuthority(address), prefetchedHistory, })); + handler.markReadOnly(); store.add(this._chatSessionsService.registerChatSessionContentProvider(sessionType, handler)); this._readOnlyHandlers.set(sessionType, store); - this._readOnlyInstances.set(sessionType, handler); - store.add(toDisposable(() => this._readOnlyInstances.delete(sessionType))); this._logService.info(`${LOG_PREFIX} Serving ${sessionType} from Mission Control history.`); return true; } - /** - * Settle a history-backed session as read-only once the connect has failed. Sessions already on - * screen observe this and disable their composer in place, without needing a reopen. - */ - private _settleReadOnly(sessionType: string, address: string): void { - const handler = this._readOnlyInstances.get(sessionType); - if (!handler) { - // The live handler owns this session type, so there is nothing being served from - // history to settle — and forcing the host read-only here would be wrong. - return; - } - handler.markReadOnly(); - // The transcript is real, but there is no host left to send to. - this._providerInstances.get(address)?.setReadOnly(true); - } - /** * Drop any read-only stand-in for an address so the live handler can own the session type. * Registering two content providers for one session type throws, so this must run before a * connection is established rather than after. */ private _clearReadOnly(address: string): void { - this._providerInstances.get(address)?.setReadOnly(false); const authority = agentHostAuthority(address); for (const sessionType of [...this._readOnlyHandlers.keys()]) { if (findRemoteAgentHostSessionTypeAuthority(sessionType, [authority]) === authority) { @@ -653,6 +628,13 @@ export class CloudSandboxAgentHostContribution extends Disposable implements IWo throw new CancellationError(); } return result; + } catch (error) { + // Settle the status here rather than waiting for a connections-changed event: a + // wake that exhausts its retry budget fails before any transport entry exists, so + // no such event is coming and the provider would sit at `connecting` forever — + // a permanent spinner with no way back to the connect action. + this._settleFailedConnect(address); + throw error; } finally { this._pendingConnects.delete(address); } @@ -661,6 +643,24 @@ export class CloudSandboxAgentHostContribution extends Disposable implements IWo return attempt; } + /** + * Return a provider to a state the user can act on after its connect failed. Defers to the + * service when it has something live to report, so a failure that raced a successful dial does + * not overwrite a good status, and leaves `incompatible` alone since redialing cannot fix it. + */ + private _settleFailedConnect(address: string): void { + const provider = this._providerInstances.get(address); + if (!provider) { + return; + } + const connectionInfo = this._remoteAgentHostService.connections.find(c => c.address === address); + if (connectionInfo) { + provider.setConnectionStatus(connectionInfo.status); + } else if (!RemoteAgentHostConnectionStatus.isIncompatible(provider.connectionStatus.get())) { + provider.setConnectionStatus(RemoteAgentHostConnectionStatus.disconnected); + } + } + private _isEnabled(): boolean { return isCloudSandboxEnabled(this._configurationService); } @@ -701,6 +701,12 @@ export class CloudSandboxAgentHostContribution extends Disposable implements IWo omitHostFromWorkspaceLabel: true, // A sandbox is a disposable remote environment, not a checkout on disk. workspaceTypeIcon: Codicon.package, + // The sandbox has to be resumed before it can be sent to, and a resume is not + // guaranteed to succeed, so an offline session is read-only until it reconnects + // rather than accepting input that would queue against an environment that may + // never come back. + readOnlyWhenDisconnected: true, + connectionLabels: CLOUD_SANDBOX_CONNECTION_LABELS, hostGroup: CLOUD_SANDBOX_HOST_GROUP, }); store.add(provider); @@ -740,6 +746,12 @@ export class CloudSandboxAgentHostContribution extends Disposable implements IWo const connectionInfo = this._remoteAgentHostService.connections.find(c => c.address === address); if (connectionInfo) { provider.setConnectionStatus(connectionInfo.status); + } else if (this._pendingConnects.has(address)) { + // A connect is in flight but has not reached `reconnect()` yet, so the service has + // no entry to report: waking an environment can spend minutes minting credentials + // beforehand. Any unrelated connection change would otherwise land here and reset + // the wake to `disconnected`, flipping the chat to a failure it has not had. + continue; } else if (!RemoteAgentHostConnectionStatus.isIncompatible(provider.connectionStatus.get())) { provider.setConnectionStatus(RemoteAgentHostConnectionStatus.disconnected); } diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxReadOnlySessionHandler.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxReadOnlySessionHandler.ts index aa1a8231e4dd25..657aa29a3e729a 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxReadOnlySessionHandler.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxReadOnlySessionHandler.ts @@ -58,10 +58,11 @@ class ReadOnlyChatSession extends Disposable implements IChatSession { } /** - * Content provider for cloud sandbox sessions whose environment is gone. + * Content provider for cloud sandbox sessions served from Mission Control's persisted history + * rather than a live host — a dormant environment, or one whose connect failed. * - * Registered only after a connect attempt has failed terminally, and disposed as soon as a real - * connection is established, so it never shadows the live handler. + * Registered only while no connection exists, and disposed as soon as one is established, so it + * never shadows the live handler. */ export class CloudSandboxReadOnlySessionHandler extends Disposable implements IChatSessionContentProvider { diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts index 9a8a93f1f4cda8..eea4e91f11b958 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts @@ -35,7 +35,7 @@ import { IChatWidgetService } from '../../../../../workbench/contrib/chat/browse import { IChatService } from '../../../../../workbench/contrib/chat/common/chatService/chatService.js'; import { IChatSessionsService } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { ILanguageModelsService } from '../../../../../workbench/contrib/chat/common/languageModels.js'; -import { IAgentHostAutoConnect, IAgentHostConnectProgress, IAgentHostGroup } from '../../../../common/agentHostSessionsProvider.js'; +import { IAgentHostAutoConnect, IAgentHostConnectProgress, IAgentHostConnectionLabels, IAgentHostGroup } from '../../../../common/agentHostSessionsProvider.js'; import { buildAgentHostSessionWorkspace, readBranchProtectionPatterns } from '../../../../common/agentHostSessionWorkspace.js'; import { IGitHubInfo, ISession, ISessionType, ISessionWorkspace, ISessionWorkspaceBrowseAction, SESSION_WORKSPACE_GROUP_REMOTE } from '../../../../services/sessions/common/session.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; @@ -76,6 +76,7 @@ export interface IRemoteAgentHostSessionsProviderConfig { readonly onDidReportConnectProgress?: Event; /** Optional kind-scoped policy for automatically starting the host. */ readonly autoConnect?: IAgentHostAutoConnect; + readonly connectionLabels?: IAgentHostConnectionLabels; /** * Set when the host addresses sessions under a scheme that differs from its agent provider, as * the cloud sandbox host does (sessions are `ahp-session:/` while the agent is `copilot`). @@ -90,6 +91,8 @@ export interface IRemoteAgentHostSessionsProviderConfig { readonly omitHostFromWorkspaceLabel?: boolean; /** Type icon for this host's workspaces. See {@link ISessionWorkspace.typeIcon}. */ readonly workspaceTypeIcon?: ThemeIcon; + /** Keeps unavailable and initially connecting sessions read-only, but permits self-healing reconnects. */ + readonly readOnlyWhenDisconnected?: boolean; /** See {@link IAgentHostAdapterOptions.defaultChangesetKind}. */ readonly defaultChangesetKind?: ChangesetKind.Branch | ChangesetKind.Uncommitted | ChangesetKind.Session; /** @@ -135,16 +138,12 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid readonly canConnectOnDemand: boolean; readonly onDidReportConnectProgress: Event | undefined; readonly autoConnect?: IAgentHostAutoConnect; + readonly connectionLabels?: IAgentHostConnectionLabels; readonly automations: ISessionsProviderAutomations; private readonly _automationStore: ReconnectableAgentHostAutomationStore; private readonly _connectionStatus = observableValue('connectionStatus', RemoteAgentHostConnectionStatus.disconnected); - /** - * Forces this host's sessions read-only. Distinct from `disconnected`: a disconnected host may - * come back, so its sessions stay writable and queue on reconnect, whereas this marks a host - * that is gone and whose sessions exist only as replayed history. - */ - private readonly _readOnly = observableValue('providerReadOnly', false); + private readonly _readOnly: IObservable; readonly connectionStatus: IObservable = this._connectionStatus; protected override get remoteConnectionStatus(): IObservable { @@ -241,7 +240,16 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid this._devContainerWorktreeScope = config.devContainerWorktreeScope; this.onDidReportConnectProgress = config.onDidReportConnectProgress; this.autoConnect = config.autoConnect; + this.connectionLabels = config.connectionLabels; this.canConnectOnDemand = !!config.connectOnDemand; + this._readOnly = config.readOnlyWhenDisconnected + ? derived(this, reader => { + const status = this._connectionStatus.read(reader); + return RemoteAgentHostConnectionStatus.isDisconnected(status) + || RemoteAgentHostConnectionStatus.isConnecting(status) + || RemoteAgentHostConnectionStatus.isIncompatible(status); + }) + : constObservable(false); this._register(this._onDidChangeResourceLabelHomes(() => this.updateResourceLabelHomes())); this.updateResourceLabelHomes(); const displayName = config.name || config.address; @@ -501,17 +509,6 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid this._connectionStatus.set(status, undefined); } - /** - * Forces every session on this host to be read-only. - * - * Set when the host is permanently unreachable and its sessions are being served from - * persisted history: the conversation is genuine, but there is no host left to send to, so the - * composer must be hidden rather than accept input that can never be delivered. - */ - setReadOnly(readOnly: boolean): void { - this._readOnly.set(readOnly, undefined); - } - /** * Seed discovered session summaries into the cache so they surface in the sessions list * **before** a connection is established (lazy discovery). diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/cloudSandboxAgentHostContribution.test.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/cloudSandboxAgentHostContribution.test.ts index d2d3a582abf481..6d2ecbe33675ad 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/cloudSandboxAgentHostContribution.test.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/cloudSandboxAgentHostContribution.test.ts @@ -4,7 +4,9 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { DeferredPromise } from '../../../../../../base/common/async.js'; import { CancellationToken } from '../../../../../../base/common/cancellation.js'; +import { CancellationError } from '../../../../../../base/common/errors.js'; import { Event } from '../../../../../../base/common/event.js'; import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; import { mock, upcastPartial } from '../../../../../../base/test/common/mock.js'; @@ -12,19 +14,26 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/ import { URI } from '../../../../../../base/common/uri.js'; import { AgentSession } from '../../../../../../platform/agentHost/common/agent.js'; import { IAgentSessionMetadata } from '../../../../../../platform/agentHost/common/agentService.js'; +import { agentHostAuthority } from '../../../../../../platform/agentHost/common/agentHostUri.js'; +import { remoteAgentHostSessionTypeId } from '../../../../../../platform/agentHost/common/agentHostSessionType.js'; +import { IReplayedTaskHistory } from '../../../../../../platform/agentHost/common/taskEventReplay.js'; import { + CLOUD_SANDBOX_AGENT_PROVIDER, CloudSandboxEnabledSettingId, ICloudSandboxAgentHostService, ICloudSandboxApiService, cloudSandboxAddress, + type CloudSandboxEnvironmentStatus, type ICloudSandboxConnectOptions, type ICloudSandboxCreateSessionRequest, type ICloudSandboxCreatedSession, type ICloudSandboxDiscoveredSession, type ICloudSandboxDiscoveryResult, + type ICloudSandboxEnvironment as ICloudSandboxEnvironmentRecord, } from '../../../../../../platform/agentHost/common/cloudSandboxAgentHost.js'; -import { IRemoteAgentHostService, RemoteAgentHostsEnabledSettingId } from '../../../../../../platform/agentHost/common/remoteAgentHostService.js'; -import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { IRemoteAgentHostService, RemoteAgentHostConnectionStatus, RemoteAgentHostsEnabledSettingId } from '../../../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { IObservable, observableValue } from '../../../../../../base/common/observable.js'; +import { ConfigurationTarget, IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; @@ -45,6 +54,10 @@ class StubProvider extends mock() { readonly seeded: IAgentSessionMetadata[] = []; /** Raw ids seeded as provisional, mirroring the real provider's listing gate. */ readonly withheld = new Set(); + /** Every connection status pushed onto this provider, in order. */ + readonly statuses: string[] = []; + private readonly _status = observableValue('stubStatus', RemoteAgentHostConnectionStatus.disconnected); + override readonly connectionStatus: IObservable = this._status; disposed = false; override readonly id: string; @@ -98,9 +111,10 @@ class StubProvider extends mock() { }); } - override setConnectionStatus(): void { } - - override setReadOnly(): void { } + override setConnectionStatus(status: RemoteAgentHostConnectionStatus): void { + this.statuses.push(status.kind); + this._status.set(status, undefined); + } override dispose(): void { this.disposed = true; @@ -110,6 +124,15 @@ class StubProvider extends mock() { class TestCloudSandboxContribution extends CloudSandboxAgentHostContribution { readonly stubProviders = new Map(); + /** + * Drives the async activation the chat service performs on open. Called directly rather than + * through the global activation registry, which also holds the generic remote-agent-host + * activator for the same session type. + */ + activate(sessionType: string): Promise { + return this._waitForActivation(sessionType); + } + protected override _instantiateProvider(config: IRemoteAgentHostSessionsProviderConfig): CloudSandboxSessionsProvider { const stub = new StubProvider(config); this.stubProviders.set(config.address, stub); @@ -135,14 +158,22 @@ const GITHUB_SANDBOX_GROUP: IAgentHostGroup = { interface ITestHarness { readonly contribution: TestCloudSandboxContribution; readonly configurationService: TestConfigurationService; + setEnabled(enabled: boolean): Promise; /** Discovery's answer, mutable so a test can change what a later pass reports. */ discovered: readonly ICloudSandboxDiscoveredSession[]; /** Runs a discovery pass and waits for it to reconcile. */ runDiscovery(): Promise; /** Runs while a `connect` is in flight, for testing what can race with it. */ onConnect?: () => Promise; + /** The state Mission Control reports for an environment. Defaults to `offline`. */ + environmentStatus: CloudSandboxEnvironmentStatus; + /** Session types currently served from replayed history. */ + readonly readOnlySessionTypes: string[]; + /** Drives the async activation the chat service performs when a session is opened. */ + activate(environmentId: string): Promise; readonly created: ICloudSandboxCreateSessionRequest[]; readonly connectedTo: string[]; + readonly historyRequests: string[]; /** Host groups currently declared to the filter service. */ readonly hostGroups: IAgentHostGroup[]; } @@ -155,26 +186,55 @@ interface ITestHarness { async function createContribution(store: Pick, sessions: readonly ICloudSandboxDiscoveredSession[], options?: { /** Task Mission Control returns from `createSession`, or a rejection. */ readonly createSession?: () => Promise; + readonly getEnvironment?: (id: string, token: CancellationToken) => Promise; /** Whether the sandbox feature settings start on. Defaults to `true`. */ readonly enabled?: boolean; }): Promise { const discoveryHandlers: (() => Promise)[] = []; const hostGroups: IAgentHostGroup[] = []; + const readOnlySessionTypes: string[] = []; const instantiationService = store.add(new TestInstantiationService()); const created: ICloudSandboxCreateSessionRequest[] = []; const connectedTo: string[] = []; + const historyRequests: string[] = []; const harness: ITestHarness = { discovered: sessions, + environmentStatus: 'offline', + readOnlySessionTypes, created, connectedTo, + historyRequests, hostGroups, + setEnabled: async (enabled: boolean) => { + await configurationService.setUserConfiguration(CloudSandboxEnabledSettingId, enabled); + configurationService.onDidChangeConfigurationEmitter.fire({ + affectsConfiguration: key => key === CloudSandboxEnabledSettingId, + affectedKeys: new Set([CloudSandboxEnabledSettingId]), + change: { keys: [CloudSandboxEnabledSettingId], overrides: [] }, + source: ConfigurationTarget.USER, + }); + }, runDiscovery: async () => { await Promise.all(discoveryHandlers.map(handler => handler())); }, + activate: async (environmentId: string) => { + const sessionType = remoteAgentHostSessionTypeId(agentHostAuthority(cloudSandboxAddress(environmentId)), CLOUD_SANDBOX_AGENT_PROVIDER); + return harness.contribution.activate(sessionType); + }, } as ITestHarness; instantiationService.stub(ICloudSandboxApiService, new class extends mock() { override async listSessions(_token: CancellationToken): Promise { return { kind: 'complete', sessions: harness.discovered }; } + override async getEnvironment(id: string, token: CancellationToken): Promise { + if (options?.getEnvironment) { + return options.getEnvironment(id, token); + } + return { id, status: harness.environmentStatus }; + } + override async getSessionHistory(taskId: string): Promise { + historyRequests.push(taskId); + return { sessions: [], truncated: false }; + } override async createSession(request: ICloudSandboxCreateSessionRequest): Promise { created.push(request); return options?.createSession @@ -192,6 +252,9 @@ async function createContribution(store: Pick, sessions: instantiationService.stub(IRemoteAgentHostService, new class extends mock() { override readonly onDidChangeConnections = Event.None; override readonly connections = []; + // No live protocol client is modelled, so activation stops once the connect has been made + // rather than going on to wait for the host to advertise its agents. + override getConnection() { return undefined; } override async removeRemoteAgentHost(): Promise { } }()); instantiationService.stub(IRemoteAgentHostConnectionCustomizationService, new class extends mock() { @@ -223,7 +286,18 @@ async function createContribution(store: Pick, sessions: override readonly onDidRegisterAuthenticationProvider = Event.None; }()); instantiationService.stub(INotificationService, new class extends mock() { }()); - instantiationService.stub(IChatSessionsService, new class extends mock() { }()); + instantiationService.stub(IChatSessionsService, new class extends mock() { + override getContentProviderSchemes(): string[] { return [...readOnlySessionTypes]; } + override registerChatSessionContentProvider(sessionType: string): IDisposable { + readOnlySessionTypes.push(sessionType); + return toDisposable(() => { + const index = readOnlySessionTypes.indexOf(sessionType); + if (index >= 0) { + readOnlySessionTypes.splice(index, 1); + } + }); + } + }()); instantiationService.stub(ILogService, new NullLogService()); const contribution = store.add(instantiationService.createInstance(TestCloudSandboxContribution)); @@ -273,6 +347,22 @@ suite('CloudSandboxAgentHostContribution', () => { assert.strictEqual(provider?.seeded[0]?.project, undefined); }); + test('supplies environment connection labels independently of the task name', async () => { + const { contribution } = await createContribution(store, [discoveredSession({ name: 'hi' })]); + const labels = contribution.stubProviders.get(cloudSandboxAddress('env-1'))?.config.connectionLabels; + + assert.deepStrictEqual(labels && { ...labels, reconnectingIn: labels.reconnectingIn(5) }, { + unavailableTitle: 'Environment Offline', + unavailable: 'Environment offline.', + connectingTitle: 'Connecting to the Environment', + connecting: 'Connecting...', + reconnecting: 'Reconnecting...', + reconnectingIn: 'Reconnecting in 5s', + incompatibleTitle: 'Cannot Connect to the Environment', + incompatible: 'This environment is incompatible with this version of Visual Studio Code.', + }); + }); + test('opts sandbox providers out of the [host] workspace-label suffix', async () => { // Each sandbox is its own provider named after its task, so the suffix would put every // session in a workspace group of one. @@ -314,6 +404,180 @@ suite('CloudSandboxAgentHostContribution', () => { assert.deepStrictEqual([...hostGroups], []); }); + + test('serves a dormant environment from history instead of waking it to open a session', async () => { + // Resuming costs minutes and Mission Control cannot say in advance whether a dormant + // environment will come back, so opening a session must not gamble that on the user's + // behalf. The session still opens — from replayed history — and the connect is offered. + const harness = await createContribution(store, [discoveredSession()]); + harness.environmentStatus = 'offline'; + + const opened = await harness.activate('env-1'); + + assert.deepStrictEqual({ opened, connectedTo: harness.connectedTo, servedFromHistory: harness.readOnlySessionTypes.length }, { + opened: true, + connectedTo: [], + servedFromHistory: 1, + }); + }); + + test('connects when opening a session on an environment that is already online', async () => { + const harness = await createContribution(store, [discoveredSession()]); + harness.environmentStatus = 'online'; + + await harness.activate('env-1'); + + assert.deepStrictEqual({ connectedTo: harness.connectedTo, servedFromHistory: harness.readOnlySessionTypes.length }, { + connectedTo: ['env-1'], + servedFromHistory: 0, + }); + }); + + test('does not wake an environment whose state could not be read', async () => { + const harness = await createContribution(store, [discoveredSession()], { + getEnvironment: async () => { throw new Error('Expected environment lookup failure'); }, + }); + + const opened = await harness.activate('env-1'); + + assert.deepStrictEqual({ + opened, + connectedTo: harness.connectedTo, + historyRequests: harness.historyRequests, + servedFromHistory: harness.readOnlySessionTypes.length, + }, { opened: true, connectedTo: [], historyRequests: ['task-1'], servedFromHistory: 1 }); + }); + + for (const reenable of [false, true]) { + test(`abandons a cancelled environment lookup when the feature is ${reenable ? 're-enabled' : 'disabled'}`, async () => { + const environment = new DeferredPromise(); + const requestedToken = new DeferredPromise(); + const harness = await createContribution(store, [discoveredSession()], { + getEnvironment: (_id, token) => { + void requestedToken.complete(token); + return environment.p; + }, + }); + const activation = harness.activate('env-1'); + const token = await requestedToken.p; + + await harness.setEnabled(false); + if (reenable) { + await harness.setEnabled(true); + await harness.runDiscovery(); + } + await environment.error(new CancellationError()); + + assert.deepStrictEqual({ + opened: await activation, + cancelled: token.isCancellationRequested, + connectedTo: harness.connectedTo, + historyRequests: harness.historyRequests, + readOnlySessionTypes: harness.readOnlySessionTypes, + }, { opened: false, cancelled: true, connectedTo: [], historyRequests: [], readOnlySessionTypes: [] }); + }); + } + + for (const status of ['online', 'offline'] as const) { + test(`does not reactivate a removed environment after a late ${status} record`, async () => { + const environment = new DeferredPromise(); + const harness = await createContribution(store, [discoveredSession()], { + getEnvironment: () => environment.p, + }); + const activation = harness.activate('env-1'); + harness.discovered = []; + await harness.runDiscovery(); + await environment.complete({ id: 'env-1', status }); + + assert.deepStrictEqual({ + opened: await activation, + connectedTo: harness.connectedTo, + historyRequests: harness.historyRequests, + readOnlySessionTypes: harness.readOnlySessionTypes, + }, { opened: false, connectedTo: [], historyRequests: [], readOnlySessionTypes: [] }); + }); + } + + test('does not register old history against a replacement provider at the same address', async () => { + const environment = new DeferredPromise(); + const harness = await createContribution(store, [discoveredSession()], { + getEnvironment: () => environment.p, + }); + const activation = harness.activate('env-1'); + harness.discovered = []; + await harness.runDiscovery(); + harness.discovered = [discoveredSession({ taskId: 'task-2' })]; + await harness.runDiscovery(); + await environment.complete({ id: 'env-1', status: 'offline' }); + + assert.deepStrictEqual({ + opened: await activation, + historyRequests: harness.historyRequests, + readOnlySessionTypes: harness.readOnlySessionTypes, + }, { opened: false, historyRequests: [], readOnlySessionTypes: [] }); + }); + + test('keeps activation valid across a discovery refresh of the same provider', async () => { + const environment = new DeferredPromise(); + const harness = await createContribution(store, [discoveredSession()], { + getEnvironment: () => environment.p, + }); + const activation = harness.activate('env-1'); + await harness.runDiscovery(); + await environment.complete({ id: 'env-1', status: 'offline' }); + + assert.deepStrictEqual({ + opened: await activation, + connectedTo: harness.connectedTo, + historyRequests: harness.historyRequests, + }, { opened: true, connectedTo: [], historyRequests: ['task-1'] }); + }); + + test('does not restore history after an old connect fails across disable and re-enable', async () => { + const harness = await createContribution(store, [discoveredSession()]); + harness.environmentStatus = 'online'; + harness.onConnect = async () => { + await harness.setEnabled(false); + await harness.setEnabled(true); + await harness.runDiscovery(); + throw new Error('Expected connection failure after teardown'); + }; + + assert.deepStrictEqual({ + opened: await harness.activate('env-1'), + historyRequests: harness.historyRequests, + readOnlySessionTypes: harness.readOnlySessionTypes, + }, { opened: false, historyRequests: [], readOnlySessionTypes: [] }); + }); + + test('connects a dormant environment that has no history to fall back on', async () => { + // Without a task there is nothing to serve read-only, so refusing to connect would leave + // the session unopenable rather than merely offline. The harness models no live protocol + // client, so the dial itself is what this asserts. + const harness = await createContribution(store, [discoveredSession({ taskId: undefined })]); + harness.environmentStatus = 'offline'; + + await harness.activate('env-1'); + + assert.deepStrictEqual({ connectedTo: harness.connectedTo, servedFromHistory: harness.readOnlySessionTypes.length }, { + connectedTo: ['env-1'], + servedFromHistory: 0, + }); + }); + + test('settles the status after a failed connect so the connect action comes back', async () => { + // A wake that exhausts its retry budget fails before any transport entry exists, so no + // connections-changed event follows. Left alone the provider would sit at `connecting` + // forever: a permanent spinner, a permanently hidden composer, and no way to retry. + const harness = await createContribution(store, [discoveredSession()]); + harness.environmentStatus = 'online'; + harness.onConnect = () => Promise.reject(new Error('Timed out waiting for sandbox environment to wake.')); + + await harness.activate('env-1'); + + const provider = harness.contribution.stubProviders.get(cloudSandboxAddress('env-1')); + assert.deepStrictEqual(provider?.statuses, ['connecting', 'disconnected']); + }); }); suite('CloudSandboxAgentHostContribution provisioning', () => { diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts index 630048ad813d3b..eb407dbd179a6c 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts @@ -39,7 +39,7 @@ import { IChatService, type ChatSendResult, type IChatSendRequestOptions } from import { IChatSessionsService } from '../../../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { ILanguageModelsService } from '../../../../../../workbench/contrib/chat/common/languageModels.js'; import { ISessionChangeEvent } from '../../../../../services/sessions/common/sessionsProvider.js'; -import { ChatModelSource, SessionRemoteConnectionFailureReason, SessionStatus, type ISession } from '../../../../../services/sessions/common/session.js'; +import { ChatInteractivity, ChatModelSource, SessionRemoteConnectionFailureReason, SessionStatus, type ISession } from '../../../../../services/sessions/common/session.js'; import { RemoteAgentHostSessionsProvider, type IRemoteAgentHostSessionsProviderConfig } from '../../browser/remoteAgentHostSessionsProvider.js'; import { CloudSandboxSessionsProvider } from '../../browser/cloudSandboxSessionsProvider.js'; import { ILabelService } from '../../../../../../platform/label/common/label.js'; @@ -242,7 +242,7 @@ function createSession(id: string, opts?: { provider?: string; summary?: string; }; } -function createProvider(disposables: DisposableStore, connection: MockAgentConnection, overrides?: { address?: string; preferenceKey?: string; connectionName?: string | undefined; sendRequest?: (resource: URI, message: string, options?: IChatSendRequestOptions) => Promise; openSession?: boolean; storageService?: IStorageService; localAgentHostService?: IAgentHostService; noConnection?: boolean; isWebPlatform?: boolean; workspaceTrusted?: boolean; omitHostFromWorkspaceLabel?: boolean; workspaceTypeIcon?: ThemeIcon; sessionSchemeAlias?: IAgentHostSessionSchemeAlias; defaultChangesetKind?: IRemoteAgentHostSessionsProviderConfig['defaultChangesetKind']; sessionResolutionPolicies?: Array<{ authority: string; policy: IAgentHostSessionResolutionPolicy }>; devContainerWorktreeScope?: string; ctor?: typeof RemoteAgentHostSessionsProvider; labelService?: ILabelService; defaultDirectory?: string }): RemoteAgentHostSessionsProvider { +function createProvider(disposables: DisposableStore, connection: MockAgentConnection, overrides?: { address?: string; preferenceKey?: string; connectionName?: string | undefined; sendRequest?: (resource: URI, message: string, options?: IChatSendRequestOptions) => Promise; openSession?: boolean; storageService?: IStorageService; localAgentHostService?: IAgentHostService; noConnection?: boolean; isWebPlatform?: boolean; workspaceTrusted?: boolean; omitHostFromWorkspaceLabel?: boolean; workspaceTypeIcon?: ThemeIcon; sessionSchemeAlias?: IAgentHostSessionSchemeAlias; defaultChangesetKind?: IRemoteAgentHostSessionsProviderConfig['defaultChangesetKind']; sessionResolutionPolicies?: Array<{ authority: string; policy: IAgentHostSessionResolutionPolicy }>; devContainerWorktreeScope?: string; readOnlyWhenDisconnected?: boolean; ctor?: typeof RemoteAgentHostSessionsProvider; labelService?: ILabelService; defaultDirectory?: string }): RemoteAgentHostSessionsProvider { const instantiationService = disposables.add(new TestInstantiationService()); instantiationService.stub(IFileDialogService, {}); @@ -307,6 +307,7 @@ function createProvider(disposables: DisposableStore, connection: MockAgentConne sessionSchemeAlias: overrides?.sessionSchemeAlias, defaultChangesetKind: overrides?.defaultChangesetKind, devContainerWorktreeScope: overrides?.devContainerWorktreeScope, + readOnlyWhenDisconnected: overrides?.readOnlyWhenDisconnected, }; const baseCtor = overrides?.ctor ?? RemoteAgentHostSessionsProvider; @@ -483,6 +484,39 @@ suite('RemoteAgentHostSessionsProvider', () => { }); }); + test('keeps initial connections read-only but permits self-healing reconnects', () => { + const provider = createProvider(disposables, connection, { readOnlyWhenDisconnected: true }); + provider.seedSessions([createSession('sandbox-session')]); + const chat = provider.getSessions()[0].mainChat.get(); + const statuses = [ + RemoteAgentHostConnectionStatus.disconnected, + RemoteAgentHostConnectionStatus.connecting, + RemoteAgentHostConnectionStatus.connected, + RemoteAgentHostConnectionStatus.reconnecting, + RemoteAgentHostConnectionStatus.disconnected, + RemoteAgentHostConnectionStatus.connecting, + RemoteAgentHostConnectionStatus.disconnected, + RemoteAgentHostConnectionStatus.incompatible('Protocol version mismatch', ['1']), + RemoteAgentHostConnectionStatus.connected, + ]; + const interactivity = statuses.map(status => { + provider.setConnectionStatus(status); + return { status: status.kind, interactivity: chat.interactivity.get() }; + }); + + assert.deepStrictEqual(interactivity, [ + { status: 'disconnected', interactivity: ChatInteractivity.ReadOnly }, + { status: 'connecting', interactivity: ChatInteractivity.ReadOnly }, + { status: 'connected', interactivity: ChatInteractivity.Full }, + { status: 'reconnecting', interactivity: ChatInteractivity.Full }, + { status: 'disconnected', interactivity: ChatInteractivity.ReadOnly }, + { status: 'connecting', interactivity: ChatInteractivity.ReadOnly }, + { status: 'disconnected', interactivity: ChatInteractivity.ReadOnly }, + { status: 'incompatible', interactivity: ChatInteractivity.ReadOnly }, + { status: 'connected', interactivity: ChatInteractivity.Full }, + ]); + }); + test('does not present an active chat as busy while its remote host is unavailable', () => { const provider = createProvider(disposables, connection); provider.setConnectionStatus(RemoteAgentHostConnectionStatus.connected); diff --git a/src/vs/sessions/contrib/sessions/browser/customizationsToolbar.contribution.ts b/src/vs/sessions/contrib/sessions/browser/customizationsToolbar.contribution.ts index 6954d78071f6ec..3f937c8d37ae9e 100644 --- a/src/vs/sessions/contrib/sessions/browser/customizationsToolbar.contribution.ts +++ b/src/vs/sessions/contrib/sessions/browser/customizationsToolbar.contribution.ts @@ -7,6 +7,7 @@ import '../../../browser/media/sidebarActionButton.css'; import './media/customizationsToolbar.css'; import { Codicon } from '../../../../base/common/codicons.js'; import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { autorun, observableValue } from '../../../../base/common/observable.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { localize } from '../../../../nls.js'; import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js'; @@ -17,15 +18,15 @@ import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase import { AICustomizationManagementEditor } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.js'; import { AICustomizationManagementEditorInput } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditorInput.js'; import { IAICustomizationItemsModel, ItemsModelSection } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemsModel.js'; +import { ICustomizationMigrationAvailabilityService } from '../../../../workbench/contrib/chat/browser/aiCustomization/customizationMigrationAvailabilityService.js'; import { IMcpService } from '../../../../workbench/contrib/mcp/common/mcpTypes.js'; import { ILanguageModelToolsService } from '../../../../workbench/contrib/chat/common/tools/languageModelToolsService.js'; import { AGENT_HOST_COPILOT_CLI_SESSION_TYPE, countEnabledCustomizationTools, IAgentHostToolSetEnablementService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostToolSetEnablementService.js'; import { Menus } from '../../../browser/menus.js'; import { agentIcon, instructionsIcon, mcpServerIcon, pluginIcon, skillIcon, hookIcon, toolsIcon } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationIcons.js'; -import { ActionViewItem, IBaseActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; +import { ActionViewItem, IActionViewItemOptions, IBaseActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; import { IAction } from '../../../../base/common/actions.js'; -import { $, append } from '../../../../base/browser/dom.js'; -import { autorun } from '../../../../base/common/observable.js'; +import { $, append, reset } from '../../../../base/browser/dom.js'; import { Button } from '../../../../base/browser/ui/button/button.js'; import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js'; import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js'; @@ -33,8 +34,12 @@ import { AICustomizationManagementSection } from '../../../../workbench/contrib/ import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; import { ICustomizationHarnessService } from '../../../../workbench/contrib/chat/common/customizationHarnessService.js'; import { ISession } from '../../../services/sessions/common/session.js'; +import { IActiveSession } from '../../../services/sessions/common/sessionsManagement.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; import { SessionType } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { SessionIsCreatedContext } from '../../../common/contextkeys.js'; +import { ChatConfiguration } from '../../../../workbench/contrib/chat/common/constants.js'; +import { OPEN_CUSTOMIZATIONS_COMMAND_ID } from '../../../common/customizations.js'; export interface ICustomizationItemConfig { readonly id: string; @@ -61,11 +66,14 @@ function customizationSectionVisibleKey(section: string): string { } const CUSTOMIZATION_OVERVIEW_ITEM: ICustomizationItemConfig = { - id: 'sessions.customization.overview', + id: OPEN_CUSTOMIZATIONS_COMMAND_ID, label: localize('overview', "Overview"), icon: Codicon.home, }; +const CUSTOMIZATION_ENTRY_POINTS_ENABLED = ContextKeyExpr.equals(`config.${ChatConfiguration.CustomizationEntryPoints}`, true); +const CUSTOMIZATION_ENTRY_POINTS_DISABLED = ContextKeyExpr.equals(`config.${ChatConfiguration.CustomizationEntryPoints}`, false); + export const CUSTOMIZATION_ITEMS: ICustomizationItemConfig[] = [ { id: 'sessions.customization.plugins', @@ -124,14 +132,17 @@ export const CUSTOMIZATION_ITEMS: ICustomizationItemConfig[] = [ }, ]; -export async function openCustomizationOverviewPage(editorService: IEditorService, harnessService: ICustomizationHarnessService, sessionsService: ISessionsService): Promise { - const session = sessionsService.activeSession.get(); +export async function openCustomizationOverviewPage(editorService: IEditorService, harnessService: ICustomizationHarnessService, sessionsService: ISessionsService, session?: IActiveSession): Promise { if (session) { - harnessService.setActiveSession(session.resource); + sessionsService.setActive(session); + } + const targetSession = session ?? sessionsService.activeSession.get(); + if (targetSession) { + harnessService.setActiveSession(targetSession.resource); } const input = AICustomizationManagementEditorInput.getOrCreate(); - input.setTargetLabels(harnessService.getActiveDescriptor().label, session?.workspace.get()?.folders[0]?.name); + input.setTargetLabels(harnessService.getActiveDescriptor().label, targetSession?.workspace.get()?.folders[0]?.name); const pane = await editorService.openEditor(input, { pinned: true }); if (pane instanceof AICustomizationManagementEditor) { pane.showWelcomePage(); @@ -244,6 +255,80 @@ export class CustomizationLinkViewItem extends ActionViewItem { } } +function isActiveSessionContext(context: unknown): context is IActiveSession { + return typeof context === 'object' && context !== null && 'sessionId' in context && 'resource' in context && 'workspace' in context; +} + +class CustomizationsToolbarActionViewItem extends ActionViewItem { + private readonly session = observableValue(this, undefined); + private tooltip = ''; + + constructor( + action: IAction, + options: IActionViewItemOptions, + @ISessionsService sessionsService: ISessionsService, + @ICustomizationHarnessService harnessService: ICustomizationHarnessService, + @ICustomizationMigrationAvailabilityService migrationAvailabilityService: ICustomizationMigrationAvailabilityService, + ) { + super(undefined, action, { ...options, icon: true, label: true }); + + this._register(autorun(reader => { + const session = this.session.read(reader); + const activeSession = sessionsService.activeSession.read(reader); + harnessService.activeHarness.read(reader); + harnessService.availableHarnesses.read(reader); + const isActiveSession = session?.sessionId === activeSession?.sessionId; + const harnessLabel = session + ? isActiveSession + ? harnessService.getActiveDescriptor().label + : harnessService.findHarnessById(findHarnessIdForSession(session, harnessService) ?? '')?.label + : undefined; + const workspaceLabel = session?.workspace.read(reader)?.folders[0]?.name; + const hasMigrations = isActiveSession && migrationAvailabilityService.candidateCount.read(reader) > 0; + this.updatePresentation(harnessLabel, workspaceLabel, hasMigrations); + })); + } + + override setActionContext(context: unknown): void { + super.setActionContext(context); + this.session.set(isActiveSessionContext(context) ? context : undefined, undefined); + } + + override render(container: HTMLElement): void { + super.render(container); + container.classList.add('sessions-customize-toolbar-action'); + } + + protected override getTooltip(): string { + return this.tooltip; + } + + protected override updateLabel(): void { + if (this.label) { + const text = $('span.sessions-customize-toolbar-label'); + text.textContent = localize('customizeActionLabel', "Customize"); + reset(this.label, text); + } + } + + private updatePresentation(harnessLabel: string | undefined, workspaceLabel: string | undefined, hasMigrations: boolean): void { + let tooltip: string; + if (harnessLabel && workspaceLabel) { + tooltip = localize('customizeHarnessForWorkspace', "Customize {0} for {1}", harnessLabel, workspaceLabel); + } else if (harnessLabel) { + tooltip = localize('customizeHarness', "Customize {0}", harnessLabel); + } else if (workspaceLabel) { + tooltip = localize('customizeWorkspace', "Customize for {0}", workspaceLabel); + } else { + tooltip = localize('openCustomizationsTooltip', "Open Customizations"); + } + this.tooltip = hasMigrations + ? localize('customizeMigrationsAvailable', "{0}, migrations available", tooltip) + : tooltip; + this.updateTooltip(); + } +} + // --- Register actions and view items --- // export class CustomizationsToolbarContribution extends Disposable implements IWorkbenchContribution { @@ -287,25 +372,41 @@ export class CustomizationsToolbarContribution extends Disposable implements IWo this._register(actionViewItemService.register(Menus.SidebarCustomizations, CUSTOMIZATION_OVERVIEW_ITEM.id, (action, options) => { return instantiationService.createInstance(CustomizationLinkViewItem, action, options, CUSTOMIZATION_OVERVIEW_ITEM); }, undefined)); + this._register(actionViewItemService.register(Menus.SessionBarToolbar, CUSTOMIZATION_OVERVIEW_ITEM.id, (action, options) => { + return instantiationService.createInstance(CustomizationsToolbarActionViewItem, action, options); + }, undefined)); this._register(registerAction2(class extends Action2 { constructor() { super({ id: CUSTOMIZATION_OVERVIEW_ITEM.id, - title: CUSTOMIZATION_OVERVIEW_ITEM.label, - menu: { + title: localize('openCustomizations', "Open Customizations"), + icon: Codicon.tools, + precondition: ChatContextKeys.enabled, + menu: [{ id: Menus.SidebarCustomizations, group: 'navigation', order: 0, - when: ChatContextKeys.enabled, - } + when: ContextKeyExpr.and(ChatContextKeys.enabled, CUSTOMIZATION_ENTRY_POINTS_DISABLED), + }, { + id: Menus.SessionBarToolbar, + group: 'navigation', + order: 10, + when: ContextKeyExpr.and(SessionIsCreatedContext, ChatContextKeys.enabled, CUSTOMIZATION_ENTRY_POINTS_ENABLED), + }, { + id: Menus.SessionHeaderContext, + group: '2_edit', + order: 2, + when: ContextKeyExpr.and(SessionIsCreatedContext, ChatContextKeys.enabled, CUSTOMIZATION_ENTRY_POINTS_ENABLED), + }], }); } - async run(accessor: ServicesAccessor): Promise { + async run(accessor: ServicesAccessor, session?: IActiveSession): Promise { await openCustomizationOverviewPage( accessor.get(IEditorService), accessor.get(ICustomizationHarnessService), accessor.get(ISessionsService), + session, ); } })); @@ -322,8 +423,8 @@ export class CustomizationsToolbarContribution extends Disposable implements IWo const sectionVisibleWhen = ContextKeyExpr.has(customizationSectionVisibleKey(section)); const combinedWhen = config.when - ? ContextKeyExpr.and(ChatContextKeys.enabled, sectionVisibleWhen, config.when) - : ContextKeyExpr.and(ChatContextKeys.enabled, sectionVisibleWhen); + ? ContextKeyExpr.and(ChatContextKeys.enabled, CUSTOMIZATION_ENTRY_POINTS_DISABLED, sectionVisibleWhen, config.when) + : ContextKeyExpr.and(ChatContextKeys.enabled, CUSTOMIZATION_ENTRY_POINTS_DISABLED, sectionVisibleWhen); // Register the action with menu item this._register(registerAction2(class extends Action2 { diff --git a/src/vs/sessions/contrib/sessions/browser/media/customizationsToolbar.css b/src/vs/sessions/contrib/sessions/browser/media/customizationsToolbar.css index 4f8c5ab95aac3e..56e91177130115 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/customizationsToolbar.css +++ b/src/vs/sessions/contrib/sessions/browser/media/customizationsToolbar.css @@ -190,3 +190,19 @@ overflow: hidden; padding-bottom: 2px; } + +.chat-composite-bar-toolbar .sessions-customize-toolbar-action .action-label { + width: auto; + gap: var(--vscode-spacing-size40); + padding: 0 var(--vscode-spacing-size40); + font-family: inherit; +} + +.sessions-customize-toolbar-action .action-label::before { + font-family: codicon; + font-size: var(--vscode-codiconFontSize); +} + +.sessions-customize-toolbar-label { + font-size: var(--vscode-fontSize-body1); +} diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts index eaed70b6200117..2cc5e689089d7e 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts @@ -1888,8 +1888,16 @@ interface ISessionsListDndDelegate { reorder(dragged: ISession[], target: ISession, position: 'before' | 'after'): void; /** The id of the group the session belongs to, or `undefined`. */ getGroupIdOfSession(session: ISession): string | undefined; + /** + * The id of the workspace section (`workspace: