components for AI Customization shortcuts widget

This commit is contained in:
BeniBenj
2026-03-04 16:17:21 +01:00
parent 3674f5ad58
commit 75a2f31cda
5 changed files with 542 additions and 244 deletions

View File

@@ -0,0 +1,137 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import '../../../browser/media/sidebarActionButton.css';
import './media/customizationsToolbar.css';
import * as DOM from '../../../../base/browser/dom.js';
import { Codicon } from '../../../../base/common/codicons.js';
import { Disposable, MutableDisposable } from '../../../../base/common/lifecycle.js';
import { autorun } from '../../../../base/common/observable.js';
import { ThemeIcon } from '../../../../base/common/themables.js';
import { localize } from '../../../../nls.js';
import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js';
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js';
import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js';
import { Button } from '../../../../base/browser/ui/button/button.js';
import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js';
import { IPromptsService } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js';
import { IMcpService } from '../../../../workbench/contrib/mcp/common/mcpTypes.js';
import { IAICustomizationWorkspaceService } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js';
import { Menus } from '../../../browser/menus.js';
import { getCustomizationTotalCount } from './customizationCounts.js';
const $ = DOM.$;
const CUSTOMIZATIONS_COLLAPSED_KEY = 'agentSessions.customizationsCollapsed';
export interface IAICustomizationShortcutsWidgetOptions {
readonly onDidToggleCollapse?: () => void;
}
export class AICustomizationShortcutsWidget extends Disposable {
constructor(
container: HTMLElement,
options: IAICustomizationShortcutsWidgetOptions | undefined,
@IInstantiationService private readonly instantiationService: IInstantiationService,
@IStorageService private readonly storageService: IStorageService,
@IPromptsService private readonly promptsService: IPromptsService,
@IMcpService private readonly mcpService: IMcpService,
@IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService,
@IAICustomizationWorkspaceService private readonly workspaceService: IAICustomizationWorkspaceService,
) {
super();
this._render(container, options);
}
private _render(parent: HTMLElement, options: IAICustomizationShortcutsWidgetOptions | undefined): void {
// Get initial collapsed state
const isCollapsed = this.storageService.getBoolean(CUSTOMIZATIONS_COLLAPSED_KEY, StorageScope.PROFILE, false);
const container = DOM.append(parent, $('.ai-customization-toolbar'));
if (isCollapsed) {
container.classList.add('collapsed');
}
// Header (clickable to toggle)
const header = DOM.append(container, $('.ai-customization-header'));
header.classList.toggle('collapsed', isCollapsed);
const headerButtonContainer = DOM.append(header, $('.customization-link-button-container'));
const headerButton = this._register(new Button(headerButtonContainer, {
...defaultButtonStyles,
secondary: true,
title: false,
supportIcons: true,
buttonSecondaryBackground: 'transparent',
buttonSecondaryHoverBackground: undefined,
buttonSecondaryForeground: undefined,
buttonSecondaryBorder: undefined,
}));
headerButton.element.classList.add('customization-link-button', 'sidebar-action-button');
headerButton.element.setAttribute('aria-expanded', String(!isCollapsed));
headerButton.label = localize('customizations', "CUSTOMIZATIONS");
const chevronContainer = DOM.append(headerButton.element, $('span.customization-link-counts'));
const chevron = DOM.append(chevronContainer, $('.ai-customization-chevron'));
const headerTotalCount = DOM.append(chevronContainer, $('span.ai-customization-header-total.hidden'));
chevron.classList.add(...ThemeIcon.asClassNameArray(isCollapsed ? Codicon.chevronRight : Codicon.chevronDown));
// Toolbar container
const toolbarContainer = DOM.append(container, $('.ai-customization-toolbar-content.sidebar-action-list'));
this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, toolbarContainer, Menus.SidebarCustomizations, {
hiddenItemStrategy: HiddenItemStrategy.NoHide,
toolbarOptions: { primaryGroup: () => true },
telemetrySource: 'sidebarCustomizations',
}));
let updateCountRequestId = 0;
const updateHeaderTotalCount = async () => {
const requestId = ++updateCountRequestId;
const totalCount = await getCustomizationTotalCount(this.promptsService, this.mcpService, this.workspaceService, this.workspaceContextService);
if (requestId !== updateCountRequestId) {
return;
}
headerTotalCount.classList.toggle('hidden', totalCount === 0);
headerTotalCount.textContent = `${totalCount}`;
};
this._register(this.promptsService.onDidChangeCustomAgents(() => updateHeaderTotalCount()));
this._register(this.promptsService.onDidChangeSlashCommands(() => updateHeaderTotalCount()));
this._register(this.workspaceContextService.onDidChangeWorkspaceFolders(() => updateHeaderTotalCount()));
this._register(autorun(reader => {
this.mcpService.servers.read(reader);
updateHeaderTotalCount();
}));
this._register(autorun(reader => {
this.workspaceService.activeProjectRoot.read(reader);
updateHeaderTotalCount();
}));
updateHeaderTotalCount();
// Toggle collapse on header click
const transitionListener = this._register(new MutableDisposable());
const toggleCollapse = () => {
const collapsed = container.classList.toggle('collapsed');
header.classList.toggle('collapsed', collapsed);
this.storageService.store(CUSTOMIZATIONS_COLLAPSED_KEY, collapsed, StorageScope.PROFILE, StorageTarget.USER);
headerButton.element.setAttribute('aria-expanded', String(!collapsed));
chevron.classList.remove(...ThemeIcon.asClassNameArray(Codicon.chevronRight), ...ThemeIcon.asClassNameArray(Codicon.chevronDown));
chevron.classList.add(...ThemeIcon.asClassNameArray(collapsed ? Codicon.chevronRight : Codicon.chevronDown));
// Re-layout after the transition
transitionListener.value = DOM.addDisposableListener(toolbarContainer, 'transitionend', () => {
transitionListener.clear();
options?.onDidToggleCollapse?.();
});
};
this._register(headerButton.onDidClick(() => toggleCollapse()));
}
}

