Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -71,6 +71,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 groups and workspace sections in the sessions list show an unread indicator for the sessions they contain."),
Comment thread
benibenj marked this conversation as resolved.
Outdated
default: true,
experiment: { mode: 'auto' }
},
[SESSIONS_ARCHIVE_SESSION_CONFETTI_SETTING]: {
type: 'boolean',
tags: ['preview'],
Expand Down
70 changes: 49 additions & 21 deletions src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -119,6 +120,7 @@ export const NEW_SESSION_FOR_WORKSPACE_ACTION_ID = 'sessionsView.sectionNewSessi

/** Controls whether the empty default Chats group is shown in the sessions list. */
export const SESSIONS_LIST_SHOW_EMPTY_DEFAULT_GROUPS_SETTING = 'sessions.list.showEmptyDefaultGroups';
export const SESSIONS_LIST_SHOW_UNREAD_IN_COLLAPSED_SECTIONS_SETTING = 'sessions.list.showUnreadInCollapsedSections';

export const IsSessionPinnedContext = new RawContextKey<boolean>('sessionItem.isPinned', false);
export const SessionItemHasBranchNameContext = new RawContextKey<boolean>('sessionItem.hasBranchName', false);
Expand Down Expand Up @@ -1289,19 +1291,39 @@ 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;
}

function hasUnreadSessions(sessions: readonly ISession[], reader: IReader): boolean {
return sessions.some(session => !session.isRead.read(reader) && !session.isArchived.read(reader));
}

function renderSessionHeaderIcon(template: ISessionHeaderTemplate, sessions: readonly ISession[], icon: ThemeIcon | undefined, showUnreadInCollapsedSections: IObservable<boolean>, instantiationService: IInstantiationService): void {
template.elementDisposables.add(autorun(reader => {
const showUnread = template.collapsed.read(reader) && showUnreadInCollapsedSections.read(reader) && hasUnreadSessions(sessions, reader);
DOM.clearNode(template.icon);
template.icon.className = 'session-section-icon';
template.icon.style.display = showUnread || icon ? '' : 'none';
if (showUnread) {
const statusIcon = reader.store.add(instantiationService.createInstance(SessionStatusIcon, template.icon));
statusIcon.setStatus(SessionStatus.Completed, false, false);
} else if (icon) {
template.icon.classList.add(...ThemeIcon.asClassNameArray(icon));
}
}));
}

function renderSessionHeaderToolbar<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;
}

interface ISessionSectionTemplate extends ISessionHeaderTemplate {
readonly container: HTMLElement;
readonly icon: HTMLElement;
readonly label: HTMLElement;
readonly count: HTMLElement;
readonly newBadge: HTMLElement;
Expand Down Expand Up @@ -1353,6 +1375,7 @@ 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 instantiationService: IInstantiationService,
private readonly contextKeyService: IContextKeyService,
private readonly automationService: IAutomationService,
Expand Down Expand Up @@ -1426,7 +1449,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 {
Expand All @@ -1450,14 +1473,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);
Expand Down Expand Up @@ -1488,6 +1508,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.instantiationService);
}

template.label.textContent = element.label;
Expand All @@ -1499,9 +1521,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);
Expand All @@ -1523,11 +1542,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) {
Expand All @@ -1541,6 +1556,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');
Expand Down Expand Up @@ -1593,6 +1609,7 @@ class SessionGroupRenderer implements ITreeRenderer<SessionListItem, FuzzyScore,

constructor(
private readonly delegate: ISessionGroupRendererDelegate,
private readonly showUnreadInCollapsedSections: IObservable<boolean>,
private readonly instantiationService: IInstantiationService,
private readonly contextKeyService: IContextKeyService,
) { }
Expand All @@ -1604,7 +1621,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'));
Expand All @@ -1616,7 +1632,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 {
Expand All @@ -1632,6 +1648,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.instantiationService);
SessionGroupHasVisibleSessionsContext.bindTo(template.contextKeyService).set(element.sessions.length > 0);
SessionGroupIsEmptyContext.bindTo(template.contextKeyService).set(element.isEmpty);

Expand Down Expand Up @@ -1701,6 +1718,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');
Expand Down Expand Up @@ -1806,6 +1824,7 @@ interface ISessionsAccessibilityProviderOptions {
readonly isRenderedInCustomGroup?: (session: ISession) => boolean;
readonly includeQuickChatInAriaLabel?: boolean;
readonly automationNewBadgeVisible?: IObservable<boolean>;
readonly showUnreadInCollapsedSections?: IObservable<boolean>;
/** Mirrors {@link SessionItemRenderer}'s option of the same name — see there for rationale. */
readonly deriveStatusFromMainChat?: boolean;
}
Expand All @@ -1831,7 +1850,7 @@ class SessionsAccessibilityProvider {
));
}
if (isSessionGroupItem(element)) {
return `${element.group.name}, ${element.sessions.length}`;
return this.getSectionAriaLabel(element.group.name, element.sessions);
}
if (isSessionSection(element)) {
if (element.id === AUTOMATIONS_SECTION_ID) {
Expand All @@ -1853,7 +1872,7 @@ class SessionsAccessibilityProvider {
: label;
});
}
return `${element.label}, ${element.sessions.length}`;
return this.getSectionAriaLabel(element.label, element.sessions);
}
if (isSessionShowMore(element)) {
if (element.mode === 'less') {
Expand Down Expand Up @@ -1906,6 +1925,12 @@ class SessionsAccessibilityProvider {
return label;
});
}

