diff --git a/src/vs/sessions/browser/media/sidebarActionButton.css b/src/vs/sessions/browser/media/sidebarActionButton.css new file mode 100644 index 00000000000..f170a6ed4fd --- /dev/null +++ b/src/vs/sessions/browser/media/sidebarActionButton.css @@ -0,0 +1,49 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.sidebar-action-list .actions-container { + gap: 4px; +} + +.sidebar-action > .action-label { + /* Hide the default action-label rendered by ActionViewItem */ + display: none; +} + +/* Shared styling for interactive sidebar action buttons (account widget, customization links, etc.) */ +.sidebar-action-button { + display: flex; + align-items: center; + border: none; + padding: 4px 8px; + margin: 0; + font-size: 12px; + height: auto; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + background: transparent; + color: var(--vscode-sideBar-foreground); + width: 100%; + text-align: left; + justify-content: flex-start; + text-decoration: none; + border-radius: 4px; + cursor: pointer; + gap: 10px; + display: flex; +} + +.sidebar-action-button:hover { + background-color: var(--vscode-toolbar-hoverBackground); +} + +.sidebar-action-button.monaco-text-button:focus { + outline-offset: -1px !important; +} + +.sidebar-action-button.monaco-text-button .codicon { + margin: 0; +} diff --git a/src/vs/sessions/browser/parts/media/sidebarPart.css b/src/vs/sessions/browser/parts/media/sidebarPart.css index d8f4c728949..0162bcb26d0 100644 --- a/src/vs/sessions/browser/parts/media/sidebarPart.css +++ b/src/vs/sessions/browser/parts/media/sidebarPart.css @@ -31,18 +31,27 @@ /* Sidebar Footer Container */ .monaco-workbench .part.sidebar > .sidebar-footer { display: flex; - align-items: center; + align-items: stretch; + gap: 4px; padding: 6px; border-top: 1px solid var(--vscode-sideBarSectionHeader-border, transparent); flex-shrink: 0; } -/* Make the toolbar and its action-item fill the full footer width */ +/* Make the toolbar fill the footer width and stack actions vertically */ .monaco-workbench .part.sidebar > .sidebar-footer .monaco-toolbar, .monaco-workbench .part.sidebar > .sidebar-footer .monaco-action-bar, -.monaco-workbench .part.sidebar > .sidebar-footer .actions-container, + +.monaco-workbench .part.sidebar > .sidebar-footer .actions-container { + width: 100%; + max-width: 100%; + display: flex; + flex-direction: column; + align-items: stretch; + cursor: default; +} + .monaco-workbench .part.sidebar > .sidebar-footer .action-item { - flex: 1; width: 100%; max-width: 100%; cursor: default; diff --git a/src/vs/sessions/browser/parts/sidebarPart.ts b/src/vs/sessions/browser/parts/sidebarPart.ts index d632124f3de..f669616ce17 100644 --- a/src/vs/sessions/browser/parts/sidebarPart.ts +++ b/src/vs/sessions/browser/parts/sidebarPart.ts @@ -53,8 +53,13 @@ export class SidebarPart extends AbstractPaneCompositePart { static readonly MARGIN_TOP = 0; static readonly MARGIN_BOTTOM = 0; static readonly MARGIN_LEFT = 0; - static readonly FOOTER_HEIGHT = 39; + private static readonly FOOTER_ITEM_HEIGHT = 26; + private static readonly FOOTER_ITEM_GAP = 4; + private static readonly FOOTER_VERTICAL_PADDING = 6; + private footerContainer: HTMLElement | undefined; + private footerToolbar: MenuWorkbenchToolBar | undefined; + private previousLayoutDimensions: { width: number; height: number; top: number; left: number } | undefined; //#region IView @@ -167,13 +172,41 @@ export class SidebarPart extends AbstractPaneCompositePart { } private createFooter(parent: HTMLElement): void { - const footer = append(parent, $('.sidebar-footer')); + const footer = append(parent, $('.sidebar-footer.sidebar-action-list')); + this.footerContainer = footer; - this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, footer, Menus.SidebarFooter, { + this.footerToolbar = this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, footer, Menus.SidebarFooter, { hiddenItemStrategy: HiddenItemStrategy.NoHide, toolbarOptions: { primaryGroup: () => true }, telemetrySource: 'sidebarFooter', })); + + this._register(this.footerToolbar.onDidChangeMenuItems(() => { + if (this.previousLayoutDimensions) { + const { width, height, top, left } = this.previousLayoutDimensions; + this.layout(width, height, top, left); + } + })); + } + + private getFooterHeight(): number { + const actionCount = this.footerToolbar?.getItemsLength() ?? 0; + if (actionCount === 0) { + return 0; + } + + return SidebarPart.FOOTER_VERTICAL_PADDING * 2 + + (actionCount * SidebarPart.FOOTER_ITEM_HEIGHT) + + ((actionCount - 1) * SidebarPart.FOOTER_ITEM_GAP); + } + + private updateFooterVisibility(): void { + const footer = this.footerContainer; + if (!footer) { + return; + } + + footer.style.display = this.getFooterHeight() > 0 ? '' : 'none'; } override updateStyles(): void { @@ -193,14 +226,19 @@ export class SidebarPart extends AbstractPaneCompositePart { } override layout(width: number, height: number, top: number, left: number): void { + this.previousLayoutDimensions = { width, height, top, left }; + if (!this.layoutService.isVisible(Parts.SIDEBAR_PART)) { return; } + this.updateFooterVisibility(); + const footerHeight = Math.min(height, this.getFooterHeight()); + // Layout content with reduced height to account for footer super.layout( width, - height - SidebarPart.FOOTER_HEIGHT, + height - footerHeight, top, left ); diff --git a/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts b/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts index fc9b4c7aedc..d19bc8ff3ae 100644 --- a/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts +++ b/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import '../../../browser/media/sidebarActionButton.css'; import './media/accountWidget.css'; import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; import { localize, localize2 } from '../../../../nls.js'; @@ -11,7 +12,7 @@ import { ContextKeyExpr, IContextKeyService } from '../../../../platform/context import { IDefaultAccountService } from '../../../../platform/defaultAccount/common/defaultAccount.js'; import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; -import { appendUpdateMenuItems as registerUpdateMenuItems } from '../../../../workbench/contrib/update/browser/update.js'; +import { appendUpdateMenuItems as registerUpdateMenuItems, CONTEXT_UPDATE_STATE } from '../../../../workbench/contrib/update/browser/update.js'; import { Menus } from '../../../browser/menus.js'; import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; @@ -84,7 +85,6 @@ registerUpdateMenuItems(AccountMenu, '3_updates'); class AccountWidget extends ActionViewItem { private accountButton: Button | undefined; - private updateButton: Button | undefined; private readonly viewItemDisposables = this._register(new DisposableStore()); constructor( @@ -94,14 +94,17 @@ class AccountWidget extends ActionViewItem { @IContextMenuService private readonly contextMenuService: IContextMenuService, @IMenuService private readonly menuService: IMenuService, @IContextKeyService private readonly contextKeyService: IContextKeyService, - @IUpdateService private readonly updateService: IUpdateService, ) { super(undefined, action, { ...options, icon: false, label: false }); } + protected override getTooltip(): string | undefined { + return undefined; + } + override render(container: HTMLElement): void { super.render(container); - container.classList.add('account-widget'); + container.classList.add('account-widget', 'sidebar-action'); // Account button (left) const accountContainer = append(container, $('.account-widget-account')); @@ -115,7 +118,7 @@ class AccountWidget extends ActionViewItem { buttonSecondaryForeground: undefined, buttonSecondaryBorder: undefined, })); - this.accountButton.element.classList.add('account-widget-account-button'); + this.accountButton.element.classList.add('account-widget-account-button', 'sidebar-action-button'); this.updateAccountButton(); this.viewItemDisposables.add(this.defaultAccountService.onDidChangeDefaultAccount(() => this.updateAccountButton())); @@ -125,38 +128,6 @@ class AccountWidget extends ActionViewItem { e?.stopPropagation(); this.showAccountMenu(this.accountButton!.element); })); - - // Update button (shown for progress and restart-to-update states) - const updateContainer = append(container, $('.account-widget-update')); - this.updateButton = this.viewItemDisposables.add(new Button(updateContainer, { - ...defaultButtonStyles, - secondary: true, - title: false, - supportIcons: true, - buttonSecondaryBackground: 'transparent', - buttonSecondaryHoverBackground: undefined, - buttonSecondaryForeground: undefined, - buttonSecondaryBorder: undefined, - })); - this.updateButton.element.classList.add('account-widget-update-button'); - this.updateButton.label = `$(${Codicon.debugRestart.id}) ${localize('update', "Update")}`; - this.viewItemDisposables.add(this.updateButton.onDidClick(() => this.update())); - - this.updateUpdateButton(); - this.viewItemDisposables.add(this.updateService.onStateChange(() => this.updateUpdateButton())); - } - - private isUpdateAvailable(): boolean { - return this.updateService.state.type === StateType.Ready; - } - - private isUpdateInProgress(): boolean { - const type = this.updateService.state.type; - return type === StateType.CheckingForUpdates - || type === StateType.Downloading - || type === StateType.Downloaded - || type === StateType.Updating - || type === StateType.Overwriting; } private showAccountMenu(anchor: HTMLElement): void { @@ -165,21 +136,12 @@ class AccountWidget extends ActionViewItem { fillInActionBarActions(menu.getActions(), actions); menu.dispose(); - if (this.isUpdateAvailable()) { - // Update button visible: open above the button - this.contextMenuService.showContextMenu({ - getAnchor: () => anchor, - getActions: () => actions, - anchorAlignment: AnchorAlignment.LEFT, - }); - } else { - // No update button: open to the right of the button - const rect = anchor.getBoundingClientRect(); - this.contextMenuService.showContextMenu({ - getAnchor: () => ({ x: rect.right, y: rect.top }), - getActions: () => actions, - }); - } + const rect = anchor.getBoundingClientRect(); + this.contextMenuService.showContextMenu({ + getAnchor: () => ({ x: rect.right, y: rect.top }), + getActions: () => actions, + anchorAlignment: AnchorAlignment.LEFT, + }); } private async updateAccountButton(): Promise { @@ -195,22 +157,77 @@ class AccountWidget extends ActionViewItem { : `$(${Codicon.account.id}) ${localize('signInLabel', "Sign In")}`; } + + override onClick(): void { + // Handled by custom click handlers + } +} + +class UpdateWidget extends ActionViewItem { + + private updateButton: Button | undefined; + private readonly viewItemDisposables = this._register(new DisposableStore()); + + constructor( + action: IAction, + options: IBaseActionViewItemOptions, + @IUpdateService private readonly updateService: IUpdateService, + ) { + super(undefined, action, { ...options, icon: false, label: false }); + } + + protected override getTooltip(): string | undefined { + return undefined; + } + + override render(container: HTMLElement): void { + super.render(container); + container.classList.add('update-widget', 'sidebar-action'); + + const updateContainer = append(container, $('.update-widget-action')); + this.updateButton = this.viewItemDisposables.add(new Button(updateContainer, { + ...defaultButtonStyles, + secondary: true, + title: false, + supportIcons: true, + buttonSecondaryBackground: 'transparent', + buttonSecondaryHoverBackground: undefined, + buttonSecondaryForeground: undefined, + buttonSecondaryBorder: undefined, + })); + this.updateButton.element.classList.add('update-widget-button', 'sidebar-action-button'); + this.viewItemDisposables.add(this.updateButton.onDidClick(() => this.update())); + + this.updateUpdateButton(); + this.viewItemDisposables.add(this.updateService.onStateChange(() => this.updateUpdateButton())); + } + + private isUpdateReady(): boolean { + return this.updateService.state.type === StateType.Ready; + } + + private isUpdatePending(): boolean { + const type = this.updateService.state.type; + return type === StateType.AvailableForDownload + || type === StateType.CheckingForUpdates + || type === StateType.Downloading + || type === StateType.Downloaded + || type === StateType.Updating + || type === StateType.Overwriting; + } + private updateUpdateButton(): void { if (!this.updateButton) { return; } const state = this.updateService.state; - if (this.isUpdateInProgress()) { - this.updateButton.element.parentElement!.style.display = ''; + if (this.isUpdatePending() && !this.isUpdateReady()) { this.updateButton.enabled = false; this.updateButton.label = `$(${Codicon.loading.id}~spin) ${this.getUpdateProgressMessage(state.type)}`; - } else if (this.isUpdateAvailable()) { - this.updateButton.element.parentElement!.style.display = ''; + } else { this.updateButton.enabled = true; this.updateButton.label = `$(${Codicon.debugRestart.id}) ${localize('update', "Update")}`; - } else { - this.updateButton.element.parentElement!.style.display = 'none'; } } @@ -257,6 +274,11 @@ class AccountWidgetContribution extends Disposable implements IWorkbenchContribu return instantiationService.createInstance(AccountWidget, action, options); }, undefined)); + const sessionsUpdateWidgetAction = 'sessions.action.updateWidget'; + this._register(actionViewItemService.register(Menus.SidebarFooter, sessionsUpdateWidgetAction, (action, options) => { + return instantiationService.createInstance(UpdateWidget, action, options); + }, undefined)); + // Register the action with menu item after the view item provider // so the toolbar picks up the custom widget this._register(registerAction2(class extends Action2 { @@ -275,6 +297,32 @@ class AccountWidgetContribution extends Disposable implements IWorkbenchContribu // Handled by the custom view item } })); + + this._register(registerAction2(class extends Action2 { + constructor() { + super({ + id: sessionsUpdateWidgetAction, + title: localize2('sessionsUpdateWidget', 'Sessions Update'), + menu: { + id: Menus.SidebarFooter, + group: 'navigation', + order: 0, + when: ContextKeyExpr.or( + CONTEXT_UPDATE_STATE.isEqualTo(StateType.Ready), + CONTEXT_UPDATE_STATE.isEqualTo(StateType.AvailableForDownload), + CONTEXT_UPDATE_STATE.isEqualTo(StateType.CheckingForUpdates), + CONTEXT_UPDATE_STATE.isEqualTo(StateType.Downloading), + CONTEXT_UPDATE_STATE.isEqualTo(StateType.Downloaded), + CONTEXT_UPDATE_STATE.isEqualTo(StateType.Updating), + CONTEXT_UPDATE_STATE.isEqualTo(StateType.Overwriting), + ) + } + }); + } + async run(): Promise { + // Handled by the custom view item + } + })); } } diff --git a/src/vs/sessions/contrib/accountMenu/browser/media/accountWidget.css b/src/vs/sessions/contrib/accountMenu/browser/media/accountWidget.css index ad72846d5c5..01bdd2c100b 100644 --- a/src/vs/sessions/contrib/accountMenu/browser/media/accountWidget.css +++ b/src/vs/sessions/contrib/accountMenu/browser/media/accountWidget.css @@ -3,19 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -/* Account Widget */ -.monaco-workbench .part.sidebar > .sidebar-footer .account-widget > .action-label { - display: none; -} - -.monaco-workbench .part.sidebar > .sidebar-footer .account-widget { - display: flex; - align-items: center; - justify-content: space-between; - width: 100%; - gap: 8px; -} - /* Account Button */ .monaco-workbench .part.sidebar > .sidebar-footer .account-widget-account { overflow: hidden; @@ -23,49 +10,9 @@ flex: 1; } -.monaco-workbench .part.sidebar > .sidebar-footer .account-widget-account-button { - border: none; - padding: 4px 8px; - font-size: 12px; - height: auto; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - background: transparent; - color: var(--vscode-sideBar-foreground); - width: 100%; - text-align: left; - justify-content: flex-start; - border-radius: 4px; -} - -.monaco-workbench .part.sidebar > .sidebar-footer .account-widget-account-button:hover { - background-color: var(--vscode-toolbar-hoverBackground); -} - /* Update Button */ -.monaco-workbench .part.sidebar > .sidebar-footer .account-widget-update { +.monaco-workbench .part.sidebar > .sidebar-footer .update-widget-action { overflow: hidden; min-width: 0; - flex-shrink: 1; -} - -.monaco-workbench .part.sidebar > .sidebar-footer .account-widget-update-button { - border: none; - padding: 4px 8px; - font-size: 12px; - height: auto; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - background: transparent; - color: var(--vscode-sideBar-foreground); - width: 100%; - text-align: left; - justify-content: flex-start; - border-radius: 4px; -} - -.monaco-workbench .part.sidebar > .sidebar-footer .account-widget-update-button:hover:not(:disabled) { - background-color: var(--vscode-toolbar-hoverBackground); + flex: 1; } diff --git a/src/vs/sessions/contrib/aiCustomizationManagement/browser/media/aiCustomizationManagement.css b/src/vs/sessions/contrib/aiCustomizationManagement/browser/media/aiCustomizationManagement.css index a0a97bea3bf..4954a2a3fd9 100644 --- a/src/vs/sessions/contrib/aiCustomizationManagement/browser/media/aiCustomizationManagement.css +++ b/src/vs/sessions/contrib/aiCustomizationManagement/browser/media/aiCustomizationManagement.css @@ -38,11 +38,11 @@ .ai-customization-management-editor .section-list-item { display: flex; align-items: center; - padding: 8px 16px; + padding: 4px 8px; gap: 10px; cursor: pointer; - margin: 2px 6px; - border-radius: 6px; + margin: 1px 6px; + border-radius: 4px; transition: background-color 0.1s ease, opacity 0.1s ease; } @@ -79,7 +79,7 @@ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - font-size: 13px; + font-size: 12px; font-weight: 400; } diff --git a/src/vs/sessions/contrib/sessions/browser/customizationCounts.ts b/src/vs/sessions/contrib/sessions/browser/customizationCounts.ts new file mode 100644 index 00000000000..dd874c3e86c --- /dev/null +++ b/src/vs/sessions/contrib/sessions/browser/customizationCounts.ts @@ -0,0 +1,61 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; +import { IPromptsService, PromptsStorage } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; +import { IMcpService } from '../../../../workbench/contrib/mcp/common/mcpTypes.js'; + +export interface ISourceCounts { + readonly workspace: number; + readonly user: number; + readonly extension: number; +} + +export function getSourceCountsTotal(counts: ISourceCounts): number { + return counts.workspace + counts.user + counts.extension; +} + +export async function getPromptSourceCounts(promptsService: IPromptsService, promptType: PromptsType): Promise { + const [workspaceItems, userItems, extensionItems] = await Promise.all([ + promptsService.listPromptFilesForStorage(promptType, PromptsStorage.local, CancellationToken.None), + promptsService.listPromptFilesForStorage(promptType, PromptsStorage.user, CancellationToken.None), + promptsService.listPromptFilesForStorage(promptType, PromptsStorage.extension, CancellationToken.None), + ]); + return { + workspace: workspaceItems.length, + user: userItems.length, + extension: extensionItems.length, + }; +} + +export async function getSkillSourceCounts(promptsService: IPromptsService): Promise { + const skills = await promptsService.findAgentSkills(CancellationToken.None); + if (!skills || skills.length === 0) { + return { workspace: 0, user: 0, extension: 0 }; + } + return { + workspace: skills.filter(s => s.storage === PromptsStorage.local).length, + user: skills.filter(s => s.storage === PromptsStorage.user).length, + extension: skills.filter(s => s.storage === PromptsStorage.extension).length, + }; +} + +export async function getCustomizationTotalCount(promptsService: IPromptsService, mcpService: IMcpService): Promise { + const [agentCounts, skillCounts, instructionCounts, promptCounts, hookCounts] = await Promise.all([ + getPromptSourceCounts(promptsService, PromptsType.agent), + getSkillSourceCounts(promptsService), + getPromptSourceCounts(promptsService, PromptsType.instructions), + getPromptSourceCounts(promptsService, PromptsType.prompt), + getPromptSourceCounts(promptsService, PromptsType.hook), + ]); + + return getSourceCountsTotal(agentCounts) + + getSourceCountsTotal(skillCounts) + + getSourceCountsTotal(instructionCounts) + + getSourceCountsTotal(promptCounts) + + getSourceCountsTotal(hookCounts) + + mcpService.servers.get().length; +} diff --git a/src/vs/sessions/contrib/sessions/browser/customizationsToolbar.contribution.ts b/src/vs/sessions/contrib/sessions/browser/customizationsToolbar.contribution.ts new file mode 100644 index 00000000000..2d14ab2f632 --- /dev/null +++ b/src/vs/sessions/contrib/sessions/browser/customizationsToolbar.contribution.ts @@ -0,0 +1,265 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Codicon } from '../../../../base/common/codicons.js'; +import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { localize, localize2 } from '../../../../nls.js'; +import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; +import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { IEditorGroupsService } from '../../../../workbench/services/editor/common/editorGroupsService.js'; +import { AICustomizationManagementEditor } from '../../aiCustomizationManagement/browser/aiCustomizationManagementEditor.js'; +import { AICustomizationManagementSection } from '../../aiCustomizationManagement/browser/aiCustomizationManagement.js'; +import { AICustomizationManagementEditorInput } from '../../aiCustomizationManagement/browser/aiCustomizationManagementEditorInput.js'; +import { IPromptsService } 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 { IMcpService } from '../../../../workbench/contrib/mcp/common/mcpTypes.js'; +import { Menus } from '../../../browser/menus.js'; +import { agentIcon, instructionsIcon, promptIcon, skillIcon, hookIcon, workspaceIcon, userIcon, extensionIcon } from '../../aiCustomizationTreeView/browser/aiCustomizationTreeViewIcons.js'; +import { ActionViewItem, 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 { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; +import { ISessionsManagementService } from './sessionsManagementService.js'; +import { Button } from '../../../../base/browser/ui/button/button.js'; +import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js'; +import { getPromptSourceCounts, getSkillSourceCounts, getSourceCountsTotal, ISourceCounts } from './customizationCounts.js'; + +interface ICustomizationItemConfig { + readonly id: string; + readonly label: string; + readonly icon: ThemeIcon; + readonly section: AICustomizationManagementSection; + readonly getSourceCounts?: (promptsService: IPromptsService) => Promise; + readonly getCount?: (languageModelsService: ILanguageModelsService, mcpService: IMcpService) => Promise; +} + +const CUSTOMIZATION_ITEMS: ICustomizationItemConfig[] = [ + { + id: 'sessions.customization.agents', + label: localize('agents', "Agents"), + icon: agentIcon, + section: AICustomizationManagementSection.Agents, + getSourceCounts: (ps) => getPromptSourceCounts(ps, PromptsType.agent), + }, + { + id: 'sessions.customization.skills', + label: localize('skills', "Skills"), + icon: skillIcon, + section: AICustomizationManagementSection.Skills, + getSourceCounts: (ps) => getSkillSourceCounts(ps), + }, + { + id: 'sessions.customization.instructions', + label: localize('instructions', "Instructions"), + icon: instructionsIcon, + section: AICustomizationManagementSection.Instructions, + getSourceCounts: (ps) => getPromptSourceCounts(ps, PromptsType.instructions), + }, + { + id: 'sessions.customization.prompts', + label: localize('prompts', "Prompts"), + icon: promptIcon, + section: AICustomizationManagementSection.Prompts, + getSourceCounts: (ps) => getPromptSourceCounts(ps, PromptsType.prompt), + }, + { + id: 'sessions.customization.hooks', + label: localize('hooks', "Hooks"), + icon: hookIcon, + section: AICustomizationManagementSection.Hooks, + getSourceCounts: (ps) => getPromptSourceCounts(ps, PromptsType.hook), + }, + { + id: 'sessions.customization.mcpServers', + label: localize('mcpServers', "MCP Servers"), + icon: Codicon.server, + section: AICustomizationManagementSection.McpServers, + getCount: (_lm, mcp) => Promise.resolve(mcp.servers.get().length), + }, + { + id: 'sessions.customization.models', + label: localize('models', "Models"), + icon: Codicon.vm, + section: AICustomizationManagementSection.Models, + getCount: (lm) => Promise.resolve(lm.getLanguageModelIds().length), + }, +]; + +/** + * Custom ActionViewItem for each customization link in the toolbar. + * Renders icon + label + source count badges, matching the sidebar footer style. + */ +class CustomizationLinkViewItem extends ActionViewItem { + + private readonly _viewItemDisposables: DisposableStore; + private _button: Button | undefined; + private _countContainer: HTMLElement | undefined; + + constructor( + action: IAction, + options: IBaseActionViewItemOptions, + private readonly _config: ICustomizationItemConfig, + @IPromptsService private readonly _promptsService: IPromptsService, + @ILanguageModelsService private readonly _languageModelsService: ILanguageModelsService, + @IMcpService private readonly _mcpService: IMcpService, + @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, + @ISessionsManagementService private readonly _activeSessionService: ISessionsManagementService, + ) { + super(undefined, action, { ...options, icon: false, label: false }); + this._viewItemDisposables = this._register(new DisposableStore()); + } + + protected override getTooltip(): string | undefined { + return undefined; + } + + override render(container: HTMLElement): void { + super.render(container); + container.classList.add('customization-link-widget', 'sidebar-action'); + + // Button (left) - uses supportIcons to render codicon in label + const buttonContainer = append(container, $('.customization-link-button-container')); + this._button = this._viewItemDisposables.add(new Button(buttonContainer, { + ...defaultButtonStyles, + secondary: true, + title: false, + supportIcons: true, + buttonSecondaryBackground: 'transparent', + buttonSecondaryHoverBackground: undefined, + buttonSecondaryForeground: undefined, + buttonSecondaryBorder: undefined, + })); + this._button.element.classList.add('customization-link-button', 'sidebar-action-button'); + this._button.label = `$(${this._config.icon.id}) ${this._config.label}`; + + this._viewItemDisposables.add(this._button.onDidClick(() => { + this._action.run(); + })); + + // Count container (inside button, floating right) + this._countContainer = append(this._button.element, $('span.customization-link-counts')); + + // Subscribe to changes + this._viewItemDisposables.add(this._promptsService.onDidChangeCustomAgents(() => this._updateCounts())); + this._viewItemDisposables.add(this._promptsService.onDidChangeSlashCommands(() => this._updateCounts())); + this._viewItemDisposables.add(this._languageModelsService.onDidChangeLanguageModels(() => this._updateCounts())); + this._viewItemDisposables.add(autorun(reader => { + this._mcpService.servers.read(reader); + this._updateCounts(); + })); + this._viewItemDisposables.add(this._workspaceContextService.onDidChangeWorkspaceFolders(() => this._updateCounts())); + this._viewItemDisposables.add(autorun(reader => { + this._activeSessionService.activeSession.read(reader); + this._updateCounts(); + })); + + // Initial count + this._updateCounts(); + } + + private async _updateCounts(): Promise { + if (!this._countContainer) { + return; + } + + if (this._config.getSourceCounts) { + const counts = await this._config.getSourceCounts(this._promptsService); + this._renderSourceCounts(this._countContainer, counts); + } else if (this._config.getCount) { + const count = await this._config.getCount(this._languageModelsService, this._mcpService); + this._renderSimpleCount(this._countContainer, count); + } + } + + private _renderSourceCounts(container: HTMLElement, counts: ISourceCounts): void { + container.textContent = ''; + const total = getSourceCountsTotal(counts); + container.classList.toggle('hidden', total === 0); + if (total === 0) { + return; + } + + const sources: { count: number; icon: ThemeIcon; title: string }[] = [ + { count: counts.workspace, icon: workspaceIcon, title: localize('workspaceCount', "{0} from workspace", counts.workspace) }, + { count: counts.user, icon: userIcon, title: localize('userCount', "{0} from user", counts.user) }, + { count: counts.extension, icon: extensionIcon, title: localize('extensionCount', "{0} from extensions", counts.extension) }, + ]; + + for (const source of sources) { + if (source.count === 0) { + continue; + } + const badge = append(container, $('span.source-count-badge')); + badge.title = source.title; + const icon = append(badge, $('span.source-count-icon')); + icon.classList.add(...ThemeIcon.asClassNameArray(source.icon)); + const num = append(badge, $('span.source-count-num')); + num.textContent = `${source.count}`; + } + } + + private _renderSimpleCount(container: HTMLElement, count: number): void { + container.textContent = ''; + container.classList.toggle('hidden', count === 0); + if (count > 0) { + const badge = append(container, $('span.source-count-badge')); + const num = append(badge, $('span.source-count-num')); + num.textContent = `${count}`; + } + } +} + +// --- Register actions and view items --- // + +class CustomizationsToolbarContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.sessionsCustomizationsToolbar'; + + constructor( + @IActionViewItemService actionViewItemService: IActionViewItemService, + @IInstantiationService instantiationService: IInstantiationService, + ) { + super(); + + for (const [index, config] of CUSTOMIZATION_ITEMS.entries()) { + // Register the custom ActionViewItem for this action + this._register(actionViewItemService.register(Menus.SidebarCustomizations, config.id, (action, options) => { + return instantiationService.createInstance(CustomizationLinkViewItem, action, options, config); + }, undefined)); + + // Register the action with menu item + this._register(registerAction2(class extends Action2 { + constructor() { + super({ + id: config.id, + title: localize2('customizationAction', '{0}', config.label), + menu: { + id: Menus.SidebarCustomizations, + group: 'navigation', + order: index + 1, + } + }); + } + async run(accessor: ServicesAccessor): Promise { + const editorGroupsService = accessor.get(IEditorGroupsService); + const input = AICustomizationManagementEditorInput.getOrCreate(); + const editor = await editorGroupsService.activeGroup.openEditor(input, { pinned: true }); + if (editor instanceof AICustomizationManagementEditor) { + editor.selectSectionById(config.section); + } + } + })); + } + } +} + +registerWorkbenchContribution2(CustomizationsToolbarContribution.ID, CustomizationsToolbarContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/sessions/contrib/sessions/browser/media/customizationsToolbar.css b/src/vs/sessions/contrib/sessions/browser/media/customizationsToolbar.css new file mode 100644 index 00000000000..d671775dbd5 --- /dev/null +++ b/src/vs/sessions/contrib/sessions/browser/media/customizationsToolbar.css @@ -0,0 +1,133 @@ +/*--------------------------------------------------------------------------------------------- + * 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; + } +} diff --git a/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css b/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css index 4b37cbc4323..1666be70127 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css +++ b/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css @@ -13,7 +13,6 @@ } /* Section headers - more prominent than time-based groupings */ - .ai-customization-header, .agent-sessions-header { font-size: 11px; font-weight: 500; @@ -23,130 +22,17 @@ letter-spacing: 0.05em; } - /* Customization header - clickable for collapse */ - .ai-customization-header { - display: flex; - align-items: center; - cursor: pointer; - user-select: none; - margin: 0 6px; - border-radius: 6px; - } - - .ai-customization-header:hover { - background-color: var(--vscode-list-hoverBackground); - } - - .ai-customization-header:focus { - outline: 1px solid var(--vscode-focusBorder); - outline-offset: -1px; - } - - .ai-customization-chevron, .agent-sessions-chevron { flex-shrink: 0; - margin-left: auto; - padding-right: 4px; opacity: 0; transition: opacity 0.1s ease-in-out; } - .ai-customization-header:hover .ai-customization-chevron, - .ai-customization-header:focus .ai-customization-chevron, .agent-sessions-header:hover .agent-sessions-chevron, .agent-sessions-header:focus .agent-sessions-chevron { opacity: 0.7; } - /* AI Customization section - pinned to bottom */ - .ai-customization-shortcuts { - display: flex; - flex-direction: column; - flex-shrink: 0; - border-top: 1px solid var(--vscode-widget-border); - margin-top: 8px; - padding-top: 4px; - padding-bottom: 8px; - } - - .ai-customization-shortcuts .ai-customization-links { - display: flex; - flex-direction: column; - max-height: 500px; - overflow: hidden; - transition: max-height 0.2s ease-out; - } - - .ai-customization-shortcuts .ai-customization-links.collapsed { - max-height: 0; - } - - .ai-customization-shortcuts .ai-customization-link { - display: flex; - align-items: center; - gap: 10px; - font-size: 13px; - color: var(--vscode-foreground); - cursor: pointer; - text-decoration: none; - padding: 6px 14px; - margin: 0 6px; - line-height: 22px; - border-radius: 6px; - } - - .ai-customization-shortcuts .ai-customization-link:hover { - background-color: var(--vscode-list-hoverBackground); - } - - .ai-customization-shortcuts .ai-customization-link:focus { - outline: 1px solid var(--vscode-focusBorder); - outline-offset: -1px; - } - - .ai-customization-shortcuts .ai-customization-link .link-icon { - flex-shrink: 0; - width: 16px; - height: 16px; - display: flex; - align-items: center; - justify-content: center; - opacity: 0.85; - } - - .ai-customization-shortcuts .ai-customization-link .link-label { - flex: 1; - } - - .ai-customization-shortcuts .ai-customization-link .link-counts { - flex-shrink: 0; - display: flex; - align-items: center; - gap: 6px; - margin-left: auto; - } - - .ai-customization-shortcuts .ai-customization-link .link-counts.hidden { - display: none; - } - - .ai-customization-shortcuts .ai-customization-link .source-count-badge { - display: flex; - align-items: center; - gap: 2px; - } - - .ai-customization-shortcuts .ai-customization-link .source-count-icon { - font-size: 12px; - opacity: 0.6; - } - - .ai-customization-shortcuts .ai-customization-link .source-count-num { - font-size: 11px; - color: var(--vscode-descriptionForeground); - opacity: 0.8; - } - /* Sessions section - fills remaining space above customizations */ .agent-sessions-section { display: flex; @@ -162,6 +48,7 @@ gap: 4px; padding-top: 10px; padding-right: 12px; + -webkit-user-select: none; user-select: none; } diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsAuxiliaryBarContribution.ts b/src/vs/sessions/contrib/sessions/browser/sessionsAuxiliaryBarContribution.ts index bcadacb70d5..d8618d36a1b 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsAuxiliaryBarContribution.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsAuxiliaryBarContribution.ts @@ -3,53 +3,106 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { autorun } from '../../../../base/common/observable.js'; -import { Disposable, IDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { autorun, derivedOpts } from '../../../../base/common/observable.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { ResourceMap } from '../../../../base/common/map.js'; +import { isEqual } from '../../../../base/common/resources.js'; +import { URI } from '../../../../base/common/uri.js'; +import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; +import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; +import { IChatService } from '../../../../workbench/contrib/chat/common/chatService/chatService.js'; +import { IChatEditingService } from '../../../../workbench/contrib/chat/common/editing/chatEditingService.js'; +import { getChatSessionType } from '../../../../workbench/contrib/chat/common/model/chatUri.js'; import { IWorkbenchLayoutService, Parts } from '../../../../workbench/services/layout/browser/layoutService.js'; -import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; -import { CHANGES_VIEW_ID, ChangesViewPane } from '../../changesView/browser/changesView.js'; +import { ISessionsManagementService } from './sessionsManagementService.js'; + +interface IPendingTurnState { + readonly hadChangesBeforeSend: boolean; + readonly submittedAt: number; +} export class SessionsAuxiliaryBarContribution extends Disposable { static readonly ID = 'workbench.contrib.sessionsAuxiliaryBarContribution'; - private readonly activeChangesListener = this._register(new MutableDisposable()); - private activeChangesView: ChangesViewPane | null = null; + private readonly pendingTurnStateByResource = new ResourceMap(); constructor( - @IViewsService private readonly viewsService: IViewsService, @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, + @ISessionsManagementService private readonly sessionManagementService: ISessionsManagementService, + @IChatEditingService private readonly chatEditingService: IChatEditingService, + @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, + @IChatService private readonly chatService: IChatService, ) { super(); - this.tryBindToChangesView(); + const activeSessionResourceObs = derivedOpts({ + equalsFn: isEqual, + }, (reader) => { + return this.sessionManagementService.activeSession.map(activeSession => activeSession?.resource).read(reader); + }).recomputeInitiallyAndOnChange(this._store); - this._register(this.viewsService.onDidChangeViewVisibility(e => { - if (e.id !== CHANGES_VIEW_ID) { + this._register(this.chatService.onDidSubmitRequest(({ chatSessionResource }) => { + this.pendingTurnStateByResource.set(chatSessionResource, { + hadChangesBeforeSend: this.hasSessionChanges(chatSessionResource), + submittedAt: Date.now(), + }); + })); + + // When a turn is completed, check if there were changes before the turn and if there are changes after the turn. + // If there were no changes before the turn and there are changes after the turn, show the auxiliary bar. + this._register(autorun((reader) => { + const activeSessionResource = activeSessionResourceObs.read(reader); + if (!activeSessionResource) { return; } - this.tryBindToChangesView(); + const pendingTurnState = this.pendingTurnStateByResource.get(activeSessionResource); + if (!pendingTurnState) { + return; + } + + const activeSession = this.agentSessionsService.getSession(activeSessionResource); + const turnCompleted = !!activeSession?.timing.lastRequestEnded && activeSession.timing.lastRequestEnded >= pendingTurnState.submittedAt; + if (!turnCompleted) { + return; + } + + const hasChangesAfterTurn = this.hasSessionChanges(activeSessionResource); + if (!pendingTurnState.hadChangesBeforeSend && hasChangesAfterTurn) { + this.layoutService.setPartHidden(false, Parts.AUXILIARYBAR_PART); + } + + this.pendingTurnStateByResource.delete(activeSessionResource); + })); + + // When the session is switched, show the auxiliary bar if there are pending changes from the session + this._register(autorun(reader => { + const sessionResource = activeSessionResourceObs.read(reader); + if (!sessionResource) { + this.syncAuxiliaryBarVisibility(false); + return; + } + + const hasChanges = this.hasSessionChanges(sessionResource); + this.syncAuxiliaryBarVisibility(hasChanges); })); } - private tryBindToChangesView(): void { - const changesView = this.viewsService.getViewWithId(CHANGES_VIEW_ID); - if (!changesView) { - this.activeChangesView = null; - this.activeChangesListener.clear(); - return; + private hasSessionChanges(sessionResource: URI): boolean { + const isBackgroundSession = getChatSessionType(sessionResource) === AgentSessionProviders.Background; + + let editingSessionCount = 0; + if (!isBackgroundSession) { + const sessions = this.chatEditingService.editingSessionsObs.read(undefined); + const editingSession = sessions.find(candidate => isEqual(candidate.chatSessionResource, sessionResource)); + editingSessionCount = editingSession ? editingSession.entries.read(undefined).length : 0; } - if (this.activeChangesView === changesView) { - return; - } + const session = this.agentSessionsService.getSession(sessionResource); + const sessionFilesCount = session?.changes instanceof Array ? session.changes.length : 0; - this.activeChangesView = changesView; - this.activeChangesListener.value = autorun(reader => { - const hasChanges = changesView.activeSessionHasChanges.read(reader); - this.syncAuxiliaryBarVisibility(hasChanges); - }); + return editingSessionCount + sessionFilesCount > 0; } private syncAuxiliaryBarVisibility(hasChanges: boolean): void { @@ -61,4 +114,4 @@ export class SessionsAuxiliaryBarContribution extends Disposable { this.layoutService.setPartHidden(shouldHideAuxiliaryBar, Parts.AUXILIARYBAR_PART); } -} +} diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts index ab3ba0b5a08..7aaea04ff89 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts @@ -3,9 +3,10 @@ * 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 { CancellationToken } from '../../../../base/common/cancellation.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; import { autorun } from '../../../../base/common/observable.js'; @@ -23,51 +24,27 @@ import { IHoverService } from '../../../../platform/hover/browser/hover.js'; 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 { IPromptsService } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; +import { IMcpService } from '../../../../workbench/contrib/mcp/common/mcpTypes.js'; import { ISessionsManagementService } from './sessionsManagementService.js'; import { Action2, ISubmenuItem, MenuId, MenuRegistry, registerAction2 } from '../../../../platform/actions/common/actions.js'; import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js'; import { IWorkbenchLayoutService } from '../../../../workbench/services/layout/browser/layoutService.js'; import { Button } from '../../../../base/browser/ui/button/button.js'; import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js'; -import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { KeybindingsRegistry, KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; import { ACTION_ID_NEW_CHAT } from '../../../../workbench/contrib/chat/browser/actions/chatActions.js'; -import { IEditorGroupsService } from '../../../../workbench/services/editor/common/editorGroupsService.js'; -import { AICustomizationManagementEditor } from '../../aiCustomizationManagement/browser/aiCustomizationManagementEditor.js'; -import { AICustomizationManagementSection } from '../../aiCustomizationManagement/browser/aiCustomizationManagement.js'; -import { AICustomizationManagementEditorInput } from '../../aiCustomizationManagement/browser/aiCustomizationManagementEditorInput.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 { IMcpService } from '../../../../workbench/contrib/mcp/common/mcpTypes.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 { agentIcon, instructionsIcon, promptIcon, skillIcon, hookIcon, workspaceIcon, userIcon, extensionIcon } from '../../aiCustomizationTreeView/browser/aiCustomizationTreeViewIcons.js'; +import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; +import { Menus } from '../../../browser/menus.js'; +import { getCustomizationTotalCount } from './customizationCounts.js'; const $ = DOM.$; export const SessionsViewId = 'agentic.workbench.view.sessionsView'; const SessionsViewFilterSubMenu = new MenuId('AgentSessionsViewFilterSubMenu'); -/** - * Per-source breakdown of item counts. - */ -interface ISourceCounts { - readonly workspace: number; - readonly user: number; - readonly extension: number; -} - -interface IShortcutItem { - readonly label: string; - readonly icon: ThemeIcon; - readonly action: () => Promise; - readonly getSourceCounts?: () => Promise; - /** For items without per-source breakdown (MCP, Models). */ - readonly getCount?: () => Promise; - countContainer?: HTMLElement; -} - const CUSTOMIZATIONS_COLLAPSED_KEY = 'agentSessions.customizationsCollapsed'; export class AgenticSessionsViewPane extends ViewPane { @@ -76,7 +53,6 @@ export class AgenticSessionsViewPane extends ViewPane { private sessionsControlContainer: HTMLElement | undefined; sessionsControl: AgentSessionsControl | undefined; private aiCustomizationContainer: HTMLElement | undefined; - private readonly shortcuts: IShortcutItem[] = []; constructor( options: IViewPaneOptions, @@ -90,44 +66,13 @@ export class AgenticSessionsViewPane extends ViewPane { @IThemeService themeService: IThemeService, @IHoverService hoverService: IHoverService, @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, - @ICommandService commandService: ICommandService, - @IEditorGroupsService private readonly editorGroupsService: IEditorGroupsService, - @IPromptsService private readonly promptsService: IPromptsService, - @ILanguageModelsService private readonly languageModelsService: ILanguageModelsService, - @IMcpService private readonly mcpService: IMcpService, @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, ) { super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService); - - // Initialize shortcuts - this.shortcuts = [ - { label: localize('agents', "Agents"), icon: agentIcon, action: () => this.openAICustomizationSection(AICustomizationManagementSection.Agents), getSourceCounts: () => this.getPromptSourceCounts(PromptsType.agent) }, - { label: localize('skills', "Skills"), icon: skillIcon, action: () => this.openAICustomizationSection(AICustomizationManagementSection.Skills), getSourceCounts: () => this.getSkillSourceCounts() }, - { label: localize('instructions', "Instructions"), icon: instructionsIcon, action: () => this.openAICustomizationSection(AICustomizationManagementSection.Instructions), getSourceCounts: () => this.getPromptSourceCounts(PromptsType.instructions) }, - { label: localize('prompts', "Prompts"), icon: promptIcon, action: () => this.openAICustomizationSection(AICustomizationManagementSection.Prompts), getSourceCounts: () => this.getPromptSourceCounts(PromptsType.prompt) }, - { label: localize('hooks', "Hooks"), icon: hookIcon, action: () => this.openAICustomizationSection(AICustomizationManagementSection.Hooks), getSourceCounts: () => this.getPromptSourceCounts(PromptsType.hook) }, - { label: localize('mcpServers', "MCP Servers"), icon: Codicon.server, action: () => this.openAICustomizationSection(AICustomizationManagementSection.McpServers), getCount: () => Promise.resolve(this.mcpService.servers.get().length) }, - { label: localize('models', "Models"), icon: Codicon.vm, action: () => this.openAICustomizationSection(AICustomizationManagementSection.Models), getCount: () => Promise.resolve(this.languageModelsService.getLanguageModelIds().length) }, - ]; - - // Listen to changes to update counts - this._register(this.promptsService.onDidChangeCustomAgents(() => this.updateCounts())); - this._register(this.promptsService.onDidChangeSlashCommands(() => this.updateCounts())); - this._register(this.languageModelsService.onDidChangeLanguageModels(() => this.updateCounts())); - this._register(autorun(reader => { - this.mcpService.servers.read(reader); - this.updateCounts(); - })); - - // Listen to workspace folder changes to update counts - this._register(this.workspaceContextService.onDidChangeWorkspaceFolders(() => this.updateCounts())); - this._register(autorun(reader => { - this.activeSessionService.activeSession.read(reader); - this.updateCounts(); - })); - } protected override renderBody(parent: HTMLElement): void { @@ -199,8 +144,8 @@ export class AgenticSessionsViewPane extends ViewPane { } })); - // AI Customization shortcuts (bottom, fixed height) - this.aiCustomizationContainer = DOM.append(sessionsContainer, $('.ai-customization-shortcuts')); + // AI Customization toolbar (bottom, fixed height) + this.aiCustomizationContainer = DOM.append(sessionsContainer, $('div')); this.createAICustomizationShortcuts(this.aiCustomizationContainer); } @@ -215,177 +160,86 @@ export class AgenticSessionsViewPane extends ViewPane { // 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.tabIndex = 0; - header.setAttribute('role', 'button'); - header.setAttribute('aria-expanded', String(!isCollapsed)); + header.classList.toggle('collapsed', isCollapsed); - // Header text - const headerText = DOM.append(header, $('span')); - headerText.textContent = localize('customizations', "CUSTOMIZATIONS"); + 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"); - // Chevron icon (right-aligned, shown on hover) - const chevron = DOM.append(header, $('.ai-customization-chevron')); + 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)); - // Links container - const linksContainer = DOM.append(container, $('.ai-customization-links')); - if (isCollapsed) { - linksContainer.classList.add('collapsed'); - } + // 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); + 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(); + })); + updateHeaderTotalCount(); // Toggle collapse on header click const toggleCollapse = () => { - const collapsed = linksContainer.classList.toggle('collapsed'); + const collapsed = container.classList.toggle('collapsed'); + header.classList.toggle('collapsed', collapsed); this.storageService.store(CUSTOMIZATIONS_COLLAPSED_KEY, collapsed, StorageScope.PROFILE, StorageTarget.USER); - header.setAttribute('aria-expanded', String(!collapsed)); + 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 const onTransitionEnd = () => { - linksContainer.removeEventListener('transitionend', onTransitionEnd); + toolbarContainer.removeEventListener('transitionend', onTransitionEnd); if (this.viewPaneContainer) { const { offsetHeight, offsetWidth } = this.viewPaneContainer; this.layoutBody(offsetHeight, offsetWidth); } }; - linksContainer.addEventListener('transitionend', onTransitionEnd); + toolbarContainer.addEventListener('transitionend', onTransitionEnd); }; - this._register(DOM.addDisposableListener(header, 'click', toggleCollapse)); - this._register(DOM.addDisposableListener(header, 'keydown', (e: KeyboardEvent) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - toggleCollapse(); - } - })); - - for (const shortcut of this.shortcuts) { - const link = DOM.append(linksContainer, $('a.ai-customization-link')); - link.tabIndex = 0; - link.setAttribute('role', 'button'); - link.setAttribute('aria-label', shortcut.label); - - // Icon - const iconElement = DOM.append(link, $('.link-icon')); - iconElement.classList.add(...ThemeIcon.asClassNameArray(shortcut.icon)); - - // Label - const labelElement = DOM.append(link, $('.link-label')); - labelElement.textContent = shortcut.label; - - // Count container (right-aligned, shows per-source badges) - const countContainer = DOM.append(link, $('.link-counts')); - shortcut.countContainer = countContainer; - - this._register(DOM.addDisposableListener(link, 'click', (e) => { - DOM.EventHelper.stop(e); - shortcut.action(); - })); - - this._register(DOM.addDisposableListener(link, 'keydown', (e: KeyboardEvent) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - shortcut.action(); - } - })); - } - - // Load initial counts - this.updateCounts(); - } - - private async updateCounts(): Promise { - for (const shortcut of this.shortcuts) { - if (!shortcut.countContainer) { - continue; - } - - if (shortcut.getSourceCounts) { - const counts = await shortcut.getSourceCounts(); - this.renderSourceCounts(shortcut.countContainer, counts); - } else if (shortcut.getCount) { - const count = await shortcut.getCount(); - this.renderSimpleCount(shortcut.countContainer, count); - } - } - } - - private renderSourceCounts(container: HTMLElement, counts: ISourceCounts): void { - DOM.clearNode(container); - const total = counts.workspace + counts.user + counts.extension; - container.classList.toggle('hidden', total === 0); - if (total === 0) { - return; - } - - const sources: { count: number; icon: ThemeIcon; title: string }[] = [ - { count: counts.workspace, icon: workspaceIcon, title: localize('workspaceCount', "{0} from workspace", counts.workspace) }, - { count: counts.user, icon: userIcon, title: localize('userCount', "{0} from user", counts.user) }, - { count: counts.extension, icon: extensionIcon, title: localize('extensionCount', "{0} from extensions", counts.extension) }, - ]; - - for (const source of sources) { - if (source.count === 0) { - continue; - } - const badge = DOM.append(container, $('.source-count-badge')); - badge.title = source.title; - const icon = DOM.append(badge, $('.source-count-icon')); - icon.classList.add(...ThemeIcon.asClassNameArray(source.icon)); - const num = DOM.append(badge, $('.source-count-num')); - num.textContent = `${source.count}`; - } - } - - private renderSimpleCount(container: HTMLElement, count: number): void { - DOM.clearNode(container); - container.classList.toggle('hidden', count === 0); - if (count > 0) { - const badge = DOM.append(container, $('.source-count-badge')); - const num = DOM.append(badge, $('.source-count-num')); - num.textContent = `${count}`; - } - } - - private async getPromptSourceCounts(promptType: PromptsType): Promise { - const [workspaceItems, userItems, extensionItems] = await Promise.all([ - this.promptsService.listPromptFilesForStorage(promptType, PromptsStorage.local, CancellationToken.None), - this.promptsService.listPromptFilesForStorage(promptType, PromptsStorage.user, CancellationToken.None), - this.promptsService.listPromptFilesForStorage(promptType, PromptsStorage.extension, CancellationToken.None), - ]); - - return { - workspace: workspaceItems.length, - user: userItems.length, - extension: extensionItems.length, - }; - } - - private async getSkillSourceCounts(): Promise { - const skills = await this.promptsService.findAgentSkills(CancellationToken.None); - if (!skills || skills.length === 0) { - return { workspace: 0, user: 0, extension: 0 }; - } - - const workspaceSkills = skills.filter(s => s.storage === PromptsStorage.local); - - return { - workspace: workspaceSkills.length, - user: skills.filter(s => s.storage === PromptsStorage.user).length, - extension: skills.filter(s => s.storage === PromptsStorage.extension).length, - }; - } - - private async openAICustomizationSection(sectionId: AICustomizationManagementSection): Promise { - const input = AICustomizationManagementEditorInput.getOrCreate(); - const editor = await this.editorGroupsService.activeGroup.openEditor(input, { pinned: true }); - - if (editor instanceof AICustomizationManagementEditor) { - editor.selectSectionById(sectionId); - } + this._register(headerButton.onDidClick(() => toggleCollapse())); } private getSessionHoverPosition(): HoverPosition { diff --git a/src/vs/sessions/sessions.desktop.main.ts b/src/vs/sessions/sessions.desktop.main.ts index b9a4d26199c..b81b0de984e 100644 --- a/src/vs/sessions/sessions.desktop.main.ts +++ b/src/vs/sessions/sessions.desktop.main.ts @@ -191,6 +191,7 @@ import './contrib/aiCustomizationTreeView/browser/aiCustomizationTreeView.contri import './contrib/aiCustomizationManagement/browser/aiCustomizationManagement.contribution.js'; import './contrib/chat/browser/chat.contribution.js'; import './contrib/sessions/browser/sessions.contribution.js'; +import './contrib/sessions/browser/customizationsToolbar.contribution.js'; import './contrib/changesView/browser/changesView.contribution.js'; import './contrib/configuration/browser/configuration.contribution.js';