Merge pull request #335533 from microsoft/benibenj/agents/unread-icon-collapsed-groups

sessions: Show status indicators in collapsed sections
This commit is contained in:
Benjamin Christopher Simmonds
2026-09-11 15:55:57 +02:00
committed by GitHub
7 changed files with 837 additions and 46 deletions
@@ -19,7 +19,7 @@ import { AutomationsCustomViewContribution } from './views/automationsView.js';
import './views/sessionsViewActions.js';
import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.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 { SESSIONS_LIST_SHOW_EMPTY_DEFAULT_GROUPS_SETTING, SESSIONS_LIST_SHOW_UNREAD_IN_COLLAPSED_SECTIONS_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';
@@ -72,6 +72,13 @@ Registry.as<IConfigurationRegistry>(ConfigurationExtensions.Configuration).regis
default: true,
experiment: { mode: 'auto' }
},
[SESSIONS_LIST_SHOW_UNREAD_IN_COLLAPSED_SECTIONS_SETTING]: {
type: 'boolean',
tags: ['preview'],
description: localize('sessions.list.showUnreadInCollapsedSections', "Controls whether collapsed sections in the sessions list show needs-input, CI-failure, or unread indicators for the unarchived sessions they contain."),
default: false,
experiment: { mode: 'auto' }
},
[SESSIONS_ARCHIVE_SESSION_CONFETTI_SETTING]: {
type: 'boolean',
tags: ['preview'],
@@ -19,7 +19,7 @@ import { createMatches, FuzzyScore, IMatch } from '../../../../../base/common/fi
import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js';
import { MarkdownString } from '../../../../../base/common/htmlContent.js';
import { constObservable, IObservable, IReader, ISettableObservable, autorun, derived, observableSignalFromEvent, observableValue } from '../../../../../base/common/observable.js';
import { ThemeIcon } from '../../../../../base/common/themables.js';
import { ThemeIcon, themeColorFromId } from '../../../../../base/common/themables.js';
import { URI } from '../../../../../base/common/uri.js';
import { fromNow } from '../../../../../base/common/date.js';
import { KeyCode } from '../../../../../base/common/keyCodes.js';
@@ -44,6 +44,7 @@ import { asCssVariable } from '../../../../../platform/theme/common/colorUtils.j
import { chartsOrange } from '../../../../../platform/theme/common/colors/chartsColors.js';
import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js';
import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';
import { observableConfigValue } from '../../../../../platform/observable/common/platformObservableUtils.js';
import { IUriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentity.js';
import { IOpenerService } from '../../../../../platform/opener/common/opener.js';
import { ILabelService } from '../../../../../platform/label/common/label.js';
@@ -102,6 +103,7 @@ import { AutomationsNewBadgeState, type AutomationsNewBadgeStyle } from '../auto
import { Menus } from '../../../../browser/menus.js';
import { getSessionConversationStatusAriaLabel } from '../../../../browser/sessionConversationGroups.js';
import { getAgentMergeAwarePullRequestIcon, getSessionAgentMergeConfigurationObservable, ISessionAgentMergeConfiguration, isAgentMergePullRequestIcon } from '../../../../browser/sessionAgentMerge.js';
import { BlockedSessionReason, BlockedSessions } from '../../../blockedSessions/browser/blockedSessions.js';
const $ = DOM.$;
@@ -119,6 +121,7 @@ export const NEW_SESSION_FOR_WORKSPACE_ACTION_ID = 'sessionsView.sectionNewSessi
/** Controls whether the empty default Chats group is shown in the sessions list. */
export const SESSIONS_LIST_SHOW_EMPTY_DEFAULT_GROUPS_SETTING = 'sessions.list.showEmptyDefaultGroups';
export const SESSIONS_LIST_SHOW_UNREAD_IN_COLLAPSED_SECTIONS_SETTING = 'sessions.list.showUnreadInCollapsedSections';
export const IsSessionPinnedContext = new RawContextKey<boolean>('sessionItem.isPinned', false);
export const SessionItemHasBranchNameContext = new RawContextKey<boolean>('sessionItem.hasBranchName', false);
@@ -1274,11 +1277,59 @@ function getWorkspaceBadgeLabel(workspace: ISessionWorkspace): string | undefine
//#region Section Header Renderer
interface ISessionHeaderTemplate {
readonly icon: HTMLElement;
readonly collapsed: ISettableObservable<boolean>;
readonly toolbarContainer: HTMLElement;
readonly toolbar: MenuWorkbenchToolBar;
readonly elementDisposables: DisposableStore;
}
const enum SessionHeaderStatus {
NeedsInput,
FailingCI,
Unread,
}
function getSessionHeaderStatus(sessions: readonly ISession[], reader: IReader, sessionsWithFailingCI: ReadonlySet<string> | undefined): SessionHeaderStatus | undefined {
let hasFailingCI = false;
let hasUnread = false;
for (const session of sessions) {
if (session.isArchived.read(reader)) {
continue;
}
const status = session.status.read(reader);
if (status === SessionStatus.NeedsInput) {
return SessionHeaderStatus.NeedsInput;
}
hasFailingCI ||= status !== SessionStatus.InProgress && sessionsWithFailingCI?.has(session.sessionId) === true;
hasUnread ||= !session.isRead.read(reader);
}
return hasFailingCI ? SessionHeaderStatus.FailingCI : hasUnread ? SessionHeaderStatus.Unread : undefined;
}
function renderSessionHeaderIcon(template: ISessionHeaderTemplate, sessions: readonly ISession[], icon: ThemeIcon | undefined, showUnreadInCollapsedSections: IObservable<boolean>, sessionsWithFailingCI: IObservable<ReadonlySet<string>>, instantiationService: IInstantiationService): void {
const headerStatus = derived(reader => template.collapsed.read(reader) && showUnreadInCollapsedSections.read(reader)
? getSessionHeaderStatus(sessions, reader, sessionsWithFailingCI.read(reader))
: undefined);
template.elementDisposables.add(autorun(reader => {
const status = headerStatus.read(reader);
DOM.clearNode(template.icon);
template.icon.className = 'session-section-icon';
template.icon.style.display = status !== undefined || icon ? '' : 'none';
if (status !== undefined) {
const statusIcon = reader.store.add(instantiationService.createInstance(SessionStatusIcon, template.icon));
statusIcon.setStatus(
status === SessionHeaderStatus.NeedsInput ? SessionStatus.NeedsInput : SessionStatus.Completed,
status !== SessionHeaderStatus.Unread,
false,
status === SessionHeaderStatus.FailingCI ? { ...Codicon.circleFilled, color: themeColorFromId('list.warningForeground') } : undefined,
);
} else if (icon) {
template.icon.classList.add(...ThemeIcon.asClassNameArray(icon));
}
}));
}
function renderSessionHeaderToolbar<T>(template: ISessionHeaderTemplate, element: T, select: (element: T, event: MouseEvent) => void): void {
template.elementDisposables.add(DOM.addDisposableListener(template.toolbarContainer, DOM.EventType.CONTEXT_MENU, event => select(element, event), true));
template.toolbar.context = element;
@@ -1286,7 +1337,6 @@ function renderSessionHeaderToolbar<T>(template: ISessionHeaderTemplate, element
interface ISessionSectionTemplate extends ISessionHeaderTemplate {
readonly container: HTMLElement;
readonly icon: HTMLElement;
readonly label: HTMLElement;
readonly count: HTMLElement;
readonly newBadge: HTMLElement;
@@ -1338,6 +1388,8 @@ export class SessionSectionRenderer implements ITreeRenderer<SessionListItem, Fu
constructor(
private readonly hideSectionCount: boolean,
private readonly select: (element: ISessionSection, event: MouseEvent) => void,
private readonly showUnreadInCollapsedSections: IObservable<boolean>,
private readonly sessionsWithFailingCI: IObservable<ReadonlySet<string>>,
private readonly instantiationService: IInstantiationService,
private readonly contextKeyService: IContextKeyService,
private readonly automationService: IAutomationService,
@@ -1411,7 +1463,7 @@ export class SessionSectionRenderer implements ITreeRenderer<SessionListItem, Fu
},
}));
return { container, icon, label, count, newBadge, toolbarContainer, toolbar, chevron, contextKeyService, elementDisposables, disposables };
return { container, icon, collapsed: observableValue(this, false), label, count, newBadge, toolbarContainer, toolbar, chevron, contextKeyService, elementDisposables, disposables };
}
renderElement(node: ITreeNode<SessionListItem, FuzzyScore>, _index: number, template: ISessionSectionTemplate): void {
@@ -1435,14 +1487,11 @@ export class SessionSectionRenderer implements ITreeRenderer<SessionListItem, Fu
template.container.classList.add('session-section-shortcut');
}
// Leading icon for the "Pinned" and "Chats" (quick chats) section headers.
// Templates are reused across rows, so recompute the icon every render.
DOM.clearNode(template.icon);
const sectionIcon = getSessionSectionIcon(element.id);
template.icon.className = sectionIcon ? `session-section-icon ${ThemeIcon.asClassName(sectionIcon)}` : 'session-section-icon';
template.icon.style.display = sectionIcon ? '' : 'none';
this.updateChevron(template, node.collapsible, node.collapsed);
if (element.id === AUTOMATIONS_SECTION_ID) {
DOM.clearNode(template.icon);
template.icon.style.display = '';
template.elementDisposables.add(autorun(reader => {
const activeCustomView = this.customViewService.activeCustomView.read(reader);
template.container.classList.toggle('active', activeCustomView?.id === AUTOMATIONS_CUSTOM_VIEW_ID);
@@ -1473,6 +1522,8 @@ export class SessionSectionRenderer implements ITreeRenderer<SessionListItem, Fu
template.icon.className = `session-section-icon ${ThemeIcon.asClassName(Codicon.calendar)}`;
}
}));
} else {
renderSessionHeaderIcon(template, element.sessions, getSessionSectionIcon(element.id), this.showUnreadInCollapsedSections, this.sessionsWithFailingCI, this.instantiationService);
}
template.label.textContent = element.label;
@@ -1484,9 +1535,6 @@ export class SessionSectionRenderer implements ITreeRenderer<SessionListItem, Fu
template.count.style.display = '';
}
this.updateChevron(template, node.collapsible, node.collapsed);
template.chevron.classList.toggle('collapsible', node.collapsible);
// Set context key for section type so toolbar actions can use when clauses
const sectionType = element.id.startsWith('workspace:') ? 'workspace' : element.id;
SessionSectionTypeContext.bindTo(template.contextKeyService).set(sectionType);
@@ -1508,11 +1556,7 @@ export class SessionSectionRenderer implements ITreeRenderer<SessionListItem, Fu
}));
}
/**
* Updates the expand/collapse chevron for an already-rendered section. The
* tree only re-invokes `renderTwistie` (not `renderElement`) when a section's
* collapse state toggles, so the owning list forwards collapse changes here.
*/
/** Updates the leading icon and chevron when the tree toggles a section without re-rendering it. */
updateCollapseState(element: ISessionSection, collapsed: boolean): void {
const template = this.templatesByElement.get(element);
if (template) {
@@ -1526,6 +1570,7 @@ export class SessionSectionRenderer implements ITreeRenderer<SessionListItem, Fu
}
private updateChevron(template: ISessionSectionTemplate, collapsible: boolean, collapsed: boolean): void {
template.collapsed.set(collapsible && collapsed, undefined);
template.chevron.className = 'session-section-chevron';
if (collapsible) {
template.chevron.classList.add('collapsible');
@@ -1578,6 +1623,8 @@ class SessionGroupRenderer implements ITreeRenderer<SessionListItem, FuzzyScore,
constructor(
private readonly delegate: ISessionGroupRendererDelegate,
private readonly showUnreadInCollapsedSections: IObservable<boolean>,
private readonly sessionsWithFailingCI: IObservable<ReadonlySet<string>>,
private readonly instantiationService: IInstantiationService,
private readonly contextKeyService: IContextKeyService,
) { }
@@ -1589,7 +1636,6 @@ class SessionGroupRenderer implements ITreeRenderer<SessionListItem, FuzzyScore,
const chevron = DOM.append(container, $('span.session-section-chevron'));
chevron.setAttribute('aria-hidden', 'true');
const icon = DOM.append(container, $('span.session-section-icon'));
icon.classList.add(...ThemeIcon.asClassNameArray(Codicon.folderLibrary));
icon.setAttribute('aria-hidden', 'true');
const label = DOM.append(container, $('span.session-section-label'));
const inputContainer = DOM.append(container, $('.session-group-input'));
@@ -1601,7 +1647,7 @@ class SessionGroupRenderer implements ITreeRenderer<SessionListItem, FuzzyScore,
menuOptions: { shouldForwardArgs: true },
}));
return { container, label, inputContainer, toolbarContainer, toolbar, chevron, contextKeyService, disposables, elementDisposables: disposables.add(new DisposableStore()) };
return { container, icon, collapsed: observableValue(this, false), label, inputContainer, toolbarContainer, toolbar, chevron, contextKeyService, disposables, elementDisposables: disposables.add(new DisposableStore()) };
}
renderElement(node: ITreeNode<SessionListItem, FuzzyScore>, _index: number, template: ISessionGroupTemplate): void {
@@ -1617,6 +1663,7 @@ class SessionGroupRenderer implements ITreeRenderer<SessionListItem, FuzzyScore,
template.label.textContent = element.group.name;
this.updateChevron(template, node.collapsible, node.collapsed);
renderSessionHeaderIcon(template, element.sessions, Codicon.folderLibrary, this.showUnreadInCollapsedSections, this.sessionsWithFailingCI, this.instantiationService);
SessionGroupHasVisibleSessionsContext.bindTo(template.contextKeyService).set(element.sessions.length > 0);
SessionGroupIsEmptyContext.bindTo(template.contextKeyService).set(element.isEmpty);
@@ -1686,6 +1733,7 @@ class SessionGroupRenderer implements ITreeRenderer<SessionListItem, FuzzyScore,
}
private updateChevron(template: ISessionGroupTemplate, collapsible: boolean, collapsed: boolean): void {
template.collapsed.set(collapsible && collapsed, undefined);
template.chevron.className = 'session-section-chevron';
if (collapsible) {
template.chevron.classList.add('collapsible');
@@ -1791,6 +1839,8 @@ interface ISessionsAccessibilityProviderOptions {
readonly isRenderedInCustomGroup?: (session: ISession) => boolean;
readonly includeQuickChatInAriaLabel?: boolean;
readonly automationNewBadgeVisible?: IObservable<boolean>;
readonly showUnreadInCollapsedSections?: IObservable<boolean>;
readonly sessionsWithFailingCI?: IObservable<ReadonlySet<string>>;
/** Mirrors {@link SessionItemRenderer}'s option of the same name — see there for rationale. */
readonly deriveStatusFromMainChat?: boolean;
}
@@ -1816,7 +1866,7 @@ class SessionsAccessibilityProvider {
));
}
if (isSessionGroupItem(element)) {
return `${element.group.name}, ${element.sessions.length}`;
return this.getSectionAriaLabel(element.group.name, element.sessions);
}
if (isSessionSection(element)) {
if (element.id === AUTOMATIONS_SECTION_ID) {
@@ -1838,7 +1888,7 @@ class SessionsAccessibilityProvider {
: label;
});
}
return `${element.label}, ${element.sessions.length}`;
return this.getSectionAriaLabel(element.label, element.sessions);
}
if (isSessionShowMore(element)) {
if (element.mode === 'less') {
@@ -1891,6 +1941,22 @@ class SessionsAccessibilityProvider {
return label;
});
}
private getSectionAriaLabel(label: string, sessions: readonly ISession[]): IObservable<string> {
return derived(this, reader => {
const status = this.options?.showUnreadInCollapsedSections?.read(reader) ? getSessionHeaderStatus(sessions, reader, this.options.sessionsWithFailingCI?.read(reader)) : undefined;
switch (status) {
case SessionHeaderStatus.NeedsInput:
return localize('sessionSectionNeedsInputAria', "{0}, {1}, session needs input", label, sessions.length);
case SessionHeaderStatus.FailingCI:
return localize('sessionSectionFailingCIAria', "{0}, {1}, session has failing CI checks", label, sessions.length);
case SessionHeaderStatus.Unread:
return localize('sessionSectionUnreadAria', "{0}, {1}, contains unread sessions", label, sessions.length);
default:
return localize('sessionSectionAria', "{0}, {1}", label, sessions.length);
}
});
}
}
//#endregion
@@ -2641,9 +2707,20 @@ export class SessionsList extends Disposable implements ISessionsList {
this.tree.setFocus([element], event);
this.tree.setSelection([element], event);
};
const showUnreadInCollapsedSections = observableConfigValue(SESSIONS_LIST_SHOW_UNREAD_IN_COLLAPSED_SECTIONS_SETTING, false, this.configurationService);
const blockedSessions = derived(this, reader => showUnreadInCollapsedSections.read(reader)
? reader.store.add(instantiationService.createInstance(BlockedSessions))
: undefined);
const sessionsWithFailingCI = derived(this, reader => new Set(
blockedSessions.read(reader)?.blockedSessionsWithReasons.read(reader)
.filter(blocked => blocked.reason === BlockedSessionReason.FailingCI)
.map(blocked => blocked.session.sessionId)
));
const sectionRenderer = new SessionSectionRenderer(
true /* hideSectionCount */,
selectHeader,
showUnreadInCollapsedSections,
sessionsWithFailingCI,
instantiationService,
contextKeyService,
this.automationService,
@@ -2658,7 +2735,7 @@ export class SessionsList extends Disposable implements ISessionsList {
commitEdit: (group, name) => this.commitGroupEdit(group, name),
cancelEdit: group => this.cancelGroupEdit(group),
select: selectHeader,
}, instantiationService, contextKeyService);
}, showUnreadInCollapsedSections, sessionsWithFailingCI, instantiationService, contextKeyService);
this._groupRenderer = groupRenderer;
// Read (don't bind) `IsPhoneLayoutContext` from the parent context so we
@@ -2696,6 +2773,8 @@ export class SessionsList extends Disposable implements ISessionsList {
isRenderedInCustomGroup: session => this.isRenderedInCustomGroup(session),
deriveStatusFromMainChat: true,
automationNewBadgeVisible: this.automationsNewBadgeState.showNewBadge,
showUnreadInCollapsedSections,
sessionsWithFailingCI,
}),
dnd: this._register(new SessionsListDragAndDrop({
isReorderable: session => this.isReorderable(session),
@@ -0,0 +1,32 @@
/*---------------------------------------------------------------------------------------------
* 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 { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';
import { Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../../platform/configuration/common/configurationRegistry.js';
import { Registry } from '../../../../../platform/registry/common/platform.js';
import { SESSIONS_LIST_SHOW_UNREAD_IN_COLLAPSED_SECTIONS_SETTING } from '../../browser/views/sessionsList.js';
import '../../browser/sessions.contribution.js';
const configurationRegistry = Registry.as<IConfigurationRegistry>(ConfigurationExtensions.Configuration);
// Capture the registered schema before configuration tests reset the shared registry.
const collapsedSectionStatusProperty = configurationRegistry.getConfigurationProperties()[SESSIONS_LIST_SHOW_UNREAD_IN_COLLAPSED_SECTIONS_SETTING];
suite('Sessions Contribution', () => {
ensureNoDisposablesAreLeakedInTestSuite();
test('disables collapsed section status indicators by default with automatic experiments', () => {
assert.deepStrictEqual({
type: collapsedSectionStatusProperty.type,
default: collapsedSectionStatusProperty.default,
experiment: collapsedSectionStatusProperty.experiment,
}, {
type: 'boolean',
default: false,
experiment: { mode: 'auto' },
});
});
});
@@ -22,7 +22,7 @@ import { IMenu, IMenuService, MenuId, MenuItemAction } from '../../../../../plat
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 { IConfigurationChangeEvent, IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';
import { IHoverService } from '../../../../../platform/hover/browser/hover.js';
import { NullHoverService } from '../../../../../platform/hover/test/browser/nullHoverService.js';
import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';
@@ -42,21 +42,22 @@ import { ARCHIVE_SESSION_COMMAND_ID } from '../../../../common/sessionCommands.j
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 { ISessionsListModelService, SessionsListModelService } from '../../../../services/sessions/browser/sessionsListModelService.js';
import { ISessionGroup, ISessionGroupsChangeEvent, ISessionGroupsService } from '../../../../services/sessions/browser/sessionGroupsService.js';
import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js';
import { ChatInteractivity, ChatOriginKind, IChat, ISession, ISessionChangeset, ISessionChangesSummary, ISessionFileChange, SessionStatus } from '../../../../services/sessions/common/session.js';
import { IActiveSession, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js';
import { ISessionsProvider } from '../../../../services/sessions/common/sessionsProvider.js';
import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js';
import { computeReorderSortChanges, groupByDate, groupByWorkspace, groupSessionsForList, ISessionSection, limitSessionsForList, SessionItemToolbarMenuId, SessionSectionRenderer, SESSIONS_LIST_SHOW_EMPTY_DEFAULT_GROUPS_SETTING, SessionsFlatList, SessionsList, SessionsListFocusedChatItemContext, sortSessions, SessionsGrouping, SessionsSorting } from '../../browser/views/sessionsList.js';
import { computeReorderSortChanges, groupByDate, groupByWorkspace, groupSessionsForList, ISessionSection, limitSessionsForList, SessionItemToolbarMenuId, SessionSectionRenderer, SESSIONS_LIST_SHOW_EMPTY_DEFAULT_GROUPS_SETTING, SESSIONS_LIST_SHOW_UNREAD_IN_COLLAPSED_SECTIONS_SETTING, SessionsFlatList, SessionsList, SessionsListFocusedChatItemContext, sortSessions, SessionsGrouping, SessionsSorting } from '../../browser/views/sessionsList.js';
import { AgentSessionApprovalKind, AgentSessionApprovalModel, IAgentSessionApprovalInfo } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentSessionApprovalModel.js';
import { getSessionDiffStats, getSessionSummaryHoverData } from '../../browser/sessionHoverContent.js';
import { createListHarness, createTestSession, ISortChangeRecord } from './sessionsListTestUtils.js';
import { createListHarness, createTestSession, IListHarnessOptions, ISortChangeRecord } 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, type AutomationsNewBadgeStyle } from '../../browser/automationsNewBadge.js';
import { BlockedSessionReason, BlockedSessions } from '../../../blockedSessions/browser/blockedSessions.js';
function createSession(id: string, opts: {
workspaceLabel?: string;
@@ -127,6 +128,8 @@ suite('Sessions - SessionsList', () => {
const renderer = new SessionSectionRenderer(
true,
section => selectedSections.push(section),
constObservable(true),
constObservable(new Set<string>()),
instantiationService,
contextKeyService,
automationService,
@@ -180,6 +183,8 @@ suite('Sessions - SessionsList', () => {
const renderer = new SessionSectionRenderer(
true,
() => { },
constObservable(true),
constObservable(new Set<string>()),
instantiationService,
contextKeyService,
automationService,
@@ -238,6 +243,8 @@ suite('Sessions - SessionsList', () => {
const renderer = new SessionSectionRenderer(
true,
() => { },
constObservable(true),
constObservable(new Set<string>()),
instantiationService,
contextKeyService,
automationService,
@@ -413,6 +420,8 @@ suite('Sessions - SessionsList', () => {
const renderer = new SessionSectionRenderer(
true,
() => { },
constObservable(true),
constObservable(new Set<string>()),
new class extends mock<IInstantiationService>() { },
new class extends mock<IContextKeyService>() { },
automationService,
@@ -481,6 +490,8 @@ suite('Sessions - SessionsList', () => {
const renderer = new SessionSectionRenderer(
true,
() => { },
constObservable(true),
constObservable(new Set<string>()),
new class extends mock<IInstantiationService>() { },
new class extends mock<IContextKeyService>() { },
automationService,
@@ -521,6 +532,531 @@ suite('Sessions - SessionsList', () => {
});
});
suite('collapsed section status indicators', () => {
const group: ISessionGroup = { id: 'group-a', name: 'Group A', createdAt: 1 };
function renderList(sessions: ISession[], options: IListHarnessOptions & { useDefaultSetting?: boolean } = {}, reducedMotion = false) {
const harness = createListHarness(disposables, sessions, options);
if (!options.useDefaultSetting) {
(harness.instantiationService.get(IConfigurationService) as TestConfigurationService).setUserConfiguration(SESSIONS_LIST_SHOW_UNREAD_IN_COLLAPSED_SECTIONS_SETTING, true);
}
harness.instantiationService.stub(ISessionsListModelService, 'getStatusIcon', SessionsListModelService.prototype.getStatusIcon);
harness.instantiationService.stub(IAccessibilityService, new class extends TestAccessibilityService {
override isMotionReduced(): boolean { return reducedMotion; }
}());
const failingCISessions = observableValue<readonly ISession[]>('failingCISessions', []);
harness.instantiationService.stubInstance(BlockedSessions, new class extends mock<BlockedSessions>() {
override readonly blockedSessionsWithReasons = derived(reader => failingCISessions.read(reader).map(session => ({
session,
reason: BlockedSessionReason.FailingCI,
occurrenceId: 'failingCI:head',
})));
override dispose(): void { }
}());
const container = harness.createContainer(400, 700);
const list = harness.store.add(harness.instantiationService.createInstance(SessionsList, container, {
grouping: () => SessionsGrouping.Workspace,
sorting: () => SessionsSorting.Created,
onSessionOpen: () => { },
}));
list.layout(700, 400);
return { ...harness, list, container, failingCISessions };
}
function unreadSections(container: HTMLElement): string[] {
return [...container.querySelectorAll('.session-section')]
.filter(header => header.querySelector('.session-section-icon .codicon-circle-filled'))
.map(header => header.querySelector('.session-section-label')!.textContent!);
}
function needsInputSections(container: HTMLElement): string[] {
return [...container.querySelectorAll('.session-section')]
.filter(header => header.querySelector('.session-section-icon .monaco-pixel-spinner-ring'))
.map(header => header.querySelector('.session-section-label')!.textContent!);
}
function getHeader(container: HTMLElement, label: string): HTMLElement {
const header = [...container.querySelectorAll<HTMLElement>('.session-section')]
.find(header => header.querySelector('.session-section-label')?.textContent === label);
assert.ok(header, `Expected section ${label}`);
return header;
}
test('keeps normal section icons and labels when the setting is unset', () => {
const unread = createTestSession('Unread', { workspaceLabel: 'Unread workspace', isRead: false }).session;
const failingCI = createTestSession('Failing CI', { workspaceLabel: 'CI workspace' }).session;
const needsInput = createTestSession('Needs input', { status: SessionStatus.NeedsInput }).session;
const { list, container, failingCISessions } = renderList([unread, failingCI, needsInput], {
groups: [group],
memberships: new Map([[needsInput.sessionId, group.id]]),
useDefaultSetting: true,
});
failingCISessions.set([failingCI], undefined);
list.collapseAllSections();
assert.deepStrictEqual([group.name, 'CI workspace', 'Unread workspace'].map(label => {
const header = getHeader(container, label);
return {
ariaLabel: header.closest('.monaco-list-row')?.getAttribute('aria-label'),
icon: header.querySelector('.session-section-icon')?.className,
indicator: !!header.querySelector('.codicon-circle-filled, .monaco-pixel-spinner'),
};
}), [
{ ariaLabel: 'Group A, 1', icon: 'session-section-icon codicon codicon-folder-library', indicator: false },
{ ariaLabel: 'CI workspace, 1', icon: 'session-section-icon codicon codicon-folder', indicator: false },
{ ariaLabel: 'Unread workspace, 1', icon: 'session-section-icon codicon codicon-folder', indicator: false },
]);
});
test('marks the group containing an unread session, not the session workspace', () => {
const grouped = createTestSession('Grouped unread', { workspaceLabel: 'Workspace B', isRead: false }).session;
const read = createTestSession('Workspace read', { workspaceLabel: 'Workspace B' }).session;
const unread = createTestSession('Workspace unread', { workspaceLabel: 'Workspace C', isRead: false }).session;
const { list, container } = renderList([grouped, read, unread], {
groups: [group, { id: 'empty', name: 'Empty group', createdAt: 2 }],
memberships: new Map([[grouped.sessionId, group.id]]),
});
list.collapseAllSections();
assert.deepStrictEqual({
unreadSections: unreadSections(container),
groupAria: getHeader(container, group.name).closest('.monaco-list-row')?.getAttribute('aria-label'),
workspaceAria: getHeader(container, 'Workspace B').closest('.monaco-list-row')?.getAttribute('aria-label'),
color: getHeader(container, group.name).querySelector<HTMLElement>('.codicon-circle-filled')?.style.color,
}, {
unreadSections: [group.name, 'Workspace C'],
groupAria: 'Group A, 1, contains unread sessions',
workspaceAria: 'Workspace B, 1',
color: 'var(--vscode-textLink-foreground)',
});
});
test('shows needs-input only in the containing section and respects filters and pins', () => {
const grouped = createTestSession('Grouped needs input', { workspaceLabel: 'Workspace B', status: SessionStatus.NeedsInput }).session;
const read = createTestSession('Workspace read', { workspaceLabel: 'Workspace B' }).session;
const needsInput = createTestSession('Workspace needs input', { workspaceLabel: 'Workspace C', status: SessionStatus.NeedsInput }).session;
const pinnedSessionIds = new Set<string>();
const { list, container } = renderList([grouped, read, needsInput], {
groups: [group],
memberships: new Map([[grouped.sessionId, group.id]]),
pinnedSessionIds,
});
list.collapseAllSections();
const states = [needsInputSections(container)];
list.setStatusExcluded(SessionStatus.NeedsInput, true);
states.push(needsInputSections(container));
list.setStatusExcluded(SessionStatus.NeedsInput, false);
states.push(needsInputSections(container));
pinnedSessionIds.add(grouped.sessionId);
list.update();
states.push(needsInputSections(container));
assert.deepStrictEqual(states, [
[group.name, 'Workspace C'],
[],
[group.name, 'Workspace C'],
['Pinned', 'Workspace C'],
]);
});
test('shows CI failures only in their containing sections and respects filters and pins', () => {
const grouped = createTestSession('Grouped CI failure', { workspaceLabel: 'Workspace B', status: SessionStatus.Error }).session;
const read = createTestSession('Workspace read', { workspaceLabel: 'Workspace B' }).session;
const failingCI = createTestSession('Workspace CI failure', { workspaceLabel: 'Workspace C', status: SessionStatus.Error }).session;
const pinnedSessionIds = new Set<string>();
const { list, container, failingCISessions } = renderList([grouped, read, failingCI], {
groups: [group],
memberships: new Map([[grouped.sessionId, group.id]]),
pinnedSessionIds,
});
failingCISessions.set([grouped, failingCI, createTestSession('Outside the list').session], undefined);
const markedSections = () => [...container.querySelectorAll('.session-section')]
.filter(header => header.querySelector<HTMLElement>('.session-section-icon .codicon-circle-filled')?.style.color === 'var(--vscode-list-warningForeground)')
.map(header => header.querySelector('.session-section-label')?.textContent);
list.collapseAllSections();
const states = [markedSections()];
list.setStatusExcluded(SessionStatus.Error, true);
states.push(markedSections());
list.setStatusExcluded(SessionStatus.Error, false);
states.push(markedSections());
pinnedSessionIds.add(grouped.sessionId);
list.update();
states.push(markedSections());
assert.deepStrictEqual(states, [
[group.name, 'Workspace C'],
[],
[group.name, 'Workspace C'],
['Pinned', 'Workspace C'],
]);
});
for (const grouped of [false, true]) {
const kind = grouped ? 'group' : 'workspace';
test(`prioritizes input, inactive CI failures, then unread in a ${kind}`, () => {
const unread = createTestSession('Unread', { isRead: false });
const failingCI = createTestSession('Failing CI');
const needsInput = createTestSession('Needs input');
const sessions = [unread.session, ...Array.from({ length: 4 }, (_, index) => createTestSession(`Read ${index}`).session), failingCI.session, needsInput.session]
.map((session, index) => ({ ...session, createdAt: new Date(Date.now() - index * 1000) }));
const { list, container, failingCISessions } = renderList(sessions, grouped ? {
groups: [group],
memberships: new Map(sessions.map(session => [session.sessionId, group.id])),
} : {});
failingCISessions.set([failingCI.session], undefined);
const label = grouped ? group.name : 'Workspace';
const header = getHeader(container, label);
const getStatus = () => {
if (header.querySelector('.monaco-pixel-spinner-ring')) {
return 'needsInput';
}
const dot = header.querySelector<HTMLElement>('.codicon-circle-filled');
return dot ? dot.style.color === 'var(--vscode-list-warningForeground)' ? 'failingCI' : 'unread' : 'none';
};
const hiddenCI = !list.getVisibleSessions().some(session => session.sessionId === failingCI.session.sessionId);
const states = [getStatus()];
list.collapseAllSections();
states.push(getStatus());
const ciAria = header.closest('.monaco-list-row')?.getAttribute('aria-label');
needsInput.status.set(SessionStatus.NeedsInput, undefined);
states.push(getStatus());
needsInput.isArchived.set(true, undefined);
states.push(getStatus());
failingCI.status.set(SessionStatus.InProgress, undefined);
states.push(getStatus());
unread.isRead.set(true, undefined);
states.push(getStatus());
failingCI.status.set(SessionStatus.Completed, undefined);
states.push(getStatus());
failingCI.isArchived.set(true, undefined);
states.push(getStatus());
failingCI.isArchived.set(false, undefined);
states.push(getStatus());
failingCISessions.set([], undefined);
states.push(getStatus());
unread.isRead.set(false, undefined);
states.push(getStatus());
unread.isArchived.set(true, undefined);
states.push(getStatus());
assert.deepStrictEqual({ hiddenCI, states, ciAria }, {
hiddenCI: true,
states: ['none', 'failingCI', 'needsInput', 'failingCI', 'unread', 'none', 'failingCI', 'none', 'failingCI', 'none', 'unread', 'none'],
ciAria: `${label}, 7, session has failing CI checks`,
});
});
test(`prioritizes needs-input behind show more and reacts to status changes in a ${kind}`, () => {
const unread = createTestSession('Unread', { isRead: false });
const needsInput = createTestSession('Needs input', { status: SessionStatus.NeedsInput });
const sessions = [unread.session, ...Array.from({ length: 4 }, (_, index) => createTestSession(`Read ${index}`).session), needsInput.session]
.map((session, index) => ({ ...session, createdAt: new Date(Date.now() - index * 1000) }));
const { list, container } = renderList(sessions, grouped ? {
groups: [group],
memberships: new Map(sessions.map(session => [session.sessionId, group.id])),
} : {});
const label = grouped ? group.name : 'Workspace';
const header = getHeader(container, label);
const getStatus = () => header.querySelector('.monaco-pixel-spinner-ring') ? 'needsInput' : header.querySelector('.codicon-circle-filled') ? 'unread' : 'none';
const states = [getStatus()];
const hiddenNeedsInput = !list.getVisibleSessions().some(session => session.sessionId === needsInput.session.sessionId);
list.collapseAllSections();
states.push(getStatus());
const spinner = header.querySelector<HTMLElement>('.monaco-pixel-spinner-ring');
const needsInputAria = header.closest('.monaco-list-row')?.getAttribute('aria-label');
unread.isRead.set(true, undefined);
unread.isRead.set(false, undefined);
const preservesSpinner = !!spinner && spinner === header.querySelector('.monaco-pixel-spinner-ring');
needsInput.status.set(SessionStatus.Completed, undefined);
states.push(getStatus());
const unreadAria = header.closest('.monaco-list-row')?.getAttribute('aria-label');
needsInput.status.set(SessionStatus.NeedsInput, undefined);
states.push(getStatus());
needsInput.isArchived.set(true, undefined);
states.push(getStatus());
needsInput.isArchived.set(false, undefined);
states.push(getStatus());
header.click();
states.push(getStatus());
const expandedPulse = !!header.querySelector('.session-icon-pulse');
list.collapseAllSections();
states.push(getStatus());
needsInput.status.set(SessionStatus.Completed, undefined);
states.push(getStatus());
unread.isRead.set(true, undefined);
states.push(getStatus());
assert.deepStrictEqual({
hiddenNeedsInput,
states,
preservesSpinner,
color: spinner?.style.color,
needsInputAria,
unreadAria,
expandedPulse,
clearedPulse: !!header.querySelector('.session-icon-pulse'),
}, {
hiddenNeedsInput: true,
states: ['none', 'needsInput', 'unread', 'needsInput', 'unread', 'needsInput', 'none', 'needsInput', 'unread', 'none'],
preservesSpinner: true,
color: 'var(--vscode-list-warningForeground)',
needsInputAria: `${label}, 6, session needs input`,
unreadAria: `${label}, 6, contains unread sessions`,
expandedPulse: false,
clearedPulse: false,
});
});
test(`reacts to collapse, read, and archive changes in a ${kind}`, () => {
const { session, isRead, isArchived } = createTestSession('Unread', { isRead: false });
const { list, container } = renderList([session], grouped ? {
groups: [group],
memberships: new Map([[session.sessionId, group.id]]),
} : {});
const label = grouped ? group.name : 'Workspace';
const states = [unreadSections(container)];
list.collapseAllSections();
states.push(unreadSections(container));
isRead.set(true, undefined);
states.push(unreadSections(container));
isRead.set(false, undefined);
states.push(unreadSections(container));
isArchived.set(true, undefined);
states.push(unreadSections(container));
isArchived.set(false, undefined);
states.push(unreadSections(container));
getHeader(container, label).click();
states.push(unreadSections(container));
assert.deepStrictEqual({
states,
expanded: getHeader(container, label).closest('.monaco-list-row')?.getAttribute('aria-expanded'),
restoredIcon: !!getHeader(container, label).querySelector(grouped ? '.codicon-folder-library' : '.codicon-folder'),
}, {
states: [[], [label], [], [label], [], [label], []],
expanded: 'true',
restoredIcon: true,
});
});
test(`includes unread sessions behind show more in a collapsed ${kind}`, () => {
const sessions = Array.from({ length: 6 }, (_, index) => ({
...createTestSession(`Session ${index}`, { isRead: index !== 5 }).session,
createdAt: new Date(Date.now() - index * 1000),
}));
const { list, container } = renderList(sessions, grouped ? {
groups: [group],
memberships: new Map(sessions.map(session => [session.sessionId, group.id])),
} : {});
const visibleTitles = [...container.querySelectorAll('.session-title')].map(title => title.textContent);
list.collapseAllSections();
assert.deepStrictEqual({
visibleTitles,
unreadSections: unreadSections(container),
}, {
visibleTitles: ['Session 0', 'Session 1', 'Session 2', 'Session 3', 'Session 4'],
unreadSections: [grouped ? group.name : 'Workspace'],
});
});
}
test('updates membership and does not retain unread state when headers are reused', () => {
const { session: unread, isRead } = createTestSession('Unread', { workspaceLabel: 'Workspace B', isRead: false });
const read = createTestSession('Read', { workspaceLabel: 'Workspace B' }).session;
const memberships = new Map<string, string>();
const { list, container, managementService } = renderList([unread, read], { groups: [group], memberships });
list.collapseAllSections();
const states = [unreadSections(container)];
memberships.set(unread.sessionId, group.id);
list.update();
states.push(unreadSections(container));
memberships.delete(unread.sessionId);
list.update();
states.push(unreadSections(container));
managementService.sessions = [read];
list.refresh();
isRead.set(true, undefined);
isRead.set(false, undefined);
states.push(unreadSections(container));
assert.deepStrictEqual(states, [['Workspace B'], [group.name], ['Workspace B'], []]);
});
test('respects pinned, archived, and automation placement before aggregating unread sessions', () => {
const pinned = createTestSession('Pinned unread', { workspaceLabel: 'Workspace B', isRead: false }).session;
const archived = createTestSession('Archived unread', { workspaceLabel: 'Workspace B', isRead: false, isArchived: true }).session;
const automation: ISession = {
...createTestSession('Automation unread', { workspaceLabel: 'Workspace B', isRead: false }).session,
isAutomation: constObservable(true),
};
const read = createTestSession('Read', { workspaceLabel: 'Workspace B' }).session;
const { list, container } = renderList([pinned, archived, automation, read], {
groups: [group],
memberships: new Map([pinned, archived, automation].map(session => [session.sessionId, group.id])),
pinnedSessionIds: new Set([pinned.sessionId]),
});
list.setExcludeArchived(false);
list.collapseAllSections();
assert.deepStrictEqual({
unreadSections: unreadSections(container),
groupAria: getHeader(container, group.name).closest('.monaco-list-row')?.getAttribute('aria-label'),
workspaceAria: getHeader(container, 'Workspace B').closest('.monaco-list-row')?.getAttribute('aria-label'),
}, {
unreadSections: ['Pinned'],
groupAria: 'Group A, 0',
workspaceAria: 'Workspace B, 1',
});
});
test('does not count unread sessions excluded by list filters', () => {
const grouped = createTestSession('Grouped unread', { isRead: false, status: SessionStatus.Error }).session;
const unread = createTestSession('Workspace unread', { isRead: false, status: SessionStatus.Error }).session;
const read = createTestSession('Read').session;
const { list, container } = renderList([grouped, unread, read], {
groups: [group],
memberships: new Map([[grouped.sessionId, group.id]]),
});
list.collapseAllSections();
list.setStatusExcluded(SessionStatus.Error, true);
const filtered = unreadSections(container);
list.setStatusExcluded(SessionStatus.Error, false);
assert.deepStrictEqual({ filtered, restored: unreadSections(container) }, {
filtered: [],
restored: [group.name, 'Workspace'],
});
});
test('uses the existing orange needs-input fallback with reduced motion', () => {
const { session } = createTestSession('Needs input', { status: SessionStatus.NeedsInput });
const { list, container } = renderList([session], {}, true);
list.collapseAllSections();
const header = getHeader(container, 'Workspace');
assert.deepStrictEqual({
hasSpinner: !!header.querySelector('.monaco-pixel-spinner'),
color: header.querySelector<HTMLElement>('.codicon-circle-filled')?.style.color,
ariaLabel: header.closest('.monaco-list-row')?.getAttribute('aria-label'),
}, {
hasSpinner: false,
color: 'var(--vscode-list-warningForeground)',
ariaLabel: 'Workspace, 1, session needs input',
});
});
test('never shows status indicators for archived sessions', () => {
const unread = createTestSession('Archived unread', { isRead: false, isArchived: true }).session;
const needsInput = createTestSession('Archived needs input', { status: SessionStatus.NeedsInput, isArchived: true }).session;
const failingCI = createTestSession('Archived CI failure', { isArchived: true }).session;
const read = createTestSession('Read').session;
const { list, container, failingCISessions } = renderList([unread, needsInput, failingCI, read], {
groups: [group],
memberships: new Map([unread, needsInput, failingCI].map(session => [session.sessionId, group.id])),
});
failingCISessions.set([failingCI], undefined);
list.setExcludeArchived(false);
list.collapseAllSections();
assert.deepStrictEqual([group.name, 'Workspace', 'Archived'].map(label => {
const header = getHeader(container, label);
return {
ariaLabel: header.closest('.monaco-list-row')?.getAttribute('aria-label'),
indicator: !!header.querySelector('.codicon-circle-filled, .monaco-pixel-spinner'),
};
}), [
{ ariaLabel: 'Group A, 0', indicator: false },
{ ariaLabel: 'Workspace, 1', indicator: false },
{ ariaLabel: 'Archived, 3', indicator: false },
]);
});
test('reacts to setting changes while a session has failing CI', async () => {
const { session } = createTestSession('Failing CI');
const { list, container, instantiationService, failingCISessions } = renderList([session]);
failingCISessions.set([session], undefined);
list.collapseAllSections();
const configurationService = instantiationService.get(IConfigurationService) as TestConfigurationService;
const states = [];
for (const enabled of [false, true]) {
await configurationService.setUserConfiguration(SESSIONS_LIST_SHOW_UNREAD_IN_COLLAPSED_SECTIONS_SETTING, enabled);
configurationService.onDidChangeConfigurationEmitter.fire(upcastPartial<IConfigurationChangeEvent>({
affectsConfiguration: key => key === SESSIONS_LIST_SHOW_UNREAD_IN_COLLAPSED_SECTIONS_SETTING,
}));
const header = getHeader(container, 'Workspace');
states.push({
color: header.querySelector<HTMLElement>('.codicon-circle-filled')?.style.color,
ariaLabel: header.closest('.monaco-list-row')?.getAttribute('aria-label'),
});
}
assert.deepStrictEqual(states, [
{ color: undefined, ariaLabel: 'Workspace, 1' },
{ color: 'var(--vscode-list-warningForeground)', ariaLabel: 'Workspace, 1, session has failing CI checks' },
]);
});
test('reacts to setting changes while a session needs input', async () => {
const { session } = createTestSession('Needs input', { status: SessionStatus.NeedsInput });
const { list, container, instantiationService } = renderList([session]);
list.collapseAllSections();
const configurationService = instantiationService.get(IConfigurationService) as TestConfigurationService;
const states = [];
for (const enabled of [false, true]) {
await configurationService.setUserConfiguration(SESSIONS_LIST_SHOW_UNREAD_IN_COLLAPSED_SECTIONS_SETTING, enabled);
configurationService.onDidChangeConfigurationEmitter.fire(upcastPartial<IConfigurationChangeEvent>({
affectsConfiguration: key => key === SESSIONS_LIST_SHOW_UNREAD_IN_COLLAPSED_SECTIONS_SETTING,
}));
const header = getHeader(container, 'Workspace');
states.push({
needsInputSections: needsInputSections(container),
workspaceIcon: !!header.querySelector('.codicon-folder'),
ariaLabel: header.closest('.monaco-list-row')?.getAttribute('aria-label'),
});
}
assert.deepStrictEqual(states, [
{ needsInputSections: [], workspaceIcon: true, ariaLabel: 'Workspace, 1' },
{ needsInputSections: ['Workspace'], workspaceIcon: false, ariaLabel: 'Workspace, 1, session needs input' },
]);
});
test('reacts to setting changes without refreshing the list', async () => {
const grouped = createTestSession('Grouped unread', { isRead: false }).session;
const unread = createTestSession('Workspace unread', { isRead: false }).session;
const { list, container, instantiationService } = renderList([grouped, unread], {
groups: [group],
memberships: new Map([[grouped.sessionId, group.id]]),
});
list.collapseAllSections();
const configurationService = instantiationService.get(IConfigurationService) as TestConfigurationService;
const states = [];
for (const enabled of [false, true]) {
await configurationService.setUserConfiguration(SESSIONS_LIST_SHOW_UNREAD_IN_COLLAPSED_SECTIONS_SETTING, enabled);
configurationService.onDidChangeConfigurationEmitter.fire(upcastPartial<IConfigurationChangeEvent>({
affectsConfiguration: key => key === SESSIONS_LIST_SHOW_UNREAD_IN_COLLAPSED_SECTIONS_SETTING,
}));
states.push({
unreadSections: unreadSections(container),
groupIcon: !!getHeader(container, group.name).querySelector('.codicon-folder-library'),
workspaceIcon: !!getHeader(container, 'Workspace').querySelector('.codicon-folder'),
groupAria: getHeader(container, group.name).closest('.monaco-list-row')?.getAttribute('aria-label'),
});
}
assert.deepStrictEqual(states, [
{ unreadSections: [], groupIcon: true, workspaceIcon: true, groupAria: 'Group A, 1' },
{ unreadSections: [group.name, 'Workspace'], groupIcon: false, workspaceIcon: false, groupAria: 'Group A, 1, contains unread sessions' },
]);
});
});
suite('groupByWorkspace', () => {
test('groups are sorted alphabetically regardless of insertion order', () => {
@@ -94,6 +94,7 @@ export interface ITestSession {
readonly capabilities: ISettableObservable<ISessionCapabilities, void>;
readonly status: ISettableObservable<SessionStatus, void>;
readonly isArchived: ISettableObservable<boolean, void>;
readonly isRead: ISettableObservable<boolean, void>;
}
export interface ITestSessionOptions {
@@ -101,6 +102,7 @@ export interface ITestSessionOptions {
readonly workspaceLabel?: string;
readonly status?: SessionStatus;
readonly isArchived?: boolean;
readonly isRead?: boolean;
readonly isQuickChat?: boolean;
readonly changesSummary?: ISessionChangesSummary;
}
@@ -116,6 +118,7 @@ export function createTestSession(title: string, options: ITestSessionOptions =
override readonly status = status;
}();
const isArchived = observableValue(`archived-${resourceId}`, options.isArchived ?? false);
const isRead = observableValue(`read-${resourceId}`, options.isRead ?? true);
const workspaceLabel = options.workspaceLabel ?? 'Workspace';
const isQuickChat = options.isQuickChat ?? false;
const session: ISession = {
@@ -144,14 +147,14 @@ export function createTestSession(title: string, options: ITestSessionOptions =
mode: constObservable(undefined),
loading: constObservable(false),
isArchived,
isRead: constObservable(true),
isRead,
description: constObservable(undefined),
lastTurnEnd: constObservable(undefined),
chats: constObservable<readonly IChat[]>([]),
mainChat: constObservable(mainChat),
capabilities,
};
return { session, capabilities, status, isArchived };
return { session, capabilities, status, isArchived, isRead };
}
export function createSession(title: string, resourceId: string = title): ITestSession {
@@ -12,9 +12,11 @@ import { Disposable, IDisposable, toDisposable } from '../../../../../base/commo
import { constObservable, IObservable, observableValue } from '../../../../../base/common/observable.js';
import { OS } from '../../../../../base/common/platform.js';
import { ExtUri } from '../../../../../base/common/resources.js';
import { ThemeIcon, themeColorFromId } from '../../../../../base/common/themables.js';
import { ThemeIcon } from '../../../../../base/common/themables.js';
import { URI } from '../../../../../base/common/uri.js';
import { mock } from '../../../../../base/test/common/mock.js';
import { IAccessibilityService } from '../../../../../platform/accessibility/common/accessibility.js';
import { TestAccessibilityService } from '../../../../../platform/accessibility/test/common/testAccessibilityService.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';
@@ -38,7 +40,7 @@ import { ISessionGroup, ISessionGroupsService } from '../../../../../sessions/se
// eslint-disable-next-line local/code-import-patterns
import { ISessionSectionOrderService } from '../../../../../sessions/services/sessions/browser/sessionSectionOrderService.js';
// eslint-disable-next-line local/code-import-patterns
import { ISessionsListModelService } from '../../../../../sessions/services/sessions/browser/sessionsListModelService.js';
import { ISessionsListModelService, SessionsListModelService } from '../../../../../sessions/services/sessions/browser/sessionsListModelService.js';
// eslint-disable-next-line local/code-import-patterns
import { ISessionsProvidersService } from '../../../../../sessions/services/sessions/browser/sessionsProvidersService.js';
// eslint-disable-next-line local/code-import-patterns
@@ -54,7 +56,9 @@ import { IChat, ISession, ISessionChangeset, ISessionChangesSummary, ISessionFol
// 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 { SessionItemToolbarMenuId, SessionsGrouping, SessionsList, SessionsSorting } from '../../../../../sessions/contrib/sessions/browser/views/sessionsList.js';
import { SESSIONS_LIST_SHOW_UNREAD_IN_COLLAPSED_SECTIONS_SETTING, SessionItemToolbarMenuId, SessionsGrouping, SessionsList, SessionsSorting } from '../../../../../sessions/contrib/sessions/browser/views/sessionsList.js';
// eslint-disable-next-line local/code-import-patterns
import { BlockedSessionReason, BlockedSessions } from '../../../../../sessions/contrib/blockedSessions/browser/blockedSessions.js';
// eslint-disable-next-line local/code-import-patterns
import { ARCHIVE_SESSION_COMMAND_ID } from '../../../../../sessions/common/sessionCommands.js';
// eslint-disable-next-line local/code-import-patterns
@@ -132,6 +136,8 @@ interface ISessionSpec {
readonly minutesAgo: number;
readonly changesSummary?: ISessionChangesSummary;
readonly group?: string;
readonly isRead?: boolean;
readonly hasFailingCI?: boolean;
/** Nested (non-main) chats shown as child rows under the session. */
readonly chats?: readonly IChatSpec[];
/** Terminal command awaiting approval on the session's main chat (renders on the session row). */
@@ -206,7 +212,7 @@ function createSession(spec: ISessionSpec, approvals: Map<string, IAgentSessionA
override readonly workspace: IObservable<ISessionWorkspace | undefined> = constObservable(spec.workspace ? createWorkspace(spec.workspace) : undefined);
override readonly isQuickChat: IObservable<boolean> = constObservable(!spec.workspace);
override readonly isArchived: IObservable<boolean> = constObservable(false);
override readonly isRead: IObservable<boolean> = constObservable(true);
override readonly isRead: IObservable<boolean> = constObservable(spec.isRead ?? true);
override readonly changes: IObservable<readonly never[]> = constObservable([]);
override readonly changesets: IObservable<readonly ISessionChangeset[]> = constObservable([]);
override readonly changesSummary: IObservable<ISessionChangesSummary | undefined> = constObservable(spec.changesSummary);
@@ -229,6 +235,9 @@ interface IRenderOptions {
readonly sessions: readonly ISessionSpec[];
readonly groups?: readonly ISessionGroup[];
readonly grouping?: SessionsGrouping;
readonly collapsed?: boolean;
readonly showUnreadInCollapsedSections?: boolean;
readonly reducedMotion?: boolean;
readonly width?: number;
readonly phone?: boolean;
readonly revealHierarchyGuides?: boolean;
@@ -270,6 +279,12 @@ async function renderSessionsList(ctx: ComponentFixtureContext, options: IRender
additionalServices: reg => {
registerWorkbenchServices(reg);
reg.defineInstance(IProductService, TestProductService);
const reducedMotion = options.reducedMotion;
if (reducedMotion !== undefined) {
reg.defineInstance(IAccessibilityService, new class extends TestAccessibilityService {
override isMotionReduced(): boolean { return reducedMotion; }
}());
}
if (options.showFocusedToolbar || options.focusSelectedSession) {
const archiveAction = new class extends mock<MenuItemAction>() {
override readonly id = 'sessions.fixture.archive';
@@ -327,19 +342,7 @@ async function renderSessionsList(ctx: ComponentFixtureContext, options: IRender
override isSessionPinned(): boolean { return false; }
override migrateLegacyReadState(): void { }
override getSortKey(session: ISession): number { return session.createdAt.getTime(); }
override getStatusIcon(status: SessionStatus, isRead: boolean): ThemeIcon {
switch (status) {
case SessionStatus.InProgress:
return { ...Codicon.sessionInProgress, color: themeColorFromId('textLink.foreground') };
case SessionStatus.NeedsInput:
return { ...Codicon.circleFilled, color: themeColorFromId('list.warningForeground') };
default:
if (!isRead) {
return { ...Codicon.circleFilled, color: themeColorFromId('textLink.foreground') };
}
return { ...Codicon.circleSmallFilled, color: themeColorFromId('agentSessionReadIndicator.foreground') };
}
}
override getStatusIcon = SessionsListModelService.prototype.getStatusIcon;
}());
reg.defineInstance(ISessionGroupsService, new class extends mock<ISessionGroupsService>() {
override readonly onDidChange = Event.None;
@@ -436,12 +439,22 @@ async function renderSessionsList(ctx: ComponentFixtureContext, options: IRender
}());
}
instantiationService.stubInstance(BlockedSessions, new class extends mock<BlockedSessions>() {
override readonly blockedSessionsWithReasons = constObservable(sessions
.filter((_session, index) => options.sessions[index].hasFailingCI)
.map(session => ({ session, reason: BlockedSessionReason.FailingCI, occurrenceId: 'failingCI:fixture' })));
override dispose = Disposable.None.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);
}
if (options.showUnreadInCollapsedSections !== undefined) {
await (instantiationService.get(IConfigurationService) as TestConfigurationService).setUserConfiguration(SESSIONS_LIST_SHOW_UNREAD_IN_COLLAPSED_SECTIONS_SETTING, options.showUnreadInCollapsedSections);
}
instantiationService.get(IMarkdownRendererService).setDefaultCodeBlockRenderer(instantiationService.createInstance(EditorMarkdownCodeBlockRenderer));
// Phone layout is driven by both a CSS class (visual) and a context key (row
@@ -481,6 +494,9 @@ async function renderSessionsList(ctx: ComponentFixtureContext, options: IRender
approvalModel,
}));
list.layout(options.phone ? 260 : showHeader ? 180 : 220, width);
if (options.collapsed) {
list.collapseAllSections();
}
if (options.archiveOnboarding) {
listHost.style.width = `${width}px`;
const reveal = disposableStore.add(list.revealArchiveAction(sessions[0]));
@@ -584,6 +600,21 @@ const GROUPED_SESSIONS: readonly ISessionSpec[] = [
{ id: 'b', title: 'Add reconnect backoff', workspace: 'agent-host-protocol', minutesAgo: 64, group: GROUP.id },
{ id: 'c', title: 'Update onboarding copy', workspace: 'vscode-docs', minutesAgo: 180 },
];
const COLLAPSED_SECTION_SESSIONS: readonly ISessionSpec[] = [
{ id: 'grouped-unread', title: 'Unread session in the group only', workspace: 'vscode', minutesAgo: 12, group: GROUP.id, isRead: false },
{ id: 'workspace-read', title: 'Read session in the workspace', workspace: 'vscode', minutesAgo: 24 },
{ id: 'workspace-unread', title: 'Unread session in the workspace', workspace: 'vscode-docs', minutesAgo: 36, isRead: false },
];
const COLLAPSED_NEEDS_INPUT_SESSIONS: readonly ISessionSpec[] = [
...COLLAPSED_SECTION_SESSIONS,
{ id: 'grouped-needs-input', title: 'Needs input in the group only', workspace: 'vscode', minutesAgo: 48, group: GROUP.id, status: SessionStatus.NeedsInput },
{ id: 'workspace-needs-input', title: 'Needs input in the workspace', workspace: 'vscode-docs', minutesAgo: 60, status: SessionStatus.NeedsInput },
];
const COLLAPSED_CI_FAILURE_SESSIONS: readonly ISessionSpec[] = [
...COLLAPSED_SECTION_SESSIONS,
{ id: 'grouped-failing-ci', title: 'CI failure in the group only', workspace: 'vscode', minutesAgo: 48, group: GROUP.id, hasFailingCI: true },
{ id: 'workspace-failing-ci', title: 'CI failure in the workspace', workspace: 'vscode-docs', minutesAgo: 60, hasFailingCI: true },
];
export default defineThemedFixtureGroup({ path: 'sessions/' }, {
SessionsList_ArchiveOnboarding: defineComponentFixture({
@@ -598,6 +629,42 @@ export default defineThemedFixtureGroup({ path: 'sessions/' }, {
SessionsList_CustomGroup: defineComponentFixture({
render: ctx => renderSessionsList(ctx, { sessions: GROUPED_SESSIONS, groups: [GROUP] }),
}),
SessionsList_CollapsedUnreadSections: defineComponentFixture({
labels: { kind: 'screenshot' },
additionalThemes: ['darkHighContrast', 'lightHighContrast'],
expectedVisualDescriptions: ['All sections are collapsed. Filled unread indicators replace the icons for Release work and vscode-docs. The vscode section retains its folder icon because its unread session appears only in Release work.'],
render: ctx => renderSessionsList(ctx, { sessions: COLLAPSED_SECTION_SESSIONS, groups: [GROUP], collapsed: true, showUnreadInCollapsedSections: true }),
}),
SessionsList_CollapsedUnreadSections_Disabled: defineComponentFixture({
labels: { kind: 'screenshot' },
additionalThemes: ['darkHighContrast', 'lightHighContrast'],
expectedVisualDescriptions: ['All sections are collapsed and retain their normal group or folder icons despite containing unread sessions, because collapsed-section indicators are disabled by default.'],
render: ctx => renderSessionsList(ctx, { sessions: COLLAPSED_SECTION_SESSIONS, groups: [GROUP], collapsed: true }),
}),
SessionsList_CollapsedNeedsInputSections: defineComponentFixture({
labels: { kind: 'screenshot' },
additionalThemes: ['darkHighContrast', 'lightHighContrast'],
expectedVisualDescriptions: ['All sections are collapsed. Orange ring pixel spinners replace the icons for Release work and vscode-docs, taking priority over unread indicators. The vscode section retains its folder icon because its needs-input session appears only in Release work.'],
render: ctx => renderSessionsList(ctx, { sessions: COLLAPSED_NEEDS_INPUT_SESSIONS, groups: [GROUP], collapsed: true, showUnreadInCollapsedSections: true, reducedMotion: false }),
}),
SessionsList_CollapsedNeedsInputSections_Disabled: defineComponentFixture({
labels: { kind: 'screenshot' },
additionalThemes: ['darkHighContrast', 'lightHighContrast'],
expectedVisualDescriptions: ['All collapsed sections retain their normal group or folder icons despite containing unread and needs-input sessions, because collapsed-section indicators are disabled by default.'],
render: ctx => renderSessionsList(ctx, { sessions: COLLAPSED_NEEDS_INPUT_SESSIONS, groups: [GROUP], collapsed: true, reducedMotion: false }),
}),
SessionsList_CollapsedCIFailureSections: defineComponentFixture({
labels: { kind: 'screenshot' },
additionalThemes: ['darkHighContrast', 'lightHighContrast'],
expectedVisualDescriptions: ['All sections are collapsed. Orange dots replace the icons for Release work and vscode-docs, taking priority over unread indicators. The vscode section retains its folder icon because its session with failing CI appears only in Release work.'],
render: ctx => renderSessionsList(ctx, { sessions: COLLAPSED_CI_FAILURE_SESSIONS, groups: [GROUP], collapsed: true, showUnreadInCollapsedSections: true }),
}),
SessionsList_CollapsedCIFailureSections_Disabled: defineComponentFixture({
labels: { kind: 'screenshot' },
additionalThemes: ['darkHighContrast', 'lightHighContrast'],
expectedVisualDescriptions: ['All collapsed sections retain their normal group or folder icons despite containing unread sessions and CI failures, because collapsed-section indicators are disabled by default.'],
render: ctx => renderSessionsList(ctx, { sessions: COLLAPSED_CI_FAILURE_SESSIONS, groups: [GROUP], collapsed: true }),
}),
SessionsList_CustomGroup_LongWorkspaceNarrow: defineComponentFixture({
render: ctx => renderSessionsList(ctx, {
sessions: [
@@ -28,3 +28,70 @@ test('reveals the nested chat twistie only while hovering the session row', asyn
await expect(twistie).toHaveCSS('opacity', '0');
await expect(statusIcon).toHaveCSS('visibility', 'visible');
});
for (const theme of ['Dark', 'Light', 'DarkHighContrast', 'LightHighContrast']) {
for (const { name, fixture, ariaStatus, indicatorClass, color, count } of [
{ name: 'unread', fixture: 'SessionsList_CollapsedUnreadSections', ariaStatus: 'contains unread sessions', indicatorClass: '.codicon-circle-filled', color: '--vscode-textLink-foreground', count: 1 },
{ name: 'needs-input', fixture: 'SessionsList_CollapsedNeedsInputSections', ariaStatus: 'session needs input', indicatorClass: '.monaco-pixel-spinner-ring', color: '--vscode-list-warningForeground', count: 2 },
{ name: 'CI-failure', fixture: 'SessionsList_CollapsedCIFailureSections', ariaStatus: 'session has failing CI checks', indicatorClass: '.codicon-circle-filled', color: '--vscode-list-warningForeground', count: 2 },
]) {
test.describe(`collapsed section ${name} indicators (${theme})`, () => {
test('replaces only the owning section icons and preserves hover and keyboard chevrons', async ({ page }) => {
await openFixture(page, `sessions/sessionsList/${fixture}/${theme}`, '.sessions-list-control');
const readWorkspace = page.getByRole('treeitem', { name: 'vscode, 1', exact: true });
await expect(readWorkspace.locator('.session-section-icon.codicon-folder')).toBeVisible();
await expect(readWorkspace.locator(indicatorClass)).toHaveCount(0);
for (const label of ['Release work', 'vscode-docs']) {
const row = page.getByRole('treeitem', { name: `${label}, ${count}, ${ariaStatus}`, exact: true });
const icon = row.locator('.session-section-icon');
const indicator = icon.locator(indicatorClass);
const chevron = row.locator('.session-section-chevron');
await expect(row).toHaveAttribute('aria-expanded', 'false');
await expect(indicator).toBeVisible();
await expect(indicator).toHaveAttribute('style', `color: var(${color});`);
await expect(chevron).toBeHidden();
await row.hover();
await expect(icon).toBeHidden();
await expect(chevron).toBeVisible();
await readWorkspace.hover();
await expect(indicator).toBeVisible();
await expect(chevron).toBeHidden();
await row.click();
await readWorkspace.hover();
await expect(row).toHaveAttribute('aria-expanded', 'true');
await expect(indicator).toHaveCount(0);
await expect(icon).toHaveClass(/codicon-folder(?:-library)?/);
await expect(icon).toBeVisible();
await row.click();
await readWorkspace.hover();
await expect(row).toHaveAttribute('aria-expanded', 'false');
await expect(indicator).toHaveCount(1);
await page.keyboard.press('Tab');
await page.keyboard.press('Shift+Tab');
await expect(page.getByRole('tree', { name: 'Sessions', exact: true })).toBeFocused();
await expect(icon).toBeHidden();
await expect(chevron).toBeVisible();
}
});
test('keeps normal icons with the default-disabled setting', async ({ page }) => {
await openFixture(page, `sessions/sessionsList/${fixture}_Disabled/${theme}`, '.sessions-list-control');
await expect(page.locator(`.session-section-icon ${indicatorClass}`)).toHaveCount(0);
await expect(page.getByRole('treeitem', { name: `Release work, ${count}`, exact: true }).locator('.codicon-folder-library')).toBeVisible();
for (const label of ['vscode', 'vscode-docs']) {
const row = page.getByRole('treeitem', { name: `${label}, ${label === 'vscode' ? 1 : count}`, exact: true });
await expect(row).toHaveAttribute('aria-expanded', 'false');
await expect(row.locator('.session-section-icon.codicon-folder')).toBeVisible();
}
});
});
}
}