From 8c59c82f064fbafd0b5bee2d5c766ff2a1167f91 Mon Sep 17 00:00:00 2001 From: benibenj Date: Sat, 14 Nov 2026 14:58:40 +0100 Subject: [PATCH 1/2] sessions: show the other Customizations categories with More and Less Adds the experiment-controlled sessions.sidebar.customizationsCategories setting, which defaults to reduced. The Customizations section below the Sessions list then shows Overview, Plugins, MCP Servers, Skills, and Instructions, followed by a More row that counts the customizations of the other available categories. More shows them in place, followed by a Less row that hides them again, and the section remembers per profile which of the two was used last. Setting it to all shows every category, as before. The section also keeps keyboard focus at its position when updating its items replaces the focused one, so that focus moves from More to the first category it shows, and from Less back to More. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../customizationsToolbar.contribution.ts | 164 +++++++++++++++--- .../sessions/browser/sessions.contribution.ts | 14 +- .../browser/customizationsToolbar.test.ts | 149 +++++++++++++++- .../sessions/test/ai-customizations.test.md | 3 +- .../aiCustomizationSectionBadges.ts | 8 +- .../browser/aiCustomizationShortcutsWidget.ts | 39 +++++ .../sessions/browser/customizationItems.ts | 6 + .../contrib/sessions/common/sessionConfig.ts | 11 ++ .../aiCustomizationShortcutsWidget.test.ts | 123 ++++++++++--- 9 files changed, 466 insertions(+), 51 deletions(-) diff --git a/src/vs/sessions/contrib/sessions/browser/customizationsToolbar.contribution.ts b/src/vs/sessions/contrib/sessions/browser/customizationsToolbar.contribution.ts index 8b8dd82e8eff8e..567e0293e01ea6 100644 --- a/src/vs/sessions/contrib/sessions/browser/customizationsToolbar.contribution.ts +++ b/src/vs/sessions/contrib/sessions/browser/customizationsToolbar.contribution.ts @@ -23,7 +23,7 @@ import { Menus } from '../../../../workbench/contrib/sessions/browser/menus.js'; import { BaseActionViewItem, IActionViewItemOptions, IBaseActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; import { IAction } from '../../../../base/common/actions.js'; import { $, append } from '../../../../base/browser/dom.js'; -import { autorun } from '../../../../base/common/observable.js'; +import { autorun, IObservable } from '../../../../base/common/observable.js'; import { Button } from '../../../../base/browser/ui/button/button.js'; import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js'; import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js'; @@ -41,13 +41,13 @@ import { ICustomizationItemConfig, readCustomizationCount, CUSTOMIZATION_ITEMS } import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { ChatAgentLocation, ChatConfiguration } from '../../../../workbench/contrib/chat/common/constants.js'; import { ChatCustomizationsLocation, ChatCustomizationsLocationContext, resolveChatCustomizationsLocation } from '../../../../workbench/contrib/chat/common/customizationsLocation.js'; -import { SESSIONS_SIDEBAR_CUSTOMIZATIONS_AT_BOTTOM_SETTING, SESSIONS_SIDEBAR_SEPARATE_NAVIGATION_SETTING } from '../../../../workbench/contrib/sessions/common/sessionConfig.js'; +import { SESSIONS_SIDEBAR_CUSTOMIZATIONS_AT_BOTTOM_SETTING, SESSIONS_SIDEBAR_CUSTOMIZATIONS_CATEGORIES_SETTING, SESSIONS_SIDEBAR_SEPARATE_NAVIGATION_SETTING, SessionsSidebarCustomizationsCategories } from '../../../../workbench/contrib/sessions/common/sessionConfig.js'; import { ChatCustomizationsActionViewItem, ChatCustomizationsHover } from '../../../../workbench/contrib/chat/browser/aiCustomization/chatCustomizationsHover.js'; import { observeVisibleSectionBadge, observeVisibleSectionsBadge } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationSectionBadges.js'; import { ChatCustomizationsTelemetry } from '../../../../workbench/contrib/chat/browser/aiCustomization/chatCustomizationsTelemetry.js'; import { ICustomizationMigrationService } from '../../../../workbench/contrib/chat/common/promptSyntax/service/customizationMigrationService.js'; import { getIgnoredCustomizationMigrationGroups } from '../../../../workbench/contrib/chat/browser/aiCustomization/customizationMigrationIgnoredGroups.js'; -import { IStorageService } from '../../../../platform/storage/common/storage.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { getChatSessionType } from '../../../../workbench/contrib/chat/common/model/chatUri.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { URI } from '../../../../base/common/uri.js'; @@ -80,6 +80,21 @@ const CUSTOMIZATION_OVERVIEW_ITEM: ICustomizationItemConfig = { icon: Codicon.home, }; +const CUSTOMIZATIONS_REDUCED = ContextKeyExpr.equals(`config.${SESSIONS_SIDEBAR_CUSTOMIZATIONS_CATEGORIES_SETTING}`, SessionsSidebarCustomizationsCategories.Reduced); + +/** Whether the reduced Customizations section shows the categories that it hides by default. */ +const CUSTOMIZATIONS_SHOW_MORE_STORAGE_KEY = 'agentSessions.customizationsShortcuts.showMore'; +const CustomizationsShowMoreContext = new RawContextKey('sessionsCustomizationsShowMore', false); +/** Whether any of the categories that the reduced Customizations section hides by default is available. */ +const CustomizationsMoreAvailableContext = new RawContextKey('sessionsCustomizationsMoreAvailable', false); + +export const SHOW_MORE_CUSTOMIZATIONS_COMMAND_ID = 'sessions.customizations.showMore'; +export const SHOW_LESS_CUSTOMIZATIONS_COMMAND_ID = 'sessions.customizations.showLess'; + +// More shows the categories after those that the reduced section always shows, followed by Less. +const MORE_CUSTOMIZATIONS_ORDER = CUSTOMIZATION_ITEMS.length + 1; +const SHOW_MORE_OR_LESS_ORDER = 2 * MORE_CUSTOMIZATIONS_ORDER; + async function openCustomizationOverviewPage(editorService: IEditorService, harnessService: ICustomizationHarnessService, focusedSessionHostService: IFocusedSessionHostService, workspaceService: IAICustomizationWorkspaceService, options?: IOpenAICustomizationsCommandOptions, targetSession?: ISession, targetResource?: URI): Promise { const session = targetSession ?? (targetResource ? undefined : focusedSessionHostService.focusedHost.get().session.get()); const resource = targetResource ?? session?.resource; @@ -134,7 +149,7 @@ async function openCustomizationSectionPage(editorService: IEditorService, harne export class CustomizationLinkViewItem extends BaseActionViewItem { private readonly _viewItemDisposables: DisposableStore; - private _button: Button | undefined; + protected _button: Button | undefined; private _countContainer: HTMLElement | undefined; constructor( @@ -146,7 +161,7 @@ export class CustomizationLinkViewItem extends BaseActionViewItem { @ILanguageModelToolsService private readonly _toolsService: ILanguageModelToolsService, @IAgentHostToolSetEnablementService private readonly _toolEnablementService: IAgentHostToolSetEnablementService, @IHoverService private readonly _hoverService: IHoverService, - @IInstantiationService private readonly instantiationService: IInstantiationService, + @IInstantiationService protected readonly instantiationService: IInstantiationService, ) { super(undefined, action, options); this._viewItemDisposables = this._register(new DisposableStore()); @@ -156,6 +171,28 @@ export class CustomizationLinkViewItem extends BaseActionViewItem { return undefined; } + /** + * The categories whose customizations the item counts. + */ + protected getCountedConfigs(): readonly ICustomizationItemConfig[] { + return [this._config]; + } + + /** + * The description of the item's suggestion badge while it has one. + */ + protected observeBadge(): IObservable | undefined { + const section = this._config.section; + return section && this.instantiationService.invokeFunction(accessor => observeVisibleSectionBadge(accessor, section)); + } + + protected onButtonClick(): void { + if (this._config.id === CUSTOMIZATION_OVERVIEW_ITEM.id) { + this.instantiationService.createInstance(ChatCustomizationsTelemetry).logInteraction('buttonClicked', 'sidebar'); + } + this.actionRunner.run(this._action, this._context); + } + override render(container: HTMLElement): void { this._viewItemDisposables.clear(); this.element = container; @@ -177,30 +214,29 @@ export class CustomizationLinkViewItem extends BaseActionViewItem { this._button.label = `$(${this._config.icon.id}) ${this._config.label}`; this.updateEnabled(); - this._viewItemDisposables.add(this._button.onDidClick(() => { - if (this._config.id === CUSTOMIZATION_OVERVIEW_ITEM.id) { - this.instantiationService.createInstance(ChatCustomizationsTelemetry).logInteraction('buttonClicked', 'sidebar'); - } - this.actionRunner.run(this._action, this._context); - })); + this._viewItemDisposables.add(this._button.onDidClick(() => this.onButtonClick())); // Count container (inside button, floating right) this._countContainer = append(this._button.element, $('span.customization-link-counts')); + const countedConfigs = this.getCountedConfigs(); this._viewItemDisposables.add(autorun(reader => { - const count = readCustomizationCount(this._config, reader, this._itemsModel, this._mcpServerCountService, this._toolsService, this._toolEnablementService); + let count = 0; + for (const config of countedConfigs) { + count += readCustomizationCount(config, reader, this._itemsModel, this._mcpServerCountService, this._toolsService, this._toolEnablementService); + } if (this._countContainer) { this._renderTotalCount(this._countContainer, count); } })); - if (this._config.section) { - this._renderSectionBadge(this._button, this._config.section); + const badge = this.observeBadge(); + if (badge) { + this._renderBadge(this._button, badge); } } - private _renderSectionBadge(button: Button, section: AICustomizationManagementSection): void { - const description = this.instantiationService.invokeFunction(accessor => observeVisibleSectionBadge(accessor, section)); + private _renderBadge(button: Button, description: IObservable): void { const badge = append(button.element, $('span.ai-customization-suggestion-badge.hidden', { role: 'img' })); this._viewItemDisposables.add(autorun(reader => { const content = description.read(reader); @@ -254,6 +290,46 @@ export class CustomizationLinkViewItem extends BaseActionViewItem { } } +/** + * The More or Less row of the reduced Customizations section, which shows or hides in place the categories that the + * section hides by default. More counts the customizations of those that are available, and shows the badge of the + * first of them that has one. + */ +export class CustomizationsMoreViewItem extends CustomizationLinkViewItem { + + constructor( + action: IAction, + options: IBaseActionViewItemOptions, + private readonly _expanded: boolean, + private readonly _moreConfigs: readonly ICustomizationItemConfig[], + @IAICustomizationItemsModel itemsModel: IAICustomizationItemsModel, + @IAICustomizationMcpServerCountService mcpServerCountService: IAICustomizationMcpServerCountService, + @ILanguageModelToolsService toolsService: ILanguageModelToolsService, + @IAgentHostToolSetEnablementService toolEnablementService: IAgentHostToolSetEnablementService, + @IHoverService hoverService: IHoverService, + @IInstantiationService instantiationService: IInstantiationService, + ) { + super(action, options, { id: action.id, label: action.label, icon: _expanded ? Codicon.chevronUp : Codicon.ellipsis }, itemsModel, mcpServerCountService, toolsService, toolEnablementService, hoverService, instantiationService); + } + + protected override getCountedConfigs(): readonly ICustomizationItemConfig[] { + return this._expanded ? [] : this._moreConfigs; + } + + protected override observeBadge(): IObservable | undefined { + if (this._expanded) { + return undefined; + } + const sections = new Set(this._moreConfigs.flatMap(config => config.section ? [config.section] : [])); + return this.instantiationService.invokeFunction(accessor => observeVisibleSectionsBadge(accessor, sections)); + } + + override render(container: HTMLElement): void { + super.render(container); + this._button?.element.setAttribute('aria-expanded', String(this._expanded)); + } +} + class SessionsCustomizationsActionViewItemFactory { constructor( @IInstantiationService private readonly instantiationService: IInstantiationService, @@ -280,6 +356,7 @@ export class CustomizationsToolbarContribution extends Disposable implements IWo @ICustomizationHarnessService harnessService: ICustomizationHarnessService, @IContextKeyService contextKeyService: IContextKeyService, @IConfigurationService configurationService: IConfigurationService, + @IStorageService storageService: IStorageService, ) { super(); @@ -392,6 +469,48 @@ export class CustomizationsToolbarContribution extends Disposable implements IWo return instantiationService.createInstance(CustomizationLinkViewItem, action, options, CUSTOMIZATION_OVERVIEW_ITEM); }, undefined)); + // More and Less show and hide in place the categories that the reduced section hides by default, and the + // section remembers which of them was used last. + const moreItems = CUSTOMIZATION_ITEMS.flatMap(config => config.section && !config.showWhenReduced ? [{ config, when: customizationItemWhen(config, config.section) }] : []); + const moreAvailable = CustomizationsMoreAvailableContext.bindTo(contextKeyService); + const moreAvailableKeys = new Set(moreItems.flatMap(item => item.when?.keys() ?? [])); + const updateMoreAvailable = () => moreAvailable.set(moreItems.some(item => contextKeyService.contextMatchesRules(item.when))); + updateMoreAvailable(); + this._register(contextKeyService.onDidChangeContext(e => { + if (e.affectsSome(moreAvailableKeys)) { + updateMoreAvailable(); + } + })); + const showMore = CustomizationsShowMoreContext.bindTo(contextKeyService); + const updateShowMore = () => showMore.set(storageService.getBoolean(CUSTOMIZATIONS_SHOW_MORE_STORAGE_KEY, StorageScope.PROFILE, false)); + updateShowMore(); + this._register(storageService.onDidChangeValue(StorageScope.PROFILE, CUSTOMIZATIONS_SHOW_MORE_STORAGE_KEY, this._store)(updateShowMore)); + for (const expanded of [false, true]) { + const id = expanded ? SHOW_LESS_CUSTOMIZATIONS_COMMAND_ID : SHOW_MORE_CUSTOMIZATIONS_COMMAND_ID; + this._register(actionViewItemService.register(Menus.SidebarCustomizations, id, (action, options) => instantiationService.createInstance( + CustomizationsMoreViewItem, action, options, expanded, + moreItems.filter(item => contextKeyService.contextMatchesRules(item.when)).map(item => item.config), + ), undefined)); + this._register(registerAction2(class extends Action2 { + constructor() { + super({ + id, + title: expanded ? localize('lessCustomizations', "Less") : localize('moreCustomizations', "More"), + precondition: ChatContextKeys.enabled, + menu: { + id: Menus.SidebarCustomizations, + group: 'navigation', + order: SHOW_MORE_OR_LESS_ORDER, + when: ContextKeyExpr.and(ChatContextKeys.enabled, CUSTOMIZATIONS_REDUCED, CustomizationsMoreAvailableContext, expanded ? CustomizationsShowMoreContext : CustomizationsShowMoreContext.negate()), + }, + }); + } + run(accessor: ServicesAccessor): void { + accessor.get(IStorageService).store(CUSTOMIZATIONS_SHOW_MORE_STORAGE_KEY, !expanded, StorageScope.PROFILE, StorageTarget.USER); + } + })); + } + this._register(registerAction2(class extends Action2 { constructor() { super({ @@ -432,12 +551,13 @@ export class CustomizationsToolbarContribution extends Disposable implements IWo // Register the action with menu item this._register(registerAction2(class extends Action2 { constructor() { - const menu = { - id: Menus.SidebarCustomizations, - group: 'navigation', - order: index + 1, - when: combinedWhen, - }; + const order = index + 1; + const menu = config.showWhenReduced + ? { id: Menus.SidebarCustomizations, group: 'navigation', order, when: combinedWhen } + : [ + { id: Menus.SidebarCustomizations, group: 'navigation', order, when: ContextKeyExpr.and(combinedWhen, CUSTOMIZATIONS_REDUCED.negate()) }, + { id: Menus.SidebarCustomizations, group: 'navigation', order: MORE_CUSTOMIZATIONS_ORDER + order, when: ContextKeyExpr.and(combinedWhen, CUSTOMIZATIONS_REDUCED, CustomizationsShowMoreContext) }, + ]; super(config.command ? { id: config.id, title: config.command.title, diff --git a/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts b/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts index 5123b3594fbffc..3159bef11b6f76 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts @@ -35,7 +35,7 @@ import { AutomationSessionsListingContribution } from './automationSessionsListi import { SessionsUnattendedRestartParticipant } from './sessionsUnattendedRestart.js'; import { ISessionAttentionService } from '../../../../workbench/contrib/sessions/common/sessionAttentionService.js'; import { SessionAttentionService } from '../../../../workbench/contrib/sessions/browser/sessionAttentionService.js'; -import { SESSIONS_CHAT_TABS_DEFAULT, SESSIONS_CHAT_TABS_SETTING, SESSIONS_DRAFTS_ENABLED_SETTING, SESSIONS_SEND_LATER_ENABLED_SETTING, SESSIONS_LIST_AGENT_TOOLS_SETTING, SESSIONS_LIST_EXTERNAL_APPLICATION_BADGE_SETTING, SESSIONS_LIST_EXTERNAL_APPLICATION_BADGE_SHOW_FROM_SETTING, SESSIONS_LIST_GROUP_EXTERNAL_SESSIONS_SETTING, SESSIONS_LIST_INBOX_ENABLED_SETTING, SESSIONS_LIST_SHOW_AUTOMATION_SESSIONS_SETTING, SESSIONS_SIDEBAR_SEPARATE_NAVIGATION_SETTING, SESSIONS_SIDEBAR_CUSTOMIZATIONS_AT_BOTTOM_SETTING, SESSIONS_LIST_REARRANGE_TREATMENT, SessionsChatTabsMode, USE_WORKTREE_SETTING } from '../../../../workbench/contrib/sessions/common/sessionConfig.js'; +import { SESSIONS_CHAT_TABS_DEFAULT, SESSIONS_CHAT_TABS_SETTING, SESSIONS_DRAFTS_ENABLED_SETTING, SESSIONS_SEND_LATER_ENABLED_SETTING, SESSIONS_LIST_AGENT_TOOLS_SETTING, SESSIONS_LIST_EXTERNAL_APPLICATION_BADGE_SETTING, SESSIONS_LIST_EXTERNAL_APPLICATION_BADGE_SHOW_FROM_SETTING, SESSIONS_LIST_GROUP_EXTERNAL_SESSIONS_SETTING, SESSIONS_LIST_INBOX_ENABLED_SETTING, SESSIONS_LIST_SHOW_AUTOMATION_SESSIONS_SETTING, SESSIONS_SIDEBAR_SEPARATE_NAVIGATION_SETTING, SESSIONS_SIDEBAR_CUSTOMIZATIONS_AT_BOTTOM_SETTING, SESSIONS_SIDEBAR_CUSTOMIZATIONS_CATEGORIES_SETTING, SESSIONS_LIST_REARRANGE_TREATMENT, SessionsChatTabsMode, SessionsSidebarCustomizationsCategories, USE_WORKTREE_SETTING } from '../../../../workbench/contrib/sessions/common/sessionConfig.js'; import { sessionsListConfigurationProperties } from '../../../../workbench/contrib/sessions/browser/sessionsConfiguration.js'; registerSingleton(ISessionAttentionService, SessionAttentionService, InstantiationType.Delayed); @@ -121,6 +121,18 @@ export const sessionsConfiguration = { default: false, experiment: { mode: 'auto' }, }, + [SESSIONS_SIDEBAR_CUSTOMIZATIONS_CATEGORIES_SETTING]: { + type: 'string', + enum: [SessionsSidebarCustomizationsCategories.All, SessionsSidebarCustomizationsCategories.Reduced], + enumDescriptions: [ + localize('sessions.sidebar.customizationsCategories.all', "Show all customization categories."), + localize('sessions.sidebar.customizationsCategories.reduced', "Show Overview, Plugins, MCP Servers, Skills, and Instructions. More shows the other categories, and Less hides them again."), + ], + tags: ['experimental'], + description: localize('sessions.sidebar.customizationsCategories', "Controls which customization categories the Customizations section below the Sessions list shows."), + default: SessionsSidebarCustomizationsCategories.Reduced, + experiment: { mode: 'auto' }, + }, [SESSIONS_LIST_GROUP_EXTERNAL_SESSIONS_SETTING]: sessionsListConfigurationProperties[SESSIONS_LIST_GROUP_EXTERNAL_SESSIONS_SETTING], [SESSIONS_LIST_INBOX_ENABLED_SETTING]: sessionsListConfigurationProperties[SESSIONS_LIST_INBOX_ENABLED_SETTING], [SESSIONS_LIST_AGENT_TOOLS_SETTING]: { diff --git a/src/vs/sessions/contrib/sessions/test/browser/customizationsToolbar.test.ts b/src/vs/sessions/contrib/sessions/test/browser/customizationsToolbar.test.ts index 8f134b09fba5a1..dd98f47ce5a71d 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/customizationsToolbar.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/customizationsToolbar.test.ts @@ -36,8 +36,8 @@ import { USLayoutResolvedKeybinding } from '../../../../../platform/keybinding/c import { createUSLayoutResolvedKeybinding } from '../../../../../platform/keybinding/test/common/keybindingsTestUtils.js'; import { MockContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; -import { InMemoryStorageService } from '../../../../../platform/storage/common/storage.js'; -import { IAICustomizationItemsModel } from '../../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemsModel.js'; +import { InMemoryStorageService, IStorageService } from '../../../../../platform/storage/common/storage.js'; +import { IAICustomizationItemsModel, ItemsModelSection } from '../../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemsModel.js'; import { CustomizationMigrationCategoryId } from '../../../../../workbench/contrib/chat/browser/aiCustomization/customizationMigrationCategories.js'; import { getCustomizationMigrationGroupIgnoreKey, storeIgnoredCustomizationMigrationGroupKeys } from '../../../../../workbench/contrib/chat/browser/aiCustomization/customizationMigrationIgnoredGroups.js'; import { IAgentHostToolSetEnablementService } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostToolSetEnablementService.js'; @@ -49,7 +49,7 @@ import { CustomizationMigrationType, ICustomizationMigrationHint, ICustomization import { PromptsStorage } from '../../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; import { ILanguageModelToolsService, IToolData, IToolSet } from '../../../../../workbench/contrib/chat/common/tools/languageModelToolsService.js'; import { IAICustomizationMcpServerCountService } from '../../../../../workbench/contrib/sessions/browser/customizationMcpServerCount.js'; -import { CustomizationLinkViewItem, CustomizationsToolbarContribution } from '../../browser/customizationsToolbar.contribution.js'; +import { CustomizationLinkViewItem, CustomizationsMoreViewItem, CustomizationsToolbarContribution, SHOW_LESS_CUSTOMIZATIONS_COMMAND_ID, SHOW_MORE_CUSTOMIZATIONS_COMMAND_ID } from '../../browser/customizationsToolbar.contribution.js'; import { CUSTOMIZATION_ITEMS, readCustomizationCount } from '../../../../../workbench/contrib/sessions/browser/customizationItems.js'; import { VIEWLET_ID as EXTENSIONS_VIEWLET_ID } from '../../../../../workbench/contrib/extensions/common/extensions.js'; import { CustomizationsNavigationState } from '../../../../../workbench/contrib/sessions/browser/customizationsNavigationState.js'; @@ -63,6 +63,8 @@ import { IEditorService } from '../../../../../workbench/services/editor/common/ import { OPEN_AI_CUSTOMIZATIONS_COMMAND_ID } from '../../../../../workbench/contrib/sessions/browser/customizationsConstants.js'; import { AICustomizationManagementEditorInput } from '../../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditorInput.js'; import { aiCustomizationManagementSectionRegistry } from '../../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementSectionRegistry.js'; +import { Menus } from '../../../../../workbench/contrib/sessions/browser/menus.js'; +import { SESSIONS_SIDEBAR_CUSTOMIZATIONS_CATEGORIES_SETTING, SessionsSidebarCustomizationsCategories } from '../../../../../workbench/contrib/sessions/common/sessionConfig.js'; suite('Customizations toolbar', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); @@ -114,6 +116,7 @@ suite('Customizations toolbar', () => { harnessService, disposables.add(new MockContextKeyService()), new TestConfigurationService(), + disposables.add(new InMemoryStorageService()), )); await instantiationService.invokeFunction(accessor => CommandsRegistry.getCommand(OPEN_AI_CUSTOMIZATIONS_COMMAND_ID)!.handler(accessor)); @@ -210,6 +213,7 @@ suite('Customizations toolbar', () => { }, disposables.add(new MockContextKeyService()), new TestConfigurationService(), + disposables.add(new InMemoryStorageService()), )); const palette = MenuRegistry.getMenuItems(MenuId.CommandPalette).filter(isIMenuItem).find(item => item.command.id === EXTENSIONS_VIEWLET_ID); const keybinding = keybindingsOnLoad[0]; @@ -267,6 +271,7 @@ suite('Customizations toolbar', () => { }, contextKeyService, configurationService, + disposables.add(new InMemoryStorageService()), )); // As the Keyboard Shortcuts editor of editor windows writes them, scoped to the Extensions view container's enablement like the default keybinding there. @@ -548,6 +553,144 @@ suite('Customizations toolbar', () => { }); }); + test('shows the categories that the reduced section hides with More, hides them with Less, and remembers which was used last', async () => { + const configurationService = new TestConfigurationService({ + [SESSIONS_SIDEBAR_CUSTOMIZATIONS_CATEGORIES_SETTING]: SessionsSidebarCustomizationsCategories.All, + [EXTENSIONS_AGENTS_WINDOW_MANAGEMENT]: true, + }); + const instantiationService = workbenchInstantiationService({}, disposables); + const storageService = instantiationService.get(IStorageService); + // Each window, or restart, has its own context. + const createWindow = () => { + const contextKeyService = new ContextKeyService(configurationService); + ChatContextKeys.enabled.bindTo(contextKeyService).set(true); + const contribution = new CustomizationsToolbarContribution( + new class extends mock() { + override register() { return toDisposable(() => { }); } + }, + new class extends mock() { }, + new class extends mock() { + override readonly activeHarness = constObservable('vscode'); + override readonly availableHarnesses = constObservable([]); + override getActiveDescriptor() { return upcastPartial({ hiddenSections: [] }); } + }, + contextKeyService, + configurationService, + storageService, + ); + return { + offered: () => MenuRegistry.getMenuItems(Menus.SidebarCustomizations) + .filter(item => contextKeyService.contextMatchesRules(item.when)) + .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) + .map(item => isIMenuItem(item) ? item.command.id : item.submenu.id), + dispose: () => { + contribution.dispose(); + contextKeyService.dispose(); + }, + }; + }; + const run = (id: string) => instantiationService.invokeFunction(accessor => CommandsRegistry.getCommand(id)!.handler(accessor)); + + let window = createWindow(); + const all = window.offered(); + configurationService.setUserConfiguration(SESSIONS_SIDEBAR_CUSTOMIZATIONS_CATEGORIES_SETTING, SessionsSidebarCustomizationsCategories.Reduced); + configurationService.onDidChangeConfigurationEmitter.fire(upcastPartial({ affectedKeys: new Set([SESSIONS_SIDEBAR_CUSTOMIZATIONS_CATEGORIES_SETTING]), affectsConfiguration: key => key === SESSIONS_SIDEBAR_CUSTOMIZATIONS_CATEGORIES_SETTING })); + const reduced = window.offered(); + await run(SHOW_MORE_CUSTOMIZATIONS_COMMAND_ID); + const more = window.offered(); + window.dispose(); + window = createWindow(); + const moreAfterRestart = window.offered(); + await run(SHOW_LESS_CUSTOMIZATIONS_COMMAND_ID); + const less = window.offered(); + window.dispose(); + window = createWindow(); + const lessAfterRestart = window.offered(); + window.dispose(); + + const kept = [ + OPEN_AI_CUSTOMIZATIONS_COMMAND_ID, + 'sessions.customization.plugins', + 'sessions.customization.mcpServers', + 'sessions.customization.skills', + 'sessions.customization.instructions', + ]; + const hidden = [ + 'sessions.customization.agents', + 'sessions.customization.hooks', + 'sessions.customization.tools', + EXTENSIONS_VIEWLET_ID, + ]; + assert.deepStrictEqual({ all, reduced, more, moreAfterRestart, less, lessAfterRestart }, { + all: [...kept, ...hidden], + reduced: [...kept, SHOW_MORE_CUSTOMIZATIONS_COMMAND_ID], + more: [...kept, ...hidden, SHOW_LESS_CUSTOMIZATIONS_COMMAND_ID], + moreAfterRestart: [...kept, ...hidden, SHOW_LESS_CUSTOMIZATIONS_COMMAND_ID], + less: [...kept, SHOW_MORE_CUSTOMIZATIONS_COMMAND_ID], + lessAfterRestart: [...kept, SHOW_MORE_CUSTOMIZATIONS_COMMAND_ID], + }); + }); + + test('counts and badges the categories that More shows, and tells whether they are shown', async () => { + const badge = observableValue(disposables, undefined); + disposables.add(aiCustomizationManagementSectionRegistry.registerBadge(AICustomizationManagementSection.Hooks, () => badge)); + const instantiationService = telemetryInstantiationService(); + instantiationService.stub(ICustomizationHarnessService, new class extends mock() { + override readonly activeHarness = constObservable('test'); + override readonly availableHarnesses = constObservable([upcastPartial({ id: 'test', hiddenSections: [] })]); + override findHarnessById(id: string) { + return this.availableHarnesses.get().find(harness => harness.id === id); + } + }); + instantiationService.stub(IAICustomizationWorkspaceService, new class extends mock() { + override readonly managementSections = [AICustomizationManagementSection.Skills, AICustomizationManagementSection.Agents, AICustomizationManagementSection.Hooks]; + }); + instantiationService.stub(IAICustomizationItemsModel, new class extends mock() { + override getCount(section: ItemsModelSection) { + return constObservable(section === AICustomizationManagementSection.Agents ? 2 : section === AICustomizationManagementSection.Hooks ? 3 : 30); + } + }); + instantiationService.stub(IAICustomizationMcpServerCountService, new class extends mock() { }); + instantiationService.stub(ILanguageModelToolsService, new class extends mock() { }); + instantiationService.stub(IAgentHostToolSetEnablementService, new class extends mock() { }); + instantiationService.stub(IHoverService, NullHoverService); + const moreConfigs = CUSTOMIZATION_ITEMS.filter(config => config.section === AICustomizationManagementSection.Agents || config.section === AICustomizationManagementSection.Hooks); + const runs: string[] = []; + const render = (expanded: boolean) => { + const label = expanded ? 'Less' : 'More'; + const action = disposables.add(new Action(label, label, undefined, true, () => { runs.push(label); })); + const item = disposables.add(instantiationService.createInstance(CustomizationsMoreViewItem, action, {}, expanded, moreConfigs)); + const container = mainWindow.document.createElement('div'); + item.render(container); + return container.querySelector('.customization-link-button'); + }; + const more = render(false); + const less = render(true); + const readState = () => [more, less].map(button => ({ + count: button?.querySelector('.customization-link-counts')?.textContent, + badge: button?.querySelector('.ai-customization-suggestion-badge:not(.hidden)')?.getAttribute('aria-label'), + expanded: button?.getAttribute('aria-expanded'), + })); + + const withoutBadge = readState(); + badge.set('Hooks to review.', undefined); + more?.click(); + less?.click(); + await Promise.resolve(); + + assert.deepStrictEqual({ withoutBadge, withBadge: readState(), runs }, { + withoutBadge: [ + { count: '5', badge: undefined, expanded: 'false' }, + { count: '', badge: undefined, expanded: 'true' }, + ], + withBadge: [ + { count: '5', badge: 'Hooks to review.', expanded: 'false' }, + { count: '', badge: undefined, expanded: 'true' }, + ], + runs: ['More', 'Less'], + }); + }); + test('forwards ActionBar roving focus to the nested button', () => { const container = mainWindow.document.createElement('div'); const createViewItem = (action: IAction) => new CustomizationLinkViewItem( diff --git a/src/vs/sessions/test/ai-customizations.test.md b/src/vs/sessions/test/ai-customizations.test.md index 1683eccb2fc75b..253a6638ecd93f 100644 --- a/src/vs/sessions/test/ai-customizations.test.md +++ b/src/vs/sessions/test/ai-customizations.test.md @@ -13,10 +13,11 @@ Production behavior reads the `sessions.list.rearrage` treatment directly from t - Treatment: `"experiments.override.sessions.list.rearrage": true` - Control: `"experiments.override.sessions.list.rearrage": false` - Customizations at the bottom (with the treatment): `"experiments.override.config.sessions.sidebar.customizationsAtBottom": true` +- All customization categories in the expandable Customizations pane (in the control, or with `customizationsAtBottom`): `"experiments.override.config.sessions.sidebar.customizationsCategories": "all"` The Agents sidebar updates reactively when the override changes. Remove the override to use the automatically assigned experiment variant. -In the treatment, Automations and Customizations are the first rows in the Sessions tree and scroll with its content. The Sessions header follows them and remains sticky while session rows scroll beneath it. The control keeps the expandable Customizations pane above the Sessions list. The `customizationsAtBottom` variant shows the treatment's navigation rows without Customizations, and keeps the control's expandable Customizations pane. It has no effect in the control. +In the treatment, Automations and Customizations are the first rows in the Sessions tree and scroll with its content. The Sessions header follows them and remains sticky while session rows scroll beneath it. The control keeps the expandable Customizations pane above the Sessions list. The `customizationsAtBottom` variant shows the treatment's navigation rows without Customizations, and keeps the control's expandable Customizations pane. It has no effect in the control. By default, the expandable Customizations pane shows Overview, Plugins, MCP Servers, Skills, and Instructions, and a More row that counts the customizations of the other available categories. More shows them in place, followed by a Less row that hides them again. The pane remembers across restarts which of the two was used last. With all categories, the pane shows every available category without More or Less. ## SCENARIOS diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationSectionBadges.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationSectionBadges.ts index 79f61de14511a0..7e898e76f0db95 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationSectionBadges.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationSectionBadges.ts @@ -38,11 +38,11 @@ export function observeVisibleSectionBadge(accessor: ServicesAccessor, section: } /** - * Observes the badge of the first section with a badge that the active harness shows in the editor, otherwise - * `undefined`. Entry points to the editor show it as a single badge. + * Observes the badge of the first section with a badge, of `sections` if given, that the active harness shows in the + * editor, otherwise `undefined`. Entry points to the editor show it as a single badge. */ -export function observeVisibleSectionsBadge(accessor: ServicesAccessor): IObservable { - const badges = observeVisibleSectionBadges(getSectionBadgeServices(accessor)); +export function observeVisibleSectionsBadge(accessor: ServicesAccessor, sections?: ReadonlySet): IObservable { + const badges = observeVisibleSectionBadges(getSectionBadgeServices(accessor), sections && (managementSections => managementSections.filter(section => sections.has(section)))); return derived(reader => badges.read(reader)[0]?.badge); } diff --git a/src/vs/workbench/contrib/sessions/browser/aiCustomizationShortcutsWidget.ts b/src/vs/workbench/contrib/sessions/browser/aiCustomizationShortcutsWidget.ts index 955b68f232ad30..7865d7cb90e9c7 100644 --- a/src/vs/workbench/contrib/sessions/browser/aiCustomizationShortcutsWidget.ts +++ b/src/vs/workbench/contrib/sessions/browser/aiCustomizationShortcutsWidget.ts @@ -204,8 +204,47 @@ export class AICustomizationShortcutsWidget extends Disposable { })); this._toolbar = toolbar; + // Updating the items replaces them, including the focused one. To keep keyboard focus at its position, e.g. on + // the first item that More shows or on More after Less hides them, remember the focused item until focus leaves. + let focusedItem: { readonly element: HTMLElement; readonly index: number } | undefined; + this._renderDisposables.add(DOM.addDisposableListener(toolbarContainer, DOM.EventType.FOCUS_IN, e => { + const target = e.target; + for (let index = 0; DOM.isHTMLElement(target) && index < toolbar.getItemsLength(); index++) { + if (toolbar.getItemElement(index)?.contains(target)) { + focusedItem = { element: target, index }; + return; + } + } + })); + this._renderDisposables.add(DOM.addDisposableListener(toolbarContainer, DOM.EventType.FOCUS_OUT, e => { + const element = e.target; + if (!DOM.isHTMLElement(element)) { + return; + } + if (e.relatedTarget) { + if (!(DOM.isHTMLElement(e.relatedTarget) && toolbarContainer.contains(e.relatedTarget))) { + focusedItem = undefined; + } + return; + } + // Removing the focused item also moves focus to no element, but leaves it disconnected after the update. + queueMicrotask(() => { + if (focusedItem?.element === element && element.isConnected) { + focusedItem = undefined; + } + }); + })); + // Re-layout when toolbar items change (e.g., Plugins item appearing after extension activation) this._renderDisposables.add(toolbar.onDidChangeMenuItems(() => { + if (focusedItem && !focusedItem.element.isConnected) { + const index = focusedItem.index; + focusedItem = undefined; + const active = DOM.getActiveElement(); + if ((!active || active === DOM.getWindow(container).document.body) && toolbar.getItemsLength() > 0) { + toolbar.focus(Math.min(index, toolbar.getItemsLength() - 1)); + } + } this._scrollableElement?.scanDomNode(); this._onDidChangeHeight.fire(); options?.onDidChangeLayout?.(); diff --git a/src/vs/workbench/contrib/sessions/browser/customizationItems.ts b/src/vs/workbench/contrib/sessions/browser/customizationItems.ts index 80ba16f0d4bd07..da6f972d8b76e4 100644 --- a/src/vs/workbench/contrib/sessions/browser/customizationItems.ts +++ b/src/vs/workbench/contrib/sessions/browser/customizationItems.ts @@ -36,6 +36,8 @@ export interface ICustomizationItemConfig { readonly isMcp?: boolean; readonly isPlugins?: boolean; readonly isTools?: boolean; + /** Whether the reduced Customizations section still shows the item, rather than listing it under More. */ + readonly showWhenReduced?: boolean; /** Additional `when` clause beyond the standard harness-visibility gate. */ readonly when?: ContextKeyExpression; /** If set, the Command Palette also offers the item, with this title and category, and its default keybinding, if any. */ @@ -104,6 +106,7 @@ export const CUSTOMIZATION_ITEMS: ICustomizationItemConfig[] = [ icon: pluginIcon, section: AICustomizationManagementSection.Plugins, isPlugins: true, + showWhenReduced: true, }, { id: 'sessions.customization.canvases', @@ -121,6 +124,7 @@ export const CUSTOMIZATION_ITEMS: ICustomizationItemConfig[] = [ icon: mcpServerIcon, section: AICustomizationManagementSection.McpServers, isMcp: true, + showWhenReduced: true, }, { id: 'sessions.customization.skills', @@ -128,6 +132,7 @@ export const CUSTOMIZATION_ITEMS: ICustomizationItemConfig[] = [ icon: skillIcon, section: AICustomizationManagementSection.Skills, modelSection: AICustomizationManagementSection.Skills, + showWhenReduced: true, }, { id: 'sessions.customization.instructions', @@ -135,6 +140,7 @@ export const CUSTOMIZATION_ITEMS: ICustomizationItemConfig[] = [ icon: instructionsIcon, section: AICustomizationManagementSection.Instructions, modelSection: AICustomizationManagementSection.Instructions, + showWhenReduced: true, }, { id: 'sessions.customization.agents', diff --git a/src/vs/workbench/contrib/sessions/common/sessionConfig.ts b/src/vs/workbench/contrib/sessions/common/sessionConfig.ts index 0ccb8c21ea6aff..14a6bb8e8bae85 100644 --- a/src/vs/workbench/contrib/sessions/common/sessionConfig.ts +++ b/src/vs/workbench/contrib/sessions/common/sessionConfig.ts @@ -74,6 +74,17 @@ export const SESSIONS_SIDEBAR_SEPARATE_NAVIGATION_SETTING = 'sessions.sidebar.se */ export const SESSIONS_SIDEBAR_CUSTOMIZATIONS_AT_BOTTOM_SETTING = 'sessions.sidebar.customizationsAtBottom'; +/** + * Which customization categories the Customizations section below the Sessions list shows. + */ +export const SESSIONS_SIDEBAR_CUSTOMIZATIONS_CATEGORIES_SETTING = 'sessions.sidebar.customizationsCategories'; + +export const enum SessionsSidebarCustomizationsCategories { + All = 'all', + /** Only the categories that are shown when reduced, and a More menu with the others. */ + Reduced = 'reduced', +} + export const SESSIONS_LIST_REARRANGE_TREATMENT = 'sessions.list.rearrage'; export const enum SessionsChatTabsMode { diff --git a/src/vs/workbench/contrib/sessions/test/browser/aiCustomizationShortcutsWidget.test.ts b/src/vs/workbench/contrib/sessions/test/browser/aiCustomizationShortcutsWidget.test.ts index c29a5fb4f2d72a..7df7b6c13cbc75 100644 --- a/src/vs/workbench/contrib/sessions/test/browser/aiCustomizationShortcutsWidget.test.ts +++ b/src/vs/workbench/contrib/sessions/test/browser/aiCustomizationShortcutsWidget.test.ts @@ -6,12 +6,16 @@ import assert from 'assert'; import * as DOM from '../../../../../base/browser/dom.js'; import { mainWindow } from '../../../../../base/browser/window.js'; +import { timeout } from '../../../../../base/common/async.js'; import { Codicon } from '../../../../../base/common/codicons.js'; -import { toDisposable } from '../../../../../base/common/lifecycle.js'; +import { Emitter } from '../../../../../base/common/event.js'; +import { DisposableStore, toDisposable } from '../../../../../base/common/lifecycle.js'; import { constObservable } from '../../../../../base/common/observable.js'; import { upcastPartial } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { IMenu, IMenuChangeEvent, IMenuService, MenuItemAction } from '../../../../../platform/actions/common/actions.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { IAICustomizationItemsModel } from '../../../chat/browser/aiCustomization/aiCustomizationItemsModel.js'; import { IAgentHostToolSetEnablementService } from '../../../chat/browser/agentSessions/agentHost/agentHostToolSetEnablementService.js'; import { IAICustomizationWorkspaceService } from '../../../chat/common/aiCustomizationWorkspaceService.js'; @@ -21,6 +25,28 @@ import { workbenchInstantiationService } from '../../../../test/browser/workbenc import { AICustomizationShortcutsWidget } from '../../browser/aiCustomizationShortcutsWidget.js'; import { IAICustomizationMcpServerCountService } from '../../browser/customizationMcpServerCount.js'; +function createInstantiationService(disposables: Pick): TestInstantiationService { + const instantiationService = workbenchInstantiationService({}, disposables); + instantiationService.stub(ICommandService, { executeCommand: async () => undefined }); + instantiationService.stub(IAICustomizationItemsModel, { + getCount: () => constObservable(1), + getPluginCount: () => constObservable(1), + }); + instantiationService.stub(IAICustomizationMcpServerCountService, { count: constObservable(1) }); + instantiationService.stub(ILanguageModelToolsService, { toolSets: constObservable([]) }); + instantiationService.stub(IAgentHostToolSetEnablementService, { + observe: () => constObservable({ toolSets: new Map(), tools: new Map() }), + }); + const descriptor = upcastPartial({ id: 'test', label: 'Test', icon: Codicon.extensions }); + instantiationService.stub(ICustomizationHarnessService, { + activeHarness: constObservable('test'), + availableHarnesses: constObservable([descriptor]), + getActiveDescriptor: () => descriptor, + }); + instantiationService.stub(IAICustomizationWorkspaceService, { managementSections: [] }); + return instantiationService; +} + suite('AI customization shortcuts header', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); let container: HTMLElement; @@ -34,25 +60,7 @@ suite('AI customization shortcuts header', () => { container.style.cssText = 'position: fixed; top: 0; left: 0; width: 300px; height: 200px; z-index: 10000;'; disposables.add(toDisposable(() => container.remove())); - const instantiationService = workbenchInstantiationService({}, disposables); - instantiationService.stub(ICommandService, { executeCommand: async () => undefined }); - instantiationService.stub(IAICustomizationItemsModel, { - getCount: () => constObservable(1), - getPluginCount: () => constObservable(1), - }); - instantiationService.stub(IAICustomizationMcpServerCountService, { count: constObservable(1) }); - instantiationService.stub(ILanguageModelToolsService, { toolSets: constObservable([]) }); - instantiationService.stub(IAgentHostToolSetEnablementService, { - observe: () => constObservable({ toolSets: new Map(), tools: new Map() }), - }); - const descriptor = upcastPartial({ id: 'test', label: 'Test', icon: Codicon.extensions }); - instantiationService.stub(ICustomizationHarnessService, { - activeHarness: constObservable('test'), - availableHarnesses: constObservable([descriptor]), - getActiveDescriptor: () => descriptor, - }); - instantiationService.stub(IAICustomizationWorkspaceService, { managementSections: [] }); - disposables.add(instantiationService.createInstance(AICustomizationShortcutsWidget, container, undefined)); + disposables.add(createInstantiationService(disposables).createInstance(AICustomizationShortcutsWidget, container, undefined)); const renderedHeader = container.querySelector('.ai-customization-header'); assert.ok(renderedHeader); @@ -208,3 +216,78 @@ suite('AI customization shortcuts header', () => { } }); }); + +suite('AI customization shortcuts list', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + test('keeps keyboard focus at its position when updating the items replaces the focused one, until focus leaves', async () => { + const container = DOM.append(mainWindow.document.body, DOM.$('div')); + disposables.add(toDisposable(() => container.remove())); + const outside = DOM.append(mainWindow.document.body, DOM.$('button')); + disposables.add(toDisposable(() => outside.remove())); + let labels = ['Overview', 'Skills', 'More']; + const onDidChange = disposables.add(new Emitter()); + const instantiationService = createInstantiationService(disposables); + instantiationService.stub(IMenuService, upcastPartial({ + createMenu: () => upcastPartial({ + onDidChange: onDidChange.event, + getActions: () => [['navigation', labels.map(label => upcastPartial({ id: label, label, tooltip: '', enabled: true, run: async () => { } }))]], + dispose: () => { }, + }), + })); + disposables.add(instantiationService.createInstance(AICustomizationShortcutsWidget, container, undefined)); + const items = () => [...container.querySelectorAll('.actions-container > .action-item')]; + // Hidden Electron windows move focus without dispatching focus events, so dispatch them like a focused window. + const dispatchFocusEvents = (previous: Element | null | undefined, next: HTMLElement | undefined) => { + if (!mainWindow.document.hasFocus()) { + previous?.dispatchEvent(new FocusEvent('blur', { relatedTarget: next ?? null })); + previous?.dispatchEvent(new FocusEvent('focusout', { bubbles: true, relatedTarget: next ?? null })); + next?.dispatchEvent(new FocusEvent('focus', { relatedTarget: previous ?? null })); + next?.dispatchEvent(new FocusEvent('focusin', { bubbles: true, relatedTarget: previous ?? null })); + } + }; + const moveFocus = (element: HTMLElement | undefined) => { + const previous = DOM.getActiveElement(); + if (element) { + element.focus(); + } else if (DOM.isHTMLElement(previous)) { + previous.blur(); + } + dispatchFocusEvents(previous, element); + }; + // Enters the list afresh, so that its action bar tracks the focused item like with keyboard navigation. + const focus = async (index: number) => { + moveFocus(undefined); + await timeout(0); + moveFocus(items()[index].querySelector('.action-label') ?? undefined); + }; + const focused = () => { + const active = DOM.getActiveElement(); + return active === outside ? 'outside' : labels[items().findIndex(item => item.contains(active))] ?? 'none'; + }; + const update = async (next: string[]) => { + // A focused window moves focus out of the focused item before updating the items removes it. + if (container.contains(DOM.getActiveElement())) { + dispatchFocusEvents(DOM.getActiveElement(), undefined); + } + labels = next; + onDidChange.fire(upcastPartial({})); + await Promise.resolve(); + return focused(); + }; + + await focus(2); + const more = await update(['Overview', 'Skills', 'Agents', 'Hooks', 'Less']); + await focus(4); + const less = await update(['Overview', 'Skills', 'More']); + await focus(1); + moveFocus(undefined); + await Promise.resolve(); + const focusLeftToNothing = await update(['Overview', 'Skills', 'Agents', 'Hooks', 'Less']); + await focus(1); + moveFocus(outside); + const focusLeftToOutside = await update(['Overview', 'Skills', 'More']); + + assert.deepStrictEqual({ more, less, focusLeftToNothing, focusLeftToOutside }, { more: 'Agents', less: 'More', focusLeftToNothing: 'none', focusLeftToOutside: 'outside' }); + }); +}); From 29e2cc9d20c3352eb744c55a671f4f59de388af2 Mon Sep 17 00:00:00 2001 From: benibenj Date: Sun, 15 Nov 2026 15:00:05 +0100 Subject: [PATCH 2/2] sessions: keep one tab stop after More or Less and document them in the Agents window help Updating the Customizations items made the first item a tab stop again, even when focus stayed on, or was restored to, another item, so Shift+Tab moved to Overview instead of leaving the list. Only the focused item now remains a tab stop. The Agents window accessibility help now explains how More and Less show and hide the other customization categories, where focus moves, and the setting that always shows every category. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/sessionsChatAccessibilityHelp.ts | 3 ++- .../sessionsChatAccessibilityHelp.test.ts | 23 ++++++++++++++++++- .../browser/aiCustomizationShortcutsWidget.ts | 17 ++++++++++++++ .../aiCustomizationShortcutsWidget.test.ts | 13 ++++++++--- 4 files changed, 51 insertions(+), 5 deletions(-) diff --git a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts index 018662460d288b..a18f244a1ad808 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts @@ -37,7 +37,7 @@ import { AgentWorkbenchLayout, ISessionsHostLayoutService } from '../../../../wo import { ISessionComparisonService } from '../../../../workbench/contrib/sessions/common/sessionComparison.js'; import { CHAT_WINDOWS_ENABLED_SETTING } from '../../chatWindow/common/chatWindows.js'; import { buildSessionComparisonAccessibleContent, isJudgeSession, SessionComparisonResultFocused } from '../../../../workbench/contrib/sessions/browser/chat/sessionComparisonResult.js'; -import { SESSION_ACTIVITY_BAR_SETTING, SESSIONS_CHAT_TABS_DEFAULT, SESSIONS_CHAT_TABS_SETTING, SESSIONS_DRAFTS_ENABLED_SETTING, SESSIONS_SEND_LATER_ENABLED_SETTING, SESSIONS_LIST_GROUP_EXTERNAL_SESSIONS_SETTING, SESSIONS_LIST_INBOX_ENABLED_SETTING, SESSIONS_LIST_SHOW_AUTOMATION_SESSIONS_SETTING, SessionsChatTabsMode } from '../../../../workbench/contrib/sessions/common/sessionConfig.js'; +import { SESSION_ACTIVITY_BAR_SETTING, SESSIONS_CHAT_TABS_DEFAULT, SESSIONS_CHAT_TABS_SETTING, SESSIONS_DRAFTS_ENABLED_SETTING, SESSIONS_SEND_LATER_ENABLED_SETTING, SESSIONS_LIST_GROUP_EXTERNAL_SESSIONS_SETTING, SESSIONS_LIST_INBOX_ENABLED_SETTING, SESSIONS_LIST_SHOW_AUTOMATION_SESSIONS_SETTING, SESSIONS_SIDEBAR_CUSTOMIZATIONS_CATEGORIES_SETTING, SessionsChatTabsMode } from '../../../../workbench/contrib/sessions/common/sessionConfig.js'; import { CHAT_AUTOMATIONS_ENABLED_SETTING } from '../../../../workbench/contrib/chat/common/automations/automationsEnabled.js'; import { AGENT_SESSIONS_APPEARANCE_TOOLS_ENABLED_SETTING, AGENT_SESSIONS_RESPONSE_SELECTION_MENU_SETTING, AGENTS_PICKER_IN_ATTACH_CONTEXT_MENU_SETTING, COMPARE_AGENTS_ENABLED_SETTING, COMPARE_AGENTS_TOOLS_ENABLED_SETTING, ENVIRONMENT_PICKER_MANAGE_REMOTES_SETTING, ENVIRONMENT_PICKER_SETTING, NEW_SESSION_CONNECTOR_RECOMMENDATIONS_SETTING, NEW_SESSION_WELCOME_PHRASES_SETTING, WORKSPACE_OPT_OUT_SETTING } from '../../../../workbench/contrib/sessions/common/chatConstants.js'; import { logSettingExperimentTrigger } from '../../../../platform/telemetry/common/experimentTrigger.js'; @@ -273,6 +273,7 @@ export class SessionsChatAccessibilityHelp implements IAccessibleViewImplementat content.push(localize('sessionsChat.inputBackground', "Use Send in Background{0} to start a new session without navigating into it. The started session appears in the Sessions list, and the composer resets for another prompt. A notification offers Open Session when the send succeeds. Enter sends and opens the session as usual. You can change the background-send shortcuts in Keyboard Shortcuts.", ``)); content.push(localize('sessionsChat.workspace', "Use Tab or Shift+Tab to navigate to the workspace picker and choose a workspace for your session. Opening the picker focuses its search input so you can immediately type to filter workspaces. If quick chats are available, you can also choose Chat to start a workspace-less chat.")); content.push(localize('sessionsChat.customizationsLocation', "The Chat: Customizations Location setting places Customizations below Sessions, at the top left of Sessions, in the Chat toolbar, or inside the input after the model pickers. Activate Customizations with Enter or Space to open its editor. Its hover contains counts and links for plugins, MCP servers, skills, instructions, and hooks. Needs attention links to customization migrations and MCP servers that require sign-in, showing up to two server names. Activate a server name to sign in directly; the sign-in count opens MCP server management. The footer offers Browse Additional and a settings gear named Change Button Location. Use Show or Focus Hover while the button is focused, then Tab or Shift+Tab to reach the links and Enter to activate them.")); + content.push(localize('sessionsChat.customizationsMore', "The Customizations section below the Sessions list may show only Overview, Plugins, MCP Servers, Skills, and Instructions, followed by More, which shows how many customizations the other categories contain. Activate More with Enter or Space to show those categories in place, followed by Less, and move focus to the first category shown. Activate Less to hide them again and return focus to More. The section remembers which of the two you used last. To always show every category, set {0} to all.", SESSIONS_SIDEBAR_CUSTOMIZATIONS_CATEGORIES_SETTING)); content.push(localize('sessionsChat.pickerShortcuts', "While the new-session input has focus, open the environment picker{0}, branch picker{1}, or Branch/New Worktree picker{2}. Focus Sync Changes{3}, then press Enter to synchronize. These shortcuts are available only when the corresponding control is available and expand hidden session options before focusing them.", '', '', '', '')); content.push(localize('sessionsChat.removeWorkspace', "Workspace rows offer a Remove action. Use the arrow keys to focus a workspace, then Tab to reach its actions. Removed workspaces stay hidden across reloads until you browse to that workspace and successfully start a session from the new-session composer. Removing a workspace does not delete its sessions or files.")); content.push(accessor.get(IAccessibilityService).isScreenReaderOptimized() 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 0170cba9a80a96..dec391a980a9dc 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionsChatAccessibilityHelp.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionsChatAccessibilityHelp.test.ts @@ -38,7 +38,7 @@ import { SESSION_ARCHIVE_NUDGE_SETTING } from '../../../../../workbench/contrib/ import { SessionComparisonAccessibleView, SessionsChatAccessibilityHelp } from '../../browser/sessionsChatAccessibilityHelp.js'; import { CustomizationMarketplaceConfiguration } from '../../../../../platform/customizationMarketplace/common/customizationMarketplaceSources.js'; import { SESSIONS_LIST_PROMOTE_NEW_CHAT_ACTION_DEFAULT, SessionsListPromoteNewChatActionContext } from '../../../../../workbench/contrib/sessions/common/contextkeys.js'; -import { SESSION_ACTIVITY_BAR_SETTING, SESSIONS_CHAT_TABS_SETTING, SESSIONS_LIST_GROUP_EXTERNAL_SESSIONS_SETTING, SESSIONS_LIST_SHOW_AUTOMATION_SESSIONS_SETTING, SessionsChatTabsMode } from '../../../../../workbench/contrib/sessions/common/sessionConfig.js'; +import { SESSION_ACTIVITY_BAR_SETTING, SESSIONS_CHAT_TABS_SETTING, SESSIONS_LIST_GROUP_EXTERNAL_SESSIONS_SETTING, SESSIONS_LIST_SHOW_AUTOMATION_SESSIONS_SETTING, SESSIONS_SIDEBAR_CUSTOMIZATIONS_CATEGORIES_SETTING, SessionsChatTabsMode } from '../../../../../workbench/contrib/sessions/common/sessionConfig.js'; import { CHAT_AUTOMATIONS_ENABLED_SETTING } from '../../../../../workbench/contrib/chat/common/automations/automationsEnabled.js'; import { IAgentWorkbenchLayoutService } from '../../../../browser/workbench.js'; import { AgentWorkbenchLayout, ISessionsHostLayoutService } from '../../../../../workbench/services/sessions/browser/sessionsHostLayoutService.js'; @@ -94,6 +94,27 @@ suite('SessionsChatAccessibilityHelp', () => { }, { preview: true, keyboard: true, scrolling: true, secondaryActions: true, states: true }); }); + test('documents showing and hiding the other customization categories with More and Less', () => { + const instantiation = store.add(new TestInstantiationService()); + const configuration = new TestConfigurationService(); + store.add(configuration.onDidChangeConfigurationEmitter); + instantiation.stub(IConfigurationService, configuration); + stubContextKeyService(instantiation, configuration); + instantiation.stub(ISessionsPartService, new class extends mock() { }()); + instantiation.stub(ISessionsService, new class extends mock() { }()); + instantiation.stub(IAgentHostFilterService, { selectedHost: undefined }); + instantiation.stub(IWorkbenchLayoutService, { mainContainer: mainWindow.document.createElement('div') }); + instantiation.stub(ISessionsHostLayoutService, {}); + const content = store.add(new SessionsChatAccessibilityHelp().getProvider(instantiation)).provideContent(); + + assert.deepStrictEqual({ + more: content.includes('Activate More with Enter or Space to show those categories in place, followed by Less, and move focus to the first category shown.'), + less: content.includes('Activate Less to hide them again and return focus to More.'), + remembered: content.includes('The section remembers which of the two you used last.'), + allCategories: content.includes(`To always show every category, set ${SESSIONS_SIDEBAR_CUSTOMIZATIONS_CATEGORIES_SETTING} to all.`), + }, { more: true, less: true, remembered: true, allCategories: true }); + }); + for (const { aiDisabled, hidden, htmlEnabled } of [ { aiDisabled: false, hidden: false, htmlEnabled: true }, { aiDisabled: true, hidden: false, htmlEnabled: true }, diff --git a/src/vs/workbench/contrib/sessions/browser/aiCustomizationShortcutsWidget.ts b/src/vs/workbench/contrib/sessions/browser/aiCustomizationShortcutsWidget.ts index 7865d7cb90e9c7..661db2df7c718d 100644 --- a/src/vs/workbench/contrib/sessions/browser/aiCustomizationShortcutsWidget.ts +++ b/src/vs/workbench/contrib/sessions/browser/aiCustomizationShortcutsWidget.ts @@ -7,6 +7,7 @@ import './media/sidebarActionButton.css'; import './media/customizationsToolbar.css'; import './media/disclosure.css'; import * as DOM from '../../../../base/browser/dom.js'; +import { BaseActionViewItem } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { Emitter } from '../../../../base/common/event.js'; import { Disposable, DisposableStore, IDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; @@ -245,6 +246,22 @@ export class AICustomizationShortcutsWidget extends Disposable { toolbar.focus(Math.min(index, toolbar.getItemsLength() - 1)); } } + // The updated items make the first item a tab stop even when focus stays on another one, so keep only the + // focused item's, for Tab and Shift+Tab to leave the list. + let focusedIndex = -1; + for (let index = 0; index < toolbar.getItemsLength(); index++) { + const element = toolbar.getItemElement(index); + if (element && DOM.isAncestorOfActiveElement(element)) { + focusedIndex = index; + break; + } + } + for (let index = 0; focusedIndex !== -1 && index < toolbar.getItemsLength(); index++) { + const viewItem = toolbar.getItemViewItem(index); + if (viewItem instanceof BaseActionViewItem) { + viewItem.setFocusable(index === focusedIndex); + } + } this._scrollableElement?.scanDomNode(); this._onDidChangeHeight.fire(); options?.onDidChangeLayout?.(); diff --git a/src/vs/workbench/contrib/sessions/test/browser/aiCustomizationShortcutsWidget.test.ts b/src/vs/workbench/contrib/sessions/test/browser/aiCustomizationShortcutsWidget.test.ts index 7df7b6c13cbc75..65bca4fc19ed94 100644 --- a/src/vs/workbench/contrib/sessions/test/browser/aiCustomizationShortcutsWidget.test.ts +++ b/src/vs/workbench/contrib/sessions/test/browser/aiCustomizationShortcutsWidget.test.ts @@ -220,7 +220,7 @@ suite('AI customization shortcuts header', () => { suite('AI customization shortcuts list', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); - test('keeps keyboard focus at its position when updating the items replaces the focused one, until focus leaves', async () => { + test('keeps keyboard focus and the only tab stop at its position when updating the items replaces the focused one, until focus leaves', async () => { const container = DOM.append(mainWindow.document.body, DOM.$('div')); disposables.add(toDisposable(() => container.remove())); const outside = DOM.append(mainWindow.document.body, DOM.$('button')); @@ -273,7 +273,9 @@ suite('AI customization shortcuts list', () => { labels = next; onDidChange.fire(upcastPartial({})); await Promise.resolve(); - return focused(); + // Tab and Shift+Tab move between tab stops, so they leave the list when it has only the focused one. + const tabStops = items().flatMap((item, index) => item.querySelector('.action-label')?.tabIndex === 0 ? [labels[index]] : []); + return { focused: focused(), tabStops }; }; await focus(2); @@ -288,6 +290,11 @@ suite('AI customization shortcuts list', () => { moveFocus(outside); const focusLeftToOutside = await update(['Overview', 'Skills', 'More']); - assert.deepStrictEqual({ more, less, focusLeftToNothing, focusLeftToOutside }, { more: 'Agents', less: 'More', focusLeftToNothing: 'none', focusLeftToOutside: 'outside' }); + assert.deepStrictEqual({ more, less, focusLeftToNothing, focusLeftToOutside }, { + more: { focused: 'Agents', tabStops: ['Agents'] }, + less: { focused: 'More', tabStops: ['More'] }, + focusLeftToNothing: { focused: 'none', tabStops: ['Overview'] }, + focusLeftToOutside: { focused: 'outside', tabStops: ['Overview'] }, + }); }); });