diff --git a/src/vs/sessions/contrib/sessions/browser/automationsNewBadge.ts b/src/vs/sessions/contrib/sessions/browser/automationsNewBadge.ts new file mode 100644 index 000000000000..62445fc9f2a4 --- /dev/null +++ b/src/vs/sessions/contrib/sessions/browser/automationsNewBadge.ts @@ -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 { Disposable } from '../../../../base/common/lifecycle.js'; +import { autorun, derived, observableValue } from '../../../../base/common/observable.js'; +import { onUnexpectedError } from '../../../../base/common/errors.js'; +import { IConfigurationService, isConfigured } from '../../../../platform/configuration/common/configuration.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { observableMemento, type ObservableMemento } from '../../../../platform/observable/common/observableMemento.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; +import { IAutomationService } from '../../../../workbench/contrib/chat/common/automations/automationService.js'; +import { IWorkbenchAssignmentService } from '../../../../workbench/services/assignment/common/assignmentService.js'; +import { ICustomViewService } from '../../../services/customView/browser/customViewService.js'; +import { AUTOMATIONS_CUSTOM_VIEW_ID } from './automationsConstants.js'; + +export const AUTOMATIONS_NEW_BADGE_SEEN_STORAGE_KEY = 'sessions.automations.newBadgeSeen'; +export const AUTOMATIONS_NEW_BADGE_STYLE_SETTING = 'sessions.automations.newBadgeStyle'; +export const AUTOMATIONS_NEW_BADGE_STYLE_TREATMENT = 'agentSessionsAutomationsNewBadgeStyle'; + +export type AutomationsNewBadgeStyle = 'accent' | 'soft' | 'outline'; + +const DEFAULT_AUTOMATIONS_NEW_BADGE_STYLE: AutomationsNewBadgeStyle = 'outline'; + +const automationsNewBadgeSeenMemento = observableMemento({ + key: AUTOMATIONS_NEW_BADGE_SEEN_STORAGE_KEY, + defaultValue: false, + toStorage: value => String(value), + fromStorage: value => value === 'true', +}); + +/** Owns the first-use state for the Automations shortcut badge. */ +export class AutomationsNewBadgeState extends Disposable { + + private readonly seen: ObservableMemento; + private readonly resolvedStyle = observableValue(this, undefined); + private observingActiveView = false; + private initializationPromise: Promise | undefined; + private styleRequest = 0; + readonly presentation = derived(this, reader => this.seen.read(reader) ? undefined : this.resolvedStyle.read(reader)); + readonly showNewBadge = derived(this, reader => this.presentation.read(reader) !== undefined); + + constructor( + @IAutomationService private readonly automationService: IAutomationService, + @ICustomViewService private readonly customViewService: ICustomViewService, + @IStorageService private readonly storageService: IStorageService, + @IWorkbenchAssignmentService private readonly assignmentService: IWorkbenchAssignmentService, + @IConfigurationService private readonly configurationService: IConfigurationService, + @ILogService private readonly logService: ILogService, + ) { + super(); + this.seen = this._register(automationsNewBadgeSeenMemento(StorageScope.APPLICATION, StorageTarget.MACHINE, storageService)); + } + + initialize(): Promise { + if (!this.observingActiveView) { + this.observingActiveView = true; + this._register(autorun(reader => { + if (this.customViewService.activeCustomView.read(reader)?.id === AUTOMATIONS_CUSTOM_VIEW_ID) { + this.markSeen(); + } + })); + } + if (!this.initializationPromise) { + this.initializationPromise = this.doInitialize(); + this._register(this.configurationService.onDidChangeConfiguration(event => { + if (event.affectsConfiguration(AUTOMATIONS_NEW_BADGE_STYLE_SETTING)) { + void this.updateStyle().catch(onUnexpectedError); + } + })); + this._register(this.assignmentService.onDidRefetchAssignments(() => { + void this.updateStyle().catch(onUnexpectedError); + })); + } + return this.initializationPromise; + } + + async reset(): Promise { + this.seen.set(false, undefined); + this.storageService.remove(AUTOMATIONS_NEW_BADGE_SEEN_STORAGE_KEY, StorageScope.APPLICATION); + this.resolvedStyle.set(undefined, undefined); + await this.updateStyle(); + } + + private async doInitialize(): Promise { + const hasPriorUse = this.seen.get() + || this.automationService.automations.get().length > 0 + || this.automationService.runs.get().length > 0; + if (hasPriorUse) { + this.markSeen(); + return; + } + + await this.updateStyle(); + } + + private async updateStyle(): Promise { + if (this.seen.get()) { + return; + } + + const request = ++this.styleRequest; + const inspection = this.configurationService.inspect(AUTOMATIONS_NEW_BADGE_STYLE_SETTING); + let value: string | undefined; + if (isConfigured(inspection)) { + value = inspection.value; + } else { + try { + value = await this.assignmentService.getTreatment(AUTOMATIONS_NEW_BADGE_STYLE_TREATMENT); + } catch (error) { + this.logService.warn(`[AutomationsNewBadgeState] Failed to resolve badge style treatment; using '${DEFAULT_AUTOMATIONS_NEW_BADGE_STYLE}'.`, error); + } + } + if (request !== this.styleRequest || this.seen.get()) { + return; + } + this.resolvedStyle.set(this.normalizeStyle(value), undefined); + } + + private normalizeStyle(value: string | undefined): AutomationsNewBadgeStyle { + if (value === undefined || value === DEFAULT_AUTOMATIONS_NEW_BADGE_STYLE) { + return DEFAULT_AUTOMATIONS_NEW_BADGE_STYLE; + } + if (value === 'accent' || value === 'soft') { + return value; + } + this.logService.warn(`[AutomationsNewBadgeState] Unsupported badge style treatment '${value}'; using '${DEFAULT_AUTOMATIONS_NEW_BADGE_STYLE}'.`); + return DEFAULT_AUTOMATIONS_NEW_BADGE_STYLE; + } + + private markSeen(): void { + if (!this.seen.get()) { + this.seen.set(true, undefined); + } + } +} diff --git a/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css b/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css index 75e27e16a6b6..73c9cf83b78f 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css +++ b/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css @@ -724,6 +724,39 @@ margin-right: 4px; } + .session-section-new-badge { + display: none; + align-items: center; + flex-shrink: 0; + height: var(--vscode-spacing-size160); + margin-left: var(--vscode-spacing-size40); + padding: 0 var(--vscode-spacing-size40); + box-sizing: border-box; + border: var(--vscode-strokeThickness) solid transparent; + border-radius: var(--vscode-cornerRadius-circle); + color: var(--vscode-foreground); + font-size: var(--vscode-fontSize-label3); + font-weight: var(--vscode-fontWeight-semiBold); + letter-spacing: 0.04em; + text-transform: uppercase; + white-space: nowrap; + pointer-events: none; + } + + .session-section-new-badge-accent { + border-color: var(--vscode-activityBarBadge-background); + background-color: var(--vscode-activityBarBadge-background); + color: var(--vscode-activityBarBadge-foreground); + } + + .session-section-new-badge-soft { + background-color: color-mix(in srgb, var(--vscode-foreground) 12%, transparent); + } + + .session-section-new-badge-outline { + border-color: var(--vscode-descriptionForeground); + } + .session-section-toolbar { flex-shrink: 0; margin-left: 4px; @@ -750,6 +783,11 @@ } } +.hc-black .session-section-new-badge, +.hc-light .session-section-new-badge { + border-color: var(--vscode-contrastBorder); +} + .monaco-list-row:hover .session-section .session-section-toolbar, .monaco-list-row.focused .session-section .session-section-toolbar, .monaco-list-row .session-section.dropdown-active .session-section-toolbar { diff --git a/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts b/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts index 38a00abdd3d8..4a2518f6be12 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts @@ -18,8 +18,9 @@ import { SessionsView, SessionsViewId } from './views/sessionsView.js'; import { AutomationsCustomViewContribution } from './views/automationsView.js'; import './views/sessionsViewActions.js'; import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; -import { Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; +import { ConfigurationScope, Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; import { SESSIONS_LIST_SHOW_EMPTY_DEFAULT_GROUPS_SETTING } from './views/sessionsList.js'; +import { AUTOMATIONS_NEW_BADGE_STYLE_SETTING, AUTOMATIONS_NEW_BADGE_STYLE_TREATMENT } from './automationsNewBadge.js'; import { SessionsMouseNavigationContribution } from './sessionsMouseNavigation.js'; import './sessionDetailsAction.js'; import { SessionsWindowNotifier } from './sessionsWindowNotifier.js'; @@ -69,6 +70,19 @@ Registry.as(ConfigurationExtensions.Configuration).regis default: true, experiment: { mode: 'auto' } }, + [AUTOMATIONS_NEW_BADGE_STYLE_SETTING]: { + type: 'string', + enum: ['accent', 'soft', 'outline'], + default: 'outline', + scope: ConfigurationScope.APPLICATION, + included: false, + tags: ['experimental'], + experiment: { + mode: 'auto', + name: AUTOMATIONS_NEW_BADGE_STYLE_TREATMENT, + }, + description: localize('sessions.automations.newBadgeStyle', "Controls the visual style of the Automations first-use badge."), + }, }, }); diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts index 109218084ae8..6f4d91d9ec37 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts @@ -96,6 +96,7 @@ import { ChatAutomationsEnabledContext } from '../../../../../workbench/contrib/ import { IAutomationService } from '../../../../../workbench/contrib/chat/common/automations/automationService.js'; import { ICustomViewService } from '../../../../services/customView/browser/customViewService.js'; import { AUTOMATIONS_CUSTOM_VIEW_ID } from '../automationsConstants.js'; +import { AutomationsNewBadgeState, type AutomationsNewBadgeStyle } from '../automationsNewBadge.js'; import { Menus } from '../../../../browser/menus.js'; import { getSessionConversationStatusAriaLabel } from '../../../../browser/sessionConversationGroups.js'; import { getAgentMergeAwarePullRequestIcon, getSessionAgentMergeConfigurationObservable, ISessionAgentMergeConfiguration, isAgentMergePullRequestIcon } from '../../../../browser/sessionAgentMerge.js'; @@ -1266,6 +1267,7 @@ interface ISessionSectionTemplate extends ISessionHeaderTemplate { readonly icon: HTMLElement; readonly label: HTMLElement; readonly count: HTMLElement; + readonly newBadge: HTMLElement; readonly chevron: HTMLElement; readonly contextKeyService: IContextKeyService; readonly disposables: DisposableStore; @@ -1318,6 +1320,7 @@ export class SessionSectionRenderer implements ITreeRenderer, + private readonly automationNewBadgePresentation: IObservable, private readonly uriIdentityService: IUriIdentityService, private readonly customViewService: ICustomViewService, private readonly menuService: IMenuService, @@ -1339,6 +1342,9 @@ export class SessionSectionRenderer implements ITreeRenderer, _index: number, template: ISessionSectionTemplate): void { @@ -1397,6 +1403,12 @@ export class SessionSectionRenderer implements ITreeRenderer { const activeCustomView = this.customViewService.activeCustomView.read(reader); template.container.classList.toggle('active', activeCustomView?.id === AUTOMATIONS_CUSTOM_VIEW_ID); + const badgeStyle = this.automationNewBadgePresentation.read(reader); + template.newBadge.style.display = badgeStyle ? 'inline-flex' : 'none'; + template.newBadge.classList.toggle('session-section-new-badge-accent', badgeStyle === 'accent'); + template.newBadge.classList.toggle('session-section-new-badge-soft', badgeStyle === 'soft'); + template.newBadge.classList.toggle('session-section-new-badge-outline', badgeStyle === 'outline'); })); const statusIcon = template.elementDisposables.add(this.instantiationService.createInstance(SessionStatusIcon, template.icon)); template.elementDisposables.add(autorun(reader => { @@ -1747,6 +1764,7 @@ interface ISessionsAccessibilityProviderOptions { readonly isPinned: (session: ISession) => boolean; readonly isRenderedInCustomGroup?: (session: ISession) => boolean; readonly includeQuickChatInAriaLabel?: boolean; + readonly automationNewBadgeVisible?: IObservable; /** Mirrors {@link SessionItemRenderer}'s option of the same name — see there for rationale. */ readonly deriveStatusFromMainChat?: boolean; } @@ -1776,20 +1794,23 @@ class SessionsAccessibilityProvider { } if (isSessionSection(element)) { if (element.id === AUTOMATIONS_SECTION_ID) { - return this.automationStatus - ? derived(this, reader => { - switch (this.automationStatus?.read(reader)) { - case SessionStatus.NeedsInput: - return localize('automationsNeedsInputAria', "{0}, run needs input", element.label); - case SessionStatus.InProgress: - return localize('automationsActiveAria', "{0}, run in progress", element.label); - case SessionStatus.Completed: - return localize('automationsUnreadRunAria', "{0}, unread run", element.label); - default: - return element.label; - } - }) - : element.label; + return derived(this, reader => { + let label = element.label; + switch (this.automationStatus?.read(reader)) { + case SessionStatus.NeedsInput: + label = localize('automationsNeedsInputAria', "{0}, run needs input", element.label); + break; + case SessionStatus.InProgress: + label = localize('automationsActiveAria', "{0}, run in progress", element.label); + break; + case SessionStatus.Completed: + label = localize('automationsUnreadRunAria', "{0}, unread run", element.label); + break; + } + return this.options?.automationNewBadgeVisible?.read(reader) + ? localize('automationsNewFeatureAria', "{0}, new feature", label) + : label; + }); } return `${element.label}, ${element.sessions.length}`; } @@ -2323,6 +2344,7 @@ export class SessionsList extends Disposable implements ISessionsList { */ private readonly chatApprovalHeightReconcile = this._register(new MutableDisposable()); private readonly automationSessions = observableValue(this, []); + private readonly automationsNewBadgeState: AutomationsNewBadgeState; /** * Session IDs whose hierarchy indent/connector guides should be visible: * the union of the currently-hovered session (if any) and every session @@ -2396,6 +2418,13 @@ export class SessionsList extends Disposable implements ISessionsList { get element(): HTMLElement { return this.listContainer; } + async resetAutomationsNewBadge(): Promise { + if (this.customViewService.activeCustomView.get()?.id === AUTOMATIONS_CUSTOM_VIEW_ID) { + this.customViewService.hideCustomView(); + } + await this.automationsNewBadgeState.reset(); + } + constructor( container: HTMLElement, private readonly options: ISessionsListControlOptions, @@ -2424,6 +2453,7 @@ export class SessionsList extends Disposable implements ISessionsList { @IPreferencesService private readonly preferencesService: IPreferencesService, ) { super(); + this.automationsNewBadgeState = this._register(instantiationService.createInstance(AutomationsNewBadgeState)); // Load excluded session types from storage this.excludedSessionTypes = this.loadExcludedSessionTypes(); @@ -2518,7 +2548,18 @@ export class SessionsList extends Disposable implements ISessionsList { this.tree.setFocus([element], event); this.tree.setSelection([element], event); }; - const sectionRenderer = new SessionSectionRenderer(true /* hideSectionCount */, selectHeader, instantiationService, contextKeyService, this.automationService, this.automationSessions, this.uriIdentityService, this.customViewService, this.menuService); + const sectionRenderer = new SessionSectionRenderer( + true /* hideSectionCount */, + selectHeader, + instantiationService, + contextKeyService, + this.automationService, + this.automationSessions, + this.automationsNewBadgeState.presentation, + this.uriIdentityService, + this.customViewService, + this.menuService, + ); this._sectionRenderer = sectionRenderer; const groupRenderer = new SessionGroupRenderer({ commitEdit: (group, name) => this.commitGroupEdit(group, name), @@ -2561,6 +2602,7 @@ export class SessionsList extends Disposable implements ISessionsList { isPinned: session => this.isSessionPinned(session), isRenderedInCustomGroup: session => this.isRenderedInCustomGroup(session), deriveStatusFromMainChat: true, + automationNewBadgeVisible: this.automationsNewBadgeState.showNewBadge, }), dnd: this._register(new SessionsListDragAndDrop({ isReorderable: session => this.isReorderable(session), @@ -3200,6 +3242,7 @@ export class SessionsList extends Disposable implements ISessionsList { }; if (this.contextKeyService.getContextKeyValue(ChatAutomationsEnabledContext.key)) { + void this.automationsNewBadgeState.initialize().catch(onUnexpectedError); children.push(renderSection({ id: AUTOMATIONS_SECTION_ID, label: localize('automations', "Automations"), sessions: [] })); } diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsView.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsView.ts index dc530fc8137b..ce50eaef16df 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsView.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsView.ts @@ -60,6 +60,40 @@ export const SessionsViewGroupingContext = new RawContextKey('sessionsVi export const SessionsViewSortingContext = new RawContextKey('sessionsViewPane.sorting', SessionsSorting.Created); export const IsWorkspaceGroupCappedContext = new RawContextKey('sessionsViewPane.workspaceGroupCapped', true); +export interface ISessionsHeaderElements { + readonly row: HTMLElement; + readonly label: HTMLElement; + readonly actions: HTMLElement; + readonly toolbar: MenuWorkbenchToolBar | undefined; +} + +export function renderSessionsHeader( + parent: HTMLElement, + phoneLayout: boolean, + instantiationService: IInstantiationService, + contextKeyService: IContextKeyService, + disposables: DisposableStore, +): ISessionsHeaderElements { + const row = DOM.append(parent, $('.agent-sessions-header-row')); + const label = DOM.append(row, $('.agent-sessions-header-label')); + const actions = DOM.append(row, $('.agent-sessions-header-actions')); + let toolbar: MenuWorkbenchToolBar | undefined; + + if (!phoneLayout) { + label.textContent = localize('sessionsHeader', "Sessions"); + const scopedInstantiationService = disposables.add(instantiationService.createChild(new ServiceCollection([IContextKeyService, contextKeyService]))); + toolbar = disposables.add(scopedInstantiationService.createInstance(MenuWorkbenchToolBar, actions, Menus.SidebarSessionsHeader, { + hiddenItemStrategy: HiddenItemStrategy.NoHide, + telemetrySource: 'sessionsView.header', + toolbarOptions: { primaryGroup: () => true }, + })); + } else { + row.classList.add('phone-layout-empty'); + } + + return { row, label, actions, toolbar }; +} + export class SessionsView extends ViewPane { private viewPaneContainer: HTMLElement | undefined; @@ -156,32 +190,15 @@ export class SessionsView extends ViewPane { // Sessions content container const sessionsContent = DOM.append(sessionsSection, $('.agent-sessions-content')); - // Header row: "Sessions" label (left) + compact "New" button (right) - const headerRow = this.headerRow = DOM.append(sessionsContent, $('.agent-sessions-header-row')); - const headerLabel = this.headerLabel = DOM.append(headerRow, $('.agent-sessions-header-label')); - - const headerActions = this.headerActions = DOM.append(headerRow, $('.agent-sessions-header-actions')); - // On phone, the desktop header content (label + new button + filter/find toolbar) // is hidden in favor of the mobile filter chip row + the (+) button in the // MobileTitlebarPart. We still create the row container because the find // widget mounts inside it. const phoneLayout = isPhoneLayout(this.layoutService); - if (!phoneLayout) { - headerLabel.textContent = localize('sessionsHeader', "Sessions"); - - // Header actions (visual order: New, Filter, Search). The "New" button is - // contributed to Menus.SidebarSessionsHeader and rendered as a compact pill - // by NewSessionActionViewItem. - const scopedInstantiationService = this._register(this.instantiationService.createChild(new ServiceCollection([IContextKeyService, this.scopedContextKeyService]))); - this._register(scopedInstantiationService.createInstance(MenuWorkbenchToolBar, headerActions, Menus.SidebarSessionsHeader, { - hiddenItemStrategy: HiddenItemStrategy.NoHide, - telemetrySource: 'sessionsView.header', - toolbarOptions: { primaryGroup: () => true }, - })); - } else { - headerRow.classList.add('phone-layout-empty'); - } + const header = renderSessionsHeader(sessionsContent, phoneLayout, this.instantiationService, this.scopedContextKeyService, this._register(new DisposableStore())); + const headerRow = this.headerRow = header.row; + this.headerLabel = header.label; + this.headerActions = header.actions; // Container for the tree's find widget (toggled by the toolbar's Find action) const findWidgetContainer = this.findWidgetContainer = DOM.append(headerRow, $('.agent-sessions-find-widget-container')); diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts index d878e9c6f118..4fcb2e70c0d8 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts @@ -9,10 +9,12 @@ import { KeyChord, KeyCode, KeyMod } from '../../../../../base/common/keyCodes.j import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js'; import { isMobile, isWeb } from '../../../../../base/common/platform.js'; import { localize, localize2 } from '../../../../../nls.js'; +import { Categories } from '../../../../../platform/action/common/actionCommonCategories.js'; import { Action2, MenuId, MenuRegistry, registerAction2 } from '../../../../../platform/actions/common/actions.js'; import { CommandsRegistry, ICommandService } from '../../../../../platform/commands/common/commands.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { ContextKeyExpr, IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { IsDevelopmentContext } from '../../../../../platform/contextkey/common/contextkeys.js'; import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; import { IQuickInputService } from '../../../../../platform/quickinput/common/quickInput.js'; @@ -40,6 +42,7 @@ import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase import { registerExternalSessionsFilterMenu } from '../../../../../workbench/contrib/chat/browser/agentSessions/externalSessionsFilterMenu.js'; import { ICustomViewService } from '../../../../services/customView/browser/customViewService.js'; import { IAutomationService } from '../../../../../workbench/contrib/chat/common/automations/automationService.js'; +import { ChatAutomationsEnabledContext } from '../../../../../workbench/contrib/chat/common/automations/automationsEnabled.js'; import { AUTOMATIONS_CUSTOM_VIEW_ID } from '../automationsConstants.js'; const CLOSE_SESSION_COMMAND_ID = 'sessionsViewPane.closeSession'; @@ -1336,6 +1339,23 @@ registerAction2(class ManageAutomationsAction extends Action2 { } }); +registerAction2(class ResetAutomationsNewBadgeAction extends Action2 { + constructor() { + super({ + id: 'sessions.developer.resetAutomationsNewBadge', + title: localize2('resetAutomationsNewBadge', "Reset Automations New Badge"), + category: Categories.Developer, + f1: true, + precondition: ContextKeyExpr.and(IsDevelopmentContext, IsSessionsWindowContext, ChatAutomationsEnabledContext), + }); + } + + override async run(accessor: ServicesAccessor): Promise { + const view = await accessor.get(IViewsService).openView(SessionsViewId, false); + await view?.sessionsControl?.resetAutomationsNewBadge(); + } +}); + const MARK_ALL_AUTOMATION_RUNS_READ_COMMAND_ID = 'sessionsView.markAllAutomationRunsRead'; registerAction2(class MarkAllAutomationRunsReadAction extends Action2 { diff --git a/src/vs/sessions/contrib/sessions/test/browser/automationsNewBadge.test.ts b/src/vs/sessions/contrib/sessions/test/browser/automationsNewBadge.test.ts new file mode 100644 index 000000000000..a95823f476db --- /dev/null +++ b/src/vs/sessions/contrib/sessions/test/browser/automationsNewBadge.test.ts @@ -0,0 +1,236 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { Emitter } from '../../../../../base/common/event.js'; +import { observableValue } from '../../../../../base/common/observable.js'; +import { mock, upcastPartial } from '../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import type { IConfigurationChangeEvent } from '../../../../../platform/configuration/common/configuration.js'; +import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { NullLogService } from '../../../../../platform/log/common/log.js'; +import { InMemoryStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; +import type { IAutomationDescriptor, IAutomationRun } from '../../../../../workbench/contrib/chat/common/automations/automation.js'; +import { IAutomationService } from '../../../../../workbench/contrib/chat/common/automations/automationService.js'; +import { IWorkbenchAssignmentService } from '../../../../../workbench/services/assignment/common/assignmentService.js'; +import type { ICustomViewDescriptor } from '../../../../services/customView/browser/customView.js'; +import { ICustomViewService } from '../../../../services/customView/browser/customViewService.js'; +import { AUTOMATIONS_CUSTOM_VIEW_ID } from '../../browser/automationsConstants.js'; +import { AUTOMATIONS_NEW_BADGE_SEEN_STORAGE_KEY, AUTOMATIONS_NEW_BADGE_STYLE_SETTING, AUTOMATIONS_NEW_BADGE_STYLE_TREATMENT, AutomationsNewBadgeState, type AutomationsNewBadgeStyle } from '../../browser/automationsNewBadge.js'; + +suite('AutomationsNewBadgeState', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + class TestAssignmentService extends mock() { + readonly treatments: string[] = []; + override readonly onDidRefetchAssignments; + + constructor( + private readonly style: AutomationsNewBadgeStyle | undefined, + refetchAssignments: Emitter, + private readonly error?: Error, + ) { + super(); + this.onDidRefetchAssignments = refetchAssignments.event; + } + + override async getTreatment(name: string): Promise { + this.treatments.push(name); + if (this.error) { + throw this.error; + } + return this.style as T | undefined; + } + } + + function createState(options: { + readonly automations?: readonly IAutomationDescriptor[]; + readonly runs?: readonly IAutomationRun[]; + readonly activeView?: ICustomViewDescriptor; + readonly seen?: boolean; + readonly style?: AutomationsNewBadgeStyle; + readonly configuredStyle?: AutomationsNewBadgeStyle; + readonly treatmentError?: Error; + } = {}) { + const storageService = disposables.add(new InMemoryStorageService()); + if (options.seen) { + storageService.store(AUTOMATIONS_NEW_BADGE_SEEN_STORAGE_KEY, true, StorageScope.APPLICATION, StorageTarget.MACHINE); + } + const automations = observableValue(disposables, options.automations ?? []); + const runs = observableValue(disposables, options.runs ?? []); + const activeView = observableValue(disposables, options.activeView); + const automationService = new class extends mock() { + override readonly automations = automations; + override readonly runs = runs; + }; + const customViewService = new class extends mock() { + override readonly activeCustomView = activeView; + }; + const refetchAssignments = disposables.add(new Emitter()); + const assignmentService = new TestAssignmentService(options.style, refetchAssignments, options.treatmentError); + const configurationService = new TestConfigurationService(); + if (options.configuredStyle) { + void configurationService.setUserConfiguration(AUTOMATIONS_NEW_BADGE_STYLE_SETTING, options.configuredStyle); + } + const state = disposables.add(new AutomationsNewBadgeState( + automationService, + customViewService, + storageService, + assignmentService, + configurationService, + new NullLogService(), + )); + return { state, storageService, automations, runs, activeView, assignmentService, configurationService, refetchAssignments }; + } + + test('keeps the resolved style stable until Automations is activated', async () => { + const { state, storageService, automations, runs, activeView } = createState(); + + await state.initialize(); + automations.set([upcastPartial({ id: 'late-automation' })], undefined); + runs.set([upcastPartial({ id: 'late-run' })], undefined); + const beforeActivation = { + showNewBadge: state.showNewBadge.get(), + style: state.presentation.get(), + stored: storageService.get(AUTOMATIONS_NEW_BADGE_SEEN_STORAGE_KEY, StorageScope.APPLICATION), + }; + + activeView.set(upcastPartial({ id: AUTOMATIONS_CUSTOM_VIEW_ID }), undefined); + const afterActivation = { + showNewBadge: state.showNewBadge.get(), + stored: storageService.get(AUTOMATIONS_NEW_BADGE_SEEN_STORAGE_KEY, StorageScope.APPLICATION), + }; + + assert.deepStrictEqual({ beforeActivation, afterActivation }, { + beforeActivation: { showNewBadge: true, style: 'outline', stored: undefined }, + afterActivation: { + showNewBadge: false, + stored: 'true', + }, + }); + }); + + test('resolves accent, soft, and outline from the hidden treatment', async () => { + const snapshots = []; + for (const style of ['accent', 'soft', 'outline'] as const) { + const fixture = createState({ style }); + await fixture.state.initialize(); + snapshots.push({ + style: fixture.state.presentation.get(), + treatments: fixture.assignmentService.treatments, + }); + } + + assert.deepStrictEqual(snapshots, [ + { style: 'accent', treatments: [AUTOMATIONS_NEW_BADGE_STYLE_TREATMENT] }, + { style: 'soft', treatments: [AUTOMATIONS_NEW_BADGE_STYLE_TREATMENT] }, + { style: 'outline', treatments: [AUTOMATIONS_NEW_BADGE_STYLE_TREATMENT] }, + ]); + }); + + test('falls back to outline when treatment resolution fails', async () => { + const fixture = createState({ treatmentError: new Error('Unavailable') }); + + await fixture.state.initialize(); + + assert.deepStrictEqual({ + style: fixture.state.presentation.get(), + treatments: fixture.assignmentService.treatments, + }, { + style: 'outline', + treatments: [AUTOMATIONS_NEW_BADGE_STYLE_TREATMENT], + }); + }); + + test('lets the hidden setting override and live-update the treatment', async () => { + const fixture = createState({ style: 'outline', configuredStyle: 'soft' }); + await fixture.state.initialize(); + const initial = fixture.state.presentation.get(); + + await fixture.configurationService.setUserConfiguration(AUTOMATIONS_NEW_BADGE_STYLE_SETTING, 'accent'); + fixture.configurationService.onDidChangeConfigurationEmitter.fire(upcastPartial({ + affectsConfiguration: (key: string) => key === AUTOMATIONS_NEW_BADGE_STYLE_SETTING, + })); + + assert.deepStrictEqual({ + initial, + updated: fixture.state.presentation.get(), + treatments: fixture.assignmentService.treatments, + }, { + initial: 'soft', + updated: 'accent', + treatments: [], + }); + }); + + test('resets seen state for development even when prior Automation evidence exists', async () => { + const fixture = createState({ + automations: [upcastPartial({ id: 'existing-automation' })], + style: 'accent', + }); + await fixture.state.initialize(); + + await fixture.state.reset(); + + assert.deepStrictEqual({ + showNewBadge: fixture.state.showNewBadge.get(), + style: fixture.state.presentation.get(), + stored: fixture.storageService.get(AUTOMATIONS_NEW_BADGE_SEEN_STORAGE_KEY, StorageScope.APPLICATION), + }, { + showNewBadge: true, + style: 'accent', + stored: undefined, + }); + }); + + test('suppresses the badge when synchronous Automation evidence exists', async () => { + const definition = createState({ + automations: [upcastPartial({ id: 'existing-automation' })], + }); + const run = createState({ + runs: [upcastPartial({ id: 'existing-run' })], + }); + + await definition.state.initialize(); + await run.state.initialize(); + + assert.deepStrictEqual({ + definition: { + showNewBadge: definition.state.showNewBadge.get(), + stored: definition.storageService.get(AUTOMATIONS_NEW_BADGE_SEEN_STORAGE_KEY, StorageScope.APPLICATION), + }, + run: { + showNewBadge: run.state.showNewBadge.get(), + stored: run.storageService.get(AUTOMATIONS_NEW_BADGE_SEEN_STORAGE_KEY, StorageScope.APPLICATION), + }, + }, { + definition: { showNewBadge: false, stored: 'true' }, + run: { showNewBadge: false, stored: 'true' }, + }); + }); + + test('honors persisted and restored seen state before the row renders', async () => { + const persisted = createState({ seen: true }); + const restored = createState({ + activeView: upcastPartial({ id: AUTOMATIONS_CUSTOM_VIEW_ID }), + }); + + await persisted.state.initialize(); + await restored.state.initialize(); + + assert.deepStrictEqual({ + persisted: { + showNewBadge: persisted.state.showNewBadge.get(), + }, + restored: { + showNewBadge: restored.state.showNewBadge.get(), + stored: restored.storageService.get(AUTOMATIONS_NEW_BADGE_SEEN_STORAGE_KEY, StorageScope.APPLICATION), + }, + }, { + persisted: { showNewBadge: false }, + restored: { showNewBadge: false, stored: 'true' }, + }); + }); +}); diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts index 526c469fc604..541fc0caec37 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts @@ -19,6 +19,7 @@ import { IMenuService } from '../../../../../platform/actions/common/actions.js' import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; import { ContextKeyService } from '../../../../../platform/contextkey/browser/contextKeyService.js'; import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { ILabelService } from '../../../../../platform/label/common/label.js'; @@ -27,12 +28,14 @@ import { IUriIdentityService } from '../../../../../platform/uriIdentity/common/ import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { IAutomationRun } from '../../../../../workbench/contrib/chat/common/automations/automation.js'; import { IAutomationService } from '../../../../../workbench/contrib/chat/common/automations/automationService.js'; +import { ChatAutomationsEnabledContext } from '../../../../../workbench/contrib/chat/common/automations/automationsEnabled.js'; import { IPreferencesService, IOpenSettingsOptions } from '../../../../../workbench/services/preferences/common/preferences.js'; import { AgentMergeSessionState } from '../../../../../platform/agentHost/common/agentMerge.js'; import { getSessionChatDragData, isSessionChatDrag, SessionsDataTransfers } from '../../../../browser/dnd.js'; import { IsPhoneLayoutContext } from '../../../../common/contextkeys.js'; import { IAgentHostSessionsProvider, LOCAL_AGENT_HOST_PROVIDER_ID } from '../../../../common/agentHostSessionsProvider.js'; import { ICustomViewService } from '../../../../services/customView/browser/customViewService.js'; +import type { ICustomViewDescriptor } from '../../../../services/customView/browser/customView.js'; import { ISessionsListModelService } from '../../../../services/sessions/browser/sessionsListModelService.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; import { ChatInteractivity, ChatOriginKind, IChat, ISession, SessionStatus } from '../../../../services/sessions/common/session.js'; @@ -45,6 +48,8 @@ import { getSessionSummaryHoverData } from '../../browser/sessionHoverContent.js import { createListHarness, createTestSession } from './sessionsListTestUtils.js'; import '../../browser/views/sessionsViewActions.js'; import { computePullRequestIcon, GitHubPullRequestState } from '../../../github/common/types.js'; +import { AUTOMATIONS_CUSTOM_VIEW_ID } from '../../browser/automationsConstants.js'; +import { AUTOMATIONS_NEW_BADGE_STYLE_SETTING } from '../../browser/automationsNewBadge.js'; function createSession(id: string, opts: { workspaceLabel?: string; @@ -119,6 +124,7 @@ suite('Sessions - SessionsList', () => { contextKeyService, automationService, constObservable([]), + constObservable(undefined), new class extends mock() { override readonly extUri = new ExtUri(() => true); }, @@ -171,6 +177,7 @@ suite('Sessions - SessionsList', () => { contextKeyService, automationService, constObservable([]), + constObservable(undefined), new class extends mock() { override readonly extUri = new ExtUri(() => true); }, @@ -201,6 +208,106 @@ suite('Sessions - SessionsList', () => { }); }); + test('renders the new badge only on the Automations section when templates are recycled', () => { + const instantiationService = disposables.add(new TestInstantiationService()); + instantiationService.stubInstance(MenuWorkbenchToolBar, new class extends mock() { + override set context(_context: unknown) { } + override dispose(): void { } + }); + instantiationService.stub(IAccessibilityService, new class extends TestAccessibilityService { + override isMotionReduced(): boolean { return false; } + }()); + instantiationService.stub(ISessionsListModelService, new class extends mock() { }); + const contextKeyService = disposables.add(new ContextKeyService(new TestConfigurationService())); + const automationService = new class extends mock() { + override readonly runs = constObservable([]); + }; + const renderer = new SessionSectionRenderer( + true, + () => { }, + instantiationService, + contextKeyService, + automationService, + constObservable([]), + constObservable('outline'), + new class extends mock() { + override readonly extUri = new ExtUri(() => true); + }, + new class extends mock() { + override readonly activeCustomView = constObservable(undefined); + }, + new class extends mock() { }, + ); + const container = document.createElement('div'); + const template = renderer.renderTemplate(container); + disposables.add(template.disposables); + + renderer.renderElement(upcastPartial[0]>({ + element: { id: 'automations', label: 'Automations', sessions: [] }, + collapsible: false, + collapsed: false, + }), 0, template); + const automationSnapshot = { + text: template.newBadge.textContent, + display: template.newBadge.style.display, + ariaHidden: template.newBadge.getAttribute('aria-hidden'), + }; + + renderer.renderElement(upcastPartial[0]>({ + element: { id: 'workspace:test', label: 'Test', sessions: [] }, + collapsible: true, + collapsed: false, + }), 0, template); + + assert.deepStrictEqual({ + automationSnapshot, + recycledDisplay: template.newBadge.style.display, + recycledShortcutClass: template.container.classList.contains('session-section-shortcut'), + }, { + automationSnapshot: { + text: 'New', + display: 'inline-flex', + ariaHidden: 'true', + }, + recycledDisplay: 'none', + recycledShortcutClass: false, + }); + }); + + test('updates the Automations row accessible label when the new badge is dismissed', () => { + const activeCustomView = observableValue(disposables, undefined); + const harness = createListHarness(disposables, [], instantiationService => { + ChatAutomationsEnabledContext.bindTo(instantiationService.get(IContextKeyService)).set(true); + void (instantiationService.get(IConfigurationService) as TestConfigurationService).setUserConfiguration(AUTOMATIONS_NEW_BADGE_STYLE_SETTING, 'outline'); + instantiationService.stub(IAutomationService, new class extends mock() { + override readonly automations = constObservable([]); + override readonly runs = constObservable([]); + }); + instantiationService.stub(ICustomViewService, new class extends mock() { + override readonly activeCustomView = activeCustomView; + }); + }); + const container = harness.createContainer(); + const list = harness.store.add(harness.instantiationService.createInstance(SessionsList, container, { + grouping: () => SessionsGrouping.Date, + sorting: () => SessionsSorting.Created, + onSessionOpen: () => { }, + })); + list.layout(300, 400); + const row = container.querySelector('.monaco-list-row'); + const before = row?.getAttribute('aria-label'); + + activeCustomView.set(upcastPartial({ id: AUTOMATIONS_CUSTOM_VIEW_ID }), undefined); + + assert.deepStrictEqual({ + before, + after: row?.getAttribute('aria-label'), + }, { + before: 'Automations, new feature', + after: 'Automations', + }); + }); + test('derives terminal automation status from the supplied session snapshot', () => { const session = createSession('automation', { isRead: false, @@ -234,6 +341,7 @@ suite('Sessions - SessionsList', () => { new class extends mock() { }, automationService, automationSessions, + constObservable(undefined), uriIdentityService, new class extends mock() { }, new class extends mock() { }, @@ -301,6 +409,7 @@ suite('Sessions - SessionsList', () => { new class extends mock() { }, automationService, constObservable([runningSession, needsInputSession]), + constObservable(undefined), uriIdentityService, new class extends mock() { }, new class extends mock() { }, diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsList.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsList.fixture.ts index 4600407be570..b6e02d641eb3 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsList.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsList.fixture.ts @@ -4,21 +4,26 @@ *--------------------------------------------------------------------------------------------*/ import { Codicon } from '../../../../../base/common/codicons.js'; -import { Event } from '../../../../../base/common/event.js'; +import * as DOM from '../../../../../base/browser/dom.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; import { IMarkdownString, MarkdownString } from '../../../../../base/common/htmlContent.js'; -import { constObservable, IObservable } from '../../../../../base/common/observable.js'; +import { Disposable, IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { constObservable, IObservable, observableValue } from '../../../../../base/common/observable.js'; import { ExtUri } from '../../../../../base/common/resources.js'; import { ThemeIcon, themeColorFromId } from '../../../../../base/common/themables.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 { IListService, ListService } from '../../../../../platform/list/browser/listService.js'; import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; -import { IMenu, IMenuService, MenuItemAction } from '../../../../../platform/actions/common/actions.js'; +import { IMenu, IMenuService, MenuId, MenuItemAction } from '../../../../../platform/actions/common/actions.js'; import { EditorMarkdownCodeBlockRenderer } from '../../../../../editor/browser/widget/markdownRenderer/browser/editorMarkdownCodeBlockRenderer.js'; import { IMarkdownRendererService, MarkdownRendererService } from '../../../../../platform/markdown/browser/markdownRenderer.js'; import { IAgentHostConnectionsService } from '../../../../../platform/agentHost/common/agentHostConnectionsService.js'; +import { IProductService } from '../../../../../platform/product/common/productService.js'; import { IUriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentity.js'; // eslint-disable-next-line local/code-import-patterns import { IAgentHostFilterService } from '../../../../../sessions/services/agentHostFilter/common/agentHostFilter.js'; @@ -35,25 +40,64 @@ import { ISessionsService } from '../../../../../sessions/services/sessions/brow // eslint-disable-next-line local/code-import-patterns import { ICustomViewService } from '../../../../../sessions/services/customView/browser/customViewService.js'; // eslint-disable-next-line local/code-import-patterns +import { Menus } from '../../../../../sessions/browser/menus.js'; +// eslint-disable-next-line local/code-import-patterns import { IChat, ISession, ISessionChangesSummary, ISessionFolder, ISessionWorkspace, SessionStatus, ChatInteractivity } from '../../../../../sessions/services/sessions/common/session.js'; // eslint-disable-next-line local/code-import-patterns import { IActiveSession, ISessionsManagementService } from '../../../../../sessions/services/sessions/common/sessionsManagement.js'; // eslint-disable-next-line local/code-import-patterns import { SessionsGrouping, SessionsList, SessionsSorting } from '../../../../../sessions/contrib/sessions/browser/views/sessionsList.js'; // eslint-disable-next-line local/code-import-patterns +import { AUTOMATIONS_NEW_BADGE_STYLE_SETTING, type AutomationsNewBadgeStyle } from '../../../../../sessions/contrib/sessions/browser/automationsNewBadge.js'; +// eslint-disable-next-line local/code-import-patterns +import { renderSessionsHeader } from '../../../../../sessions/contrib/sessions/browser/views/sessionsView.js'; +// eslint-disable-next-line local/code-import-patterns +import { NewSessionActionViewItemContribution } from '../../../../../sessions/contrib/sessions/browser/sessionsActions.js'; +// eslint-disable-next-line local/code-import-patterns +import { NEW_SESSION_ACTION_ID } from '../../../../../sessions/contrib/chat/common/constants.js'; +// eslint-disable-next-line local/code-import-patterns import { IsPhoneLayoutContext } from '../../../../../sessions/common/contextkeys.js'; import { AgentSessionApprovalKind, AgentSessionApprovalModel, IAgentSessionApprovalInfo } from '../../../../contrib/chat/browser/agentSessions/agentSessionApprovalModel.js'; import { IAgentSessionsService } from '../../../../contrib/chat/browser/agentSessions/agentSessionsService.js'; import { IAgentSession, IAgentSessionsModel } from '../../../../contrib/chat/browser/agentSessions/agentSessionsModel.js'; import { IAutomationService } from '../../../../contrib/chat/common/automations/automationService.js'; +import type { IAutomationRun } from '../../../../contrib/chat/common/automations/automation.js'; +import { ChatAutomationsEnabledContext } from '../../../../contrib/chat/common/automations/automationsEnabled.js'; import { IChatService } from '../../../../contrib/chat/common/chatService/chatService.js'; import { IChatModel } from '../../../../contrib/chat/common/model/chatModel.js'; import { IVoicePlaybackService } from '../../../../contrib/chat/common/voicePlaybackService.js'; import { IWorkbenchAssignmentService } from '../../../../services/assignment/common/assignmentService.js'; +import { TestProductService } from '../../../common/workbenchTestServices.js'; import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from '../fixtureUtils.js'; // eslint-disable-next-line local/code-import-patterns import '../../../../../sessions/contrib/sessions/browser/media/sessionsList.css'; +// eslint-disable-next-line local/code-import-patterns +import '../../../../../sessions/contrib/sessions/browser/media/sessionsViewPane.css'; +// eslint-disable-next-line local/code-import-patterns +import '../../../../../sessions/contrib/sessions/browser/media/newSessionActionViewItem.css'; + +class FixtureActionViewItemService extends Disposable implements IActionViewItemService { + declare _serviceBrand: undefined; + + private readonly providers = new Map(); + private readonly changeEmitter = this._register(new Emitter()); + readonly onDidChange = this.changeEmitter.event; + + register(menu: MenuId, commandId: string | MenuId, provider: IActionViewItemFactory, event?: Event): IDisposable { + const key = `${menu.id}/${commandId instanceof MenuId ? commandId.id : commandId}`; + this.providers.set(key, provider); + const listener = event?.(() => this.changeEmitter.fire(menu)); + return toDisposable(() => { + listener?.dispose(); + this.providers.delete(key); + }); + } + + lookUp(menu: MenuId, commandId: string | MenuId): IActionViewItemFactory | undefined { + return this.providers.get(`${menu.id}/${commandId instanceof MenuId ? commandId.id : commandId}`); + } +} interface IChatSpec { readonly id: string; @@ -172,15 +216,20 @@ interface IRenderOptions { readonly width?: number; readonly phone?: boolean; readonly revealHierarchyGuides?: boolean; + readonly showAutomations?: boolean; + readonly automationRunStatus?: IAutomationRun['status']; + readonly automationBadgeStyle?: AutomationsNewBadgeStyle; readonly showFocusedToolbar?: boolean; } -function renderSessionsList(ctx: ComponentFixtureContext, options: IRenderOptions): void | Promise { +async function renderSessionsList(ctx: ComponentFixtureContext, options: IRenderOptions): Promise { const { container, disposableStore } = ctx; const approvals = new Map(); const sessions = options.sessions.map(spec => createSession(spec, approvals)); const approvalModel = createApprovalModel(approvals); const groups = options.groups ?? []; + const automationRuns = observableValue(disposableStore, []); + const actionViewItemService = disposableStore.add(new FixtureActionViewItemService()); const membership = new Map(); for (const spec of options.sessions) { if (spec.group) { @@ -192,6 +241,7 @@ function renderSessionsList(ctx: ComponentFixtureContext, options: IRenderOption colorTheme: ctx.theme, additionalServices: reg => { registerWorkbenchServices(reg); + reg.defineInstance(IProductService, TestProductService); if (options.showFocusedToolbar) { const archiveAction = new class extends mock() { override readonly id = 'sessions.fixture.archive'; @@ -279,7 +329,8 @@ function renderSessionsList(ctx: ComponentFixtureContext, options: IRenderOption override hasPendingResponse() { return false; } }()); reg.defineInstance(IAutomationService, new class extends mock() { - override readonly runs = constObservable([]); + override readonly automations = constObservable([]); + override readonly runs = automationRuns; }()); reg.defineInstance(IWorkbenchAssignmentService, new class extends mock() { override readonly onDidRefetchAssignments = Event.None; @@ -288,13 +339,40 @@ function renderSessionsList(ctx: ComponentFixtureContext, options: IRenderOption reg.defineInstance(IUriIdentityService, new class extends mock() { override readonly extUri = new ExtUri(() => true); }()); - reg.defineInstance(ICustomViewService, new class extends mock() { }()); + reg.defineInstance(ICustomViewService, new class extends mock() { + override readonly activeCustomView = constObservable(undefined); + }()); }, }); + if (options.showAutomations) { + const contextKeyService = instantiationService.get(IContextKeyService); + const newSessionAction = new MenuItemAction( + { id: NEW_SESSION_ACTION_ID, title: 'New Session' }, + undefined, + undefined, + undefined, + undefined, + contextKeyService, + instantiationService.get(ICommandService), + ); + instantiationService.stub(IActionViewItemService, actionViewItemService); + instantiationService.stub(IMenuService, new class extends mock() { + override createMenu(id: MenuId): IMenu { + return { + onDidChange: Event.None, + getActions: () => id === Menus.SidebarSessionsHeader ? [['navigation', [newSessionAction]]] : [], + dispose: () => { }, + }; + } + }()); + } // Render terminal-approval labels as real (monospace) code blocks — otherwise // the markdown renderer emits empty code-block spans and the command is blank. (instantiationService.get(IConfigurationService) as TestConfigurationService).setUserConfiguration('editor', { fontFamily: 'monospace' }); + if (options.automationBadgeStyle) { + await (instantiationService.get(IConfigurationService) as TestConfigurationService).setUserConfiguration(AUTOMATIONS_NEW_BADGE_STYLE_SETTING, options.automationBadgeStyle); + } instantiationService.get(IMarkdownRendererService).setDefaultCodeBlockRenderer(instantiationService.createInstance(EditorMarkdownCodeBlockRenderer)); // Phone layout is driven by both a CSS class (visual) and a context key (row @@ -303,6 +381,9 @@ function renderSessionsList(ctx: ComponentFixtureContext, options: IRenderOption if (options.phone) { IsPhoneLayoutContext.bindTo(instantiationService.get(IContextKeyService)).set(true); } + if (options.showAutomations) { + ChatAutomationsEnabledContext.bindTo(instantiationService.get(IContextKeyService)).set(true); + } const width = options.width ?? 340; container.style.width = `${width}px`; @@ -312,15 +393,41 @@ function renderSessionsList(ctx: ComponentFixtureContext, options: IRenderOption container.classList.add('agent-sessions-workbench', 'phone-layout'); } - const listHost = container.ownerDocument.createElement('div'); - container.appendChild(listHost); + let listParent = container; + if (options.showAutomations) { + container.classList.add('agent-sessions-viewpane', 'agent-sessions-section'); + const content = DOM.append(container, DOM.$('.agent-sessions-content')); + disposableStore.add(instantiationService.createInstance(NewSessionActionViewItemContribution)); + renderSessionsHeader(content, false, instantiationService, instantiationService.get(IContextKeyService), disposableStore).toolbar?.refresh(); + listParent = content; + } + const listHost = DOM.append(listParent, DOM.$(options.showAutomations ? '.agent-sessions-control-container' : 'div')); const list = disposableStore.add(instantiationService.createInstance(SessionsList, listHost, { grouping: () => options.grouping ?? SessionsGrouping.Workspace, sorting: () => SessionsSorting.Created, onSessionOpen: () => { }, approvalModel, })); - list.layout(options.phone ? 260 : 220, width); + list.layout(options.phone ? 260 : options.showAutomations ? 180 : 220, width); + + if (options.automationRunStatus) { + automationRuns.set([{ + id: 'fixture-run', + automationId: 'fixture-automation', + status: options.automationRunStatus, + trigger: 'schedule', + startedAt: new Date().toISOString(), + leaderWindowId: 1, + }], undefined); + } + await Promise.resolve(); + if (options.showAutomations && !container.querySelector('.agent-sessions-compact-new-button')) { + const menu = instantiationService.get(IMenuService).createMenu(Menus.SidebarSessionsHeader, instantiationService.get(IContextKeyService)); + const actionCount = menu.getActions().flatMap(([, actions]) => actions).length; + menu.dispose(); + const hasProvider = !!instantiationService.get(IActionViewItemService).lookUp(Menus.SidebarSessionsHeader, NEW_SESSION_ACTION_ID); + throw new Error(`Expected the production New Session action; found ${actionCount} menu action(s), provider=${hasProvider}.`); + } if (options.showFocusedToolbar) { return Promise.resolve().then(() => { @@ -413,6 +520,55 @@ export default defineThemedFixtureGroup({ path: 'sessions/' }, { sessions: [{ id: 'c', title: 'Update onboarding copy', workspace: 'vscode-docs', minutesAgo: 180 }], }), }), + SessionsList_AutomationsNewBadge: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: true }, + additionalThemes: ['darkHighContrast'], + expectedVisualDescriptions: ['The Sessions header has an outlined New button. Directly below it, the Automations row has a smaller right-aligned outlined NEW capsule that reads as a non-interactive feature badge rather than a second button.'], + render: ctx => renderSessionsList(ctx, { + sessions: [], + showAutomations: true, + }), + }), + SessionsList_AutomationsNewBadge_Accent: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: true }, + additionalThemes: ['darkHighContrast'], + expectedVisualDescriptions: ['The Automations row has a compact right-aligned NEW pill using the prominent activity badge colors, while the larger outlined New button remains visually distinct in the Sessions header.'], + render: ctx => renderSessionsList(ctx, { + sessions: [], + showAutomations: true, + automationBadgeStyle: 'accent', + }), + }), + SessionsList_AutomationsNewBadge_Soft: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: true }, + additionalThemes: ['darkHighContrast'], + expectedVisualDescriptions: ['The Automations row has a compact right-aligned NEW pill with a subtle neutral fill, while the larger outlined New button remains visually distinct in the Sessions header.'], + render: ctx => renderSessionsList(ctx, { + sessions: [], + showAutomations: true, + automationBadgeStyle: 'soft', + }), + }), + SessionsList_AutomationsNewBadge_Narrow: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: true }, + additionalThemes: ['darkHighContrast'], + expectedVisualDescriptions: ['At the 170px minimum sidebar width, the Automations label remains readable and the outlined NEW capsule stays right-aligned without changing the row height.'], + render: ctx => renderSessionsList(ctx, { + sessions: [], + showAutomations: true, + width: 170, + }), + }), + SessionsList_AutomationsNewBadge_Running: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: true }, + additionalThemes: ['darkHighContrast'], + expectedVisualDescriptions: ['The Automations row shows its running status icon and the outlined NEW capsule together without overlap or layout shift.'], + render: ctx => renderSessionsList(ctx, { + sessions: [], + showAutomations: true, + automationRunStatus: 'running', + }), + }), SessionsList_CustomGroup_Phone: defineComponentFixture({ render: ctx => renderSessionsList(ctx, { sessions: GROUPED_SESSIONS, groups: [GROUP], phone: true, width: 340 }), }), diff --git a/test/componentFixtures/blocks-ci-screenshots.md b/test/componentFixtures/blocks-ci-screenshots.md index 3e8e18551ddd..822f3dc323dd 100644 --- a/test/componentFixtures/blocks-ci-screenshots.md +++ b/test/componentFixtures/blocks-ci-screenshots.md @@ -210,6 +210,51 @@ #### sessions/chat/newWidget/newChatWidget/NewSessionWorkspacePicker/Light ![screenshot](https://hediet-screenshots.azurewebsites.net/images/695c9069e5791b7d24b24c1f9db4561755ff5694ad38370a7706426b6d38210a) +#### sessions/sessionsList/SessionsList_AutomationsNewBadge_Accent/Dark +![screenshot](https://hediet-screenshots.azurewebsites.net/images/7d1ed985c2b64d4cb7e9ef6c56df06761e308d0f4c8c49370bfaaf289d11121f) + +#### sessions/sessionsList/SessionsList_AutomationsNewBadge_Accent/DarkHighContrast +![screenshot](https://hediet-screenshots.azurewebsites.net/images/dc2d1502e0902545279c33075ad656aff4206049872b18e9cbe87ddd171729f7) + +#### sessions/sessionsList/SessionsList_AutomationsNewBadge_Accent/Light +![screenshot](https://hediet-screenshots.azurewebsites.net/images/b338d37441f73f6548b2cc9988d3875e351b914aa87269d06f74f4e844a51681) + +#### sessions/sessionsList/SessionsList_AutomationsNewBadge_Narrow/Dark +![screenshot](https://hediet-screenshots.azurewebsites.net/images/be3b3a64f1f39466e2239ab7d884befc5540d6a42253e77ba96408e4957c1404) + +#### sessions/sessionsList/SessionsList_AutomationsNewBadge_Narrow/DarkHighContrast +![screenshot](https://hediet-screenshots.azurewebsites.net/images/761d4a6a30b41df0f542aa11ba02fb10896fecdd32fd3723e63b0b9062552d6f) + +#### sessions/sessionsList/SessionsList_AutomationsNewBadge_Narrow/Light +![screenshot](https://hediet-screenshots.azurewebsites.net/images/22cf5e117dce9936a9d7189741fbea102c57dea6e5ff9b04f6d9e9532f261ec5) + +#### sessions/sessionsList/SessionsList_AutomationsNewBadge_Running/Dark +![screenshot](https://hediet-screenshots.azurewebsites.net/images/8d11fedea833e441d29331a2e51b034f6aeb3de94bb9eb4aa313428007748c2f) + +#### sessions/sessionsList/SessionsList_AutomationsNewBadge_Running/DarkHighContrast +![screenshot](https://hediet-screenshots.azurewebsites.net/images/5c1f4114f8de4b3fcd390d0a952440e0107b0b2ecd98c39cf8a24ba54499f23e) + +#### sessions/sessionsList/SessionsList_AutomationsNewBadge_Running/Light +![screenshot](https://hediet-screenshots.azurewebsites.net/images/40ebe91ecf151cf2dfaa702cb260805da15d3a9cc89465d353a9d2ea37f61405) + +#### sessions/sessionsList/SessionsList_AutomationsNewBadge_Soft/Dark +![screenshot](https://hediet-screenshots.azurewebsites.net/images/806a587cfc90c9fa9b69728137b2a26f0e3827278541f74e50dde685816fae40) + +#### sessions/sessionsList/SessionsList_AutomationsNewBadge_Soft/DarkHighContrast +![screenshot](https://hediet-screenshots.azurewebsites.net/images/34b08a9593a152f108dfa5a005d968a2e709a8ae12fcdc2f57fd0a15280a5b72) + +#### sessions/sessionsList/SessionsList_AutomationsNewBadge_Soft/Light +![screenshot](https://hediet-screenshots.azurewebsites.net/images/55b9eb610b82aa126043592d1ecc5458ce8f0f4a37b3a9c2db557c60ed39d353) + +#### sessions/sessionsList/SessionsList_AutomationsNewBadge/Dark +![screenshot](https://hediet-screenshots.azurewebsites.net/images/11b9c20d4100af6684519045dbef07052202c37041363c888a6cbc4be276b4d4) + +#### sessions/sessionsList/SessionsList_AutomationsNewBadge/DarkHighContrast +![screenshot](https://hediet-screenshots.azurewebsites.net/images/dc2d1502e0902545279c33075ad656aff4206049872b18e9cbe87ddd171729f7) + +#### sessions/sessionsList/SessionsList_AutomationsNewBadge/Light +![screenshot](https://hediet-screenshots.azurewebsites.net/images/5db359cb5c1f424f4f5153ad59e633efda657332d2e19049e472f27bd38df64f) + #### sessions/sessionsList/SessionsList_NarrowHoverToolbar/Dark ![screenshot](https://hediet-screenshots.azurewebsites.net/images/8af0c707c9c8e321ac7c8fd792b3c242a0d394cdaf68c3fe1c61804095395030)