Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts
5283 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import './media/agentsessionsviewer.css';
7
import { h } from '../../../../../base/browser/dom.js';
8
import { localize } from '../../../../../nls.js';
9
import { IIdentityProvider, IListVirtualDelegate, NotSelectableGroupId, NotSelectableGroupIdType } from '../../../../../base/browser/ui/list/list.js';
10
import { AriaRole } from '../../../../../base/browser/ui/aria/aria.js';
11
import { IListAccessibilityProvider } from '../../../../../base/browser/ui/list/listWidget.js';
12
import { ITreeCompressionDelegate } from '../../../../../base/browser/ui/tree/asyncDataTree.js';
13
import { ICompressedTreeNode } from '../../../../../base/browser/ui/tree/compressedObjectTreeModel.js';
14
import { ICompressibleKeyboardNavigationLabelProvider, ICompressibleTreeRenderer } from '../../../../../base/browser/ui/tree/objectTree.js';
15
import { ITreeNode, ITreeElementRenderDetails, IAsyncDataSource, ITreeSorter, ITreeDragAndDrop, ITreeDragOverReaction } from '../../../../../base/browser/ui/tree/tree.js';
16
import { Disposable, DisposableStore, IDisposable, MutableDisposable } from '../../../../../base/common/lifecycle.js';
17
import { AgentSessionSection, AgentSessionStatus, getAgentChangesSummary, hasValidDiff, IAgentSession, IAgentSessionSection, IAgentSessionsModel, isAgentSession, isAgentSessionSection, isAgentSessionsModel, isSessionInProgressStatus } from './agentSessionsModel.js';
18
import { IconLabel } from '../../../../../base/browser/ui/iconLabel/iconLabel.js';
19
import { ThemeIcon } from '../../../../../base/common/themables.js';
20
import { Codicon } from '../../../../../base/common/codicons.js';
21
import { fromNow, getDurationString } from '../../../../../base/common/date.js';
22
import { FuzzyScore, createMatches } from '../../../../../base/common/filters.js';
23
import { IMarkdownRendererService } from '../../../../../platform/markdown/browser/markdownRenderer.js';
24
import { allowedChatMarkdownHtmlTags } from '../widget/chatContentMarkdownRenderer.js';
25
import { IProductService } from '../../../../../platform/product/common/productService.js';
26
import { IDragAndDropData } from '../../../../../base/browser/dnd.js';
27
import { ListViewTargetSector } from '../../../../../base/browser/ui/list/listView.js';
28
import { coalesce } from '../../../../../base/common/arrays.js';
29
import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';
30
import { fillEditorsDragData } from '../../../../browser/dnd.js';
31
import { HoverStyle, IDelayedHoverOptions } from '../../../../../base/browser/ui/hover/hover.js';
32
import { HoverPosition } from '../../../../../base/browser/ui/hover/hoverWidget.js';
33
import { IHoverService } from '../../../../../platform/hover/browser/hover.js';
34
import { IntervalTimer } from '../../../../../base/common/async.js';
35
import { MenuWorkbenchToolBar } from '../../../../../platform/actions/browser/toolbar.js';
36
import { MenuId } from '../../../../../platform/actions/common/actions.js';
37
import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js';
38
import { ChatContextKeys } from '../../common/actions/chatContextKeys.js';
39
import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js';
40
import { Event } from '../../../../../base/common/event.js';
41
import { renderAsPlaintext } from '../../../../../base/browser/markdownRenderer.js';
42
import { MarkdownString, IMarkdownString } from '../../../../../base/common/htmlContent.js';
43
import { AgentSessionHoverWidget } from './agentSessionHoverWidget.js';
44
import { AgentSessionProviders, getAgentSessionTime } from './agentSessions.js';
45
import { AgentSessionsGrouping } from './agentSessionsFilter.js';
46
47
export type AgentSessionListItem = IAgentSession | IAgentSessionSection;
48
49
//#region Agent Session Renderer
50
51
interface IAgentSessionItemTemplate {
52
readonly element: HTMLElement;
53
54
// Column 1
55
readonly icon: HTMLElement;
56
57
// Column 2 Row 1
58
readonly title: IconLabel;
59
readonly statusContainer: HTMLElement;
60
readonly statusProviderIcon: HTMLElement;
61
readonly statusTime: HTMLElement;
62
readonly titleToolbar: MenuWorkbenchToolBar;
63
64
// Column 2 Row 2
65
readonly diffContainer: HTMLElement;
66
readonly diffAddedSpan: HTMLSpanElement;
67
readonly diffRemovedSpan: HTMLSpanElement;
68
69
readonly badge: HTMLElement;
70
readonly separator: HTMLElement;
71
readonly description: HTMLElement;
72
73
readonly contextKeyService: IContextKeyService;
74
readonly elementDisposable: DisposableStore;
75
readonly disposables: IDisposable;
76
}
77
78
export interface IAgentSessionRendererOptions {
79
getHoverPosition(): HoverPosition;
80
}
81
82
export class AgentSessionRenderer extends Disposable implements ICompressibleTreeRenderer<IAgentSession, FuzzyScore, IAgentSessionItemTemplate> {
83
84
static readonly TEMPLATE_ID = 'agent-session';
85
86
readonly templateId = AgentSessionRenderer.TEMPLATE_ID;
87
88
private readonly sessionHover = this._register(new MutableDisposable<AgentSessionHoverWidget>());
89
90
constructor(
91
private readonly options: IAgentSessionRendererOptions,
92
@IMarkdownRendererService private readonly markdownRendererService: IMarkdownRendererService,
93
@IProductService private readonly productService: IProductService,
94
@IHoverService private readonly hoverService: IHoverService,
95
@IInstantiationService private readonly instantiationService: IInstantiationService,
96
@IContextKeyService private readonly contextKeyService: IContextKeyService,
97
) {
98
super();
99
}
100
101
renderTemplate(container: HTMLElement): IAgentSessionItemTemplate {
102
const disposables = new DisposableStore();
103
const elementDisposable = disposables.add(new DisposableStore());
104
105
const elements = h(
106
'div.agent-session-item@item',
107
[
108
h('div.agent-session-icon-col', [
109
h('div.agent-session-icon@icon')
110
]),
111
h('div.agent-session-main-col', [
112
h('div.agent-session-title-row', [
113
h('div.agent-session-title@title'),
114
h('div.agent-session-status@statusContainer', [
115
h('span.agent-session-status-provider-icon@statusProviderIcon'),
116
h('span.agent-session-status-time@statusTime')
117
]),
118
h('div.agent-session-title-toolbar@titleToolbar'),
119
]),
120
h('div.agent-session-details-row', [
121
h('div.agent-session-diff-container@diffContainer',
122
[
123
h('span.agent-session-diff-added@addedSpan'),
124
h('span.agent-session-diff-removed@removedSpan')
125
]),
126
h('div.agent-session-badge@badge'),
127
h('span.agent-session-separator@separator'),
128
h('div.agent-session-description@description'),
129
])
130
])
131
]
132
);
133
134
const contextKeyService = disposables.add(this.contextKeyService.createScoped(elements.item));
135
const scopedInstantiationService = disposables.add(this.instantiationService.createChild(new ServiceCollection([IContextKeyService, contextKeyService])));
136
const titleToolbar = disposables.add(scopedInstantiationService.createInstance(MenuWorkbenchToolBar, elements.titleToolbar, MenuId.AgentSessionItemToolbar, {
137
menuOptions: { shouldForwardArgs: true },
138
}));
139
140
container.appendChild(elements.item);
141
142
return {
143
element: elements.item,
144
icon: elements.icon,
145
title: disposables.add(new IconLabel(elements.title, { supportHighlights: true, supportIcons: true })),
146
titleToolbar,
147
diffContainer: elements.diffContainer,
148
diffAddedSpan: elements.addedSpan,
149
diffRemovedSpan: elements.removedSpan,
150
badge: elements.badge,
151
separator: elements.separator,
152
description: elements.description,
153
statusContainer: elements.statusContainer,
154
statusProviderIcon: elements.statusProviderIcon,
155
statusTime: elements.statusTime,
156
contextKeyService,
157
elementDisposable,
158
disposables
159
};
160
}
161
162
renderElement(session: ITreeNode<IAgentSession, FuzzyScore>, index: number, template: IAgentSessionItemTemplate, details?: ITreeElementRenderDetails): void {
163
164
// Clear old state
165
template.elementDisposable.clear();
166
template.diffAddedSpan.textContent = '';
167
template.diffRemovedSpan.textContent = '';
168
template.badge.textContent = '';
169
template.description.textContent = '';
170
171
// Archived
172
template.element.classList.toggle('archived', session.element.isArchived());
173
174
// Icon
175
template.icon.className = `agent-session-icon ${ThemeIcon.asClassName(this.getIcon(session.element))}`;
176
177
// Title
178
const markdownTitle = new MarkdownString(session.element.label);
179
template.title.setLabel(renderAsPlaintext(markdownTitle), undefined, { matches: createMatches(session.filterData) });
180
181
// Title Actions - Update context keys
182
ChatContextKeys.isArchivedAgentSession.bindTo(template.contextKeyService).set(session.element.isArchived());
183
ChatContextKeys.isReadAgentSession.bindTo(template.contextKeyService).set(session.element.isRead());
184
ChatContextKeys.agentSessionType.bindTo(template.contextKeyService).set(session.element.providerType);
185
template.titleToolbar.context = session.element;
186
187
// Diff information
188
let hasDiff = false;
189
const { changes: diff } = session.element;
190
if (!isSessionInProgressStatus(session.element.status) && diff && hasValidDiff(diff)) {
191
if (this.renderDiff(session, template)) {
192
hasDiff = true;
193
}
194
}
195
template.diffContainer.classList.toggle('has-diff', hasDiff);
196
197
let hasAgentSessionChanges = false;
198
if (
199
session.element.providerType === AgentSessionProviders.Background ||
200
session.element.providerType === AgentSessionProviders.Cloud
201
) {
202
// Background and Cloud agents provide the list of changes directly,
203
// so we have to use the list of changes to determine whether to show
204
// the "View All Changes" action
205
hasAgentSessionChanges = Array.isArray(diff) && diff.length > 0;
206
} else {
207
hasAgentSessionChanges = hasDiff;
208
}
209
210
ChatContextKeys.hasAgentSessionChanges.bindTo(template.contextKeyService).set(hasAgentSessionChanges);
211
212
// Badge
213
const hasBadge = this.renderBadge(session, template);
214
template.badge.classList.toggle('has-badge', hasBadge);
215
216
// Description (unless diff is shown)
217
if (!hasDiff) {
218
this.renderDescription(session, template, hasBadge);
219
}
220
221
// Separator (dot between badge and description)
222
template.separator.classList.toggle('has-separator', hasBadge && !hasDiff);
223
224
// Status
225
this.renderStatus(session, template);
226
227
// Hover
228
this.renderHover(session, template);
229
}
230
231
private renderBadge(session: ITreeNode<IAgentSession, FuzzyScore>, template: IAgentSessionItemTemplate): boolean {
232
const badge = session.element.badge;
233
if (badge) {
234
this.renderMarkdownOrText(badge, template.badge, template.elementDisposable);
235
}
236
237
return !!badge;
238
}
239
240
private renderMarkdownOrText(content: string | IMarkdownString, container: HTMLElement, disposables: DisposableStore): void {
241
if (typeof content === 'string') {
242
container.textContent = content;
243
} else {
244
disposables.add(this.markdownRendererService.render(content, {
245
sanitizerConfig: {
246
replaceWithPlaintext: true,
247
allowedTags: {
248
override: allowedChatMarkdownHtmlTags,
249
},
250
allowedLinkSchemes: { augment: [this.productService.urlProtocol] }
251
},
252
}, container));
253
}
254
}
255
256
private renderDiff(session: ITreeNode<IAgentSession, FuzzyScore>, template: IAgentSessionItemTemplate): boolean {
257
const diff = getAgentChangesSummary(session.element.changes);
258
if (!diff) {
259
return false;
260
}
261
262
if (diff.insertions >= 0 /* render even `0` for more homogeneity */) {
263
template.diffAddedSpan.textContent = `+${diff.insertions}`;
264
}
265
266
if (diff.deletions >= 0 /* render even `0` for more homogeneity */) {
267
template.diffRemovedSpan.textContent = `-${diff.deletions}`;
268
}
269
270
return true;
271
}
272
273
private getIcon(session: IAgentSession): ThemeIcon {
274
if (session.status === AgentSessionStatus.InProgress) {
275
return Codicon.sessionInProgress;
276
}
277
278
if (session.status === AgentSessionStatus.NeedsInput) {
279
return Codicon.report;
280
}
281
282
if (session.status === AgentSessionStatus.Failed) {
283
return Codicon.error;
284
}
285
286
if (!session.isRead() && !session.isArchived()) {
287
return Codicon.circleFilled;
288
}
289
290
return Codicon.circleSmallFilled;
291
}
292
293
private renderDescription(session: ITreeNode<IAgentSession, FuzzyScore>, template: IAgentSessionItemTemplate, hasBadge: boolean): void {
294
const description = session.element.description;
295
if (description) {
296
this.renderMarkdownOrText(description, template.description, template.elementDisposable);
297
}
298
299
// Fallback to state label
300
else {
301
if (session.element.status === AgentSessionStatus.InProgress) {
302
template.description.textContent = localize('chat.session.status.inProgress', "Working...");
303
} else if (session.element.status === AgentSessionStatus.NeedsInput) {
304
template.description.textContent = localize('chat.session.status.needsInput', "Input needed.");
305
} else if (hasBadge && session.element.status === AgentSessionStatus.Completed) {
306
template.description.textContent = ''; // no description if completed and has badge
307
} else if (
308
session.element.timing.lastRequestEnded &&
309
session.element.timing.lastRequestStarted &&
310
session.element.timing.lastRequestEnded > session.element.timing.lastRequestStarted
311
) {
312
const duration = this.toDuration(session.element.timing.lastRequestStarted, session.element.timing.lastRequestEnded, false, true);
313
314
template.description.textContent = session.element.status === AgentSessionStatus.Failed ?
315
localize('chat.session.status.failedAfter', "Failed after {0}", duration) :
316
localize('chat.session.status.completedAfter', "Completed in {0}", duration);
317
} else {
318
template.description.textContent = session.element.status === AgentSessionStatus.Failed ?
319
localize('chat.session.status.failed', "Failed") :
320
localize('chat.session.status.completed', "Completed");
321
}
322
}
323
}
324
325
private toDuration(startTime: number, endTime: number, useFullTimeWords: boolean, disallowNow: boolean): string {
326
const elapsed = Math.max(Math.round((endTime - startTime) / 1000) * 1000, 1000 /* clamp to 1s */);
327
if (!disallowNow && elapsed < 60000) {
328
return localize('secondsDuration', "now");
329
}
330
331
return getDurationString(elapsed, useFullTimeWords);
332
}
333
334
private renderStatus(session: ITreeNode<IAgentSession, FuzzyScore>, template: IAgentSessionItemTemplate): void {
335
336
const getTimeLabel = (session: IAgentSession) => {
337
let timeLabel: string | undefined;
338
if (session.status === AgentSessionStatus.InProgress && session.timing.lastRequestStarted) {
339
timeLabel = this.toDuration(session.timing.lastRequestStarted, Date.now(), false, false);
340
}
341
342
if (!timeLabel) {
343
const date = getAgentSessionTime(session.timing);
344
const seconds = Math.round((new Date().getTime() - date) / 1000);
345
if (seconds < 60) {
346
timeLabel = localize('secondsDuration', "now");
347
} else {
348
timeLabel = sessionDateFromNow(date);
349
}
350
}
351
352
return timeLabel;
353
};
354
355
// Provider icon (only shown for non-local sessions)
356
const isLocal = session.element.providerType === AgentSessionProviders.Local;
357
template.statusProviderIcon.className = isLocal ? '' : `agent-session-status-provider-icon ${ThemeIcon.asClassName(session.element.icon)}`;
358
359
// Time label
360
template.statusTime.textContent = getTimeLabel(session.element);
361
const timer = template.elementDisposable.add(new IntervalTimer());
362
timer.cancelAndSet(() => template.statusTime.textContent = getTimeLabel(session.element), session.element.status === AgentSessionStatus.InProgress ? 1000 /* every second */ : 60 * 1000 /* every minute */);
363
}
364
365
private renderHover(session: ITreeNode<IAgentSession, FuzzyScore>, template: IAgentSessionItemTemplate): void {
366
if (!isSessionInProgressStatus(session.element.status) && session.element.isRead()) {
367
return; // the hover is complex and large, for now limit it to in-progress sessions only
368
}
369
370
const reducedDelay = session.element.status === AgentSessionStatus.NeedsInput;
371
template.elementDisposable.add(
372
this.hoverService.setupDelayedHover(template.element, () => this.buildHoverContent(session.element), { groupId: 'agent.sessions', reducedDelay })
373
);
374
}
375
376
private buildHoverContent(session: IAgentSession): IDelayedHoverOptions {
377
if (this.sessionHover.value?.session.resource.toString() !== session.resource.toString()) {
378
// note: hover service use mouseover which triggers again if the mouse moves
379
// within the element. Only recreate the hover widget if the session changed.
380
this.sessionHover.value = this.instantiationService.createInstance(AgentSessionHoverWidget, session);
381
}
382
383
const widget = this.sessionHover.value;
384
return {
385
id: `agent.session.hover.${session.resource.toString()}`,
386
content: widget.domNode,
387
style: HoverStyle.Pointer,
388
onDidShow: () => widget.onRendered(),
389
position: {
390
hoverPosition: this.options.getHoverPosition()
391
}
392
};
393
}
394
395
renderCompressedElements(node: ITreeNode<ICompressedTreeNode<IAgentSession>, FuzzyScore>, index: number, templateData: IAgentSessionItemTemplate, details?: ITreeElementRenderDetails): void {
396
throw new Error('Should never happen since session is incompressible');
397
}
398
399
disposeElement(element: ITreeNode<IAgentSession, FuzzyScore>, index: number, template: IAgentSessionItemTemplate, details?: ITreeElementRenderDetails): void {
400
template.elementDisposable.clear();
401
}
402
403
disposeTemplate(templateData: IAgentSessionItemTemplate): void {
404
templateData.disposables.dispose();
405
}
406
}
407
408
export function toStatusLabel(status: AgentSessionStatus): string {
409
let statusLabel: string;
410
switch (status) {
411
case AgentSessionStatus.NeedsInput:
412
statusLabel = localize('agentSessionNeedsInput', "Needs Input");
413
break;
414
case AgentSessionStatus.InProgress:
415
statusLabel = localize('agentSessionInProgress', "In Progress");
416
break;
417
case AgentSessionStatus.Failed:
418
statusLabel = localize('agentSessionFailed', "Failed");
419
break;
420
default:
421
statusLabel = localize('agentSessionCompleted', "Completed");
422
}
423
424
return statusLabel;
425
}
426
427
//#endregion
428
429
//#region Section Header Renderer
430
431
interface IAgentSessionSectionTemplate {
432
readonly container: HTMLElement;
433
readonly label: HTMLSpanElement;
434
readonly toolbar: MenuWorkbenchToolBar;
435
readonly contextKeyService: IContextKeyService;
436
readonly disposables: IDisposable;
437
}
438
439
export class AgentSessionSectionRenderer implements ICompressibleTreeRenderer<IAgentSessionSection, FuzzyScore, IAgentSessionSectionTemplate> {
440
441
static readonly TEMPLATE_ID = 'agent-session-section';
442
443
readonly templateId = AgentSessionSectionRenderer.TEMPLATE_ID;
444
445
constructor(
446
@IInstantiationService private readonly instantiationService: IInstantiationService,
447
@IContextKeyService private readonly contextKeyService: IContextKeyService,
448
) { }
449
450
renderTemplate(container: HTMLElement): IAgentSessionSectionTemplate {
451
const disposables = new DisposableStore();
452
453
const elements = h(
454
'div.agent-session-section@container',
455
[
456
h('span.agent-session-section-label@label'),
457
h('div.agent-session-section-toolbar@toolbar')
458
]
459
);
460
461
const contextKeyService = disposables.add(this.contextKeyService.createScoped(elements.container));
462
const scopedInstantiationService = disposables.add(this.instantiationService.createChild(new ServiceCollection([IContextKeyService, contextKeyService])));
463
const toolbar = disposables.add(scopedInstantiationService.createInstance(MenuWorkbenchToolBar, elements.toolbar, MenuId.AgentSessionSectionToolbar, {
464
menuOptions: { shouldForwardArgs: true },
465
}));
466
467
container.appendChild(elements.container);
468
469
return {
470
container: elements.container,
471
label: elements.label,
472
toolbar,
473
contextKeyService,
474
disposables
475
};
476
}
477
478
renderElement(element: ITreeNode<IAgentSessionSection, FuzzyScore>, index: number, template: IAgentSessionSectionTemplate, details?: ITreeElementRenderDetails): void {
479
480
// Label
481
template.label.textContent = element.element.label;
482
483
// Toolbar
484
ChatContextKeys.agentSessionSection.bindTo(template.contextKeyService).set(element.element.section);
485
template.toolbar.context = element.element;
486
}
487
488
renderCompressedElements(node: ITreeNode<ICompressedTreeNode<IAgentSessionSection>, FuzzyScore>, index: number, templateData: IAgentSessionSectionTemplate, details?: ITreeElementRenderDetails): void {
489
throw new Error('Should never happen since section header is incompressible');
490
}
491
492
disposeElement(element: ITreeNode<IAgentSessionSection, FuzzyScore>, index: number, template: IAgentSessionSectionTemplate, details?: ITreeElementRenderDetails): void {
493
// noop
494
}
495
496
disposeTemplate(templateData: IAgentSessionSectionTemplate): void {
497
templateData.disposables.dispose();
498
}
499
}
500
501
//#endregion
502
503
export class AgentSessionsListDelegate implements IListVirtualDelegate<AgentSessionListItem> {
504
505
static readonly ITEM_HEIGHT = 44;
506
static readonly SECTION_HEIGHT = 26;
507
508
getHeight(element: AgentSessionListItem): number {
509
if (isAgentSessionSection(element)) {
510
return AgentSessionsListDelegate.SECTION_HEIGHT;
511
}
512
513
return AgentSessionsListDelegate.ITEM_HEIGHT;
514
}
515
516
getTemplateId(element: AgentSessionListItem): string {
517
if (isAgentSessionSection(element)) {
518
return AgentSessionSectionRenderer.TEMPLATE_ID;
519
}
520
521
return AgentSessionRenderer.TEMPLATE_ID;
522
}
523
}
524
525
export class AgentSessionsAccessibilityProvider implements IListAccessibilityProvider<AgentSessionListItem> {
526
527
getWidgetRole(): AriaRole {
528
return 'list';
529
}
530
531
getRole(element: AgentSessionListItem): AriaRole | undefined {
532
return 'listitem';
533
}
534
535
getWidgetAriaLabel(): string {
536
return localize('agentSessions', "Agent Sessions");
537
}
538
539
getAriaLabel(element: AgentSessionListItem): string | null {
540
if (isAgentSessionSection(element)) {
541
return localize('agentSessionSectionAriaLabel', "{0} sessions section", element.label);
542
}
543
544
return localize('agentSessionItemAriaLabel', "{0} session {1} ({2}), created {3}", element.providerLabel, element.label, toStatusLabel(element.status), new Date(element.timing.created).toLocaleString());
545
}
546
}
547
548
export interface IAgentSessionsFilterExcludes {
549
readonly providers: readonly string[];
550
readonly states: readonly AgentSessionStatus[];
551
552
readonly archived: boolean;
553
readonly read: boolean;
554
}
555
556
export interface IAgentSessionsFilter {
557
558
/**
559
* An event that fires when the filter changes and sessions
560
* should be re-evaluated.
561
*/
562
readonly onDidChange: Event<void>;
563
564
/**
565
* Optional limit on the number of sessions to show.
566
*/
567
readonly limitResults?: () => number | undefined;
568
569
/**
570
* Whether to show section headers to group sessions.
571
* When undefined, sessions are shown as a flat list.
572
*/
573
readonly groupResults?: () => AgentSessionsGrouping | undefined;
574
575
/**
576
* A callback to notify the filter about the number of
577
* results after filtering.
578
*/
579
notifyResults?(count: number): void;
580
581
/**
582
* The logic to exclude sessions from the view.
583
*/
584
exclude(session: IAgentSession): boolean;
585
586
/**
587
* Get the current filter excludes for display in the UI.
588
*/
589
getExcludes(): IAgentSessionsFilterExcludes;
590
}
591
592
export class AgentSessionsDataSource implements IAsyncDataSource<IAgentSessionsModel, AgentSessionListItem> {
593
594
private static readonly CAPPED_SESSIONS_LIMIT = 3;
595
596
constructor(
597
private readonly filter: IAgentSessionsFilter | undefined,
598
private readonly sorter: ITreeSorter<IAgentSession>,
599
) { }
600
601
hasChildren(element: IAgentSessionsModel | AgentSessionListItem): boolean {
602
603
// Sessions model
604
if (isAgentSessionsModel(element)) {
605
return true;
606
}
607
608
// Sessions section
609
else if (isAgentSessionSection(element)) {
610
return element.sessions.length > 0;
611
}
612
613
// Session element
614
else {
615
return false;
616
}
617
}
618
619
getChildren(element: IAgentSessionsModel | AgentSessionListItem): Iterable<AgentSessionListItem> {
620
621
// Sessions model
622
if (isAgentSessionsModel(element)) {
623
624
// Apply filter if configured
625
let filteredSessions = element.sessions.filter(session => !this.filter?.exclude(session));
626
627
// Apply sorter unless we group into sections or we are to limit results
628
const limitResultsCount = this.filter?.limitResults?.();
629
if (!this.filter?.groupResults?.() || typeof limitResultsCount === 'number') {
630
filteredSessions.sort(this.sorter.compare.bind(this.sorter));
631
}
632
633
// Apply limiter if configured (requires sorting)
634
if (typeof limitResultsCount === 'number') {
635
filteredSessions = filteredSessions.slice(0, limitResultsCount);
636
}
637
638
// Callback results count
639
this.filter?.notifyResults?.(filteredSessions.length);
640
641
// Group sessions into sections if enabled
642
if (this.filter?.groupResults?.()) {
643
return this.groupSessionsIntoSections(filteredSessions);
644
}
645
646
// Otherwise return flat sorted list
647
return filteredSessions;
648
}
649
650
// Sessions section
651
else if (isAgentSessionSection(element)) {
652
return element.sessions;
653
}
654
655
// Session element
656
else {
657
return [];
658
}
659
}
660
661
private groupSessionsIntoSections(sessions: IAgentSession[]): AgentSessionListItem[] {
662
const sortedSessions = sessions.sort(this.sorter.compare.bind(this.sorter));
663
664
if (this.filter?.groupResults?.() === AgentSessionsGrouping.Capped) {
665
if (this.filter?.getExcludes().read) {
666
return sortedSessions; // When filtering to show only unread sessions, show a flat list
667
}
668
669
return this.groupSessionsCapped(sortedSessions);
670
} else {
671
return this.groupSessionsByDate(sortedSessions);
672
}
673
}
674
675
private groupSessionsCapped(sortedSessions: IAgentSession[]): AgentSessionListItem[] {
676
const result: AgentSessionListItem[] = [];
677
678
const firstArchivedIndex = sortedSessions.findIndex(session => session.isArchived());
679
const nonArchivedCount = firstArchivedIndex === -1 ? sortedSessions.length : firstArchivedIndex;
680
681
const topSessions = sortedSessions.slice(0, Math.min(AgentSessionsDataSource.CAPPED_SESSIONS_LIMIT, nonArchivedCount));
682
const othersSessions = sortedSessions.slice(topSessions.length);
683
684
// Add top sessions directly (no section header)
685
result.push(...topSessions);
686
687
// Add "More" section for the rest
688
if (othersSessions.length > 0) {
689
result.push({
690
section: AgentSessionSection.More,
691
label: localize('agentSessions.moreSectionWithCount', "More ({0})", othersSessions.length),
692
sessions: othersSessions
693
});
694
}
695
696
return result;
697
}
698
699
private groupSessionsByDate(sortedSessions: IAgentSession[]): AgentSessionListItem[] {
700
const result: AgentSessionListItem[] = [];
701
const groupedSessions = groupAgentSessionsByDate(sortedSessions);
702
703
for (const { sessions, section, label } of groupedSessions.values()) {
704
if (sessions.length === 0) {
705
continue;
706
}
707
708
result.push({ section, label, sessions });
709
}
710
711
return result;
712
}
713
}
714
715
export const AgentSessionSectionLabels = {
716
[AgentSessionSection.InProgress]: localize('agentSessions.inProgressSection', "In progress"),
717
[AgentSessionSection.Today]: localize('agentSessions.todaySection', "Today"),
718
[AgentSessionSection.Yesterday]: localize('agentSessions.yesterdaySection', "Yesterday"),
719
[AgentSessionSection.Week]: localize('agentSessions.weekSection', "Last 7 days"),
720
[AgentSessionSection.Older]: localize('agentSessions.olderSection', "Older"),
721
[AgentSessionSection.Archived]: localize('agentSessions.archivedSection', "Archived"),
722
[AgentSessionSection.More]: localize('agentSessions.moreSection', "More"),
723
};
724
725
const DAY_THRESHOLD = 24 * 60 * 60 * 1000;
726
const WEEK_THRESHOLD = 7 * DAY_THRESHOLD;
727
728
export function groupAgentSessionsByDate(sessions: IAgentSession[]): Map<AgentSessionSection, IAgentSessionSection> {
729
const now = Date.now();
730
const startOfToday = new Date(now).setHours(0, 0, 0, 0);
731
const startOfYesterday = startOfToday - DAY_THRESHOLD;
732
const weekThreshold = now - WEEK_THRESHOLD;
733
734
const inProgressSessions: IAgentSession[] = [];
735
const todaySessions: IAgentSession[] = [];
736
const yesterdaySessions: IAgentSession[] = [];
737
const weekSessions: IAgentSession[] = [];
738
const olderSessions: IAgentSession[] = [];
739
const archivedSessions: IAgentSession[] = [];
740
741
for (const session of sessions) {
742
if (session.isArchived()) {
743
archivedSessions.push(session);
744
} else if (isSessionInProgressStatus(session.status)) {
745
inProgressSessions.push(session);
746
} else {
747
const sessionTime = getAgentSessionTime(session.timing);
748
if (sessionTime >= startOfToday) {
749
todaySessions.push(session);
750
} else if (sessionTime >= startOfYesterday) {
751
yesterdaySessions.push(session);
752
} else if (sessionTime >= weekThreshold) {
753
weekSessions.push(session);
754
} else {
755
olderSessions.push(session);
756
}
757
}
758
}
759
760
return new Map<AgentSessionSection, IAgentSessionSection>([
761
[AgentSessionSection.InProgress, { section: AgentSessionSection.InProgress, label: AgentSessionSectionLabels[AgentSessionSection.InProgress], sessions: inProgressSessions }],
762
[AgentSessionSection.Today, { section: AgentSessionSection.Today, label: AgentSessionSectionLabels[AgentSessionSection.Today], sessions: todaySessions }],
763
[AgentSessionSection.Yesterday, { section: AgentSessionSection.Yesterday, label: AgentSessionSectionLabels[AgentSessionSection.Yesterday], sessions: yesterdaySessions }],
764
[AgentSessionSection.Week, { section: AgentSessionSection.Week, label: AgentSessionSectionLabels[AgentSessionSection.Week], sessions: weekSessions }],
765
[AgentSessionSection.Older, { section: AgentSessionSection.Older, label: AgentSessionSectionLabels[AgentSessionSection.Older], sessions: olderSessions }],
766
[AgentSessionSection.Archived, { section: AgentSessionSection.Archived, label: localize('agentSessions.archivedSectionWithCount', "Archived ({0})", archivedSessions.length), sessions: archivedSessions }],
767
]);
768
}
769
770
export function sessionDateFromNow(sessionTime: number): string {
771
const now = Date.now();
772
const startOfToday = new Date(now).setHours(0, 0, 0, 0);
773
const startOfYesterday = startOfToday - DAY_THRESHOLD;
774
const startOfTwoDaysAgo = startOfYesterday - DAY_THRESHOLD;
775
776
// our grouping by date uses absolute start times for "Today"
777
// and "Yesterday" while `fromNow` only works with full 24h
778
// and 48h ranges for these. To prevent a label like "1 day ago"
779
// to show under the "Last 7 Days" section, we do a bit of
780
// normalization logic.
781
782
if (sessionTime < startOfToday && sessionTime >= startOfYesterday) {
783
return localize('date.fromNow.days.singular', '1 day');
784
}
785
786
if (sessionTime < startOfYesterday && sessionTime >= startOfTwoDaysAgo) {
787
return localize('date.fromNow.days.multiple', '2 days');
788
}
789
790
return fromNow(sessionTime, false);
791
}
792
793
export class AgentSessionsIdentityProvider implements IIdentityProvider<IAgentSessionsModel | AgentSessionListItem> {
794
795
getId(element: IAgentSessionsModel | AgentSessionListItem): string {
796
if (isAgentSessionSection(element)) {
797
return `section-${element.section}`;
798
}
799
800
if (isAgentSession(element)) {
801
return element.resource.toString();
802
}
803
804
return 'agent-sessions-id';
805
}
806
807
getGroupId(element: IAgentSessionsModel | AgentSessionListItem): number | NotSelectableGroupIdType {
808
if (isAgentSessionSection(element) || isAgentSessionsModel(element)) {
809
return NotSelectableGroupId;
810
}
811
return 1;
812
}
813
}
814
815
export class AgentSessionsCompressionDelegate implements ITreeCompressionDelegate<AgentSessionListItem> {
816
817
isIncompressible(element: AgentSessionListItem): boolean {
818
return true;
819
}
820
}
821
822
export interface IAgentSessionsSorterOptions {
823
overrideCompare?(sessionA: IAgentSession, sessionB: IAgentSession): number | undefined;
824
}
825
826
export class AgentSessionsSorter implements ITreeSorter<IAgentSession> {
827
828
constructor(private readonly options?: IAgentSessionsSorterOptions) { }
829
830
compare(sessionA: IAgentSession, sessionB: IAgentSession): number {
831
832
// Input Needed
833
const aNeedsInput = sessionA.status === AgentSessionStatus.NeedsInput;
834
const bNeedsInput = sessionB.status === AgentSessionStatus.NeedsInput;
835
836
if (aNeedsInput && !bNeedsInput) {
837
return -1; // a (needs input) comes before b (other)
838
}
839
if (!aNeedsInput && bNeedsInput) {
840
return 1; // a (other) comes after b (needs input)
841
}
842
843
// In Progress
844
const aInProgress = sessionA.status === AgentSessionStatus.InProgress;
845
const bInProgress = sessionB.status === AgentSessionStatus.InProgress;
846
847
if (aInProgress && !bInProgress) {
848
return -1; // a (in-progress) comes before b (finished)
849
}
850
if (!aInProgress && bInProgress) {
851
return 1; // a (finished) comes after b (in-progress)
852
}
853
854
// Archived
855
const aArchived = sessionA.isArchived();
856
const bArchived = sessionB.isArchived();
857
858
if (!aArchived && bArchived) {
859
return -1; // a (non-archived) comes before b (archived)
860
}
861
if (aArchived && !bArchived) {
862
return 1; // a (archived) comes after b (non-archived)
863
}
864
865
// Before we compare by time, allow override
866
const override = this.options?.overrideCompare?.(sessionA, sessionB);
867
if (typeof override === 'number') {
868
return override;
869
}
870
871
//Sort by end or start time (most recent first)
872
const timeA = getAgentSessionTime(sessionA.timing);
873
const timeB = getAgentSessionTime(sessionB.timing);
874
return timeB - timeA;
875
}
876
}
877
878
export class AgentSessionsKeyboardNavigationLabelProvider implements ICompressibleKeyboardNavigationLabelProvider<AgentSessionListItem> {
879
880
getKeyboardNavigationLabel(element: AgentSessionListItem): string {
881
if (isAgentSessionSection(element)) {
882
return element.label;
883
}
884
885
return element.label;
886
}
887
888
getCompressedNodeKeyboardNavigationLabel(elements: AgentSessionListItem[]): { toString(): string | undefined } | undefined {
889
return undefined; // not enabled
890
}
891
}
892
893
export class AgentSessionsDragAndDrop extends Disposable implements ITreeDragAndDrop<AgentSessionListItem> {
894
895
constructor(
896
@IInstantiationService private readonly instantiationService: IInstantiationService
897
) {
898
super();
899
}
900
901
onDragStart(data: IDragAndDropData, originalEvent: DragEvent): void {
902
const elements = (data.getData() as AgentSessionListItem[]).filter(e => isAgentSession(e));
903
const uris = coalesce(elements.map(e => e.resource));
904
this.instantiationService.invokeFunction(accessor => fillEditorsDragData(accessor, uris, originalEvent));
905
}
906
907
getDragURI(element: AgentSessionListItem): string | null {
908
if (isAgentSessionSection(element)) {
909
return null; // section headers are not draggable
910
}
911
912
return element.resource.toString();
913
}
914
915
getDragLabel?(elements: AgentSessionListItem[], originalEvent: DragEvent): string | undefined {
916
const sessions = elements.filter(e => isAgentSession(e));
917
if (sessions.length === 1) {
918
return sessions[0].label;
919
}
920
921
return localize('agentSessions.dragLabel', "{0} agent sessions", sessions.length);
922
}
923
924
onDragOver(data: IDragAndDropData, targetElement: AgentSessionListItem | undefined, targetIndex: number | undefined, targetSector: ListViewTargetSector | undefined, originalEvent: DragEvent): boolean | ITreeDragOverReaction {
925
return false;
926
}
927
928
drop(data: IDragAndDropData, targetElement: AgentSessionListItem | undefined, targetIndex: number | undefined, targetSector: ListViewTargetSector | undefined, originalEvent: DragEvent): void { }
929
}
930
931