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