diff --git a/src/vs/platform/actions/common/actions.ts b/src/vs/platform/actions/common/actions.ts index 71b04b2fd1e..52c247143a9 100644 --- a/src/vs/platform/actions/common/actions.ts +++ b/src/vs/platform/actions/common/actions.ts @@ -225,6 +225,7 @@ export class MenuId { static readonly TimelineFilterSubMenu = new MenuId('TimelineFilterSubMenu'); static readonly AgentSessionsTitle = new MenuId('AgentSessionsTitle'); static readonly AgentSessionsFilterSubMenu = new MenuId('AgentSessionsFilterSubMenu'); + static readonly AgentSessionItemToolbar = new MenuId('AgentSessionItemToolbar'); static readonly AccountsContext = new MenuId('AccountsContext'); static readonly SidebarTitle = new MenuId('SidebarTitle'); static readonly PanelTitle = new MenuId('PanelTitle'); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts index d3323e33552..febda6fd0b7 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts @@ -5,7 +5,7 @@ import './media/agentsessionsactions.css'; import { localize, localize2 } from '../../../../../nls.js'; -import { IAgentSessionViewModel } from './agentSessionViewModel.js'; +import { IAgentSession } from './agentSessionsModel.js'; import { Action, IAction } from '../../../../../base/common/actions.js'; import { ActionViewItem, IActionViewItemOptions } from '../../../../../base/browser/ui/actionbar/actionViewItems.js'; import { CommandsRegistry, ICommandService } from '../../../../../platform/commands/common/commands.js'; @@ -19,10 +19,8 @@ import { AGENT_SESSIONS_VIEW_ID, AgentSessionProviders } from './agentSessions.j import { AgentSessionsView } from './agentSessionsView.js'; import { URI } from '../../../../../base/common/uri.js'; import { IChatService } from '../../common/chatService.js'; -import { IStorageService } from '../../../../../platform/storage/common/storage.js'; -import { resetFilter } from './agentSessionsViewFilter.js'; -import { CHAT_CATEGORY } from '../actions/chatActions.js'; import { ChatContextKeys } from '../../common/chatContextKeys.js'; +import { CHAT_CATEGORY } from '../actions/chatActions.js'; import { NEW_CHAT_SESSION_ACTION_ID } from '../chatSessions/common.js'; //#region New Chat Session Actions @@ -73,14 +71,56 @@ registerAction2(class NewCloudChatAction extends Action2 { //#endregion -//#region Diff Statistics Action +//#region Item Title Actions + +registerAction2(class extends Action2 { + constructor() { + super({ + id: 'agentSession.archive', + title: localize('archive', "Archive"), + icon: Codicon.archive, + menu: { + id: MenuId.AgentSessionItemToolbar, + group: 'navigation', + order: 1, + when: ChatContextKeys.isArchivedItem.negate(), + } + }); + } + run(accessor: ServicesAccessor, session: IAgentSession): void { + session.setArchived(true); + } +}); + +registerAction2(class extends Action2 { + constructor() { + super({ + id: 'agentSession.unarchive', + title: localize('unarchive', "Unarchive"), + icon: Codicon.discard, + menu: { + id: MenuId.AgentSessionItemToolbar, + group: 'navigation', + order: 1, + when: ChatContextKeys.isArchivedItem, + } + }); + } + run(accessor: ServicesAccessor, session: IAgentSession): void { + session.setArchived(false); + } +}); + +//#endregion + +//#region Item Detail Actions export class AgentSessionShowDiffAction extends Action { static ID = 'agentSession.showDiff'; constructor( - private readonly session: IAgentSessionViewModel + private readonly session: IAgentSession ) { super(AgentSessionShowDiffAction.ID, localize('showDiff', "Open Changes"), undefined, true); } @@ -89,7 +129,7 @@ export class AgentSessionShowDiffAction extends Action { // This will be handled by the action view item } - getSession(): IAgentSessionViewModel { + getSession(): IAgentSession { return this.session; } } @@ -219,23 +259,4 @@ MenuRegistry.appendMenuItem(MenuId.AgentSessionsTitle, { icon: Codicon.filter } satisfies ISubmenuItem); -registerAction2(class extends Action2 { - constructor() { - super({ - id: 'agentSessions.filter.resetExcludes', - title: localize('agentSessions.filter.reset', 'Reset'), - menu: { - id: MenuId.AgentSessionsFilterSubMenu, - group: '4_reset', - order: 0, - }, - }); - } - run(accessor: ServicesAccessor): void { - const storageService = accessor.get(IStorageService); - - resetFilter(storageService); - } -}); - //#endregion diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewFilter.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.ts similarity index 75% rename from src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewFilter.ts rename to src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.ts index 59973405329..7c6e8afa014 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewFilter.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.ts @@ -11,9 +11,9 @@ import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contex import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { ChatSessionStatus, IChatSessionsService } from '../../common/chatSessionsService.js'; import { AgentSessionProviders, getAgentSessionProviderName } from './agentSessions.js'; -import { IAgentSessionViewModel } from './agentSessionViewModel.js'; +import { IAgentSession } from './agentSessionsModel.js'; -export interface IAgentSessionsViewFilterOptions { +export interface IAgentSessionsFilterOptions { readonly filterMenuId: MenuId; } @@ -29,21 +29,9 @@ const DEFAULT_EXCLUDES: IAgentSessionsViewExcludes = Object.freeze({ archived: true as const, }); -const FILTER_STORAGE_KEY = 'agentSessions.filterExcludes'; +export class AgentSessionsFilter extends Disposable { -export function resetFilter(storageService: IStorageService): void { - const excludes = { - providers: [...DEFAULT_EXCLUDES.providers], - states: [...DEFAULT_EXCLUDES.states], - archived: DEFAULT_EXCLUDES.archived, - }; - - storageService.store(FILTER_STORAGE_KEY, JSON.stringify(excludes), StorageScope.PROFILE, StorageTarget.USER); -} - -export class AgentSessionsViewFilter extends Disposable { - - private static readonly STORAGE_KEY = FILTER_STORAGE_KEY; + private readonly STORAGE_KEY: string; private readonly _onDidChange = this._register(new Emitter()); readonly onDidChange = this._onDidChange.event; @@ -53,12 +41,14 @@ export class AgentSessionsViewFilter extends Disposable { private actionDisposables = this._register(new DisposableStore()); constructor( - private readonly options: IAgentSessionsViewFilterOptions, + private readonly options: IAgentSessionsFilterOptions, @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, @IStorageService private readonly storageService: IStorageService, ) { super(); + this.STORAGE_KEY = `agentSessions.filterExcludes.${this.options.filterMenuId.id.toLowerCase()}`; + this.updateExcludes(false); this.registerListeners(); @@ -68,16 +58,20 @@ export class AgentSessionsViewFilter extends Disposable { this._register(this.chatSessionsService.onDidChangeItemsProviders(() => this.updateFilterActions())); this._register(this.chatSessionsService.onDidChangeAvailability(() => this.updateFilterActions())); - this._register(this.storageService.onDidChangeValue(StorageScope.PROFILE, AgentSessionsViewFilter.STORAGE_KEY, this._store)(() => this.updateExcludes(true))); + this._register(this.storageService.onDidChangeValue(StorageScope.PROFILE, this.STORAGE_KEY, this._store)(() => this.updateExcludes(true))); } private updateExcludes(fromEvent: boolean): void { - const excludedTypesRaw = this.storageService.get(AgentSessionsViewFilter.STORAGE_KEY, StorageScope.PROFILE); - this.excludes = excludedTypesRaw ? JSON.parse(excludedTypesRaw) as IAgentSessionsViewExcludes : { - providers: [...DEFAULT_EXCLUDES.providers], - states: [...DEFAULT_EXCLUDES.states], - archived: DEFAULT_EXCLUDES.archived, - }; + const excludedTypesRaw = this.storageService.get(this.STORAGE_KEY, StorageScope.PROFILE); + if (excludedTypesRaw) { + try { + this.excludes = JSON.parse(excludedTypesRaw) as IAgentSessionsViewExcludes; + } catch { + this.resetExcludes(); + } + } else { + this.resetExcludes(); + } this.updateFilterActions(); @@ -86,10 +80,18 @@ export class AgentSessionsViewFilter extends Disposable { } } + private resetExcludes(): void { + this.excludes = { + providers: [...DEFAULT_EXCLUDES.providers], + states: [...DEFAULT_EXCLUDES.states], + archived: DEFAULT_EXCLUDES.archived, + }; + } + private storeExcludes(excludes: IAgentSessionsViewExcludes): void { this.excludes = excludes; - this.storageService.store(AgentSessionsViewFilter.STORAGE_KEY, JSON.stringify(this.excludes), StorageScope.PROFILE, StorageTarget.USER); + this.storageService.store(this.STORAGE_KEY, JSON.stringify(this.excludes), StorageScope.PROFILE, StorageTarget.USER); } private updateFilterActions(): void { @@ -98,6 +100,7 @@ export class AgentSessionsViewFilter extends Disposable { this.registerProviderActions(this.actionDisposables); this.registerStateActions(this.actionDisposables); this.registerArchivedActions(this.actionDisposables); + this.registerResetAction(this.actionDisposables); } private registerProviderActions(disposables: DisposableStore): void { @@ -121,7 +124,7 @@ export class AgentSessionsViewFilter extends Disposable { disposables.add(registerAction2(class extends Action2 { constructor() { super({ - id: `agentSessions.filter.toggleExclude:${provider.id}`, + id: `agentSessions.filter.toggleExclude:${provider.id}.${that.options.filterMenuId.id.toLowerCase()}`, title: provider.label, menu: { id: that.options.filterMenuId, @@ -156,7 +159,7 @@ export class AgentSessionsViewFilter extends Disposable { disposables.add(registerAction2(class extends Action2 { constructor() { super({ - id: `agentSessions.filter.toggleExcludeState:${state.id}`, + id: `agentSessions.filter.toggleExcludeState:${state.id}.${that.options.filterMenuId.id.toLowerCase()}`, title: state.label, menu: { id: that.options.filterMenuId, @@ -183,7 +186,7 @@ export class AgentSessionsViewFilter extends Disposable { disposables.add(registerAction2(class extends Action2 { constructor() { super({ - id: 'agentSessions.filter.toggleExcludeArchived', + id: `agentSessions.filter.toggleExcludeArchived.${that.options.filterMenuId.id.toLowerCase()}`, title: localize('agentSessions.filter.archived', 'Archived'), menu: { id: that.options.filterMenuId, @@ -199,8 +202,30 @@ export class AgentSessionsViewFilter extends Disposable { })); } - exclude(session: IAgentSessionViewModel): boolean { - if (this.excludes.archived && session.archived) { + private registerResetAction(disposables: DisposableStore): void { + const that = this; + disposables.add(registerAction2(class extends Action2 { + constructor() { + super({ + id: `agentSessions.filter.resetExcludes.${that.options.filterMenuId.id.toLowerCase()}`, + title: localize('agentSessions.filter.reset', "Reset"), + menu: { + id: MenuId.AgentSessionsFilterSubMenu, + group: '4_reset', + order: 0, + }, + }); + } + run(): void { + that.resetExcludes(); + + that.storageService.store(that.STORAGE_KEY, JSON.stringify(that.excludes), StorageScope.PROFILE, StorageTarget.USER); + } + })); + } + + exclude(session: IAgentSession): boolean { + if (this.excludes.archived && session.isArchived()) { return true; } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionViewModel.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts similarity index 70% rename from src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionViewModel.ts rename to src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts index 33531fda4c9..63e75276b86 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionViewModel.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts @@ -12,29 +12,27 @@ import { Disposable } from '../../../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../../../base/common/map.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI, UriComponents } from '../../../../../base/common/uri.js'; -import { MenuId } from '../../../../../platform/actions/common/actions.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { ILifecycleService } from '../../../../services/lifecycle/common/lifecycle.js'; import { ChatSessionStatus, IChatSessionsExtensionPoint, IChatSessionsService, localChatSessionType } from '../../common/chatSessionsService.js'; import { AgentSessionProviders, getAgentSessionProviderIcon, getAgentSessionProviderName } from './agentSessions.js'; -import { AgentSessionsViewFilter } from './agentSessionsViewFilter.js'; //#region Interfaces, Types -export interface IAgentSessionsViewModel { +export interface IAgentSessionsModel { readonly onWillResolve: Event; readonly onDidResolve: Event; readonly onDidChangeSessions: Event; - readonly sessions: IAgentSessionViewModel[]; + readonly sessions: IAgentSession[]; resolve(provider: string | string[] | undefined): Promise; } -export interface IAgentSessionViewModel { +interface IAgentSessionData { readonly providerType: string; readonly providerLabel: string; @@ -42,7 +40,6 @@ export interface IAgentSessionViewModel { readonly resource: URI; readonly status: ChatSessionStatus; - readonly archived: boolean; readonly tooltip?: string | IMarkdownString; @@ -65,29 +62,44 @@ export interface IAgentSessionViewModel { }; } -export function isLocalAgentSessionItem(session: IAgentSessionViewModel): boolean { +export interface IAgentSession extends IAgentSessionData { + isArchived(): boolean; + setArchived(archived: boolean): void; +} + +interface IInternalAgentSessionData extends IAgentSessionData { + + /** + * The `archived` property is provided by the session provider + * and will be used as the initial value if the user has not + * changed the archived state for the session previously. It + * is kept internal to not expose it publicly. Use `isArchived()` + * and `setArchived()` methods instead. + */ + readonly archived: boolean | undefined; +} + +interface IInternalAgentSession extends IAgentSession, IInternalAgentSessionData { } + +export function isLocalAgentSessionItem(session: IAgentSession): boolean { return session.providerType === localChatSessionType; } -export function isAgentSession(obj: IAgentSessionsViewModel | IAgentSessionViewModel): obj is IAgentSessionViewModel { - const session = obj as IAgentSessionViewModel | undefined; +export function isAgentSession(obj: IAgentSessionsModel | IAgentSession): obj is IAgentSession { + const session = obj as IAgentSession | undefined; return URI.isUri(session?.resource); } -export function isAgentSessionsViewModel(obj: IAgentSessionsViewModel | IAgentSessionViewModel): obj is IAgentSessionsViewModel { - const sessionsViewModel = obj as IAgentSessionsViewModel | undefined; +export function isAgentSessionsModel(obj: IAgentSessionsModel | IAgentSession): obj is IAgentSessionsModel { + const sessionsModel = obj as IAgentSessionsModel | undefined; - return Array.isArray(sessionsViewModel?.sessions); + return Array.isArray(sessionsModel?.sessions); } //#endregion -export interface IAgentSessionsViewModelOptions { - readonly filterMenuId: MenuId; -} - -export class AgentSessionsViewModel extends Disposable implements IAgentSessionsViewModel { +export class AgentSessionsModel extends Disposable implements IAgentSessionsModel { private readonly _onWillResolve = this._register(new Emitter()); readonly onWillResolve = this._onWillResolve.event; @@ -98,11 +110,8 @@ export class AgentSessionsViewModel extends Disposable implements IAgentSessions private readonly _onDidChangeSessions = this._register(new Emitter()); readonly onDidChangeSessions = this._onDidChangeSessions.event; - private _sessions: IAgentSessionViewModel[] = []; - - get sessions(): IAgentSessionViewModel[] { - return this._sessions.filter(session => !this.filter.exclude(session)); - } + private _sessions: IInternalAgentSession[] = []; + get sessions(): IAgentSession[] { return this._sessions; } private readonly resolver = this._register(new ThrottledDelayer(100)); private readonly providersToResolve = new Set(); @@ -114,11 +123,9 @@ export class AgentSessionsViewModel extends Disposable implements IAgentSessions finishedOrFailedTime?: number; }>(); - private readonly filter: AgentSessionsViewFilter; private readonly cache: AgentSessionsCache; constructor( - options: IAgentSessionsViewModelOptions, @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, @ILifecycleService private readonly lifecycleService: ILifecycleService, @IInstantiationService private readonly instantiationService: IInstantiationService, @@ -126,12 +133,9 @@ export class AgentSessionsViewModel extends Disposable implements IAgentSessions ) { super(); - this.filter = this._register(this.instantiationService.createInstance(AgentSessionsViewFilter, { filterMenuId: options.filterMenuId })); - this.cache = this.instantiationService.createInstance(AgentSessionsCache); - this._sessions = this.cache.loadCachedSessions(); - - this.resolve(undefined); + this._sessions = this.cache.loadCachedSessions().map(data => this.toAgentSession(data)); + this.sessionStates = this.cache.loadSessionStates(); this.registerListeners(); } @@ -140,8 +144,10 @@ export class AgentSessionsViewModel extends Disposable implements IAgentSessions this._register(this.chatSessionsService.onDidChangeItemsProviders(({ chatSessionType: provider }) => this.resolve(provider))); this._register(this.chatSessionsService.onDidChangeAvailability(() => this.resolve(undefined))); this._register(this.chatSessionsService.onDidChangeSessionItems(provider => this.resolve(provider))); - this._register(this.filter.onDidChange(() => this._onDidChangeSessions.fire())); - this._register(this.storageService.onWillSaveState(() => this.cache.saveCachedSessions(this._sessions))); + this._register(this.storageService.onWillSaveState(() => { + this.cache.saveCachedSessions(this._sessions); + this.cache.saveSessionStates(this.sessionStates); + })); } async resolve(provider: string | string[] | undefined): Promise { @@ -177,7 +183,7 @@ export class AgentSessionsViewModel extends Disposable implements IAgentSessions } const resolvedProviders = new Set(); - const sessions = new ResourceMap(); + const sessions = new ResourceMap(); for (const provider of this.chatSessionsService.getAllChatSessionItemProviders()) { if (!providersToResolve.includes(undefined) && !providersToResolve.includes(provider.chatSessionType)) { continue; // skip: not considered for resolving @@ -243,7 +249,7 @@ export class AgentSessionsViewModel extends Disposable implements IAgentSessions }); } - sessions.set(session.resource, { + sessions.set(session.resource, this.toAgentSession({ providerType: provider.chatSessionType, providerLabel, resource: session.resource, @@ -252,7 +258,7 @@ export class AgentSessionsViewModel extends Disposable implements IAgentSessions icon, tooltip: session.tooltip, status, - archived: session.archived ?? false, + archived: session.archived, timing: { startTime: session.timing.startTime, endTime: session.timing.endTime, @@ -260,7 +266,7 @@ export class AgentSessionsViewModel extends Disposable implements IAgentSessions finishedOrFailedTime }, statistics: session.statistics, - }); + })); } } @@ -281,11 +287,39 @@ export class AgentSessionsViewModel extends Disposable implements IAgentSessions this._onDidChangeSessions.fire(); } + + private toAgentSession(data: IInternalAgentSessionData): IInternalAgentSession { + return { + ...data, + isArchived: () => this.isArchived(data), + setArchived: (archived: boolean) => this.setArchived(data, archived) + }; + } + + //#region States + + private readonly sessionStates: ResourceMap<{ archived: boolean }>; + + private isArchived(session: IInternalAgentSessionData): boolean { + return this.sessionStates.get(session.resource)?.archived ?? Boolean(session.archived); + } + + private setArchived(session: IInternalAgentSessionData, archived: boolean): void { + if (archived === this.isArchived(session)) { + return; // no change + } + + this.sessionStates.set(session.resource, { archived }); + + this._onDidChangeSessions.fire(); + } + + //#endregion } //#region Sessions Cache -interface ISerializedAgentSessionViewModel { +interface ISerializedAgentSession { readonly providerType: string; readonly providerLabel: string; @@ -300,7 +334,7 @@ interface ISerializedAgentSessionViewModel { readonly tooltip?: string | IMarkdownString; readonly status: ChatSessionStatus; - readonly archived: boolean; + readonly archived: boolean | undefined; readonly timing: { readonly startTime: number; @@ -314,14 +348,24 @@ interface ISerializedAgentSessionViewModel { }; } +interface ISerializedAgentSessionState { + readonly resource: UriComponents; + readonly archived: boolean; +} + class AgentSessionsCache { - private static readonly STORAGE_KEY = 'agentSessions.cache'; + private static readonly SESSIONS_STORAGE_KEY = 'agentSessions.model.cache'; + private static readonly STATE_STORAGE_KEY = 'agentSessions.state.cache'; - constructor(@IStorageService private readonly storageService: IStorageService) { } + constructor( + @IStorageService private readonly storageService: IStorageService + ) { } - saveCachedSessions(sessions: IAgentSessionViewModel[]): void { - const serialized: ISerializedAgentSessionViewModel[] = sessions + //#region Sessions + + saveCachedSessions(sessions: IInternalAgentSessionData[]): void { + const serialized: ISerializedAgentSession[] = sessions .filter(session => // Only consider providers that we own where we know that // we can also invalidate the data after startup @@ -351,17 +395,18 @@ class AgentSessionsCache { statistics: session.statistics, })); - this.storageService.store(AgentSessionsCache.STORAGE_KEY, JSON.stringify(serialized), StorageScope.WORKSPACE, StorageTarget.MACHINE); + + this.storageService.store(AgentSessionsCache.SESSIONS_STORAGE_KEY, JSON.stringify(serialized), StorageScope.WORKSPACE, StorageTarget.MACHINE); } - loadCachedSessions(): IAgentSessionViewModel[] { - const sessionsCache = this.storageService.get(AgentSessionsCache.STORAGE_KEY, StorageScope.WORKSPACE); + loadCachedSessions(): IInternalAgentSessionData[] { + const sessionsCache = this.storageService.get(AgentSessionsCache.SESSIONS_STORAGE_KEY, StorageScope.WORKSPACE); if (!sessionsCache) { return []; } try { - const cached = JSON.parse(sessionsCache) as ISerializedAgentSessionViewModel[]; + const cached = JSON.parse(sessionsCache) as ISerializedAgentSession[]; return cached.map(session => ({ providerType: session.providerType, providerLabel: session.providerLabel, @@ -387,6 +432,44 @@ class AgentSessionsCache { return []; // invalid data in storage, fallback to empty sessions list } } + + //#endregion + + //#region States + + private static readonly STATES_SCOPE = StorageScope.APPLICATION; // use application scope to track globally + + saveSessionStates(states: ResourceMap<{ archived: boolean }>): void { + const serialized: ISerializedAgentSessionState[] = Array.from(states.entries()).map(([resource, state]) => ({ + resource: resource.toJSON(), + archived: state.archived + })); + + this.storageService.store(AgentSessionsCache.STATE_STORAGE_KEY, JSON.stringify(serialized), AgentSessionsCache.STATES_SCOPE, StorageTarget.MACHINE); + } + + loadSessionStates(): ResourceMap<{ archived: boolean }> { + const states = new ResourceMap<{ archived: boolean }>(); + + const statesCache = this.storageService.get(AgentSessionsCache.STATE_STORAGE_KEY, AgentSessionsCache.STATES_SCOPE); + if (!statesCache) { + return states; + } + + try { + const cached = JSON.parse(statesCache) as ISerializedAgentSessionState[]; + + for (const entry of cached) { + states.set(URI.revive(entry.resource), { archived: entry.archived }); + } + } catch { + // invalid data in storage, fallback to empty states + } + + return states; + } + + //#endregion } //#endregion diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsService.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsService.ts new file mode 100644 index 00000000000..17100e350cf --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsService.ts @@ -0,0 +1,36 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { createDecorator, IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { AgentSessionsModel, IAgentSessionsModel } from './agentSessionsModel.js'; + +export interface IAgentSessionsService { + + readonly _serviceBrand: undefined; + + readonly model: IAgentSessionsModel; +} + +export class AgentSessionsService extends Disposable implements IAgentSessionsService { + + declare readonly _serviceBrand: undefined; + + private _model: IAgentSessionsModel | undefined; + get model(): IAgentSessionsModel { + if (!this._model) { + this._model = this._register(this.instantiationService.createInstance(AgentSessionsModel)); + this._model.resolve(undefined /* all providers */); + } + + return this._model; + } + + constructor(@IInstantiationService private readonly instantiationService: IInstantiationService) { + super(); + } +} + +export const IAgentSessionsService = createDecorator('agentSessions'); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsView.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsView.ts index c12308769ff..e41c8fc7bc7 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsView.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsView.ts @@ -24,7 +24,7 @@ import { IOpenerService } from '../../../../../platform/opener/common/opener.js' import { IThemeService } from '../../../../../platform/theme/common/themeService.js'; import { IOpenEvent, WorkbenchCompressibleAsyncDataTree } from '../../../../../platform/list/browser/listService.js'; import { $, append } from '../../../../../base/browser/dom.js'; -import { AgentSessionsViewModel, IAgentSessionViewModel, IAgentSessionsViewModel, isLocalAgentSessionItem } from './agentSessionViewModel.js'; +import { IAgentSession, IAgentSessionsModel, isLocalAgentSessionItem } from './agentSessionsModel.js'; import { AgentSessionRenderer, AgentSessionsAccessibilityProvider, AgentSessionsCompressionDelegate, AgentSessionsDataSource, AgentSessionsDragAndDrop, AgentSessionsIdentityProvider, AgentSessionsKeyboardNavigationLabelProvider, AgentSessionsListDelegate, AgentSessionsSorter } from './agentSessionsViewer.js'; import { defaultButtonStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; import { ButtonWithDropdown } from '../../../../../base/browser/ui/button/button.js'; @@ -52,11 +52,11 @@ import { TreeFindMode } from '../../../../../base/browser/ui/tree/abstractTree.j import { SIDE_GROUP } from '../../../../services/editor/common/editorService.js'; import { IMarshalledChatSessionContext } from '../actions/chatSessionActions.js'; import { distinct } from '../../../../../base/common/arrays.js'; +import { IAgentSessionsService } from './agentSessionsService.js'; +import { AgentSessionsFilter } from './agentSessionsFilter.js'; export class AgentSessionsView extends ViewPane { - private sessionsViewModel: IAgentSessionsViewModel | undefined; - constructor( options: IViewPaneOptions, @IKeybindingService keybindingService: IKeybindingService, @@ -75,6 +75,7 @@ export class AgentSessionsView extends ViewPane { @IChatService private readonly chatService: IChatService, @IMenuService private readonly menuService: IMenuService, @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, + @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, ) { super({ ...options, titleMenuId: MenuId.AgentSessionsTitle }, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService); } @@ -96,17 +97,10 @@ export class AgentSessionsView extends ViewPane { } private registerListeners(): void { - - // Sessions List const list = assertReturnsDefined(this.list); - this._register(this.onDidChangeBodyVisibility(visible => { - if (!visible || this.sessionsViewModel) { - return; - } - if (!this.sessionsViewModel) { - this.createViewModel(); - } else { + this._register(this.onDidChangeBodyVisibility(visible => { + if (visible) { this.list?.updateChildren(); } })); @@ -126,7 +120,7 @@ export class AgentSessionsView extends ViewPane { })); } - private async openAgentSession(e: IOpenEvent): Promise { + private async openAgentSession(e: IOpenEvent): Promise { const session = e.element; if (!session) { return; @@ -153,7 +147,7 @@ export class AgentSessionsView extends ViewPane { await this.chatWidgetService.openSession(session.resource, group, options); } - private async showContextMenu({ element: session, anchor }: ITreeContextMenuEvent): Promise { + private async showContextMenu({ element: session, anchor }: ITreeContextMenuEvent): Promise { if (!session) { return; } @@ -272,9 +266,14 @@ export class AgentSessionsView extends ViewPane { //#region Sessions List private listContainer: HTMLElement | undefined; - private list: WorkbenchCompressibleAsyncDataTree | undefined; + private list: WorkbenchCompressibleAsyncDataTree | undefined; + private listFilter: AgentSessionsFilter | undefined; private createList(container: HTMLElement): void { + this.listFilter = this._register(this.instantiationService.createInstance(AgentSessionsFilter, { + filterMenuId: MenuId.AgentSessionsFilterSubMenu, + })); + this.listContainer = append(container, $('.agent-sessions-viewer')); this.list = this._register(this.instantiationService.createInstance(WorkbenchCompressibleAsyncDataTree, @@ -285,7 +284,7 @@ export class AgentSessionsView extends ViewPane { [ this.instantiationService.createInstance(AgentSessionRenderer) ], - new AgentSessionsDataSource(), + new AgentSessionsDataSource(this.listFilter), { accessibilityProvider: new AgentSessionsAccessibilityProvider(), dnd: this.instantiationService.createInstance(AgentSessionsDragAndDrop), @@ -295,27 +294,27 @@ export class AgentSessionsView extends ViewPane { findWidgetEnabled: true, defaultFindMode: TreeFindMode.Filter, keyboardNavigationLabelProvider: new AgentSessionsKeyboardNavigationLabelProvider(), - sorter: new AgentSessionsSorter(), + sorter: this.instantiationService.createInstance(AgentSessionsSorter), paddingBottom: AgentSessionsListDelegate.ITEM_HEIGHT, twistieAdditionalCssClass: () => 'force-no-twistie', } - )) as WorkbenchCompressibleAsyncDataTree; - } + )) as WorkbenchCompressibleAsyncDataTree; - private createViewModel(): void { - const sessionsViewModel = this.sessionsViewModel = this._register(this.instantiationService.createInstance(AgentSessionsViewModel, { filterMenuId: MenuId.AgentSessionsFilterSubMenu })); - this.list?.setInput(sessionsViewModel); + const model = this.agentSessionsService.model; - this._register(sessionsViewModel.onDidChangeSessions(() => { + this._register(Event.any( + this.listFilter.onDidChange, + model.onDidChangeSessions + )(() => { if (this.isBodyVisible()) { this.list?.updateChildren(); } })); const didResolveDisposable = this._register(new MutableDisposable()); - this._register(sessionsViewModel.onWillResolve(() => { + this._register(model.onWillResolve(() => { const didResolve = new DeferredPromise(); - didResolveDisposable.value = Event.once(sessionsViewModel.onDidResolve)(() => didResolve.complete()); + didResolveDisposable.value = Event.once(model.onDidResolve)(() => didResolve.complete()); this.progressService.withProgress( { @@ -326,6 +325,8 @@ export class AgentSessionsView extends ViewPane { () => didResolve.p ); })); + + this.list?.setInput(model); } //#endregion @@ -337,7 +338,7 @@ export class AgentSessionsView extends ViewPane { } refresh(): void { - this.sessionsViewModel?.resolve(undefined); + this.agentSessionsService.model.resolve(undefined); } //#endregion diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts index cbd6058efd6..a474733dea2 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts @@ -13,7 +13,7 @@ import { ICompressedTreeNode } from '../../../../../base/browser/ui/tree/compres import { ICompressibleKeyboardNavigationLabelProvider, ICompressibleTreeRenderer } from '../../../../../base/browser/ui/tree/objectTree.js'; import { ITreeNode, ITreeElementRenderDetails, IAsyncDataSource, ITreeSorter, ITreeDragAndDrop, ITreeDragOverReaction } from '../../../../../base/browser/ui/tree/tree.js'; import { Disposable, DisposableStore, IDisposable } from '../../../../../base/common/lifecycle.js'; -import { IAgentSessionViewModel, IAgentSessionsViewModel, isAgentSession, isAgentSessionsViewModel } from './agentSessionViewModel.js'; +import { IAgentSession, IAgentSessionsModel, isAgentSession, isAgentSessionsModel } from './agentSessionsModel.js'; import { IconLabel } from '../../../../../base/browser/ui/iconLabel/iconLabel.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { Codicon } from '../../../../../base/common/codicons.js'; @@ -37,6 +37,11 @@ import { AGENT_SESSIONS_VIEW_ID } from './agentSessions.js'; import { IntervalTimer } from '../../../../../base/common/async.js'; import { ActionBar } from '../../../../../base/browser/ui/actionbar/actionbar.js'; import { AgentSessionDiffActionViewItem, AgentSessionShowDiffAction } from './agentSessionsActions.js'; +import { MenuWorkbenchToolBar } from '../../../../../platform/actions/browser/toolbar.js'; +import { MenuId } from '../../../../../platform/actions/common/actions.js'; +import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { ChatContextKeys } from '../../common/chatContextKeys.js'; +import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js'; interface IAgentSessionItemTemplate { readonly element: HTMLElement; @@ -46,17 +51,19 @@ interface IAgentSessionItemTemplate { // Column 2 Row 1 readonly title: IconLabel; + readonly titleToolbar: MenuWorkbenchToolBar; // Column 2 Row 2 - readonly toolbar: ActionBar; + readonly detailsToolbar: ActionBar; readonly description: HTMLElement; readonly status: HTMLElement; + readonly contextKeyService: IContextKeyService; readonly elementDisposable: DisposableStore; readonly disposables: IDisposable; } -export class AgentSessionRenderer implements ICompressibleTreeRenderer { +export class AgentSessionRenderer implements ICompressibleTreeRenderer { static readonly TEMPLATE_ID = 'agent-session'; @@ -69,6 +76,7 @@ export class AgentSessionRenderer implements ICompressibleTreeRenderer { if (action.id === AgentSessionShowDiffAction.ID) { return this.instantiationService.createInstance(AgentSessionDiffActionViewItem, action, options); @@ -106,23 +119,27 @@ export class AgentSessionRenderer implements ICompressibleTreeRenderer, index: number, template: IAgentSessionItemTemplate, details?: ITreeElementRenderDetails): void { + renderElement(session: ITreeNode, index: number, template: IAgentSessionItemTemplate, details?: ITreeElementRenderDetails): void { // Clear old state template.elementDisposable.clear(); - template.toolbar.clear(); + template.detailsToolbar.clear(); template.description.textContent = ''; // Icon @@ -131,11 +148,15 @@ export class AgentSessionRenderer implements ICompressibleTreeRenderer 0 || diff.insertions > 0 || diff.deletions > 0)) { const diffAction = template.elementDisposable.add(new AgentSessionShowDiffAction(session.element)); - template.toolbar.push([diffAction], { icon: false, label: true }); + template.detailsToolbar.push([diffAction], { icon: false, label: true }); } // Description otherwise @@ -150,7 +171,7 @@ export class AgentSessionRenderer implements ICompressibleTreeRenderer, template: IAgentSessionItemTemplate): void { + private renderDescription(session: ITreeNode, template: IAgentSessionItemTemplate): void { // Support description as string if (typeof session.element.description === 'string') { @@ -213,9 +234,9 @@ export class AgentSessionRenderer implements ICompressibleTreeRenderer, template: IAgentSessionItemTemplate): void { + private renderStatus(session: ITreeNode, template: IAgentSessionItemTemplate): void { - const getStatus = (session: IAgentSessionViewModel) => { + const getStatus = (session: IAgentSession) => { let timeLabel: string | undefined; if (session.status === ChatSessionStatus.InProgress && session.timing.inProgressTime) { timeLabel = this.toDuration(session.timing.inProgressTime, Date.now()); @@ -232,7 +253,7 @@ export class AgentSessionRenderer implements ICompressibleTreeRenderer template.status.textContent = getStatus(session.element), session.element.status === ChatSessionStatus.InProgress ? 1000 /* every second */ : 60 * 1000 /* every minute */); } - private renderHover(session: ITreeNode, template: IAgentSessionItemTemplate): void { + private renderHover(session: ITreeNode, template: IAgentSessionItemTemplate): void { const tooltip = session.element.tooltip; if (tooltip) { template.elementDisposable.add( @@ -258,11 +279,11 @@ export class AgentSessionRenderer implements ICompressibleTreeRenderer, FuzzyScore>, index: number, templateData: IAgentSessionItemTemplate, details?: ITreeElementRenderDetails): void { + renderCompressedElements(node: ITreeNode, FuzzyScore>, index: number, templateData: IAgentSessionItemTemplate, details?: ITreeElementRenderDetails): void { throw new Error('Should never happen since session is incompressible'); } - disposeElement(element: ITreeNode, index: number, template: IAgentSessionItemTemplate, details?: ITreeElementRenderDetails): void { + disposeElement(element: ITreeNode, index: number, template: IAgentSessionItemTemplate, details?: ITreeElementRenderDetails): void { template.elementDisposable.clear(); } @@ -271,48 +292,56 @@ export class AgentSessionRenderer implements ICompressibleTreeRenderer { +export class AgentSessionsListDelegate implements IListVirtualDelegate { static readonly ITEM_HEIGHT = 44; - getHeight(element: IAgentSessionViewModel): number { + getHeight(element: IAgentSession): number { return AgentSessionsListDelegate.ITEM_HEIGHT; } - getTemplateId(element: IAgentSessionViewModel): string { + getTemplateId(element: IAgentSession): string { return AgentSessionRenderer.TEMPLATE_ID; } } -export class AgentSessionsAccessibilityProvider implements IListAccessibilityProvider { +export class AgentSessionsAccessibilityProvider implements IListAccessibilityProvider { getWidgetAriaLabel(): string { return localize('agentSessions', "Agent Sessions"); } - getAriaLabel(element: IAgentSessionViewModel): string | null { + getAriaLabel(element: IAgentSession): string | null { return element.label; } } -export class AgentSessionsDataSource implements IAsyncDataSource { +export interface IAgentSessionsDataFilter { + exclude(session: IAgentSession): boolean; +} - hasChildren(element: IAgentSessionsViewModel | IAgentSessionViewModel): boolean { - return isAgentSessionsViewModel(element); +export class AgentSessionsDataSource implements IAsyncDataSource { + + constructor( + private readonly filter: IAgentSessionsDataFilter + ) { } + + hasChildren(element: IAgentSessionsModel | IAgentSession): boolean { + return isAgentSessionsModel(element); } - getChildren(element: IAgentSessionsViewModel | IAgentSessionViewModel): Iterable { - if (!isAgentSessionsViewModel(element)) { + getChildren(element: IAgentSessionsModel | IAgentSession): Iterable { + if (!isAgentSessionsModel(element)) { return []; } - return element.sessions; + return element.sessions.filter(session => !this.filter.exclude(session)); } } -export class AgentSessionsIdentityProvider implements IIdentityProvider { +export class AgentSessionsIdentityProvider implements IIdentityProvider { - getId(element: IAgentSessionsViewModel | IAgentSessionViewModel): string { + getId(element: IAgentSessionsModel | IAgentSession): string { if (isAgentSession(element)) { return element.resource.toString(); } @@ -321,16 +350,16 @@ export class AgentSessionsIdentityProvider implements IIdentityProvider { +export class AgentSessionsCompressionDelegate implements ITreeCompressionDelegate { - isIncompressible(element: IAgentSessionViewModel): boolean { + isIncompressible(element: IAgentSession): boolean { return true; } } -export class AgentSessionsSorter implements ITreeSorter { +export class AgentSessionsSorter implements ITreeSorter { - compare(sessionA: IAgentSessionViewModel, sessionB: IAgentSessionViewModel): number { + compare(sessionA: IAgentSession, sessionB: IAgentSession): number { const aInProgress = sessionA.status === ChatSessionStatus.InProgress; const bInProgress = sessionB.status === ChatSessionStatus.InProgress; @@ -341,23 +370,33 @@ export class AgentSessionsSorter implements ITreeSorter return 1; // a (finished) comes after b (in-progress) } + const aArchived = sessionA.isArchived(); + const bArchived = sessionB.isArchived(); + + if (!aArchived && bArchived) { + return -1; // a (non-archived) comes before b (archived) + } + if (aArchived && !bArchived) { + return 1; // a (archived) comes after b (non-archived) + } + // Both in-progress or finished: sort by end or start time (most recent first) return (sessionB.timing.endTime || sessionB.timing.startTime) - (sessionA.timing.endTime || sessionA.timing.startTime); } } -export class AgentSessionsKeyboardNavigationLabelProvider implements ICompressibleKeyboardNavigationLabelProvider { +export class AgentSessionsKeyboardNavigationLabelProvider implements ICompressibleKeyboardNavigationLabelProvider { - getKeyboardNavigationLabel(element: IAgentSessionViewModel): string { + getKeyboardNavigationLabel(element: IAgentSession): string { return element.label; } - getCompressedNodeKeyboardNavigationLabel(elements: IAgentSessionViewModel[]): { toString(): string | undefined } | undefined { + getCompressedNodeKeyboardNavigationLabel(elements: IAgentSession[]): { toString(): string | undefined } | undefined { return undefined; // not enabled } } -export class AgentSessionsDragAndDrop extends Disposable implements ITreeDragAndDrop { +export class AgentSessionsDragAndDrop extends Disposable implements ITreeDragAndDrop { constructor( @IInstantiationService private readonly instantiationService: IInstantiationService @@ -366,16 +405,16 @@ export class AgentSessionsDragAndDrop extends Disposable implements ITreeDragAnd } onDragStart(data: IDragAndDropData, originalEvent: DragEvent): void { - const elements = data.getData() as IAgentSessionViewModel[]; + const elements = data.getData() as IAgentSession[]; const uris = coalesce(elements.map(e => e.resource)); this.instantiationService.invokeFunction(accessor => fillEditorsDragData(accessor, uris, originalEvent)); } - getDragURI(element: IAgentSessionViewModel): string | null { + getDragURI(element: IAgentSession): string | null { return element.resource.toString(); } - getDragLabel?(elements: IAgentSessionViewModel[], originalEvent: DragEvent): string | undefined { + getDragLabel?(elements: IAgentSession[], originalEvent: DragEvent): string | undefined { if (elements.length === 1) { return elements[0].label; } @@ -383,9 +422,9 @@ export class AgentSessionsDragAndDrop extends Disposable implements ITreeDragAnd return localize('agentSessions.dragLabel', "{0} agent sessions", elements.length); } - onDragOver(data: IDragAndDropData, targetElement: IAgentSessionViewModel | undefined, targetIndex: number | undefined, targetSector: ListViewTargetSector | undefined, originalEvent: DragEvent): boolean | ITreeDragOverReaction { + onDragOver(data: IDragAndDropData, targetElement: IAgentSession | undefined, targetIndex: number | undefined, targetSector: ListViewTargetSector | undefined, originalEvent: DragEvent): boolean | ITreeDragOverReaction { return false; } - drop(data: IDragAndDropData, targetElement: IAgentSessionViewModel | undefined, targetIndex: number | undefined, targetSector: ListViewTargetSector | undefined, originalEvent: DragEvent): void { } + drop(data: IDragAndDropData, targetElement: IAgentSession | undefined, targetIndex: number | undefined, targetSector: ListViewTargetSector | undefined, originalEvent: DragEvent): void { } } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsactions.css b/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsactions.css index 85e5adecffb..987e26e6833 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsactions.css +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsactions.css @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -.agent-sessions-viewer .agent-session-item .agent-session-toolbar { +.agent-sessions-viewer .agent-session-item .agent-session-details-toolbar { .monaco-action-bar .actions-container .action-item .action-label { padding: 0; @@ -31,7 +31,7 @@ } } -.monaco-list-row.selected .agent-session-item .agent-session-toolbar { +.monaco-list-row.selected .agent-session-item .agent-session-details-toolbar { .agent-session-diff-files, .agent-session-diff-added, 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 25a25fd83b1..6ae03598c19 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css @@ -5,7 +5,7 @@ .agent-sessions-viewer { - .monaco-list-row > .monaco-tl-row > .monaco-tl-twistie.force-no-twistie { + .monaco-list-row .force-no-twistie { display: none !important; } @@ -19,6 +19,15 @@ } } + .monaco-list-row .agent-session-title-toolbar .monaco-toolbar { + visibility: hidden; + } + + .monaco-list-row:hover .agent-session-title-toolbar .monaco-toolbar, + .monaco-list-row.focused .agent-session-title-toolbar .monaco-toolbar { + visibility: visible; + } + .agent-session-item { display: flex; flex-direction: row; diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index 3d6002f5350..cfc23d9aff4 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -132,6 +132,7 @@ import { PromptUrlHandler } from './promptSyntax/promptUrlHandler.js'; import { ConfigureToolSets, UserToolSetsContributions } from './tools/toolSetsContribution.js'; import { ChatViewsWelcomeHandler } from './viewsWelcome/chatViewsWelcomeHandler.js'; import { ChatWidgetService } from './chatWidgetService.js'; +import { AgentSessionsService, IAgentSessionsService } from './agentSessions/agentSessionsService.js'; const toolReferenceNameEnumValues: string[] = []; const toolReferenceNameEnumDescriptions: string[] = []; @@ -1195,6 +1196,7 @@ registerSingleton(IChatAttachmentResolveService, ChatAttachmentResolveService, I registerSingleton(IChatTodoListService, ChatTodoListService, InstantiationType.Delayed); registerSingleton(IChatOutputRendererService, ChatOutputRendererService, InstantiationType.Delayed); registerSingleton(IChatLayoutService, ChatLayoutService, InstantiationType.Delayed); +registerSingleton(IAgentSessionsService, AgentSessionsService, InstantiationType.Delayed); registerAction2(ConfigureToolSets); registerAction2(RenameChatSessionAction); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessionViewModel.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessionViewModel.test.ts index f89cc801097..2bac32a3001 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessionViewModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessionViewModel.test.ts @@ -10,8 +10,8 @@ import { DisposableStore } from '../../../../../base/common/lifecycle.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { AgentSessionsViewModel, IAgentSessionViewModel, isAgentSession, isAgentSessionsViewModel, isLocalAgentSessionItem } from '../../browser/agentSessions/agentSessionViewModel.js'; -import { AgentSessionsViewFilter } from '../../browser/agentSessions/agentSessionsViewFilter.js'; +import { AgentSessionsModel, IAgentSession, isAgentSession, isAgentSessionsModel, isLocalAgentSessionItem } from '../../browser/agentSessions/agentSessionsModel.js'; +import { AgentSessionsFilter } from '../../browser/agentSessions/agentSessionsFilter.js'; import { ChatSessionStatus, IChatSessionItem, IChatSessionItemProvider, IChatSessionsService, localChatSessionType } from '../../common/chatSessionsService.js'; import { LocalChatSessionUri } from '../../common/chatUri.js'; import { MockChatSessionsService } from '../common/mockChatSessionsService.js'; @@ -22,974 +22,1856 @@ import { MenuId } from '../../../../../platform/actions/common/actions.js'; import { ILifecycleService } from '../../../../services/lifecycle/common/lifecycle.js'; import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; +import { AgentSessionProviders, getAgentSessionProviderIcon, getAgentSessionProviderName } from '../../browser/agentSessions/agentSessions.js'; -suite('AgentSessionsViewModel', () => { +suite('Agent Sessions', () => { - const disposables = new DisposableStore(); - let mockChatSessionsService: MockChatSessionsService; - let mockLifecycleService: TestLifecycleService; - let viewModel: AgentSessionsViewModel; - let instantiationService: TestInstantiationService; + suite('AgentSessionsViewModel', () => { - function createViewModel(): AgentSessionsViewModel { - return disposables.add(instantiationService.createInstance( - AgentSessionsViewModel, - { filterMenuId: MenuId.ViewTitle } - )); - } + const disposables = new DisposableStore(); + let mockChatSessionsService: MockChatSessionsService; + let mockLifecycleService: TestLifecycleService; + let viewModel: AgentSessionsModel; + let instantiationService: TestInstantiationService; - setup(() => { - mockChatSessionsService = new MockChatSessionsService(); - mockLifecycleService = disposables.add(new TestLifecycleService()); - instantiationService = disposables.add(workbenchInstantiationService(undefined, disposables)); - instantiationService.stub(IChatSessionsService, mockChatSessionsService); - instantiationService.stub(ILifecycleService, mockLifecycleService); - }); + function createViewModel(): AgentSessionsModel { + return disposables.add(instantiationService.createInstance( + AgentSessionsModel, + )); + } - teardown(() => { - disposables.clear(); - }); - - ensureNoDisposablesAreLeakedInTestSuite(); - - test('should initialize with empty sessions', () => { - viewModel = createViewModel(); - - assert.strictEqual(viewModel.sessions.length, 0); - }); - - test('should resolve sessions from providers', async () => { - return runWithFakedTimers({}, async () => { - const provider: IChatSessionItemProvider = { - chatSessionType: 'test-type', - onDidChangeChatSessionItems: Event.None, - provideChatSessionItems: async () => [ - { - resource: URI.parse('test://session-1'), - label: 'Test Session 1', - description: 'Description 1', - timing: { startTime: Date.now() } - }, - { - resource: URI.parse('test://session-2'), - label: 'Test Session 2', - timing: { startTime: Date.now() } - } - ] - }; - - mockChatSessionsService.registerChatSessionItemProvider(provider); - viewModel = createViewModel(); - - await viewModel.resolve(undefined); - - assert.strictEqual(viewModel.sessions.length, 2); - assert.strictEqual(viewModel.sessions[0].resource.toString(), 'test://session-1'); - assert.strictEqual(viewModel.sessions[0].label, 'Test Session 1'); - assert.strictEqual(viewModel.sessions[1].resource.toString(), 'test://session-2'); - assert.strictEqual(viewModel.sessions[1].label, 'Test Session 2'); + setup(() => { + mockChatSessionsService = new MockChatSessionsService(); + mockLifecycleService = disposables.add(new TestLifecycleService()); + instantiationService = disposables.add(workbenchInstantiationService(undefined, disposables)); + instantiationService.stub(IChatSessionsService, mockChatSessionsService); + instantiationService.stub(ILifecycleService, mockLifecycleService); }); - }); - test('should resolve sessions from multiple providers', async () => { - return runWithFakedTimers({}, async () => { - const provider1: IChatSessionItemProvider = { - chatSessionType: 'type-1', - onDidChangeChatSessionItems: Event.None, - provideChatSessionItems: async () => [ - { - resource: URI.parse('test://session-1'), - label: 'Session 1', - timing: { startTime: Date.now() } - } - ] - }; - - const provider2: IChatSessionItemProvider = { - chatSessionType: 'type-2', - onDidChangeChatSessionItems: Event.None, - provideChatSessionItems: async () => [ - { - resource: URI.parse('test://session-2'), - label: 'Session 2', - timing: { startTime: Date.now() } - } - ] - }; - - mockChatSessionsService.registerChatSessionItemProvider(provider1); - mockChatSessionsService.registerChatSessionItemProvider(provider2); - - viewModel = createViewModel(); - - await viewModel.resolve(undefined); - - assert.strictEqual(viewModel.sessions.length, 2); - assert.strictEqual(viewModel.sessions[0].resource.toString(), 'test://session-1'); - assert.strictEqual(viewModel.sessions[1].resource.toString(), 'test://session-2'); + teardown(() => { + disposables.clear(); }); - }); - test('should fire onWillResolve and onDidResolve events', async () => { - return runWithFakedTimers({}, async () => { - const provider: IChatSessionItemProvider = { - chatSessionType: 'test-type', - onDidChangeChatSessionItems: Event.None, - provideChatSessionItems: async () => [] - }; + ensureNoDisposablesAreLeakedInTestSuite(); - mockChatSessionsService.registerChatSessionItemProvider(provider); + test('should initialize with empty sessions', () => { viewModel = createViewModel(); - let willResolveFired = false; - let didResolveFired = false; - - disposables.add(viewModel.onWillResolve(() => { - willResolveFired = true; - assert.strictEqual(didResolveFired, false, 'onDidResolve should not fire before onWillResolve completes'); - })); - - disposables.add(viewModel.onDidResolve(() => { - didResolveFired = true; - assert.strictEqual(willResolveFired, true, 'onWillResolve should fire before onDidResolve'); - })); - - await viewModel.resolve(undefined); - - assert.strictEqual(willResolveFired, true, 'onWillResolve should have fired'); - assert.strictEqual(didResolveFired, true, 'onDidResolve should have fired'); - }); - }); - - test('should fire onDidChangeSessions event after resolving', async () => { - return runWithFakedTimers({}, async () => { - const provider: IChatSessionItemProvider = { - chatSessionType: 'test-type', - onDidChangeChatSessionItems: Event.None, - provideChatSessionItems: async () => [ - { - resource: URI.parse('test://session-1'), - label: 'Test Session', - timing: { startTime: Date.now() } - } - ] - }; - - mockChatSessionsService.registerChatSessionItemProvider(provider); - viewModel = createViewModel(); - - let sessionsChangedFired = false; - disposables.add(viewModel.onDidChangeSessions(() => { - sessionsChangedFired = true; - })); - - await viewModel.resolve(undefined); - - assert.strictEqual(sessionsChangedFired, true, 'onDidChangeSessions should have fired'); - }); - }); - - test('should handle session with all properties', async () => { - return runWithFakedTimers({}, async () => { - const startTime = Date.now(); - const endTime = startTime + 1000; - - const provider: IChatSessionItemProvider = { - chatSessionType: 'test-type', - onDidChangeChatSessionItems: Event.None, - provideChatSessionItems: async () => [ - { - resource: URI.parse('test://session-1'), - label: 'Test Session', - description: new MarkdownString('**Bold** description'), - status: ChatSessionStatus.Completed, - tooltip: 'Session tooltip', - iconPath: ThemeIcon.fromId('check'), - timing: { startTime, endTime }, - statistics: { files: 1, insertions: 10, deletions: 5 } - } - ] - }; - - mockChatSessionsService.registerChatSessionItemProvider(provider); - viewModel = createViewModel(); - - await viewModel.resolve(undefined); - - assert.strictEqual(viewModel.sessions.length, 1); - const session = viewModel.sessions[0]; - assert.strictEqual(session.resource.toString(), 'test://session-1'); - assert.strictEqual(session.label, 'Test Session'); - assert.ok(session.description instanceof MarkdownString); - if (session.description instanceof MarkdownString) { - assert.strictEqual(session.description.value, '**Bold** description'); - } - assert.strictEqual(session.status, ChatSessionStatus.Completed); - assert.strictEqual(session.timing.startTime, startTime); - assert.strictEqual(session.timing.endTime, endTime); - assert.deepStrictEqual(session.statistics, { files: 1, insertions: 10, deletions: 5 }); - }); - }); - - test('should handle resolve with specific provider', async () => { - return runWithFakedTimers({}, async () => { - const provider1: IChatSessionItemProvider = { - chatSessionType: 'type-1', - onDidChangeChatSessionItems: Event.None, - provideChatSessionItems: async () => [ - { - resource: URI.parse('test://session-1'), - label: 'Session 1', - timing: { startTime: Date.now() } - } - ] - }; - - const provider2: IChatSessionItemProvider = { - chatSessionType: 'type-2', - onDidChangeChatSessionItems: Event.None, - provideChatSessionItems: async () => [ - { - id: 'session-2', - resource: URI.parse('test://session-2'), - label: 'Session 2', - timing: { startTime: Date.now() } - } - ] - }; - - mockChatSessionsService.registerChatSessionItemProvider(provider1); - mockChatSessionsService.registerChatSessionItemProvider(provider2); - - viewModel = createViewModel(); - - // First resolve all - await viewModel.resolve(undefined); - assert.strictEqual(viewModel.sessions.length, 2); - - // Now resolve only type-1 - await viewModel.resolve('type-1'); - // Should still have both sessions, but only type-1 was re-resolved - assert.strictEqual(viewModel.sessions.length, 2); - }); - }); - - test('should handle resolve with multiple specific providers', async () => { - return runWithFakedTimers({}, async () => { - const provider1: IChatSessionItemProvider = { - chatSessionType: 'type-1', - onDidChangeChatSessionItems: Event.None, - provideChatSessionItems: async () => [ - { - resource: URI.parse('test://session-1'), - label: 'Session 1', - timing: { startTime: Date.now() } - } - ] - }; - - const provider2: IChatSessionItemProvider = { - chatSessionType: 'type-2', - onDidChangeChatSessionItems: Event.None, - provideChatSessionItems: async () => [ - { - id: 'session-2', - resource: URI.parse('test://session-2'), - label: 'Session 2', - timing: { startTime: Date.now() } - } - ] - }; - - mockChatSessionsService.registerChatSessionItemProvider(provider1); - mockChatSessionsService.registerChatSessionItemProvider(provider2); - - viewModel = createViewModel(); - - await viewModel.resolve(['type-1', 'type-2']); - - assert.strictEqual(viewModel.sessions.length, 2); - }); - }); - - test('should respond to onDidChangeItemsProviders event', async () => { - return runWithFakedTimers({}, async () => { - const provider: IChatSessionItemProvider = { - chatSessionType: 'test-type', - onDidChangeChatSessionItems: Event.None, - provideChatSessionItems: async () => [ - { - resource: URI.parse('test://session-1'), - label: 'Test Session', - timing: { startTime: Date.now() } - } - ] - }; - - mockChatSessionsService.registerChatSessionItemProvider(provider); - viewModel = createViewModel(); - - const sessionsChangedPromise = Event.toPromise(viewModel.onDidChangeSessions); - - // Trigger event - this should automatically call resolve - mockChatSessionsService.fireDidChangeItemsProviders(provider); - - // Wait for the sessions to be resolved - await sessionsChangedPromise; - - assert.strictEqual(viewModel.sessions.length, 1); - }); - }); - - test('should respond to onDidChangeAvailability event', async () => { - return runWithFakedTimers({}, async () => { - const provider: IChatSessionItemProvider = { - chatSessionType: 'test-type', - onDidChangeChatSessionItems: Event.None, - provideChatSessionItems: async () => [ - { - resource: URI.parse('test://session-1'), - label: 'Test Session', - timing: { startTime: Date.now() } - } - ] - }; - - mockChatSessionsService.registerChatSessionItemProvider(provider); - viewModel = createViewModel(); - - const sessionsChangedPromise = Event.toPromise(viewModel.onDidChangeSessions); - - // Trigger event - this should automatically call resolve - mockChatSessionsService.fireDidChangeAvailability(); - - // Wait for the sessions to be resolved - await sessionsChangedPromise; - - assert.strictEqual(viewModel.sessions.length, 1); - }); - }); - - test('should respond to onDidChangeSessionItems event', async () => { - return runWithFakedTimers({}, async () => { - const provider: IChatSessionItemProvider = { - chatSessionType: 'test-type', - onDidChangeChatSessionItems: Event.None, - provideChatSessionItems: async () => [ - { - resource: URI.parse('test://session-1'), - label: 'Test Session', - timing: { startTime: Date.now() } - } - ] - }; - - mockChatSessionsService.registerChatSessionItemProvider(provider); - viewModel = createViewModel(); - - const sessionsChangedPromise = Event.toPromise(viewModel.onDidChangeSessions); - - // Trigger event - this should automatically call resolve - mockChatSessionsService.fireDidChangeSessionItems('test-type'); - - // Wait for the sessions to be resolved - await sessionsChangedPromise; - - assert.strictEqual(viewModel.sessions.length, 1); - }); - }); - - test('should maintain provider reference in session view model', async () => { - return runWithFakedTimers({}, async () => { - const provider: IChatSessionItemProvider = { - chatSessionType: 'test-type', - onDidChangeChatSessionItems: Event.None, - provideChatSessionItems: async () => [ - { - resource: URI.parse('test://session-1'), - label: 'Test Session', - timing: { startTime: Date.now() } - } - ] - }; - - mockChatSessionsService.registerChatSessionItemProvider(provider); - viewModel = createViewModel(); - - await viewModel.resolve(undefined); - - assert.strictEqual(viewModel.sessions.length, 1); - assert.strictEqual(viewModel.sessions[0].providerType, 'test-type'); - }); - }); - - test('should handle empty provider results', async () => { - return runWithFakedTimers({}, async () => { - const provider: IChatSessionItemProvider = { - chatSessionType: 'test-type', - onDidChangeChatSessionItems: Event.None, - provideChatSessionItems: async () => [] - }; - - mockChatSessionsService.registerChatSessionItemProvider(provider); - viewModel = createViewModel(); - - await viewModel.resolve(undefined); - assert.strictEqual(viewModel.sessions.length, 0); }); - }); - test('should handle sessions with different statuses', async () => { - return runWithFakedTimers({}, async () => { - const provider: IChatSessionItemProvider = { - chatSessionType: 'test-type', - onDidChangeChatSessionItems: Event.None, - provideChatSessionItems: async () => [ - { - id: 'session-failed', - resource: URI.parse('test://session-failed'), - label: 'Failed Session', - status: ChatSessionStatus.Failed, - timing: { startTime: Date.now() } - }, - { - id: 'session-completed', - resource: URI.parse('test://session-completed'), - label: 'Completed Session', - status: ChatSessionStatus.Completed, - timing: { startTime: Date.now() } - }, - { - id: 'session-inprogress', - resource: URI.parse('test://session-inprogress'), - label: 'In Progress Session', - status: ChatSessionStatus.InProgress, - timing: { startTime: Date.now() } - } - ] - }; - - mockChatSessionsService.registerChatSessionItemProvider(provider); - viewModel = createViewModel(); - - await viewModel.resolve(undefined); - - assert.strictEqual(viewModel.sessions.length, 3); - assert.strictEqual(viewModel.sessions[0].status, ChatSessionStatus.Failed); - assert.strictEqual(viewModel.sessions[1].status, ChatSessionStatus.Completed); - assert.strictEqual(viewModel.sessions[2].status, ChatSessionStatus.InProgress); - }); - }); - - test('should replace sessions on re-resolve', async () => { - return runWithFakedTimers({}, async () => { - let sessionCount = 1; - - const provider: IChatSessionItemProvider = { - chatSessionType: 'test-type', - onDidChangeChatSessionItems: Event.None, - provideChatSessionItems: async () => { - const sessions: IChatSessionItem[] = []; - for (let i = 0; i < sessionCount; i++) { - sessions.push({ - resource: URI.parse(`test://session-${i}`), - label: `Session ${i}`, + test('should resolve sessions from providers', async () => { + return runWithFakedTimers({}, async () => { + const provider: IChatSessionItemProvider = { + chatSessionType: 'test-type', + onDidChangeChatSessionItems: Event.None, + provideChatSessionItems: async () => [ + { + resource: URI.parse('test://session-1'), + label: 'Test Session 1', + description: 'Description 1', timing: { startTime: Date.now() } - }); - } - return sessions; - } - }; + }, + { + resource: URI.parse('test://session-2'), + label: 'Test Session 2', + timing: { startTime: Date.now() } + } + ] + }; - mockChatSessionsService.registerChatSessionItemProvider(provider); - viewModel = createViewModel(); + mockChatSessionsService.registerChatSessionItemProvider(provider); + viewModel = createViewModel(); - await viewModel.resolve(undefined); - assert.strictEqual(viewModel.sessions.length, 1); + await viewModel.resolve(undefined); - sessionCount = 3; - await viewModel.resolve(undefined); - assert.strictEqual(viewModel.sessions.length, 3); + assert.strictEqual(viewModel.sessions.length, 2); + assert.strictEqual(viewModel.sessions[0].resource.toString(), 'test://session-1'); + assert.strictEqual(viewModel.sessions[0].label, 'Test Session 1'); + assert.strictEqual(viewModel.sessions[1].resource.toString(), 'test://session-2'); + assert.strictEqual(viewModel.sessions[1].label, 'Test Session 2'); + }); }); - }); - test('should handle local agent session type specially', async () => { - return runWithFakedTimers({}, async () => { - const provider: IChatSessionItemProvider = { - chatSessionType: localChatSessionType, - onDidChangeChatSessionItems: Event.None, - provideChatSessionItems: async () => [ - { - id: 'local-session', - resource: LocalChatSessionUri.forSession('local-session'), - label: 'Local Session', - timing: { startTime: Date.now() } - } - ] - }; + test('should resolve sessions from multiple providers', async () => { + return runWithFakedTimers({}, async () => { + const provider1: IChatSessionItemProvider = { + chatSessionType: 'type-1', + onDidChangeChatSessionItems: Event.None, + provideChatSessionItems: async () => [ + { + resource: URI.parse('test://session-1'), + label: 'Session 1', + timing: { startTime: Date.now() } + } + ] + }; - mockChatSessionsService.registerChatSessionItemProvider(provider); - viewModel = createViewModel(); + const provider2: IChatSessionItemProvider = { + chatSessionType: 'type-2', + onDidChangeChatSessionItems: Event.None, + provideChatSessionItems: async () => [ + { + resource: URI.parse('test://session-2'), + label: 'Session 2', + timing: { startTime: Date.now() } + } + ] + }; - await viewModel.resolve(undefined); + mockChatSessionsService.registerChatSessionItemProvider(provider1); + mockChatSessionsService.registerChatSessionItemProvider(provider2); - assert.strictEqual(viewModel.sessions.length, 1); - assert.strictEqual(viewModel.sessions[0].providerType, localChatSessionType); + viewModel = createViewModel(); + + await viewModel.resolve(undefined); + + assert.strictEqual(viewModel.sessions.length, 2); + assert.strictEqual(viewModel.sessions[0].resource.toString(), 'test://session-1'); + assert.strictEqual(viewModel.sessions[1].resource.toString(), 'test://session-2'); + }); }); - }); - test('should correctly construct resource URIs for sessions', async () => { - return runWithFakedTimers({}, async () => { - const resource = URI.parse('custom://my-session/path'); + test('should fire onWillResolve and onDidResolve events', async () => { + return runWithFakedTimers({}, async () => { + const provider: IChatSessionItemProvider = { + chatSessionType: 'test-type', + onDidChangeChatSessionItems: Event.None, + provideChatSessionItems: async () => [] + }; - const provider: IChatSessionItemProvider = { - chatSessionType: 'test-type', - onDidChangeChatSessionItems: Event.None, - provideChatSessionItems: async () => [ - { - resource: resource, - label: 'Test Session', - timing: { startTime: Date.now() } - } - ] - }; + mockChatSessionsService.registerChatSessionItemProvider(provider); + viewModel = createViewModel(); - mockChatSessionsService.registerChatSessionItemProvider(provider); - viewModel = createViewModel(); + let willResolveFired = false; + let didResolveFired = false; - await viewModel.resolve(undefined); + disposables.add(viewModel.onWillResolve(() => { + willResolveFired = true; + assert.strictEqual(didResolveFired, false, 'onDidResolve should not fire before onWillResolve completes'); + })); - assert.strictEqual(viewModel.sessions.length, 1); - assert.strictEqual(viewModel.sessions[0].resource.toString(), resource.toString()); + disposables.add(viewModel.onDidResolve(() => { + didResolveFired = true; + assert.strictEqual(willResolveFired, true, 'onWillResolve should fire before onDidResolve'); + })); + + await viewModel.resolve(undefined); + + assert.strictEqual(willResolveFired, true, 'onWillResolve should have fired'); + assert.strictEqual(didResolveFired, true, 'onDidResolve should have fired'); + }); }); - }); - test('should throttle multiple rapid resolve calls', async () => { - return runWithFakedTimers({}, async () => { - let providerCallCount = 0; - - const provider: IChatSessionItemProvider = { - chatSessionType: 'test-type', - onDidChangeChatSessionItems: Event.None, - provideChatSessionItems: async () => { - providerCallCount++; - return [ + test('should fire onDidChangeSessions event after resolving', async () => { + return runWithFakedTimers({}, async () => { + const provider: IChatSessionItemProvider = { + chatSessionType: 'test-type', + onDidChangeChatSessionItems: Event.None, + provideChatSessionItems: async () => [ { resource: URI.parse('test://session-1'), label: 'Test Session', timing: { startTime: Date.now() } } - ]; - } - }; + ] + }; - mockChatSessionsService.registerChatSessionItemProvider(provider); - viewModel = createViewModel(); + mockChatSessionsService.registerChatSessionItemProvider(provider); + viewModel = createViewModel(); - // Make multiple rapid resolve calls - const resolvePromises = [ - viewModel.resolve(undefined), - viewModel.resolve(undefined), - viewModel.resolve(undefined) - ]; + let sessionsChangedFired = false; + disposables.add(viewModel.onDidChangeSessions(() => { + sessionsChangedFired = true; + })); - await Promise.all(resolvePromises); + await viewModel.resolve(undefined); - // Should only call provider once due to throttling - assert.strictEqual(providerCallCount, 1); - assert.strictEqual(viewModel.sessions.length, 1); + assert.strictEqual(sessionsChangedFired, true, 'onDidChangeSessions should have fired'); + }); }); - }); - test('should preserve sessions from non-resolved providers', async () => { - return runWithFakedTimers({}, async () => { - let provider1CallCount = 0; - let provider2CallCount = 0; + test('should handle session with all properties', async () => { + return runWithFakedTimers({}, async () => { + const startTime = Date.now(); + const endTime = startTime + 1000; - const provider1: IChatSessionItemProvider = { - chatSessionType: 'type-1', - onDidChangeChatSessionItems: Event.None, - provideChatSessionItems: async () => { - provider1CallCount++; - return [ + const provider: IChatSessionItemProvider = { + chatSessionType: 'test-type', + onDidChangeChatSessionItems: Event.None, + provideChatSessionItems: async () => [ { resource: URI.parse('test://session-1'), - label: `Session 1 (call ${provider1CallCount})`, - timing: { startTime: Date.now() } + label: 'Test Session', + description: new MarkdownString('**Bold** description'), + status: ChatSessionStatus.Completed, + tooltip: 'Session tooltip', + iconPath: ThemeIcon.fromId('check'), + timing: { startTime, endTime }, + statistics: { files: 1, insertions: 10, deletions: 5 } } - ]; - } - }; + ] + }; - const provider2: IChatSessionItemProvider = { - chatSessionType: 'type-2', - onDidChangeChatSessionItems: Event.None, - provideChatSessionItems: async () => { - provider2CallCount++; - return [ + mockChatSessionsService.registerChatSessionItemProvider(provider); + viewModel = createViewModel(); + + await viewModel.resolve(undefined); + + assert.strictEqual(viewModel.sessions.length, 1); + const session = viewModel.sessions[0]; + assert.strictEqual(session.resource.toString(), 'test://session-1'); + assert.strictEqual(session.label, 'Test Session'); + assert.ok(session.description instanceof MarkdownString); + if (session.description instanceof MarkdownString) { + assert.strictEqual(session.description.value, '**Bold** description'); + } + assert.strictEqual(session.status, ChatSessionStatus.Completed); + assert.strictEqual(session.timing.startTime, startTime); + assert.strictEqual(session.timing.endTime, endTime); + assert.deepStrictEqual(session.statistics, { files: 1, insertions: 10, deletions: 5 }); + }); + }); + + test('should handle resolve with specific provider', async () => { + return runWithFakedTimers({}, async () => { + const provider1: IChatSessionItemProvider = { + chatSessionType: 'type-1', + onDidChangeChatSessionItems: Event.None, + provideChatSessionItems: async () => [ { - resource: URI.parse('test://session-2'), - label: `Session 2 (call ${provider2CallCount})`, + resource: URI.parse('test://session-1'), + label: 'Session 1', timing: { startTime: Date.now() } } - ]; - } - }; + ] + }; - mockChatSessionsService.registerChatSessionItemProvider(provider1); - mockChatSessionsService.registerChatSessionItemProvider(provider2); + const provider2: IChatSessionItemProvider = { + chatSessionType: 'type-2', + onDidChangeChatSessionItems: Event.None, + provideChatSessionItems: async () => [ + { + id: 'session-2', + resource: URI.parse('test://session-2'), + label: 'Session 2', + timing: { startTime: Date.now() } + } + ] + }; - viewModel = createViewModel(); + mockChatSessionsService.registerChatSessionItemProvider(provider1); + mockChatSessionsService.registerChatSessionItemProvider(provider2); - // First resolve all - await viewModel.resolve(undefined); - assert.strictEqual(viewModel.sessions.length, 2); - assert.strictEqual(provider1CallCount, 1); - assert.strictEqual(provider2CallCount, 1); - const originalSession1Label = viewModel.sessions[0].label; + viewModel = createViewModel(); - // Now resolve only type-2 - await viewModel.resolve('type-2'); + // First resolve all + await viewModel.resolve(undefined); + assert.strictEqual(viewModel.sessions.length, 2); - // Should still have both sessions - assert.strictEqual(viewModel.sessions.length, 2); - // Provider 1 should not be called again - assert.strictEqual(provider1CallCount, 1); - // Provider 2 should be called again - assert.strictEqual(provider2CallCount, 2); - // Session 1 should be preserved with original label - assert.strictEqual(viewModel.sessions.find(s => s.resource.toString() === 'test://session-1')?.label, originalSession1Label); + // Now resolve only type-1 + await viewModel.resolve('type-1'); + // Should still have both sessions, but only type-1 was re-resolved + assert.strictEqual(viewModel.sessions.length, 2); + }); + }); + + test('should handle resolve with multiple specific providers', async () => { + return runWithFakedTimers({}, async () => { + const provider1: IChatSessionItemProvider = { + chatSessionType: 'type-1', + onDidChangeChatSessionItems: Event.None, + provideChatSessionItems: async () => [ + { + resource: URI.parse('test://session-1'), + label: 'Session 1', + timing: { startTime: Date.now() } + } + ] + }; + + const provider2: IChatSessionItemProvider = { + chatSessionType: 'type-2', + onDidChangeChatSessionItems: Event.None, + provideChatSessionItems: async () => [ + { + id: 'session-2', + resource: URI.parse('test://session-2'), + label: 'Session 2', + timing: { startTime: Date.now() } + } + ] + }; + + mockChatSessionsService.registerChatSessionItemProvider(provider1); + mockChatSessionsService.registerChatSessionItemProvider(provider2); + + viewModel = createViewModel(); + + await viewModel.resolve(['type-1', 'type-2']); + + assert.strictEqual(viewModel.sessions.length, 2); + }); + }); + + test('should respond to onDidChangeItemsProviders event', async () => { + return runWithFakedTimers({}, async () => { + const provider: IChatSessionItemProvider = { + chatSessionType: 'test-type', + onDidChangeChatSessionItems: Event.None, + provideChatSessionItems: async () => [ + { + resource: URI.parse('test://session-1'), + label: 'Test Session', + timing: { startTime: Date.now() } + } + ] + }; + + mockChatSessionsService.registerChatSessionItemProvider(provider); + viewModel = createViewModel(); + + const sessionsChangedPromise = Event.toPromise(viewModel.onDidChangeSessions); + + // Trigger event - this should automatically call resolve + mockChatSessionsService.fireDidChangeItemsProviders(provider); + + // Wait for the sessions to be resolved + await sessionsChangedPromise; + + assert.strictEqual(viewModel.sessions.length, 1); + }); + }); + + test('should respond to onDidChangeAvailability event', async () => { + return runWithFakedTimers({}, async () => { + const provider: IChatSessionItemProvider = { + chatSessionType: 'test-type', + onDidChangeChatSessionItems: Event.None, + provideChatSessionItems: async () => [ + { + resource: URI.parse('test://session-1'), + label: 'Test Session', + timing: { startTime: Date.now() } + } + ] + }; + + mockChatSessionsService.registerChatSessionItemProvider(provider); + viewModel = createViewModel(); + + const sessionsChangedPromise = Event.toPromise(viewModel.onDidChangeSessions); + + // Trigger event - this should automatically call resolve + mockChatSessionsService.fireDidChangeAvailability(); + + // Wait for the sessions to be resolved + await sessionsChangedPromise; + + assert.strictEqual(viewModel.sessions.length, 1); + }); + }); + + test('should respond to onDidChangeSessionItems event', async () => { + return runWithFakedTimers({}, async () => { + const provider: IChatSessionItemProvider = { + chatSessionType: 'test-type', + onDidChangeChatSessionItems: Event.None, + provideChatSessionItems: async () => [ + { + resource: URI.parse('test://session-1'), + label: 'Test Session', + timing: { startTime: Date.now() } + } + ] + }; + + mockChatSessionsService.registerChatSessionItemProvider(provider); + viewModel = createViewModel(); + + const sessionsChangedPromise = Event.toPromise(viewModel.onDidChangeSessions); + + // Trigger event - this should automatically call resolve + mockChatSessionsService.fireDidChangeSessionItems('test-type'); + + // Wait for the sessions to be resolved + await sessionsChangedPromise; + + assert.strictEqual(viewModel.sessions.length, 1); + }); + }); + + test('should maintain provider reference in session view model', async () => { + return runWithFakedTimers({}, async () => { + const provider: IChatSessionItemProvider = { + chatSessionType: 'test-type', + onDidChangeChatSessionItems: Event.None, + provideChatSessionItems: async () => [ + { + resource: URI.parse('test://session-1'), + label: 'Test Session', + timing: { startTime: Date.now() } + } + ] + }; + + mockChatSessionsService.registerChatSessionItemProvider(provider); + viewModel = createViewModel(); + + await viewModel.resolve(undefined); + + assert.strictEqual(viewModel.sessions.length, 1); + assert.strictEqual(viewModel.sessions[0].providerType, 'test-type'); + }); + }); + + test('should handle empty provider results', async () => { + return runWithFakedTimers({}, async () => { + const provider: IChatSessionItemProvider = { + chatSessionType: 'test-type', + onDidChangeChatSessionItems: Event.None, + provideChatSessionItems: async () => [] + }; + + mockChatSessionsService.registerChatSessionItemProvider(provider); + viewModel = createViewModel(); + + await viewModel.resolve(undefined); + + assert.strictEqual(viewModel.sessions.length, 0); + }); + }); + + test('should handle sessions with different statuses', async () => { + return runWithFakedTimers({}, async () => { + const provider: IChatSessionItemProvider = { + chatSessionType: 'test-type', + onDidChangeChatSessionItems: Event.None, + provideChatSessionItems: async () => [ + { + id: 'session-failed', + resource: URI.parse('test://session-failed'), + label: 'Failed Session', + status: ChatSessionStatus.Failed, + timing: { startTime: Date.now() } + }, + { + id: 'session-completed', + resource: URI.parse('test://session-completed'), + label: 'Completed Session', + status: ChatSessionStatus.Completed, + timing: { startTime: Date.now() } + }, + { + id: 'session-inprogress', + resource: URI.parse('test://session-inprogress'), + label: 'In Progress Session', + status: ChatSessionStatus.InProgress, + timing: { startTime: Date.now() } + } + ] + }; + + mockChatSessionsService.registerChatSessionItemProvider(provider); + viewModel = createViewModel(); + + await viewModel.resolve(undefined); + + assert.strictEqual(viewModel.sessions.length, 3); + assert.strictEqual(viewModel.sessions[0].status, ChatSessionStatus.Failed); + assert.strictEqual(viewModel.sessions[1].status, ChatSessionStatus.Completed); + assert.strictEqual(viewModel.sessions[2].status, ChatSessionStatus.InProgress); + }); + }); + + test('should replace sessions on re-resolve', async () => { + return runWithFakedTimers({}, async () => { + let sessionCount = 1; + + const provider: IChatSessionItemProvider = { + chatSessionType: 'test-type', + onDidChangeChatSessionItems: Event.None, + provideChatSessionItems: async () => { + const sessions: IChatSessionItem[] = []; + for (let i = 0; i < sessionCount; i++) { + sessions.push({ + resource: URI.parse(`test://session-${i}`), + label: `Session ${i}`, + timing: { startTime: Date.now() } + }); + } + return sessions; + } + }; + + mockChatSessionsService.registerChatSessionItemProvider(provider); + viewModel = createViewModel(); + + await viewModel.resolve(undefined); + assert.strictEqual(viewModel.sessions.length, 1); + + sessionCount = 3; + await viewModel.resolve(undefined); + assert.strictEqual(viewModel.sessions.length, 3); + }); + }); + + test('should handle local agent session type specially', async () => { + return runWithFakedTimers({}, async () => { + const provider: IChatSessionItemProvider = { + chatSessionType: localChatSessionType, + onDidChangeChatSessionItems: Event.None, + provideChatSessionItems: async () => [ + { + id: 'local-session', + resource: LocalChatSessionUri.forSession('local-session'), + label: 'Local Session', + timing: { startTime: Date.now() } + } + ] + }; + + mockChatSessionsService.registerChatSessionItemProvider(provider); + viewModel = createViewModel(); + + await viewModel.resolve(undefined); + + assert.strictEqual(viewModel.sessions.length, 1); + assert.strictEqual(viewModel.sessions[0].providerType, localChatSessionType); + }); + }); + + test('should correctly construct resource URIs for sessions', async () => { + return runWithFakedTimers({}, async () => { + const resource = URI.parse('custom://my-session/path'); + + const provider: IChatSessionItemProvider = { + chatSessionType: 'test-type', + onDidChangeChatSessionItems: Event.None, + provideChatSessionItems: async () => [ + { + resource: resource, + label: 'Test Session', + timing: { startTime: Date.now() } + } + ] + }; + + mockChatSessionsService.registerChatSessionItemProvider(provider); + viewModel = createViewModel(); + + await viewModel.resolve(undefined); + + assert.strictEqual(viewModel.sessions.length, 1); + assert.strictEqual(viewModel.sessions[0].resource.toString(), resource.toString()); + }); + }); + + test('should throttle multiple rapid resolve calls', async () => { + return runWithFakedTimers({}, async () => { + let providerCallCount = 0; + + const provider: IChatSessionItemProvider = { + chatSessionType: 'test-type', + onDidChangeChatSessionItems: Event.None, + provideChatSessionItems: async () => { + providerCallCount++; + return [ + { + resource: URI.parse('test://session-1'), + label: 'Test Session', + timing: { startTime: Date.now() } + } + ]; + } + }; + + mockChatSessionsService.registerChatSessionItemProvider(provider); + viewModel = createViewModel(); + + // Make multiple rapid resolve calls + const resolvePromises = [ + viewModel.resolve(undefined), + viewModel.resolve(undefined), + viewModel.resolve(undefined) + ]; + + await Promise.all(resolvePromises); + + // Should only call provider once due to throttling + assert.strictEqual(providerCallCount, 1); + assert.strictEqual(viewModel.sessions.length, 1); + }); + }); + + test('should preserve sessions from non-resolved providers', async () => { + return runWithFakedTimers({}, async () => { + let provider1CallCount = 0; + let provider2CallCount = 0; + + const provider1: IChatSessionItemProvider = { + chatSessionType: 'type-1', + onDidChangeChatSessionItems: Event.None, + provideChatSessionItems: async () => { + provider1CallCount++; + return [ + { + resource: URI.parse('test://session-1'), + label: `Session 1 (call ${provider1CallCount})`, + timing: { startTime: Date.now() } + } + ]; + } + }; + + const provider2: IChatSessionItemProvider = { + chatSessionType: 'type-2', + onDidChangeChatSessionItems: Event.None, + provideChatSessionItems: async () => { + provider2CallCount++; + return [ + { + resource: URI.parse('test://session-2'), + label: `Session 2 (call ${provider2CallCount})`, + timing: { startTime: Date.now() } + } + ]; + } + }; + + mockChatSessionsService.registerChatSessionItemProvider(provider1); + mockChatSessionsService.registerChatSessionItemProvider(provider2); + + viewModel = createViewModel(); + + // First resolve all + await viewModel.resolve(undefined); + assert.strictEqual(viewModel.sessions.length, 2); + assert.strictEqual(provider1CallCount, 1); + assert.strictEqual(provider2CallCount, 1); + const originalSession1Label = viewModel.sessions[0].label; + + // Now resolve only type-2 + await viewModel.resolve('type-2'); + + // Should still have both sessions + assert.strictEqual(viewModel.sessions.length, 2); + // Provider 1 should not be called again + assert.strictEqual(provider1CallCount, 1); + // Provider 2 should be called again + assert.strictEqual(provider2CallCount, 2); + // Session 1 should be preserved with original label + assert.strictEqual(viewModel.sessions.find(s => s.resource.toString() === 'test://session-1')?.label, originalSession1Label); + }); + }); + + test('should accumulate providers when resolve is called with different provider types', async () => { + return runWithFakedTimers({}, async () => { + let resolveCount = 0; + const resolvedProviders: (string | undefined)[] = []; + + const provider1: IChatSessionItemProvider = { + chatSessionType: 'type-1', + onDidChangeChatSessionItems: Event.None, + provideChatSessionItems: async () => { + resolveCount++; + resolvedProviders.push('type-1'); + return [{ + resource: URI.parse('test://session-1'), + label: 'Session 1', + timing: { startTime: Date.now() } + }]; + } + }; + + const provider2: IChatSessionItemProvider = { + chatSessionType: 'type-2', + onDidChangeChatSessionItems: Event.None, + provideChatSessionItems: async () => { + resolveCount++; + resolvedProviders.push('type-2'); + return [{ + resource: URI.parse('test://session-2'), + label: 'Session 2', + timing: { startTime: Date.now() } + }]; + } + }; + + mockChatSessionsService.registerChatSessionItemProvider(provider1); + mockChatSessionsService.registerChatSessionItemProvider(provider2); + + viewModel = createViewModel(); + + // Call resolve with different types rapidly - they should accumulate + const promise1 = viewModel.resolve('type-1'); + const promise2 = viewModel.resolve(['type-2']); + + await Promise.all([promise1, promise2]); + + // Both providers should be resolved + assert.strictEqual(viewModel.sessions.length, 2); + }); }); }); - test('should accumulate providers when resolve is called with different provider types', async () => { - return runWithFakedTimers({}, async () => { - let resolveCount = 0; - const resolvedProviders: (string | undefined)[] = []; + suite('AgentSessionsViewModel - Helper Functions', () => { + const disposables = new DisposableStore(); + teardown(() => { + disposables.clear(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('isLocalAgentSessionItem should identify local sessions', () => { + const localSession: IAgentSession = { + providerType: localChatSessionType, + providerLabel: 'Local', + icon: Codicon.chatSparkle, + resource: URI.parse('test://local-1'), + label: 'Local', + description: 'test', + timing: { startTime: Date.now() }, + status: ChatSessionStatus.Completed, + isArchived: () => false, + setArchived: archived => { } + }; + + const remoteSession: IAgentSession = { + providerType: 'remote', + providerLabel: 'Remote', + icon: Codicon.chatSparkle, + resource: URI.parse('test://remote-1'), + label: 'Remote', + description: 'test', + timing: { startTime: Date.now() }, + status: ChatSessionStatus.Completed, + isArchived: () => false, + setArchived: archived => { } + }; + + assert.strictEqual(isLocalAgentSessionItem(localSession), true); + assert.strictEqual(isLocalAgentSessionItem(remoteSession), false); + }); + + test('isAgentSession should identify session view models', () => { + const session: IAgentSession = { + providerType: 'test', + providerLabel: 'Local', + icon: Codicon.chatSparkle, + resource: URI.parse('test://test-1'), + label: 'Test', + description: 'test', + timing: { startTime: Date.now() }, + status: ChatSessionStatus.Completed, + isArchived: () => false, + setArchived: archived => { } + }; + + // Test with a session object + assert.strictEqual(isAgentSession(session), true); + + // Test with a sessions container - pass as session to see it returns false + const sessionOrContainer: IAgentSession = session; + assert.strictEqual(isAgentSession(sessionOrContainer), true); + }); + + test('isAgentSessionsViewModel should identify sessions view models', () => { + const session: IAgentSession = { + providerType: 'test', + providerLabel: 'Local', + icon: Codicon.chatSparkle, + resource: URI.parse('test://test-1'), + label: 'Test', + description: 'test', + timing: { startTime: Date.now() }, + status: ChatSessionStatus.Completed, + isArchived: () => false, + setArchived: archived => { } + }; + + // Test with actual view model + const instantiationService = workbenchInstantiationService(undefined, disposables); + const lifecycleService = disposables.add(new TestLifecycleService()); + instantiationService.stub(IChatSessionsService, new MockChatSessionsService()); + instantiationService.stub(ILifecycleService, lifecycleService); + const actualViewModel = disposables.add(instantiationService.createInstance( + AgentSessionsModel, + )); + assert.strictEqual(isAgentSessionsModel(actualViewModel), true); + + // Test with session object + assert.strictEqual(isAgentSessionsModel(session), false); + }); + }); + + suite('AgentSessionsFilter', () => { + const disposables = new DisposableStore(); + let mockChatSessionsService: MockChatSessionsService; + let instantiationService: TestInstantiationService; + + function createSession(overrides: Partial = {}): IAgentSession { + return { + providerType: 'test-type', + providerLabel: 'Test Provider', + icon: Codicon.chatSparkle, + resource: URI.parse('test://session'), + label: 'Test Session', + timing: { startTime: Date.now() }, + status: ChatSessionStatus.Completed, + isArchived: () => false, + setArchived: () => { }, + ...overrides + }; + } + + setup(() => { + mockChatSessionsService = new MockChatSessionsService(); + instantiationService = disposables.add(workbenchInstantiationService(undefined, disposables)); + instantiationService.stub(IChatSessionsService, mockChatSessionsService); + }); + + teardown(() => { + disposables.clear(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('should initialize with default excludes', () => { + const filter = disposables.add(instantiationService.createInstance( + AgentSessionsFilter, + { filterMenuId: MenuId.ViewTitle } + )); + + // Default: archived sessions should be excluded + const archivedSession = createSession({ + isArchived: () => true + }); + const activeSession = createSession({ + isArchived: () => false + }); + + assert.strictEqual(filter.exclude(archivedSession), true); + assert.strictEqual(filter.exclude(activeSession), false); + }); + + test('should filter out sessions from excluded provider', () => { + const storageService = instantiationService.get(IStorageService); + const filter = disposables.add(instantiationService.createInstance( + AgentSessionsFilter, + { filterMenuId: MenuId.ViewTitle } + )); + + const session1 = createSession({ + providerType: 'type-1', + resource: URI.parse('test://session-1') + }); + + const session2 = createSession({ + providerType: 'type-2', + resource: URI.parse('test://session-2') + }); + + // Initially, no sessions should be filtered by provider + assert.strictEqual(filter.exclude(session1), false); + assert.strictEqual(filter.exclude(session2), false); + + // Exclude type-1 by setting it in storage + const excludes = { + providers: ['type-1'], + states: [], + archived: false + }; + storageService.store(`agentSessions.filterExcludes.${MenuId.ViewTitle.id.toLowerCase()}`, JSON.stringify(excludes), StorageScope.PROFILE, StorageTarget.USER); + + // After excluding type-1, session1 should be filtered but not session2 + assert.strictEqual(filter.exclude(session1), true); + assert.strictEqual(filter.exclude(session2), false); + }); + + test('should filter out multiple excluded providers', () => { + const storageService = instantiationService.get(IStorageService); + const filter = disposables.add(instantiationService.createInstance( + AgentSessionsFilter, + { filterMenuId: MenuId.ViewTitle } + )); + + const session1 = createSession({ providerType: 'type-1' }); + const session2 = createSession({ providerType: 'type-2' }); + const session3 = createSession({ providerType: 'type-3' }); + + // Exclude type-1 and type-2 + const excludes = { + providers: ['type-1', 'type-2'], + states: [], + archived: false + }; + storageService.store(`agentSessions.filterExcludes.${MenuId.ViewTitle.id.toLowerCase()}`, JSON.stringify(excludes), StorageScope.PROFILE, StorageTarget.USER); + + assert.strictEqual(filter.exclude(session1), true); + assert.strictEqual(filter.exclude(session2), true); + assert.strictEqual(filter.exclude(session3), false); + }); + + test('should filter out archived sessions', () => { + const storageService = instantiationService.get(IStorageService); + const filter = disposables.add(instantiationService.createInstance( + AgentSessionsFilter, + { filterMenuId: MenuId.ViewTitle } + )); + + const archivedSession = createSession({ + resource: URI.parse('test://archived-session'), + isArchived: () => true + }); + + const activeSession = createSession({ + resource: URI.parse('test://active-session'), + isArchived: () => false + }); + + // By default, archived sessions should be filtered (archived: true in default excludes) + assert.strictEqual(filter.exclude(archivedSession), true); + assert.strictEqual(filter.exclude(activeSession), false); + + // Include archived by setting archived to false in storage + const excludes = { + providers: [], + states: [], + archived: false + }; + storageService.store(`agentSessions.filterExcludes.${MenuId.ViewTitle.id.toLowerCase()}`, JSON.stringify(excludes), StorageScope.PROFILE, StorageTarget.USER); + + // After including archived, both sessions should not be filtered + assert.strictEqual(filter.exclude(archivedSession), false); + assert.strictEqual(filter.exclude(activeSession), false); + }); + + test('should filter out sessions with excluded status', () => { + const storageService = instantiationService.get(IStorageService); + const filter = disposables.add(instantiationService.createInstance( + AgentSessionsFilter, + { filterMenuId: MenuId.ViewTitle } + )); + + const failedSession = createSession({ + resource: URI.parse('test://failed-session'), + status: ChatSessionStatus.Failed + }); + + const completedSession = createSession({ + resource: URI.parse('test://completed-session'), + status: ChatSessionStatus.Completed + }); + + const inProgressSession = createSession({ + resource: URI.parse('test://inprogress-session'), + status: ChatSessionStatus.InProgress + }); + + // Initially, no sessions should be filtered by status (archived is default exclude) + assert.strictEqual(filter.exclude(failedSession), false); + assert.strictEqual(filter.exclude(completedSession), false); + assert.strictEqual(filter.exclude(inProgressSession), false); + + // Exclude failed status by setting it in storage + const excludes = { + providers: [], + states: [ChatSessionStatus.Failed], + archived: false + }; + storageService.store(`agentSessions.filterExcludes.${MenuId.ViewTitle.id.toLowerCase()}`, JSON.stringify(excludes), StorageScope.PROFILE, StorageTarget.USER); + + // After excluding failed status, only failedSession should be filtered + assert.strictEqual(filter.exclude(failedSession), true); + assert.strictEqual(filter.exclude(completedSession), false); + assert.strictEqual(filter.exclude(inProgressSession), false); + }); + + test('should filter out multiple excluded statuses', () => { + const storageService = instantiationService.get(IStorageService); + const filter = disposables.add(instantiationService.createInstance( + AgentSessionsFilter, + { filterMenuId: MenuId.ViewTitle } + )); + + const failedSession = createSession({ status: ChatSessionStatus.Failed }); + const completedSession = createSession({ status: ChatSessionStatus.Completed }); + const inProgressSession = createSession({ status: ChatSessionStatus.InProgress }); + + // Exclude failed and in-progress + const excludes = { + providers: [], + states: [ChatSessionStatus.Failed, ChatSessionStatus.InProgress], + archived: false + }; + storageService.store(`agentSessions.filterExcludes.${MenuId.ViewTitle.id.toLowerCase()}`, JSON.stringify(excludes), StorageScope.PROFILE, StorageTarget.USER); + + assert.strictEqual(filter.exclude(failedSession), true); + assert.strictEqual(filter.exclude(completedSession), false); + assert.strictEqual(filter.exclude(inProgressSession), true); + }); + + test('should combine multiple filter conditions', () => { + const storageService = instantiationService.get(IStorageService); + const filter = disposables.add(instantiationService.createInstance( + AgentSessionsFilter, + { filterMenuId: MenuId.ViewTitle } + )); + + const session1 = createSession({ + providerType: 'type-1', + status: ChatSessionStatus.Failed, + isArchived: () => true + }); + + const session2 = createSession({ + providerType: 'type-2', + status: ChatSessionStatus.Completed, + isArchived: () => false + }); + + // Exclude type-1, failed status, and archived + const excludes = { + providers: ['type-1'], + states: [ChatSessionStatus.Failed], + archived: true + }; + storageService.store(`agentSessions.filterExcludes.${MenuId.ViewTitle.id.toLowerCase()}`, JSON.stringify(excludes), StorageScope.PROFILE, StorageTarget.USER); + + // session1 should be excluded for multiple reasons + assert.strictEqual(filter.exclude(session1), true); + // session2 should not be excluded + assert.strictEqual(filter.exclude(session2), false); + }); + + test('should emit onDidChange when excludes are updated', () => { + const storageService = instantiationService.get(IStorageService); + const filter = disposables.add(instantiationService.createInstance( + AgentSessionsFilter, + { filterMenuId: MenuId.ViewTitle } + )); + + let changeEventFired = false; + disposables.add(filter.onDidChange(() => { + changeEventFired = true; + })); + + // Update excludes + const excludes = { + providers: ['type-1'], + states: [], + archived: false + }; + storageService.store(`agentSessions.filterExcludes.${MenuId.ViewTitle.id.toLowerCase()}`, JSON.stringify(excludes), StorageScope.PROFILE, StorageTarget.USER); + + assert.strictEqual(changeEventFired, true); + }); + + test('should handle storage updates from other windows', () => { + const storageService = instantiationService.get(IStorageService); + const filter = disposables.add(instantiationService.createInstance( + AgentSessionsFilter, + { filterMenuId: MenuId.ViewTitle } + )); + + const session = createSession({ providerType: 'type-1' }); + + // Initially not excluded + assert.strictEqual(filter.exclude(session), false); + + // Simulate storage update from another window + const excludes = { + providers: ['type-1'], + states: [], + archived: false + }; + storageService.store(`agentSessions.filterExcludes.${MenuId.ViewTitle.id.toLowerCase()}`, JSON.stringify(excludes), StorageScope.PROFILE, StorageTarget.USER); + + // Should now be excluded + assert.strictEqual(filter.exclude(session), true); + }); + + test('should register provider filter actions', () => { const provider1: IChatSessionItemProvider = { - chatSessionType: 'type-1', + chatSessionType: 'custom-type-1', onDidChangeChatSessionItems: Event.None, - provideChatSessionItems: async () => { - resolveCount++; - resolvedProviders.push('type-1'); - return [{ - resource: URI.parse('test://session-1'), - label: 'Session 1', - timing: { startTime: Date.now() } - }]; - } - }; - - const provider2: IChatSessionItemProvider = { - chatSessionType: 'type-2', - onDidChangeChatSessionItems: Event.None, - provideChatSessionItems: async () => { - resolveCount++; - resolvedProviders.push('type-2'); - return [{ - resource: URI.parse('test://session-2'), - label: 'Session 2', - timing: { startTime: Date.now() } - }]; - } + provideChatSessionItems: async () => [] }; mockChatSessionsService.registerChatSessionItemProvider(provider1); - mockChatSessionsService.registerChatSessionItemProvider(provider2); - viewModel = createViewModel(); + const filter = disposables.add(instantiationService.createInstance( + AgentSessionsFilter, + { filterMenuId: MenuId.ViewTitle } + )); - // Call resolve with different types rapidly - they should accumulate - const promise1 = viewModel.resolve('type-1'); - const promise2 = viewModel.resolve(['type-2']); + // Filter should work with custom provider + const session = createSession({ providerType: 'custom-type-1' }); + assert.strictEqual(filter.exclude(session), false); + }); - await Promise.all([promise1, promise2]); + test('should handle providers registered after filter creation', () => { + const filter = disposables.add(instantiationService.createInstance( + AgentSessionsFilter, + { filterMenuId: MenuId.ViewTitle } + )); - // Both providers should be resolved - assert.strictEqual(viewModel.sessions.length, 2); + const provider: IChatSessionItemProvider = { + chatSessionType: 'new-type', + onDidChangeChatSessionItems: Event.None, + provideChatSessionItems: async () => [] + }; + + // Register provider after filter creation + mockChatSessionsService.registerChatSessionItemProvider(provider); + mockChatSessionsService.fireDidChangeItemsProviders(provider); + + // Filter should work with new provider + const session = createSession({ providerType: 'new-type' }); + assert.strictEqual(filter.exclude(session), false); + }); + + test('should not exclude when all filters are disabled', () => { + const storageService = instantiationService.get(IStorageService); + const filter = disposables.add(instantiationService.createInstance( + AgentSessionsFilter, + { filterMenuId: MenuId.ViewTitle } + )); + + const session = createSession({ + providerType: 'type-1', + status: ChatSessionStatus.Failed, + isArchived: () => true + }); + + // Disable all filters + const excludes = { + providers: [], + states: [], + archived: false + }; + storageService.store(`agentSessions.filterExcludes.${MenuId.ViewTitle.id.toLowerCase()}`, JSON.stringify(excludes), StorageScope.PROFILE, StorageTarget.USER); + + // Nothing should be excluded + assert.strictEqual(filter.exclude(session), false); + }); + + test('should handle empty provider list in storage', () => { + const storageService = instantiationService.get(IStorageService); + const filter = disposables.add(instantiationService.createInstance( + AgentSessionsFilter, + { filterMenuId: MenuId.ViewTitle } + )); + + const session = createSession({ providerType: 'type-1' }); + + // Set empty provider list + const excludes = { + providers: [], + states: [], + archived: false + }; + storageService.store(`agentSessions.filterExcludes.${MenuId.ViewTitle.id.toLowerCase()}`, JSON.stringify(excludes), StorageScope.PROFILE, StorageTarget.USER); + + assert.strictEqual(filter.exclude(session), false); + }); + + test('should handle different MenuId contexts', () => { + const storageService = instantiationService.get(IStorageService); + + // Create two filters with different menu IDs + const filter1 = disposables.add(instantiationService.createInstance( + AgentSessionsFilter, + { filterMenuId: MenuId.ViewTitle } + )); + + const filter2 = disposables.add(instantiationService.createInstance( + AgentSessionsFilter, + { filterMenuId: MenuId.ViewItemContext } + )); + + const session = createSession({ providerType: 'type-1' }); + + // Set excludes only for ViewTitle + const excludes = { + providers: ['type-1'], + states: [], + archived: false + }; + storageService.store(`agentSessions.filterExcludes.${MenuId.ViewTitle.id.toLowerCase()}`, JSON.stringify(excludes), StorageScope.PROFILE, StorageTarget.USER); + + // filter1 should exclude the session + assert.strictEqual(filter1.exclude(session), true); + // filter2 should not exclude the session (different storage key) + assert.strictEqual(filter2.exclude(session), false); + }); + + test('should handle malformed storage data gracefully', () => { + const storageService = instantiationService.get(IStorageService); + + // Store malformed JSON + storageService.store(`agentSessions.filterExcludes.${MenuId.ViewTitle.id.toLowerCase()}`, 'invalid json', StorageScope.PROFILE, StorageTarget.USER); + + // Filter should still be created with default excludes + const filter = disposables.add(instantiationService.createInstance( + AgentSessionsFilter, + { filterMenuId: MenuId.ViewTitle } + )); + + const archivedSession = createSession({ isArchived: () => true }); + // Default behavior: archived should be excluded + assert.strictEqual(filter.exclude(archivedSession), true); + }); + + test('should prioritize archived check first', () => { + const storageService = instantiationService.get(IStorageService); + const filter = disposables.add(instantiationService.createInstance( + AgentSessionsFilter, + { filterMenuId: MenuId.ViewTitle } + )); + + const session = createSession({ + providerType: 'type-1', + status: ChatSessionStatus.Completed, + isArchived: () => true + }); + + // Set excludes for provider and status, but include archived + const excludes = { + providers: ['type-1'], + states: [ChatSessionStatus.Completed], + archived: true + }; + storageService.store(`agentSessions.filterExcludes.${MenuId.ViewTitle.id.toLowerCase()}`, JSON.stringify(excludes), StorageScope.PROFILE, StorageTarget.USER); + + // Should be excluded due to archived (checked first) + assert.strictEqual(filter.exclude(session), true); + }); + + test('should handle all three status types correctly', () => { + const storageService = instantiationService.get(IStorageService); + const filter = disposables.add(instantiationService.createInstance( + AgentSessionsFilter, + { filterMenuId: MenuId.ViewTitle } + )); + + const completedSession = createSession({ status: ChatSessionStatus.Completed }); + const inProgressSession = createSession({ status: ChatSessionStatus.InProgress }); + const failedSession = createSession({ status: ChatSessionStatus.Failed }); + + // Exclude all statuses + const excludes = { + providers: [], + states: [ChatSessionStatus.Completed, ChatSessionStatus.InProgress, ChatSessionStatus.Failed], + archived: false + }; + storageService.store(`agentSessions.filterExcludes.${MenuId.ViewTitle.id.toLowerCase()}`, JSON.stringify(excludes), StorageScope.PROFILE, StorageTarget.USER); + + assert.strictEqual(filter.exclude(completedSession), true); + assert.strictEqual(filter.exclude(inProgressSession), true); + assert.strictEqual(filter.exclude(failedSession), true); }); }); -}); -suite('AgentSessionsViewModel - Helper Functions', () => { - const disposables = new DisposableStore(); + suite('AgentSessionsViewModel - Session Archiving', () => { + const disposables = new DisposableStore(); + let mockChatSessionsService: MockChatSessionsService; + let instantiationService: TestInstantiationService; + let viewModel: AgentSessionsModel; - teardown(() => { - disposables.clear(); + setup(() => { + mockChatSessionsService = new MockChatSessionsService(); + instantiationService = disposables.add(workbenchInstantiationService(undefined, disposables)); + instantiationService.stub(IChatSessionsService, mockChatSessionsService); + instantiationService.stub(ILifecycleService, disposables.add(new TestLifecycleService())); + }); + + teardown(() => { + disposables.clear(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('should archive and unarchive sessions', async () => { + return runWithFakedTimers({}, async () => { + const provider: IChatSessionItemProvider = { + chatSessionType: 'test-type', + onDidChangeChatSessionItems: Event.None, + provideChatSessionItems: async () => [ + { + resource: URI.parse('test://session-1'), + label: 'Test Session', + timing: { startTime: Date.now() } + } + ] + }; + + mockChatSessionsService.registerChatSessionItemProvider(provider); + viewModel = disposables.add(instantiationService.createInstance(AgentSessionsModel)); + + await viewModel.resolve(undefined); + + const session = viewModel.sessions[0]; + assert.strictEqual(session.isArchived(), false); + + // Archive the session + session.setArchived(true); + assert.strictEqual(session.isArchived(), true); + + // Unarchive the session + session.setArchived(false); + assert.strictEqual(session.isArchived(), false); + }); + }); + + test('should fire onDidChangeSessions when archiving', async () => { + return runWithFakedTimers({}, async () => { + const provider: IChatSessionItemProvider = { + chatSessionType: 'test-type', + onDidChangeChatSessionItems: Event.None, + provideChatSessionItems: async () => [ + { + resource: URI.parse('test://session-1'), + label: 'Test Session', + timing: { startTime: Date.now() } + } + ] + }; + + mockChatSessionsService.registerChatSessionItemProvider(provider); + viewModel = disposables.add(instantiationService.createInstance(AgentSessionsModel)); + + await viewModel.resolve(undefined); + + const session = viewModel.sessions[0]; + let changeEventFired = false; + disposables.add(viewModel.onDidChangeSessions(() => { + changeEventFired = true; + })); + + session.setArchived(true); + assert.strictEqual(changeEventFired, true); + }); + }); + + test('should not fire onDidChangeSessions when archiving with same value', async () => { + return runWithFakedTimers({}, async () => { + const provider: IChatSessionItemProvider = { + chatSessionType: 'test-type', + onDidChangeChatSessionItems: Event.None, + provideChatSessionItems: async () => [ + { + resource: URI.parse('test://session-1'), + label: 'Test Session', + timing: { startTime: Date.now() } + } + ] + }; + + mockChatSessionsService.registerChatSessionItemProvider(provider); + viewModel = disposables.add(instantiationService.createInstance(AgentSessionsModel)); + + await viewModel.resolve(undefined); + + const session = viewModel.sessions[0]; + session.setArchived(true); + + let changeEventFired = false; + disposables.add(viewModel.onDidChangeSessions(() => { + changeEventFired = true; + })); + + // Try to archive again with same value + session.setArchived(true); + assert.strictEqual(changeEventFired, false); + }); + }); + + test('should preserve archived state from provider', async () => { + return runWithFakedTimers({}, async () => { + const provider: IChatSessionItemProvider = { + chatSessionType: 'test-type', + onDidChangeChatSessionItems: Event.None, + provideChatSessionItems: async () => [ + { + resource: URI.parse('test://session-1'), + label: 'Test Session', + archived: true, + timing: { startTime: Date.now() } + } + ] + }; + + mockChatSessionsService.registerChatSessionItemProvider(provider); + viewModel = disposables.add(instantiationService.createInstance(AgentSessionsModel)); + + await viewModel.resolve(undefined); + + const session = viewModel.sessions[0]; + assert.strictEqual(session.isArchived(), true); + }); + }); + + test('should override provider archived state with user preference', async () => { + return runWithFakedTimers({}, async () => { + const provider: IChatSessionItemProvider = { + chatSessionType: 'test-type', + onDidChangeChatSessionItems: Event.None, + provideChatSessionItems: async () => [ + { + resource: URI.parse('test://session-1'), + label: 'Test Session', + archived: true, + timing: { startTime: Date.now() } + } + ] + }; + + mockChatSessionsService.registerChatSessionItemProvider(provider); + viewModel = disposables.add(instantiationService.createInstance(AgentSessionsModel)); + + await viewModel.resolve(undefined); + + const session = viewModel.sessions[0]; + assert.strictEqual(session.isArchived(), true); + + // User unarchives + session.setArchived(false); + assert.strictEqual(session.isArchived(), false); + + // Re-resolve should preserve user preference + await viewModel.resolve(undefined); + const sessionAfterResolve = viewModel.sessions[0]; + assert.strictEqual(sessionAfterResolve.isArchived(), false); + }); + }); }); - ensureNoDisposablesAreLeakedInTestSuite(); + suite('AgentSessionsViewModel - State Tracking', () => { + const disposables = new DisposableStore(); + let mockChatSessionsService: MockChatSessionsService; + let instantiationService: TestInstantiationService; + let viewModel: AgentSessionsModel; - test('isLocalAgentSessionItem should identify local sessions', () => { - const localSession: IAgentSessionViewModel = { - providerType: localChatSessionType, - providerLabel: 'Local', - icon: Codicon.chatSparkle, - resource: URI.parse('test://local-1'), - label: 'Local', - description: 'test', - timing: { startTime: Date.now() }, - archived: false, - status: ChatSessionStatus.Completed - }; + setup(() => { + mockChatSessionsService = new MockChatSessionsService(); + instantiationService = disposables.add(workbenchInstantiationService(undefined, disposables)); + instantiationService.stub(IChatSessionsService, mockChatSessionsService); + instantiationService.stub(ILifecycleService, disposables.add(new TestLifecycleService())); + }); - const remoteSession: IAgentSessionViewModel = { - providerType: 'remote', - providerLabel: 'Remote', - icon: Codicon.chatSparkle, - resource: URI.parse('test://remote-1'), - label: 'Remote', - description: 'test', - timing: { startTime: Date.now() }, - archived: false, - status: ChatSessionStatus.Completed - }; + teardown(() => { + disposables.clear(); + }); - assert.strictEqual(isLocalAgentSessionItem(localSession), true); - assert.strictEqual(isLocalAgentSessionItem(remoteSession), false); + ensureNoDisposablesAreLeakedInTestSuite(); + + test('should track status transitions', async () => { + return runWithFakedTimers({}, async () => { + let sessionStatus = ChatSessionStatus.InProgress; + + const provider: IChatSessionItemProvider = { + chatSessionType: 'test-type', + onDidChangeChatSessionItems: Event.None, + provideChatSessionItems: async () => [ + { + resource: URI.parse('test://session-1'), + label: 'Test Session', + status: sessionStatus, + timing: { startTime: Date.now() } + } + ] + }; + + mockChatSessionsService.registerChatSessionItemProvider(provider); + viewModel = disposables.add(instantiationService.createInstance(AgentSessionsModel)); + + await viewModel.resolve(undefined); + assert.strictEqual(viewModel.sessions[0].status, ChatSessionStatus.InProgress); + + // Change status + sessionStatus = ChatSessionStatus.Completed; + await viewModel.resolve(undefined); + assert.strictEqual(viewModel.sessions[0].status, ChatSessionStatus.Completed); + }); + }); + + test('should track inProgressTime when transitioning to InProgress', async () => { + return runWithFakedTimers({}, async () => { + let sessionStatus = ChatSessionStatus.Completed; + + const provider: IChatSessionItemProvider = { + chatSessionType: 'test-type', + onDidChangeChatSessionItems: Event.None, + provideChatSessionItems: async () => [ + { + resource: URI.parse('test://session-1'), + label: 'Test Session', + status: sessionStatus, + timing: { startTime: Date.now() } + } + ] + }; + + mockChatSessionsService.registerChatSessionItemProvider(provider); + viewModel = disposables.add(instantiationService.createInstance(AgentSessionsModel)); + + await viewModel.resolve(undefined); + const session1 = viewModel.sessions[0]; + assert.strictEqual(session1.timing.inProgressTime, undefined); + + // Change to InProgress + sessionStatus = ChatSessionStatus.InProgress; + await viewModel.resolve(undefined); + const session2 = viewModel.sessions[0]; + assert.notStrictEqual(session2.timing.inProgressTime, undefined); + }); + }); + + test('should track finishedOrFailedTime when transitioning from InProgress', async () => { + return runWithFakedTimers({}, async () => { + let sessionStatus = ChatSessionStatus.InProgress; + + const provider: IChatSessionItemProvider = { + chatSessionType: 'test-type', + onDidChangeChatSessionItems: Event.None, + provideChatSessionItems: async () => [ + { + resource: URI.parse('test://session-1'), + label: 'Test Session', + status: sessionStatus, + timing: { startTime: Date.now() } + } + ] + }; + + mockChatSessionsService.registerChatSessionItemProvider(provider); + viewModel = disposables.add(instantiationService.createInstance(AgentSessionsModel)); + + await viewModel.resolve(undefined); + const session1 = viewModel.sessions[0]; + assert.strictEqual(session1.timing.finishedOrFailedTime, undefined); + + // Change to Completed + sessionStatus = ChatSessionStatus.Completed; + await viewModel.resolve(undefined); + const session2 = viewModel.sessions[0]; + assert.notStrictEqual(session2.timing.finishedOrFailedTime, undefined); + }); + }); + + test('should clean up state tracking for removed sessions', async () => { + return runWithFakedTimers({}, async () => { + let includeSessions = true; + + const provider: IChatSessionItemProvider = { + chatSessionType: 'test-type', + onDidChangeChatSessionItems: Event.None, + provideChatSessionItems: async () => { + if (includeSessions) { + return [ + { + resource: URI.parse('test://session-1'), + label: 'Test Session', + timing: { startTime: Date.now() } + } + ]; + } + return []; + } + }; + + mockChatSessionsService.registerChatSessionItemProvider(provider); + viewModel = disposables.add(instantiationService.createInstance(AgentSessionsModel)); + + await viewModel.resolve(undefined); + assert.strictEqual(viewModel.sessions.length, 1); + + // Remove sessions + includeSessions = false; + await viewModel.resolve(undefined); + assert.strictEqual(viewModel.sessions.length, 0); + }); + }); }); - test('isAgentSession should identify session view models', () => { - const session: IAgentSessionViewModel = { - providerType: 'test', - providerLabel: 'Local', - icon: Codicon.chatSparkle, - resource: URI.parse('test://test-1'), - label: 'Test', - description: 'test', - timing: { startTime: Date.now() }, - archived: false, - status: ChatSessionStatus.Completed - }; + suite('AgentSessionsViewModel - Provider Icons and Names', () => { + const disposables = new DisposableStore(); - // Test with a session object - assert.strictEqual(isAgentSession(session), true); + teardown(() => { + disposables.clear(); + }); - // Test with a sessions container - pass as session to see it returns false - const sessionOrContainer: IAgentSessionViewModel = session; - assert.strictEqual(isAgentSession(sessionOrContainer), true); + ensureNoDisposablesAreLeakedInTestSuite(); + + test('should return correct name for Local provider', () => { + const name = getAgentSessionProviderName(AgentSessionProviders.Local); + assert.ok(name.length > 0); + }); + + test('should return correct name for Background provider', () => { + const name = getAgentSessionProviderName(AgentSessionProviders.Background); + assert.ok(name.length > 0); + }); + + test('should return correct name for Cloud provider', () => { + const name = getAgentSessionProviderName(AgentSessionProviders.Cloud); + assert.ok(name.length > 0); + }); + + test('should return correct icon for Local provider', () => { + const icon = getAgentSessionProviderIcon(AgentSessionProviders.Local); + assert.strictEqual(icon.id, Codicon.vm.id); + }); + + test('should return correct icon for Background provider', () => { + const icon = getAgentSessionProviderIcon(AgentSessionProviders.Background); + assert.strictEqual(icon.id, Codicon.collection.id); + }); + + test('should return correct icon for Cloud provider', () => { + const icon = getAgentSessionProviderIcon(AgentSessionProviders.Cloud); + assert.strictEqual(icon.id, Codicon.cloud.id); + }); + + test('should handle Local provider type in model', async () => { + return runWithFakedTimers({}, async () => { + const instantiationService = disposables.add(workbenchInstantiationService(undefined, disposables)); + const mockChatSessionsService = new MockChatSessionsService(); + instantiationService.stub(IChatSessionsService, mockChatSessionsService); + instantiationService.stub(ILifecycleService, disposables.add(new TestLifecycleService())); + + const provider: IChatSessionItemProvider = { + chatSessionType: AgentSessionProviders.Local, + onDidChangeChatSessionItems: Event.None, + provideChatSessionItems: async () => [ + { + resource: URI.parse('test://session-1'), + label: 'Test Session', + timing: { startTime: Date.now() } + } + ] + }; + + mockChatSessionsService.registerChatSessionItemProvider(provider); + const viewModel = disposables.add(instantiationService.createInstance(AgentSessionsModel)); + + await viewModel.resolve(undefined); + + const session = viewModel.sessions[0]; + assert.strictEqual(session.providerType, AgentSessionProviders.Local); + assert.strictEqual(session.icon.id, Codicon.vm.id); + assert.strictEqual(session.providerLabel, getAgentSessionProviderName(AgentSessionProviders.Local)); + }); + }); + + test('should handle Background provider type in model', async () => { + return runWithFakedTimers({}, async () => { + const instantiationService = disposables.add(workbenchInstantiationService(undefined, disposables)); + const mockChatSessionsService = new MockChatSessionsService(); + instantiationService.stub(IChatSessionsService, mockChatSessionsService); + instantiationService.stub(ILifecycleService, disposables.add(new TestLifecycleService())); + + const provider: IChatSessionItemProvider = { + chatSessionType: AgentSessionProviders.Background, + onDidChangeChatSessionItems: Event.None, + provideChatSessionItems: async () => [ + { + resource: URI.parse('test://session-1'), + label: 'Test Session', + timing: { startTime: Date.now() } + } + ] + }; + + mockChatSessionsService.registerChatSessionItemProvider(provider); + const viewModel = disposables.add(instantiationService.createInstance(AgentSessionsModel)); + + await viewModel.resolve(undefined); + + const session = viewModel.sessions[0]; + assert.strictEqual(session.providerType, AgentSessionProviders.Background); + assert.strictEqual(session.icon.id, Codicon.collection.id); + assert.strictEqual(session.providerLabel, getAgentSessionProviderName(AgentSessionProviders.Background)); + }); + }); + + test('should handle Cloud provider type in model', async () => { + return runWithFakedTimers({}, async () => { + const instantiationService = disposables.add(workbenchInstantiationService(undefined, disposables)); + const mockChatSessionsService = new MockChatSessionsService(); + instantiationService.stub(IChatSessionsService, mockChatSessionsService); + instantiationService.stub(ILifecycleService, disposables.add(new TestLifecycleService())); + + const provider: IChatSessionItemProvider = { + chatSessionType: AgentSessionProviders.Cloud, + onDidChangeChatSessionItems: Event.None, + provideChatSessionItems: async () => [ + { + resource: URI.parse('test://session-1'), + label: 'Test Session', + timing: { startTime: Date.now() } + } + ] + }; + + mockChatSessionsService.registerChatSessionItemProvider(provider); + const viewModel = disposables.add(instantiationService.createInstance(AgentSessionsModel)); + + await viewModel.resolve(undefined); + + const session = viewModel.sessions[0]; + assert.strictEqual(session.providerType, AgentSessionProviders.Cloud); + assert.strictEqual(session.icon.id, Codicon.cloud.id); + assert.strictEqual(session.providerLabel, getAgentSessionProviderName(AgentSessionProviders.Cloud)); + }); + }); + + test('should use custom icon from session item', async () => { + return runWithFakedTimers({}, async () => { + const instantiationService = disposables.add(workbenchInstantiationService(undefined, disposables)); + const mockChatSessionsService = new MockChatSessionsService(); + instantiationService.stub(IChatSessionsService, mockChatSessionsService); + instantiationService.stub(ILifecycleService, disposables.add(new TestLifecycleService())); + + const customIcon = ThemeIcon.fromId('beaker'); + const provider: IChatSessionItemProvider = { + chatSessionType: 'custom-type', + onDidChangeChatSessionItems: Event.None, + provideChatSessionItems: async () => [ + { + resource: URI.parse('test://session-1'), + label: 'Test Session', + iconPath: customIcon, + timing: { startTime: Date.now() } + } + ] + }; + + mockChatSessionsService.registerChatSessionItemProvider(provider); + const viewModel = disposables.add(instantiationService.createInstance(AgentSessionsModel)); + + await viewModel.resolve(undefined); + + const session = viewModel.sessions[0]; + assert.strictEqual(session.icon.id, customIcon.id); + }); + }); + + test('should use default icon for custom provider without iconPath', async () => { + return runWithFakedTimers({}, async () => { + const instantiationService = disposables.add(workbenchInstantiationService(undefined, disposables)); + const mockChatSessionsService = new MockChatSessionsService(); + instantiationService.stub(IChatSessionsService, mockChatSessionsService); + instantiationService.stub(ILifecycleService, disposables.add(new TestLifecycleService())); + + const provider: IChatSessionItemProvider = { + chatSessionType: 'custom-type', + onDidChangeChatSessionItems: Event.None, + provideChatSessionItems: async () => [ + { + resource: URI.parse('test://session-1'), + label: 'Test Session', + timing: { startTime: Date.now() } + } + ] + }; + + mockChatSessionsService.registerChatSessionItemProvider(provider); + const viewModel = disposables.add(instantiationService.createInstance(AgentSessionsModel)); + + await viewModel.resolve(undefined); + + const session = viewModel.sessions[0]; + assert.strictEqual(session.icon.id, Codicon.terminal.id); + }); + }); }); - test('isAgentSessionsViewModel should identify sessions view models', () => { - const session: IAgentSessionViewModel = { - providerType: 'test', - providerLabel: 'Local', - icon: Codicon.chatSparkle, - resource: URI.parse('test://test-1'), - label: 'Test', - description: 'test', - timing: { startTime: Date.now() }, - archived: false, - status: ChatSessionStatus.Completed - }; + suite('AgentSessionsViewModel - Cancellation and Lifecycle', () => { + const disposables = new DisposableStore(); + let mockChatSessionsService: MockChatSessionsService; + let mockLifecycleService: TestLifecycleService; + let instantiationService: TestInstantiationService; + let viewModel: AgentSessionsModel; - // Test with actual view model - const instantiationService = workbenchInstantiationService(undefined, disposables); - const lifecycleService = disposables.add(new TestLifecycleService()); - instantiationService.stub(IChatSessionsService, new MockChatSessionsService()); - instantiationService.stub(ILifecycleService, lifecycleService); - const actualViewModel = disposables.add(instantiationService.createInstance( - AgentSessionsViewModel, - { filterMenuId: MenuId.ViewTitle } - )); - assert.strictEqual(isAgentSessionsViewModel(actualViewModel), true); + setup(() => { + mockChatSessionsService = new MockChatSessionsService(); + mockLifecycleService = disposables.add(new TestLifecycleService()); + instantiationService = disposables.add(workbenchInstantiationService(undefined, disposables)); + instantiationService.stub(IChatSessionsService, mockChatSessionsService); + instantiationService.stub(ILifecycleService, mockLifecycleService); + }); - // Test with session object - assert.strictEqual(isAgentSessionsViewModel(session), false); - }); -}); + teardown(() => { + disposables.clear(); + }); -suite('AgentSessionsViewFilter', () => { - const disposables = new DisposableStore(); - let mockChatSessionsService: MockChatSessionsService; - let instantiationService: TestInstantiationService; + ensureNoDisposablesAreLeakedInTestSuite(); - setup(() => { - mockChatSessionsService = new MockChatSessionsService(); - instantiationService = disposables.add(workbenchInstantiationService(undefined, disposables)); - instantiationService.stub(IChatSessionsService, mockChatSessionsService); + test('should not resolve if lifecycle will shutdown', async () => { + return runWithFakedTimers({}, async () => { + const provider: IChatSessionItemProvider = { + chatSessionType: 'test-type', + onDidChangeChatSessionItems: Event.None, + provideChatSessionItems: async () => [ + { + resource: URI.parse('test://session-1'), + label: 'Test Session', + timing: { startTime: Date.now() } + } + ] + }; + + mockChatSessionsService.registerChatSessionItemProvider(provider); + viewModel = disposables.add(instantiationService.createInstance(AgentSessionsModel)); + + // Set willShutdown to true + mockLifecycleService.willShutdown = true; + + await viewModel.resolve(undefined); + + // Should not resolve sessions + assert.strictEqual(viewModel.sessions.length, 0); + }); + }); }); - teardown(() => { - disposables.clear(); + suite('AgentSessionsFilter - Dynamic Provider Registration', () => { + const disposables = new DisposableStore(); + let mockChatSessionsService: MockChatSessionsService; + let instantiationService: TestInstantiationService; + + setup(() => { + mockChatSessionsService = new MockChatSessionsService(); + instantiationService = disposables.add(workbenchInstantiationService(undefined, disposables)); + instantiationService.stub(IChatSessionsService, mockChatSessionsService); + }); + + teardown(() => { + disposables.clear(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('should respond to onDidChangeAvailability', () => { + const filter = disposables.add(instantiationService.createInstance( + AgentSessionsFilter, + { filterMenuId: MenuId.ViewTitle } + )); + + disposables.add(filter.onDidChange(() => { + // Event handler registered to verify filter responds to availability changes + })); + + // Trigger availability change + mockChatSessionsService.fireDidChangeAvailability(); + + // Filter should update its actions (internally) + // We can't directly test action registration but we verified event handling + }); }); - ensureNoDisposablesAreLeakedInTestSuite(); - - test('should filter out sessions from excluded provider', () => { - const storageService = instantiationService.get(IStorageService); - const filter = disposables.add(instantiationService.createInstance( - AgentSessionsViewFilter, - { filterMenuId: MenuId.ViewTitle } - )); - - const provider1: IChatSessionItemProvider = { - chatSessionType: 'type-1', - onDidChangeChatSessionItems: Event.None, - provideChatSessionItems: async () => [] - }; - - const provider2: IChatSessionItemProvider = { - chatSessionType: 'type-2', - onDidChangeChatSessionItems: Event.None, - provideChatSessionItems: async () => [] - }; - - const session1: IAgentSessionViewModel = { - providerType: provider1.chatSessionType, - providerLabel: 'Provider 1', - icon: Codicon.chatSparkle, - resource: URI.parse('test://session-1'), - label: 'Session 1', - timing: { startTime: Date.now() }, - archived: false, - status: ChatSessionStatus.Completed - }; - - const session2: IAgentSessionViewModel = { - providerType: provider2.chatSessionType, - providerLabel: 'Provider 2', - icon: Codicon.chatSparkle, - resource: URI.parse('test://session-2'), - label: 'Session 2', - timing: { startTime: Date.now() }, - archived: false, - status: ChatSessionStatus.Completed - }; - - // Initially, no sessions should be filtered - assert.strictEqual(filter.exclude(session1), false); - assert.strictEqual(filter.exclude(session2), false); - - // Exclude type-1 by setting it in storage - const excludes = { - providers: ['type-1'], - states: [], - archived: true - }; - storageService.store('agentSessions.filterExcludes', JSON.stringify(excludes), StorageScope.PROFILE, StorageTarget.USER); - - // After excluding type-1, session1 should be filtered but not session2 - assert.strictEqual(filter.exclude(session1), true); - assert.strictEqual(filter.exclude(session2), false); - }); - - test('should filter out archived sessions', () => { - const storageService = instantiationService.get(IStorageService); - const filter = disposables.add(instantiationService.createInstance( - AgentSessionsViewFilter, - { filterMenuId: MenuId.ViewTitle } - )); - - const provider: IChatSessionItemProvider = { - chatSessionType: 'test-type', - onDidChangeChatSessionItems: Event.None, - provideChatSessionItems: async () => [] - }; - - const archivedSession: IAgentSessionViewModel = { - providerType: provider.chatSessionType, - providerLabel: 'Test Provider', - icon: Codicon.chatSparkle, - resource: URI.parse('test://archived-session'), - label: 'Archived Session', - timing: { startTime: Date.now() }, - archived: true, - status: ChatSessionStatus.Completed - }; - - const activeSession: IAgentSessionViewModel = { - providerType: provider.chatSessionType, - providerLabel: 'Test Provider', - icon: Codicon.chatSparkle, - resource: URI.parse('test://active-session'), - label: 'Active Session', - timing: { startTime: Date.now() }, - archived: false, - status: ChatSessionStatus.Completed - }; - - // By default, archived sessions should be filtered (archived: true in default excludes) - assert.strictEqual(filter.exclude(archivedSession), true); - assert.strictEqual(filter.exclude(activeSession), false); - - // Include archived by setting archived to false in storage - const excludes = { - providers: [], - states: [], - archived: false - }; - storageService.store('agentSessions.filterExcludes', JSON.stringify(excludes), StorageScope.PROFILE, StorageTarget.USER); - - // After including archived, both sessions should not be filtered - assert.strictEqual(filter.exclude(archivedSession), false); - assert.strictEqual(filter.exclude(activeSession), false); - }); - - test('should filter out sessions with excluded status', () => { - const storageService = instantiationService.get(IStorageService); - const filter = disposables.add(instantiationService.createInstance( - AgentSessionsViewFilter, - { filterMenuId: MenuId.ViewTitle } - )); - - const provider: IChatSessionItemProvider = { - chatSessionType: 'test-type', - onDidChangeChatSessionItems: Event.None, - provideChatSessionItems: async () => [] - }; - - const failedSession: IAgentSessionViewModel = { - providerType: provider.chatSessionType, - providerLabel: 'Test Provider', - icon: Codicon.chatSparkle, - resource: URI.parse('test://failed-session'), - label: 'Failed Session', - timing: { startTime: Date.now() }, - archived: false, - status: ChatSessionStatus.Failed - }; - - const completedSession: IAgentSessionViewModel = { - providerType: provider.chatSessionType, - providerLabel: 'Test Provider', - icon: Codicon.chatSparkle, - resource: URI.parse('test://completed-session'), - label: 'Completed Session', - timing: { startTime: Date.now() }, - archived: false, - status: ChatSessionStatus.Completed - }; - - const inProgressSession: IAgentSessionViewModel = { - providerType: provider.chatSessionType, - providerLabel: 'Test Provider', - icon: Codicon.chatSparkle, - resource: URI.parse('test://inprogress-session'), - label: 'In Progress Session', - timing: { startTime: Date.now() }, - archived: false, - status: ChatSessionStatus.InProgress - }; - - // Initially, no sessions should be filtered by status - assert.strictEqual(filter.exclude(failedSession), false); - assert.strictEqual(filter.exclude(completedSession), false); - assert.strictEqual(filter.exclude(inProgressSession), false); - - // Exclude failed status by setting it in storage - const excludes = { - providers: [], - states: [ChatSessionStatus.Failed], - archived: false - }; - storageService.store('agentSessions.filterExcludes', JSON.stringify(excludes), StorageScope.PROFILE, StorageTarget.USER); - - // After excluding failed status, only failedSession should be filtered - assert.strictEqual(filter.exclude(failedSession), true); - assert.strictEqual(filter.exclude(completedSession), false); - assert.strictEqual(filter.exclude(inProgressSession), false); - }); -}); +}); // End of Agent Sessions suite