diff --git a/.github/workflows/sessions-e2e.yml b/.github/workflows/sessions-e2e.yml index b7047a8e30a..3d2fa4e4e88 100644 --- a/.github/workflows/sessions-e2e.yml +++ b/.github/workflows/sessions-e2e.yml @@ -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 diff --git a/src/vs/sessions/browser/media/style.css b/src/vs/sessions/browser/media/style.css index 0a57fa73f7d..10c2dfa67da 100644 --- a/src/vs/sessions/browser/media/style.css +++ b/src/vs/sessions/browser/media/style.css @@ -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); +} diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts index eaac1ae2b66..71b060ddddf 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts @@ -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; -} diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts index 97260bb9db3..4b07c8ac71c 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts @@ -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; 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(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 ); } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.ts index 056f9ecd07f..d04feb219aa 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.ts @@ -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(' • '); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts index bcb46c65c15..ae2f4b5597e 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts @@ -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, template: IAgentSessionItemTemplate): void { + private renderDescription(session: ITreeNode, 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, template: IAgentSessionItemTemplate): void { + private renderStatus(session: ITreeNode, 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, 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= startOfToday) { todaySessions.push(session); } else if (sessionTime >= startOfYesterday) { @@ -1120,7 +1137,7 @@ export function groupAgentSessionsByDate(sessions: IAgentSession[]): Map= 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 { @@ -1172,25 +1193,21 @@ export class AgentSessionsCompressionDelegate implements ITreeCompressionDelegat } } -export interface IAgentSessionsSorterOptions { - overrideCompare?(sessionA: IAgentSession, sessionB: IAgentSession): number | undefined; -} - export class AgentSessionsSorter implements ITreeSorter { - 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 { 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; } } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css b/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css index 913a82d0e39..0feb76c1fcd 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css @@ -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; + } +} diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingActions.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingActions.ts index 1f021914c0b..32cd6e3de7b 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingActions.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingActions.ts @@ -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()) } ], }); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts index be9b5763d2e..fad507dfa1f 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts @@ -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 { 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']); + }); +});