From 290d4c07d5d30c51008f192467133c425b6ce805 Mon Sep 17 00:00:00 2001 From: BeniBenj Date: Thu, 10 Sep 2026 20:51:39 +0200 Subject: [PATCH 1/7] sessions: Show unread icons in collapsed sections Add a default-on experiment-controlled setting. Derive unread state from section membership, preserving hover and keyboard-focus chevrons. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../sessions/browser/sessions.contribution.ts | 9 +- .../sessions/browser/views/sessionsList.ts | 70 ++++-- .../browser/sessions.contribution.test.ts | 29 +++ .../test/browser/sessionsList.test.ts | 223 +++++++++++++++++- .../test/browser/sessionsListTestUtils.ts | 7 +- .../sessions/sessionsList.fixture.ts | 30 ++- .../tests/sessionsListTwistie.spec.ts | 61 +++++ 7 files changed, 399 insertions(+), 30 deletions(-) create mode 100644 src/vs/sessions/contrib/sessions/test/browser/sessions.contribution.test.ts diff --git a/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts b/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts index 7f3f8309f64653..fee17bd648f0d9 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts @@ -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'; @@ -71,6 +71,13 @@ Registry.as(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 groups and workspace sections in the sessions list show an unread indicator for the sessions they contain."), + default: true, + experiment: { mode: 'auto' } + }, [SESSIONS_ARCHIVE_SESSION_CONFETTI_SETTING]: { type: 'boolean', tags: ['preview'], diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts index 98a909b4d121c8..fbeb440ce3afa4 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts @@ -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'; @@ -119,6 +120,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('sessionItem.isPinned', false); export const SessionItemHasBranchNameContext = new RawContextKey('sessionItem.hasBranchName', false); @@ -1289,11 +1291,32 @@ function getWorkspaceBadgeLabel(workspace: ISessionWorkspace): string | undefine //#region Section Header Renderer interface ISessionHeaderTemplate { + readonly icon: HTMLElement; + readonly collapsed: ISettableObservable; readonly toolbarContainer: HTMLElement; readonly toolbar: MenuWorkbenchToolBar; readonly elementDisposables: DisposableStore; } +function hasUnreadSessions(sessions: readonly ISession[], reader: IReader): boolean { + return sessions.some(session => !session.isRead.read(reader) && !session.isArchived.read(reader)); +} + +function renderSessionHeaderIcon(template: ISessionHeaderTemplate, sessions: readonly ISession[], icon: ThemeIcon | undefined, showUnreadInCollapsedSections: IObservable, instantiationService: IInstantiationService): void { + template.elementDisposables.add(autorun(reader => { + const showUnread = template.collapsed.read(reader) && showUnreadInCollapsedSections.read(reader) && hasUnreadSessions(sessions, reader); + DOM.clearNode(template.icon); + template.icon.className = 'session-section-icon'; + template.icon.style.display = showUnread || icon ? '' : 'none'; + if (showUnread) { + const statusIcon = reader.store.add(instantiationService.createInstance(SessionStatusIcon, template.icon)); + statusIcon.setStatus(SessionStatus.Completed, false, false); + } else if (icon) { + template.icon.classList.add(...ThemeIcon.asClassNameArray(icon)); + } + })); +} + function renderSessionHeaderToolbar(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; @@ -1301,7 +1324,6 @@ function renderSessionHeaderToolbar(template: ISessionHeaderTemplate, element interface ISessionSectionTemplate extends ISessionHeaderTemplate { readonly container: HTMLElement; - readonly icon: HTMLElement; readonly label: HTMLElement; readonly count: HTMLElement; readonly newBadge: HTMLElement; @@ -1353,6 +1375,7 @@ export class SessionSectionRenderer implements ITreeRenderer void, + private readonly showUnreadInCollapsedSections: IObservable, private readonly instantiationService: IInstantiationService, private readonly contextKeyService: IContextKeyService, private readonly automationService: IAutomationService, @@ -1426,7 +1449,7 @@ export class SessionSectionRenderer implements ITreeRenderer, _index: number, template: ISessionSectionTemplate): void { @@ -1450,14 +1473,11 @@ export class SessionSectionRenderer implements ITreeRenderer { const activeCustomView = this.customViewService.activeCustomView.read(reader); template.container.classList.toggle('active', activeCustomView?.id === AUTOMATIONS_CUSTOM_VIEW_ID); @@ -1488,6 +1508,8 @@ export class SessionSectionRenderer implements ITreeRenderer, private readonly instantiationService: IInstantiationService, private readonly contextKeyService: IContextKeyService, ) { } @@ -1604,7 +1621,6 @@ class SessionGroupRenderer implements ITreeRenderer, _index: number, template: ISessionGroupTemplate): void { @@ -1632,6 +1648,7 @@ class SessionGroupRenderer implements ITreeRenderer 0); SessionGroupIsEmptyContext.bindTo(template.contextKeyService).set(element.isEmpty); @@ -1701,6 +1718,7 @@ class SessionGroupRenderer implements ITreeRenderer boolean; readonly includeQuickChatInAriaLabel?: boolean; readonly automationNewBadgeVisible?: IObservable; + readonly showUnreadInCollapsedSections?: IObservable; /** Mirrors {@link SessionItemRenderer}'s option of the same name — see there for rationale. */ readonly deriveStatusFromMainChat?: boolean; } @@ -1831,7 +1850,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) { @@ -1853,7 +1872,7 @@ class SessionsAccessibilityProvider { : label; }); } - return `${element.label}, ${element.sessions.length}`; + return this.getSectionAriaLabel(element.label, element.sessions); } if (isSessionShowMore(element)) { if (element.mode === 'less') { @@ -1906,6 +1925,12 @@ class SessionsAccessibilityProvider { return label; }); } + + private getSectionAriaLabel(label: string, sessions: readonly ISession[]): IObservable { + return derived(this, reader => this.options?.showUnreadInCollapsedSections?.read(reader) && hasUnreadSessions(sessions, reader) + ? localize('sessionSectionUnreadAria', "{0}, {1}, unread sessions", label, sessions.length) + : localize('sessionSectionAria', "{0}, {1}", label, sessions.length)); + } } //#endregion @@ -2651,9 +2676,11 @@ 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, true, this.configurationService); const sectionRenderer = new SessionSectionRenderer( true /* hideSectionCount */, selectHeader, + showUnreadInCollapsedSections, instantiationService, contextKeyService, this.automationService, @@ -2668,7 +2695,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, instantiationService, contextKeyService); this._groupRenderer = groupRenderer; // Read (don't bind) `IsPhoneLayoutContext` from the parent context so we @@ -2706,6 +2733,7 @@ export class SessionsList extends Disposable implements ISessionsList { isRenderedInCustomGroup: session => this.isRenderedInCustomGroup(session), deriveStatusFromMainChat: true, automationNewBadgeVisible: this.automationsNewBadgeState.showNewBadge, + showUnreadInCollapsedSections, }), dnd: this._register(new SessionsListDragAndDrop({ isReorderable: session => this.isReorderable(session), diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessions.contribution.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessions.contribution.test.ts new file mode 100644 index 00000000000000..6f72425a73bbd6 --- /dev/null +++ b/src/vs/sessions/contrib/sessions/test/browser/sessions.contribution.test.ts @@ -0,0 +1,29 @@ +/*--------------------------------------------------------------------------------------------- + * 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'; + +suite('Sessions Contribution', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('enables collapsed section unread indicators by default with automatic experiments', () => { + const property = Registry.as(ConfigurationExtensions.Configuration).getConfigurationProperties()[SESSIONS_LIST_SHOW_UNREAD_IN_COLLAPSED_SECTIONS_SETTING]; + assert.deepStrictEqual({ + type: property.type, + default: property.default, + experiment: property.experiment, + }, { + type: 'boolean', + default: true, + experiment: { mode: 'auto' }, + }); + }); +}); diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts index 824b46b9521200..42ea1e2eb9a0f6 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts @@ -20,7 +20,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 { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { ILabelService } from '../../../../../platform/label/common/label.js'; @@ -38,17 +38,17 @@ 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, 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, SessionsFlatList, SessionsList, SessionsListFocusedChatItemContext, sortSessions, SessionsGrouping, SessionsSorting } from '../../browser/views/sessionsList.js'; +import { computeReorderSortChanges, groupByDate, groupByWorkspace, groupSessionsForList, ISessionSection, limitSessionsForList, SESSIONS_LIST_SHOW_UNREAD_IN_COLLAPSED_SECTIONS_SETTING, SessionItemToolbarMenuId, SessionSectionRenderer, SessionsFlatList, SessionsList, SessionsListFocusedChatItemContext, sortSessions, SessionsGrouping, SessionsSorting } from '../../browser/views/sessionsList.js'; import { AgentSessionApprovalKind, AgentSessionApprovalModel, IAgentSessionApprovalInfo } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentSessionApprovalModel.js'; import { 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'; @@ -123,6 +123,7 @@ suite('Sessions - SessionsList', () => { const renderer = new SessionSectionRenderer( true, section => selectedSections.push(section), + constObservable(true), instantiationService, contextKeyService, automationService, @@ -176,6 +177,7 @@ suite('Sessions - SessionsList', () => { const renderer = new SessionSectionRenderer( true, () => { }, + constObservable(true), instantiationService, contextKeyService, automationService, @@ -234,6 +236,7 @@ suite('Sessions - SessionsList', () => { const renderer = new SessionSectionRenderer( true, () => { }, + constObservable(true), instantiationService, contextKeyService, automationService, @@ -409,6 +412,7 @@ suite('Sessions - SessionsList', () => { const renderer = new SessionSectionRenderer( true, () => { }, + constObservable(true), new class extends mock() { }, new class extends mock() { }, automationService, @@ -477,6 +481,7 @@ suite('Sessions - SessionsList', () => { const renderer = new SessionSectionRenderer( true, () => { }, + constObservable(true), new class extends mock() { }, new class extends mock() { }, automationService, @@ -517,6 +522,216 @@ suite('Sessions - SessionsList', () => { }); }); + suite('collapsed section unread indicators', () => { + const group: ISessionGroup = { id: 'group-a', name: 'Group A', createdAt: 1 }; + + function renderList(sessions: ISession[], options: IListHarnessOptions = {}) { + const harness = createListHarness(disposables, sessions, options); + harness.instantiationService.stub(ISessionsListModelService, 'getStatusIcon', SessionsListModelService.prototype.getStatusIcon); + 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 }; + } + + 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 getHeader(container: HTMLElement, label: string): HTMLElement { + const header = [...container.querySelectorAll('.session-section')] + .find(header => header.querySelector('.session-section-label')?.textContent === label); + assert.ok(header, `Expected section ${label}`); + return header; + } + + 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('.codicon-circle-filled')?.style.color, + }, { + unreadSections: [group.name, 'Workspace C'], + groupAria: 'Group A, 1, unread sessions', + workspaceAria: 'Workspace B, 1', + color: 'var(--vscode-textLink-foreground)', + }); + }); + + for (const grouped of [false, true]) { + const kind = grouped ? 'group' : 'workspace'; + + 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(); + 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('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({ + 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, unread sessions' }, + ]); + }); + }); + suite('groupByWorkspace', () => { test('groups are sorted alphabetically regardless of insertion order', () => { diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsListTestUtils.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsListTestUtils.ts index 6d6e799682aa43..f4e556502ab1b7 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsListTestUtils.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsListTestUtils.ts @@ -94,6 +94,7 @@ export interface ITestSession { readonly capabilities: ISettableObservable; readonly status: ISettableObservable; readonly isArchived: ISettableObservable; + readonly isRead: ISettableObservable; } 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([]), mainChat: constObservable(mainChat), capabilities, }; - return { session, capabilities, status, isArchived }; + return { session, capabilities, status, isArchived, isRead }; } export function createSession(title: string, resourceId: string = title): ITestSession { diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsList.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsList.fixture.ts index 1a5a8963514a4d..14d7177c6a980f 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsList.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsList.fixture.ts @@ -54,7 +54,7 @@ import { IChat, ISession, ISessionChangesSummary, ISessionFolder, ISessionWorksp // 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 { ARCHIVE_SESSION_COMMAND_ID } from '../../../../../sessions/common/sessionCommands.js'; // eslint-disable-next-line local/code-import-patterns @@ -132,6 +132,7 @@ interface ISessionSpec { readonly minutesAgo: number; readonly changesSummary?: ISessionChangesSummary; readonly group?: string; + readonly isRead?: 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 +207,7 @@ function createSession(spec: ISessionSpec, approvals: Map = constObservable(spec.workspace ? createWorkspace(spec.workspace) : undefined); override readonly isQuickChat: IObservable = constObservable(!spec.workspace); override readonly isArchived: IObservable = constObservable(false); - override readonly isRead: IObservable = constObservable(true); + override readonly isRead: IObservable = constObservable(spec.isRead ?? true); override readonly changes: IObservable = constObservable([]); override readonly changesSummary: IObservable = constObservable(spec.changesSummary); override readonly description: IObservable = constObservable(description); @@ -228,6 +229,8 @@ interface IRenderOptions { readonly sessions: readonly ISessionSpec[]; readonly groups?: readonly ISessionGroup[]; readonly grouping?: SessionsGrouping; + readonly collapsed?: boolean; + readonly showUnreadInCollapsedSections?: boolean; readonly width?: number; readonly phone?: boolean; readonly revealHierarchyGuides?: boolean; @@ -441,6 +444,9 @@ async function renderSessionsList(ctx: ComponentFixtureContext, options: IRender 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 @@ -480,6 +486,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])); @@ -583,6 +592,11 @@ 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 }, +]; export default defineThemedFixtureGroup({ path: 'sessions/' }, { SessionsList_ArchiveOnboarding: defineComponentFixture({ @@ -597,6 +611,18 @@ export default defineThemedFixtureGroup({ path: 'sessions/' }, { SessionsList_CustomGroup: defineComponentFixture({ render: ctx => renderSessionsList(ctx, { sessions: GROUPED_SESSIONS, groups: [GROUP] }), }), + SessionsList_CollapsedUnreadSections: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: true }, + 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 }), + }), + SessionsList_CollapsedUnreadSections_Disabled: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: true }, + additionalThemes: ['darkHighContrast', 'lightHighContrast'], + expectedVisualDescriptions: ['All sections are collapsed and retain their normal group or folder icons despite containing unread sessions, because collapsed-section unread indicators are disabled.'], + render: ctx => renderSessionsList(ctx, { sessions: COLLAPSED_SECTION_SESSIONS, groups: [GROUP], collapsed: true, showUnreadInCollapsedSections: false }), + }), SessionsList_CustomGroup_LongWorkspaceNarrow: defineComponentFixture({ render: ctx => renderSessionsList(ctx, { sessions: [ diff --git a/test/componentFixtures/playwright/tests/sessionsListTwistie.spec.ts b/test/componentFixtures/playwright/tests/sessionsListTwistie.spec.ts index 060ae6227dc063..61c7f5e4f31a34 100644 --- a/test/componentFixtures/playwright/tests/sessionsListTwistie.spec.ts +++ b/test/componentFixtures/playwright/tests/sessionsListTwistie.spec.ts @@ -28,3 +28,64 @@ 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']) { + test.describe(`collapsed section unread indicators (${theme})`, () => { + test('replaces only the owning section icons and preserves hover and keyboard chevrons', async ({ page }) => { + await openFixture(page, `sessions/sessionsList/SessionsList_CollapsedUnreadSections/${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('.codicon-circle-filled')).toHaveCount(0); + + for (const label of ['Release work', 'vscode-docs']) { + const row = page.getByRole('treeitem', { name: `${label}, 1, unread sessions`, exact: true }); + const icon = row.locator('.session-section-icon'); + const unread = icon.locator('.codicon-circle-filled'); + const chevron = row.locator('.session-section-chevron'); + + await expect(row).toHaveAttribute('aria-expanded', 'false'); + await expect(unread).toBeVisible(); + await expect(unread).toHaveAttribute('style', 'color: var(--vscode-textLink-foreground);'); + await expect(chevron).toBeHidden(); + + await row.hover(); + await expect(icon).toBeHidden(); + await expect(chevron).toBeVisible(); + + await readWorkspace.hover(); + await expect(unread).toBeVisible(); + await expect(chevron).toBeHidden(); + + await row.click(); + await readWorkspace.hover(); + await expect(row).toHaveAttribute('aria-expanded', 'true'); + await expect(unread).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(unread).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 when the setting is disabled', async ({ page }) => { + await openFixture(page, `sessions/sessionsList/SessionsList_CollapsedUnreadSections_Disabled/${theme}`, '.sessions-list-control'); + + await expect(page.locator('.session-section-icon .codicon-circle-filled')).toHaveCount(0); + await expect(page.getByRole('treeitem', { name: 'Release work, 1', exact: true }).locator('.codicon-folder-library')).toBeVisible(); + for (const label of ['vscode', 'vscode-docs']) { + const row = page.getByRole('treeitem', { name: `${label}, 1`, exact: true }); + await expect(row).toHaveAttribute('aria-expanded', 'false'); + await expect(row.locator('.session-section-icon.codicon-folder')).toBeVisible(); + } + }); + }); +} From 9940eb84c2d5c66d46af567c14d25ac313a35bec Mon Sep 17 00:00:00 2001 From: BeniBenj Date: Thu, 10 Sep 2026 21:16:47 +0200 Subject: [PATCH 2/7] sessions: Show needs-input spinners in collapsed sections Prioritize needs-input sessions over unread indicators using the existing status widget, preserving section ownership, hover chevrons, and reduced-motion behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../sessions/browser/sessions.contribution.ts | 2 +- .../sessions/browser/views/sessionsList.ts | 39 +++-- .../test/browser/sessionsList.test.ts | 145 +++++++++++++++++- .../sessions/sessionsList.fixture.ts | 26 ++++ .../tests/sessionsListTwistie.spec.ts | 119 +++++++------- 5 files changed, 262 insertions(+), 69 deletions(-) diff --git a/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts b/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts index fee17bd648f0d9..722dc3d0765c18 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts @@ -74,7 +74,7 @@ Registry.as(ConfigurationExtensions.Configuration).regis [SESSIONS_LIST_SHOW_UNREAD_IN_COLLAPSED_SECTIONS_SETTING]: { type: 'boolean', tags: ['preview'], - description: localize('sessions.list.showUnreadInCollapsedSections', "Controls whether collapsed groups and workspace sections in the sessions list show an unread indicator for the sessions they contain."), + description: localize('sessions.list.showUnreadInCollapsedSections', "Controls whether collapsed groups and workspace sections in the sessions list show unread or needs-input indicators for the sessions they contain."), default: true, experiment: { mode: 'auto' } }, diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts index fbeb440ce3afa4..8b8c183292da01 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts @@ -1298,19 +1298,32 @@ interface ISessionHeaderTemplate { readonly elementDisposables: DisposableStore; } -function hasUnreadSessions(sessions: readonly ISession[], reader: IReader): boolean { - return sessions.some(session => !session.isRead.read(reader) && !session.isArchived.read(reader)); +function getSessionHeaderStatus(sessions: readonly ISession[], reader: IReader): SessionStatus | undefined { + let hasUnread = false; + for (const session of sessions) { + if (session.isArchived.read(reader)) { + continue; + } + if (session.status.read(reader) === SessionStatus.NeedsInput) { + return SessionStatus.NeedsInput; + } + hasUnread ||= !session.isRead.read(reader); + } + return hasUnread ? SessionStatus.Completed : undefined; } function renderSessionHeaderIcon(template: ISessionHeaderTemplate, sessions: readonly ISession[], icon: ThemeIcon | undefined, showUnreadInCollapsedSections: IObservable, instantiationService: IInstantiationService): void { + const headerStatus = derived(reader => template.collapsed.read(reader) && showUnreadInCollapsedSections.read(reader) + ? getSessionHeaderStatus(sessions, reader) + : undefined); template.elementDisposables.add(autorun(reader => { - const showUnread = template.collapsed.read(reader) && showUnreadInCollapsedSections.read(reader) && hasUnreadSessions(sessions, reader); + const status = headerStatus.read(reader); DOM.clearNode(template.icon); template.icon.className = 'session-section-icon'; - template.icon.style.display = showUnread || icon ? '' : 'none'; - if (showUnread) { + template.icon.style.display = status !== undefined || icon ? '' : 'none'; + if (status !== undefined) { const statusIcon = reader.store.add(instantiationService.createInstance(SessionStatusIcon, template.icon)); - statusIcon.setStatus(SessionStatus.Completed, false, false); + statusIcon.setStatus(status, status !== SessionStatus.Completed, false); } else if (icon) { template.icon.classList.add(...ThemeIcon.asClassNameArray(icon)); } @@ -1927,9 +1940,17 @@ class SessionsAccessibilityProvider { } private getSectionAriaLabel(label: string, sessions: readonly ISession[]): IObservable { - return derived(this, reader => this.options?.showUnreadInCollapsedSections?.read(reader) && hasUnreadSessions(sessions, reader) - ? localize('sessionSectionUnreadAria', "{0}, {1}, unread sessions", label, sessions.length) - : localize('sessionSectionAria', "{0}, {1}", label, sessions.length)); + return derived(this, reader => { + const status = this.options?.showUnreadInCollapsedSections?.read(reader) ? getSessionHeaderStatus(sessions, reader) : undefined; + switch (status) { + case SessionStatus.NeedsInput: + return localize('sessionSectionNeedsInputAria', "{0}, {1}, session needs input", label, sessions.length); + case SessionStatus.Completed: + return localize('sessionSectionUnreadAria', "{0}, {1}, unread sessions", label, sessions.length); + default: + return localize('sessionSectionAria', "{0}, {1}", label, sessions.length); + } + }); } } diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts index 42ea1e2eb9a0f6..f68c068421fa7e 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts @@ -522,12 +522,15 @@ suite('Sessions - SessionsList', () => { }); }); - suite('collapsed section unread indicators', () => { + suite('collapsed section status indicators', () => { const group: ISessionGroup = { id: 'group-a', name: 'Group A', createdAt: 1 }; - function renderList(sessions: ISession[], options: IListHarnessOptions = {}) { + function renderList(sessions: ISession[], options: IListHarnessOptions = {}, reducedMotion = false) { const harness = createListHarness(disposables, sessions, options); harness.instantiationService.stub(ISessionsListModelService, 'getStatusIcon', SessionsListModelService.prototype.getStatusIcon); + harness.instantiationService.stub(IAccessibilityService, new class extends TestAccessibilityService { + override isMotionReduced(): boolean { return reducedMotion; } + }()); const container = harness.createContainer(400, 700); const list = harness.store.add(harness.instantiationService.createInstance(SessionsList, container, { grouping: () => SessionsGrouping.Workspace, @@ -544,6 +547,12 @@ suite('Sessions - SessionsList', () => { .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('.session-section')] .find(header => header.querySelector('.session-section-label')?.textContent === label); @@ -574,9 +583,99 @@ suite('Sessions - SessionsList', () => { }); }); + 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(); + 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'], + ]); + }); + for (const grouped of [false, true]) { const kind = grouped ? 'group' : 'workspace'; + 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('.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, 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 ? { @@ -702,6 +801,48 @@ suite('Sessions - SessionsList', () => { }); }); + 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('.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('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({ + 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; diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsList.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsList.fixture.ts index 14d7177c6a980f..18183d95ce4675 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsList.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsList.fixture.ts @@ -15,6 +15,8 @@ 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 { 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'; @@ -231,6 +233,7 @@ interface IRenderOptions { readonly grouping?: SessionsGrouping; readonly collapsed?: boolean; readonly showUnreadInCollapsedSections?: boolean; + readonly reducedMotion?: boolean; readonly width?: number; readonly phone?: boolean; readonly revealHierarchyGuides?: boolean; @@ -272,6 +275,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() { override readonly id = 'sessions.fixture.archive'; @@ -597,6 +606,11 @@ const COLLAPSED_SECTION_SESSIONS: readonly ISessionSpec[] = [ { 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 }, +]; export default defineThemedFixtureGroup({ path: 'sessions/' }, { SessionsList_ArchiveOnboarding: defineComponentFixture({ @@ -623,6 +637,18 @@ export default defineThemedFixtureGroup({ path: 'sessions/' }, { expectedVisualDescriptions: ['All sections are collapsed and retain their normal group or folder icons despite containing unread sessions, because collapsed-section unread indicators are disabled.'], render: ctx => renderSessionsList(ctx, { sessions: COLLAPSED_SECTION_SESSIONS, groups: [GROUP], collapsed: true, showUnreadInCollapsedSections: false }), }), + SessionsList_CollapsedNeedsInputSections: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: true }, + 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, reducedMotion: false }), + }), + SessionsList_CollapsedNeedsInputSections_Disabled: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: true }, + 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.'], + render: ctx => renderSessionsList(ctx, { sessions: COLLAPSED_NEEDS_INPUT_SESSIONS, groups: [GROUP], collapsed: true, showUnreadInCollapsedSections: false, reducedMotion: false }), + }), SessionsList_CustomGroup_LongWorkspaceNarrow: defineComponentFixture({ render: ctx => renderSessionsList(ctx, { sessions: [ diff --git a/test/componentFixtures/playwright/tests/sessionsListTwistie.spec.ts b/test/componentFixtures/playwright/tests/sessionsListTwistie.spec.ts index 61c7f5e4f31a34..55fc6b21ff5b17 100644 --- a/test/componentFixtures/playwright/tests/sessionsListTwistie.spec.ts +++ b/test/componentFixtures/playwright/tests/sessionsListTwistie.spec.ts @@ -30,62 +30,67 @@ test('reveals the nested chat twistie only while hovering the session row', asyn }); for (const theme of ['Dark', 'Light', 'DarkHighContrast', 'LightHighContrast']) { - test.describe(`collapsed section unread indicators (${theme})`, () => { - test('replaces only the owning section icons and preserves hover and keyboard chevrons', async ({ page }) => { - await openFixture(page, `sessions/sessionsList/SessionsList_CollapsedUnreadSections/${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('.codicon-circle-filled')).toHaveCount(0); - - for (const label of ['Release work', 'vscode-docs']) { - const row = page.getByRole('treeitem', { name: `${label}, 1, unread sessions`, exact: true }); - const icon = row.locator('.session-section-icon'); - const unread = icon.locator('.codicon-circle-filled'); - const chevron = row.locator('.session-section-chevron'); - - await expect(row).toHaveAttribute('aria-expanded', 'false'); - await expect(unread).toBeVisible(); - await expect(unread).toHaveAttribute('style', 'color: var(--vscode-textLink-foreground);'); - await expect(chevron).toBeHidden(); - - await row.hover(); - await expect(icon).toBeHidden(); - await expect(chevron).toBeVisible(); - - await readWorkspace.hover(); - await expect(unread).toBeVisible(); - await expect(chevron).toBeHidden(); - - await row.click(); - await readWorkspace.hover(); - await expect(row).toHaveAttribute('aria-expanded', 'true'); - await expect(unread).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(unread).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(); - } + for (const { name, fixture, ariaStatus, indicatorClass, color, count } of [ + { name: 'unread', fixture: 'SessionsList_CollapsedUnreadSections', ariaStatus: '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 }, + ]) { + 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 when the setting is disabled', 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(); + } + }); }); - - test('keeps normal icons when the setting is disabled', async ({ page }) => { - await openFixture(page, `sessions/sessionsList/SessionsList_CollapsedUnreadSections_Disabled/${theme}`, '.sessions-list-control'); - - await expect(page.locator('.session-section-icon .codicon-circle-filled')).toHaveCount(0); - await expect(page.getByRole('treeitem', { name: 'Release work, 1', exact: true }).locator('.codicon-folder-library')).toBeVisible(); - for (const label of ['vscode', 'vscode-docs']) { - const row = page.getByRole('treeitem', { name: `${label}, 1`, exact: true }); - await expect(row).toHaveAttribute('aria-expanded', 'false'); - await expect(row.locator('.session-section-icon.codicon-folder')).toBeVisible(); - } - }); - }); + } } From 5414ca92a71a55095d6e6e4f385208277058510d Mon Sep 17 00:00:00 2001 From: BeniBenj Date: Thu, 10 Sep 2026 22:55:49 +0200 Subject: [PATCH 3/7] sessions: Make section indicators opt-in and show CI failures Prioritize needs-input, then CI failures in sessions that are not in progress, then unread state. Always exclude archived sessions and retain automatic experiment control. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../sessions/browser/sessions.contribution.ts | 4 +- .../sessions/browser/views/sessionsList.ts | 60 ++++-- .../browser/sessions.contribution.test.ts | 4 +- .../test/browser/sessionsList.test.ts | 186 +++++++++++++++++- .../sessions/sessionsList.fixture.ts | 57 ++++-- .../tests/sessionsListTwistie.spec.ts | 3 +- 6 files changed, 270 insertions(+), 44 deletions(-) diff --git a/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts b/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts index 722dc3d0765c18..6e42295964e08a 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts @@ -74,8 +74,8 @@ Registry.as(ConfigurationExtensions.Configuration).regis [SESSIONS_LIST_SHOW_UNREAD_IN_COLLAPSED_SECTIONS_SETTING]: { type: 'boolean', tags: ['preview'], - description: localize('sessions.list.showUnreadInCollapsedSections', "Controls whether collapsed groups and workspace sections in the sessions list show unread or needs-input indicators for the sessions they contain."), - default: true, + description: localize('sessions.list.showUnreadInCollapsedSections', "Controls whether collapsed groups and workspace 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]: { diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts index 8b8c183292da01..fc5d3659b292e7 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts @@ -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'; @@ -103,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.$; @@ -1298,23 +1299,32 @@ interface ISessionHeaderTemplate { readonly elementDisposables: DisposableStore; } -function getSessionHeaderStatus(sessions: readonly ISession[], reader: IReader): SessionStatus | undefined { +const enum SessionHeaderStatus { + NeedsInput, + FailingCI, + Unread, +} + +function getSessionHeaderStatus(sessions: readonly ISession[], reader: IReader, sessionsWithFailingCI: ReadonlySet | undefined): SessionHeaderStatus | undefined { + let hasFailingCI = false; let hasUnread = false; for (const session of sessions) { if (session.isArchived.read(reader)) { continue; } - if (session.status.read(reader) === SessionStatus.NeedsInput) { - return SessionStatus.NeedsInput; + 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 hasUnread ? SessionStatus.Completed : undefined; + return hasFailingCI ? SessionHeaderStatus.FailingCI : hasUnread ? SessionHeaderStatus.Unread : undefined; } -function renderSessionHeaderIcon(template: ISessionHeaderTemplate, sessions: readonly ISession[], icon: ThemeIcon | undefined, showUnreadInCollapsedSections: IObservable, instantiationService: IInstantiationService): void { +function renderSessionHeaderIcon(template: ISessionHeaderTemplate, sessions: readonly ISession[], icon: ThemeIcon | undefined, showUnreadInCollapsedSections: IObservable, sessionsWithFailingCI: IObservable>, instantiationService: IInstantiationService): void { const headerStatus = derived(reader => template.collapsed.read(reader) && showUnreadInCollapsedSections.read(reader) - ? getSessionHeaderStatus(sessions, reader) + ? getSessionHeaderStatus(sessions, reader, sessionsWithFailingCI.read(reader)) : undefined); template.elementDisposables.add(autorun(reader => { const status = headerStatus.read(reader); @@ -1323,7 +1333,12 @@ function renderSessionHeaderIcon(template: ISessionHeaderTemplate, sessions: rea template.icon.style.display = status !== undefined || icon ? '' : 'none'; if (status !== undefined) { const statusIcon = reader.store.add(instantiationService.createInstance(SessionStatusIcon, template.icon)); - statusIcon.setStatus(status, status !== SessionStatus.Completed, false); + 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)); } @@ -1389,6 +1404,7 @@ export class SessionSectionRenderer implements ITreeRenderer void, private readonly showUnreadInCollapsedSections: IObservable, + private readonly sessionsWithFailingCI: IObservable>, private readonly instantiationService: IInstantiationService, private readonly contextKeyService: IContextKeyService, private readonly automationService: IAutomationService, @@ -1522,7 +1538,7 @@ export class SessionSectionRenderer implements ITreeRenderer, + private readonly sessionsWithFailingCI: IObservable>, private readonly instantiationService: IInstantiationService, private readonly contextKeyService: IContextKeyService, ) { } @@ -1661,7 +1678,7 @@ class SessionGroupRenderer implements ITreeRenderer 0); SessionGroupIsEmptyContext.bindTo(template.contextKeyService).set(element.isEmpty); @@ -1838,6 +1855,7 @@ interface ISessionsAccessibilityProviderOptions { readonly includeQuickChatInAriaLabel?: boolean; readonly automationNewBadgeVisible?: IObservable; readonly showUnreadInCollapsedSections?: IObservable; + readonly sessionsWithFailingCI?: IObservable>; /** Mirrors {@link SessionItemRenderer}'s option of the same name — see there for rationale. */ readonly deriveStatusFromMainChat?: boolean; } @@ -1941,11 +1959,13 @@ class SessionsAccessibilityProvider { private getSectionAriaLabel(label: string, sessions: readonly ISession[]): IObservable { return derived(this, reader => { - const status = this.options?.showUnreadInCollapsedSections?.read(reader) ? getSessionHeaderStatus(sessions, reader) : undefined; + const status = this.options?.showUnreadInCollapsedSections?.read(reader) ? getSessionHeaderStatus(sessions, reader, this.options.sessionsWithFailingCI?.read(reader)) : undefined; switch (status) { - case SessionStatus.NeedsInput: + case SessionHeaderStatus.NeedsInput: return localize('sessionSectionNeedsInputAria', "{0}, {1}, session needs input", label, sessions.length); - case SessionStatus.Completed: + case SessionHeaderStatus.FailingCI: + return localize('sessionSectionFailingCIAria', "{0}, {1}, session has failing CI checks", label, sessions.length); + case SessionHeaderStatus.Unread: return localize('sessionSectionUnreadAria', "{0}, {1}, unread sessions", label, sessions.length); default: return localize('sessionSectionAria', "{0}, {1}", label, sessions.length); @@ -2697,11 +2717,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, true, this.configurationService); + 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, @@ -2716,7 +2745,7 @@ export class SessionsList extends Disposable implements ISessionsList { commitEdit: (group, name) => this.commitGroupEdit(group, name), cancelEdit: group => this.cancelGroupEdit(group), select: selectHeader, - }, showUnreadInCollapsedSections, instantiationService, contextKeyService); + }, showUnreadInCollapsedSections, sessionsWithFailingCI, instantiationService, contextKeyService); this._groupRenderer = groupRenderer; // Read (don't bind) `IsPhoneLayoutContext` from the parent context so we @@ -2755,6 +2784,7 @@ export class SessionsList extends Disposable implements ISessionsList { deriveStatusFromMainChat: true, automationNewBadgeVisible: this.automationsNewBadgeState.showNewBadge, showUnreadInCollapsedSections, + sessionsWithFailingCI, }), dnd: this._register(new SessionsListDragAndDrop({ isReorderable: session => this.isReorderable(session), diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessions.contribution.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessions.contribution.test.ts index 6f72425a73bbd6..f07076fbadfefe 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessions.contribution.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessions.contribution.test.ts @@ -14,7 +14,7 @@ import '../../browser/sessions.contribution.js'; suite('Sessions Contribution', () => { ensureNoDisposablesAreLeakedInTestSuite(); - test('enables collapsed section unread indicators by default with automatic experiments', () => { + test('disables collapsed section status indicators by default with automatic experiments', () => { const property = Registry.as(ConfigurationExtensions.Configuration).getConfigurationProperties()[SESSIONS_LIST_SHOW_UNREAD_IN_COLLAPSED_SECTIONS_SETTING]; assert.deepStrictEqual({ type: property.type, @@ -22,7 +22,7 @@ suite('Sessions Contribution', () => { experiment: property.experiment, }, { type: 'boolean', - default: true, + default: false, experiment: { mode: 'auto' }, }); }); diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts index f68c068421fa7e..a6b490916f645f 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts @@ -9,7 +9,7 @@ import { Codicon } from '../../../../../base/common/codicons.js'; import { findOnboardingTarget } from '../../../../../workbench/contrib/onboarding/browser/spotlight/onboardingTarget.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { ExtUri } from '../../../../../base/common/resources.js'; -import { constObservable, IObservable, ISettableObservable, observableFromEvent, observableValue } from '../../../../../base/common/observable.js'; +import { constObservable, derived, IObservable, ISettableObservable, observableFromEvent, observableValue } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; import { mock, upcastPartial } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; @@ -53,6 +53,7 @@ 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; @@ -124,6 +125,7 @@ suite('Sessions - SessionsList', () => { true, section => selectedSections.push(section), constObservable(true), + constObservable(new Set()), instantiationService, contextKeyService, automationService, @@ -178,6 +180,7 @@ suite('Sessions - SessionsList', () => { true, () => { }, constObservable(true), + constObservable(new Set()), instantiationService, contextKeyService, automationService, @@ -237,6 +240,7 @@ suite('Sessions - SessionsList', () => { true, () => { }, constObservable(true), + constObservable(new Set()), instantiationService, contextKeyService, automationService, @@ -413,6 +417,7 @@ suite('Sessions - SessionsList', () => { true, () => { }, constObservable(true), + constObservable(new Set()), new class extends mock() { }, new class extends mock() { }, automationService, @@ -482,6 +487,7 @@ suite('Sessions - SessionsList', () => { true, () => { }, constObservable(true), + constObservable(new Set()), new class extends mock() { }, new class extends mock() { }, automationService, @@ -525,12 +531,24 @@ 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 = {}, reducedMotion = false) { + 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('failingCISessions', []); + harness.instantiationService.stubInstance(BlockedSessions, new class extends mock() { + 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, @@ -538,7 +556,7 @@ suite('Sessions - SessionsList', () => { onSessionOpen: () => { }, })); list.layout(700, 400); - return { ...harness, list, container }; + return { ...harness, list, container, failingCISessions }; } function unreadSections(container: HTMLElement): string[] { @@ -560,6 +578,32 @@ suite('Sessions - SessionsList', () => { 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; @@ -611,9 +655,94 @@ suite('Sessions - SessionsList', () => { ]); }); + 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(); + 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('.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('.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 }); @@ -818,6 +947,57 @@ suite('Sessions - SessionsList', () => { }); }); + 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({ + affectsConfiguration: key => key === SESSIONS_LIST_SHOW_UNREAD_IN_COLLAPSED_SECTIONS_SETTING, + })); + const header = getHeader(container, 'Workspace'); + states.push({ + color: header.querySelector('.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]); diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsList.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsList.fixture.ts index 18183d95ce4675..faf4448838b192 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsList.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsList.fixture.ts @@ -12,7 +12,7 @@ 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'; @@ -40,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 @@ -58,6 +58,8 @@ import { IActiveSession, ISessionsManagementService } from '../../../../../sessi // eslint-disable-next-line local/code-import-patterns 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 import { createSessionArchiveTour } from '../../../../../sessions/contrib/onboardingTours/browser/tours/sessionArchiveTour.js'; @@ -135,6 +137,7 @@ interface ISessionSpec { 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). */ @@ -338,19 +341,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() { override readonly onDidChange = Event.None; @@ -447,6 +438,13 @@ async function renderSessionsList(ctx: ComponentFixtureContext, options: IRender }()); } + instantiationService.stubInstance(BlockedSessions, new class extends mock() { + 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' }); @@ -611,6 +609,11 @@ const COLLAPSED_NEEDS_INPUT_SESSIONS: readonly ISessionSpec[] = [ { 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({ @@ -629,25 +632,37 @@ export default defineThemedFixtureGroup({ path: 'sessions/' }, { labels: { kind: 'screenshot', blocksCi: true }, 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 }), + render: ctx => renderSessionsList(ctx, { sessions: COLLAPSED_SECTION_SESSIONS, groups: [GROUP], collapsed: true, showUnreadInCollapsedSections: true }), }), SessionsList_CollapsedUnreadSections_Disabled: defineComponentFixture({ labels: { kind: 'screenshot', blocksCi: true }, additionalThemes: ['darkHighContrast', 'lightHighContrast'], - expectedVisualDescriptions: ['All sections are collapsed and retain their normal group or folder icons despite containing unread sessions, because collapsed-section unread indicators are disabled.'], - render: ctx => renderSessionsList(ctx, { sessions: COLLAPSED_SECTION_SESSIONS, groups: [GROUP], collapsed: true, showUnreadInCollapsedSections: false }), + 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', blocksCi: true }, 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, reducedMotion: false }), + render: ctx => renderSessionsList(ctx, { sessions: COLLAPSED_NEEDS_INPUT_SESSIONS, groups: [GROUP], collapsed: true, showUnreadInCollapsedSections: true, reducedMotion: false }), }), SessionsList_CollapsedNeedsInputSections_Disabled: defineComponentFixture({ labels: { kind: 'screenshot', blocksCi: true }, 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.'], - render: ctx => renderSessionsList(ctx, { sessions: COLLAPSED_NEEDS_INPUT_SESSIONS, groups: [GROUP], collapsed: true, showUnreadInCollapsedSections: false, reducedMotion: false }), + 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', blocksCi: true }, + 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', blocksCi: true }, + 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, { diff --git a/test/componentFixtures/playwright/tests/sessionsListTwistie.spec.ts b/test/componentFixtures/playwright/tests/sessionsListTwistie.spec.ts index 55fc6b21ff5b17..2db932c9767b74 100644 --- a/test/componentFixtures/playwright/tests/sessionsListTwistie.spec.ts +++ b/test/componentFixtures/playwright/tests/sessionsListTwistie.spec.ts @@ -33,6 +33,7 @@ for (const theme of ['Dark', 'Light', 'DarkHighContrast', 'LightHighContrast']) for (const { name, fixture, ariaStatus, indicatorClass, color, count } of [ { name: 'unread', fixture: 'SessionsList_CollapsedUnreadSections', ariaStatus: '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 }) => { @@ -80,7 +81,7 @@ for (const theme of ['Dark', 'Light', 'DarkHighContrast', 'LightHighContrast']) } }); - test('keeps normal icons when the setting is disabled', async ({ page }) => { + 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); From 6c0bfe35883ab343295c029d1411e04714f82841 Mon Sep 17 00:00:00 2001 From: BeniBenj Date: Thu, 10 Sep 2026 23:23:10 +0200 Subject: [PATCH 4/7] sessions: Clarify collapsed-section status wording Describe unread containment without implying an unread count, and clarify that the setting covers collapsed sections generally. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../contrib/sessions/browser/sessions.contribution.ts | 2 +- .../sessions/contrib/sessions/browser/views/sessionsList.ts | 2 +- .../contrib/sessions/test/browser/sessionsList.test.ts | 6 +++--- .../playwright/tests/sessionsListTwistie.spec.ts | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts b/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts index 6e42295964e08a..62acf96452a836 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts @@ -74,7 +74,7 @@ Registry.as(ConfigurationExtensions.Configuration).regis [SESSIONS_LIST_SHOW_UNREAD_IN_COLLAPSED_SECTIONS_SETTING]: { type: 'boolean', tags: ['preview'], - description: localize('sessions.list.showUnreadInCollapsedSections', "Controls whether collapsed groups and workspace sections in the sessions list show needs-input, CI-failure, or unread indicators for the unarchived sessions they contain."), + 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' } }, diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts index fc5d3659b292e7..68008776e5b360 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts @@ -1966,7 +1966,7 @@ class SessionsAccessibilityProvider { case SessionHeaderStatus.FailingCI: return localize('sessionSectionFailingCIAria', "{0}, {1}, session has failing CI checks", label, sessions.length); case SessionHeaderStatus.Unread: - return localize('sessionSectionUnreadAria', "{0}, {1}, unread sessions", label, sessions.length); + return localize('sessionSectionUnreadAria', "{0}, {1}, contains unread sessions", label, sessions.length); default: return localize('sessionSectionAria', "{0}, {1}", label, sessions.length); } diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts index a6b490916f645f..f6fb43800e55af 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts @@ -621,7 +621,7 @@ suite('Sessions - SessionsList', () => { color: getHeader(container, group.name).querySelector('.codicon-circle-filled')?.style.color, }, { unreadSections: [group.name, 'Workspace C'], - groupAria: 'Group A, 1, unread sessions', + groupAria: 'Group A, 1, contains unread sessions', workspaceAria: 'Workspace B, 1', color: 'var(--vscode-textLink-foreground)', }); @@ -799,7 +799,7 @@ suite('Sessions - SessionsList', () => { preservesSpinner: true, color: 'var(--vscode-list-warningForeground)', needsInputAria: `${label}, 6, session needs input`, - unreadAria: `${label}, 6, unread sessions`, + unreadAria: `${label}, 6, contains unread sessions`, expandedPulse: false, clearedPulse: false, }); @@ -1048,7 +1048,7 @@ suite('Sessions - SessionsList', () => { assert.deepStrictEqual(states, [ { unreadSections: [], groupIcon: true, workspaceIcon: true, groupAria: 'Group A, 1' }, - { unreadSections: [group.name, 'Workspace'], groupIcon: false, workspaceIcon: false, groupAria: 'Group A, 1, unread sessions' }, + { unreadSections: [group.name, 'Workspace'], groupIcon: false, workspaceIcon: false, groupAria: 'Group A, 1, contains unread sessions' }, ]); }); }); diff --git a/test/componentFixtures/playwright/tests/sessionsListTwistie.spec.ts b/test/componentFixtures/playwright/tests/sessionsListTwistie.spec.ts index 2db932c9767b74..20c93692e4a498 100644 --- a/test/componentFixtures/playwright/tests/sessionsListTwistie.spec.ts +++ b/test/componentFixtures/playwright/tests/sessionsListTwistie.spec.ts @@ -31,7 +31,7 @@ test('reveals the nested chat twistie only while hovering the session row', asyn for (const theme of ['Dark', 'Light', 'DarkHighContrast', 'LightHighContrast']) { for (const { name, fixture, ariaStatus, indicatorClass, color, count } of [ - { name: 'unread', fixture: 'SessionsList_CollapsedUnreadSections', ariaStatus: 'unread sessions', indicatorClass: '.codicon-circle-filled', color: '--vscode-textLink-foreground', count: 1 }, + { 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 }, ]) { From e6cd29e6bbe3157a52ca248d08d28bcc639ccc1e Mon Sep 17 00:00:00 2001 From: BeniBenj Date: Fri, 11 Sep 2026 09:16:34 +0200 Subject: [PATCH 5/7] sessions: Isolate setting test from registry resets Capture the registered schema before configuration tests reset the shared registry, matching the existing Automations contribution test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../test/browser/sessions.contribution.test.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessions.contribution.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessions.contribution.test.ts index f07076fbadfefe..e00a762f3d1b4a 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessions.contribution.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessions.contribution.test.ts @@ -11,15 +11,18 @@ import { SESSIONS_LIST_SHOW_UNREAD_IN_COLLAPSED_SECTIONS_SETTING } from '../../b import '../../browser/sessions.contribution.js'; +const configurationRegistry = Registry.as(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', () => { - const property = Registry.as(ConfigurationExtensions.Configuration).getConfigurationProperties()[SESSIONS_LIST_SHOW_UNREAD_IN_COLLAPSED_SECTIONS_SETTING]; assert.deepStrictEqual({ - type: property.type, - default: property.default, - experiment: property.experiment, + type: collapsedSectionStatusProperty.type, + default: collapsedSectionStatusProperty.default, + experiment: collapsedSectionStatusProperty.experiment, }, { type: 'boolean', default: false, From ed91a55e1c46c8476f9225ee0feb5e32c3caeae5 Mon Sep 17 00:00:00 2001 From: BeniBenj Date: Fri, 11 Sep 2026 10:19:15 +0200 Subject: [PATCH 6/7] sessions: Add collapsed-section screenshot baselines Record the 24 CI-rendered indicator fixtures, including the default-disabled variants, across all four themes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../blocks-ci-screenshots.md | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/test/componentFixtures/blocks-ci-screenshots.md b/test/componentFixtures/blocks-ci-screenshots.md index 56189680a19247..1a2b60d53616c8 100644 --- a/test/componentFixtures/blocks-ci-screenshots.md +++ b/test/componentFixtures/blocks-ci-screenshots.md @@ -402,6 +402,78 @@ #### sessions/sessionsList/SessionsList_AutomationsNewBadge/Light ![screenshot](https://hediet-screenshots.azurewebsites.net/images/0154b8cd8302a62959c0a067e02aadc24d27a54d85891dd19fb9e9b4d99362f2) +#### sessions/sessionsList/SessionsList_CollapsedCIFailureSections_Disabled/Dark +![screenshot](https://hediet-screenshots.azurewebsites.net/images/29321280d21b9151dd34726ae6cccc6ae6ce74e796dab433d969b9e0391131ab) + +#### sessions/sessionsList/SessionsList_CollapsedCIFailureSections_Disabled/DarkHighContrast +![screenshot](https://hediet-screenshots.azurewebsites.net/images/85f65cca3860448563d244522076422277057899936ef9a11e18955f47614eaf) + +#### sessions/sessionsList/SessionsList_CollapsedCIFailureSections_Disabled/Light +![screenshot](https://hediet-screenshots.azurewebsites.net/images/6b35f5e8fa32ffc25051034250575c9359da387041a555851e8301d63792d337) + +#### sessions/sessionsList/SessionsList_CollapsedCIFailureSections_Disabled/LightHighContrast +![screenshot](https://hediet-screenshots.azurewebsites.net/images/5b213b9daa7baff2feaa4bafdf16165e966f041e55ff3deb45852d850826987b) + +#### sessions/sessionsList/SessionsList_CollapsedCIFailureSections/Dark +![screenshot](https://hediet-screenshots.azurewebsites.net/images/6a05d1c10de06e3a1f30b56b3226070ef30044c5d570f7a873d626fc948f24ab) + +#### sessions/sessionsList/SessionsList_CollapsedCIFailureSections/DarkHighContrast +![screenshot](https://hediet-screenshots.azurewebsites.net/images/d32216e6ca29f810f1bfc7e67410eed4cf8ffafd35e46ba316405e50cf266094) + +#### sessions/sessionsList/SessionsList_CollapsedCIFailureSections/Light +![screenshot](https://hediet-screenshots.azurewebsites.net/images/f66a7458ace54acccfdcbd767eed1fed24b71fff5c3241922dbf627382a754d8) + +#### sessions/sessionsList/SessionsList_CollapsedCIFailureSections/LightHighContrast +![screenshot](https://hediet-screenshots.azurewebsites.net/images/70b4d73102a70197ca28ef2a15d3a52d1d95c00936d20bcb78397b6a2697e673) + +#### sessions/sessionsList/SessionsList_CollapsedNeedsInputSections_Disabled/Dark +![screenshot](https://hediet-screenshots.azurewebsites.net/images/29321280d21b9151dd34726ae6cccc6ae6ce74e796dab433d969b9e0391131ab) + +#### sessions/sessionsList/SessionsList_CollapsedNeedsInputSections_Disabled/DarkHighContrast +![screenshot](https://hediet-screenshots.azurewebsites.net/images/85f65cca3860448563d244522076422277057899936ef9a11e18955f47614eaf) + +#### sessions/sessionsList/SessionsList_CollapsedNeedsInputSections_Disabled/Light +![screenshot](https://hediet-screenshots.azurewebsites.net/images/6b35f5e8fa32ffc25051034250575c9359da387041a555851e8301d63792d337) + +#### sessions/sessionsList/SessionsList_CollapsedNeedsInputSections_Disabled/LightHighContrast +![screenshot](https://hediet-screenshots.azurewebsites.net/images/5b213b9daa7baff2feaa4bafdf16165e966f041e55ff3deb45852d850826987b) + +#### sessions/sessionsList/SessionsList_CollapsedNeedsInputSections/Dark +![screenshot](https://hediet-screenshots.azurewebsites.net/images/a812f496901ed3cf413c236b0da2510029228ad4578c54122287429ea6fc53a6) + +#### sessions/sessionsList/SessionsList_CollapsedNeedsInputSections/DarkHighContrast +![screenshot](https://hediet-screenshots.azurewebsites.net/images/b73f6813f091242cf610b8280c5f20ed36ff0c18fe49e07367671bc469c66447) + +#### sessions/sessionsList/SessionsList_CollapsedNeedsInputSections/Light +![screenshot](https://hediet-screenshots.azurewebsites.net/images/311c8634bf0d3f7bbc261965748fdad2f8f46e4b5db47356fd88c446092b9a4b) + +#### sessions/sessionsList/SessionsList_CollapsedNeedsInputSections/LightHighContrast +![screenshot](https://hediet-screenshots.azurewebsites.net/images/93cbfa1cd34470b1b3843e7adfc02bdf37cf22cb9f7efeb67306062bae1a39db) + +#### sessions/sessionsList/SessionsList_CollapsedUnreadSections_Disabled/Dark +![screenshot](https://hediet-screenshots.azurewebsites.net/images/29321280d21b9151dd34726ae6cccc6ae6ce74e796dab433d969b9e0391131ab) + +#### sessions/sessionsList/SessionsList_CollapsedUnreadSections_Disabled/DarkHighContrast +![screenshot](https://hediet-screenshots.azurewebsites.net/images/85f65cca3860448563d244522076422277057899936ef9a11e18955f47614eaf) + +#### sessions/sessionsList/SessionsList_CollapsedUnreadSections_Disabled/Light +![screenshot](https://hediet-screenshots.azurewebsites.net/images/6b35f5e8fa32ffc25051034250575c9359da387041a555851e8301d63792d337) + +#### sessions/sessionsList/SessionsList_CollapsedUnreadSections_Disabled/LightHighContrast +![screenshot](https://hediet-screenshots.azurewebsites.net/images/5b213b9daa7baff2feaa4bafdf16165e966f041e55ff3deb45852d850826987b) + +#### sessions/sessionsList/SessionsList_CollapsedUnreadSections/Dark +![screenshot](https://hediet-screenshots.azurewebsites.net/images/ddde17f262dec621459eff7754eae76dfce8f98100fa563e11b80d36e21667c0) + +#### sessions/sessionsList/SessionsList_CollapsedUnreadSections/DarkHighContrast +![screenshot](https://hediet-screenshots.azurewebsites.net/images/da90ddfae04f6aacbcc8788b3d0d7ecbbf5309a3b145f3c0eec908e4056b5c53) + +#### sessions/sessionsList/SessionsList_CollapsedUnreadSections/Light +![screenshot](https://hediet-screenshots.azurewebsites.net/images/76c44cf698b74effe89ce037529be3815305c677e5fcebbc818a47793523ec45) + +#### sessions/sessionsList/SessionsList_CollapsedUnreadSections/LightHighContrast +![screenshot](https://hediet-screenshots.azurewebsites.net/images/b0589c81120b49b4df2950153017f8fc0172804f380aa1b4ad8d97b157e6a415) + #### sessions/sessionsList/SessionsList_LightweightNewButton/Dark ![screenshot](https://hediet-screenshots.azurewebsites.net/images/d8f12a4790516fe09f20ccff0012b2422bac95e48c7f3360af36c254b31b57dc) From 8029c2ed43c29854103763a06b5b7ea2d707c88d Mon Sep 17 00:00:00 2001 From: BeniBenj Date: Fri, 11 Sep 2026 14:56:01 +0200 Subject: [PATCH 7/7] sessions: Make collapsed-section screenshots non-blocking Keep the new screenshot fixtures and browser tests without requiring committed baseline updates for their visual differences. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../sessions/sessionsList.fixture.ts | 12 ++-- .../blocks-ci-screenshots.md | 72 ------------------- 2 files changed, 6 insertions(+), 78 deletions(-) diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsList.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsList.fixture.ts index 8c425928bca27e..8c227163986bf0 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsList.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsList.fixture.ts @@ -630,37 +630,37 @@ export default defineThemedFixtureGroup({ path: 'sessions/' }, { render: ctx => renderSessionsList(ctx, { sessions: GROUPED_SESSIONS, groups: [GROUP] }), }), SessionsList_CollapsedUnreadSections: defineComponentFixture({ - labels: { kind: 'screenshot', blocksCi: true }, + 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', blocksCi: true }, + 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', blocksCi: true }, + 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', blocksCi: true }, + 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', blocksCi: true }, + 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', blocksCi: true }, + 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 }), diff --git a/test/componentFixtures/blocks-ci-screenshots.md b/test/componentFixtures/blocks-ci-screenshots.md index 6a694b21121695..cbe4a5c8e56d16 100644 --- a/test/componentFixtures/blocks-ci-screenshots.md +++ b/test/componentFixtures/blocks-ci-screenshots.md @@ -414,78 +414,6 @@ #### sessions/sessionsList/SessionsList_AutomationsNewBadge/Light ![screenshot](https://hediet-screenshots.azurewebsites.net/images/0154b8cd8302a62959c0a067e02aadc24d27a54d85891dd19fb9e9b4d99362f2) -#### sessions/sessionsList/SessionsList_CollapsedCIFailureSections_Disabled/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/29321280d21b9151dd34726ae6cccc6ae6ce74e796dab433d969b9e0391131ab) - -#### sessions/sessionsList/SessionsList_CollapsedCIFailureSections_Disabled/DarkHighContrast -![screenshot](https://hediet-screenshots.azurewebsites.net/images/85f65cca3860448563d244522076422277057899936ef9a11e18955f47614eaf) - -#### sessions/sessionsList/SessionsList_CollapsedCIFailureSections_Disabled/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/6b35f5e8fa32ffc25051034250575c9359da387041a555851e8301d63792d337) - -#### sessions/sessionsList/SessionsList_CollapsedCIFailureSections_Disabled/LightHighContrast -![screenshot](https://hediet-screenshots.azurewebsites.net/images/5b213b9daa7baff2feaa4bafdf16165e966f041e55ff3deb45852d850826987b) - -#### sessions/sessionsList/SessionsList_CollapsedCIFailureSections/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/6a05d1c10de06e3a1f30b56b3226070ef30044c5d570f7a873d626fc948f24ab) - -#### sessions/sessionsList/SessionsList_CollapsedCIFailureSections/DarkHighContrast -![screenshot](https://hediet-screenshots.azurewebsites.net/images/d32216e6ca29f810f1bfc7e67410eed4cf8ffafd35e46ba316405e50cf266094) - -#### sessions/sessionsList/SessionsList_CollapsedCIFailureSections/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/f66a7458ace54acccfdcbd767eed1fed24b71fff5c3241922dbf627382a754d8) - -#### sessions/sessionsList/SessionsList_CollapsedCIFailureSections/LightHighContrast -![screenshot](https://hediet-screenshots.azurewebsites.net/images/70b4d73102a70197ca28ef2a15d3a52d1d95c00936d20bcb78397b6a2697e673) - -#### sessions/sessionsList/SessionsList_CollapsedNeedsInputSections_Disabled/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/29321280d21b9151dd34726ae6cccc6ae6ce74e796dab433d969b9e0391131ab) - -#### sessions/sessionsList/SessionsList_CollapsedNeedsInputSections_Disabled/DarkHighContrast -![screenshot](https://hediet-screenshots.azurewebsites.net/images/85f65cca3860448563d244522076422277057899936ef9a11e18955f47614eaf) - -#### sessions/sessionsList/SessionsList_CollapsedNeedsInputSections_Disabled/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/6b35f5e8fa32ffc25051034250575c9359da387041a555851e8301d63792d337) - -#### sessions/sessionsList/SessionsList_CollapsedNeedsInputSections_Disabled/LightHighContrast -![screenshot](https://hediet-screenshots.azurewebsites.net/images/5b213b9daa7baff2feaa4bafdf16165e966f041e55ff3deb45852d850826987b) - -#### sessions/sessionsList/SessionsList_CollapsedNeedsInputSections/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/a812f496901ed3cf413c236b0da2510029228ad4578c54122287429ea6fc53a6) - -#### sessions/sessionsList/SessionsList_CollapsedNeedsInputSections/DarkHighContrast -![screenshot](https://hediet-screenshots.azurewebsites.net/images/b73f6813f091242cf610b8280c5f20ed36ff0c18fe49e07367671bc469c66447) - -#### sessions/sessionsList/SessionsList_CollapsedNeedsInputSections/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/311c8634bf0d3f7bbc261965748fdad2f8f46e4b5db47356fd88c446092b9a4b) - -#### sessions/sessionsList/SessionsList_CollapsedNeedsInputSections/LightHighContrast -![screenshot](https://hediet-screenshots.azurewebsites.net/images/93cbfa1cd34470b1b3843e7adfc02bdf37cf22cb9f7efeb67306062bae1a39db) - -#### sessions/sessionsList/SessionsList_CollapsedUnreadSections_Disabled/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/29321280d21b9151dd34726ae6cccc6ae6ce74e796dab433d969b9e0391131ab) - -#### sessions/sessionsList/SessionsList_CollapsedUnreadSections_Disabled/DarkHighContrast -![screenshot](https://hediet-screenshots.azurewebsites.net/images/85f65cca3860448563d244522076422277057899936ef9a11e18955f47614eaf) - -#### sessions/sessionsList/SessionsList_CollapsedUnreadSections_Disabled/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/6b35f5e8fa32ffc25051034250575c9359da387041a555851e8301d63792d337) - -#### sessions/sessionsList/SessionsList_CollapsedUnreadSections_Disabled/LightHighContrast -![screenshot](https://hediet-screenshots.azurewebsites.net/images/5b213b9daa7baff2feaa4bafdf16165e966f041e55ff3deb45852d850826987b) - -#### sessions/sessionsList/SessionsList_CollapsedUnreadSections/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/ddde17f262dec621459eff7754eae76dfce8f98100fa563e11b80d36e21667c0) - -#### sessions/sessionsList/SessionsList_CollapsedUnreadSections/DarkHighContrast -![screenshot](https://hediet-screenshots.azurewebsites.net/images/da90ddfae04f6aacbcc8788b3d0d7ecbbf5309a3b145f3c0eec908e4056b5c53) - -#### sessions/sessionsList/SessionsList_CollapsedUnreadSections/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/76c44cf698b74effe89ce037529be3815305c677e5fcebbc818a47793523ec45) - -#### sessions/sessionsList/SessionsList_CollapsedUnreadSections/LightHighContrast -![screenshot](https://hediet-screenshots.azurewebsites.net/images/b0589c81120b49b4df2950153017f8fc0172804f380aa1b4ad8d97b157e6a415) - #### sessions/sessionsList/SessionsList_LightweightNewButton/Dark ![screenshot](https://hediet-screenshots.azurewebsites.net/images/a061a58bb4fbabce4ce5badf826c4377a624fc9ba88dbc5ec66f0bfaaef9e3d4)