From dbba5657864541cc1abcab840d13bcccb5c9a801 Mon Sep 17 00:00:00 2001 From: Hawk Ticehurst Date: Thu, 15 Oct 2026 14:51:39 -0400 Subject: [PATCH 1/4] Agents window: recommend connectors below new chat composer Add product-configured Linear, Notion, and WorkIQ recommendation cards behind an experiment, including direct connector-detail navigation, responsive and accessible dismissal behavior, and targeted fixture coverage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/base/common/product.ts | 5 + .../contrib/chat/browser/chat.contribution.ts | 10 +- .../contrib/chat/browser/media/chatWidget.css | 35 ++- .../media/newChatConnectorRecommendations.css | 130 +++++++++++ .../browser/media/newChatMigrationNotice.css | 16 +- .../newChatConnectorRecommendations.ts | 196 ++++++++++++++++ .../contrib/chat/browser/newChatWidget.ts | 17 +- .../browser/sessionsChatAccessibilityHelp.ts | 8 +- .../sessions/contrib/chat/common/constants.ts | 1 + .../newChatConnectorRecommendations.test.ts | 210 ++++++++++++++++++ .../test/browser/newChatWidget.fixture.ts | 207 ++++++++++++++++- .../sessionsChatAccessibilityHelp.test.ts | 25 ++- .../aiCustomizationManagement.contribution.ts | 14 +- .../aiCustomizationManagementEditor.ts | 15 +- .../common/aiCustomizationWorkspaceService.ts | 2 + ...aiCustomizationManagementEditor.fixture.ts | 17 ++ 16 files changed, 872 insertions(+), 36 deletions(-) create mode 100644 src/vs/sessions/contrib/chat/browser/media/newChatConnectorRecommendations.css create mode 100644 src/vs/sessions/contrib/chat/browser/newChatConnectorRecommendations.ts create mode 100644 src/vs/sessions/contrib/chat/test/browser/newChatConnectorRecommendations.test.ts diff --git a/src/vs/base/common/product.ts b/src/vs/base/common/product.ts index 0767bb78c5aff..2c732eba205ac 100644 --- a/src/vs/base/common/product.ts +++ b/src/vs/base/common/product.ts @@ -177,6 +177,11 @@ export interface IProductConfiguration { readonly trustedExtensionPublishers?: readonly string[]; readonly extensionRecommendations?: IStringDictionary; + readonly connectorRecommendations?: IStringDictionary<{ + readonly name: string; + readonly description: string; + readonly iconUrl: string; + }>; readonly configBasedExtensionTips?: IStringDictionary; readonly exeBasedExtensionTips?: IStringDictionary; readonly remoteExtensionTips?: IStringDictionary; diff --git a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts index b0fe46c4ea4fe..1b96e9c73f10a 100644 --- a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts @@ -58,7 +58,7 @@ import { WorktreeCreatedTaskDispatcher, AGENT_HOST_RUN_WORKTREE_CREATED_TASKS_SE import { AGENT_SESSIONS_SCOPED_INPUT_HISTORY_SETTING } from './sessionsChatHistory.js'; import '../../sessions/browser/mobile/mobileOverlayContribution.js'; import { IsSessionsWindowContext } from '../../../../workbench/common/contextkeys.js'; -import { AGENTS_PICKER_IN_ATTACH_CONTEXT_MENU_SETTING, COLLAPSED_SESSION_OPTIONS_SHOW_ICONS_SETTING, COMPARE_AGENTS_ENABLED_SETTING, COMPARE_AGENTS_OPEN_IN_GRID_SETTING, EXPERIMENTAL_NEW_SESSION_COMPOSER_LAYOUT_SETTING, NEW_SESSION_COMPOSER_OPTIONS_EXPANDED_SETTING, NEW_SESSION_WELCOME_MESSAGES_SETTING, NEW_SESSION_WELCOME_NAME_SETTING, NEW_SESSION_WELCOME_PHRASES_SETTING } from '../common/constants.js'; +import { AGENTS_PICKER_IN_ATTACH_CONTEXT_MENU_SETTING, COLLAPSED_SESSION_OPTIONS_SHOW_ICONS_SETTING, COMPARE_AGENTS_ENABLED_SETTING, COMPARE_AGENTS_OPEN_IN_GRID_SETTING, EXPERIMENTAL_NEW_SESSION_COMPOSER_LAYOUT_SETTING, NEW_SESSION_COMPOSER_OPTIONS_EXPANDED_SETTING, NEW_SESSION_CONNECTOR_RECOMMENDATIONS_SETTING, NEW_SESSION_WELCOME_MESSAGES_SETTING, NEW_SESSION_WELCOME_NAME_SETTING, NEW_SESSION_WELCOME_PHRASES_SETTING } from '../common/constants.js'; import { SessionsChatBackgroundAvailableContext, SessionsChatBackgroundImageConfiguredContext } from '../../../common/contextkeys.js'; import { Menus } from '../../../browser/menus.js'; import { ISessionsChatViewStateService, SessionsChatViewStateService } from './chatViewStateService.js'; @@ -645,6 +645,14 @@ Registry.as(ConfigurationExtensions.Configuration).regis tags: ['experimental'], experiment: { mode: 'auto' }, }, + [NEW_SESSION_CONNECTOR_RECOMMENDATIONS_SETTING]: { + type: 'boolean', + default: false, + scope: ConfigurationScope.APPLICATION, + description: localize('sessions.chat.experimental.connectorRecommendations', "Controls whether the new-session composer recommends connectors that can add work context to chats."), + tags: ['experimental'], + experiment: { mode: 'auto' }, + }, [NEW_SESSION_WELCOME_PHRASES_SETTING]: { type: 'boolean', default: false, diff --git a/src/vs/sessions/contrib/chat/browser/media/chatWidget.css b/src/vs/sessions/contrib/chat/browser/media/chatWidget.css index 64cbc1583a72c..7e859d01a9761 100644 --- a/src/vs/sessions/contrib/chat/browser/media/chatWidget.css +++ b/src/vs/sessions/contrib/chat/browser/media/chatWidget.css @@ -116,21 +116,42 @@ border-radius: var(--vscode-cornerRadius-large) var(--vscode-cornerRadius-large) 0 0; } -/* Getting-started tip demoted below the input in the experimental composer, matching - the placement of the new-session migration notice. Hidden by the presenter until a - tip is showing, so it reserves no space otherwise. */ +.new-session-below-input-surfaces { + position: absolute; + top: 100%; + left: 0; + right: 0; + width: 100%; + margin-top: var(--vscode-spacing-size200); +} + +/* Getting-started tip demoted below the input in the experimental composer. */ .experimental-new-session-composer .new-session-getting-started-tip-below { width: 100%; - max-width: 800px; /* Extra top gap so the tip's hover actions, which float above its top edge, clear the input. */ - margin: var(--vscode-spacing-size280) auto 0; + margin-top: var(--vscode-spacing-size160); } /* As a standalone card (not attached above the input) the tip takes a full border and rounds every corner, unlike the input-attached notice that squares its joined bottom. */ .new-session-getting-started-tip-below .chat-input-notice { - border-bottom: var(--vscode-strokeThickness) solid var(--vscode-input-border, var(--vscode-widget-border, transparent)); - border-radius: var(--vscode-cornerRadius-large); + border: var(--vscode-strokeThickness) solid var(--vscode-contrastBorder, color-mix(in srgb, var(--vscode-foreground) 10%, transparent)); + border-radius: var(--vscode-cornerRadius-medium); + background-color: color-mix(in srgb, var(--vscode-foreground) 3%, var(--session-view-background, var(--vscode-editor-background))); +} + +:is(.hc-black, .hc-light) .new-session-getting-started-tip-below .chat-input-notice { + border-color: var(--vscode-contrastBorder); + background-color: var(--vscode-editor-background); +} + +.agent-sessions-workbench.phone-layout .new-session-below-input-surfaces { + position: static; + order: 1; + flex: 0 1 auto; + min-height: 0; + margin: 0 0 var(--vscode-spacing-size200); + overflow-y: auto; } .new-chat-session-options .new-chat-session-options-details { diff --git a/src/vs/sessions/contrib/chat/browser/media/newChatConnectorRecommendations.css b/src/vs/sessions/contrib/chat/browser/media/newChatConnectorRecommendations.css new file mode 100644 index 0000000000000..24f7c344f8852 --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/media/newChatConnectorRecommendations.css @@ -0,0 +1,130 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.new-session-connector-recommendations { + width: 100%; + color: var(--vscode-foreground); +} + +.new-session-connector-recommendations[hidden], +.new-chat-widget-content.no-agent-host .new-session-connector-recommendations { + display: none; +} + +.new-session-connector-recommendation-cards { + display: flex; + gap: var(--vscode-spacing-size80); + width: 100%; + padding: var(--vscode-spacing-size20); + box-sizing: border-box; + overflow-x: auto; + scrollbar-width: thin; +} + +.new-session-connector-recommendation-card { + position: relative; + flex: 1 0 0; + min-width: 180px; +} + +.monaco-button.new-session-connector-recommendation-card-primary { + display: grid; + grid-template-columns: var(--vscode-spacing-size320) minmax(0, 1fr); + column-gap: var(--vscode-spacing-size100); + align-items: start; + width: 100%; + min-height: 100%; + padding: var(--vscode-spacing-size60); + padding-right: var(--vscode-spacing-size320); + box-sizing: border-box; + border: var(--vscode-strokeThickness) solid var(--vscode-contrastBorder, color-mix(in srgb, var(--vscode-foreground) 10%, transparent)); + border-radius: var(--vscode-cornerRadius-medium); + background-color: color-mix(in srgb, var(--vscode-foreground) 3%, var(--session-view-background, var(--vscode-editor-background))); + color: var(--vscode-foreground); + text-align: left; +} + +.monaco-button.new-session-connector-recommendation-card-primary:hover { + border-color: var(--vscode-focusBorder); + background-color: color-mix(in srgb, var(--vscode-focusBorder) 8%, var(--vscode-editorWidget-background)); +} + +.monaco-button.new-session-connector-recommendation-card-primary:focus-visible { + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: calc(-1 * var(--vscode-strokeThickness)); +} + +.new-session-connector-recommendation-icon { + display: flex; + align-items: center; + justify-content: center; + width: var(--vscode-spacing-size320); + height: var(--vscode-spacing-size320); + border-radius: var(--vscode-cornerRadius-medium); + overflow: hidden; + background-color: var(--vscode-editor-background); +} + +.new-session-connector-recommendation-icon img { + display: block; + width: 100%; + height: 100%; + object-fit: contain; +} + +.new-session-connector-recommendation-icon > .codicon { + font-size: var(--vscode-codiconFontSize); + color: var(--vscode-descriptionForeground); +} + +.new-session-connector-recommendation-content { + min-width: 0; +} + +.new-session-connector-recommendation-name { + overflow: hidden; + color: var(--vscode-foreground); + font-size: var(--vscode-fontSize-label1); + font-weight: var(--vscode-fontWeight-semiBold); + text-overflow: ellipsis; + white-space: nowrap; +} + +.new-session-connector-recommendation-description { + margin-top: var(--vscode-spacing-size20); + color: var(--vscode-descriptionForeground); + font-size: var(--vscode-fontSize-label2); + line-height: 1.2; +} + +.monaco-button.new-session-connector-recommendation-dismiss { + position: absolute; + top: var(--vscode-spacing-size60); + right: var(--vscode-spacing-size60); + display: flex; + align-items: center; + justify-content: center; + width: var(--vscode-spacing-size240); + height: var(--vscode-spacing-size240); + padding: 0; + border: 0; + border-radius: var(--vscode-cornerRadius-small); + background: transparent; + color: var(--vscode-icon-foreground); +} + +.monaco-button.new-session-connector-recommendation-dismiss:hover { + background-color: var(--vscode-toolbar-hoverBackground); +} + +.monaco-button.new-session-connector-recommendation-dismiss:focus-visible { + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: calc(-1 * var(--vscode-strokeThickness)); +} + +:is(.hc-black, .hc-light) .monaco-button.new-session-connector-recommendation-card-primary { + border-color: var(--vscode-contrastBorder); + background-color: var(--vscode-editor-background); +} diff --git a/src/vs/sessions/contrib/chat/browser/media/newChatMigrationNotice.css b/src/vs/sessions/contrib/chat/browser/media/newChatMigrationNotice.css index 636b2ba79dc36..d113a0dd3cb79 100644 --- a/src/vs/sessions/contrib/chat/browser/media/newChatMigrationNotice.css +++ b/src/vs/sessions/contrib/chat/browser/media/newChatMigrationNotice.css @@ -4,21 +4,17 @@ *--------------------------------------------------------------------------------------------*/ .chat-customization-migration-notice.new-chat-migration-notice { - /* Keep the composer centered independently of the notice's height. */ - position: absolute; - top: 100%; - left: 0; - right: 0; - width: auto; - margin-top: var(--vscode-spacing-size160); - /* The composer floats over the chat background wallpaper, which the shared - notice's translucent fill lets read straight through. Composite the same - tint over the opaque session background so the notice stays a legible chip. */ + width: 100%; + padding: var(--vscode-spacing-size40) var(--vscode-spacing-size80); + border-color: var(--vscode-contrastBorder, color-mix(in srgb, var(--vscode-foreground) 10%, transparent)); + border-radius: var(--vscode-cornerRadius-medium); background-color: color-mix(in srgb, var(--vscode-foreground) 3%, var(--session-view-background, var(--vscode-editor-background))); + line-height: normal; } .hc-black .chat-customization-migration-notice.new-chat-migration-notice, .hc-light .chat-customization-migration-notice.new-chat-migration-notice { + border-color: var(--vscode-contrastBorder); background-color: var(--vscode-editor-background); } diff --git a/src/vs/sessions/contrib/chat/browser/newChatConnectorRecommendations.ts b/src/vs/sessions/contrib/chat/browser/newChatConnectorRecommendations.ts new file mode 100644 index 0000000000000..591e98ff93515 --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/newChatConnectorRecommendations.ts @@ -0,0 +1,196 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/newChatConnectorRecommendations.css'; +import * as dom from '../../../../base/browser/dom.js'; +import { Button, IButtonOptions } from '../../../../base/browser/ui/button/button.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { Disposable, DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; +import { URI } from '../../../../base/common/uri.js'; +import { localize } from '../../../../nls.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { CustomizationMarketplaceConfiguration, CustomizationMarketplaceSources } from '../../../../platform/customizationMarketplace/common/customizationMarketplaceSources.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { INotificationService } from '../../../../platform/notification/common/notification.js'; +import { IProductService } from '../../../../platform/product/common/productService.js'; +import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js'; +import { IThemeService } from '../../../../platform/theme/common/themeService.js'; +import { logSettingExperimentTrigger } from '../../../../platform/telemetry/common/experimentTrigger.js'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; +import { mcpServerIcon } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationIcons.js'; +import { renderCustomizationMarketplaceIcon } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationPresentation.js'; +import { AICustomizationManagementCommands, AICustomizationManagementSection } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; +import { NEW_SESSION_CONNECTOR_RECOMMENDATIONS_SETTING } from '../common/constants.js'; + +interface IConnectorRecommendation { + readonly id: string; + readonly name: string; + readonly description: string; + readonly icon: URI | undefined; +} + +interface IConnectorRecommendationCard { + readonly definition: IConnectorRecommendation; + readonly element: HTMLElement; + readonly primary: Button; + readonly icon: HTMLElement; + readonly iconDisposables: DisposableStore; +} + +const quietButtonStyles: IButtonOptions = { + ...defaultButtonStyles, + buttonBackground: undefined, + buttonHoverBackground: undefined, + buttonForeground: undefined, + buttonBorder: undefined, + buttonSecondaryBackground: undefined, + buttonSecondaryHoverBackground: undefined, + buttonSecondaryForeground: undefined, + buttonSecondaryBorder: undefined, + secondary: true, +}; + +export class NewChatConnectorRecommendations extends Disposable { + + readonly element = dom.$('.new-session-connector-recommendations'); + private readonly cards = new Map(); + private readonly dismissed = new Set(); + + constructor( + private readonly focusInput: () => void, + @IConfigurationService private readonly configurationService: IConfigurationService, + @ICommandService private readonly commandService: ICommandService, + @ILogService private readonly logService: ILogService, + @INotificationService private readonly notificationService: INotificationService, + @IProductService productService: IProductService, + @IThemeService private readonly themeService: IThemeService, + @ITelemetryService telemetryService: ITelemetryService, + ) { + super(); + this._register(toDisposable(() => this.element.remove())); + if (isConfigurationValueFromDefault(this.configurationService, NEW_SESSION_CONNECTOR_RECOMMENDATIONS_SETTING)) { + logSettingExperimentTrigger(telemetryService, NEW_SESSION_CONNECTOR_RECOMMENDATIONS_SETTING); + } + + this.element.setAttribute('role', 'group'); + this.element.setAttribute('aria-label', localize('newSession.connectorRecommendations.ariaLabel', "Recommended connectors")); + const cards = dom.append(this.element, dom.$('.new-session-connector-recommendation-cards')); + for (const [id, recommendation] of Object.entries(productService.connectorRecommendations ?? {})) { + this.renderCard(cards, { + id, + name: recommendation.name, + description: recommendation.description, + icon: parseIconUri(recommendation.iconUrl), + }); + } + + this.updateVisibility(); + this._register(this.themeService.onDidColorThemeChange(() => this.updateCardIcons())); + this._register(this.configurationService.onDidChangeConfiguration(event => { + if (event.affectsConfiguration(CustomizationMarketplaceConfiguration.MarketplaceEnabled) + || event.affectsConfiguration(CustomizationMarketplaceConfiguration.CopilotConnectorsEnabled) + || event.affectsConfiguration(NEW_SESSION_CONNECTOR_RECOMMENDATIONS_SETTING)) { + this.updateVisibility(); + } + })); + } + + private renderCard(container: HTMLElement, definition: IConnectorRecommendation): void { + const ariaLabel = localize('newSession.connectorRecommendations.open', "Review the {0} connector. {1}", definition.name, definition.description); + const card = dom.append(container, dom.$('.new-session-connector-recommendation-card')); + card.dataset.connectorId = definition.id; + const primary = this._register(new Button(card, { ...quietButtonStyles, ariaLabel })); + primary.element.classList.add('new-session-connector-recommendation-card-primary'); + const icon = dom.append(primary.element, dom.$('.new-session-connector-recommendation-icon')); + const content = dom.append(primary.element, dom.$('.new-session-connector-recommendation-content')); + dom.append(content, dom.$('.new-session-connector-recommendation-name', undefined, definition.name)); + dom.append(content, dom.$('.new-session-connector-recommendation-description', undefined, definition.description)); + this._register(primary.onDidClick(() => this.openConnector(definition))); + const dismiss = this._register(new Button(card, { + ...quietButtonStyles, + ariaLabel: localize('newSession.connectorRecommendations.dismissOne', "Dismiss {0} Connector Recommendation for This Session", definition.name), + })); + dismiss.element.classList.add('new-session-connector-recommendation-dismiss'); + dismiss.icon = Codicon.close; + this._register(dismiss.onDidClick(() => this.dismissCard(definition.id))); + const iconDisposables = this._register(new DisposableStore()); + const recommendationCard = { definition, element: card, primary, icon, iconDisposables }; + this.cards.set(definition.id, recommendationCard); + this.renderCardIcon(recommendationCard); + } + + private dismissCard(id: string): void { + this.dismissed.add(id); + const remainingCards = [...this.cards.values()].filter(card => !this.dismissed.has(card.definition.id)); + this.updateVisibility(); + remainingCards[0]?.primary.focus(); + if (remainingCards.length === 0) { + this.focusInput(); + } + } + + private async openConnector(definition: IConnectorRecommendation): Promise { + try { + await this.commandService.executeCommand(AICustomizationManagementCommands.OpenMarketplace, { + section: AICustomizationManagementSection.McpServers, + sourceId: CustomizationMarketplaceSources.CopilotConnectors.id, + connectorName: definition.id, + }); + } catch (error) { + this.logService.error(`Failed to open the ${definition.name} Copilot connector`, error); + this.notificationService.error(localize('newSession.connectorRecommendations.openError', "Unable to open the {0} connector.", definition.name)); + } + } + + private updateVisibility(): void { + for (const card of this.cards.values()) { + card.element.hidden = this.dismissed.has(card.definition.id); + } + this.element.hidden = this.cards.size === 0 + || this.dismissed.size === this.cards.size + || this.configurationService.getValue(NEW_SESSION_CONNECTOR_RECOMMENDATIONS_SETTING) !== true + || this.configurationService.getValue(CustomizationMarketplaceConfiguration.MarketplaceEnabled) !== true + || this.configurationService.getValue(CustomizationMarketplaceConfiguration.CopilotConnectorsEnabled) !== true; + } + + private updateCardIcons(): void { + for (const card of this.cards.values()) { + this.renderCardIcon(card); + } + } + + private renderCardIcon(card: IConnectorRecommendationCard): void { + card.iconDisposables.clear(); + renderCustomizationMarketplaceIcon( + card.icon, + mcpServerIcon, + card.definition.icon, + this.themeService.getColorTheme().type, + card.iconDisposables, + ); + } +} + +function parseIconUri(iconUrl: string): URI | undefined { + try { + const icon = URI.parse(iconUrl); + return icon.scheme === 'https' || icon.scheme === 'data' ? icon : undefined; + } catch { + return undefined; + } +} + +function isConfigurationValueFromDefault(configurationService: IConfigurationService, setting: string): boolean { + const inspected = configurationService.inspect(setting); + return inspected.applicationValue === undefined + && inspected.userValue === undefined + && inspected.userLocalValue === undefined + && inspected.userRemoteValue === undefined + && inspected.workspaceValue === undefined + && inspected.workspaceFolderValue === undefined + && inspected.memoryValue === undefined + && inspected.policyValue === undefined; +} diff --git a/src/vs/sessions/contrib/chat/browser/newChatWidget.ts b/src/vs/sessions/contrib/chat/browser/newChatWidget.ts index 39948755ba5e1..7aab127ae4f27 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatWidget.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatWidget.ts @@ -59,7 +59,7 @@ import { buildNewSessionPrompt } from '../../agentFeedback/browser/agentFeedback import { SessionInputBannerWidget } from '../../sessionInputBanners/browser/sessionInputBannerWidget.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { ChatInputTipPresenter } from '../../../../workbench/contrib/chat/browser/widget/input/chatInputTipPresenter.js'; -import { chatInputStackClass, ChatInputStackSlot, setChatInputStackSlot } from '../../../../workbench/contrib/chat/browser/widget/input/chatInputStack.js'; +import { chatInputStackClass, chatInputStackSlotClass, chatInputSurfaceStackClass, ChatInputStackSlot, setChatInputStackSlot } from '../../../../workbench/contrib/chat/browser/widget/input/chatInputStack.js'; import { IChatPetService } from '../../../../workbench/contrib/chat/browser/chatPetService.js'; import { IChatTipService } from '../../../../workbench/contrib/chat/browser/chatTipService.js'; import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; @@ -82,6 +82,7 @@ import { isAutoModel, isHydraFusionModel } from '../../../../workbench/contrib/c import { IAgentsWindowDraft } from '../../../../platform/window/common/window.js'; import { reviveChatDraft } from '../../../../workbench/contrib/chat/common/attachments/chatDraft.js'; import { NewChatMigrationNotice } from './newChatMigrationNotice.js'; +import { NewChatConnectorRecommendations } from './newChatConnectorRecommendations.js'; import { FOCUS_NEW_SESSION_HARNESS_PICKER_WHEN, FOCUS_NEW_SESSION_WORKSPACE_PICKER_WHEN } from './newChatPickerKeybinding.js'; import { IAuthenticationService } from '../../../../workbench/services/authentication/common/authentication.js'; import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; @@ -768,7 +769,9 @@ export class NewChatWidget extends Disposable { : undefined ); })); - this._register(this.instantiationService.createInstance(NewChatMigrationNotice, chatWidgetContent, this._session, () => this.focusInput())); + const belowInputSurfaces = dom.append(chatWidgetContent, dom.$(`.new-session-below-input-surfaces.${chatInputSurfaceStackClass}`)); + const connectorRecommendations = this._register(this.instantiationService.createInstance(NewChatConnectorRecommendations, () => this.focusInput())); + belowInputSurfaces.appendChild(connectorRecommendations.element); // In the experimental composer the getting-started tip is demoted to a standalone // notice below the input (like the migration notice) so it never sits between the @@ -777,13 +780,13 @@ export class NewChatWidget extends Disposable { // experimental layout (which requires the unified workspace picker) moves the tip without // recreating the presenter. const inputTipSlot = this._newChatInput.gettingStartedTipContainerElement; - // The below host carries the tip container class so the shared tip styling applies in the - // demoted position exactly as it does in the input's own slot. - const tipBelowHost = dom.append(chatWidgetContent, dom.$('.new-session-getting-started-tip-below.chat-getting-started-tip-container')); - const chatTipContainer = dom.$('.new-session-getting-started-tip'); + const chatTipContainer = dom.$(`.new-session-getting-started-tip.chat-getting-started-tip-container.${chatInputStackSlotClass}`); this._register(autorun(reader => { - (this._useExperimentalComposerLayout.read(reader) ? tipBelowHost : inputTipSlot)?.appendChild(chatTipContainer); + const showBelowInput = this._useExperimentalComposerLayout.read(reader); + chatTipContainer.classList.toggle('new-session-getting-started-tip-below', showBelowInput); + (showBelowInput ? belowInputSurfaces : inputTipSlot)?.appendChild(chatTipContainer); })); + this._register(this.instantiationService.createInstance(NewChatMigrationNotice, belowInputSurfaces, this._session, () => this.focusInput())); this._chatTipPresenter.value = this.instantiationService.createInstance( ChatInputTipPresenter, { diff --git a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts index c721c207c1152..e379b972852c9 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts @@ -31,13 +31,14 @@ import { isPhoneLayout } from '../../../browser/parts/mobile/mobileLayout.js'; import { ISessionComparisonService } from '../../../services/sessions/common/sessionComparison.js'; import { buildSessionComparisonAccessibleContent, isJudgeSession, SessionComparisonResultFocused } from './sessionComparisonResult.js'; import { SESSIONS_CHAT_TABS_DEFAULT, SESSIONS_CHAT_TABS_SETTING, SESSIONS_LIST_GROUP_EXTERNAL_SESSIONS_SETTING, SessionsChatTabsMode } from '../../../common/sessionConfig.js'; -import { AGENTS_PICKER_IN_ATTACH_CONTEXT_MENU_SETTING, COMPARE_AGENTS_ENABLED_SETTING, EXPERIMENTAL_NEW_SESSION_COMPOSER_LAYOUT_SETTING, NEW_SESSION_WELCOME_PHRASES_SETTING, UNIFIED_WORKSPACE_PICKER_SETTING } from '../common/constants.js'; +import { AGENTS_PICKER_IN_ATTACH_CONTEXT_MENU_SETTING, COMPARE_AGENTS_ENABLED_SETTING, EXPERIMENTAL_NEW_SESSION_COMPOSER_LAYOUT_SETTING, NEW_SESSION_CONNECTOR_RECOMMENDATIONS_SETTING, NEW_SESSION_WELCOME_PHRASES_SETTING, UNIFIED_WORKSPACE_PICKER_SETTING } from '../common/constants.js'; import { IAgentHostFilterService } from '../../../services/agentHostFilter/common/agentHostFilter.js'; import { IAccessibilityService } from '../../../../platform/accessibility/common/accessibility.js'; import { RemoteAgentHostAutoConnectSettingId, RemoteAgentHostsEnabledSettingId } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; import { IChatEntitlementService } from '../../../../workbench/services/chat/common/chatEntitlementService.js'; import { AGENT_SESSIONS_RESPONSE_SELECTION_MENU_SETTING } from './responseSelectionSideChatController.js'; import { areDevContainerSamplesEnabled } from '../../../common/devContainerAgentHostService.js'; +import { CustomizationMarketplaceConfiguration } from '../../../../platform/customizationMarketplace/common/customizationMarketplaceSources.js'; export class SessionsChatAccessibilityHelp implements IAccessibleViewImplementation { readonly priority = 120; readonly name = 'sessionsChat'; @@ -137,6 +138,11 @@ export class SessionsChatAccessibilityHelp implements IAccessibleViewImplementat content.push(localize('sessionsChat.compareAgentsInactivePaneNotification', "When a question tool needs attention in an inactive visible pane, the confirmation notification setting can show an operating system notification even while another pane in the Agents window is active.")); } content.push(localize('sessionsChat.migrations', "When agent customizations need an update, a notice below the new-session input shows how many need attention. Use Tab to reach Review Migrations and open the Migrations page. Dismiss Migration Notice for This Workspace hides the notice for that workspace, including after restarting, and returns focus to the input.")); + if (configurationService.getValue(NEW_SESSION_CONNECTOR_RECOMMENDATIONS_SETTING) === true + && configurationService.getValue(CustomizationMarketplaceConfiguration.MarketplaceEnabled) === true + && configurationService.getValue(CustomizationMarketplaceConfiguration.CopilotConnectorsEnabled) === true) { + content.push(localize('sessionsChat.connectorRecommendations', "Near the new-session input, three connector cards offer Linear, Notion, and WorkIQ. On phones the cards appear above the input; on wider layouts they appear below it. Use Tab and Shift+Tab to move between each card and its dismiss button. Press Enter or Space on a card to review that connector in the Copilot Connectors marketplace. Dismissing a recommendation moves focus to the next card; dismissing the last card returns focus to the input.")); + } if (configurationService.getValue(UNIFIED_WORKSPACE_PICKER_SETTING)) { content.push(localize('sessionsChat.newSessionPickers', "In a new-session composer, open and focus the workspace picker{0} or the harness picker{1}. Focus either picker control and open its context menu{2} to configure its keybinding.", ``, ``, '')); } diff --git a/src/vs/sessions/contrib/chat/common/constants.ts b/src/vs/sessions/contrib/chat/common/constants.ts index 0a047ff0a74b2..7adb995917057 100644 --- a/src/vs/sessions/contrib/chat/common/constants.ts +++ b/src/vs/sessions/contrib/chat/common/constants.ts @@ -17,3 +17,4 @@ export const COLLAPSED_SESSION_OPTIONS_SHOW_ICONS_SETTING = 'sessions.chat.exper export const NEW_SESSION_WELCOME_PHRASES_SETTING = 'sessions.chat.experimental.welcomePhrases'; export const NEW_SESSION_WELCOME_NAME_SETTING = 'sessions.chat.experimental.welcomeName'; export const NEW_SESSION_WELCOME_MESSAGES_SETTING = 'sessions.chat.experimental.welcomeMessages'; +export const NEW_SESSION_CONNECTOR_RECOMMENDATIONS_SETTING = 'sessions.chat.experimental.connectorRecommendations'; diff --git a/src/vs/sessions/contrib/chat/test/browser/newChatConnectorRecommendations.test.ts b/src/vs/sessions/contrib/chat/test/browser/newChatConnectorRecommendations.test.ts new file mode 100644 index 0000000000000..3ef1227409664 --- /dev/null +++ b/src/vs/sessions/contrib/chat/test/browser/newChatConnectorRecommendations.test.ts @@ -0,0 +1,210 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { mock } from '../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { type IConfigurationChangeEvent, type IConfigurationOverrides, IConfigurationService, type IConfigurationValue } from '../../../../../platform/configuration/common/configuration.js'; +import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { CustomizationMarketplaceConfiguration, CustomizationMarketplaceSources } from '../../../../../platform/customizationMarketplace/common/customizationMarketplaceSources.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; +import { INotificationService } from '../../../../../platform/notification/common/notification.js'; +import { IProductService } from '../../../../../platform/product/common/productService.js'; +import { IThemeService } from '../../../../../platform/theme/common/themeService.js'; +import { TestThemeService } from '../../../../../platform/theme/test/common/testThemeService.js'; +import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; +import { TestExperimentTriggerTelemetryService } from '../../../../../platform/telemetry/test/common/experimentTriggerTestUtils.js'; +import { mcpServerIcon } from '../../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationIcons.js'; +import { AICustomizationManagementCommands, AICustomizationManagementSection } from '../../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; +import { NewChatConnectorRecommendations } from '../../browser/newChatConnectorRecommendations.js'; +import { NEW_SESSION_CONNECTOR_RECOMMENDATIONS_SETTING } from '../../common/constants.js'; + +const connectorRecommendations: NonNullable = { + linear: { name: 'Linear', description: 'Plan and track product work.', iconUrl: 'https://example.com/linear.png' }, + notion: { name: 'Notion', description: 'Search team knowledge and documentation.', iconUrl: 'https://example.com/notion.png' }, + 'workiq-mail': { name: 'WorkIQ', description: 'Bring Microsoft 365 work context into chats.', iconUrl: 'https://example.com/workiq.png' }, +}; + +suite('NewChatConnectorRecommendations', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + function setup(marketplaceEnabled: boolean, connectorsEnabled: boolean, fromDefault = true, productRecommendations: IProductService['connectorRecommendations'] | null = connectorRecommendations, recommendationsEnabled = connectorsEnabled) { + const instantiationService = store.add(new TestInstantiationService()); + const configurationService = new class extends TestConfigurationService { + override inspect(key: string, overrides?: IConfigurationOverrides): IConfigurationValue { + if (!fromDefault) { + return super.inspect(key, overrides); + } + const value = this.getValue(key, overrides); + return { value, defaultValue: value, overrideIdentifiers: [] }; + } + }({ + [NEW_SESSION_CONNECTOR_RECOMMENDATIONS_SETTING]: recommendationsEnabled, + [CustomizationMarketplaceConfiguration.MarketplaceEnabled]: marketplaceEnabled, + [CustomizationMarketplaceConfiguration.CopilotConnectorsEnabled]: connectorsEnabled, + }); + store.add(configurationService.onDidChangeConfigurationEmitter); + const commands: { id: string; args: readonly unknown[] }[] = []; + const errors: unknown[][] = []; + const notifications: string[] = []; + const telemetryService = new TestExperimentTriggerTelemetryService(); + let focusCount = 0; + instantiationService.stub(IConfigurationService, configurationService); + instantiationService.stub(ICommandService, { + executeCommand: async (id: string, ...args: unknown[]) => { + commands.push({ id, args }); + }, + }); + instantiationService.stub(ILogService, { + error: (...args: unknown[]) => errors.push(args), + warn: (...args: unknown[]) => errors.push(args), + }); + instantiationService.stub(INotificationService, new class extends mock() { + override error(message: string) { notifications.push(message); return undefined; } + }()); + instantiationService.stub(IProductService, { _serviceBrand: undefined, connectorRecommendations: productRecommendations ?? undefined }); + instantiationService.stub(IThemeService, new TestThemeService()); + instantiationService.stub(ITelemetryService, telemetryService); + const recommendations = store.add(instantiationService.createInstance(NewChatConnectorRecommendations, () => focusCount++)); + document.body.appendChild(recommendations.element); + return { recommendations, configurationService, commands, errors, notifications, telemetryService, get focusCount() { return focusCount; } }; + } + + test('triggers the recommendation experiment before deciding visibility', () => { + const triggers = [ + setup(false, false).telemetryService.triggers, + setup(true, true).telemetryService.triggers, + setup(true, true, false).telemetryService.triggers, + ]; + + assert.deepStrictEqual(triggers, [ + ['config.sessions.chat.experimental.connectorRecommendations'], + ['config.sessions.chat.experimental.connectorRecommendations'], + [], + ]); + }); + + test('requires the dedicated recommendation setting in addition to connector capability', () => { + const fixture = setup(true, true, true, connectorRecommendations, false); + + assert.deepStrictEqual({ + hidden: fixture.recommendations.element.hidden, + triggers: fixture.telemetryService.triggers, + }, { + hidden: true, + triggers: ['config.sessions.chat.experimental.connectorRecommendations'], + }); + }); + + test('renders product recommendations and opens the selected connector detail', async () => { + const fixture = setup(true, true); + await Promise.resolve(); + await Promise.resolve(); + const cards = [...fixture.recommendations.element.querySelectorAll('.new-session-connector-recommendation-card')]; + cards[2].querySelector('.new-session-connector-recommendation-card-primary')!.click(); + await Promise.resolve(); + + assert.deepStrictEqual({ + hidden: fixture.recommendations.element.hidden, + role: fixture.recommendations.element.getAttribute('role'), + names: cards.map(card => card.querySelector('.new-session-connector-recommendation-name')?.textContent), + descriptions: cards.map(card => card.querySelector('.new-session-connector-recommendation-description')?.textContent), + icons: cards.map(card => card.querySelector('img')?.src), + commands: fixture.commands, + errors: fixture.errors, + triggers: fixture.telemetryService.triggers, + }, { + hidden: false, + role: 'group', + names: ['Linear', 'Notion', 'WorkIQ'], + descriptions: [ + 'Plan and track product work.', + 'Search team knowledge and documentation.', + 'Bring Microsoft 365 work context into chats.', + ], + icons: ['https://example.com/linear.png', 'https://example.com/notion.png', 'https://example.com/workiq.png'], + commands: [{ + id: AICustomizationManagementCommands.OpenMarketplace, + args: [{ + section: AICustomizationManagementSection.McpServers, + sourceId: CustomizationMarketplaceSources.CopilotConnectors.id, + connectorName: 'workiq-mail', + }], + }], + errors: [], + triggers: ['config.sessions.chat.experimental.connectorRecommendations'], + }); + }); + + test('tracks enablement and dismisses individual cards for the widget lifetime', async () => { + const fixture = setup(true, false, true, connectorRecommendations, true); + assert.strictEqual(fixture.recommendations.element.hidden, true); + + await fixture.configurationService.setUserConfiguration(CustomizationMarketplaceConfiguration.CopilotConnectorsEnabled, true); + fixture.configurationService.onDidChangeConfigurationEmitter.fire(new class extends mock() { + override affectsConfiguration(section: string): boolean { + return section === CustomizationMarketplaceConfiguration.CopilotConnectorsEnabled; + } + }()); + assert.strictEqual(fixture.recommendations.element.hidden, false); + + const cards = [...fixture.recommendations.element.querySelectorAll('.new-session-connector-recommendation-card')]; + const dismissButtons = cards.map(card => card.querySelector('.new-session-connector-recommendation-dismiss')!); + dismissButtons[0].click(); + const focusAfterFirstDismiss = document.activeElement === cards[1].querySelector('.new-session-connector-recommendation-card-primary'); + dismissButtons[1].click(); + dismissButtons[2].click(); + await fixture.configurationService.setUserConfiguration(CustomizationMarketplaceConfiguration.MarketplaceEnabled, false); + await fixture.configurationService.setUserConfiguration(CustomizationMarketplaceConfiguration.MarketplaceEnabled, true); + fixture.configurationService.onDidChangeConfigurationEmitter.fire(new class extends mock() { + override affectsConfiguration(section: string): boolean { + return section === CustomizationMarketplaceConfiguration.MarketplaceEnabled; + } + }()); + + assert.deepStrictEqual({ + hidden: fixture.recommendations.element.hidden, + dismissedCards: cards.map(card => card.hidden), + focusAfterFirstDismiss, + focusCount: fixture.focusCount, + }, { + hidden: true, + dismissedCards: [true, true, true], + focusAfterFirstDismiss: true, + focusCount: 1, + }); + }); + + test('uses the MCP server icon when a product image URL is invalid', () => { + const fixture = setup(true, true, true, { + linear: { name: 'Linear', description: 'Plan and track product work.', iconUrl: 'data:image/png;base64,invalid' }, + notion: { name: 'Notion', description: 'Search team knowledge and documentation.', iconUrl: '' }, + 'workiq-mail': { name: 'WorkIQ', description: 'Bring Microsoft 365 work context into chats.', iconUrl: 'file:///tmp/workiq.png' }, + }); + const fallbackClasses = [...fixture.recommendations.element.querySelectorAll('.new-session-connector-recommendation-icon > .codicon')] + .map(icon => [...icon.classList]); + + assert.deepStrictEqual(fallbackClasses, [ + ['codicon', 'codicon-ai-customization-mcp-server'], + ['codicon', 'codicon-ai-customization-mcp-server'], + ['codicon', 'codicon-ai-customization-mcp-server'], + ]); + assert.strictEqual(mcpServerIcon.id, 'ai-customization-mcp-server'); + }); + + test('hides the surface when the product has no recommendations', () => { + const fixture = setup(true, true, true, null); + + assert.deepStrictEqual({ + hidden: fixture.recommendations.element.hidden, + cardCount: fixture.recommendations.element.querySelectorAll('.new-session-connector-recommendation-card').length, + }, { + hidden: true, + cardCount: 0, + }); + }); +}); 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 922f25184a140..02496ee540e55 100644 --- a/src/vs/sessions/contrib/chat/test/browser/newChatWidget.fixture.ts +++ b/src/vs/sessions/contrib/chat/test/browser/newChatWidget.fixture.ts @@ -31,19 +31,23 @@ import { TestConfigurationService } from '../../../../../platform/configuration/ import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { IContextViewService } from '../../../../../platform/contextview/browser/contextView.js'; import { ContextViewService } from '../../../../../platform/contextview/browser/contextViewService.js'; +import { ICustomizationMarketplaceResource } from '../../../../../platform/customizationMarketplace/common/customizationMarketplaceService.js'; +import { CustomizationMarketplaceConfiguration, CustomizationMarketplaceSources } from '../../../../../platform/customizationMarketplace/common/customizationMarketplaceSources.js'; import { ILayoutService } from '../../../../../platform/layout/browser/layoutService.js'; import { IQuickInputService } from '../../../../../platform/quickinput/common/quickInput.js'; -import { InMemoryStorageService, IStorageService } from '../../../../../platform/storage/common/storage.js'; +import { InMemoryStorageService, IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { asCssVariable } from '../../../../../platform/theme/common/colorUtils.js'; import { isHighContrast } from '../../../../../platform/theme/common/theme.js'; import { IUriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentity.js'; import { IRequestService } from '../../../../../platform/request/common/request.js'; +import { IProductService } from '../../../../../platform/product/common/productService.js'; import { IChatTipService } from '../../../../../workbench/contrib/chat/browser/chatTipService.js'; import { ChatSpeechToTextState, IChatSpeechToTextService } from '../../../../../workbench/contrib/chat/browser/speechToText/chatSpeechToTextService.js'; import { IMicCaptureService } from '../../../../../workbench/contrib/chat/browser/voiceClient/micCaptureService.js'; import { ITtsPlaybackService } from '../../../../../workbench/contrib/chat/browser/voiceClient/ttsPlaybackService.js'; import { IVoiceSessionController } from '../../../../../workbench/contrib/chat/browser/voiceClient/voiceSessionController.js'; import { IChatWidgetService } from '../../../../../workbench/contrib/chat/browser/chat.js'; +import { ChatContextKeys } from '../../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; import { IVoiceInputModeService, VoiceInputMode } from '../../../../../workbench/contrib/chat/browser/voiceInputMode/voiceInputMode.js'; import { IAICustomizationWorkspaceService } from '../../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; import { ICustomizationHarnessService } from '../../../../../workbench/contrib/chat/common/customizationHarnessService.js'; @@ -66,7 +70,7 @@ import { IsPhoneLayoutContext } from '../../../../common/contextkeys.js'; import { IViewsService } from '../../../../../workbench/services/views/common/viewsService.js'; import { ISearchService } from '../../../../../workbench/services/search/common/search.js'; import { FixtureMenuService, registerChatFixtureServices } from '../../../../../workbench/test/browser/componentFixtures/chat/chatFixtureUtils.js'; -import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup } from '../../../../../workbench/test/browser/componentFixtures/fixtureUtils.js'; +import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, waitForFixtureCondition } from '../../../../../workbench/test/browser/componentFixtures/fixtureUtils.js'; import { activeSessionViewBackground } from '../../../../common/theme.js'; import { IAgentHostSessionsProvider, LOCAL_AGENT_HOST_PROVIDER_ID } from '../../../../common/agentHostSessionsProvider.js'; import { IAgentWorkbenchLayoutService } from '../../../../browser/workbench.js'; @@ -85,11 +89,12 @@ import { AGENT_FEEDBACK_NEW_SESSION_RESOURCE, AgentFeedbackKind, AgentFeedbackSt import { IAquariumService } from '../../../aquarium/browser/aquariumOverlay.js'; import { computeIssueIcon, computePullRequestIcon, GitHubIssueState, GitHubPullRequestState } from '../../../github/common/types.js'; import { NewChatView } from '../../browser/chatView.js'; -import { COLLAPSED_SESSION_OPTIONS_SHOW_ICONS_SETTING, COMPARE_AGENTS_ENABLED_SETTING, EXPERIMENTAL_NEW_SESSION_COMPOSER_LAYOUT_SETTING, NEW_SESSION_WELCOME_MESSAGES_SETTING, NEW_SESSION_WELCOME_NAME_SETTING, NEW_SESSION_WELCOME_PHRASES_SETTING, UNIFIED_WORKSPACE_PICKER_SETTING } from '../../common/constants.js'; +import { COLLAPSED_SESSION_OPTIONS_SHOW_ICONS_SETTING, COMPARE_AGENTS_ENABLED_SETTING, EXPERIMENTAL_NEW_SESSION_COMPOSER_LAYOUT_SETTING, NEW_SESSION_CONNECTOR_RECOMMENDATIONS_SETTING, NEW_SESSION_WELCOME_MESSAGES_SETTING, NEW_SESSION_WELCOME_NAME_SETTING, NEW_SESSION_WELCOME_PHRASES_SETTING, UNIFIED_WORKSPACE_PICKER_SETTING } from '../../common/constants.js'; import { getAdditionalFolderContextId, getAdditionalRepositoryContextId } from '../../common/newChatContextIds.js'; import { INewSessionComposerService, INewSessionPromptOption, NewSessionComposerService, NewSessionPromptOptionsState } from '../../browser/newSessionComposerService.js'; import { INewChatVoiceTargetService, NewChatVoiceTargetService } from '../../browser/newChatVoice.js'; import { ISessionChangesService } from '../../../changes/browser/sessionChangesService.js'; +import { AGENTS_WINDOW_TOTAL_SESSIONS_STORAGE_KEY } from '../../../../../platform/chat/common/agentsWindowInvitation.js'; import '../../../../browser/media/style.css'; import '../../../../browser/parts/media/sessionView.css'; @@ -101,6 +106,58 @@ const ATTACHED_FOLDER_URI = URI.file('/Code/docs'); const ATTACHED_REPOSITORY_URI = URI.parse('https://github.com/microsoft/typescript'); const ATTACHED_REPOSITORY_ROOT = URI.parse('vscode-vfs://github/microsoft/typescript/HEAD'); +function connectorIcon(base64: string): URI { + return URI.parse(`data:image/png;base64,${base64}`); +} + +const linearConnectorIcon = 'iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAGZElEQVR42s1Xa2xTZRh+vnPabmNd1250bQcDnGHohDElkYFx84ew8EcuXrYxLoNtDMQ5f6iJwMxiooiXjUswKhEYWzfQhIuCEn7IEKJm3EIykIRsiAlYoS3burU9pz3f64+26x0xRvEkb07Od77vfZ/v/Z738rHpM4oJD/BRET1Q+2EAjAGxWCLHYv8zBgAMAEW84+ckWx8aU4UWch5SGp5IFB4L/GdjiwNzQhpjvUggSrQecSCjjiDRaYTHGIgoCILAOSHsPQZBEILzKQ5IMv1Ef4MDgsCgKBycc2i1WmRnZ0On0wEAXC4XHA4HXC4XBIFBFFXgnCfwTEIOIIEbWcRYYHeSJCM/Px/l5fMxd+4cTJk8GTpdxhiAGzd+w48//YwTJ07g2rV+qNWqiGNiSfWzgmmFkU6KM845hyAIWFa9FA0N9cjJybnnju7cuYNdu75AR6cVnPMg+SiGsGH9bGrBIxQejGepKIpobt6EqsoKAIDP5wNjbExC5x4StVoNADh0+Ag2bNgERfEjmX4AEAJs5UEFfEyRIAhwOp0oLS1FVWUF3G43ZFlOaDgMmEGWZXi9XixetBDvvNMCWfZFzOVRYIkIoiEruyU2hBgL7DQvbxIuXLiI9PRxKCkpgSRJCXcdAhH5liQJxcUzcfPWTZw9ew5paangPJ6UosEQDYAxBp/PB71ej2NHj0AQBLz11kZYLGbMnv0kPB5vRDxTUuE8sNvHHivE198cxeioG4IgIDbqRL0hK84DAIMkSZg16wmsXlUDSZKwYWMzLGYLSkpmw+PxRMRy2Fjs2+v1wmw2o79/AL29Z5GWlhYMz0gA+mgARIGYV6vVsFq7UFhYiLVr18Dj9mDjpmaYzSbMmTMHo6OjUV7gnEcB4JxDURSIoghJknDs2LdI0WjAYz2QqTe0RLJeUThUKhW6uzths/2Bd9/bjJlFRVi3bi2GXcNofrsFZpMZTz01FyMjI8E0G288coyB4fCRr+H3++NqQlwmZIxBkrzwuN3Y174Hi5e8gMqqanR3deLDD7bA71ewbv16cOKor6uF3W6HKIpJOSHLMjIytNBq02G3O6BWq6N4EFcLQim3tq4B+9p346sv9+OliipUVC5Fl7UDba0fgXMFL69/BVxRUFu7GvY7dgiiEBeaodoR8kRs1CT0gKJwpKRo4HDY8WJFFb460I3urg5UVi3D0url6Oxsx9a2VigKxyuNTeBEWLlieZQnIgmqUqkwODgYrBNCMBdEJSIKMp+COYBheNiFN994HQ/n5+O5hYtx+fIVdFk7UFZWiurqFTh48BC2tn2MtQ1r0NjYhD1722EwGCDLctT5+/1+iKKI/oHrGBoahigKMV4gCIGSGZYQB+wOB7qsHTCbTVi06Hn09V0eA7Fs+QocOnQY27a1or6+Dk2vvoa97fuQlZUFWZahKAoURYHf74cgMJw5cwY+ny9xAsudkEfRMolM5gk05aGHqa+vj3755SrNLH6CjDlm+uH0abLb7TS/fAGlpI6j/fsPkMfjprq6NQQI1NraRrdv36YrV67Q1atXqb+/ny5cvEhTCx6lHFMu5U6YRLH2YMmdSLEyMW8yaTMyqaZmNd29e5fOnTtHxY/PovFGE/X0nCKbzUbzyxeQRpNGXd3d5HK5qLaungBGH33cSjbb73Tp0iWy2WxUv6aB0rWZNDFvMiWyBbNlAiUSS24e6TKzqLVtKzmdDurt7aUZRcVkNJro+5Mn6datmzRvXjlpUtKos9NKQ0ODtGp1LQGg9za/T0ODg7R9+w7SZWaRJTePktkR07UZLYnKJBGgUqlw8mQPdDodykqfxrxnn8V3x49j9+49eKasDPX1dTh//jy2bd+BgoJpaHq1EQ6HE59+9jkGBq6j02oNEE1gSNZ5sRyTJWnfFIrhQHldhKamRmRkZGBlzSpcH7iOvXt3Y/r06WhoWIeeUz34ZOdOFBXNwEsVS3Hjxq/Q6w0RDUkSG8Yc8z0bN8YYGBiGhodhNI7HkiWLYTQasWXLFuj1BuzYvhU6nQ41q2rhdruh1+vhdDqh0WigKH/dF7LxRtN9daWiKEKWZbhGRpCakoL09HR4vRLUahVSU1MhyTJ4MPxi0+09AWSPz7nvq1Go/Q605ByMBTKbonCIohDVrPwrV7NQtQt/K2CMRWW4//xu+E/X/38upw/q+RNjBjB4UKkl3AAAAABJRU5ErkJggg=='; +const notionConnectorIcon = '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'; +const workIqConnectorIcon = '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'; + +const fixtureConnectors: readonly ICustomizationMarketplaceResource[] = [ + { + sourceId: CustomizationMarketplaceSources.CopilotConnectors.id, + identifier: 'linear', + displayName: 'Linear', + description: 'Plan and track product work.', + mediaType: 'application/mcp-server+json', + tags: [], + capabilities: [], + representativeQueries: [], + icon: connectorIcon(linearConnectorIcon), + }, + { + sourceId: CustomizationMarketplaceSources.CopilotConnectors.id, + identifier: 'notion', + displayName: 'Notion', + description: 'Search team knowledge and documentation.', + mediaType: 'application/mcp-server+json', + tags: [], + capabilities: [], + representativeQueries: [], + icon: connectorIcon(notionConnectorIcon), + }, + { + sourceId: CustomizationMarketplaceSources.CopilotConnectors.id, + identifier: 'workiq-mail', + displayName: 'Work IQ', + description: 'Bring Microsoft 365 work context into chats.', + mediaType: 'application/mcp-server+json', + tags: [], + capabilities: [], + representativeQueries: [], + icon: connectorIcon(workIqConnectorIcon), + }, +]; + +const fixtureConnectorRecommendations: NonNullable = Object.fromEntries( + fixtureConnectors.map(connector => [connector.identifier, { + name: connector.identifier === 'workiq-mail' ? 'WorkIQ' : connector.displayName, + description: connector.description, + iconUrl: URI.isUri(connector.icon) ? connector.icon.toString(true) : '', + }]), +); + interface INewChatWidgetFixtureOptions { readonly width?: number; readonly height?: number; @@ -130,6 +187,10 @@ interface INewChatWidgetFixtureOptions { readonly unifiedWorkspacePicker?: boolean; readonly collapsedSessionOptionsShowIcons?: boolean; readonly welcomePhrases?: boolean; + readonly connectorRecommendations?: boolean; + readonly connectorProductIcons?: boolean; + readonly dismissedConnectorCount?: number; + readonly focusConnectorRecommendations?: boolean; } class FixturePickerActionViewItem extends BaseActionViewItem implements IChatInputPickerResponsiveState { @@ -280,6 +341,10 @@ async function renderNewChatWidget(context: ComponentFixtureContext, options: IN unifiedWorkspacePicker = experimentalComposerLayout, collapsedSessionOptionsShowIcons = false, welcomePhrases = false, + connectorRecommendations = false, + connectorProductIcons = true, + dismissedConnectorCount = 0, + focusConnectorRecommendations = false, } = options; const hasChatBackground = chatBackground !== undefined; const feedbackItems: readonly IAgentFeedback[] = Array.from({ length: commentCount }, (_, index) => ({ @@ -310,23 +375,35 @@ async function renderNewChatWidget(context: ComponentFixtureContext, options: IN [UNIFIED_WORKSPACE_PICKER_SETTING]: unifiedWorkspacePicker, [EXPERIMENTAL_NEW_SESSION_COMPOSER_LAYOUT_SETTING]: experimentalComposerLayout, [COLLAPSED_SESSION_OPTIONS_SHOW_ICONS_SETTING]: collapsedSessionOptionsShowIcons, + [CustomizationMarketplaceConfiguration.MarketplaceEnabled]: connectorRecommendations, + [CustomizationMarketplaceConfiguration.CopilotConnectorsEnabled]: connectorRecommendations, + [NEW_SESSION_CONNECTOR_RECOMMENDATIONS_SETTING]: connectorRecommendations, ...(chatBackground === 'codicons' ? { [AGENT_SESSIONS_PREFERRED_DARK_CHAT_BACKGROUND_IMAGE_SETTING]: AGENT_SESSIONS_CHAT_BACKGROUND_CODICONS_PRESET, [AGENT_SESSIONS_PREFERRED_LIGHT_CHAT_BACKGROUND_IMAGE_SETTING]: AGENT_SESSIONS_CHAT_BACKGROUND_CODICONS_PRESET, } : {}), }); disposableStore.add(configurationService.onDidChangeConfigurationEmitter); + const storageService = disposableStore.add(new InMemoryStorageService()); + if (showTip) { + storageService.store(AGENTS_WINDOW_TOTAL_SESSIONS_STORAGE_KEY, 2, StorageScope.APPLICATION, StorageTarget.MACHINE); + } const instantiationService = createEditorServices(disposableStore, { colorTheme: context.theme, additionalServices: reg => { registerChatFixtureServices(reg); reg.defineInstance(IConfigurationService, configurationService); + reg.defineInstance(IProductService, new class extends mock() { + override readonly connectorRecommendations = connectorRecommendations + ? connectorProductIcons + ? fixtureConnectorRecommendations + : Object.fromEntries(Object.entries(fixtureConnectorRecommendations).map(([id, recommendation]) => [id, { ...recommendation, iconUrl: '' }])) + : undefined; + }()); + reg.defineInstance(IStorageService, storageService); reg.defineInstance(IAuthenticationService, new class extends mock() { }()); reg.defineInstance(IRequestService, new class extends mock() { }()); - if (migrationCount > 0) { - reg.defineInstance(IStorageService, disposableStore.add(new InMemoryStorageService())); - } if (withAutoModel || withConfiguredModel) { reg.define(IMenuService, AutoModelFixtureMenuService); reg.defineInstance(ILanguageModelsService, new class extends NullLanguageModelsService { @@ -518,6 +595,9 @@ async function renderNewChatWidget(context: ComponentFixtureContext, options: IN reg.define(ISessionsChatBackgroundService, SessionsChatBackgroundService); }, }); + if (showTip) { + ChatContextKeys.foregroundSessionCount.bindTo(instantiationService.get(IContextKeyService)).set(0); + } await instantiationService.get(IConfigurationService).updateValue(COMPARE_AGENTS_ENABLED_SETTING, true); container.style.width = `${width}px`; @@ -827,6 +907,63 @@ async function renderNewChatWidget(context: ComponentFixtureContext, options: IN assert(notice.getBoundingClientRect().bottom <= container.getBoundingClientRect().bottom, 'The migration notice must fit below the input.'); } + if (connectorRecommendations) { + const recommendations = view.element.querySelector('.new-session-connector-recommendations'); + const cards = recommendations?.querySelectorAll('.new-session-connector-recommendation-card'); + assert(!!recommendations && cards?.length === 3 && targetWindow.getComputedStyle(recommendations).display !== 'none'); + if (connectorProductIcons) { + await waitForFixtureCondition(() => recommendations.querySelectorAll('img').length === 3, 'Connector recommendation icons did not load.'); + await Promise.all([...recommendations.querySelectorAll('img')].map(image => image.decode())); + } else { + assert(recommendations.querySelectorAll('.codicon-ai-customization-mcp-server').length === 3, + 'Every recommendation without a product image must use the MCP server fallback icon.'); + } + const dismissButtons = [...recommendations.querySelectorAll('.new-session-connector-recommendation-dismiss')]; + for (let index = 0; index < dismissedConnectorCount; index++) { + dismissButtons[index]?.click(); + } + assert([...cards].filter(card => !card.hidden).length === 3 - dismissedConnectorCount, + 'Dismissed connector recommendations must collapse while the remaining cards stay visible.'); + assert(recommendations.getBoundingClientRect().bottom <= container.getBoundingClientRect().bottom, + 'Connector recommendations must fit below the input.'); + const input = view.element.querySelector('.new-chat-input-container'); + const composerGap = phoneLayout + ? (input?.getBoundingClientRect().top ?? 0) - recommendations.getBoundingClientRect().bottom + : recommendations.getBoundingClientRect().top - (input?.getBoundingClientRect().bottom ?? 0); + assert(!!input && composerGap >= 20, + 'Connector recommendations must have at least 20px of breathing room next to the composer.'); + if (width >= DEFAULT_WIDTH) { + assert([...cards].every(card => card.getBoundingClientRect().height <= 80), + 'Wide connector cards must stay compact at no more than 80px tall.'); + } + if (focusConnectorRecommendations) { + context.focus({ focus: () => cards[0].querySelector('.new-session-connector-recommendation-card-primary')?.focus() }); + } + } + if (showTip) { + const tip = view.element.querySelector('.chat-tip-widget'); + assert(!!tip && targetWindow.getComputedStyle(tip).display !== 'none', 'The getting-started tip must be visible.'); + } + if (connectorRecommendations && showTip && migrationCount > 0) { + const recommendations = view.element.querySelector('.new-session-connector-recommendations'); + const tip = view.element.querySelector('.chat-tip-widget'); + const migration = view.element.querySelector('.new-chat-migration-notice'); + assert(!!recommendations && !!tip && !!migration + && recommendations.getBoundingClientRect().top < tip.getBoundingClientRect().top + && tip.getBoundingClientRect().top < migration.getBoundingClientRect().top, + 'Connector cards, the getting-started tip, and migration notice must appear in that order.'); + if (!phoneLayout) { + assert(Math.abs(tip.getBoundingClientRect().height - migration.getBoundingClientRect().height) <= 1, + 'The getting-started tip and migration notice must have matching heights.'); + } + } + if (phoneLayout) { + const input = view.element.querySelector('.new-chat-input-container'); + const containerRect = container.getBoundingClientRect(); + const inputRect = input?.getBoundingClientRect(); + assert(!!input && input.checkVisibility() && !!inputRect && inputRect.height > 0 && inputRect.top >= containerRect.top && inputRect.bottom <= containerRect.bottom, + 'The phone composer must remain visible within the fixture viewport.'); + } const repositoryConfigContainer = view.element.querySelector('.new-chat-repo-config-container'); if (withControlPickers) { const workspacePickerContainer = view.element.querySelector('.new-session-workspace-picker-container'); @@ -1077,6 +1214,64 @@ export default defineThemedFixtureGroup({ path: 'sessions/chat/newWidget/' }, { expectedVisualDescriptions: ['Over a loud repeating magenta, cyan, yellow, and blue striped background, the new-session composer shows the customization migration notice below the input on an opaque bordered surface. None of the stripes show through the notice.'], render: context => renderNewChatWidget(context, { withWorkspace: true, migrationCount: 4, chatBackground: 'loud' }), }), + ConnectorRecommendationsBefore: defineComponentFixture({ + labels: { kind: 'screenshot' }, + additionalThemes: ['darkHighContrast', 'lightHighContrast'], + expectedVisualDescriptions: ['The control state shows the centered new-session composer with no connector recommendation below it.'], + render: context => renderNewChatWidget(context, { height: 700, withWorkspace: true, experimentalComposerLayout: true }), + }), + ConnectorRecommendations: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: true }, + additionalThemes: ['darkHighContrast', 'lightHighContrast'], + expectedVisualDescriptions: ['Below the centered new-session composer, three equal cards for Linear, Notion, and WorkIQ each show a product image, name, short benefit, and independent dismiss control. No section heading, subtitle, or arrow icon appears.'], + render: context => renderNewChatWidget(context, { height: 700, withWorkspace: true, connectorRecommendations: true, experimentalComposerLayout: true }), + }), + ConnectorRecommendationsNarrow: defineComponentFixture({ + labels: { kind: 'screenshot' }, + expectedVisualDescriptions: ['In a narrow Agents window, the three connector cards retain readable widths in one horizontally scrollable row instead of compressing their product images and copy.'], + render: context => renderNewChatWidget(context, { width: 420, height: 760, withWorkspace: true, connectorRecommendations: true, experimentalComposerLayout: true }), + }), + ConnectorRecommendationsOneDismissed: defineComponentFixture({ + labels: { kind: 'screenshot' }, + expectedVisualDescriptions: ['After dismissing Linear, the Notion and WorkIQ recommendation cards remain and expand evenly across the available row without leaving an empty slot.'], + render: context => renderNewChatWidget(context, { height: 700, withWorkspace: true, connectorRecommendations: true, dismissedConnectorCount: 1, experimentalComposerLayout: true }), + }), + ConnectorRecommendationsTwoDismissed: defineComponentFixture({ + labels: { kind: 'screenshot' }, + expectedVisualDescriptions: ['After dismissing Linear and Notion, the WorkIQ recommendation card remains as one full-width card without leaving empty slots.'], + render: context => renderNewChatWidget(context, { height: 700, withWorkspace: true, connectorRecommendations: true, dismissedConnectorCount: 2, experimentalComposerLayout: true }), + }), + ConnectorRecommendationsFallbackIcons: defineComponentFixture({ + labels: { kind: 'screenshot' }, + expectedVisualDescriptions: ['When connector catalog images are unavailable, all three recommendation cards use the same standard MCP server fallback icon while retaining their names, benefits, and actions.'], + render: context => renderNewChatWidget(context, { height: 700, withWorkspace: true, connectorRecommendations: true, connectorProductIcons: false, experimentalComposerLayout: true }), + }), + ConnectorRecommendationsBackground: defineComponentFixture({ + labels: { kind: 'screenshot' }, + expectedVisualDescriptions: ['Over a loud repeating background, all three connector cards remain legible on opaque neutral card surfaces, and the section heading remains visually subordinate to the composer.'], + render: context => renderNewChatWidget(context, { height: 700, withWorkspace: true, connectorRecommendations: true, experimentalComposerLayout: true, chatBackground: 'loud' }), + }), + ConnectorRecommendationsWithNotices: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: true }, + additionalThemes: ['darkHighContrast', 'lightHighContrast'], + expectedVisualDescriptions: ['The compact three-card connector recommendation section appears first, followed by the getting-started tip and then the migration notice in a consistently spaced stack below the composer.'], + render: context => renderNewChatWidget(context, { height: 820, withWorkspace: true, migrationCount: 4, showTip: true, connectorRecommendations: true, experimentalComposerLayout: true }), + }), + ConnectorRecommendationsFocused: defineComponentFixture({ + labels: { kind: 'screenshot' }, + expectedVisualDescriptions: ['Keyboard focus is visible around the first connector card primary action with the standard focus treatment while each card retains a separate reachable dismiss control.'], + render: context => renderNewChatWidget(context, { height: 700, withWorkspace: true, connectorRecommendations: true, focusConnectorRecommendations: true, experimentalComposerLayout: true }), + }), + ConnectorRecommendationsPhone: defineComponentFixture({ + labels: { kind: 'screenshot' }, + expectedVisualDescriptions: ['On a phone-sized Agents window, the connector section sits immediately above the bottom-pinned composer and preserves readable cards in a horizontally scrollable row within the viewport.'], + render: context => renderNewChatWidget(context, { width: 390, height: 760, withWorkspace: true, withControlPickers: true, connectorRecommendations: true, experimentalComposerLayout: true, phoneLayout: true }), + }), + ConnectorRecommendationsPhoneWithNotices: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: true }, + expectedVisualDescriptions: ['On a phone-sized Agents window, the connector cards, getting-started tip, and migration notice form a compact stack immediately above the bottom-pinned composer, without covering the workspace controls or overflowing the viewport.'], + render: context => renderNewChatWidget(context, { width: 390, height: 760, withWorkspace: true, withControlPickers: true, migrationCount: 4, showTip: true, connectorRecommendations: true, experimentalComposerLayout: true, phoneLayout: true }), + }), NewSessionDefault: defineComponentFixture({ labels: { kind: 'screenshot' }, render: context => renderNewChatWidget(context, { withWorkspace: true }), diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionsChatAccessibilityHelp.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionsChatAccessibilityHelp.test.ts index 236f526235838..ba246f56f53b9 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionsChatAccessibilityHelp.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionsChatAccessibilityHelp.test.ts @@ -28,10 +28,11 @@ import { ISessionsPartService } from '../../../../services/sessions/browser/sess import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; import { IActiveSession } from '../../../../services/sessions/common/sessionsManagement.js'; import { ISessionComparison, ISessionComparisonService, SessionComparisonParticipantRole } from '../../../../services/sessions/common/sessionComparison.js'; -import { AGENTS_PICKER_IN_ATTACH_CONTEXT_MENU_SETTING, COMPARE_AGENTS_ENABLED_SETTING, EXPERIMENTAL_NEW_SESSION_COMPOSER_LAYOUT_SETTING, NEW_SESSION_WELCOME_PHRASES_SETTING, UNIFIED_WORKSPACE_PICKER_SETTING } from '../../common/constants.js'; +import { AGENTS_PICKER_IN_ATTACH_CONTEXT_MENU_SETTING, COMPARE_AGENTS_ENABLED_SETTING, EXPERIMENTAL_NEW_SESSION_COMPOSER_LAYOUT_SETTING, NEW_SESSION_CONNECTOR_RECOMMENDATIONS_SETTING, NEW_SESSION_WELCOME_PHRASES_SETTING, UNIFIED_WORKSPACE_PICKER_SETTING } from '../../common/constants.js'; import { AGENT_SESSIONS_RESPONSE_SELECTION_MENU_SETTING } from '../../browser/responseSelectionSideChatController.js'; import { SESSION_ARCHIVE_NUDGE_SETTING } from '../../browser/sessionArchiveNudge.js'; import { SessionComparisonAccessibleView, SessionsChatAccessibilityHelp } from '../../browser/sessionsChatAccessibilityHelp.js'; +import { CustomizationMarketplaceConfiguration } from '../../../../../platform/customizationMarketplace/common/customizationMarketplaceSources.js'; import { SessionsListPromoteNewChatActionContext } from '../../../../common/contextkeys.js'; import { SESSIONS_CHAT_TABS_SETTING, SESSIONS_LIST_GROUP_EXTERNAL_SESSIONS_SETTING, SessionsChatTabsMode } from '../../../../common/sessionConfig.js'; import { RemoteSessionToolsEnabledSettingId } from '../../../remoteSessions/common/remoteSessions.js'; @@ -58,6 +59,28 @@ suite('SessionsChatAccessibilityHelp', () => { }, { back: true, forward: true, singletonViews: true }); }); + test('documents connector recommendations when the marketplace is available', () => { + const enabled = [false, true].map(value => { + const instantiationService = store.add(new TestInstantiationService()); + const configuration = new TestConfigurationService({ + [NEW_SESSION_CONNECTOR_RECOMMENDATIONS_SETTING]: value, + [CustomizationMarketplaceConfiguration.MarketplaceEnabled]: value, + [CustomizationMarketplaceConfiguration.CopilotConnectorsEnabled]: value, + }); + store.add(configuration.onDidChangeConfigurationEmitter); + instantiationService.stub(IConfigurationService, configuration); + stubContextKeyService(instantiationService, configuration); + instantiationService.stub(ISessionsPartService, new class extends mock() { }()); + instantiationService.stub(ISessionsService, new class extends mock() { }()); + instantiationService.stub(IAgentHostFilterService, { selectedHost: undefined }); + instantiationService.stub(IWorkbenchLayoutService, { mainContainer: mainWindow.document.createElement('div') }); + return store.add(new SessionsChatAccessibilityHelp().getProvider(instantiationService)).provideContent() + .includes('three connector cards offer Linear, Notion, and WorkIQ'); + }); + + assert.deepStrictEqual(enabled, [false, true]); + }); + test('documents repo-less Cloud chats in the harness picker', () => { const instantiationService = store.add(new TestInstantiationService()); const configuration = new TestConfigurationService(); diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts index fe8ba38a43272..890e1b40708c8 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts @@ -880,8 +880,18 @@ class AICustomizationManagementActionsContribution extends Disposable implements const pane = await editorService.openEditor(input, { pinned: true }); if (pane instanceof AICustomizationManagementEditor) { const section = typeof target === 'string' ? target : target?.section ?? AICustomizationManagementSection.McpServers; - const sourceId = typeof target === 'string' ? undefined : target?.sourceId; - pane.selectSectionById(section, { showMarketplace: true, marketplaceSourceId: sourceId }); + if (typeof target !== 'string' && target?.connectorName) { + const opened = await pane.showCopilotConnectorDetail(target.connectorName); + if (!opened) { + throw new Error(`Copilot connector '${target.connectorName}' is not available`); + } + } else { + const sourceId = typeof target === 'string' ? undefined : target?.sourceId; + pane.selectSectionById(section, { showMarketplace: true, marketplaceSourceId: sourceId }); + if (typeof target !== 'string' && target?.searchQuery !== undefined) { + pane.setSearchQuery(target.searchQuery); + } + } } } })); diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts index 4283715706470..20d275cf7ea4a 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts @@ -114,7 +114,7 @@ import { IAgentPluginService } from '../../common/plugins/agentPluginService.js' import { createWorkbenchMcpServerDetailInput, EmbeddedMcpServerDetail, IMcpServerDetailInput, isMcpServerDetailMigratable } from './embeddedMcpServerDetail.js'; import { EmbeddedAgentPluginDetail } from './embeddedAgentPluginDetail.js'; import { EmbeddedConnectorDetail } from './embeddedConnectorDetail.js'; -import { ICopilotConnector } from './copilotConnectorsService.js'; +import { ICopilotConnector, ICopilotConnectorsService } from './copilotConnectorsService.js'; import { EmbeddedMarketplaceDetail } from './embeddedMarketplaceDetail.js'; import { IMcpService, IMcpWorkbenchService, type IWorkbenchMcpServer, McpServerInstallState } from '../../../mcp/common/mcpTypes.js'; import { IAgentHostCustomizationService } from '../agentSessions/agentHost/agentHostCustomizationService.js'; @@ -530,6 +530,7 @@ export class AICustomizationManagementEditor extends EditorPane { @IEditorService private readonly editorService: IEditorService, @ICustomizationMarketplaceService private readonly marketplaceService: ICustomizationMarketplaceService, @ICustomizationMarketplaceInstallService private readonly marketplaceInstallService: ICustomizationMarketplaceInstallService, + @ICopilotConnectorsService private readonly copilotConnectorsService: ICopilotConnectorsService, @IAgentPluginService private readonly agentPluginService: IAgentPluginService, @IEnvironmentService private readonly environmentService: IEnvironmentService, @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, @@ -3872,6 +3873,7 @@ export class AICustomizationManagementEditor extends EditorPane { await this.revealCustomizationByUri(target.uri); return; } + for (let attempt = 0; attempt < 10; attempt++) { const revealed = target.section === AICustomizationManagementSection.Plugins && target.uri ? await this.pluginListWidget?.revealAndSelectItemByUri(target.uri) @@ -3885,6 +3887,17 @@ export class AICustomizationManagementEditor extends EditorPane { } } + public async showCopilotConnectorDetail(connectorName: string): Promise { + const connectors = await this.copilotConnectorsService.getConnectors(CancellationToken.None); + const connector = connectors.find(candidate => candidate.name === connectorName); + if (!connector) { + return false; + } + this.selectSectionById(AICustomizationManagementSection.McpServers); + this.showEmbeddedConnectorDetail(connector); + return true; + } + /** * Scrolls the active list widget so the last item is visible. */ diff --git a/src/vs/workbench/contrib/chat/common/aiCustomizationWorkspaceService.ts b/src/vs/workbench/contrib/chat/common/aiCustomizationWorkspaceService.ts index 4d68f19d7898a..ef19a3e4f8479 100644 --- a/src/vs/workbench/contrib/chat/common/aiCustomizationWorkspaceService.ts +++ b/src/vs/workbench/contrib/chat/common/aiCustomizationWorkspaceService.ts @@ -55,6 +55,8 @@ export type AICustomizationManagementSection = typeof AICustomizationManagementS export interface IAICustomizationMarketplaceOpenTarget { readonly section: AICustomizationManagementSection; readonly sourceId?: string; + readonly searchQuery?: string; + readonly connectorName?: string; } /** Command IDs for the AI Customizations Management Editor. */ diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts index 066cb59c25acb..204d73d74f8c5 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts @@ -1113,6 +1113,7 @@ interface IRenderEditorOptions { readonly customizationMarketplaceDetailState?: 'missing' | 'error' | 'unavailable'; readonly useRealConnectorInstallService?: boolean; readonly discoveryQuery?: string; + readonly directConnectorName?: string; readonly clearDiscoveryQuery?: boolean; readonly selectDiscoveryResult?: boolean; readonly customizationSearchQuery?: string; @@ -2019,6 +2020,10 @@ async function renderEditor(ctx: ComponentFixtureContext, options: IRenderEditor assert(!browseScrollHost || browseScrollHost.scrollWidth <= browseScrollHost.clientWidth, 'Discover browse must not overflow horizontally.'); } + if (options.directConnectorName) { + assert(await editor.showCopilotConnectorDetail(options.directConnectorName), `Connector ${options.directConnectorName} must open directly.`); + } + if (options.toggleMarketplaceVisibility) { const configuration = marketplaceConfiguration!; const setting = CustomizationMarketplaceConfiguration.MarketplaceEnabled; @@ -3919,6 +3924,18 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { }), }), + DiscoverCopilotConnectorDetail: defineComponentFixture({ + labels: { kind: 'screenshot' }, + expectedVisualDescriptions: ['Activating the WorkIQ recommendation opens Work IQ Mail directly in its in-product connector detail with Back, the connector identity and image, a Connect action, and its catalog metadata.'], + render: ctx => renderEditor(ctx, { + sessionResource: localSessionResource, + copilotConnectorsEnabled: true, + copilotConnectors: fixtureCopilotConnectors.map(connector => connector.name === 'workiq-mail' ? { ...connector, connectionStatus: 'not_connected' } : connector), + marketplaceVisibilityEnabled: true, + directConnectorName: 'workiq-mail', + }), + }), + DiscoverPendingCopilotConnector: defineComponentFixture({ labels: { kind: 'screenshot', blocksCi: false }, expectedVisualDescriptions: ['A pending Copilot Connector remains actionable with a Connect button so interrupted authorization can be retried.'], From b4c825fbea1c1da32e3c56daa8bb89fe83214acc Mon Sep 17 00:00:00 2001 From: Hawk Ticehurst Date: Thu, 15 Oct 2026 15:08:33 -0400 Subject: [PATCH 2/4] test: update connector recommendation screenshot hashes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../blocks-ci-screenshots.md | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/test/componentFixtures/blocks-ci-screenshots.md b/test/componentFixtures/blocks-ci-screenshots.md index 3ed5aebb0f8a3..8ef410d3ff719 100644 --- a/test/componentFixtures/blocks-ci-screenshots.md +++ b/test/componentFixtures/blocks-ci-screenshots.md @@ -102,8 +102,17 @@ #### sessions/accountMenu/petAchievementBadges/chatPetAchievementBadges/AllBadges/Light ![screenshot](https://hediet-screenshots.azurewebsites.net/images/a92e0c554f09d1e7d3b864d0b1cd5c70c057b2acbe4fb67daadeba0cb8351eb8) +#### sessions/chat/newWidget/newChatWidget/ConnectorRecommendations/Light +![screenshot](https://hediet-screenshots.azurewebsites.net/images/724baf4cd5c55657d4810754f50cca06e1976cbc7e65fa802065b3b7da343cda) + +#### sessions/chat/newWidget/newChatWidget/ConnectorRecommendationsPhoneWithNotices/Light +![screenshot](https://hediet-screenshots.azurewebsites.net/images/e56d8df57b565e2072794af6b7b6e5adfd8e5baf23ba5da7d35f85c05a785984) + +#### sessions/chat/newWidget/newChatWidget/ConnectorRecommendationsWithNotices/Light +![screenshot](https://hediet-screenshots.azurewebsites.net/images/fa18e847a5038de0465b3f3b6b38f5195c97e0cec4a648a14e28ea025ecd6612) + #### sessions/chat/newWidget/newChatWidget/MigrationsBackground/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/8e385d1dbb4f325dfd536c0965773398e726bd7868e01949afb2c740a9ef84af) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/44fe0e94a36b7c7ffa7c57f6fd7c6fc6428bf05473912483ae7a2ee39ac12450) #### sessions/chat/newWidget/newChatWidget/NewSessionAttachedContext/Light ![screenshot](https://hediet-screenshots.azurewebsites.net/images/cca0913727b6c9f3c15485a743fa9b04474cc7ebb881125df6037b9a25ec205c) @@ -118,25 +127,25 @@ ![screenshot](https://hediet-screenshots.azurewebsites.net/images/56b9ac7ce8ad6692c3269e73900a765e59c5705d6dc93d35b3134cf9ff571cde) #### sessions/chat/newWidget/newChatWidget/NewSessionExperimentalComposerBackground/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/37acfa78c3414a0a7b59287068ac2a60405bbd614a4704bd85cb006f3b9dd271) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/41f51384459c427580198a4dbf6e208713abf522fcf95d7b241696ec01ce4dd2) #### sessions/chat/newWidget/newChatWidget/NewSessionGitHubContextPicker/Light ![screenshot](https://hediet-screenshots.azurewebsites.net/images/bd4b7314d7d4e2bb7f1765f25901efdb6d4b6d08f6acc5766f95d597d87ea968) #### sessions/chat/newWidget/newChatWidget/NewSessionPhoneAttachedContext/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/b2fdc20a2a6ced07151298dca6b595192ab1fb724985e3672dacf7062fea576c) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/a03d318bee7ad3d3ca318db4d36efd83a37f40a470af5364bbd4a87f12ffad63) #### sessions/chat/newWidget/newChatWidget/NewSessionPhoneExperimentalComposer/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/72507bc243c14afe9a00c322f6e4b8a8dd976ff6d8855c5fc2099c278d23f856) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/a6681f9c8d9e5c0ed9088de69e3b4a767c2c107fae95902dc99291824a2d8b7b) #### sessions/chat/newWidget/newChatWidget/NewSessionPhoneSettingsDisabled/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/356b19cafd3c58087ea0729eaf2110ace6b5761920b4bb13fb2e80e7303667fc) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/fcfefb3c456abcc2b205d59b0e93514cd3457f25bcf3540a7e3ef0878d476635) #### sessions/chat/newWidget/newChatWidget/NewSessionPhoneUnifiedWorkspacePicker/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/fbf99fab3b3a68d2517bce54a2781eda00095033cc97a87f64efbbb0faad234d) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/6f6212ac830a9003e38677998b3619bbe7e0151d2eca1251a9c888091502907c) #### sessions/chat/newWidget/newChatWidget/NewSessionPhoneWelcomePhrase/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/6ffde361db43854329405e26225f718f0421d069654823f92de75675466c94e5) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/3a310b8216197f1af20c846b43d3295dadcb49380728bbfcb7a040f6e22a3eab) #### sessions/chat/newWidget/newChatWidget/NewSessionRemoteWorkspace/Light ![screenshot](https://hediet-screenshots.azurewebsites.net/images/310fc08cf62e403c7d3e5edf9a4eb6123c85ba89823af0b1322283f643017417) From a31b816a7f5b70cbe6f5870479411b0f1167ab7b Mon Sep 17 00:00:00 2001 From: Hawk Ticehurst Date: Thu, 15 Oct 2026 15:38:40 -0400 Subject: [PATCH 3/4] sessions: address connector recommendation feedback Keep support surfaces scrollable in short panes, preserve dynamic ordering, advance dismissal focus, and cancel superseded connector detail navigation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../contrib/chat/browser/media/chatWidget.css | 2 +- .../newChatConnectorRecommendations.ts | 11 +++-- .../contrib/chat/browser/newChatWidget.ts | 22 +++++++++- .../newChatConnectorRecommendations.test.ts | 11 +++-- .../test/browser/newChatWidget.fixture.ts | 41 ++++++++++++++++++- .../aiCustomizationManagement.contribution.ts | 6 +-- .../aiCustomizationManagementEditor.ts | 26 ++++++++++-- .../aiCustomizationManagementEditor.test.ts | 12 +++++- ...aiCustomizationManagementEditor.fixture.ts | 4 +- 9 files changed, 115 insertions(+), 20 deletions(-) diff --git a/src/vs/sessions/contrib/chat/browser/media/chatWidget.css b/src/vs/sessions/contrib/chat/browser/media/chatWidget.css index 7e859d01a9761..45f5674ed9f4e 100644 --- a/src/vs/sessions/contrib/chat/browser/media/chatWidget.css +++ b/src/vs/sessions/contrib/chat/browser/media/chatWidget.css @@ -123,6 +123,7 @@ right: 0; width: 100%; margin-top: var(--vscode-spacing-size200); + overflow-y: auto; } /* Getting-started tip demoted below the input in the experimental composer. */ @@ -151,7 +152,6 @@ flex: 0 1 auto; min-height: 0; margin: 0 0 var(--vscode-spacing-size200); - overflow-y: auto; } .new-chat-session-options .new-chat-session-options-details { diff --git a/src/vs/sessions/contrib/chat/browser/newChatConnectorRecommendations.ts b/src/vs/sessions/contrib/chat/browser/newChatConnectorRecommendations.ts index 591e98ff93515..310b18d7ef38e 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatConnectorRecommendations.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatConnectorRecommendations.ts @@ -123,11 +123,16 @@ export class NewChatConnectorRecommendations extends Disposable { } private dismissCard(id: string): void { + const cards = [...this.cards.values()]; + const dismissedIndex = cards.findIndex(card => card.definition.id === id); this.dismissed.add(id); - const remainingCards = [...this.cards.values()].filter(card => !this.dismissed.has(card.definition.id)); this.updateVisibility(); - remainingCards[0]?.primary.focus(); - if (remainingCards.length === 0) { + const nextCard = cards.slice(dismissedIndex + 1).find(card => !this.dismissed.has(card.definition.id)); + const previousCard = cards.slice(0, dismissedIndex).reverse().find(card => !this.dismissed.has(card.definition.id)); + const focusTarget = nextCard ?? previousCard; + if (focusTarget) { + focusTarget.primary.focus(); + } else { this.focusInput(); } } diff --git a/src/vs/sessions/contrib/chat/browser/newChatWidget.ts b/src/vs/sessions/contrib/chat/browser/newChatWidget.ts index 7aab127ae4f27..088e041827255 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatWidget.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatWidget.ts @@ -770,6 +770,22 @@ export class NewChatWidget extends Disposable { ); })); const belowInputSurfaces = dom.append(chatWidgetContent, dom.$(`.new-session-below-input-surfaces.${chatInputSurfaceStackClass}`)); + const updateBelowInputSurfacesHeight = () => { + if (chatWidgetContainer.closest('.phone-layout')) { + belowInputSurfaces.style.removeProperty('max-height'); + return; + } + const availableHeight = chatWidgetContainer.getBoundingClientRect().bottom - belowInputSurfaces.getBoundingClientRect().top; + belowInputSurfaces.style.maxHeight = `${Math.max(0, availableHeight)}px`; + }; + const belowInputSurfacesResizeObserver = this._register(new dom.DisposableResizeObserver( + 'NewChatWidget.belowInputSurfaces', + updateBelowInputSurfacesHeight, + dom.getWindow(belowInputSurfaces), + )); + this._register(belowInputSurfacesResizeObserver.observe(chatWidgetContainer)); + this._register(belowInputSurfacesResizeObserver.observe(chatWidgetContent)); + this._register(toDisposable(() => belowInputSurfaces.style.removeProperty('max-height'))); const connectorRecommendations = this._register(this.instantiationService.createInstance(NewChatConnectorRecommendations, () => this.focusInput())); belowInputSurfaces.appendChild(connectorRecommendations.element); @@ -784,7 +800,11 @@ export class NewChatWidget extends Disposable { this._register(autorun(reader => { const showBelowInput = this._useExperimentalComposerLayout.read(reader); chatTipContainer.classList.toggle('new-session-getting-started-tip-below', showBelowInput); - (showBelowInput ? belowInputSurfaces : inputTipSlot)?.appendChild(chatTipContainer); + if (showBelowInput) { + connectorRecommendations.element.insertAdjacentElement('afterend', chatTipContainer); + } else { + inputTipSlot?.appendChild(chatTipContainer); + } })); this._register(this.instantiationService.createInstance(NewChatMigrationNotice, belowInputSurfaces, this._session, () => this.focusInput())); this._chatTipPresenter.value = this.instantiationService.createInstance( diff --git a/src/vs/sessions/contrib/chat/test/browser/newChatConnectorRecommendations.test.ts b/src/vs/sessions/contrib/chat/test/browser/newChatConnectorRecommendations.test.ts index 3ef1227409664..e3e7d33b95288 100644 --- a/src/vs/sessions/contrib/chat/test/browser/newChatConnectorRecommendations.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/newChatConnectorRecommendations.test.ts @@ -154,10 +154,11 @@ suite('NewChatConnectorRecommendations', () => { const cards = [...fixture.recommendations.element.querySelectorAll('.new-session-connector-recommendation-card')]; const dismissButtons = cards.map(card => card.querySelector('.new-session-connector-recommendation-dismiss')!); - dismissButtons[0].click(); - const focusAfterFirstDismiss = document.activeElement === cards[1].querySelector('.new-session-connector-recommendation-card-primary'); dismissButtons[1].click(); + const focusAfterMiddleDismiss = document.activeElement === cards[2].querySelector('.new-session-connector-recommendation-card-primary'); dismissButtons[2].click(); + const focusAfterLastDismiss = document.activeElement === cards[0].querySelector('.new-session-connector-recommendation-card-primary'); + dismissButtons[0].click(); await fixture.configurationService.setUserConfiguration(CustomizationMarketplaceConfiguration.MarketplaceEnabled, false); await fixture.configurationService.setUserConfiguration(CustomizationMarketplaceConfiguration.MarketplaceEnabled, true); fixture.configurationService.onDidChangeConfigurationEmitter.fire(new class extends mock() { @@ -169,12 +170,14 @@ suite('NewChatConnectorRecommendations', () => { assert.deepStrictEqual({ hidden: fixture.recommendations.element.hidden, dismissedCards: cards.map(card => card.hidden), - focusAfterFirstDismiss, + focusAfterMiddleDismiss, + focusAfterLastDismiss, focusCount: fixture.focusCount, }, { hidden: true, dismissedCards: [true, true, true], - focusAfterFirstDismiss: true, + focusAfterMiddleDismiss: true, + focusAfterLastDismiss: true, focusCount: 1, }); }); 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 02496ee540e55..4276d801e45c3 100644 --- a/src/vs/sessions/contrib/chat/test/browser/newChatWidget.fixture.ts +++ b/src/vs/sessions/contrib/chat/test/browser/newChatWidget.fixture.ts @@ -26,7 +26,7 @@ import { ActionWidgetService, IActionWidgetService } from '../../../../../platfo import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; import { IMenuService, MenuId } from '../../../../../platform/actions/common/actions.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; -import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { type IConfigurationChangeEvent, 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 { IContextViewService } from '../../../../../platform/contextview/browser/contextView.js'; @@ -184,6 +184,7 @@ interface INewChatWidgetFixtureOptions { readonly chatBackground?: 'codicons' | 'loud'; readonly migrationCount?: number; readonly experimentalComposerLayout?: boolean; + readonly toggleExperimentalComposerLayout?: boolean; readonly unifiedWorkspacePicker?: boolean; readonly collapsedSessionOptionsShowIcons?: boolean; readonly welcomePhrases?: boolean; @@ -191,6 +192,7 @@ interface INewChatWidgetFixtureOptions { readonly connectorProductIcons?: boolean; readonly dismissedConnectorCount?: number; readonly focusConnectorRecommendations?: boolean; + readonly expectBelowInputSurfacesScrollable?: boolean; } class FixturePickerActionViewItem extends BaseActionViewItem implements IChatInputPickerResponsiveState { @@ -338,6 +340,7 @@ async function renderNewChatWidget(context: ComponentFixtureContext, options: IN chatBackground, migrationCount = 0, experimentalComposerLayout = false, + toggleExperimentalComposerLayout = false, unifiedWorkspacePicker = experimentalComposerLayout, collapsedSessionOptionsShowIcons = false, welcomePhrases = false, @@ -345,6 +348,7 @@ async function renderNewChatWidget(context: ComponentFixtureContext, options: IN connectorProductIcons = true, dismissedConnectorCount = 0, focusConnectorRecommendations = false, + expectBelowInputSurfacesScrollable = false, } = options; const hasChatBackground = chatBackground !== undefined; const feedbackItems: readonly IAgentFeedback[] = Array.from({ length: commentCount }, (_, index) => ({ @@ -709,6 +713,17 @@ async function renderNewChatWidget(context: ComponentFixtureContext, options: IN } } await nextFrame(); + if (toggleExperimentalComposerLayout) { + for (const enabled of [true, false, true]) { + await configurationService.setUserConfiguration(EXPERIMENTAL_NEW_SESSION_COMPOSER_LAYOUT_SETTING, enabled); + configurationService.onDidChangeConfigurationEmitter.fire(new class extends mock() { + override affectsConfiguration(section: string): boolean { + return section === EXPERIMENTAL_NEW_SESSION_COMPOSER_LAYOUT_SETTING; + } + }()); + await nextFrame(); + } + } const promptBox = view.element.querySelector('.new-chat-input-container'); assert(!!promptBox); const widgetContent = view.element.querySelector('.new-chat-widget-content'); @@ -948,14 +963,22 @@ async function renderNewChatWidget(context: ComponentFixtureContext, options: IN const recommendations = view.element.querySelector('.new-session-connector-recommendations'); const tip = view.element.querySelector('.chat-tip-widget'); const migration = view.element.querySelector('.new-chat-migration-notice'); + const belowInputSurfaces = view.element.querySelector('.new-session-below-input-surfaces'); assert(!!recommendations && !!tip && !!migration && recommendations.getBoundingClientRect().top < tip.getBoundingClientRect().top && tip.getBoundingClientRect().top < migration.getBoundingClientRect().top, - 'Connector cards, the getting-started tip, and migration notice must appear in that order.'); + 'Connector cards, the getting-started tip, and migration notice must appear in that order.'); if (!phoneLayout) { assert(Math.abs(tip.getBoundingClientRect().height - migration.getBoundingClientRect().height) <= 1, 'The getting-started tip and migration notice must have matching heights.'); } + if (expectBelowInputSurfacesScrollable) { + assert(!!belowInputSurfaces + && belowInputSurfaces.scrollHeight > belowInputSurfaces.clientHeight + && belowInputSurfaces.getBoundingClientRect().bottom <= container.getBoundingClientRect().bottom + && promptBox.getBoundingClientRect().bottom <= container.getBoundingClientRect().bottom, + 'Constrained desktop support surfaces must scroll without clipping the composer.'); + } } if (phoneLayout) { const input = view.element.querySelector('.new-chat-input-container'); @@ -1257,6 +1280,20 @@ export default defineThemedFixtureGroup({ path: 'sessions/chat/newWidget/' }, { expectedVisualDescriptions: ['The compact three-card connector recommendation section appears first, followed by the getting-started tip and then the migration notice in a consistently spaced stack below the composer.'], render: context => renderNewChatWidget(context, { height: 820, withWorkspace: true, migrationCount: 4, showTip: true, connectorRecommendations: true, experimentalComposerLayout: true }), }), + ConnectorRecommendationsShortAfterLayoutToggle: defineComponentFixture({ + labels: { kind: 'screenshot' }, + expectedVisualDescriptions: ['After toggling the experimental composer off and on in a short desktop pane, connector cards, the getting-started tip, and migration notice retain their order in a scrollable support region while the composer remains visible.'], + render: context => renderNewChatWidget(context, { + height: 300, + withWorkspace: true, + migrationCount: 4, + showTip: true, + connectorRecommendations: true, + unifiedWorkspacePicker: true, + toggleExperimentalComposerLayout: true, + expectBelowInputSurfacesScrollable: true, + }), + }), ConnectorRecommendationsFocused: defineComponentFixture({ labels: { kind: 'screenshot' }, expectedVisualDescriptions: ['Keyboard focus is visible around the first connector card primary action with the standard focus treatment while each card retains a separate reachable dismiss control.'], diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts index 890e1b40708c8..1bf6b41a892d6 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts @@ -65,7 +65,7 @@ import { DELETE_AI_CUSTOMIZATION_ID, resolveAICustomizationManagementOpenEditorTarget, } from './aiCustomizationManagement.js'; -import { AICustomizationManagementEditor } from './aiCustomizationManagementEditor.js'; +import { AICustomizationManagementEditor, CopilotConnectorDetailOpenResult } from './aiCustomizationManagementEditor.js'; import { AICustomizationManagementEditorInput } from './aiCustomizationManagementEditorInput.js'; import './customizationMarketplace.contribution.js'; import './customizationMigrationAccessibility.js'; @@ -881,8 +881,8 @@ class AICustomizationManagementActionsContribution extends Disposable implements if (pane instanceof AICustomizationManagementEditor) { const section = typeof target === 'string' ? target : target?.section ?? AICustomizationManagementSection.McpServers; if (typeof target !== 'string' && target?.connectorName) { - const opened = await pane.showCopilotConnectorDetail(target.connectorName); - if (!opened) { + const result = await pane.showCopilotConnectorDetail(target.connectorName); + if (result === CopilotConnectorDetailOpenResult.NotFound) { throw new Error(`Copilot connector '${target.connectorName}' is not available`); } } else { diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts index 20d275cf7ea4a..1b16eb024bf81 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts @@ -176,6 +176,21 @@ export function isCurrentPluginContributionNavigation( return requestGeneration === currentGeneration && requestedSection === selectedSection && isListView; } +export function isCurrentConnectorDetailNavigation( + requestGeneration: number, + currentGeneration: number, + requestInput: object | undefined, + currentInput: object | undefined, +): boolean { + return requestGeneration === currentGeneration && requestInput !== undefined && requestInput === currentInput; +} + +export const enum CopilotConnectorDetailOpenResult { + Opened = 'opened', + NotFound = 'notFound', + Superseded = 'superseded', +} + type CustomizationEditorItemSelectedEvent = { section: string; promptType: string; @@ -3887,15 +3902,20 @@ export class AICustomizationManagementEditor extends EditorPane { } } - public async showCopilotConnectorDetail(connectorName: string): Promise { + public async showCopilotConnectorDetail(connectorName: string): Promise { + const navigationGeneration = this.contentNavigationGeneration; + const input = this.input; const connectors = await this.copilotConnectorsService.getConnectors(CancellationToken.None); + if (!isCurrentConnectorDetailNavigation(navigationGeneration, this.contentNavigationGeneration, input, this.input)) { + return CopilotConnectorDetailOpenResult.Superseded; + } const connector = connectors.find(candidate => candidate.name === connectorName); if (!connector) { - return false; + return CopilotConnectorDetailOpenResult.NotFound; } this.selectSectionById(AICustomizationManagementSection.McpServers); this.showEmbeddedConnectorDetail(connector); - return true; + return CopilotConnectorDetailOpenResult.Opened; } /** diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationManagementEditor.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationManagementEditor.test.ts index 06ba6e5718dc6..f90c5f62c34c4 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationManagementEditor.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationManagementEditor.test.ts @@ -27,7 +27,7 @@ import { AGENT_BUILTIN_CUSTOMIZATION_SCHEME } from '../../../../../../platform/a import { toAgentHostUri } from '../../../../../../platform/agentHost/common/agentHostUri.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { URI } from '../../../../../../base/common/uri.js'; -import { AICustomizationManagementEditor, isCurrentPluginContributionNavigation } from '../../../browser/aiCustomization/aiCustomizationManagementEditor.js'; +import { AICustomizationManagementEditor, isCurrentConnectorDetailNavigation, isCurrentPluginContributionNavigation } from '../../../browser/aiCustomization/aiCustomizationManagementEditor.js'; import { IAICustomizationListItem } from '../../../browser/aiCustomization/aiCustomizationItemSource.js'; import { AgentPluginItemKind, IAgentPluginItem } from '../../../browser/agentPluginEditor/agentPluginItems.js'; import { ChatConfiguration } from '../../../common/constants.js'; @@ -93,6 +93,16 @@ suite('aiCustomizationManagementEditor', () => { ], [true, false, false, false]); }); + test('rejects superseded connector detail navigation', () => { + const requestInput = {}; + assert.deepStrictEqual([ + isCurrentConnectorDetailNavigation(2, 2, requestInput, requestInput), + isCurrentConnectorDetailNavigation(1, 2, requestInput, requestInput), + isCurrentConnectorDetailNavigation(2, 2, requestInput, {}), + isCurrentConnectorDetailNavigation(2, 2, undefined, undefined), + ], [true, false, false, false]); + }); + test('routes installed discovery items to their focused list rows', async () => { const skillUri = URI.file('/skills/security/SKILL.md'); const pluginUri = URI.file('/plugins/security'); diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts index 204d73d74f8c5..b18d871db07d3 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts @@ -86,7 +86,7 @@ import { ILabelService } from '../../../../../platform/label/common/label.js'; import { IPluginMarketplaceService, IMarketplacePlugin, MarketplaceType, PluginSourceKind } from '../../../../contrib/chat/common/plugins/pluginMarketplaceService.js'; import { IMarketplaceReference, MarketplaceReferenceKind } from '../../../../contrib/chat/common/plugins/marketplaceReference.js'; import { IPluginInstallService } from '../../../../contrib/chat/common/plugins/pluginInstallService.js'; -import { AICustomizationManagementEditor } from '../../../../contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.js'; +import { AICustomizationManagementEditor, CopilotConnectorDetailOpenResult } from '../../../../contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.js'; import { IAICustomizationItemSource, IAICustomizationListItem } from '../../../../contrib/chat/browser/aiCustomization/aiCustomizationItemSource.js'; import { AICustomizationItemsModel, IAICustomizationItemsModel, ItemsModelSection } from '../../../../contrib/chat/browser/aiCustomization/aiCustomizationItemsModel.js'; import { createWorkbenchMcpServerDetailInput, EmbeddedMcpServerDetail, IMcpServerDetailInput } from '../../../../contrib/chat/browser/aiCustomization/embeddedMcpServerDetail.js'; @@ -2021,7 +2021,7 @@ async function renderEditor(ctx: ComponentFixtureContext, options: IRenderEditor } if (options.directConnectorName) { - assert(await editor.showCopilotConnectorDetail(options.directConnectorName), `Connector ${options.directConnectorName} must open directly.`); + assert(await editor.showCopilotConnectorDetail(options.directConnectorName) === CopilotConnectorDetailOpenResult.Opened, `Connector ${options.directConnectorName} must open directly.`); } if (options.toggleMarketplaceVisibility) { From 1482aa7e12910bd06a05957ca0b26282265d3f59 Mon Sep 17 00:00:00 2001 From: Hawk Ticehurst Date: Thu, 15 Oct 2026 15:43:53 -0400 Subject: [PATCH 4/4] test: cover constrained connector recommendations Verify a short desktop pane keeps the composer visible while recommendations and notices use a scrollable region after repeated layout toggles. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../contrib/chat/test/browser/newChatWidget.fixture.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) 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 4276d801e45c3..cf8f5b99485d3 100644 --- a/src/vs/sessions/contrib/chat/test/browser/newChatWidget.fixture.ts +++ b/src/vs/sessions/contrib/chat/test/browser/newChatWidget.fixture.ts @@ -919,8 +919,10 @@ async function renderNewChatWidget(context: ComponentFixtureContext, options: IN notice.style.display = ''; assert(before.x === withoutNotice.x && before.y === withoutNotice.y && before.height === withoutNotice.height, 'The migration notice must not move or resize the centered input.'); - assert(notice.getBoundingClientRect().bottom <= container.getBoundingClientRect().bottom, - 'The migration notice must fit below the input.'); + if (!expectBelowInputSurfacesScrollable) { + assert(notice.getBoundingClientRect().bottom <= container.getBoundingClientRect().bottom, + 'The migration notice must fit below the input.'); + } } if (connectorRecommendations) { const recommendations = view.element.querySelector('.new-session-connector-recommendations'); @@ -1284,7 +1286,7 @@ export default defineThemedFixtureGroup({ path: 'sessions/chat/newWidget/' }, { labels: { kind: 'screenshot' }, expectedVisualDescriptions: ['After toggling the experimental composer off and on in a short desktop pane, connector cards, the getting-started tip, and migration notice retain their order in a scrollable support region while the composer remains visible.'], render: context => renderNewChatWidget(context, { - height: 300, + height: 400, withWorkspace: true, migrationCount: 4, showTip: true,