diff --git a/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts b/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts index a35526a5a3a867..dcb7b0b2a90f9f 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'; @@ -72,6 +72,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 sections in the sessions list show needs-input, CI-failure, or unread indicators for the unarchived sessions they contain."), + default: false, + experiment: { mode: 'auto' } + }, [SESSIONS_ARCHIVE_SESSION_CONFETTI_SETTING]: { type: 'boolean', tags: ['preview'], diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts index 8e9398f6478cb2..10b61cb7dcec74 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'; @@ -44,6 +44,7 @@ import { asCssVariable } from '../../../../../platform/theme/common/colorUtils.j import { chartsOrange } from '../../../../../platform/theme/common/colors/chartsColors.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { observableConfigValue } from '../../../../../platform/observable/common/platformObservableUtils.js'; import { IUriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentity.js'; import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; import { ILabelService } from '../../../../../platform/label/common/label.js'; @@ -102,6 +103,7 @@ import { AutomationsNewBadgeState, type AutomationsNewBadgeStyle } from '../auto import { Menus } from '../../../../browser/menus.js'; import { getSessionConversationStatusAriaLabel } from '../../../../browser/sessionConversationGroups.js'; import { getAgentMergeAwarePullRequestIcon, getSessionAgentMergeConfigurationObservable, ISessionAgentMergeConfiguration, isAgentMergePullRequestIcon } from '../../../../browser/sessionAgentMerge.js'; +import { BlockedSessionReason, BlockedSessions } from '../../../blockedSessions/browser/blockedSessions.js'; const $ = DOM.$; @@ -119,6 +121,7 @@ export const NEW_SESSION_FOR_WORKSPACE_ACTION_ID = 'sessionsView.sectionNewSessi /** Controls whether the empty default Chats group is shown in the sessions list. */ export const SESSIONS_LIST_SHOW_EMPTY_DEFAULT_GROUPS_SETTING = 'sessions.list.showEmptyDefaultGroups'; +export const SESSIONS_LIST_SHOW_UNREAD_IN_COLLAPSED_SECTIONS_SETTING = 'sessions.list.showUnreadInCollapsedSections'; export const IsSessionPinnedContext = new RawContextKey('sessionItem.isPinned', false); export const SessionItemHasBranchNameContext = new RawContextKey('sessionItem.hasBranchName', false); @@ -1274,11 +1277,59 @@ function getWorkspaceBadgeLabel(workspace: ISessionWorkspace): string | undefine //#region Section Header Renderer interface ISessionHeaderTemplate { + readonly icon: HTMLElement; + readonly collapsed: ISettableObservable; readonly toolbarContainer: HTMLElement; readonly toolbar: MenuWorkbenchToolBar; readonly elementDisposables: DisposableStore; } +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; + } + const status = session.status.read(reader); + if (status === SessionStatus.NeedsInput) { + return SessionHeaderStatus.NeedsInput; + } + hasFailingCI ||= status !== SessionStatus.InProgress && sessionsWithFailingCI?.has(session.sessionId) === true; + hasUnread ||= !session.isRead.read(reader); + } + return hasFailingCI ? SessionHeaderStatus.FailingCI : hasUnread ? SessionHeaderStatus.Unread : undefined; +} + +function renderSessionHeaderIcon(template: ISessionHeaderTemplate, sessions: readonly ISession[], icon: ThemeIcon | undefined, showUnreadInCollapsedSections: IObservable, sessionsWithFailingCI: IObservable>, instantiationService: IInstantiationService): void { + const headerStatus = derived(reader => template.collapsed.read(reader) && showUnreadInCollapsedSections.read(reader) + ? getSessionHeaderStatus(sessions, reader, sessionsWithFailingCI.read(reader)) + : undefined); + template.elementDisposables.add(autorun(reader => { + const status = headerStatus.read(reader); + DOM.clearNode(template.icon); + template.icon.className = 'session-section-icon'; + template.icon.style.display = status !== undefined || icon ? '' : 'none'; + if (status !== undefined) { + const statusIcon = reader.store.add(instantiationService.createInstance(SessionStatusIcon, template.icon)); + statusIcon.setStatus( + status === SessionHeaderStatus.NeedsInput ? SessionStatus.NeedsInput : SessionStatus.Completed, + status !== SessionHeaderStatus.Unread, + false, + status === SessionHeaderStatus.FailingCI ? { ...Codicon.circleFilled, color: themeColorFromId('list.warningForeground') } : undefined, + ); + } else if (icon) { + template.icon.classList.add(...ThemeIcon.asClassNameArray(icon)); + } + })); +} + function renderSessionHeaderToolbar(template: ISessionHeaderTemplate, element: T, select: (element: T, event: MouseEvent) => void): void { template.elementDisposables.add(DOM.addDisposableListener(template.toolbarContainer, DOM.EventType.CONTEXT_MENU, event => select(element, event), true)); template.toolbar.context = element; @@ -1286,7 +1337,6 @@ function renderSessionHeaderToolbar(template: ISessionHeaderTemplate, element interface ISessionSectionTemplate extends ISessionHeaderTemplate { readonly container: HTMLElement; - readonly icon: HTMLElement; readonly label: HTMLElement; readonly count: HTMLElement; readonly newBadge: HTMLElement; @@ -1338,6 +1388,8 @@ export class SessionSectionRenderer implements ITreeRenderer void, + private readonly showUnreadInCollapsedSections: IObservable, + private readonly sessionsWithFailingCI: IObservable>, private readonly instantiationService: IInstantiationService, private readonly contextKeyService: IContextKeyService, private readonly automationService: IAutomationService, @@ -1411,7 +1463,7 @@ export class SessionSectionRenderer implements ITreeRenderer, _index: number, template: ISessionSectionTemplate): void { @@ -1435,14 +1487,11 @@ export class SessionSectionRenderer implements ITreeRenderer { const activeCustomView = this.customViewService.activeCustomView.read(reader); template.container.classList.toggle('active', activeCustomView?.id === AUTOMATIONS_CUSTOM_VIEW_ID); @@ -1473,6 +1522,8 @@ export class SessionSectionRenderer implements ITreeRenderer, + private readonly sessionsWithFailingCI: IObservable>, private readonly instantiationService: IInstantiationService, private readonly contextKeyService: IContextKeyService, ) { } @@ -1589,7 +1636,6 @@ class SessionGroupRenderer implements ITreeRenderer, _index: number, template: ISessionGroupTemplate): void { @@ -1617,6 +1663,7 @@ class SessionGroupRenderer implements ITreeRenderer 0); SessionGroupIsEmptyContext.bindTo(template.contextKeyService).set(element.isEmpty); @@ -1686,6 +1733,7 @@ class SessionGroupRenderer implements ITreeRenderer boolean; 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; } @@ -1816,7 +1866,7 @@ class SessionsAccessibilityProvider { )); } if (isSessionGroupItem(element)) { - return `${element.group.name}, ${element.sessions.length}`; + return this.getSectionAriaLabel(element.group.name, element.sessions); } if (isSessionSection(element)) { if (element.id === AUTOMATIONS_SECTION_ID) { @@ -1838,7 +1888,7 @@ class SessionsAccessibilityProvider { : label; }); } - return `${element.label}, ${element.sessions.length}`; + return this.getSectionAriaLabel(element.label, element.sessions); } if (isSessionShowMore(element)) { if (element.mode === 'less') { @@ -1891,6 +1941,22 @@ class SessionsAccessibilityProvider { return label; }); } + + private getSectionAriaLabel(label: string, sessions: readonly ISession[]): IObservable { + return derived(this, reader => { + const status = this.options?.showUnreadInCollapsedSections?.read(reader) ? getSessionHeaderStatus(sessions, reader, this.options.sessionsWithFailingCI?.read(reader)) : undefined; + switch (status) { + case SessionHeaderStatus.NeedsInput: + return localize('sessionSectionNeedsInputAria', "{0}, {1}, session needs input", label, sessions.length); + case SessionHeaderStatus.FailingCI: + return localize('sessionSectionFailingCIAria', "{0}, {1}, session has failing CI checks", label, sessions.length); + case SessionHeaderStatus.Unread: + return localize('sessionSectionUnreadAria', "{0}, {1}, contains unread sessions", label, sessions.length); + default: + return localize('sessionSectionAria', "{0}, {1}", label, sessions.length); + } + }); + } } //#endregion @@ -2641,9 +2707,20 @@ export class SessionsList extends Disposable implements ISessionsList { this.tree.setFocus([element], event); this.tree.setSelection([element], event); }; + const showUnreadInCollapsedSections = observableConfigValue(SESSIONS_LIST_SHOW_UNREAD_IN_COLLAPSED_SECTIONS_SETTING, false, this.configurationService); + const blockedSessions = derived(this, reader => showUnreadInCollapsedSections.read(reader) + ? reader.store.add(instantiationService.createInstance(BlockedSessions)) + : undefined); + const sessionsWithFailingCI = derived(this, reader => new Set( + blockedSessions.read(reader)?.blockedSessionsWithReasons.read(reader) + .filter(blocked => blocked.reason === BlockedSessionReason.FailingCI) + .map(blocked => blocked.session.sessionId) + )); const sectionRenderer = new SessionSectionRenderer( true /* hideSectionCount */, selectHeader, + showUnreadInCollapsedSections, + sessionsWithFailingCI, instantiationService, contextKeyService, this.automationService, @@ -2658,7 +2735,7 @@ export class SessionsList extends Disposable implements ISessionsList { commitEdit: (group, name) => this.commitGroupEdit(group, name), cancelEdit: group => this.cancelGroupEdit(group), select: selectHeader, - }, instantiationService, contextKeyService); + }, showUnreadInCollapsedSections, sessionsWithFailingCI, instantiationService, contextKeyService); this._groupRenderer = groupRenderer; // Read (don't bind) `IsPhoneLayoutContext` from the parent context so we @@ -2696,6 +2773,8 @@ export class SessionsList extends Disposable implements ISessionsList { isRenderedInCustomGroup: session => this.isRenderedInCustomGroup(session), deriveStatusFromMainChat: true, automationNewBadgeVisible: this.automationsNewBadgeState.showNewBadge, + showUnreadInCollapsedSections, + sessionsWithFailingCI, }), dnd: this._register(new SessionsListDragAndDrop({ isReorderable: session => this.isReorderable(session), 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..e00a762f3d1b4a --- /dev/null +++ b/src/vs/sessions/contrib/sessions/test/browser/sessions.contribution.test.ts @@ -0,0 +1,32 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../../platform/configuration/common/configurationRegistry.js'; +import { Registry } from '../../../../../platform/registry/common/platform.js'; +import { SESSIONS_LIST_SHOW_UNREAD_IN_COLLAPSED_SECTIONS_SETTING } from '../../browser/views/sessionsList.js'; + +import '../../browser/sessions.contribution.js'; + +const configurationRegistry = Registry.as(ConfigurationExtensions.Configuration); +// Capture the registered schema before configuration tests reset the shared registry. +const collapsedSectionStatusProperty = configurationRegistry.getConfigurationProperties()[SESSIONS_LIST_SHOW_UNREAD_IN_COLLAPSED_SECTIONS_SETTING]; + +suite('Sessions Contribution', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('disables collapsed section status indicators by default with automatic experiments', () => { + assert.deepStrictEqual({ + type: collapsedSectionStatusProperty.type, + default: collapsedSectionStatusProperty.default, + experiment: collapsedSectionStatusProperty.experiment, + }, { + type: 'boolean', + default: false, + experiment: { mode: 'auto' }, + }); + }); +}); 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 0378f7062adc2e..392aaae331bcc5 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts @@ -22,7 +22,7 @@ import { IMenu, IMenuService, MenuId, MenuItemAction } from '../../../../../plat import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; import { ContextKeyService } from '../../../../../platform/contextkey/browser/contextKeyService.js'; import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; -import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { IConfigurationChangeEvent, IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; import { NullHoverService } from '../../../../../platform/hover/test/browser/nullHoverService.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; @@ -42,21 +42,22 @@ import { ARCHIVE_SESSION_COMMAND_ID } from '../../../../common/sessionCommands.j import { IAgentHostSessionsProvider, LOCAL_AGENT_HOST_PROVIDER_ID } from '../../../../common/agentHostSessionsProvider.js'; import { ICustomViewService } from '../../../../services/customView/browser/customViewService.js'; import type { ICustomViewDescriptor } from '../../../../services/customView/browser/customView.js'; -import { ISessionsListModelService } from '../../../../services/sessions/browser/sessionsListModelService.js'; +import { ISessionsListModelService, SessionsListModelService } from '../../../../services/sessions/browser/sessionsListModelService.js'; import { ISessionGroup, ISessionGroupsChangeEvent, ISessionGroupsService } from '../../../../services/sessions/browser/sessionGroupsService.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; import { ChatInteractivity, ChatOriginKind, IChat, ISession, ISessionChangeset, ISessionChangesSummary, ISessionFileChange, SessionStatus } from '../../../../services/sessions/common/session.js'; import { IActiveSession, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; import { ISessionsProvider } from '../../../../services/sessions/common/sessionsProvider.js'; import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; -import { computeReorderSortChanges, groupByDate, groupByWorkspace, groupSessionsForList, ISessionSection, limitSessionsForList, SessionItemToolbarMenuId, SessionSectionRenderer, SESSIONS_LIST_SHOW_EMPTY_DEFAULT_GROUPS_SETTING, SessionsFlatList, SessionsList, SessionsListFocusedChatItemContext, sortSessions, SessionsGrouping, SessionsSorting } from '../../browser/views/sessionsList.js'; +import { computeReorderSortChanges, groupByDate, groupByWorkspace, groupSessionsForList, ISessionSection, limitSessionsForList, SessionItemToolbarMenuId, SessionSectionRenderer, SESSIONS_LIST_SHOW_EMPTY_DEFAULT_GROUPS_SETTING, SESSIONS_LIST_SHOW_UNREAD_IN_COLLAPSED_SECTIONS_SETTING, SessionsFlatList, SessionsList, SessionsListFocusedChatItemContext, sortSessions, SessionsGrouping, SessionsSorting } from '../../browser/views/sessionsList.js'; import { AgentSessionApprovalKind, AgentSessionApprovalModel, IAgentSessionApprovalInfo } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentSessionApprovalModel.js'; import { getSessionDiffStats, getSessionSummaryHoverData } from '../../browser/sessionHoverContent.js'; -import { createListHarness, createTestSession, ISortChangeRecord } from './sessionsListTestUtils.js'; +import { createListHarness, createTestSession, IListHarnessOptions, ISortChangeRecord } from './sessionsListTestUtils.js'; import '../../browser/views/sessionsViewActions.js'; import { computePullRequestIcon, GitHubPullRequestState } from '../../../github/common/types.js'; import { AUTOMATIONS_CUSTOM_VIEW_ID } from '../../browser/automationsConstants.js'; import { AUTOMATIONS_NEW_BADGE_STYLE_SETTING, type AutomationsNewBadgeStyle } from '../../browser/automationsNewBadge.js'; +import { BlockedSessionReason, BlockedSessions } from '../../../blockedSessions/browser/blockedSessions.js'; function createSession(id: string, opts: { workspaceLabel?: string; @@ -127,6 +128,8 @@ suite('Sessions - SessionsList', () => { const renderer = new SessionSectionRenderer( true, section => selectedSections.push(section), + constObservable(true), + constObservable(new Set()), instantiationService, contextKeyService, automationService, @@ -180,6 +183,8 @@ suite('Sessions - SessionsList', () => { const renderer = new SessionSectionRenderer( true, () => { }, + constObservable(true), + constObservable(new Set()), instantiationService, contextKeyService, automationService, @@ -238,6 +243,8 @@ suite('Sessions - SessionsList', () => { const renderer = new SessionSectionRenderer( true, () => { }, + constObservable(true), + constObservable(new Set()), instantiationService, contextKeyService, automationService, @@ -413,6 +420,8 @@ suite('Sessions - SessionsList', () => { const renderer = new SessionSectionRenderer( true, () => { }, + constObservable(true), + constObservable(new Set()), new class extends mock() { }, new class extends mock() { }, automationService, @@ -481,6 +490,8 @@ suite('Sessions - SessionsList', () => { const renderer = new SessionSectionRenderer( true, () => { }, + constObservable(true), + constObservable(new Set()), new class extends mock() { }, new class extends mock() { }, automationService, @@ -521,6 +532,531 @@ suite('Sessions - SessionsList', () => { }); }); + suite('collapsed section status indicators', () => { + const group: ISessionGroup = { id: 'group-a', name: 'Group A', createdAt: 1 }; + + function renderList(sessions: ISession[], options: IListHarnessOptions & { useDefaultSetting?: boolean } = {}, reducedMotion = false) { + const harness = createListHarness(disposables, sessions, options); + if (!options.useDefaultSetting) { + (harness.instantiationService.get(IConfigurationService) as TestConfigurationService).setUserConfiguration(SESSIONS_LIST_SHOW_UNREAD_IN_COLLAPSED_SECTIONS_SETTING, true); + } + harness.instantiationService.stub(ISessionsListModelService, 'getStatusIcon', SessionsListModelService.prototype.getStatusIcon); + harness.instantiationService.stub(IAccessibilityService, new class extends TestAccessibilityService { + override isMotionReduced(): boolean { return reducedMotion; } + }()); + const failingCISessions = observableValue('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, + sorting: () => SessionsSorting.Created, + onSessionOpen: () => { }, + })); + list.layout(700, 400); + return { ...harness, list, container, failingCISessions }; + } + + function unreadSections(container: HTMLElement): string[] { + return [...container.querySelectorAll('.session-section')] + .filter(header => header.querySelector('.session-section-icon .codicon-circle-filled')) + .map(header => header.querySelector('.session-section-label')!.textContent!); + } + + function needsInputSections(container: HTMLElement): string[] { + return [...container.querySelectorAll('.session-section')] + .filter(header => header.querySelector('.session-section-icon .monaco-pixel-spinner-ring')) + .map(header => header.querySelector('.session-section-label')!.textContent!); + } + + function getHeader(container: HTMLElement, label: string): HTMLElement { + const header = [...container.querySelectorAll('.session-section')] + .find(header => header.querySelector('.session-section-label')?.textContent === label); + assert.ok(header, `Expected section ${label}`); + return header; + } + + test('keeps normal section icons and labels when the setting is unset', () => { + const unread = createTestSession('Unread', { workspaceLabel: 'Unread workspace', isRead: false }).session; + const failingCI = createTestSession('Failing CI', { workspaceLabel: 'CI workspace' }).session; + const needsInput = createTestSession('Needs input', { status: SessionStatus.NeedsInput }).session; + const { list, container, failingCISessions } = renderList([unread, failingCI, needsInput], { + groups: [group], + memberships: new Map([[needsInput.sessionId, group.id]]), + useDefaultSetting: true, + }); + failingCISessions.set([failingCI], undefined); + list.collapseAllSections(); + + assert.deepStrictEqual([group.name, 'CI workspace', 'Unread workspace'].map(label => { + const header = getHeader(container, label); + return { + ariaLabel: header.closest('.monaco-list-row')?.getAttribute('aria-label'), + icon: header.querySelector('.session-section-icon')?.className, + indicator: !!header.querySelector('.codicon-circle-filled, .monaco-pixel-spinner'), + }; + }), [ + { ariaLabel: 'Group A, 1', icon: 'session-section-icon codicon codicon-folder-library', indicator: false }, + { ariaLabel: 'CI workspace, 1', icon: 'session-section-icon codicon codicon-folder', indicator: false }, + { ariaLabel: 'Unread workspace, 1', icon: 'session-section-icon codicon codicon-folder', indicator: false }, + ]); + }); + + test('marks the group containing an unread session, not the session workspace', () => { + const grouped = createTestSession('Grouped unread', { workspaceLabel: 'Workspace B', isRead: false }).session; + const read = createTestSession('Workspace read', { workspaceLabel: 'Workspace B' }).session; + const unread = createTestSession('Workspace unread', { workspaceLabel: 'Workspace C', isRead: false }).session; + const { list, container } = renderList([grouped, read, unread], { + groups: [group, { id: 'empty', name: 'Empty group', createdAt: 2 }], + memberships: new Map([[grouped.sessionId, group.id]]), + }); + list.collapseAllSections(); + + assert.deepStrictEqual({ + unreadSections: unreadSections(container), + groupAria: getHeader(container, group.name).closest('.monaco-list-row')?.getAttribute('aria-label'), + workspaceAria: getHeader(container, 'Workspace B').closest('.monaco-list-row')?.getAttribute('aria-label'), + color: getHeader(container, group.name).querySelector('.codicon-circle-filled')?.style.color, + }, { + unreadSections: [group.name, 'Workspace C'], + groupAria: 'Group A, 1, contains unread sessions', + workspaceAria: 'Workspace B, 1', + color: 'var(--vscode-textLink-foreground)', + }); + }); + + test('shows needs-input only in the containing section and respects filters and pins', () => { + const grouped = createTestSession('Grouped needs input', { workspaceLabel: 'Workspace B', status: SessionStatus.NeedsInput }).session; + const read = createTestSession('Workspace read', { workspaceLabel: 'Workspace B' }).session; + const needsInput = createTestSession('Workspace needs input', { workspaceLabel: 'Workspace C', status: SessionStatus.NeedsInput }).session; + const pinnedSessionIds = new Set(); + const { list, container } = renderList([grouped, read, needsInput], { + groups: [group], + memberships: new Map([[grouped.sessionId, group.id]]), + pinnedSessionIds, + }); + list.collapseAllSections(); + const states = [needsInputSections(container)]; + list.setStatusExcluded(SessionStatus.NeedsInput, true); + states.push(needsInputSections(container)); + list.setStatusExcluded(SessionStatus.NeedsInput, false); + states.push(needsInputSections(container)); + pinnedSessionIds.add(grouped.sessionId); + list.update(); + states.push(needsInputSections(container)); + + assert.deepStrictEqual(states, [ + [group.name, 'Workspace C'], + [], + [group.name, 'Workspace C'], + ['Pinned', 'Workspace C'], + ]); + }); + + test('shows CI failures only in their containing sections and respects filters and pins', () => { + const grouped = createTestSession('Grouped CI failure', { workspaceLabel: 'Workspace B', status: SessionStatus.Error }).session; + const read = createTestSession('Workspace read', { workspaceLabel: 'Workspace B' }).session; + const failingCI = createTestSession('Workspace CI failure', { workspaceLabel: 'Workspace C', status: SessionStatus.Error }).session; + const pinnedSessionIds = new Set(); + 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 }); + 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, contains unread sessions`, + expandedPulse: false, + clearedPulse: false, + }); + }); + + test(`reacts to collapse, read, and archive changes in a ${kind}`, () => { + const { session, isRead, isArchived } = createTestSession('Unread', { isRead: false }); + const { list, container } = renderList([session], grouped ? { + groups: [group], + memberships: new Map([[session.sessionId, group.id]]), + } : {}); + const label = grouped ? group.name : 'Workspace'; + const states = [unreadSections(container)]; + + list.collapseAllSections(); + states.push(unreadSections(container)); + isRead.set(true, undefined); + states.push(unreadSections(container)); + isRead.set(false, undefined); + states.push(unreadSections(container)); + isArchived.set(true, undefined); + states.push(unreadSections(container)); + isArchived.set(false, undefined); + states.push(unreadSections(container)); + getHeader(container, label).click(); + states.push(unreadSections(container)); + + assert.deepStrictEqual({ + states, + expanded: getHeader(container, label).closest('.monaco-list-row')?.getAttribute('aria-expanded'), + restoredIcon: !!getHeader(container, label).querySelector(grouped ? '.codicon-folder-library' : '.codicon-folder'), + }, { + states: [[], [label], [], [label], [], [label], []], + expanded: 'true', + restoredIcon: true, + }); + }); + + test(`includes unread sessions behind show more in a collapsed ${kind}`, () => { + const sessions = Array.from({ length: 6 }, (_, index) => ({ + ...createTestSession(`Session ${index}`, { isRead: index !== 5 }).session, + createdAt: new Date(Date.now() - index * 1000), + })); + const { list, container } = renderList(sessions, grouped ? { + groups: [group], + memberships: new Map(sessions.map(session => [session.sessionId, group.id])), + } : {}); + const visibleTitles = [...container.querySelectorAll('.session-title')].map(title => title.textContent); + + list.collapseAllSections(); + + assert.deepStrictEqual({ + visibleTitles, + unreadSections: unreadSections(container), + }, { + visibleTitles: ['Session 0', 'Session 1', 'Session 2', 'Session 3', 'Session 4'], + unreadSections: [grouped ? group.name : 'Workspace'], + }); + }); + } + + test('updates membership and does not retain unread state when headers are reused', () => { + const { session: unread, isRead } = createTestSession('Unread', { workspaceLabel: 'Workspace B', isRead: false }); + const read = createTestSession('Read', { workspaceLabel: 'Workspace B' }).session; + const memberships = new Map(); + const { list, container, managementService } = renderList([unread, read], { groups: [group], memberships }); + list.collapseAllSections(); + const states = [unreadSections(container)]; + + memberships.set(unread.sessionId, group.id); + list.update(); + states.push(unreadSections(container)); + memberships.delete(unread.sessionId); + list.update(); + states.push(unreadSections(container)); + managementService.sessions = [read]; + list.refresh(); + isRead.set(true, undefined); + isRead.set(false, undefined); + states.push(unreadSections(container)); + + assert.deepStrictEqual(states, [['Workspace B'], [group.name], ['Workspace B'], []]); + }); + + test('respects pinned, archived, and automation placement before aggregating unread sessions', () => { + const pinned = createTestSession('Pinned unread', { workspaceLabel: 'Workspace B', isRead: false }).session; + const archived = createTestSession('Archived unread', { workspaceLabel: 'Workspace B', isRead: false, isArchived: true }).session; + const automation: ISession = { + ...createTestSession('Automation unread', { workspaceLabel: 'Workspace B', isRead: false }).session, + isAutomation: constObservable(true), + }; + const read = createTestSession('Read', { workspaceLabel: 'Workspace B' }).session; + const { list, container } = renderList([pinned, archived, automation, read], { + groups: [group], + memberships: new Map([pinned, archived, automation].map(session => [session.sessionId, group.id])), + pinnedSessionIds: new Set([pinned.sessionId]), + }); + list.setExcludeArchived(false); + list.collapseAllSections(); + + assert.deepStrictEqual({ + unreadSections: unreadSections(container), + groupAria: getHeader(container, group.name).closest('.monaco-list-row')?.getAttribute('aria-label'), + workspaceAria: getHeader(container, 'Workspace B').closest('.monaco-list-row')?.getAttribute('aria-label'), + }, { + unreadSections: ['Pinned'], + groupAria: 'Group A, 0', + workspaceAria: 'Workspace B, 1', + }); + }); + + test('does not count unread sessions excluded by list filters', () => { + const grouped = createTestSession('Grouped unread', { isRead: false, status: SessionStatus.Error }).session; + const unread = createTestSession('Workspace unread', { isRead: false, status: SessionStatus.Error }).session; + const read = createTestSession('Read').session; + const { list, container } = renderList([grouped, unread, read], { + groups: [group], + memberships: new Map([[grouped.sessionId, group.id]]), + }); + list.collapseAllSections(); + list.setStatusExcluded(SessionStatus.Error, true); + const filtered = unreadSections(container); + list.setStatusExcluded(SessionStatus.Error, false); + + assert.deepStrictEqual({ filtered, restored: unreadSections(container) }, { + filtered: [], + restored: [group.name, 'Workspace'], + }); + }); + + test('uses the existing orange needs-input fallback with reduced motion', () => { + const { session } = createTestSession('Needs input', { status: SessionStatus.NeedsInput }); + const { list, container } = renderList([session], {}, true); + list.collapseAllSections(); + const header = getHeader(container, 'Workspace'); + + assert.deepStrictEqual({ + hasSpinner: !!header.querySelector('.monaco-pixel-spinner'), + color: header.querySelector('.codicon-circle-filled')?.style.color, + ariaLabel: header.closest('.monaco-list-row')?.getAttribute('aria-label'), + }, { + hasSpinner: false, + color: 'var(--vscode-list-warningForeground)', + ariaLabel: 'Workspace, 1, session needs input', + }); + }); + + test('never shows status indicators for archived sessions', () => { + const unread = createTestSession('Archived unread', { isRead: false, isArchived: true }).session; + const needsInput = createTestSession('Archived needs input', { status: SessionStatus.NeedsInput, isArchived: true }).session; + const failingCI = createTestSession('Archived CI failure', { isArchived: true }).session; + const read = createTestSession('Read').session; + const { list, container, failingCISessions } = renderList([unread, needsInput, failingCI, read], { + groups: [group], + memberships: new Map([unread, needsInput, failingCI].map(session => [session.sessionId, group.id])), + }); + failingCISessions.set([failingCI], undefined); + list.setExcludeArchived(false); + list.collapseAllSections(); + + assert.deepStrictEqual([group.name, 'Workspace', 'Archived'].map(label => { + const header = getHeader(container, label); + return { + ariaLabel: header.closest('.monaco-list-row')?.getAttribute('aria-label'), + indicator: !!header.querySelector('.codicon-circle-filled, .monaco-pixel-spinner'), + }; + }), [ + { ariaLabel: 'Group A, 0', indicator: false }, + { ariaLabel: 'Workspace, 1', indicator: false }, + { ariaLabel: 'Archived, 3', indicator: false }, + ]); + }); + + test('reacts to setting changes while a session has failing CI', async () => { + const { session } = createTestSession('Failing CI'); + const { list, container, instantiationService, failingCISessions } = renderList([session]); + failingCISessions.set([session], undefined); + list.collapseAllSections(); + const configurationService = instantiationService.get(IConfigurationService) as TestConfigurationService; + const states = []; + for (const enabled of [false, true]) { + await configurationService.setUserConfiguration(SESSIONS_LIST_SHOW_UNREAD_IN_COLLAPSED_SECTIONS_SETTING, enabled); + configurationService.onDidChangeConfigurationEmitter.fire(upcastPartial({ + 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]); + 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; + 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, contains 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 c2cfef8fd89e70..8c227163986bf0 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsList.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsList.fixture.ts @@ -12,9 +12,11 @@ import { Disposable, IDisposable, toDisposable } from '../../../../../base/commo import { constObservable, IObservable, observableValue } from '../../../../../base/common/observable.js'; import { OS } from '../../../../../base/common/platform.js'; import { ExtUri } from '../../../../../base/common/resources.js'; -import { ThemeIcon, themeColorFromId } from '../../../../../base/common/themables.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; import { mock } from '../../../../../base/test/common/mock.js'; +import { IAccessibilityService } from '../../../../../platform/accessibility/common/accessibility.js'; +import { TestAccessibilityService } from '../../../../../platform/accessibility/test/common/testAccessibilityService.js'; import { IActionViewItemFactory, IActionViewItemService } from '../../../../../platform/actions/browser/actionViewItemService.js'; import { IListService, ListService } from '../../../../../platform/list/browser/listService.js'; import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; @@ -38,7 +40,7 @@ import { ISessionGroup, ISessionGroupsService } from '../../../../../sessions/se // eslint-disable-next-line local/code-import-patterns import { ISessionSectionOrderService } from '../../../../../sessions/services/sessions/browser/sessionSectionOrderService.js'; // eslint-disable-next-line local/code-import-patterns -import { ISessionsListModelService } from '../../../../../sessions/services/sessions/browser/sessionsListModelService.js'; +import { ISessionsListModelService, SessionsListModelService } from '../../../../../sessions/services/sessions/browser/sessionsListModelService.js'; // eslint-disable-next-line local/code-import-patterns import { ISessionsProvidersService } from '../../../../../sessions/services/sessions/browser/sessionsProvidersService.js'; // eslint-disable-next-line local/code-import-patterns @@ -54,7 +56,9 @@ import { IChat, ISession, ISessionChangeset, ISessionChangesSummary, ISessionFol // eslint-disable-next-line local/code-import-patterns import { IActiveSession, ISessionsManagementService } from '../../../../../sessions/services/sessions/common/sessionsManagement.js'; // eslint-disable-next-line local/code-import-patterns -import { SessionItemToolbarMenuId, SessionsGrouping, SessionsList, SessionsSorting } from '../../../../../sessions/contrib/sessions/browser/views/sessionsList.js'; +import { SESSIONS_LIST_SHOW_UNREAD_IN_COLLAPSED_SECTIONS_SETTING, SessionItemToolbarMenuId, SessionsGrouping, SessionsList, SessionsSorting } from '../../../../../sessions/contrib/sessions/browser/views/sessionsList.js'; +// eslint-disable-next-line local/code-import-patterns +import { BlockedSessionReason, BlockedSessions } from '../../../../../sessions/contrib/blockedSessions/browser/blockedSessions.js'; // eslint-disable-next-line local/code-import-patterns import { ARCHIVE_SESSION_COMMAND_ID } from '../../../../../sessions/common/sessionCommands.js'; // eslint-disable-next-line local/code-import-patterns @@ -132,6 +136,8 @@ interface ISessionSpec { readonly minutesAgo: number; readonly changesSummary?: ISessionChangesSummary; readonly group?: string; + readonly isRead?: boolean; + readonly hasFailingCI?: boolean; /** Nested (non-main) chats shown as child rows under the session. */ readonly chats?: readonly IChatSpec[]; /** Terminal command awaiting approval on the session's main chat (renders on the session row). */ @@ -206,7 +212,7 @@ function createSession(spec: ISessionSpec, approvals: Map = 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 changesets: IObservable = constObservable([]); override readonly changesSummary: IObservable = constObservable(spec.changesSummary); @@ -229,6 +235,9 @@ interface IRenderOptions { readonly sessions: readonly ISessionSpec[]; readonly groups?: readonly ISessionGroup[]; readonly grouping?: SessionsGrouping; + readonly collapsed?: boolean; + readonly showUnreadInCollapsedSections?: boolean; + readonly reducedMotion?: boolean; readonly width?: number; readonly phone?: boolean; readonly revealHierarchyGuides?: boolean; @@ -270,6 +279,12 @@ async function renderSessionsList(ctx: ComponentFixtureContext, options: IRender additionalServices: reg => { registerWorkbenchServices(reg); reg.defineInstance(IProductService, TestProductService); + const reducedMotion = options.reducedMotion; + if (reducedMotion !== undefined) { + reg.defineInstance(IAccessibilityService, new class extends TestAccessibilityService { + override isMotionReduced(): boolean { return reducedMotion; } + }()); + } if (options.showFocusedToolbar || options.focusSelectedSession) { const archiveAction = new class extends mock() { override readonly id = 'sessions.fixture.archive'; @@ -327,19 +342,7 @@ async function renderSessionsList(ctx: ComponentFixtureContext, options: IRender override isSessionPinned(): boolean { return false; } override migrateLegacyReadState(): void { } override getSortKey(session: ISession): number { return session.createdAt.getTime(); } - override getStatusIcon(status: SessionStatus, isRead: boolean): ThemeIcon { - switch (status) { - case SessionStatus.InProgress: - return { ...Codicon.sessionInProgress, color: themeColorFromId('textLink.foreground') }; - case SessionStatus.NeedsInput: - return { ...Codicon.circleFilled, color: themeColorFromId('list.warningForeground') }; - default: - if (!isRead) { - return { ...Codicon.circleFilled, color: themeColorFromId('textLink.foreground') }; - } - return { ...Codicon.circleSmallFilled, color: themeColorFromId('agentSessionReadIndicator.foreground') }; - } - } + override getStatusIcon = SessionsListModelService.prototype.getStatusIcon; }()); reg.defineInstance(ISessionGroupsService, new class extends mock() { override readonly onDidChange = Event.None; @@ -436,12 +439,22 @@ 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' }); if (options.automationBadgeStyle) { await (instantiationService.get(IConfigurationService) as TestConfigurationService).setUserConfiguration(AUTOMATIONS_NEW_BADGE_STYLE_SETTING, options.automationBadgeStyle); } + if (options.showUnreadInCollapsedSections !== undefined) { + await (instantiationService.get(IConfigurationService) as TestConfigurationService).setUserConfiguration(SESSIONS_LIST_SHOW_UNREAD_IN_COLLAPSED_SECTIONS_SETTING, options.showUnreadInCollapsedSections); + } instantiationService.get(IMarkdownRendererService).setDefaultCodeBlockRenderer(instantiationService.createInstance(EditorMarkdownCodeBlockRenderer)); // Phone layout is driven by both a CSS class (visual) and a context key (row @@ -481,6 +494,9 @@ async function renderSessionsList(ctx: ComponentFixtureContext, options: IRender approvalModel, })); list.layout(options.phone ? 260 : showHeader ? 180 : 220, width); + if (options.collapsed) { + list.collapseAllSections(); + } if (options.archiveOnboarding) { listHost.style.width = `${width}px`; const reveal = disposableStore.add(list.revealArchiveAction(sessions[0])); @@ -584,6 +600,21 @@ const GROUPED_SESSIONS: readonly ISessionSpec[] = [ { id: 'b', title: 'Add reconnect backoff', workspace: 'agent-host-protocol', minutesAgo: 64, group: GROUP.id }, { id: 'c', title: 'Update onboarding copy', workspace: 'vscode-docs', minutesAgo: 180 }, ]; +const COLLAPSED_SECTION_SESSIONS: readonly ISessionSpec[] = [ + { id: 'grouped-unread', title: 'Unread session in the group only', workspace: 'vscode', minutesAgo: 12, group: GROUP.id, isRead: false }, + { id: 'workspace-read', title: 'Read session in the workspace', workspace: 'vscode', minutesAgo: 24 }, + { id: 'workspace-unread', title: 'Unread session in the workspace', workspace: 'vscode-docs', minutesAgo: 36, isRead: false }, +]; +const COLLAPSED_NEEDS_INPUT_SESSIONS: readonly ISessionSpec[] = [ + ...COLLAPSED_SECTION_SESSIONS, + { id: 'grouped-needs-input', title: 'Needs input in the group only', workspace: 'vscode', minutesAgo: 48, group: GROUP.id, status: SessionStatus.NeedsInput }, + { id: 'workspace-needs-input', title: 'Needs input in the workspace', workspace: 'vscode-docs', minutesAgo: 60, status: SessionStatus.NeedsInput }, +]; +const COLLAPSED_CI_FAILURE_SESSIONS: readonly ISessionSpec[] = [ + ...COLLAPSED_SECTION_SESSIONS, + { id: 'grouped-failing-ci', title: 'CI failure in the group only', workspace: 'vscode', minutesAgo: 48, group: GROUP.id, hasFailingCI: true }, + { id: 'workspace-failing-ci', title: 'CI failure in the workspace', workspace: 'vscode-docs', minutesAgo: 60, hasFailingCI: true }, +]; export default defineThemedFixtureGroup({ path: 'sessions/' }, { SessionsList_ArchiveOnboarding: defineComponentFixture({ @@ -598,6 +629,42 @@ export default defineThemedFixtureGroup({ path: 'sessions/' }, { SessionsList_CustomGroup: defineComponentFixture({ render: ctx => renderSessionsList(ctx, { sessions: GROUPED_SESSIONS, groups: [GROUP] }), }), + SessionsList_CollapsedUnreadSections: defineComponentFixture({ + labels: { kind: 'screenshot' }, + additionalThemes: ['darkHighContrast', 'lightHighContrast'], + expectedVisualDescriptions: ['All sections are collapsed. Filled unread indicators replace the icons for Release work and vscode-docs. The vscode section retains its folder icon because its unread session appears only in Release work.'], + render: ctx => renderSessionsList(ctx, { sessions: COLLAPSED_SECTION_SESSIONS, groups: [GROUP], collapsed: true, showUnreadInCollapsedSections: true }), + }), + SessionsList_CollapsedUnreadSections_Disabled: defineComponentFixture({ + labels: { kind: 'screenshot' }, + additionalThemes: ['darkHighContrast', 'lightHighContrast'], + expectedVisualDescriptions: ['All sections are collapsed and retain their normal group or folder icons despite containing unread sessions, because collapsed-section indicators are disabled by default.'], + render: ctx => renderSessionsList(ctx, { sessions: COLLAPSED_SECTION_SESSIONS, groups: [GROUP], collapsed: true }), + }), + SessionsList_CollapsedNeedsInputSections: defineComponentFixture({ + labels: { kind: 'screenshot' }, + additionalThemes: ['darkHighContrast', 'lightHighContrast'], + expectedVisualDescriptions: ['All sections are collapsed. Orange ring pixel spinners replace the icons for Release work and vscode-docs, taking priority over unread indicators. The vscode section retains its folder icon because its needs-input session appears only in Release work.'], + render: ctx => renderSessionsList(ctx, { sessions: COLLAPSED_NEEDS_INPUT_SESSIONS, groups: [GROUP], collapsed: true, showUnreadInCollapsedSections: true, reducedMotion: false }), + }), + SessionsList_CollapsedNeedsInputSections_Disabled: defineComponentFixture({ + labels: { kind: 'screenshot' }, + additionalThemes: ['darkHighContrast', 'lightHighContrast'], + expectedVisualDescriptions: ['All collapsed sections retain their normal group or folder icons despite containing unread and needs-input sessions, because collapsed-section indicators are disabled by default.'], + render: ctx => renderSessionsList(ctx, { sessions: COLLAPSED_NEEDS_INPUT_SESSIONS, groups: [GROUP], collapsed: true, reducedMotion: false }), + }), + SessionsList_CollapsedCIFailureSections: defineComponentFixture({ + labels: { kind: 'screenshot' }, + additionalThemes: ['darkHighContrast', 'lightHighContrast'], + expectedVisualDescriptions: ['All sections are collapsed. Orange dots replace the icons for Release work and vscode-docs, taking priority over unread indicators. The vscode section retains its folder icon because its session with failing CI appears only in Release work.'], + render: ctx => renderSessionsList(ctx, { sessions: COLLAPSED_CI_FAILURE_SESSIONS, groups: [GROUP], collapsed: true, showUnreadInCollapsedSections: true }), + }), + SessionsList_CollapsedCIFailureSections_Disabled: defineComponentFixture({ + labels: { kind: 'screenshot' }, + additionalThemes: ['darkHighContrast', 'lightHighContrast'], + expectedVisualDescriptions: ['All collapsed sections retain their normal group or folder icons despite containing unread sessions and CI failures, because collapsed-section indicators are disabled by default.'], + render: ctx => renderSessionsList(ctx, { sessions: COLLAPSED_CI_FAILURE_SESSIONS, groups: [GROUP], collapsed: true }), + }), SessionsList_CustomGroup_LongWorkspaceNarrow: defineComponentFixture({ render: ctx => renderSessionsList(ctx, { sessions: [ diff --git a/test/componentFixtures/playwright/tests/sessionsListTwistie.spec.ts b/test/componentFixtures/playwright/tests/sessionsListTwistie.spec.ts index 060ae6227dc063..20c93692e4a498 100644 --- a/test/componentFixtures/playwright/tests/sessionsListTwistie.spec.ts +++ b/test/componentFixtures/playwright/tests/sessionsListTwistie.spec.ts @@ -28,3 +28,70 @@ test('reveals the nested chat twistie only while hovering the session row', asyn await expect(twistie).toHaveCSS('opacity', '0'); await expect(statusIcon).toHaveCSS('visibility', 'visible'); }); + +for (const theme of ['Dark', 'Light', 'DarkHighContrast', 'LightHighContrast']) { + for (const { name, fixture, ariaStatus, indicatorClass, color, count } of [ + { name: 'unread', fixture: 'SessionsList_CollapsedUnreadSections', ariaStatus: 'contains unread sessions', indicatorClass: '.codicon-circle-filled', color: '--vscode-textLink-foreground', count: 1 }, + { name: 'needs-input', fixture: 'SessionsList_CollapsedNeedsInputSections', ariaStatus: 'session needs input', indicatorClass: '.monaco-pixel-spinner-ring', color: '--vscode-list-warningForeground', count: 2 }, + { name: 'CI-failure', fixture: 'SessionsList_CollapsedCIFailureSections', ariaStatus: 'session has failing CI checks', indicatorClass: '.codicon-circle-filled', color: '--vscode-list-warningForeground', count: 2 }, + ]) { + test.describe(`collapsed section ${name} indicators (${theme})`, () => { + test('replaces only the owning section icons and preserves hover and keyboard chevrons', async ({ page }) => { + await openFixture(page, `sessions/sessionsList/${fixture}/${theme}`, '.sessions-list-control'); + + const readWorkspace = page.getByRole('treeitem', { name: 'vscode, 1', exact: true }); + await expect(readWorkspace.locator('.session-section-icon.codicon-folder')).toBeVisible(); + await expect(readWorkspace.locator(indicatorClass)).toHaveCount(0); + + for (const label of ['Release work', 'vscode-docs']) { + const row = page.getByRole('treeitem', { name: `${label}, ${count}, ${ariaStatus}`, exact: true }); + const icon = row.locator('.session-section-icon'); + const indicator = icon.locator(indicatorClass); + const chevron = row.locator('.session-section-chevron'); + + await expect(row).toHaveAttribute('aria-expanded', 'false'); + await expect(indicator).toBeVisible(); + await expect(indicator).toHaveAttribute('style', `color: var(${color});`); + await expect(chevron).toBeHidden(); + + await row.hover(); + await expect(icon).toBeHidden(); + await expect(chevron).toBeVisible(); + + await readWorkspace.hover(); + await expect(indicator).toBeVisible(); + await expect(chevron).toBeHidden(); + + await row.click(); + await readWorkspace.hover(); + await expect(row).toHaveAttribute('aria-expanded', 'true'); + await expect(indicator).toHaveCount(0); + await expect(icon).toHaveClass(/codicon-folder(?:-library)?/); + await expect(icon).toBeVisible(); + + await row.click(); + await readWorkspace.hover(); + await expect(row).toHaveAttribute('aria-expanded', 'false'); + await expect(indicator).toHaveCount(1); + await page.keyboard.press('Tab'); + await page.keyboard.press('Shift+Tab'); + await expect(page.getByRole('tree', { name: 'Sessions', exact: true })).toBeFocused(); + await expect(icon).toBeHidden(); + await expect(chevron).toBeVisible(); + } + }); + + test('keeps normal icons with the default-disabled setting', async ({ page }) => { + await openFixture(page, `sessions/sessionsList/${fixture}_Disabled/${theme}`, '.sessions-list-control'); + + await expect(page.locator(`.session-section-icon ${indicatorClass}`)).toHaveCount(0); + await expect(page.getByRole('treeitem', { name: `Release work, ${count}`, exact: true }).locator('.codicon-folder-library')).toBeVisible(); + for (const label of ['vscode', 'vscode-docs']) { + const row = page.getByRole('treeitem', { name: `${label}, ${label === 'vscode' ? 1 : count}`, exact: true }); + await expect(row).toHaveAttribute('aria-expanded', 'false'); + await expect(row.locator('.session-section-icon.codicon-folder')).toBeVisible(); + } + }); + }); + } +}