private getSectionAriaLabel(label: string, sessions: readonly ISession[]): IObservable<string> {
return derived(this, reader => this.options?.showUnreadInCollapsedSections?.read(reader) && hasUnreadSessions(sessions, reader)
? localize('sessionSectionUnreadAria', "{0}, {1}, unread sessions", label, sessions.length)
Comment thread
benibenj marked this conversation as resolved.
Outdated
: localize('sessionSectionAria', "{0}, {1}", label, sessions.length));
}
}

//#endregion
Expand Down Expand Up @@ -2651,9 +2676,11 @@ export class SessionsList extends Disposable implements ISessionsList {
this.tree.setFocus([element], event);
this.tree.setSelection([element], event);
};
const showUnreadInCollapsedSections = observableConfigValue(SESSIONS_LIST_SHOW_UNREAD_IN_COLLAPSED_SECTIONS_SETTING, true, this.configurationService);
const sectionRenderer = new SessionSectionRenderer(
true /* hideSectionCount */,
selectHeader,
showUnreadInCollapsedSections,
instantiationService,
contextKeyService,
this.automationService,
Expand All @@ -2668,7 +2695,7 @@ export class SessionsList extends Disposable implements ISessionsList {
commitEdit: (group, name) => this.commitGroupEdit(group, name),
cancelEdit: group => this.cancelGroupEdit(group),
select: selectHeader,
}, instantiationService, contextKeyService);
}, showUnreadInCollapsedSections, instantiationService, contextKeyService);
this._groupRenderer = groupRenderer;

// Read (don't bind) `IsPhoneLayoutContext` from the parent context so we
Expand Down Expand Up @@ -2706,6 +2733,7 @@ export class SessionsList extends Disposable implements ISessionsList {
isRenderedInCustomGroup: session => this.isRenderedInCustomGroup(session),
deriveStatusFromMainChat: true,
automationNewBadgeVisible: this.automationsNewBadgeState.showNewBadge,
showUnreadInCollapsedSections,
}),
dnd: this._register(new SessionsListDragAndDrop({
isReorderable: session => this.isReorderable(session),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import assert from 'assert';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';
import { Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../../platform/configuration/common/configurationRegistry.js';
import { Registry } from '../../../../../platform/registry/common/platform.js';
import { SESSIONS_LIST_SHOW_UNREAD_IN_COLLAPSED_SECTIONS_SETTING } from '../../browser/views/sessionsList.js';

import '../../browser/sessions.contribution.js';

suite('Sessions Contribution', () => {
ensureNoDisposablesAreLeakedInTestSuite();

test('enables collapsed section unread indicators by default with automatic experiments', () => {
const property = Registry.as<IConfigurationRegistry>(ConfigurationExtensions.Configuration).getConfigurationProperties()[SESSIONS_LIST_SHOW_UNREAD_IN_COLLAPSED_SECTIONS_SETTING];
assert.deepStrictEqual({
type: property.type,
default: property.default,
experiment: property.experiment,
}, {
type: 'boolean',
default: true,
experiment: { mode: 'auto' },
});
});
});
Loading
Loading