View File

@@ -34,7 +34,7 @@ import { getSourceCounts, getSourceCountsTotal } from './customizationCounts.js'
import { IEditorService, MODAL_GROUP } from '../../../../workbench/services/editor/common/editorService.js';
import { IAICustomizationWorkspaceService } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js';
interface ICustomizationItemConfig {
export interface ICustomizationItemConfig {
readonly id: string;
readonly label: string;
readonly icon: ThemeIcon;
@@ -43,7 +43,7 @@ interface ICustomizationItemConfig {
readonly isMcp?: boolean;
}
const CUSTOMIZATION_ITEMS: ICustomizationItemConfig[] = [
export const CUSTOMIZATION_ITEMS: ICustomizationItemConfig[] = [
{
id: 'sessions.customization.agents',
label: localize('agents', "Agents"),
@@ -92,7 +92,7 @@ const CUSTOMIZATION_ITEMS: ICustomizationItemConfig[] = [
* Custom ActionViewItem for each customization link in the toolbar.
* Renders icon + label + source count badges, matching the sidebar footer style.
*/
class CustomizationLinkViewItem extends ActionViewItem {
export class CustomizationLinkViewItem extends ActionViewItem {
private readonly _viewItemDisposables: DisposableStore;
private _button: Button | undefined;
@@ -199,7 +199,7 @@ class CustomizationLinkViewItem extends ActionViewItem {
// --- Register actions and view items --- //
class CustomizationsToolbarContribution extends Disposable implements IWorkbenchContribution {
export class CustomizationsToolbarContribution extends Disposable implements IWorkbenchContribution {
static readonly ID = 'workbench.contrib.sessionsCustomizationsToolbar';

View File

@@ -2,132 +2,129 @@
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
.agent-sessions-viewpane {
/* AI Customization section - pinned to bottom */
.ai-customization-toolbar {
display: flex;
flex-direction: column;
flex-shrink: 0;
border-top: 1px solid var(--vscode-widget-border);
padding: 6px;
}
/* Make the toolbar, action bar, and items fill full width and stack vertically */
.ai-customization-toolbar .ai-customization-toolbar-content .monaco-toolbar,
.ai-customization-toolbar .ai-customization-toolbar-content .monaco-action-bar {
width: 100%;
}
.ai-customization-toolbar .ai-customization-toolbar-content .monaco-action-bar .actions-container {
display: flex;
flex-direction: column;
width: 100%;
}
.ai-customization-toolbar .ai-customization-toolbar-content .monaco-action-bar .action-item {
width: 100%;
max-width: 100%;
}
.ai-customization-toolbar .customization-link-widget {
width: 100%;
}
/* Customization header - clickable for collapse */
.ai-customization-toolbar .ai-customization-header {
display: flex;
align-items: center;
-webkit-user-select: none;
user-select: none;
}
.ai-customization-toolbar .ai-customization-header:not(.collapsed) {
margin-bottom: 4px;
}
.ai-customization-toolbar .ai-customization-chevron {
flex-shrink: 0;
opacity: 0;
}
.ai-customization-toolbar .ai-customization-header:not(.collapsed) .customization-link-button:hover .ai-customization-chevron,
.ai-customization-toolbar .ai-customization-header:not(.collapsed) .customization-link-button:focus-within .ai-customization-chevron,
.ai-customization-toolbar .ai-customization-header.collapsed .customization-link-button:hover .ai-customization-chevron,
.ai-customization-toolbar .ai-customization-header.collapsed .customization-link-button:focus-within .ai-customization-chevron {
opacity: 0.7;
}
.ai-customization-toolbar .ai-customization-header-total {
display: none;
opacity: 0.7;
font-size: 11px;
line-height: 1;
}
.ai-customization-toolbar .ai-customization-header.collapsed .customization-link-button:not(:hover):not(:focus-within) .ai-customization-header-total:not(.hidden) {
display: inline;
}
.ai-customization-toolbar .ai-customization-header.collapsed .customization-link-button:hover .ai-customization-header-total,
.ai-customization-toolbar .ai-customization-header.collapsed .customization-link-button:focus-within .ai-customization-header-total,
.ai-customization-toolbar .ai-customization-header:not(.collapsed) .customization-link-button .ai-customization-header-total {
display: none;
}
/* Button container - fills available space */
.ai-customization-toolbar .customization-link-button-container {
overflow: hidden;
min-width: 0;
flex: 1;
}
/* Button needs relative positioning for counts overlay */
.ai-customization-toolbar .customization-link-button {
position: relative;
}
/* Counts - floating right inside the button */
.ai-customization-toolbar .customization-link-counts {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
gap: 6px;
}
.ai-customization-toolbar .customization-link-counts.hidden {
display: none;
}
.ai-customization-toolbar .source-count-badge {
display: flex;
align-items: center;
gap: 2px;
}
.ai-customization-toolbar .source-count-icon {
font-size: 12px;
opacity: 0.6;
}
.ai-customization-toolbar .source-count-num {
font-size: 11px;
color: var(--vscode-descriptionForeground);
opacity: 0.8;
}
/* Collapsed state */
.ai-customization-toolbar .ai-customization-toolbar-content {
max-height: 500px;
overflow: hidden;
transition: max-height 0.2s ease-out;
}
.ai-customization-toolbar.collapsed .ai-customization-toolbar-content {
max-height: 0;
}
/* AI Customization section - pinned to bottom */
.ai-customization-toolbar {
display: flex;
flex-direction: column;
flex-shrink: 0;
border-top: 1px solid var(--vscode-widget-border);
padding: 6px;
}
/* Make the toolbar, action bar, and items fill full width and stack vertically */
.ai-customization-toolbar .ai-customization-toolbar-content .monaco-toolbar,
.ai-customization-toolbar .ai-customization-toolbar-content .monaco-action-bar {
width: 100%;
}
.ai-customization-toolbar .ai-customization-toolbar-content .monaco-action-bar .actions-container {
display: flex;
flex-direction: column;
width: 100%;
}
.ai-customization-toolbar .ai-customization-toolbar-content .monaco-action-bar .action-item {
width: 100%;
max-width: 100%;
}
.ai-customization-toolbar .customization-link-widget {
width: 100%;
}
/* Customization header - clickable for collapse */
.ai-customization-toolbar .ai-customization-header {
display: flex;
align-items: center;
-webkit-user-select: none;
user-select: none;
}
.ai-customization-toolbar .ai-customization-header:not(.collapsed) {
margin-bottom: 4px;
}
.ai-customization-toolbar .ai-customization-chevron {
flex-shrink: 0;
opacity: 0;
}
.ai-customization-toolbar .ai-customization-header:not(.collapsed) .customization-link-button:hover .ai-customization-chevron,
.ai-customization-toolbar .ai-customization-header:not(.collapsed) .customization-link-button:focus-within .ai-customization-chevron,
.ai-customization-toolbar .ai-customization-header.collapsed .customization-link-button:hover .ai-customization-chevron,
.ai-customization-toolbar .ai-customization-header.collapsed .customization-link-button:focus-within .ai-customization-chevron {
opacity: 0.7;
}
.ai-customization-toolbar .ai-customization-header-total {
display: none;
opacity: 0.7;
font-size: 11px;
line-height: 1;
}
.ai-customization-toolbar .ai-customization-header.collapsed .customization-link-button:not(:hover):not(:focus-within) .ai-customization-header-total:not(.hidden) {
display: inline;
}
.ai-customization-toolbar .ai-customization-header.collapsed .customization-link-button:hover .ai-customization-header-total,
.ai-customization-toolbar .ai-customization-header.collapsed .customization-link-button:focus-within .ai-customization-header-total,
.ai-customization-toolbar .ai-customization-header:not(.collapsed) .customization-link-button .ai-customization-header-total {
display: none;
}
/* Button container - fills available space */
.ai-customization-toolbar .customization-link-button-container {
overflow: hidden;
min-width: 0;
flex: 1;
}
/* Button needs relative positioning for counts overlay */
.ai-customization-toolbar .customization-link-button {
position: relative;
}
/* Counts - floating right inside the button */
.ai-customization-toolbar .customization-link-counts {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
gap: 6px;
}
.ai-customization-toolbar .customization-link-counts.hidden {
display: none;
}
.ai-customization-toolbar .source-count-badge {
display: flex;
align-items: center;
gap: 2px;
}
.ai-customization-toolbar .source-count-icon {
font-size: 12px;
opacity: 0.6;
}
.ai-customization-toolbar .source-count-num {
font-size: 11px;
color: var(--vscode-descriptionForeground);
opacity: 0.8;
}
/* Collapsed state */
.ai-customization-toolbar .ai-customization-toolbar-content {
max-height: 500px;
overflow: hidden;
transition: max-height 0.2s ease-out;
padding-bottom: 2px;
}
.ai-customization-toolbar.collapsed .ai-customization-toolbar-content {
max-height: 0;
}

View File

@@ -3,15 +3,11 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import '../../../browser/media/sidebarActionButton.css';
import './media/customizationsToolbar.css';
import './media/sessionsViewPane.css';
import * as DOM from '../../../../base/browser/dom.js';
import { Codicon } from '../../../../base/common/codicons.js';
import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js';
import { MutableDisposable } from '../../../../base/common/lifecycle.js';
import { autorun } from '../../../../base/common/observable.js';
import { ThemeIcon } from '../../../../base/common/themables.js';
import { ContextKeyExpr, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';
import { EditorsVisibleContext } from '../../../../workbench/common/contextkeys.js';
import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js';
@@ -29,9 +25,6 @@ import { localize, localize2 } from '../../../../nls.js';
import { AgentSessionsControl } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsControl.js';
import { AgentSessionsFilter, AgentSessionsGrouping } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.js';
import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js';
import { IPromptsService } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js';
import { IMcpService } from '../../../../workbench/contrib/mcp/common/mcpTypes.js';
import { IAICustomizationWorkspaceService } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js';
import { ISessionsManagementService, IsNewChatSessionContext } from './sessionsManagementService.js';
import { Action2, ISubmenuItem, MenuId, MenuRegistry, registerAction2 } from '../../../../platform/actions/common/actions.js';
import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js';
@@ -40,26 +33,19 @@ import { Button } from '../../../../base/browser/ui/button/button.js';
import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js';
import { KeybindingsRegistry, KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js';
import { ACTION_ID_NEW_CHAT } from '../../../../workbench/contrib/chat/browser/actions/chatActions.js';
import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js';
import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js';
import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js';
import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js';
import { Menus } from '../../../browser/menus.js';
import { getCustomizationTotalCount } from './customizationCounts.js';
import { AICustomizationShortcutsWidget } from './aiCustomizationShortcutsWidget.js';
import { IHostService } from '../../../../workbench/services/host/browser/host.js';
const $ = DOM.$;
export const SessionsViewId = 'agentic.workbench.view.sessionsView';
const SessionsViewFilterSubMenu = new MenuId('AgentSessionsViewFilterSubMenu');
const CUSTOMIZATIONS_COLLAPSED_KEY = 'agentSessions.customizationsCollapsed';
export class AgenticSessionsViewPane extends ViewPane {
private viewPaneContainer: HTMLElement | undefined;
private sessionsControlContainer: HTMLElement | undefined;
sessionsControl: AgentSessionsControl | undefined;
private aiCustomizationContainer: HTMLElement | undefined;
constructor(
options: IViewPaneOptions,
@@ -73,13 +59,8 @@ export class AgenticSessionsViewPane extends ViewPane {
@IThemeService themeService: IThemeService,
@IHoverService hoverService: IHoverService,
@IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService,
@IStorageService private readonly storageService: IStorageService,
@IPromptsService private readonly promptsService: IPromptsService,
@IMcpService private readonly mcpService: IMcpService,
@IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService,
@ISessionsManagementService private readonly activeSessionService: ISessionsManagementService,
@IHostService private readonly hostService: IHostService,
@IAICustomizationWorkspaceService private readonly workspaceService: IAICustomizationWorkspaceService,
) {
super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService);
}
@@ -180,8 +161,14 @@ export class AgenticSessionsViewPane extends ViewPane {
}));
// AI Customization toolbar (bottom, fixed height)
this.aiCustomizationContainer = DOM.append(sessionsContainer, $('div'));
this.createAICustomizationShortcuts(this.aiCustomizationContainer);
this._register(this.instantiationService.createInstance(AICustomizationShortcutsWidget, sessionsContainer, {
onDidToggleCollapse: () => {
if (this.viewPaneContainer) {
const { offsetHeight, offsetWidth } = this.viewPaneContainer;
this.layoutBody(offsetHeight, offsetWidth);
}
},
}));
}
private restoreLastSelectedSession(): void {
@@ -191,96 +178,6 @@ export class AgenticSessionsViewPane extends ViewPane {
}
}
private createAICustomizationShortcuts(container: HTMLElement): void {
// Get initial collapsed state
const isCollapsed = this.storageService.getBoolean(CUSTOMIZATIONS_COLLAPSED_KEY, StorageScope.PROFILE, false);
container.classList.add('ai-customization-toolbar');
if (isCollapsed) {
container.classList.add('collapsed');
}
// Header (clickable to toggle)
const header = DOM.append(container, $('.ai-customization-header'));
header.classList.toggle('collapsed', isCollapsed);
const headerButtonContainer = DOM.append(header, $('.customization-link-button-container'));
const headerButton = this._register(new Button(headerButtonContainer, {
...defaultButtonStyles,
secondary: true,
title: false,
supportIcons: true,
buttonSecondaryBackground: 'transparent',
buttonSecondaryHoverBackground: undefined,
buttonSecondaryForeground: undefined,
buttonSecondaryBorder: undefined,
}));
headerButton.element.classList.add('customization-link-button', 'sidebar-action-button');
headerButton.element.setAttribute('aria-expanded', String(!isCollapsed));
headerButton.label = localize('customizations', "CUSTOMIZATIONS");
const chevronContainer = DOM.append(headerButton.element, $('span.customization-link-counts'));
const chevron = DOM.append(chevronContainer, $('.ai-customization-chevron'));
const headerTotalCount = DOM.append(chevronContainer, $('span.ai-customization-header-total.hidden'));
chevron.classList.add(...ThemeIcon.asClassNameArray(isCollapsed ? Codicon.chevronRight : Codicon.chevronDown));
// Toolbar container
const toolbarContainer = DOM.append(container, $('.ai-customization-toolbar-content.sidebar-action-list'));
this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, toolbarContainer, Menus.SidebarCustomizations, {
hiddenItemStrategy: HiddenItemStrategy.NoHide,
toolbarOptions: { primaryGroup: () => true },
telemetrySource: 'sidebarCustomizations',
}));
let updateCountRequestId = 0;
const updateHeaderTotalCount = async () => {
const requestId = ++updateCountRequestId;
const totalCount = await getCustomizationTotalCount(this.promptsService, this.mcpService, this.workspaceService, this.workspaceContextService);
if (requestId !== updateCountRequestId) {
return;
}
headerTotalCount.classList.toggle('hidden', totalCount === 0);
headerTotalCount.textContent = `${totalCount}`;
};
this._register(this.promptsService.onDidChangeCustomAgents(() => updateHeaderTotalCount()));
this._register(this.promptsService.onDidChangeSlashCommands(() => updateHeaderTotalCount()));
this._register(this.workspaceContextService.onDidChangeWorkspaceFolders(() => updateHeaderTotalCount()));
this._register(autorun(reader => {
this.mcpService.servers.read(reader);
updateHeaderTotalCount();
}));
this._register(autorun(reader => {
this.workspaceService.activeProjectRoot.read(reader);
updateHeaderTotalCount();
}));
updateHeaderTotalCount();
// Toggle collapse on header click
const transitionListener = this._register(new MutableDisposable());
const toggleCollapse = () => {
const collapsed = container.classList.toggle('collapsed');
header.classList.toggle('collapsed', collapsed);
this.storageService.store(CUSTOMIZATIONS_COLLAPSED_KEY, collapsed, StorageScope.PROFILE, StorageTarget.USER);
headerButton.element.setAttribute('aria-expanded', String(!collapsed));
chevron.classList.remove(...ThemeIcon.asClassNameArray(Codicon.chevronRight), ...ThemeIcon.asClassNameArray(Codicon.chevronDown));
chevron.classList.add(...ThemeIcon.asClassNameArray(collapsed ? Codicon.chevronRight : Codicon.chevronDown));
// Re-layout after the transition so sessions control gets the right height
transitionListener.value = DOM.addDisposableListener(toolbarContainer, 'transitionend', () => {
transitionListener.clear();
if (this.viewPaneContainer) {
const { offsetHeight, offsetWidth } = this.viewPaneContainer;
this.layoutBody(offsetHeight, offsetWidth);
}
});
};
this._register(headerButton.onDidClick(() => toggleCollapse()));
}
private getSessionHoverPosition(): HoverPosition {
const viewLocation = this.viewDescriptorService.getViewLocationById(this.id);
const sideBarPosition = this.layoutService.getSideBarPosition();

View File

@@ -0,0 +1,267 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { toAction } from '../../../../../base/common/actions.js';
import { Emitter, Event } from '../../../../../base/common/event.js';
import { DisposableStore } from '../../../../../base/common/lifecycle.js';
import { observableValue } from '../../../../../base/common/observable.js';
import { URI } from '../../../../../base/common/uri.js';
import { mock } from '../../../../../base/test/common/mock.js';
import { IActionViewItemFactory, IActionViewItemService } from '../../../../../platform/actions/browser/actionViewItemService.js';
import { IMenu, IMenuActionOptions, IMenuService, isIMenuItem, MenuId, MenuItemAction, MenuRegistry, SubmenuItemAction } from '../../../../../platform/actions/common/actions.js';
import { IStorageService, StorageScope } from '../../../../../platform/storage/common/storage.js';
import { IFileService } from '../../../../../platform/files/common/files.js';
import { IWorkspace, IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js';
import { IPromptsService, PromptsStorage } from '../../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js';
import { PromptsType } from '../../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js';
import { ILanguageModelsService } from '../../../../../workbench/contrib/chat/common/languageModels.js';
import { IMcpServer, IMcpService } from '../../../../../workbench/contrib/mcp/common/mcpTypes.js';
import { IAICustomizationWorkspaceService, IStorageSourceFilter } from '../../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js';
import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from '../../../../../workbench/test/browser/componentFixtures/fixtureUtils.js';
import { AICustomizationShortcutsWidget } from '../../browser/aiCustomizationShortcutsWidget.js';
import { CUSTOMIZATION_ITEMS, CustomizationLinkViewItem } from '../../browser/customizationsToolbar.contribution.js';
import { IActiveSessionItem, ISessionsManagementService } from '../../browser/sessionsManagementService.js';
import { Menus } from '../../../../browser/menus.js';
// Ensure color registrations are loaded
import '../../../../common/theme.js';
import '../../../../../platform/theme/common/colors/inputColors.js';
// ============================================================================
// One-time menu item registration (module-level).
// MenuRegistry.appendMenuItem does not throw on duplicates, unlike registerAction2
// which registers global commands and throws on the second call.
// ============================================================================
const menuRegistrations = new DisposableStore();
for (const [index, config] of CUSTOMIZATION_ITEMS.entries()) {
menuRegistrations.add(MenuRegistry.appendMenuItem(Menus.SidebarCustomizations, {
command: { id: config.id, title: config.label },
group: 'navigation',
order: index + 1,
}));
}
// ============================================================================
// FixtureMenuService — reads from MenuRegistry without context-key filtering
// (MockContextKeyService.contextMatchesRules always returns false, which hides
// every item when using the real MenuService.)
// ============================================================================
class FixtureMenuService implements IMenuService {
declare readonly _serviceBrand: undefined;
createMenu(id: MenuId): IMenu {
return {
onDidChange: Event.None,
dispose: () => { },
getActions: () => {
const items = MenuRegistry.getMenuItems(id).filter(isIMenuItem);
items.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
const actions = items.map(item => {
const title = typeof item.command.title === 'string' ? item.command.title : item.command.title.value;
return toAction({ id: item.command.id, label: title, run: () => { } });
});
return actions.length ? [['navigation', actions as unknown as (MenuItemAction | SubmenuItemAction)[]]] : [];
},
};
}
getMenuActions(_id: MenuId, _contextKeyService: unknown, _options?: IMenuActionOptions) { return []; }
getMenuContexts() { return new Set<string>(); }
resetHiddenStates() { }
}
// ============================================================================
// Minimal IActionViewItemService that supports register/lookUp
// ============================================================================
class FixtureActionViewItemService implements IActionViewItemService {
declare _serviceBrand: undefined;
private readonly _providers = new Map<string, IActionViewItemFactory>();
private readonly _onDidChange = new Emitter<MenuId>();
readonly onDidChange = this._onDidChange.event;
register(menu: MenuId, commandId: string | MenuId, provider: IActionViewItemFactory): { dispose(): void } {
const key = `${menu.id}/${commandId instanceof MenuId ? commandId.id : commandId}`;
this._providers.set(key, provider);
return { dispose: () => { this._providers.delete(key); } };
}
lookUp(menu: MenuId, commandId: string | MenuId): IActionViewItemFactory | undefined {
const key = `${menu.id}/${commandId instanceof MenuId ? commandId.id : commandId}`;
return this._providers.get(key);
}
}
// ============================================================================
// Mock helpers
// ============================================================================
const defaultFilter: IStorageSourceFilter = {
sources: [PromptsStorage.local, PromptsStorage.user, PromptsStorage.extension],
};
function createMockPromptsService(): IPromptsService {
return createMockPromptsServiceWithCounts();
}
interface ICustomizationCounts {
readonly agents?: number;
readonly skills?: number;
readonly instructions?: number;
readonly prompts?: number;
readonly hooks?: number;
}
function createMockPromptsServiceWithCounts(counts?: ICustomizationCounts): IPromptsService {
const fakeUri = (prefix: string, i: number) => URI.parse(`file:///mock/${prefix}-${i}.md`);
const fakeItem = (prefix: string, i: number) => ({ uri: fakeUri(prefix, i), storage: PromptsStorage.local });
const agents = Array.from({ length: counts?.agents ?? 0 }, (_, i) => ({
uri: fakeUri('agent', i),
source: { storage: PromptsStorage.local },
}));
const skills = Array.from({ length: counts?.skills ?? 0 }, (_, i) => fakeItem('skill', i));
const prompts = Array.from({ length: counts?.prompts ?? 0 }, (_, i) => ({
promptPath: { uri: fakeUri('prompt', i), storage: PromptsStorage.local, type: PromptsType.prompt },
}));
const instructions = Array.from({ length: counts?.instructions ?? 0 }, (_, i) => fakeItem('instructions', i));
const hooks = Array.from({ length: counts?.hooks ?? 0 }, (_, i) => fakeItem('hook', i));
return new class extends mock<IPromptsService>() {
override readonly onDidChangeCustomAgents = Event.None;
override readonly onDidChangeSlashCommands = Event.None;
override async getCustomAgents() { return agents as never[]; }
override async findAgentSkills() { return skills as never[]; }
override async getPromptSlashCommands() { return prompts as never[]; }
override async listPromptFiles(type: PromptsType) {
return (type === PromptsType.hook ? hooks : instructions) as never[];
}
override async listAgentInstructions() { return [] as never[]; }
}();
}
function createMockMcpService(serverCount: number = 0): IMcpService {
const MockServer = mock<IMcpServer>();
const servers = observableValue<readonly IMcpServer[]>('mockMcpServers', Array.from({ length: serverCount }, () => new MockServer()));
return new class extends mock<IMcpService>() {
override readonly servers = servers;
}();
}
function createMockWorkspaceService(): IAICustomizationWorkspaceService {
const activeProjectRoot = observableValue<URI | undefined>('mockActiveProjectRoot', undefined);
return new class extends mock<IAICustomizationWorkspaceService>() {
override readonly activeProjectRoot = activeProjectRoot;
override getActiveProjectRoot() { return undefined; }
override getStorageSourceFilter() { return defaultFilter; }
}();
}
function createMockWorkspaceContextService(): IWorkspaceContextService {
return new class extends mock<IWorkspaceContextService>() {
override readonly onDidChangeWorkspaceFolders = Event.None;
override getWorkspace(): IWorkspace { return { id: 'test', folders: [] }; }
}();
}
// ============================================================================
// Render helper
// ============================================================================
function renderWidget(ctx: ComponentFixtureContext, options?: { mcpServerCount?: number; collapsed?: boolean; counts?: ICustomizationCounts }): void {
ctx.container.style.width = '300px';
ctx.container.style.backgroundColor = 'var(--vscode-sideBar-background)';
const actionViewItemService = new FixtureActionViewItemService();
const instantiationService = createEditorServices(ctx.disposableStore, {
colorTheme: ctx.theme,
additionalServices: (reg) => {
// Register overrides BEFORE registerWorkbenchServices so they take priority
reg.defineInstance(IMenuService, new FixtureMenuService());
reg.defineInstance(IActionViewItemService, actionViewItemService);
registerWorkbenchServices(reg);
// Services needed by AICustomizationShortcutsWidget
reg.defineInstance(IPromptsService, options?.counts ? createMockPromptsServiceWithCounts(options.counts) : createMockPromptsService());
reg.defineInstance(IMcpService, createMockMcpService(options?.mcpServerCount ?? 0));
reg.defineInstance(IAICustomizationWorkspaceService, createMockWorkspaceService());
reg.defineInstance(IWorkspaceContextService, createMockWorkspaceContextService());
// Additional services needed by CustomizationLinkViewItem
reg.defineInstance(ILanguageModelsService, new class extends mock<ILanguageModelsService>() {
override readonly onDidChangeLanguageModels = Event.None;
}());
reg.defineInstance(ISessionsManagementService, new class extends mock<ISessionsManagementService>() {
override readonly activeSession = observableValue<IActiveSessionItem | undefined>('activeSession', undefined);
}());
reg.defineInstance(IFileService, new class extends mock<IFileService>() {
override readonly onDidFilesChange = Event.None;
}());
},
});
// Register view item factories from the real CustomizationLinkViewItem (per-render, instance-scoped)
for (const config of CUSTOMIZATION_ITEMS) {
ctx.disposableStore.add(actionViewItemService.register(Menus.SidebarCustomizations, config.id, (action, options) => {
return instantiationService.createInstance(CustomizationLinkViewItem, action, options, config);
}));
}
// Override storage to set initial collapsed state
if (options?.collapsed) {
const storageService = instantiationService.get(IStorageService);
instantiationService.set(IStorageService, new class extends mock<IStorageService>() {
override getBoolean(key: string, scope: StorageScope, fallbackValue?: boolean) {
if (key === 'agentSessions.customizationsCollapsed') {
return true;
}
return storageService.getBoolean(key, scope, fallbackValue!);
}
override store() { }
}());
}
// Create the widget (uses FixtureMenuService → reads MenuRegistry items registered above)
ctx.disposableStore.add(
instantiationService.createInstance(AICustomizationShortcutsWidget, ctx.container, undefined)
);
}
// ============================================================================
// Fixtures
// ============================================================================
export default defineThemedFixtureGroup({ path: 'sessions/' }, {
Expanded: defineComponentFixture({
labels: { kind: 'screenshot' },
render: (ctx) => renderWidget(ctx),
}),
Collapsed: defineComponentFixture({
labels: { kind: 'screenshot' },
render: (ctx) => renderWidget(ctx, { collapsed: true }),
}),
WithMcpServers: defineComponentFixture({
labels: { kind: 'screenshot' },
render: (ctx) => renderWidget(ctx, { mcpServerCount: 3 }),
}),
CollapsedWithMcpServers: defineComponentFixture({
labels: { kind: 'screenshot' },
render: (ctx) => renderWidget(ctx, { mcpServerCount: 3, collapsed: true }),
}),
WithCounts: defineComponentFixture({
labels: { kind: 'screenshot' },
render: (ctx) => renderWidget(ctx, {
mcpServerCount: 2,
counts: { agents: 2, skills: 30, instructions: 16, prompts: 17, hooks: 4 },
}),
}),
});