Path: blob/main/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts
5283 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import './media/agentsessionsviewer.css';6import { h } from '../../../../../base/browser/dom.js';7import { localize } from '../../../../../nls.js';8import { IIdentityProvider, IListVirtualDelegate, NotSelectableGroupId, NotSelectableGroupIdType } from '../../../../../base/browser/ui/list/list.js';9import { AriaRole } from '../../../../../base/browser/ui/aria/aria.js';10import { IListAccessibilityProvider } from '../../../../../base/browser/ui/list/listWidget.js';11import { ITreeCompressionDelegate } from '../../../../../base/browser/ui/tree/asyncDataTree.js';12import { ICompressedTreeNode } from '../../../../../base/browser/ui/tree/compressedObjectTreeModel.js';13import { ICompressibleKeyboardNavigationLabelProvider, ICompressibleTreeRenderer } from '../../../../../base/browser/ui/tree/objectTree.js';14import { ITreeNode, ITreeElementRenderDetails, IAsyncDataSource, ITreeSorter, ITreeDragAndDrop, ITreeDragOverReaction } from '../../../../../base/browser/ui/tree/tree.js';15import { Disposable, DisposableStore, IDisposable, MutableDisposable } from '../../../../../base/common/lifecycle.js';16import { AgentSessionSection, AgentSessionStatus, getAgentChangesSummary, hasValidDiff, IAgentSession, IAgentSessionSection, IAgentSessionsModel, isAgentSession, isAgentSessionSection, isAgentSessionsModel, isSessionInProgressStatus } from './agentSessionsModel.js';17import { IconLabel } from '../../../../../base/browser/ui/iconLabel/iconLabel.js';18import { ThemeIcon } from '../../../../../base/common/themables.js';19import { Codicon } from '../../../../../base/common/codicons.js';20import { fromNow, getDurationString } from '../../../../../base/common/date.js';21import { FuzzyScore, createMatches } from '../../../../../base/common/filters.js';22import { IMarkdownRendererService } from '../../../../../platform/markdown/browser/markdownRenderer.js';23import { allowedChatMarkdownHtmlTags } from '../widget/chatContentMarkdownRenderer.js';24import { IProductService } from '../../../../../platform/product/common/productService.js';25import { IDragAndDropData } from '../../../../../base/browser/dnd.js';26import { ListViewTargetSector } from '../../../../../base/browser/ui/list/listView.js';27import { coalesce } from '../../../../../base/common/arrays.js';28import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';29import { fillEditorsDragData } from '../../../../browser/dnd.js';30import { HoverStyle, IDelayedHoverOptions } from '../../../../../base/browser/ui/hover/hover.js';31import { HoverPosition } from '../../../../../base/browser/ui/hover/hoverWidget.js';32import { IHoverService } from '../../../../../platform/hover/browser/hover.js';33import { IntervalTimer } from '../../../../../base/common/async.js';34import { MenuWorkbenchToolBar } from '../../../../../platform/actions/browser/toolbar.js';35import { MenuId } from '../../../../../platform/actions/common/actions.js';36import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js';37import { ChatContextKeys } from '../../common/actions/chatContextKeys.js';38import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js';39import { Event } from '../../../../../base/common/event.js';40import { renderAsPlaintext } from '../../../../../base/browser/markdownRenderer.js';41import { MarkdownString, IMarkdownString } from '../../../../../base/common/htmlContent.js';42import { AgentSessionHoverWidget } from './agentSessionHoverWidget.js';43import { AgentSessionProviders, getAgentSessionTime } from './agentSessions.js';44import { AgentSessionsGrouping } from './agentSessionsFilter.js';4546export type AgentSessionListItem = IAgentSession | IAgentSessionSection;4748//#region Agent Session Renderer4950interface IAgentSessionItemTemplate {51readonly element: HTMLElement;5253// Column 154readonly icon: HTMLElement;5556// Column 2 Row 157readonly title: IconLabel;58readonly statusContainer: HTMLElement;59readonly statusProviderIcon: HTMLElement;60readonly statusTime: HTMLElement;61readonly titleToolbar: MenuWorkbenchToolBar;6263// Column 2 Row 264readonly diffContainer: HTMLElement;65readonly diffAddedSpan: HTMLSpanElement;66readonly diffRemovedSpan: HTMLSpanElement;6768readonly badge: HTMLElement;69readonly separator: HTMLElement;70readonly description: HTMLElement;7172readonly contextKeyService: IContextKeyService;73readonly elementDisposable: DisposableStore;74readonly disposables: IDisposable;75}7677export interface IAgentSessionRendererOptions {78getHoverPosition(): HoverPosition;79}8081export class AgentSessionRenderer extends Disposable implements ICompressibleTreeRenderer<IAgentSession, FuzzyScore, IAgentSessionItemTemplate> {8283static readonly TEMPLATE_ID = 'agent-session';8485readonly templateId = AgentSessionRenderer.TEMPLATE_ID;8687private readonly sessionHover = this._register(new MutableDisposable<AgentSessionHoverWidget>());8889constructor(90private readonly options: IAgentSessionRendererOptions,91@IMarkdownRendererService private readonly markdownRendererService: IMarkdownRendererService,92@IProductService private readonly productService: IProductService,93@IHoverService private readonly hoverService: IHoverService,94@IInstantiationService private readonly instantiationService: IInstantiationService,95@IContextKeyService private readonly contextKeyService: IContextKeyService,96) {97super();98}99100renderTemplate(container: HTMLElement): IAgentSessionItemTemplate {101const disposables = new DisposableStore();102const elementDisposable = disposables.add(new DisposableStore());103104const elements = h(105'div.agent-session-item@item',106[107h('div.agent-session-icon-col', [108h('div.agent-session-icon@icon')109]),110h('div.agent-session-main-col', [111h('div.agent-session-title-row', [112h('div.agent-session-title@title'),113h('div.agent-session-status@statusContainer', [114h('span.agent-session-status-provider-icon@statusProviderIcon'),115h('span.agent-session-status-time@statusTime')116]),117h('div.agent-session-title-toolbar@titleToolbar'),118]),119h('div.agent-session-details-row', [120h('div.agent-session-diff-container@diffContainer',121[122h('span.agent-session-diff-added@addedSpan'),123h('span.agent-session-diff-removed@removedSpan')124]),125h('div.agent-session-badge@badge'),126h('span.agent-session-separator@separator'),127h('div.agent-session-description@description'),128])129])130]131);132133const contextKeyService = disposables.add(this.contextKeyService.createScoped(elements.item));134const scopedInstantiationService = disposables.add(this.instantiationService.createChild(new ServiceCollection([IContextKeyService, contextKeyService])));135const titleToolbar = disposables.add(scopedInstantiationService.createInstance(MenuWorkbenchToolBar, elements.titleToolbar, MenuId.AgentSessionItemToolbar, {136menuOptions: { shouldForwardArgs: true },137}));138139container.appendChild(elements.item);140141return {142element: elements.item,143icon: elements.icon,144title: disposables.add(new IconLabel(elements.title, { supportHighlights: true, supportIcons: true })),145titleToolbar,146diffContainer: elements.diffContainer,147diffAddedSpan: elements.addedSpan,148diffRemovedSpan: elements.removedSpan,149badge: elements.badge,150separator: elements.separator,151description: elements.description,152statusContainer: elements.statusContainer,153statusProviderIcon: elements.statusProviderIcon,154statusTime: elements.statusTime,155contextKeyService,156elementDisposable,157disposables158};159}160161renderElement(session: ITreeNode<IAgentSession, FuzzyScore>, index: number, template: IAgentSessionItemTemplate, details?: ITreeElementRenderDetails): void {162163// Clear old state164template.elementDisposable.clear();165template.diffAddedSpan.textContent = '';166template.diffRemovedSpan.textContent = '';167template.badge.textContent = '';168template.description.textContent = '';169170// Archived171template.element.classList.toggle('archived', session.element.isArchived());172173// Icon174template.icon.className = `agent-session-icon ${ThemeIcon.asClassName(this.getIcon(session.element))}`;175176// Title177const markdownTitle = new MarkdownString(session.element.label);178template.title.setLabel(renderAsPlaintext(markdownTitle), undefined, { matches: createMatches(session.filterData) });179180// Title Actions - Update context keys181ChatContextKeys.isArchivedAgentSession.bindTo(template.contextKeyService).set(session.element.isArchived());182ChatContextKeys.isReadAgentSession.bindTo(template.contextKeyService).set(session.element.isRead());183ChatContextKeys.agentSessionType.bindTo(template.contextKeyService).set(session.element.providerType);184template.titleToolbar.context = session.element;185186// Diff information187let hasDiff = false;188const { changes: diff } = session.element;189if (!isSessionInProgressStatus(session.element.status) && diff && hasValidDiff(diff)) {190if (this.renderDiff(session, template)) {191hasDiff = true;192}193}194template.diffContainer.classList.toggle('has-diff', hasDiff);195196let hasAgentSessionChanges = false;197if (198session.element.providerType === AgentSessionProviders.Background ||199session.element.providerType === AgentSessionProviders.Cloud200) {201// Background and Cloud agents provide the list of changes directly,202// so we have to use the list of changes to determine whether to show203// the "View All Changes" action204hasAgentSessionChanges = Array.isArray(diff) && diff.length > 0;205} else {206hasAgentSessionChanges = hasDiff;207}208209ChatContextKeys.hasAgentSessionChanges.bindTo(template.contextKeyService).set(hasAgentSessionChanges);210211// Badge212const hasBadge = this.renderBadge(session, template);213template.badge.classList.toggle('has-badge', hasBadge);214215// Description (unless diff is shown)216if (!hasDiff) {217this.renderDescription(session, template, hasBadge);218}219220// Separator (dot between badge and description)221template.separator.classList.toggle('has-separator', hasBadge && !hasDiff);222223// Status224this.renderStatus(session, template);225226// Hover227this.renderHover(session, template);228}229230private renderBadge(session: ITreeNode<IAgentSession, FuzzyScore>, template: IAgentSessionItemTemplate): boolean {231const badge = session.element.badge;232if (badge) {233this.renderMarkdownOrText(badge, template.badge, template.elementDisposable);234}235236return !!badge;237}238239private renderMarkdownOrText(content: string | IMarkdownString, container: HTMLElement, disposables: DisposableStore): void {240if (typeof content === 'string') {241container.textContent = content;242} else {243disposables.add(this.markdownRendererService.render(content, {244sanitizerConfig: {245replaceWithPlaintext: true,246allowedTags: {247override: allowedChatMarkdownHtmlTags,248},249allowedLinkSchemes: { augment: [this.productService.urlProtocol] }250},251}, container));252}253}254255private renderDiff(session: ITreeNode<IAgentSession, FuzzyScore>, template: IAgentSessionItemTemplate): boolean {256const diff = getAgentChangesSummary(session.element.changes);257if (!diff) {258return false;259}260261if (diff.insertions >= 0 /* render even `0` for more homogeneity */) {262template.diffAddedSpan.textContent = `+${diff.insertions}`;263}264265if (diff.deletions >= 0 /* render even `0` for more homogeneity */) {266template.diffRemovedSpan.textContent = `-${diff.deletions}`;267}268269return true;270}271272private getIcon(session: IAgentSession): ThemeIcon {273if (session.status === AgentSessionStatus.InProgress) {274return Codicon.sessionInProgress;275}276277if (session.status === AgentSessionStatus.NeedsInput) {278return Codicon.report;279}280281if (session.status === AgentSessionStatus.Failed) {282return Codicon.error;283}284285if (!session.isRead() && !session.isArchived()) {286return Codicon.circleFilled;287}288289return Codicon.circleSmallFilled;290}291292private renderDescription(session: ITreeNode<IAgentSession, FuzzyScore>, template: IAgentSessionItemTemplate, hasBadge: boolean): void {293const description = session.element.description;294if (description) {295this.renderMarkdownOrText(description, template.description, template.elementDisposable);296}297298// Fallback to state label299else {300if (session.element.status === AgentSessionStatus.InProgress) {301template.description.textContent = localize('chat.session.status.inProgress', "Working...");302} else if (session.element.status === AgentSessionStatus.NeedsInput) {303template.description.textContent = localize('chat.session.status.needsInput', "Input needed.");304} else if (hasBadge && session.element.status === AgentSessionStatus.Completed) {305template.description.textContent = ''; // no description if completed and has badge306} else if (307session.element.timing.lastRequestEnded &&308session.element.timing.lastRequestStarted &&309session.element.timing.lastRequestEnded > session.element.timing.lastRequestStarted310) {311const duration = this.toDuration(session.element.timing.lastRequestStarted, session.element.timing.lastRequestEnded, false, true);312313template.description.textContent = session.element.status === AgentSessionStatus.Failed ?314localize('chat.session.status.failedAfter', "Failed after {0}", duration) :315localize('chat.session.status.completedAfter', "Completed in {0}", duration);316} else {317template.description.textContent = session.element.status === AgentSessionStatus.Failed ?318localize('chat.session.status.failed', "Failed") :319localize('chat.session.status.completed', "Completed");320}321}322}323324private toDuration(startTime: number, endTime: number, useFullTimeWords: boolean, disallowNow: boolean): string {325const elapsed = Math.max(Math.round((endTime - startTime) / 1000) * 1000, 1000 /* clamp to 1s */);326if (!disallowNow && elapsed < 60000) {327return localize('secondsDuration', "now");328}329330return getDurationString(elapsed, useFullTimeWords);331}332333private renderStatus(session: ITreeNode<IAgentSession, FuzzyScore>, template: IAgentSessionItemTemplate): void {334335const getTimeLabel = (session: IAgentSession) => {336let timeLabel: string | undefined;337if (session.status === AgentSessionStatus.InProgress && session.timing.lastRequestStarted) {338timeLabel = this.toDuration(session.timing.lastRequestStarted, Date.now(), false, false);339}340341if (!timeLabel) {342const date = getAgentSessionTime(session.timing);343const seconds = Math.round((new Date().getTime() - date) / 1000);344if (seconds < 60) {345timeLabel = localize('secondsDuration', "now");346} else {347timeLabel = sessionDateFromNow(date);348}349}350351return timeLabel;352};353354// Provider icon (only shown for non-local sessions)355const isLocal = session.element.providerType === AgentSessionProviders.Local;356template.statusProviderIcon.className = isLocal ? '' : `agent-session-status-provider-icon ${ThemeIcon.asClassName(session.element.icon)}`;357358// Time label359template.statusTime.textContent = getTimeLabel(session.element);360const timer = template.elementDisposable.add(new IntervalTimer());361timer.cancelAndSet(() => template.statusTime.textContent = getTimeLabel(session.element), session.element.status === AgentSessionStatus.InProgress ? 1000 /* every second */ : 60 * 1000 /* every minute */);362}363364private renderHover(session: ITreeNode<IAgentSession, FuzzyScore>, template: IAgentSessionItemTemplate): void {365if (!isSessionInProgressStatus(session.element.status) && session.element.isRead()) {366return; // the hover is complex and large, for now limit it to in-progress sessions only367}368369const reducedDelay = session.element.status === AgentSessionStatus.NeedsInput;370template.elementDisposable.add(371this.hoverService.setupDelayedHover(template.element, () => this.buildHoverContent(session.element), { groupId: 'agent.sessions', reducedDelay })372);373}374375private buildHoverContent(session: IAgentSession): IDelayedHoverOptions {376if (this.sessionHover.value?.session.resource.toString() !== session.resource.toString()) {377// note: hover service use mouseover which triggers again if the mouse moves378// within the element. Only recreate the hover widget if the session changed.379this.sessionHover.value = this.instantiationService.createInstance(AgentSessionHoverWidget, session);380}381382const widget = this.sessionHover.value;383return {384id: `agent.session.hover.${session.resource.toString()}`,385content: widget.domNode,386style: HoverStyle.Pointer,387onDidShow: () => widget.onRendered(),388position: {389hoverPosition: this.options.getHoverPosition()390}391};392}393394renderCompressedElements(node: ITreeNode<ICompressedTreeNode<IAgentSession>, FuzzyScore>, index: number, templateData: IAgentSessionItemTemplate, details?: ITreeElementRenderDetails): void {395throw new Error('Should never happen since session is incompressible');396}397398disposeElement(element: ITreeNode<IAgentSession, FuzzyScore>, index: number, template: IAgentSessionItemTemplate, details?: ITreeElementRenderDetails): void {399template.elementDisposable.clear();400}401402disposeTemplate(templateData: IAgentSessionItemTemplate): void {403templateData.disposables.dispose();404}405}406407export function toStatusLabel(status: AgentSessionStatus): string {408let statusLabel: string;409switch (status) {410case AgentSessionStatus.NeedsInput:411statusLabel = localize('agentSessionNeedsInput', "Needs Input");412break;413case AgentSessionStatus.InProgress:414statusLabel = localize('agentSessionInProgress', "In Progress");415break;416case AgentSessionStatus.Failed:417statusLabel = localize('agentSessionFailed', "Failed");418break;419default:420statusLabel = localize('agentSessionCompleted', "Completed");421}422423return statusLabel;424}425426//#endregion427428//#region Section Header Renderer429430interface IAgentSessionSectionTemplate {431readonly container: HTMLElement;432readonly label: HTMLSpanElement;433readonly toolbar: MenuWorkbenchToolBar;434readonly contextKeyService: IContextKeyService;435readonly disposables: IDisposable;436}437438export class AgentSessionSectionRenderer implements ICompressibleTreeRenderer<IAgentSessionSection, FuzzyScore, IAgentSessionSectionTemplate> {439440static readonly TEMPLATE_ID = 'agent-session-section';441442readonly templateId = AgentSessionSectionRenderer.TEMPLATE_ID;443444constructor(445@IInstantiationService private readonly instantiationService: IInstantiationService,446@IContextKeyService private readonly contextKeyService: IContextKeyService,447) { }448449renderTemplate(container: HTMLElement): IAgentSessionSectionTemplate {450const disposables = new DisposableStore();451452const elements = h(453'div.agent-session-section@container',454[455h('span.agent-session-section-label@label'),456h('div.agent-session-section-toolbar@toolbar')457]458);459460const contextKeyService = disposables.add(this.contextKeyService.createScoped(elements.container));461const scopedInstantiationService = disposables.add(this.instantiationService.createChild(new ServiceCollection([IContextKeyService, contextKeyService])));462const toolbar = disposables.add(scopedInstantiationService.createInstance(MenuWorkbenchToolBar, elements.toolbar, MenuId.AgentSessionSectionToolbar, {463menuOptions: { shouldForwardArgs: true },464}));465466container.appendChild(elements.container);467468return {469container: elements.container,470label: elements.label,471toolbar,472contextKeyService,473disposables474};475}476477renderElement(element: ITreeNode<IAgentSessionSection, FuzzyScore>, index: number, template: IAgentSessionSectionTemplate, details?: ITreeElementRenderDetails): void {478479// Label480template.label.textContent = element.element.label;481482// Toolbar483ChatContextKeys.agentSessionSection.bindTo(template.contextKeyService).set(element.element.section);484template.toolbar.context = element.element;485}486487renderCompressedElements(node: ITreeNode<ICompressedTreeNode<IAgentSessionSection>, FuzzyScore>, index: number, templateData: IAgentSessionSectionTemplate, details?: ITreeElementRenderDetails): void {488throw new Error('Should never happen since section header is incompressible');489}490491disposeElement(element: ITreeNode<IAgentSessionSection, FuzzyScore>, index: number, template: IAgentSessionSectionTemplate, details?: ITreeElementRenderDetails): void {492// noop493}494495disposeTemplate(templateData: IAgentSessionSectionTemplate): void {496templateData.disposables.dispose();497}498}499500//#endregion501502export class AgentSessionsListDelegate implements IListVirtualDelegate<AgentSessionListItem> {503504static readonly ITEM_HEIGHT = 44;505static readonly SECTION_HEIGHT = 26;506507getHeight(element: AgentSessionListItem): number {508if (isAgentSessionSection(element)) {509return AgentSessionsListDelegate.SECTION_HEIGHT;510}511512return AgentSessionsListDelegate.ITEM_HEIGHT;513}514515getTemplateId(element: AgentSessionListItem): string {516if (isAgentSessionSection(element)) {517return AgentSessionSectionRenderer.TEMPLATE_ID;518}519520return AgentSessionRenderer.TEMPLATE_ID;521}522}523524export class AgentSessionsAccessibilityProvider implements IListAccessibilityProvider<AgentSessionListItem> {525526getWidgetRole(): AriaRole {527return 'list';528}529530getRole(element: AgentSessionListItem): AriaRole | undefined {531return 'listitem';532}533534getWidgetAriaLabel(): string {535return localize('agentSessions', "Agent Sessions");536}537538getAriaLabel(element: AgentSessionListItem): string | null {539if (isAgentSessionSection(element)) {540return localize('agentSessionSectionAriaLabel', "{0} sessions section", element.label);541}542543return localize('agentSessionItemAriaLabel', "{0} session {1} ({2}), created {3}", element.providerLabel, element.label, toStatusLabel(element.status), new Date(element.timing.created).toLocaleString());544}545}546547export interface IAgentSessionsFilterExcludes {548readonly providers: readonly string[];549readonly states: readonly AgentSessionStatus[];550551readonly archived: boolean;552readonly read: boolean;553}554555export interface IAgentSessionsFilter {556557/**558* An event that fires when the filter changes and sessions559* should be re-evaluated.560*/561readonly onDidChange: Event<void>;562563/**564* Optional limit on the number of sessions to show.565*/566readonly limitResults?: () => number | undefined;567568/**569* Whether to show section headers to group sessions.570* When undefined, sessions are shown as a flat list.571*/572readonly groupResults?: () => AgentSessionsGrouping | undefined;573574/**575* A callback to notify the filter about the number of576* results after filtering.577*/578notifyResults?(count: number): void;579580/**581* The logic to exclude sessions from the view.582*/583exclude(session: IAgentSession): boolean;584585/**586* Get the current filter excludes for display in the UI.587*/588getExcludes(): IAgentSessionsFilterExcludes;589}590591export class AgentSessionsDataSource implements IAsyncDataSource<IAgentSessionsModel, AgentSessionListItem> {592593private static readonly CAPPED_SESSIONS_LIMIT = 3;594595constructor(596private readonly filter: IAgentSessionsFilter | undefined,597private readonly sorter: ITreeSorter<IAgentSession>,598) { }599600hasChildren(element: IAgentSessionsModel | AgentSessionListItem): boolean {601602// Sessions model603if (isAgentSessionsModel(element)) {604return true;605}606607// Sessions section608else if (isAgentSessionSection(element)) {609return element.sessions.length > 0;610}611612// Session element613else {614return false;615}616}617618getChildren(element: IAgentSessionsModel | AgentSessionListItem): Iterable<AgentSessionListItem> {619620// Sessions model621if (isAgentSessionsModel(element)) {622623// Apply filter if configured624let filteredSessions = element.sessions.filter(session => !this.filter?.exclude(session));625626// Apply sorter unless we group into sections or we are to limit results627const limitResultsCount = this.filter?.limitResults?.();628if (!this.filter?.groupResults?.() || typeof limitResultsCount === 'number') {629filteredSessions.sort(this.sorter.compare.bind(this.sorter));630}631632// Apply limiter if configured (requires sorting)633if (typeof limitResultsCount === 'number') {634filteredSessions = filteredSessions.slice(0, limitResultsCount);635}636637// Callback results count638this.filter?.notifyResults?.(filteredSessions.length);639640// Group sessions into sections if enabled641if (this.filter?.groupResults?.()) {642return this.groupSessionsIntoSections(filteredSessions);643}644645// Otherwise return flat sorted list646return filteredSessions;647}648649// Sessions section650else if (isAgentSessionSection(element)) {651return element.sessions;652}653654// Session element655else {656return [];657}658}659660private groupSessionsIntoSections(sessions: IAgentSession[]): AgentSessionListItem[] {661const sortedSessions = sessions.sort(this.sorter.compare.bind(this.sorter));662663if (this.filter?.groupResults?.() === AgentSessionsGrouping.Capped) {664if (this.filter?.getExcludes().read) {665return sortedSessions; // When filtering to show only unread sessions, show a flat list666}667668return this.groupSessionsCapped(sortedSessions);669} else {670return this.groupSessionsByDate(sortedSessions);671}672}673674private groupSessionsCapped(sortedSessions: IAgentSession[]): AgentSessionListItem[] {675const result: AgentSessionListItem[] = [];676677const firstArchivedIndex = sortedSessions.findIndex(session => session.isArchived());678const nonArchivedCount = firstArchivedIndex === -1 ? sortedSessions.length : firstArchivedIndex;679680const topSessions = sortedSessions.slice(0, Math.min(AgentSessionsDataSource.CAPPED_SESSIONS_LIMIT, nonArchivedCount));681const othersSessions = sortedSessions.slice(topSessions.length);682683// Add top sessions directly (no section header)684result.push(...topSessions);685686// Add "More" section for the rest687if (othersSessions.length > 0) {688result.push({689section: AgentSessionSection.More,690label: localize('agentSessions.moreSectionWithCount', "More ({0})", othersSessions.length),691sessions: othersSessions692});693}694695return result;696}697698private groupSessionsByDate(sortedSessions: IAgentSession[]): AgentSessionListItem[] {699const result: AgentSessionListItem[] = [];700const groupedSessions = groupAgentSessionsByDate(sortedSessions);701702for (const { sessions, section, label } of groupedSessions.values()) {703if (sessions.length === 0) {704continue;705}706707result.push({ section, label, sessions });708}709710return result;711}712}713714export const AgentSessionSectionLabels = {715[AgentSessionSection.InProgress]: localize('agentSessions.inProgressSection', "In progress"),716[AgentSessionSection.Today]: localize('agentSessions.todaySection', "Today"),717[AgentSessionSection.Yesterday]: localize('agentSessions.yesterdaySection', "Yesterday"),718[AgentSessionSection.Week]: localize('agentSessions.weekSection', "Last 7 days"),719[AgentSessionSection.Older]: localize('agentSessions.olderSection', "Older"),720[AgentSessionSection.Archived]: localize('agentSessions.archivedSection', "Archived"),721[AgentSessionSection.More]: localize('agentSessions.moreSection', "More"),722};723724const DAY_THRESHOLD = 24 * 60 * 60 * 1000;725const WEEK_THRESHOLD = 7 * DAY_THRESHOLD;726727export function groupAgentSessionsByDate(sessions: IAgentSession[]): Map<AgentSessionSection, IAgentSessionSection> {728const now = Date.now();729const startOfToday = new Date(now).setHours(0, 0, 0, 0);730const startOfYesterday = startOfToday - DAY_THRESHOLD;731const weekThreshold = now - WEEK_THRESHOLD;732733const inProgressSessions: IAgentSession[] = [];734const todaySessions: IAgentSession[] = [];735const yesterdaySessions: IAgentSession[] = [];736const weekSessions: IAgentSession[] = [];737const olderSessions: IAgentSession[] = [];738const archivedSessions: IAgentSession[] = [];739740for (const session of sessions) {741if (session.isArchived()) {742archivedSessions.push(session);743} else if (isSessionInProgressStatus(session.status)) {744inProgressSessions.push(session);745} else {746const sessionTime = getAgentSessionTime(session.timing);747if (sessionTime >= startOfToday) {748todaySessions.push(session);749} else if (sessionTime >= startOfYesterday) {750yesterdaySessions.push(session);751} else if (sessionTime >= weekThreshold) {752weekSessions.push(session);753} else {754olderSessions.push(session);755}756}757}758759return new Map<AgentSessionSection, IAgentSessionSection>([760[AgentSessionSection.InProgress, { section: AgentSessionSection.InProgress, label: AgentSessionSectionLabels[AgentSessionSection.InProgress], sessions: inProgressSessions }],761[AgentSessionSection.Today, { section: AgentSessionSection.Today, label: AgentSessionSectionLabels[AgentSessionSection.Today], sessions: todaySessions }],762[AgentSessionSection.Yesterday, { section: AgentSessionSection.Yesterday, label: AgentSessionSectionLabels[AgentSessionSection.Yesterday], sessions: yesterdaySessions }],763[AgentSessionSection.Week, { section: AgentSessionSection.Week, label: AgentSessionSectionLabels[AgentSessionSection.Week], sessions: weekSessions }],764[AgentSessionSection.Older, { section: AgentSessionSection.Older, label: AgentSessionSectionLabels[AgentSessionSection.Older], sessions: olderSessions }],765[AgentSessionSection.Archived, { section: AgentSessionSection.Archived, label: localize('agentSessions.archivedSectionWithCount', "Archived ({0})", archivedSessions.length), sessions: archivedSessions }],766]);767}768769export function sessionDateFromNow(sessionTime: number): string {770const now = Date.now();771const startOfToday = new Date(now).setHours(0, 0, 0, 0);772const startOfYesterday = startOfToday - DAY_THRESHOLD;773const startOfTwoDaysAgo = startOfYesterday - DAY_THRESHOLD;774775// our grouping by date uses absolute start times for "Today"776// and "Yesterday" while `fromNow` only works with full 24h777// and 48h ranges for these. To prevent a label like "1 day ago"778// to show under the "Last 7 Days" section, we do a bit of779// normalization logic.780781if (sessionTime < startOfToday && sessionTime >= startOfYesterday) {782return localize('date.fromNow.days.singular', '1 day');783}784785if (sessionTime < startOfYesterday && sessionTime >= startOfTwoDaysAgo) {786return localize('date.fromNow.days.multiple', '2 days');787}788789return fromNow(sessionTime, false);790}791792export class AgentSessionsIdentityProvider implements IIdentityProvider<IAgentSessionsModel | AgentSessionListItem> {793794getId(element: IAgentSessionsModel | AgentSessionListItem): string {795if (isAgentSessionSection(element)) {796return `section-${element.section}`;797}798799if (isAgentSession(element)) {800return element.resource.toString();801}802803return 'agent-sessions-id';804}805806getGroupId(element: IAgentSessionsModel | AgentSessionListItem): number | NotSelectableGroupIdType {807if (isAgentSessionSection(element) || isAgentSessionsModel(element)) {808return NotSelectableGroupId;809}810return 1;811}812}813814export class AgentSessionsCompressionDelegate implements ITreeCompressionDelegate<AgentSessionListItem> {815816isIncompressible(element: AgentSessionListItem): boolean {817return true;818}819}820821export interface IAgentSessionsSorterOptions {822overrideCompare?(sessionA: IAgentSession, sessionB: IAgentSession): number | undefined;823}824825export class AgentSessionsSorter implements ITreeSorter<IAgentSession> {826827constructor(private readonly options?: IAgentSessionsSorterOptions) { }828829compare(sessionA: IAgentSession, sessionB: IAgentSession): number {830831// Input Needed832const aNeedsInput = sessionA.status === AgentSessionStatus.NeedsInput;833const bNeedsInput = sessionB.status === AgentSessionStatus.NeedsInput;834835if (aNeedsInput && !bNeedsInput) {836return -1; // a (needs input) comes before b (other)837}838if (!aNeedsInput && bNeedsInput) {839return 1; // a (other) comes after b (needs input)840}841842// In Progress843const aInProgress = sessionA.status === AgentSessionStatus.InProgress;844const bInProgress = sessionB.status === AgentSessionStatus.InProgress;845846if (aInProgress && !bInProgress) {847return -1; // a (in-progress) comes before b (finished)848}849if (!aInProgress && bInProgress) {850return 1; // a (finished) comes after b (in-progress)851}852853// Archived854const aArchived = sessionA.isArchived();855const bArchived = sessionB.isArchived();856857if (!aArchived && bArchived) {858return -1; // a (non-archived) comes before b (archived)859}860if (aArchived && !bArchived) {861return 1; // a (archived) comes after b (non-archived)862}863864// Before we compare by time, allow override865const override = this.options?.overrideCompare?.(sessionA, sessionB);866if (typeof override === 'number') {867return override;868}869870//Sort by end or start time (most recent first)871const timeA = getAgentSessionTime(sessionA.timing);872const timeB = getAgentSessionTime(sessionB.timing);873return timeB - timeA;874}875}876877export class AgentSessionsKeyboardNavigationLabelProvider implements ICompressibleKeyboardNavigationLabelProvider<AgentSessionListItem> {878879getKeyboardNavigationLabel(element: AgentSessionListItem): string {880if (isAgentSessionSection(element)) {881return element.label;882}883884return element.label;885}886887getCompressedNodeKeyboardNavigationLabel(elements: AgentSessionListItem[]): { toString(): string | undefined } | undefined {888return undefined; // not enabled889}890}891892export class AgentSessionsDragAndDrop extends Disposable implements ITreeDragAndDrop<AgentSessionListItem> {893894constructor(895@IInstantiationService private readonly instantiationService: IInstantiationService896) {897super();898}899900onDragStart(data: IDragAndDropData, originalEvent: DragEvent): void {901const elements = (data.getData() as AgentSessionListItem[]).filter(e => isAgentSession(e));902const uris = coalesce(elements.map(e => e.resource));903this.instantiationService.invokeFunction(accessor => fillEditorsDragData(accessor, uris, originalEvent));904}905906getDragURI(element: AgentSessionListItem): string | null {907if (isAgentSessionSection(element)) {908return null; // section headers are not draggable909}910911return element.resource.toString();912}913914getDragLabel?(elements: AgentSessionListItem[], originalEvent: DragEvent): string | undefined {915const sessions = elements.filter(e => isAgentSession(e));916if (sessions.length === 1) {917return sessions[0].label;918}919920return localize('agentSessions.dragLabel', "{0} agent sessions", sessions.length);921}922923onDragOver(data: IDragAndDropData, targetElement: AgentSessionListItem | undefined, targetIndex: number | undefined, targetSector: ListViewTargetSector | undefined, originalEvent: DragEvent): boolean | ITreeDragOverReaction {924return false;925}926927drop(data: IDragAndDropData, targetElement: AgentSessionListItem | undefined, targetIndex: number | undefined, targetSector: ListViewTargetSector | undefined, originalEvent: DragEvent): void { }928}929930931