Implement new badge design for automations in sidebar (#334091)

* sessions: feat: show New badge for Automations

Show a theme-aware first-use badge until the Automations view is activated. Preserve the decision across local profiles and cover migration, accessibility, recycled templates, and visual states.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Agent Host changes for ulugbekna/agents/sidebar-automation-new-badge-designs

* sessions: fix: address Automations badge review feedback

Fall back when treatment resolution fails, preserve high-contrast badge colors, cover observable accessible labels, and render the production Sessions header in fixtures.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 922f1dc5-a262-4914-a0b4-7fed0cb79b4b

* sessions: test: accept Automations badge screenshots

Add the Linux-rendered blocks-CI hashes for the new badge style, narrow, running, and high-contrast fixtures.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 922f1dc5-a262-4914-a0b4-7fed0cb79b4b

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 922f1dc5-a262-4914-a0b4-7fed0cb79b4b
This commit is contained in:
Ulugbek Abdullaev
2026-09-03 14:16:04 +05:00
committed by GitHub
co-authored by Copilot
parent 80c7fc21e4
commit 71bb7d0ed7
10 changed files with 862 additions and 47 deletions
@@ -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<boolean>({
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<boolean>;
private readonly resolvedStyle = observableValue<AutomationsNewBadgeStyle | undefined>(this, undefined);
private observingActiveView = false;
private initializationPromise: Promise<void> | 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<void> {
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<void> {
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<void> {
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<void> {
if (this.seen.get()) {
return;
}
const request = ++this.styleRequest;
const inspection = this.configurationService.inspect<string>(AUTOMATIONS_NEW_BADGE_STYLE_SETTING);
let value: string | undefined;
if (isConfigured(inspection)) {
value = inspection.value;
} else {
try {
value = await this.assignmentService.getTreatment<string>(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);
}
}
}
@@ -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 {
@@ -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<IConfigurationRegistry>(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."),
},
},
});
@@ -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<SessionListItem, Fu
private readonly contextKeyService: IContextKeyService,
private readonly automationService: IAutomationService,
private readonly automationSessions: IObservable<readonly ISession[]>,
private readonly automationNewBadgePresentation: IObservable<AutomationsNewBadgeStyle | undefined>,
private readonly uriIdentityService: IUriIdentityService,
private readonly customViewService: ICustomViewService,
private readonly menuService: IMenuService,
@@ -1339,6 +1342,9 @@ export class SessionSectionRenderer implements ITreeRenderer<SessionListItem, Fu
icon.setAttribute('aria-hidden', 'true');
const label = DOM.append(container, $('span.session-section-label'));
const count = DOM.append(container, $('span.session-section-count'));
const newBadge = DOM.append(container, $('span.session-section-new-badge'));
newBadge.setAttribute('aria-hidden', 'true');
newBadge.textContent = localize('automationsNewBadge', "New");
const toolbarContainer = DOM.append(container, $('.session-section-toolbar'));
const contextKeyService = disposables.add(this.contextKeyService.createScoped(container));
@@ -1383,7 +1389,7 @@ export class SessionSectionRenderer implements ITreeRenderer<SessionListItem, Fu
},
}));
return { container, icon, label, count, toolbarContainer, toolbar, chevron, contextKeyService, elementDisposables, disposables };
return { container, icon, label, count, newBadge, toolbarContainer, toolbar, chevron, contextKeyService, elementDisposables, disposables };
}
renderElement(node: ITreeNode<SessionListItem, FuzzyScore>, _index: number, template: ISessionSectionTemplate): void {
@@ -1397,6 +1403,12 @@ export class SessionSectionRenderer implements ITreeRenderer<SessionListItem, Fu
this.templatesById.set(element.id, template);
template.container.classList.remove(SESSION_HEADER_DROP_TARGET_CLASS);
template.container.classList.remove('session-section-shortcut');
template.newBadge.style.display = 'none';
template.newBadge.classList.remove(
'session-section-new-badge-accent',
'session-section-new-badge-soft',
'session-section-new-badge-outline',
);
if (element.id === AUTOMATIONS_SECTION_ID) {
template.container.classList.add('session-section-shortcut');
}
@@ -1412,6 +1424,11 @@ export class SessionSectionRenderer implements ITreeRenderer<SessionListItem, Fu
template.elementDisposables.add(autorun(reader => {
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<boolean>;
/** 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<readonly ISession[]>(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<void> {
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<boolean>(ChatAutomationsEnabledContext.key)) {
void this.automationsNewBadgeState.initialize().catch(onUnexpectedError);
children.push(renderSection({ id: AUTOMATIONS_SECTION_ID, label: localize('automations', "Automations"), sessions: [] }));
}
@@ -60,6 +60,40 @@ export const SessionsViewGroupingContext = new RawContextKey<string>('sessionsVi
export const SessionsViewSortingContext = new RawContextKey<string>('sessionsViewPane.sorting', SessionsSorting.Created);
export const IsWorkspaceGroupCappedContext = new RawContextKey<boolean>('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'));
@@ -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<void> {
const view = await accessor.get(IViewsService).openView<SessionsView>(SessionsViewId, false);
await view?.sessionsControl?.resetAutomationsNewBadge();
}
});
const MARK_ALL_AUTOMATION_RUNS_READ_COMMAND_ID = 'sessionsView.markAllAutomationRunsRead';
registerAction2(class MarkAllAutomationRunsReadAction extends Action2 {
@@ -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<IWorkbenchAssignmentService>() {
readonly treatments: string[] = [];
override readonly onDidRefetchAssignments;
constructor(
private readonly style: AutomationsNewBadgeStyle | undefined,
refetchAssignments: Emitter<void>,
private readonly error?: Error,
) {
super();
this.onDidRefetchAssignments = refetchAssignments.event;
}
override async getTreatment<T extends string | number | boolean>(name: string): Promise<T | undefined> {
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<readonly IAutomationDescriptor[]>(disposables, options.automations ?? []);
const runs = observableValue<readonly IAutomationRun[]>(disposables, options.runs ?? []);
const activeView = observableValue<ICustomViewDescriptor | undefined>(disposables, options.activeView);
const automationService = new class extends mock<IAutomationService>() {
override readonly automations = automations;
override readonly runs = runs;
};
const customViewService = new class extends mock<ICustomViewService>() {
override readonly activeCustomView = activeView;
};
const refetchAssignments = disposables.add(new Emitter<void>());
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<IAutomationDescriptor>({ id: 'late-automation' })], undefined);
runs.set([upcastPartial<IAutomationRun>({ 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<ICustomViewDescriptor>({ 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<IConfigurationChangeEvent>({
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<IAutomationDescriptor>({ 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<IAutomationDescriptor>({ id: 'existing-automation' })],
});
const run = createState({
runs: [upcastPartial<IAutomationRun>({ 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<ICustomViewDescriptor>({ 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' },
});
});
});
@@ -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<IUriIdentityService>() {
override readonly extUri = new ExtUri(() => true);
},
@@ -171,6 +177,7 @@ suite('Sessions - SessionsList', () => {
contextKeyService,
automationService,
constObservable([]),
constObservable(undefined),
new class extends mock<IUriIdentityService>() {
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<MenuWorkbenchToolBar>() {
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<ISessionsListModelService>() { });
const contextKeyService = disposables.add(new ContextKeyService(new TestConfigurationService()));
const automationService = new class extends mock<IAutomationService>() {
override readonly runs = constObservable<readonly IAutomationRun[]>([]);
};
const renderer = new SessionSectionRenderer(
true,
() => { },
instantiationService,
contextKeyService,
automationService,
constObservable([]),
constObservable('outline'),
new class extends mock<IUriIdentityService>() {
override readonly extUri = new ExtUri(() => true);
},
new class extends mock<ICustomViewService>() {
override readonly activeCustomView = constObservable(undefined);
},
new class extends mock<IMenuService>() { },
);
const container = document.createElement('div');
const template = renderer.renderTemplate(container);
disposables.add(template.disposables);
renderer.renderElement(upcastPartial<Parameters<SessionSectionRenderer['renderElement']>[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<Parameters<SessionSectionRenderer['renderElement']>[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<ICustomViewDescriptor | undefined>(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<IAutomationService>() {
override readonly automations = constObservable([]);
override readonly runs = constObservable([]);
});
instantiationService.stub(ICustomViewService, new class extends mock<ICustomViewService>() {
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<HTMLElement>('.monaco-list-row');
const before = row?.getAttribute('aria-label');
activeCustomView.set(upcastPartial<ICustomViewDescriptor>({ 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<IContextKeyService>() { },
automationService,
automationSessions,
constObservable(undefined),
uriIdentityService,
new class extends mock<ICustomViewService>() { },
new class extends mock<IMenuService>() { },
@@ -301,6 +409,7 @@ suite('Sessions - SessionsList', () => {
new class extends mock<IContextKeyService>() { },
automationService,
constObservable([runningSession, needsInputSession]),
constObservable(undefined),
uriIdentityService,
new class extends mock<ICustomViewService>() { },
new class extends mock<IMenuService>() { },
@@ -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<string, IActionViewItemFactory>();
private readonly changeEmitter = this._register(new Emitter<MenuId>());
readonly onDidChange = this.changeEmitter.event;
register(menu: MenuId, commandId: string | MenuId, provider: IActionViewItemFactory, event?: Event<unknown>): 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<void> {
async function renderSessionsList(ctx: ComponentFixtureContext, options: IRenderOptions): Promise<void> {
const { container, disposableStore } = ctx;
const approvals = new Map<string, IAgentSessionApprovalInfo>();
const sessions = options.sessions.map(spec => createSession(spec, approvals));
const approvalModel = createApprovalModel(approvals);
const groups = options.groups ?? [];
const automationRuns = observableValue<readonly IAutomationRun[]>(disposableStore, []);
const actionViewItemService = disposableStore.add(new FixtureActionViewItemService());
const membership = new Map<string, string>();
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<MenuItemAction>() {
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<IAutomationService>() {
override readonly runs = constObservable([]);
override readonly automations = constObservable([]);
override readonly runs = automationRuns;
}());
reg.defineInstance(IWorkbenchAssignmentService, new class extends mock<IWorkbenchAssignmentService>() {
override readonly onDidRefetchAssignments = Event.None;
@@ -288,13 +339,40 @@ function renderSessionsList(ctx: ComponentFixtureContext, options: IRenderOption
reg.defineInstance(IUriIdentityService, new class extends mock<IUriIdentityService>() {
override readonly extUri = new ExtUri(() => true);
}());
reg.defineInstance(ICustomViewService, new class extends mock<ICustomViewService>() { }());
reg.defineInstance(ICustomViewService, new class extends mock<ICustomViewService>() {
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<IMenuService>() {
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 }),
}),
@@ -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)