sessions - tweaks to sessions list for better readability (#302433)

This commit is contained in:
Benjamin Pasero
2026-03-17 18:13:32 +01:00
committed by GitHub
parent 14137c4ced
commit bc80e42af6
9 changed files with 260 additions and 162 deletions

View File

@@ -1,13 +1,14 @@
name: Sessions E2E Tests
on:
pull_request:
branches:
- main
- 'release/*'
paths:
- 'src/vs/sessions/**'
- 'scripts/code-sessions-web.*'
# Disabled: Flaky
# on:
# pull_request:
# branches:
# - main
# - 'release/*'
# paths:
# - 'src/vs/sessions/**'
# - 'scripts/code-sessions-web.*'
permissions:
contents: read

View File

@@ -82,3 +82,9 @@
padding: 4px 8px 6px 8px !important;
box-sizing: border-box;
}
/* ---- Session List ---- */
.agent-sessions-workbench .agent-session-title {
color: var(--vscode-list-activeSelectionForeground);
}

View File

@@ -7,8 +7,6 @@ import { localize } from '../../../../../nls.js';
import { Codicon } from '../../../../../base/common/codicons.js';
import { URI } from '../../../../../base/common/uri.js';
import { ThemeIcon } from '../../../../../base/common/themables.js';
import { IChatSessionTiming } from '../../common/chatService/chatService.js';
import { foreground, listActiveSelectionForeground, registerColor, transparent } from '../../../../../platform/theme/common/colorRegistry.js';
import { getChatSessionType } from '../../common/model/chatUri.js';
import { IProductService } from '../../../../../platform/product/common/productService.js';
@@ -180,7 +178,3 @@ export const agentSessionSelectedUnfocusedBadgeBorder = registerColor(
export const AGENT_SESSION_RENAME_ACTION_ID = 'agentSession.rename';
export const AGENT_SESSION_DELETE_ACTION_ID = 'agentSession.delete';
export function getAgentSessionTime(timing: IChatSessionTiming): number {
return timing.lastRequestStarted ?? timing.created;
}

View File

@@ -13,7 +13,7 @@ import { StandardKeyboardEvent } from '../../../../../base/browser/keyboardEvent
import { KeyCode } from '../../../../../base/common/keyCodes.js';
import { localize } from '../../../../../nls.js';
import { AgentSessionSection, IAgentSession, IAgentSessionSection, IAgentSessionsModel, IMarshalledAgentSessionContext, isAgentSession, isAgentSessionSection } from './agentSessionsModel.js';
import { AgentSessionListItem, AgentSessionRenderer, AgentSessionsAccessibilityProvider, AgentSessionsCompressionDelegate, AgentSessionsDataSource, AgentSessionsDragAndDrop, AgentSessionsIdentityProvider, AgentSessionsKeyboardNavigationLabelProvider, AgentSessionsListDelegate, AgentSessionSectionRenderer, AgentSessionsSorter, IAgentSessionsFilter, IAgentSessionsSorterOptions } from './agentSessionsViewer.js';
import { AgentSessionListItem, AgentSessionRenderer, AgentSessionsAccessibilityProvider, AgentSessionsCompressionDelegate, AgentSessionsDataSource, AgentSessionsDragAndDrop, AgentSessionsIdentityProvider, AgentSessionsKeyboardNavigationLabelProvider, AgentSessionsListDelegate, AgentSessionSectionRenderer, AgentSessionsSorter, IAgentSessionsFilter } from './agentSessionsViewer.js';
import { AgentSessionsGrouping } from './agentSessionsFilter.js';
import { AgentSessionApprovalModel } from './agentSessionApprovalModel.js';
import { FuzzyScore } from '../../../../../base/common/filters.js';
@@ -33,7 +33,7 @@ import { IAgentSessionsService } from './agentSessionsService.js';
import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js';
import { IListStyles } from '../../../../../base/browser/ui/list/listWidget.js';
import { IStyleOverride } from '../../../../../platform/theme/browser/defaultStyles.js';
import { getAgentSessionTime, IAgentSessionsControl } from './agentSessions.js';
import { IAgentSessionsControl } from './agentSessions.js';
import { HoverPosition } from '../../../../../base/browser/ui/hover/hoverWidget.js';
import { URI } from '../../../../../base/common/uri.js';
import { ISessionOpenOptions, openSession } from './agentSessionsOpener.js';
@@ -44,7 +44,7 @@ import { IChatWidget } from '../chat.js';
import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js';
import { ILogService } from '../../../../../platform/log/common/log.js';
export interface IAgentSessionsControlOptions extends IAgentSessionsSorterOptions {
export interface IAgentSessionsControlOptions {
readonly overrideStyles: IStyleOverride<IListStyles>;
readonly filter: IAgentSessionsFilter;
readonly source: string;
@@ -243,7 +243,7 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo
return false;
};
const sorter = new AgentSessionsSorter(this.options);
const sorter = new AgentSessionsSorter();
const approvalModel = this.options.enableApprovalRow ? this._register(this.instantiationService.createInstance(AgentSessionApprovalModel)) : undefined;
const activeSessionResource = observableValue<URI | undefined>(this, undefined);
const sessionRenderer = this._register(this.instantiationService.createInstance(AgentSessionRenderer, {
@@ -369,7 +369,7 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo
return this.agentSessionsService.model.sessions.some(session =>
!session.isArchived() &&
getAgentSessionTime(session.timing) >= startOfToday
session.timing.created >= startOfToday
);
}

View File

@@ -15,7 +15,7 @@ import { ISessionOpenOptions, openSession } from './agentSessionsOpener.js';
import { IAgentSession, isLocalAgentSessionItem } from './agentSessionsModel.js';
import { IAgentSessionsService } from './agentSessionsService.js';
import { AgentSessionsSorter, groupAgentSessionsByDate, sessionDateFromNow } from './agentSessionsViewer.js';
import { AGENT_SESSION_DELETE_ACTION_ID, AGENT_SESSION_RENAME_ACTION_ID, getAgentSessionTime } from './agentSessions.js';
import { AGENT_SESSION_DELETE_ACTION_ID, AGENT_SESSION_RENAME_ACTION_ID } from './agentSessions.js';
import { AgentSessionsFilter } from './agentSessionsFilter.js';
interface ISessionPickItem extends IQuickPickItem {
@@ -44,7 +44,7 @@ export const deleteButton: IQuickInputButton = {
export function getSessionDescription(session: IAgentSession): string {
const descriptionText = typeof session.description === 'string' ? session.description : session.description ? renderAsPlaintext(session.description) : undefined;
const timeAgo = sessionDateFromNow(getAgentSessionTime(session.timing));
const timeAgo = sessionDateFromNow(session.timing.created);
const descriptionParts = [descriptionText, session.providerLabel, timeAgo].filter(part => !!part);
return descriptionParts.join(' • ');

View File

@@ -41,7 +41,7 @@ import { Emitter, Event } from '../../../../../base/common/event.js';
import { renderAsPlaintext } from '../../../../../base/browser/markdownRenderer.js';
import { MarkdownString, IMarkdownString } from '../../../../../base/common/htmlContent.js';
import { AgentSessionHoverWidget } from './agentSessionHoverWidget.js';
import { AgentSessionProviders, getAgentSessionTime } from './agentSessions.js';
import { AgentSessionProviders } from './agentSessions.js';
import { AgentSessionsGrouping } from './agentSessionsFilter.js';
import { autorun, IObservable } from '../../../../../base/common/observable.js';
import { URI } from '../../../../../base/common/uri.js';
@@ -144,19 +144,17 @@ export class AgentSessionRenderer extends Disposable implements ICompressibleTre
h('div.agent-session-title-toolbar@titleToolbar'),
]),
h('div.agent-session-details-row', [
h('div.agent-session-badge@badge'),
h('span.agent-session-separator@separator'),
h('div.agent-session-diff-container@diffContainer',
[
h('span.agent-session-diff-added@addedSpan'),
h('span.agent-session-diff-removed@removedSpan')
]),
h('div.agent-session-description@description'),
h('div.agent-session-details-right', [
h('div.agent-session-badge@badge'),
h('span.agent-session-separator@separator'),
h('div.agent-session-status@statusContainer', [
h('span.agent-session-status-provider-icon@statusProviderIcon'),
h('span.agent-session-status-time@statusTime')
]),
h('div.agent-session-status@statusContainer', [
h('span.agent-session-status-provider-icon@statusProviderIcon'),
h('span.agent-session-status-time@statusTime')
]),
]),
h('div.agent-session-approval-row@approvalRow', [
@@ -180,11 +178,11 @@ export class AgentSessionRenderer extends Disposable implements ICompressibleTre
icon: elements.icon,
title: disposables.add(new IconLabel(elements.title, { supportHighlights: true, supportIcons: true })),
titleToolbar,
badge: elements.badge,
separator: elements.separator,
diffContainer: elements.diffContainer,
diffAddedSpan: elements.addedSpan,
diffRemovedSpan: elements.removedSpan,
badge: elements.badge,
separator: elements.separator,
description: elements.description,
statusContainer: elements.statusContainer,
statusProviderIcon: elements.statusProviderIcon,
@@ -211,7 +209,7 @@ export class AgentSessionRenderer extends Disposable implements ICompressibleTre
template.element.classList.toggle('archived', session.element.isArchived());
// Icon
template.icon.className = `agent-session-icon ${ThemeIcon.asClassName(this.getIcon(session.element))}`;
template.icon.className = `agent-session-icon ${ThemeIcon.asClassName(this.getIcon(session.element))}${session.element.status === AgentSessionStatus.NeedsInput ? ' needs-input' : ''}`;
// Title
const markdownTitle = new MarkdownString(session.element.label);
@@ -223,6 +221,9 @@ export class AgentSessionRenderer extends Disposable implements ICompressibleTre
ChatContextKeys.agentSessionType.bindTo(template.contextKeyService).set(session.element.providerType);
template.titleToolbar.context = session.element;
// Badge
const hasBadge = this.renderBadge(session, template);
// Diff information
let hasDiff = false;
const { changes: diff } = session.element;
@@ -231,7 +232,6 @@ export class AgentSessionRenderer extends Disposable implements ICompressibleTre
hasDiff = true;
}
}
template.diffContainer.classList.toggle('has-diff', hasDiff);
let hasAgentSessionChanges = false;
if (
@@ -248,20 +248,21 @@ export class AgentSessionRenderer extends Disposable implements ICompressibleTre
ChatContextKeys.hasAgentSessionChanges.bindTo(template.contextKeyService).set(hasAgentSessionChanges);
// Badge
const hasBadge = this.renderBadge(session, template);
template.badge.classList.toggle('has-badge', hasBadge);
// Description (unless diff is shown)
if (!hasDiff) {
this.renderDescription(session, template);
}
// Separator (dot between badge and timestamp)
template.separator.classList.toggle('has-separator', hasBadge);
// Description
const hasDescription = this.renderDescription(session, template);
// Status
this.renderStatus(session, template);
const hasStatus = this.renderStatus(session, template);
// When in progress with a description, only show description in the details row
const hideDetails = hasDescription && isSessionInProgressStatus(session.element.status);
template.badge.classList.toggle('has-badge', hasBadge && !hideDetails);
template.diffContainer.classList.toggle('has-diff', hasDiff && !hideDetails);
template.statusContainer.classList.toggle('hidden', hideDetails);
template.separator.classList.toggle('has-separator', !hideDetails && hasBadge && hasDiff);
template.description.classList.toggle('has-separator', hasDescription && !hideDetails && (hasBadge || hasDiff));
template.statusContainer.classList.toggle('has-separator', !hideDetails && hasStatus && (hasBadge || hasDiff || hasDescription));
// Hover
this.renderHover(session, template);
@@ -295,10 +296,21 @@ export class AgentSessionRenderer extends Disposable implements ICompressibleTre
}
}
this.renderMarkdownOrText(badge, template.badge, template.elementDisposable);
this.renderMarkdownOrText(this.stripCodicons(badge), template.badge, template.elementDisposable);
return true;
}
private stripCodicons(content: string | IMarkdownString): string | IMarkdownString {
const raw = typeof content === 'string' ? content : content.value;
const stripped = raw.replace(/\$\([a-z0-9\-]+\)\s*/gi, '').trim();
if (typeof content === 'string') {
return stripped;
}
return MarkdownString.lift({ ...content, value: stripped });
}
private renderMarkdownOrText(content: string | IMarkdownString, container: HTMLElement, disposables: DisposableStore): void {
if (typeof content === 'string') {
container.textContent = content;
@@ -321,6 +333,10 @@ export class AgentSessionRenderer extends Disposable implements ICompressibleTre
return false;
}
if (diff.insertions === 0 && diff.deletions === 0) {
return false;
}
if (diff.insertions >= 0 /* render even `0` for more homogeneity */) {
template.diffAddedSpan.textContent = `+${diff.insertions}`;
}
@@ -338,7 +354,7 @@ export class AgentSessionRenderer extends Disposable implements ICompressibleTre
}
if (session.status === AgentSessionStatus.NeedsInput) {
return Codicon.report;
return Codicon.circleFilled;
}
if (session.status === AgentSessionStatus.Failed) {
@@ -352,33 +368,27 @@ export class AgentSessionRenderer extends Disposable implements ICompressibleTre
return Codicon.circleSmallFilled;
}
private renderDescription(session: ITreeNode<IAgentSession, FuzzyScore>, template: IAgentSessionItemTemplate): void {
private renderDescription(session: ITreeNode<IAgentSession, FuzzyScore>, template: IAgentSessionItemTemplate): boolean {
const description = session.element.description;
if (description) {
this.renderMarkdownOrText(description, template.description, template.elementDisposable);
return;
return true;
}
// Fallback to state label
if (session.element.status === AgentSessionStatus.InProgress) {
template.description.textContent = localize('chat.session.status.inProgress', "Working...");
return true;
} else if (session.element.status === AgentSessionStatus.NeedsInput) {
template.description.textContent = localize('chat.session.status.needsInput', "Input needed.");
} else if (
session.element.timing.lastRequestEnded &&
session.element.timing.lastRequestStarted &&
session.element.timing.lastRequestEnded > session.element.timing.lastRequestStarted
) {
const duration = this.toDuration(session.element.timing.lastRequestStarted, session.element.timing.lastRequestEnded, false, true);
template.description.textContent = session.element.status === AgentSessionStatus.Failed ?
localize('chat.session.status.failedAfter', "Failed after {0}", duration) :
localize('chat.session.status.completedAfter', "Completed in {0}", duration);
} else {
template.description.textContent = session.element.status === AgentSessionStatus.Failed ?
localize('chat.session.status.failed', "Failed") :
localize('chat.session.status.completed', "Completed");
return true;
} else if (session.element.status === AgentSessionStatus.Failed) {
template.description.textContent = localize('chat.session.status.failed', "Failed");
return true;
}
template.description.textContent = '';
return false;
}
private toDuration(startTime: number, endTime: number, useFullTimeWords: boolean, disallowNow: boolean): string {
@@ -390,7 +400,7 @@ export class AgentSessionRenderer extends Disposable implements ICompressibleTre
return getDurationString(elapsed, useFullTimeWords);
}
private renderStatus(session: ITreeNode<IAgentSession, FuzzyScore>, template: IAgentSessionItemTemplate): void {
private renderStatus(session: ITreeNode<IAgentSession, FuzzyScore>, template: IAgentSessionItemTemplate): boolean {
const getTimeLabel = (session: IAgentSession) => {
let timeLabel: string | undefined;
@@ -399,12 +409,12 @@ export class AgentSessionRenderer extends Disposable implements ICompressibleTre
}
if (!timeLabel) {
const date = getAgentSessionTime(session.timing);
const date = session.timing.created;
const seconds = Math.round((new Date().getTime() - date) / 1000);
if (seconds < 60) {
timeLabel = localize('secondsDuration', "now");
} else {
timeLabel = sessionDateFromNow(date);
timeLabel = sessionDateFromNow(date, true);
}
}
@@ -428,6 +438,8 @@ export class AgentSessionRenderer extends Disposable implements ICompressibleTre
template.statusTime.textContent = getTimeLabel(session.element);
const timer = template.elementDisposable.add(new IntervalTimer());
timer.cancelAndSet(() => template.statusTime.textContent = getTimeLabel(session.element), session.element.status === AgentSessionStatus.InProgress ? 1000 /* every second */ : 60 * 1000 /* every minute */);
return true;
}
private renderHover(session: ITreeNode<IAgentSession, FuzzyScore>, template: IAgentSessionItemTemplate): void {
@@ -821,9 +833,14 @@ export class AgentSessionsDataSource extends Disposable implements IAsyncDataSou
}
private groupSessionsIntoSections(sessions: IAgentSession[]): AgentSessionListItem[] {
const sortedSessions = sessions.sort(this.sorter.compare.bind(this.sorter));
const isCapped = this.filter?.groupResults?.() === AgentSessionsGrouping.Capped;
if (this.filter?.groupResults?.() === AgentSessionsGrouping.Capped) {
const sorter = this.sorter;
const sortedSessions = sorter instanceof AgentSessionsSorter
? sessions.sort((a, b) => sorter.compare(a, b, isCapped /* special sorting for when results are capped to keep active ones top */))
: sessions.sort(sorter.compare.bind(sorter));
if (isCapped) {
if (this.filter?.getExcludes().read) {
return sortedSessions; // When filtering to show only unread sessions, show a flat list
}
@@ -1098,7 +1115,7 @@ export function groupAgentSessionsByDate(sessions: IAgentSession[]): Map<AgentSe
if (session.isArchived()) {
archivedSessions.push(session);
} else {
const sessionTime = getAgentSessionTime(session.timing);
const sessionTime = session.timing.created;
if (sessionTime >= startOfToday) {
todaySessions.push(session);
} else if (sessionTime >= startOfYesterday) {
@@ -1120,7 +1137,7 @@ export function groupAgentSessionsByDate(sessions: IAgentSession[]): Map<AgentSe
]);
}
export function sessionDateFromNow(sessionTime: number): string {
export function sessionDateFromNow(sessionTime: number, appendAgoLabel?: boolean): string {
const now = Date.now();
const startOfToday = new Date(now).setHours(0, 0, 0, 0);
const startOfYesterday = startOfToday - DAY_THRESHOLD;
@@ -1133,14 +1150,18 @@ export function sessionDateFromNow(sessionTime: number): string {
// normalization logic.
if (sessionTime < startOfToday && sessionTime >= startOfYesterday) {
return localize('date.fromNow.days.singular', '1 day');
return appendAgoLabel
? localize('date.fromNow.days.singular.ago', '1 day ago')
: localize('date.fromNow.days.singular', '1 day');
}
if (sessionTime < startOfYesterday && sessionTime >= startOfTwoDaysAgo) {
return localize('date.fromNow.days.multiple', '2 days');
return appendAgoLabel
? localize('date.fromNow.days.multiple.ago', '2 days ago')
: localize('date.fromNow.days.multiple', '2 days');
}
return fromNow(sessionTime, false);
return fromNow(sessionTime, appendAgoLabel);
}
export class AgentSessionsIdentityProvider implements IIdentityProvider<IAgentSessionsModel | AgentSessionListItem> {
@@ -1172,25 +1193,21 @@ export class AgentSessionsCompressionDelegate implements ITreeCompressionDelegat
}
}
export interface IAgentSessionsSorterOptions {
overrideCompare?(sessionA: IAgentSession, sessionB: IAgentSession): number | undefined;
}
export class AgentSessionsSorter implements ITreeSorter<IAgentSession> {
constructor(private readonly options?: IAgentSessionsSorterOptions) { }
compare(sessionA: IAgentSession, sessionB: IAgentSession, prioritizeActiveSessions = false): number {
compare(sessionA: IAgentSession, sessionB: IAgentSession): number {
// Special sorting if enabled
if (prioritizeActiveSessions) {
const aNeedsInput = sessionA.status === AgentSessionStatus.NeedsInput;
const bNeedsInput = sessionB.status === AgentSessionStatus.NeedsInput;
// Input Needed
const aNeedsInput = sessionA.status === AgentSessionStatus.NeedsInput;
const bNeedsInput = sessionB.status === AgentSessionStatus.NeedsInput;
if (aNeedsInput && !bNeedsInput) {
return -1; // a (needs input) comes before b (other)
}
if (!aNeedsInput && bNeedsInput) {
return 1; // a (other) comes after b (needs input)
if (aNeedsInput && !bNeedsInput) {
return -1; // a (needs input) comes before b (other)
}
if (!aNeedsInput && bNeedsInput) {
return 1; // a (other) comes after b (needs input)
}
}
// Archived
@@ -1204,15 +1221,9 @@ export class AgentSessionsSorter implements ITreeSorter<IAgentSession> {
return 1; // a (archived) comes after b (non-archived)
}
// Before we compare by time, allow override
const override = this.options?.overrideCompare?.(sessionA, sessionB);
if (typeof override === 'number') {
return override;
}
// Sort by end or start time (most recent first)
const timeA = getAgentSessionTime(sessionA.timing);
const timeB = getAgentSessionTime(sessionB.timing);
// Sort by time
const timeA = prioritizeActiveSessions ? sessionA.timing.lastRequestStarted ?? sessionA.timing.created : sessionA.timing.created;
const timeB = prioritizeActiveSessions ? sessionB.timing.lastRequestStarted ?? sessionB.timing.created : sessionB.timing.created;
return timeB - timeA;
}
}

View File

@@ -73,6 +73,10 @@
.agent-session-title {
margin-right: 8px;
}
.agent-session-status .agent-session-status-provider-icon {
display: inline;
}
}
.agent-session-item {
@@ -112,17 +116,24 @@
color: var(--vscode-errorForeground);
}
&.codicon.codicon-report {
color: var(--vscode-textLink-foreground);
}
&.codicon.codicon-circle-filled {
color: var(--vscode-textLink-foreground);
}
&.codicon.codicon-circle-filled.needs-input {
color: var(--vscode-list-warningForeground);
animation: agent-session-needs-input-pulse 2s ease-in-out infinite;
}
&.codicon.codicon-circle-small-filled {
color: var(--vscode-agentSessionReadIndicator-foreground);
}
@media (prefers-reduced-motion: reduce) {
&.codicon.codicon-circle-filled.needs-input {
animation: none;
}
}
}
}
@@ -207,10 +218,6 @@
&:not(.has-badge) {
display: none;
}
.codicon {
font-size: 11px;
}
}
}
@@ -223,17 +230,17 @@
text-overflow: ellipsis;
}
.agent-session-title {
font-size: 13px;
.agent-session-description:empty {
display: none;
}
.agent-session-details-right {
display: flex;
align-items: center;
gap: 4px;
margin-left: auto;
white-space: nowrap;
flex-shrink: 0;
.agent-session-description.has-separator::before {
content: '\00B7';
margin-right: 4px;
}
.agent-session-title {
font-size: 13px;
}
.agent-session-status {
@@ -242,13 +249,19 @@
font-variant-numeric: tabular-nums;
white-space: nowrap;
&.has-separator::before {
content: '\00B7';
margin-right: 4px;
}
&.hidden {
display: none;
}
.agent-session-status-provider-icon {
font-size: 11px;
margin-right: 4px;
&.hidden {
display: none;
}
display: none;
}
}
.agent-session-approval-row {
@@ -367,3 +380,12 @@
}
}
}
@keyframes agent-session-needs-input-pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.4;
}
}

View File

@@ -38,7 +38,6 @@ import { CHAT_CATEGORY } from '../actions/chatActions.js';
import { ChatTreeItem, IChatWidget, IChatWidgetService } from '../chat.js';
import { IAgentSession, isAgentSession } from '../agentSessions/agentSessionsModel.js';
import { AgentSessionProviders } from '../agentSessions/agentSessions.js';
import { IsSessionsWindowContext } from '../../../../common/contextkeys.js';
export abstract class EditingSessionAction extends Action2 {
@@ -358,12 +357,6 @@ export class ViewAllSessionChangesAction extends Action2 {
group: 'navigation',
order: 10,
when: ChatContextKeys.hasAgentSessionChanges
},
{
id: MenuId.AgentSessionItemToolbar,
group: 'navigation',
order: 0,
when: ContextKeyExpr.and(ChatContextKeys.hasAgentSessionChanges, IsSessionsWindowContext.negate())
}
],
});

View File

@@ -6,47 +6,13 @@
import assert from 'assert';
import { URI } from '../../../../../../base/common/uri.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js';
import { AgentSessionsDataSource, AgentSessionListItem, IAgentSessionsFilter, sessionDateFromNow, getRepositoryName } from '../../../browser/agentSessions/agentSessionsViewer.js';
import { AgentSessionsDataSource, AgentSessionListItem, IAgentSessionsFilter, sessionDateFromNow, getRepositoryName, AgentSessionsSorter } from '../../../browser/agentSessions/agentSessionsViewer.js';
import { AgentSessionSection, IAgentSession, IAgentSessionSection, IAgentSessionsModel, isAgentSessionSection } from '../../../browser/agentSessions/agentSessionsModel.js';
import { ChatSessionStatus } from '../../../common/chatSessionsService.js';
import { ITreeSorter } from '../../../../../../base/browser/ui/tree/tree.js';
import { Codicon } from '../../../../../../base/common/codicons.js';
import { Event } from '../../../../../../base/common/event.js';
import { AgentSessionsGrouping } from '../../../browser/agentSessions/agentSessionsFilter.js';
import { getAgentSessionTime } from '../../../browser/agentSessions/agentSessions.js';
import { IChatSessionTiming } from '../../../common/chatService/chatService.js';
suite('getAgentSessionTime', () => {
ensureNoDisposablesAreLeakedInTestSuite();
test('returns lastRequestStarted when available', () => {
const timing: IChatSessionTiming = {
created: 1000,
lastRequestStarted: 2000,
lastRequestEnded: 3000,
};
assert.strictEqual(getAgentSessionTime(timing), 2000);
});
test('returns lastRequestStarted even when lastRequestEnded is undefined', () => {
const timing: IChatSessionTiming = {
created: 1000,
lastRequestStarted: 2000,
lastRequestEnded: undefined,
};
assert.strictEqual(getAgentSessionTime(timing), 2000);
});
test('returns created when lastRequestStarted is undefined', () => {
const timing: IChatSessionTiming = {
created: 1000,
lastRequestStarted: undefined,
lastRequestEnded: undefined,
};
assert.strictEqual(getAgentSessionTime(timing), 1000);
});
});
suite('sessionDateFromNow', () => {
@@ -91,6 +57,22 @@ suite('sessionDateFromNow', () => {
assert.ok(result.includes('day'), `Expected days ago, got: ${result}`);
assert.ok(!result.includes('1 day') && !result.includes('2 days'), `Should not be 1 or 2 days ago, got: ${result}`);
});
test('appends "ago" when appendAgoLabel is true', () => {
const now = Date.now();
const startOfToday = new Date(now).setHours(0, 0, 0, 0);
const yesterday = startOfToday - ONE_DAY / 2;
assert.strictEqual(sessionDateFromNow(yesterday, true), '1 day ago');
const startOfYesterday = startOfToday - ONE_DAY;
const twoDaysAgo = startOfYesterday - ONE_DAY / 2;
assert.strictEqual(sessionDateFromNow(twoDaysAgo, true), '2 days ago');
const fiveDaysAgo = startOfToday - 5 * ONE_DAY;
const result = sessionDateFromNow(fiveDaysAgo, true);
assert.ok(result.includes('ago'), `Expected "ago" in result, got: ${result}`);
});
});
suite('AgentSessionsDataSource', () => {
@@ -166,9 +148,9 @@ suite('AgentSessionsDataSource', () => {
function createMockSorter(): ITreeSorter<IAgentSession> {
return {
compare: (a, b) => {
// Sort by end time, most recent first
const aTime = getAgentSessionTime(a.timing);
const bTime = getAgentSessionTime(b.timing);
// Sort by creation time, most recent first
const aTime = a.timing.created;
const bTime = b.timing.created;
return bTime - aTime;
}
};
@@ -875,3 +857,92 @@ suite('AgentSessionsDataSource', () => {
});
});
});
suite('AgentSessionsSorter', () => {
ensureNoDisposablesAreLeakedInTestSuite();
function createSession(overrides: Partial<{
id: string;
status: ChatSessionStatus;
isArchived: boolean;
created: number;
lastRequestStarted: number;
}>): IAgentSession {
const now = Date.now();
return {
providerType: 'test',
providerLabel: 'Test',
resource: URI.parse(`test://session/${overrides.id ?? 'default'}`),
status: overrides.status ?? ChatSessionStatus.Completed,
label: `Session ${overrides.id ?? 'default'}`,
icon: Codicon.terminal,
timing: {
created: overrides.created ?? now,
lastRequestEnded: undefined,
lastRequestStarted: overrides.lastRequestStarted,
},
changes: undefined,
metadata: undefined,
isArchived: () => overrides.isArchived ?? false,
setArchived: () => { },
isRead: () => true,
isMarkedUnread: () => false,
setRead: () => { },
};
}
test('default: sorts by creation time (most recent first)', () => {
const sorter = new AgentSessionsSorter();
const old = createSession({ id: 'old', created: 1000 });
const recent = createSession({ id: 'recent', created: 2000 });
const sorted = [old, recent].sort((a, b) => sorter.compare(a, b));
assert.deepStrictEqual(sorted.map(s => s.label), ['Session recent', 'Session old']);
});
test('default: archived sessions come last', () => {
const sorter = new AgentSessionsSorter();
const archived = createSession({ id: 'archived', isArchived: true, created: 3000 });
const active = createSession({ id: 'active', created: 1000 });
const sorted = [archived, active].sort((a, b) => sorter.compare(a, b));
assert.deepStrictEqual(sorted.map(s => s.label), ['Session active', 'Session archived']);
});
test('default: does NOT prioritize needs-input sessions', () => {
const sorter = new AgentSessionsSorter();
const needsInput = createSession({ id: 'needs', status: ChatSessionStatus.NeedsInput, created: 1000 });
const completed = createSession({ id: 'done', status: ChatSessionStatus.Completed, created: 2000 });
const sorted = [needsInput, completed].sort((a, b) => sorter.compare(a, b));
assert.deepStrictEqual(sorted.map(s => s.label), ['Session done', 'Session needs']);
});
test('prioritizeActive: needs-input sessions come first', () => {
const sorter = new AgentSessionsSorter();
const needsInput = createSession({ id: 'needs', status: ChatSessionStatus.NeedsInput, created: 1000 });
const completed = createSession({ id: 'done', status: ChatSessionStatus.Completed, created: 2000 });
const sorted = [completed, needsInput].sort((a, b) => sorter.compare(a, b, true));
assert.deepStrictEqual(sorted.map(s => s.label), ['Session needs', 'Session done']);
});
test('prioritizeActive: archived still come last when not active', () => {
const sorter = new AgentSessionsSorter();
const archived = createSession({ id: 'archived', isArchived: true, created: 3000 });
const active = createSession({ id: 'active', created: 1000 });
const sorted = [archived, active].sort((a, b) => sorter.compare(a, b, true));
assert.deepStrictEqual(sorted.map(s => s.label), ['Session active', 'Session archived']);
});
test('prioritizeActive: uses lastRequestStarted for time sorting', () => {
const sorter = new AgentSessionsSorter();
const recentlyActive = createSession({ id: 'recent-active', created: 1000, lastRequestStarted: 5000 });
const recentlyCreated = createSession({ id: 'recent-created', created: 3000 });
const sorted = [recentlyCreated, recentlyActive].sort((a, b) => sorter.compare(a, b, true));
assert.deepStrictEqual(sorted.map(s => s.label), ['Session recent-active', 'Session recent-created']);
});
});