Path: blob/main/src/vs/workbench/contrib/chat/browser/chatSessions.ts
3296 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import './media/chatSessions.css';6import * as DOM from '../../../../base/browser/dom.js';7import { $, append, getActiveWindow } from '../../../../base/browser/dom.js';8import { ActionBar } from '../../../../base/browser/ui/actionbar/actionbar.js';9import { IListVirtualDelegate, IListRenderer } from '../../../../base/browser/ui/list/list.js';10import { IAsyncDataSource, ITreeNode, ITreeRenderer, ITreeContextMenuEvent } from '../../../../base/browser/ui/tree/tree.js';11import { coalesce } from '../../../../base/common/arrays.js';12import { CancellationToken } from '../../../../base/common/cancellation.js';13import { Codicon } from '../../../../base/common/codicons.js';14import { fromNow } from '../../../../base/common/date.js';15import { Emitter, Event } from '../../../../base/common/event.js';16import { FuzzyScore } from '../../../../base/common/filters.js';17import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js';18import { MarshalledId } from '../../../../base/common/marshallingIds.js';19import { ThemeIcon } from '../../../../base/common/themables.js';20import { URI } from '../../../../base/common/uri.js';21import { isMarkdownString } from '../../../../base/common/htmlContent.js';22import { IChatSessionRecommendation } from '../../../../base/common/product.js';23import * as nls from '../../../../nls.js';24import { getActionBarActions } from '../../../../platform/actions/browser/menuEntryActionViewItem.js';25import { Action2, IMenuService, MenuId, MenuRegistry, registerAction2 } from '../../../../platform/actions/common/actions.js';26import { ICommandService } from '../../../../platform/commands/common/commands.js';27import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';28import { ContextKeyExpr, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';29import { IContextMenuService, IContextViewService } from '../../../../platform/contextview/browser/contextView.js';30import { IHoverService } from '../../../../platform/hover/browser/hover.js';31import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js';32import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js';33import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js';34import { WorkbenchAsyncDataTree, WorkbenchList } from '../../../../platform/list/browser/listService.js';35import { ILogService } from '../../../../platform/log/common/log.js';36import { IOpenerService } from '../../../../platform/opener/common/opener.js';37import { IProductService } from '../../../../platform/product/common/productService.js';38import { IQuickInputService } from '../../../../platform/quickinput/common/quickInput.js';39import { Registry } from '../../../../platform/registry/common/platform.js';40import { IStorageService } from '../../../../platform/storage/common/storage.js';41import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js';42import { registerIcon } from '../../../../platform/theme/common/iconRegistry.js';43import { IThemeService } from '../../../../platform/theme/common/themeService.js';44import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js';45import { IResourceLabel, ResourceLabels } from '../../../browser/labels.js';46import { IViewPaneOptions, ViewPane } from '../../../browser/parts/views/viewPane.js';47import { ViewPaneContainer } from '../../../browser/parts/views/viewPaneContainer.js';48import { IWorkbenchContribution } from '../../../common/contributions.js';49import { EditorInput } from '../../../common/editor/editorInput.js';50import { Extensions, IEditableData, IViewContainersRegistry, IViewDescriptor, IViewDescriptorService, IViewsRegistry, ViewContainerLocation } from '../../../common/views.js';51import { IEditorGroup, IEditorGroupsService } from '../../../services/editor/common/editorGroupsService.js';52import { IEditorService } from '../../../services/editor/common/editorService.js';53import { IExtensionService } from '../../../services/extensions/common/extensions.js';54import { IExtensionGalleryService } from '../../../../platform/extensionManagement/common/extensionManagement.js';55import { IWorkbenchExtensionManagementService } from '../../../services/extensionManagement/common/extensionManagement.js';56import { IWorkbenchLayoutService } from '../../../services/layout/browser/layoutService.js';57import { IChatSessionItem, IChatSessionItemProvider, IChatSessionsExtensionPoint, IChatSessionsService, ChatSessionStatus } from '../common/chatSessionsService.js';58import { IViewsService } from '../../../services/views/common/viewsService.js';59import { ChatContextKeys } from '../common/chatContextKeys.js';60import { ChatAgentLocation, ChatConfiguration } from '../common/constants.js';61import { IChatWidget, IChatWidgetService, ChatViewId } from './chat.js';62import { ChatViewPane } from './chatViewPane.js';63import { ChatEditorInput } from './chatEditorInput.js';64import { IChatEditorOptions } from './chatEditor.js';65import { IChatService } from '../common/chatService.js';66import { ChatSessionUri } from '../common/chatUri.js';67import { InputBox, MessageType } from '../../../../base/browser/ui/inputbox/inputBox.js';68import Severity from '../../../../base/common/severity.js';69import { defaultInputBoxStyles } from '../../../../platform/theme/browser/defaultStyles.js';70import { createSingleCallFunction } from '../../../../base/common/functional.js';71import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js';72import { timeout } from '../../../../base/common/async.js';73import { KeyCode } from '../../../../base/common/keyCodes.js';74import { IProgressService } from '../../../../platform/progress/common/progress.js';75import { fillEditorsDragData } from '../../../browser/dnd.js';76import { IChatModel } from '../common/chatModel.js';77import { IObservable } from '../../../../base/common/observable.js';78import { ChatSessionItemWithProvider, getChatSessionType, isChatSession } from './chatSessions/common.js';79import { ChatSessionTracker } from './chatSessions/chatSessionTracker.js';80import { MarkdownRenderer } from '../../../../editor/browser/widget/markdownRenderer/browser/markdownRenderer.js';81import { allowedChatMarkdownHtmlTags } from './chatMarkdownRenderer.js';82import product from '../../../../platform/product/common/product.js';83import { truncate } from '../../../../base/common/strings.js';84import { IChatEntitlementService } from '../common/chatEntitlementService.js';8586export const VIEWLET_ID = 'workbench.view.chat.sessions';8788// Helper function to update relative time for chat sessions (similar to timeline)89function updateRelativeTime(item: ChatSessionItemWithProvider, lastRelativeTime: string | undefined): string | undefined {90if (item.timing?.startTime) {91item.relativeTime = fromNow(item.timing.startTime);92item.relativeTimeFullWord = fromNow(item.timing.startTime, false, true);93if (lastRelativeTime === undefined || item.relativeTime !== lastRelativeTime) {94lastRelativeTime = item.relativeTime;95item.hideRelativeTime = false;96} else {97item.hideRelativeTime = true;98}99} else {100// Clear timestamp properties if no timestamp101item.relativeTime = undefined;102item.relativeTimeFullWord = undefined;103item.hideRelativeTime = false;104}105106return lastRelativeTime;107}108109// Helper function to extract timestamp from session item110function extractTimestamp(item: IChatSessionItem): number | undefined {111// Use timing.startTime if available from the API112if (item.timing?.startTime) {113return item.timing.startTime;114}115116// For other items, timestamp might already be set117if ('timestamp' in item) {118return (item as any).timestamp;119}120121return undefined;122}123124// Helper function to sort sessions by timestamp (newest first)125function sortSessionsByTimestamp(sessions: ChatSessionItemWithProvider[]): void {126sessions.sort((a, b) => {127const aTime = a.timing?.startTime ?? 0;128const bTime = b.timing?.startTime ?? 0;129return bTime - aTime; // newest first130});131}132133// Helper function to apply time grouping to a list of sessions134function applyTimeGrouping(sessions: ChatSessionItemWithProvider[]): void {135let lastRelativeTime: string | undefined;136sessions.forEach(session => {137lastRelativeTime = updateRelativeTime(session, lastRelativeTime);138});139}140141// Helper function to process session items with timestamps, sorting, and grouping142function processSessionsWithTimeGrouping(sessions: ChatSessionItemWithProvider[]): void {143// Only process if we have sessions with timestamps144if (sessions.some(session => session.timing?.startTime !== undefined)) {145sortSessionsByTimestamp(sessions);146applyTimeGrouping(sessions);147}148}149150// Helper function to create context overlay for session items151function getSessionItemContextOverlay(152session: IChatSessionItem,153provider?: IChatSessionItemProvider,154chatWidgetService?: IChatWidgetService,155chatService?: IChatService,156editorGroupsService?: IEditorGroupsService157): [string, any][] {158const overlay: [string, any][] = [];159// Do not create an overaly for the show-history node160if (session.id === 'show-history') {161return overlay;162}163if (provider) {164overlay.push([ChatContextKeys.sessionType.key, provider.chatSessionType]);165}166167// Mark history items168const isHistoryItem = session.id.startsWith('history-');169overlay.push([ChatContextKeys.isHistoryItem.key, isHistoryItem]);170171// Mark active sessions - check if session is currently open in editor or widget172let isActiveSession = false;173174if (!isHistoryItem && provider?.chatSessionType === 'local') {175// Local non-history sessions are always active176isActiveSession = true;177} else if (isHistoryItem && chatWidgetService && chatService && editorGroupsService) {178// For history sessions, check if they're currently opened somewhere179const sessionId = session.id.substring('history-'.length); // Remove 'history-' prefix180181// Check if session is open in a chat widget182const widget = chatWidgetService.getWidgetBySessionId(sessionId);183if (widget) {184isActiveSession = true;185} else {186// Check if session is open in any editor187for (const group of editorGroupsService.groups) {188for (const editor of group.editors) {189if (editor instanceof ChatEditorInput && editor.sessionId === sessionId) {190isActiveSession = true;191break;192}193}194if (isActiveSession) {195break;196}197}198}199}200201overlay.push([ChatContextKeys.isActiveSession.key, isActiveSession]);202203return overlay;204}205206// Extended interface for local chat session items that includes editor information or widget information207export interface ILocalChatSessionItem extends IChatSessionItem {208editor?: EditorInput;209group?: IEditorGroup;210widget?: IChatWidget;211sessionType: 'editor' | 'widget';212description?: string;213status?: ChatSessionStatus;214}215216interface IGettingStartedItem {217id: string;218label: string;219commandId: string;220icon?: ThemeIcon;221args?: any[];222}223224class GettingStartedDelegate implements IListVirtualDelegate<IGettingStartedItem> {225getHeight(): number {226return 22;227}228229getTemplateId(): string {230return 'gettingStartedItem';231}232}233234interface IGettingStartedTemplateData {235resourceLabel: IResourceLabel;236}237238class GettingStartedRenderer implements IListRenderer<IGettingStartedItem, IGettingStartedTemplateData> {239readonly templateId = 'gettingStartedItem';240241constructor(private readonly labels: ResourceLabels) { }242243renderTemplate(container: HTMLElement): IGettingStartedTemplateData {244const resourceLabel = this.labels.create(container, { supportHighlights: true });245return { resourceLabel };246}247248renderElement(element: IGettingStartedItem, index: number, templateData: IGettingStartedTemplateData): void {249templateData.resourceLabel.setResource({250name: element.label,251resource: undefined252}, {253icon: element.icon,254hideIcon: false255});256templateData.resourceLabel.element.setAttribute('data-command', element.commandId);257}258259disposeTemplate(templateData: IGettingStartedTemplateData): void {260templateData.resourceLabel.dispose();261}262}263264export class ChatSessionsView extends Disposable implements IWorkbenchContribution {265static readonly ID = 'workbench.contrib.chatSessions';266267private isViewContainerRegistered = false;268private localProvider: LocalChatSessionsProvider | undefined;269private readonly sessionTracker: ChatSessionTracker;270271constructor(272@IConfigurationService private readonly configurationService: IConfigurationService,273@IInstantiationService private readonly instantiationService: IInstantiationService,274@IChatSessionsService private readonly chatSessionsService: IChatSessionsService,275@IChatEntitlementService private readonly chatEntitlementService: IChatEntitlementService276) {277super();278279this.sessionTracker = this._register(this.instantiationService.createInstance(ChatSessionTracker));280this.setupEditorTracking();281282// Create and register the local chat sessions provider immediately283// This ensures it's available even when the view container is not initialized284this.localProvider = this._register(this.instantiationService.createInstance(LocalChatSessionsProvider));285this._register(this.chatSessionsService.registerChatSessionItemProvider(this.localProvider));286287// Initial check288this.updateViewContainerRegistration();289290// Listen for configuration changes291this._register(this.configurationService.onDidChangeConfiguration(e => {292if (e.affectsConfiguration(ChatConfiguration.AgentSessionsViewLocation)) {293this.updateViewContainerRegistration();294}295}));296}297298private setupEditorTracking(): void {299this._register(this.sessionTracker.onDidChangeEditors(e => {300this.chatSessionsService.notifySessionItemsChanged(e.sessionType);301}));302}303304private updateViewContainerRegistration(): void {305const location = this.configurationService.getValue<string>(ChatConfiguration.AgentSessionsViewLocation);306307if (location === 'view' && !this.isViewContainerRegistered) {308this.registerViewContainer();309} else if (location !== 'view' && this.isViewContainerRegistered) {310// Note: VS Code doesn't support unregistering view containers311// Once registered, they remain registered for the session312// but you could hide them or make them conditional through 'when' clauses313}314}315316private registerViewContainer(): void {317if (this.isViewContainerRegistered) {318return;319}320321322if (this.chatEntitlementService.sentiment.hidden || this.chatEntitlementService.sentiment.disabled) {323return; // do not register container as AI features are hidden or disabled324}325326Registry.as<IViewContainersRegistry>(Extensions.ViewContainersRegistry).registerViewContainer(327{328id: VIEWLET_ID,329title: nls.localize2('chat.sessions', "Chat Sessions"),330ctorDescriptor: new SyncDescriptor(ChatSessionsViewPaneContainer, [this.sessionTracker]),331hideIfEmpty: false,332icon: registerIcon('chat-sessions-icon', Codicon.commentDiscussionSparkle, 'Icon for Chat Sessions View'),333order: 6334}, ViewContainerLocation.Sidebar);335}336}337338// Local Chat Sessions Provider - tracks open editors as chat sessions339class LocalChatSessionsProvider extends Disposable implements IChatSessionItemProvider {340static readonly CHAT_WIDGET_VIEW_ID = 'workbench.panel.chat.view.copilot';341readonly chatSessionType = 'local';342343private readonly _onDidChange = this._register(new Emitter<void>());344readonly onDidChange: Event<void> = this._onDidChange.event;345346readonly _onDidChangeChatSessionItems = this._register(new Emitter<void>());347public get onDidChangeChatSessionItems() { return this._onDidChangeChatSessionItems.event; }348349// Track the current editor set to detect actual new additions350private currentEditorSet = new Set<string>();351352// Maintain ordered list of editor keys to preserve consistent ordering353private editorOrder: string[] = [];354355constructor(356@IEditorGroupsService private readonly editorGroupService: IEditorGroupsService,357@IChatWidgetService private readonly chatWidgetService: IChatWidgetService,358@IChatService private readonly chatService: IChatService,359@IChatSessionsService private readonly chatSessionsService: IChatSessionsService,360) {361super();362363this.initializeCurrentEditorSet();364this.registerWidgetListeners();365366this._register(this.chatService.onDidDisposeSession(() => {367this._onDidChange.fire();368}));369370// Listen for global session items changes for our session type371this._register(this.chatSessionsService.onDidChangeSessionItems((sessionType) => {372if (sessionType === this.chatSessionType) {373this.initializeCurrentEditorSet();374this._onDidChange.fire();375}376}));377}378379private registerWidgetListeners(): void {380// Listen for new chat widgets being added/removed381this._register(this.chatWidgetService.onDidAddWidget(widget => {382// Only fire for chat view instance383if (widget.location === ChatAgentLocation.Panel &&384typeof widget.viewContext === 'object' &&385'viewId' in widget.viewContext &&386widget.viewContext.viewId === LocalChatSessionsProvider.CHAT_WIDGET_VIEW_ID) {387this._onDidChange.fire();388389// Listen for view model changes on this widget390this._register(widget.onDidChangeViewModel(() => {391this._onDidChange.fire();392if (widget.viewModel) {393this.registerProgressListener(widget.viewModel.model.requestInProgressObs);394}395}));396397// Listen for title changes on the current model398this.registerModelTitleListener(widget);399if (widget.viewModel) {400this.registerProgressListener(widget.viewModel.model.requestInProgressObs);401}402}403}));404405// Check for existing chat widgets and register listeners406const existingWidgets = this.chatWidgetService.getWidgetsByLocations(ChatAgentLocation.Panel)407.filter(widget => typeof widget.viewContext === 'object' && 'viewId' in widget.viewContext && widget.viewContext.viewId === LocalChatSessionsProvider.CHAT_WIDGET_VIEW_ID);408409existingWidgets.forEach(widget => {410this._register(widget.onDidChangeViewModel(() => {411this._onDidChange.fire();412this.registerModelTitleListener(widget);413}));414415// Register title listener for existing widget416this.registerModelTitleListener(widget);417if (widget.viewModel) {418this.registerProgressListener(widget.viewModel.model.requestInProgressObs);419}420});421}422423private registerProgressListener(observable: IObservable<boolean>) {424const progressEvent = Event.fromObservableLight(observable);425this._register(progressEvent(() => {426this._onDidChangeChatSessionItems.fire();427}));428}429430private registerModelTitleListener(widget: IChatWidget): void {431const model = widget.viewModel?.model;432if (model) {433// Listen for model changes, specifically for title changes via setCustomTitle434this._register(model.onDidChange((e) => {435// Fire change events for all title-related changes to refresh the tree436if (!e || e.kind === 'setCustomTitle') {437this._onDidChange.fire();438}439}));440}441}442443private initializeCurrentEditorSet(): void {444this.currentEditorSet.clear();445this.editorOrder = []; // Reset the order446447this.editorGroupService.groups.forEach(group => {448group.editors.forEach(editor => {449if (this.isLocalChatSession(editor)) {450const key = this.getEditorKey(editor, group);451this.currentEditorSet.add(key);452this.editorOrder.push(key);453}454});455});456}457458private getEditorKey(editor: EditorInput, group: IEditorGroup): string {459return `${group.id}-${editor.typeId}-${editor.resource?.toString() || editor.getName()}`;460}461462private isLocalChatSession(editor?: EditorInput): boolean {463// For the LocalChatSessionsProvider, we only want to track sessions that are actually 'local' type464if (!isChatSession(editor)) {465return false;466}467468if (!(editor instanceof ChatEditorInput)) {469return false;470}471472const sessionType = getChatSessionType(editor);473return sessionType === 'local';474}475476private modelToStatus(model: IChatModel): ChatSessionStatus | undefined {477if (model.requestInProgress) {478return ChatSessionStatus.InProgress;479} else {480const requests = model.getRequests();481if (requests.length > 0) {482// Check if the last request was completed successfully or failed483const lastRequest = requests[requests.length - 1];484if (lastRequest && lastRequest.response) {485if (lastRequest.response.isCanceled || lastRequest.response.result?.errorDetails) {486return ChatSessionStatus.Failed;487} else if (lastRequest.response.isComplete) {488return ChatSessionStatus.Completed;489} else {490return ChatSessionStatus.InProgress;491}492}493}494}495return;496}497498async provideChatSessionItems(token: CancellationToken): Promise<IChatSessionItem[]> {499const sessions: ChatSessionItemWithProvider[] = [];500// Create a map to quickly find editors by their key501const editorMap = new Map<string, { editor: EditorInput; group: IEditorGroup }>();502503this.editorGroupService.groups.forEach(group => {504group.editors.forEach(editor => {505if (editor instanceof ChatEditorInput) {506const key = this.getEditorKey(editor, group);507editorMap.set(key, { editor, group });508}509});510});511512// Add chat view instance513const chatWidget = this.chatWidgetService.getWidgetsByLocations(ChatAgentLocation.Panel)514.find(widget => typeof widget.viewContext === 'object' && 'viewId' in widget.viewContext && widget.viewContext.viewId === LocalChatSessionsProvider.CHAT_WIDGET_VIEW_ID);515const status = chatWidget?.viewModel?.model ? this.modelToStatus(chatWidget.viewModel.model) : undefined;516const widgetSession: ILocalChatSessionItem & ChatSessionItemWithProvider = {517id: LocalChatSessionsProvider.CHAT_WIDGET_VIEW_ID,518label: chatWidget?.viewModel?.model.title || nls.localize2('chat.sessions.chatView', "Chat").value,519description: nls.localize('chat.sessions.chatView.description', "Chat View"),520iconPath: Codicon.chatSparkle,521widget: chatWidget,522sessionType: 'widget',523status,524provider: this525};526sessions.push(widgetSession);527528// Build editor-based sessions in the order specified by editorOrder529this.editorOrder.forEach((editorKey, index) => {530const editorInfo = editorMap.get(editorKey);531if (editorInfo) {532const sessionId = `local-${editorInfo.group.id}-${index}`;533534// Determine status and timestamp for editor-based session535let status: ChatSessionStatus | undefined;536let timestamp: number | undefined;537if (editorInfo.editor instanceof ChatEditorInput && editorInfo.editor.sessionId) {538const model = this.chatService.getSession(editorInfo.editor.sessionId);539if (model) {540status = this.modelToStatus(model);541// Get the last interaction timestamp from the model542const requests = model.getRequests();543if (requests.length > 0) {544const lastRequest = requests[requests.length - 1];545timestamp = lastRequest.timestamp;546} else {547// Fallback to current time if no requests yet548timestamp = Date.now();549}550}551}552553const editorSession: ILocalChatSessionItem & ChatSessionItemWithProvider = {554id: sessionId,555label: editorInfo.editor.getName(),556iconPath: Codicon.chatSparkle,557editor: editorInfo.editor,558group: editorInfo.group,559sessionType: 'editor',560status,561provider: this,562timing: {563startTime: timestamp ?? 0564}565};566sessions.push(editorSession);567}568});569570// Add "Show history..." node at the end571return [...sessions, historyNode];572}573}574575const historyNode: IChatSessionItem = {576id: 'show-history',577label: nls.localize('chat.sessions.showHistory', "History"),578};579580// Chat sessions container581class ChatSessionsViewPaneContainer extends ViewPaneContainer {582private registeredViewDescriptors: Map<string, IViewDescriptor> = new Map();583584constructor(585private readonly sessionTracker: ChatSessionTracker,586@IInstantiationService instantiationService: IInstantiationService,587@IConfigurationService configurationService: IConfigurationService,588@IWorkbenchLayoutService layoutService: IWorkbenchLayoutService,589@IContextMenuService contextMenuService: IContextMenuService,590@ITelemetryService telemetryService: ITelemetryService,591@IExtensionService extensionService: IExtensionService,592@IThemeService themeService: IThemeService,593@IStorageService storageService: IStorageService,594@IWorkspaceContextService contextService: IWorkspaceContextService,595@IViewDescriptorService viewDescriptorService: IViewDescriptorService,596@ILogService logService: ILogService,597@IProductService private readonly productService: IProductService,598@IChatSessionsService private readonly chatSessionsService: IChatSessionsService,599) {600super(601VIEWLET_ID,602{603mergeViewWithContainerWhenSingleView: false,604},605instantiationService,606configurationService,607layoutService,608contextMenuService,609telemetryService,610extensionService,611themeService,612storageService,613contextService,614viewDescriptorService,615logService616);617618this.updateViewRegistration();619620// Listen for provider changes and register/unregister views accordingly621this._register(this.chatSessionsService.onDidChangeItemsProviders(() => {622this.updateViewRegistration();623}));624625// Listen for session items changes and refresh the appropriate provider tree626this._register(this.chatSessionsService.onDidChangeSessionItems((chatSessionType) => {627this.refreshProviderTree(chatSessionType);628}));629630// Listen for contribution availability changes and update view registration631this._register(this.chatSessionsService.onDidChangeAvailability(() => {632this.updateViewRegistration();633}));634}635636override getTitle(): string {637const title = nls.localize('chat.sessions.title', "Chat Sessions");638return title;639}640641private getAllChatSessionItemProviders(): IChatSessionItemProvider[] {642return Array.from(this.chatSessionsService.getAllChatSessionItemProviders());643}644645private refreshProviderTree(chatSessionType: string): void {646// Find the provider with the matching chatSessionType647const providers = this.getAllChatSessionItemProviders();648const targetProvider = providers.find(provider => provider.chatSessionType === chatSessionType);649650if (targetProvider) {651// Find the corresponding view and refresh its tree652const viewId = `${VIEWLET_ID}.${chatSessionType}`;653const view = this.getView(viewId) as SessionsViewPane | undefined;654if (view) {655view.refreshTree();656}657}658}659660private async updateViewRegistration(): Promise<void> {661// prepare all chat session providers662const contributions = this.chatSessionsService.getAllChatSessionContributions();663await Promise.all(contributions.map(contrib => this.chatSessionsService.canResolveItemProvider(contrib.type)));664const currentProviders = this.getAllChatSessionItemProviders();665const currentProviderIds = new Set(currentProviders.map(p => p.chatSessionType));666667// Find views that need to be unregistered (providers that are no longer available)668const viewsToUnregister: IViewDescriptor[] = [];669for (const [providerId, viewDescriptor] of this.registeredViewDescriptors.entries()) {670if (!currentProviderIds.has(providerId)) {671viewsToUnregister.push(viewDescriptor);672this.registeredViewDescriptors.delete(providerId);673}674}675676// Unregister removed views677if (viewsToUnregister.length > 0) {678const container = Registry.as<IViewContainersRegistry>(Extensions.ViewContainersRegistry).get(VIEWLET_ID);679if (container) {680Registry.as<IViewsRegistry>(Extensions.ViewsRegistry).deregisterViews(viewsToUnregister, container);681}682}683684// Register new views685this.registerViews(contributions);686}687688private async registerViews(extensionPointContributions: IChatSessionsExtensionPoint[]) {689const container = Registry.as<IViewContainersRegistry>(Extensions.ViewContainersRegistry).get(VIEWLET_ID);690const providers = this.getAllChatSessionItemProviders();691692if (container && providers.length > 0) {693const viewDescriptorsToRegister: IViewDescriptor[] = [];694695// Separate providers by type and prepare display names696const localProvider = providers.find(p => p.chatSessionType === 'local');697const historyProvider = providers.find(p => p.chatSessionType === 'history');698const otherProviders = providers.filter(p => p.chatSessionType !== 'local' && p.chatSessionType !== 'history');699700// Sort other providers alphabetically by display name701const providersWithDisplayNames = otherProviders.map(provider => {702const extContribution = extensionPointContributions.find(c => c.type === provider.chatSessionType);703if (!extContribution) {704this.logService.warn(`No extension contribution found for chat session type: ${provider.chatSessionType}`);705return null;706}707return {708provider,709displayName: extContribution.displayName710};711}).filter(item => item !== null) as Array<{ provider: IChatSessionItemProvider; displayName: string }>;712713// Sort alphabetically by display name714providersWithDisplayNames.sort((a, b) => a.displayName.localeCompare(b.displayName));715716// Register views in priority order: local, history, then alphabetically sorted others717const orderedProviders = [718...(localProvider ? [{ provider: localProvider, displayName: 'Local Chat Sessions', baseOrder: 0 }] : []),719...(historyProvider ? [{ provider: historyProvider, displayName: 'History', baseOrder: 1, when: undefined }] : []),720...providersWithDisplayNames.map((item, index) => ({721...item,722baseOrder: 2 + index, // Start from 2 for other providers723when: undefined,724}))725];726727orderedProviders.forEach(({ provider, displayName, baseOrder, when }) => {728// Only register if not already registered729if (!this.registeredViewDescriptors.has(provider.chatSessionType)) {730const viewDescriptor: IViewDescriptor = {731id: `${VIEWLET_ID}.${provider.chatSessionType}`,732name: {733value: displayName,734original: displayName,735},736ctorDescriptor: new SyncDescriptor(SessionsViewPane, [provider, this.sessionTracker]),737canToggleVisibility: true,738canMoveView: true,739order: baseOrder, // Use computed order based on priority and alphabetical sorting740when,741};742743viewDescriptorsToRegister.push(viewDescriptor);744this.registeredViewDescriptors.set(provider.chatSessionType, viewDescriptor);745746if (provider.chatSessionType === 'local') {747const viewsRegistry = Registry.as<IViewsRegistry>(Extensions.ViewsRegistry);748this._register(viewsRegistry.registerViewWelcomeContent(viewDescriptor.id, {749content: nls.localize('chatSessions.noResults', "No local chat sessions\n[Start a Chat](command:workbench.action.openChat)"),750}));751}752}753});754755const gettingStartedViewId = `${VIEWLET_ID}.gettingStarted`;756if (!this.registeredViewDescriptors.has('gettingStarted')757&& this.productService.chatSessionRecommendations758&& this.productService.chatSessionRecommendations.length) {759const gettingStartedDescriptor: IViewDescriptor = {760id: gettingStartedViewId,761name: {762value: nls.localize('chat.sessions.gettingStarted', "Getting Started"),763original: 'Getting Started',764},765ctorDescriptor: new SyncDescriptor(SessionsViewPane, [null, this.sessionTracker]),766canToggleVisibility: true,767canMoveView: true,768order: 1000,769collapsed: !!otherProviders.length,770};771viewDescriptorsToRegister.push(gettingStartedDescriptor);772this.registeredViewDescriptors.set('gettingStarted', gettingStartedDescriptor);773}774775if (viewDescriptorsToRegister.length > 0) {776Registry.as<IViewsRegistry>(Extensions.ViewsRegistry).registerViews(viewDescriptorsToRegister, container);777}778}779}780781override dispose(): void {782// Unregister all views before disposal783if (this.registeredViewDescriptors.size > 0) {784const container = Registry.as<IViewContainersRegistry>(Extensions.ViewContainersRegistry).get(VIEWLET_ID);785if (container) {786const allRegisteredViews = Array.from(this.registeredViewDescriptors.values());787Registry.as<IViewsRegistry>(Extensions.ViewsRegistry).deregisterViews(allRegisteredViews, container);788}789this.registeredViewDescriptors.clear();790}791792super.dispose();793}794}795796797// Chat sessions item data source for the tree798class SessionsDataSource implements IAsyncDataSource<IChatSessionItemProvider, ChatSessionItemWithProvider> {799800constructor(801private readonly provider: IChatSessionItemProvider,802private readonly chatService: IChatService,803private readonly sessionTracker: ChatSessionTracker,804) {805}806807hasChildren(element: IChatSessionItemProvider | ChatSessionItemWithProvider): boolean {808const isProvider = element === this.provider;809if (isProvider) {810// Root provider always has children811return true;812}813814// Check if this is the "Show history..." node815if ('id' in element && element.id === historyNode.id) {816return true;817}818819return false;820}821822async getChildren(element: IChatSessionItemProvider | ChatSessionItemWithProvider): Promise<ChatSessionItemWithProvider[]> {823if (element === this.provider) {824try {825const items = await this.provider.provideChatSessionItems(CancellationToken.None);826const itemsWithProvider = items.map(item => {827const itemWithProvider: ChatSessionItemWithProvider = { ...item, provider: this.provider };828829// Extract timestamp using the helper function830itemWithProvider.timing = { startTime: extractTimestamp(item) ?? 0 };831832return itemWithProvider;833});834835// Add hybrid local editor sessions for this provider using the centralized service836if (this.provider.chatSessionType !== 'local') {837const hybridSessions = await this.sessionTracker.getHybridSessionsForProvider(this.provider);838itemsWithProvider.push(...(hybridSessions as ChatSessionItemWithProvider[]));839}840841// For non-local providers, apply time-based sorting and grouping842if (this.provider.chatSessionType !== 'local') {843processSessionsWithTimeGrouping(itemsWithProvider);844}845846return itemsWithProvider;847} catch (error) {848return [];849}850}851852// Check if this is the "Show history..." node853if ('id' in element && element.id === historyNode.id) {854return this.getHistoryItems();855}856857// Individual session items don't have children858return [];859}860861private async getHistoryItems(): Promise<ChatSessionItemWithProvider[]> {862try {863// Get all chat history864const allHistory = await this.chatService.getHistory();865866// Create history items with provider reference and timestamps867const historyItems = allHistory.map((historyDetail: any): ChatSessionItemWithProvider => ({868id: `history-${historyDetail.sessionId}`,869label: historyDetail.title,870iconPath: Codicon.chatSparkle,871provider: this.provider,872timing: {873startTime: historyDetail.lastMessageDate ?? Date.now()874}875}));876877// Apply sorting and time grouping878processSessionsWithTimeGrouping(historyItems);879880return historyItems;881882} catch (error) {883return [];884}885}886}887888// Tree delegate for session items889class SessionsDelegate implements IListVirtualDelegate<ChatSessionItemWithProvider> {890static readonly ITEM_HEIGHT = 22;891static readonly ITEM_HEIGHT_WITH_DESCRIPTION = 44; // Slightly smaller for cleaner look892893constructor(private readonly configurationService: IConfigurationService) { }894895getHeight(element: ChatSessionItemWithProvider): number {896// Return consistent height for all items (single-line layout)897if (element.description && this.configurationService.getValue(ChatConfiguration.ShowAgentSessionsViewDescription) && element.provider.chatSessionType !== 'local') {898return SessionsDelegate.ITEM_HEIGHT_WITH_DESCRIPTION;899} else {900return SessionsDelegate.ITEM_HEIGHT;901}902}903904getTemplateId(element: ChatSessionItemWithProvider): string {905return SessionsRenderer.TEMPLATE_ID;906}907}908909// Template data for session items910interface ISessionTemplateData {911readonly container: HTMLElement;912readonly resourceLabel: IResourceLabel;913readonly actionBar: ActionBar;914readonly elementDisposable: DisposableStore;915readonly timestamp: HTMLElement;916readonly descriptionRow: HTMLElement;917readonly descriptionLabel: HTMLElement;918readonly statisticsLabel: HTMLElement;919}920921// Renderer for session items in the tree922class SessionsRenderer extends Disposable implements ITreeRenderer<IChatSessionItem, FuzzyScore, ISessionTemplateData> {923static readonly TEMPLATE_ID = 'session';924private appliedIconColorStyles = new Set<string>();925private markdownRenderer: MarkdownRenderer;926927constructor(928private readonly labels: ResourceLabels,929@IThemeService private readonly themeService: IThemeService,930@ILogService private readonly logService: ILogService,931@IContextViewService private readonly contextViewService: IContextViewService,932@IConfigurationService private readonly configurationService: IConfigurationService,933@IChatSessionsService private readonly chatSessionsService: IChatSessionsService,934@IMenuService private readonly menuService: IMenuService,935@IContextKeyService private readonly contextKeyService: IContextKeyService,936@IHoverService private readonly hoverService: IHoverService,937@IInstantiationService instantiationService: IInstantiationService,938@IChatWidgetService private readonly chatWidgetService: IChatWidgetService,939@IChatService private readonly chatService: IChatService,940@IEditorGroupsService private readonly editorGroupsService: IEditorGroupsService,941) {942super();943944// Listen for theme changes to clear applied styles945this._register(this.themeService.onDidColorThemeChange(() => {946this.appliedIconColorStyles.clear();947}));948949this.markdownRenderer = instantiationService.createInstance(MarkdownRenderer, {});950}951952private applyIconColorStyle(iconId: string, colorId: string): void {953const styleKey = `${iconId}-${colorId}`;954if (this.appliedIconColorStyles.has(styleKey)) {955return; // Already applied956}957958const colorTheme = this.themeService.getColorTheme();959const color = colorTheme.getColor(colorId);960961if (color) {962// Target the ::before pseudo-element where the actual icon is rendered963const css = `.monaco-workbench .chat-session-item .monaco-icon-label.codicon-${iconId}::before { color: ${color} !important; }`;964const activeWindow = getActiveWindow();965966const styleId = `chat-sessions-icon-${styleKey}`;967const existingStyle = activeWindow.document.getElementById(styleId);968if (existingStyle) {969existingStyle.textContent = css;970} else {971const styleElement = activeWindow.document.createElement('style');972styleElement.id = styleId;973styleElement.textContent = css;974activeWindow.document.head.appendChild(styleElement);975976// Clean up on dispose977this._register({978dispose: () => {979const activeWin = getActiveWindow();980const style = activeWin.document.getElementById(styleId);981if (style) {982style.remove();983}984}985});986}987988this.appliedIconColorStyles.add(styleKey);989} else {990this.logService.debug('No color found for colorId:', colorId);991}992}993994private isLocalChatSessionItem(item: IChatSessionItem): item is ILocalChatSessionItem {995return ('editor' in item && 'group' in item) || ('widget' in item && 'sessionType' in item);996}997998get templateId(): string {999return SessionsRenderer.TEMPLATE_ID;1000}10011002renderTemplate(container: HTMLElement): ISessionTemplateData {1003const element = append(container, $('.chat-session-item'));10041005// Create a container that holds the label, timestamp, and actions1006const contentContainer = append(element, $('.session-content'));1007const resourceLabel = this.labels.create(contentContainer, { supportHighlights: true });1008const descriptionRow = append(element, $('.description-row'));1009const descriptionLabel = append(descriptionRow, $('span.description'));1010const statisticsLabel = append(descriptionRow, $('span.statistics'));10111012// Create timestamp container and element1013const timestampContainer = append(contentContainer, $('.timestamp-container'));1014const timestamp = append(timestampContainer, $('.timestamp'));10151016const actionsContainer = append(contentContainer, $('.actions'));1017const actionBar = new ActionBar(actionsContainer);1018const elementDisposable = new DisposableStore();10191020return {1021container: element,1022resourceLabel,1023actionBar,1024elementDisposable,1025timestamp,1026descriptionRow,1027descriptionLabel,1028statisticsLabel,1029};1030}10311032statusToIcon(status?: ChatSessionStatus) {1033switch (status) {1034case ChatSessionStatus.InProgress:1035return Codicon.loading;1036case ChatSessionStatus.Completed:1037return Codicon.pass;1038case ChatSessionStatus.Failed:1039return Codicon.error;1040default:1041return Codicon.circleOutline;1042}10431044}10451046renderElement(element: ITreeNode<IChatSessionItem, FuzzyScore>, index: number, templateData: ISessionTemplateData): void {1047const session = element.element;1048const sessionWithProvider = session as ChatSessionItemWithProvider;10491050// Add CSS class for local sessions1051if (sessionWithProvider.provider.chatSessionType === 'local') {1052templateData.container.classList.add('local-session');1053} else {1054templateData.container.classList.remove('local-session');1055}10561057// Get the actual session ID for editable data lookup1058let actualSessionId: string | undefined;1059if (this.isLocalChatSessionItem(session)) {1060if (session.sessionType === 'editor' && session.editor instanceof ChatEditorInput) {1061actualSessionId = session.editor.sessionId;1062} else if (session.sessionType === 'widget' && session.widget) {1063actualSessionId = session.widget.viewModel?.model.sessionId;1064}1065} else if (session.id.startsWith('history-')) {1066// For history items, extract the actual session ID by removing the 'history-' prefix1067actualSessionId = session.id.substring('history-'.length);1068}10691070// Check if this session is being edited using the actual session ID1071const editableData = actualSessionId ? this.chatSessionsService.getEditableData(actualSessionId) : undefined;1072if (editableData) {1073// Render input box for editing1074templateData.actionBar.clear();1075const editDisposable = this.renderInputBox(templateData.container, session, editableData);1076templateData.elementDisposable.add(editDisposable);1077return;1078}10791080// Normal rendering - clear the action bar in case it was used for editing1081templateData.actionBar.clear();10821083// Handle different icon types1084let iconResource: URI | undefined;1085let iconTheme: ThemeIcon | undefined;1086if (!session.iconPath && session.id !== historyNode.id) {1087iconTheme = this.statusToIcon(session.status);1088} else {1089iconTheme = session.iconPath;1090}10911092if (iconTheme?.color?.id) {1093this.applyIconColorStyle(iconTheme.id, iconTheme.color.id);1094}10951096const renderDescriptionOnSecondRow = this.configurationService.getValue(ChatConfiguration.ShowAgentSessionsViewDescription) && sessionWithProvider.provider.chatSessionType !== 'local';10971098if (renderDescriptionOnSecondRow && session.description) {1099templateData.container.classList.toggle('multiline', true);1100templateData.descriptionRow.style.display = 'flex';1101if (typeof session.description === 'string') {1102templateData.descriptionLabel.textContent = session.description;1103} else {1104templateData.elementDisposable.add(this.markdownRenderer.render(session.description, {1105sanitizerConfig: {1106replaceWithPlaintext: true,1107allowedTags: {1108override: allowedChatMarkdownHtmlTags,1109},1110allowedLinkSchemes: { augment: [product.urlProtocol] }1111},1112}, templateData.descriptionLabel));1113templateData.elementDisposable.add(DOM.addDisposableListener(templateData.descriptionLabel, 'mousedown', e => e.stopPropagation()));1114templateData.elementDisposable.add(DOM.addDisposableListener(templateData.descriptionLabel, 'click', e => e.stopPropagation()));1115templateData.elementDisposable.add(DOM.addDisposableListener(templateData.descriptionLabel, 'auxclick', e => e.stopPropagation()));1116}11171118DOM.clearNode(templateData.statisticsLabel);1119const insertionNode = append(templateData.statisticsLabel, $('span.insertions'));1120insertionNode.textContent = session.statistics ? `+${session.statistics.insertions}` : '';1121const deletionNode = append(templateData.statisticsLabel, $('span.deletions'));1122deletionNode.textContent = session.statistics ? `-${session.statistics.deletions}` : '';1123} else {1124templateData.container.classList.toggle('multiline', false);1125}11261127// Prepare tooltip content1128const tooltipContent = 'tooltip' in session && session.tooltip ?1129(typeof session.tooltip === 'string' ? session.tooltip :1130isMarkdownString(session.tooltip) ? {1131markdown: session.tooltip,1132markdownNotSupportedFallback: session.tooltip.value1133} : undefined) :1134undefined;11351136// Set the resource label1137templateData.resourceLabel.setResource({1138name: session.label,1139description: !renderDescriptionOnSecondRow && 'description' in session && typeof session.description === 'string' ? session.description : '',1140resource: iconResource1141}, {1142fileKind: undefined,1143icon: iconTheme,1144// Set tooltip on resourceLabel only for single-row items1145title: !renderDescriptionOnSecondRow || !session.description ? tooltipContent : undefined1146});11471148// For two-row items, set tooltip on the container instead1149if (renderDescriptionOnSecondRow && session.description && tooltipContent) {1150if (typeof tooltipContent === 'string') {1151templateData.elementDisposable.add(1152this.hoverService.setupDelayedHover(templateData.container, { content: tooltipContent })1153);1154} else if (tooltipContent && typeof tooltipContent === 'object' && 'markdown' in tooltipContent) {1155templateData.elementDisposable.add(1156this.hoverService.setupDelayedHover(templateData.container, { content: tooltipContent.markdown })1157);1158}1159}11601161// Handle timestamp display and grouping1162const hasTimestamp = sessionWithProvider.timing?.startTime !== undefined;1163if (hasTimestamp) {1164templateData.timestamp.textContent = sessionWithProvider.relativeTime ?? '';1165templateData.timestamp.ariaLabel = sessionWithProvider.relativeTimeFullWord ?? '';1166templateData.timestamp.parentElement!.classList.toggle('timestamp-duplicate', sessionWithProvider.hideRelativeTime === true);1167templateData.timestamp.parentElement!.style.display = '';1168} else {1169// Hide timestamp container if no timestamp available1170templateData.timestamp.parentElement!.style.display = 'none';1171}11721173// Create context overlay for this specific session item1174const contextOverlay = getSessionItemContextOverlay(1175session,1176sessionWithProvider.provider,1177this.chatWidgetService,1178this.chatService,1179this.editorGroupsService1180);11811182const contextKeyService = this.contextKeyService.createOverlay(contextOverlay);11831184// Create menu for this session item1185const menu = templateData.elementDisposable.add(1186this.menuService.createMenu(MenuId.ChatSessionsMenu, contextKeyService)1187);11881189// Setup action bar with contributed actions1190const setupActionBar = () => {1191templateData.actionBar.clear();11921193// Create marshalled context for command execution1194const marshalledSession = {1195session: session,1196$mid: MarshalledId.ChatSessionContext1197};11981199const actions = menu.getActions({ arg: marshalledSession, shouldForwardArgs: true });12001201const { primary } = getActionBarActions(1202actions,1203'inline',1204);12051206templateData.actionBar.push(primary, { icon: true, label: false });12071208// Set context for the action bar1209templateData.actionBar.context = session;1210};12111212// Setup initial action bar and listen for menu changes1213templateData.elementDisposable.add(menu.onDidChange(() => setupActionBar()));1214setupActionBar();1215}12161217disposeElement(_element: ITreeNode<IChatSessionItem, FuzzyScore>, _index: number, templateData: ISessionTemplateData): void {1218templateData.elementDisposable.clear();1219templateData.resourceLabel.clear();1220templateData.actionBar.clear();1221}12221223private renderInputBox(container: HTMLElement, session: IChatSessionItem, editableData: IEditableData): DisposableStore {1224// Hide the existing resource label element and session content1225const existingResourceLabelElement = container.querySelector('.monaco-icon-label') as HTMLElement;1226if (existingResourceLabelElement) {1227existingResourceLabelElement.style.display = 'none';1228}12291230// Hide the session content container to avoid layout conflicts1231const sessionContentElement = container.querySelector('.session-content') as HTMLElement;1232if (sessionContentElement) {1233sessionContentElement.style.display = 'none';1234}12351236// Create a simple container that mimics the file explorer's structure1237const editContainer = DOM.append(container, DOM.$('.explorer-item.explorer-item-edited'));12381239// Add the icon1240const iconElement = DOM.append(editContainer, DOM.$('.codicon'));1241if (session.iconPath && ThemeIcon.isThemeIcon(session.iconPath)) {1242iconElement.classList.add(`codicon-${session.iconPath.id}`);1243} else {1244iconElement.classList.add('codicon-file'); // Default file icon1245}12461247// Create the input box directly1248const inputBox = new InputBox(editContainer, this.contextViewService, {1249validationOptions: {1250validation: (value) => {1251const message = editableData.validationMessage(value);1252if (!message || message.severity !== Severity.Error) {1253return null;1254}1255return {1256content: message.content,1257formatContent: true,1258type: MessageType.ERROR1259};1260}1261},1262ariaLabel: nls.localize('chatSessionInputAriaLabel', "Type session name. Press Enter to confirm or Escape to cancel."),1263inputBoxStyles: defaultInputBoxStyles,1264});12651266inputBox.value = session.label;1267inputBox.focus();1268inputBox.select({ start: 0, end: session.label.length });12691270const done = createSingleCallFunction((success: boolean, finishEditing: boolean) => {1271const value = inputBox.value;12721273// Clean up the edit container1274editContainer.style.display = 'none';1275editContainer.remove();12761277// Restore the original resource label1278if (existingResourceLabelElement) {1279existingResourceLabelElement.style.display = '';1280}12811282// Restore the session content container1283const sessionContentElement = container.querySelector('.session-content') as HTMLElement;1284if (sessionContentElement) {1285sessionContentElement.style.display = '';1286}12871288if (finishEditing) {1289editableData.onFinish(value, success);1290}1291});12921293const showInputBoxNotification = () => {1294if (inputBox.isInputValid()) {1295const message = editableData.validationMessage(inputBox.value);1296if (message) {1297inputBox.showMessage({1298content: message.content,1299formatContent: true,1300type: message.severity === Severity.Info ? MessageType.INFO : message.severity === Severity.Warning ? MessageType.WARNING : MessageType.ERROR1301});1302} else {1303inputBox.hideMessage();1304}1305}1306};1307showInputBoxNotification();13081309const disposables: IDisposable[] = [1310inputBox,1311DOM.addStandardDisposableListener(inputBox.inputElement, DOM.EventType.KEY_DOWN, (e: StandardKeyboardEvent) => {1312if (e.equals(KeyCode.Enter)) {1313if (!inputBox.validate()) {1314done(true, true);1315}1316} else if (e.equals(KeyCode.Escape)) {1317done(false, true);1318}1319}),1320DOM.addStandardDisposableListener(inputBox.inputElement, DOM.EventType.KEY_UP, () => {1321showInputBoxNotification();1322}),1323DOM.addDisposableListener(inputBox.inputElement, DOM.EventType.BLUR, async () => {1324while (true) {1325await timeout(0);13261327const ownerDocument = inputBox.inputElement.ownerDocument;1328if (!ownerDocument.hasFocus()) {1329break;1330}1331if (DOM.isActiveElement(inputBox.inputElement)) {1332return;1333} else if (DOM.isHTMLElement(ownerDocument.activeElement) && DOM.hasParentWithClass(ownerDocument.activeElement, 'context-view')) {1334// Do nothing - context menu is open1335} else {1336break;1337}1338}13391340done(inputBox.isInputValid(), true);1341})1342];13431344const disposableStore = new DisposableStore();1345disposables.forEach(d => disposableStore.add(d));1346disposableStore.add(toDisposable(() => done(false, false)));1347return disposableStore;1348}13491350disposeTemplate(templateData: ISessionTemplateData): void {1351templateData.elementDisposable.dispose();1352templateData.resourceLabel.dispose();1353templateData.actionBar.dispose();1354}1355}13561357// Identity provider for session items1358class SessionsIdentityProvider {1359getId(element: ChatSessionItemWithProvider): string {1360return element.id;1361}1362}13631364// Accessibility provider for session items1365class SessionsAccessibilityProvider {1366getWidgetAriaLabel(): string {1367return nls.localize('chatSessions', 'Chat Sessions');1368}13691370getAriaLabel(element: ChatSessionItemWithProvider): string | null {1371return element.label || element.id;1372}1373}13741375class SessionsViewPane extends ViewPane {1376private tree: WorkbenchAsyncDataTree<IChatSessionItemProvider, ChatSessionItemWithProvider, FuzzyScore> | undefined;1377private list: WorkbenchList<IGettingStartedItem> | undefined;1378private treeContainer: HTMLElement | undefined;1379private messageElement?: HTMLElement;1380private _isEmpty: boolean = true;13811382constructor(1383private readonly provider: IChatSessionItemProvider,1384private readonly sessionTracker: ChatSessionTracker,1385options: IViewPaneOptions,1386@IKeybindingService keybindingService: IKeybindingService,1387@IContextMenuService contextMenuService: IContextMenuService,1388@IConfigurationService configurationService: IConfigurationService,1389@IContextKeyService contextKeyService: IContextKeyService,1390@IViewDescriptorService viewDescriptorService: IViewDescriptorService,1391@IInstantiationService instantiationService: IInstantiationService,1392@IOpenerService openerService: IOpenerService,1393@IThemeService themeService: IThemeService,1394@IHoverService hoverService: IHoverService,1395@IChatService private readonly chatService: IChatService,1396@IEditorService private readonly editorService: IEditorService,1397@IViewsService private readonly viewsService: IViewsService,1398@ILogService private readonly logService: ILogService,1399@IProgressService private readonly progressService: IProgressService,1400@IMenuService private readonly menuService: IMenuService,1401@ICommandService private readonly commandService: ICommandService,1402@IChatWidgetService private readonly chatWidgetService: IChatWidgetService,1403@IEditorGroupsService private readonly editorGroupsService: IEditorGroupsService,1404) {1405super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService);14061407// Listen for changes in the provider if it's a LocalChatSessionsProvider1408if (provider instanceof LocalChatSessionsProvider) {1409this._register(provider.onDidChange(() => {1410if (this.tree && this.isBodyVisible()) {1411this.refreshTreeWithProgress();1412}1413}));1414}14151416// Listen for configuration changes to refresh view when description display changes1417this._register(this.configurationService.onDidChangeConfiguration(e => {1418if (e.affectsConfiguration(ChatConfiguration.ShowAgentSessionsViewDescription)) {1419if (this.tree && this.isBodyVisible()) {1420this.refreshTreeWithProgress();1421}1422}1423}));1424}14251426override shouldShowWelcome(): boolean {1427return this._isEmpty;1428}14291430private isLocalChatSessionItem(item: IChatSessionItem): item is ILocalChatSessionItem {1431return ('editor' in item && 'group' in item) || ('widget' in item && 'sessionType' in item);1432}14331434public refreshTree(): void {1435if (this.tree && this.isBodyVisible()) {1436this.refreshTreeWithProgress();1437}1438}14391440private isEmpty() {1441// Check if the tree has the provider node and get its children count1442if (!this.tree?.hasNode(this.provider)) {1443return true;1444}1445const providerNode = this.tree.getNode(this.provider);1446const childCount = providerNode.children?.length || 0;14471448return childCount === 0;1449}14501451/**1452* Updates the empty state message based on current tree data.1453* Uses the tree's existing data to avoid redundant provider calls.1454*/1455private updateEmptyState(): void {1456try {1457const newEmptyState = this.isEmpty();1458if (newEmptyState !== this._isEmpty) {1459this._isEmpty = newEmptyState;1460this._onDidChangeViewWelcomeState.fire();1461}1462} catch (error) {1463this.logService.error('Error checking tree data for empty state:', error);1464}1465}14661467/**1468* Refreshes the tree data with progress indication.1469* Shows a progress indicator while the tree updates its children from the provider.1470*/1471private async refreshTreeWithProgress(): Promise<void> {1472if (!this.tree) {1473return;1474}14751476try {1477await this.progressService.withProgress(1478{1479location: this.id, // Use the view ID as the progress location1480title: nls.localize('chatSessions.refreshing', 'Refreshing chat sessions...'),1481},1482async () => {1483await this.tree!.updateChildren(this.provider);1484}1485);14861487// Check for empty state after refresh using tree data1488this.updateEmptyState();1489} catch (error) {1490// Log error but don't throw to avoid breaking the UI1491this.logService.error('Error refreshing chat sessions tree:', error);1492}1493}14941495/**1496* Loads initial tree data with progress indication.1497* Shows a progress indicator while the tree loads data from the provider.1498*/1499private async loadDataWithProgress(): Promise<void> {1500if (!this.tree) {1501return;1502}15031504try {1505await this.progressService.withProgress(1506{1507location: this.id, // Use the view ID as the progress location1508title: nls.localize('chatSessions.loading', 'Loading chat sessions...'),1509},1510async () => {1511await this.tree!.setInput(this.provider);1512}1513);15141515// Check for empty state after loading using tree data1516this.updateEmptyState();1517} catch (error) {1518// Log error but don't throw to avoid breaking the UI1519this.logService.error('Error loading chat sessions data:', error);1520}1521}15221523protected override renderBody(container: HTMLElement): void {1524super.renderBody(container);15251526// For Getting Started view (null provider), show simple list1527if (this.provider === null) {1528this.renderGettingStartedList(container);1529return;1530}15311532this.treeContainer = DOM.append(container, DOM.$('.chat-sessions-tree-container'));1533// Create message element for empty state1534this.messageElement = append(container, $('.chat-sessions-message'));1535this.messageElement.style.display = 'none';1536// Create the tree components1537const dataSource = new SessionsDataSource(this.provider, this.chatService, this.sessionTracker);1538const delegate = new SessionsDelegate(this.configurationService);1539const identityProvider = new SessionsIdentityProvider();1540const accessibilityProvider = new SessionsAccessibilityProvider();15411542// Use the existing ResourceLabels service for consistent styling1543const labels = this.instantiationService.createInstance(ResourceLabels, { onDidChangeVisibility: this.onDidChangeBodyVisibility });1544const renderer = this.instantiationService.createInstance(SessionsRenderer, labels);1545this._register(renderer);15461547const getResourceForElement = (element: ChatSessionItemWithProvider): URI | null => {1548if (this.isLocalChatSessionItem(element)) {1549return null;1550}15511552if (element.provider.chatSessionType === 'local') {1553const actualSessionId = element.id.startsWith('history-') ? element.id.substring('history-'.length) : element.id;1554return ChatSessionUri.forSession(element.provider.chatSessionType, actualSessionId);1555}15561557return ChatSessionUri.forSession(element.provider.chatSessionType, element.id);1558};15591560this.tree = this.instantiationService.createInstance(1561WorkbenchAsyncDataTree,1562'ChatSessions',1563this.treeContainer,1564delegate,1565[renderer],1566dataSource,1567{1568dnd: {1569onDragStart: (data, originalEvent) => {1570try {1571const elements = data.getData() as ChatSessionItemWithProvider[];1572const uris = coalesce(elements.map(getResourceForElement));1573this.instantiationService.invokeFunction(accessor => fillEditorsDragData(accessor, uris, originalEvent));1574} catch {1575// noop1576}1577},1578getDragURI: (element: ChatSessionItemWithProvider) => {1579if (element.id === historyNode.id) {1580return null;1581}1582return getResourceForElement(element)?.toString() ?? null;1583},1584getDragLabel: (elements: ChatSessionItemWithProvider[]) => {1585if (elements.length === 1) {1586return elements[0].label;1587}1588return nls.localize('chatSessions.dragLabel', "{0} chat sessions", elements.length);1589},1590drop: () => { },1591onDragOver: () => false,1592dispose: () => { },1593},1594accessibilityProvider,1595identityProvider,1596multipleSelectionSupport: false,1597overrideStyles: {1598listBackground: undefined1599},1600setRowLineHeight: false16011602}1603) as WorkbenchAsyncDataTree<IChatSessionItemProvider, ChatSessionItemWithProvider, FuzzyScore>;16041605// Set the input1606this.tree.setInput(this.provider);16071608// Register tree events1609this._register(this.tree.onDidOpen((e) => {1610if (e.element) {1611this.openChatSession(e.element);1612}1613}));16141615// Register context menu event for right-click actions1616this._register(this.tree.onContextMenu((e) => {1617if (e.element && e.element.id !== historyNode.id) {1618this.showContextMenu(e);1619}1620}));16211622// Handle visibility changes to load data1623this._register(this.onDidChangeBodyVisibility(async visible => {1624if (visible && this.tree) {1625await this.loadDataWithProgress();1626}1627}));16281629// Initially load data if visible1630if (this.isBodyVisible() && this.tree) {1631this.loadDataWithProgress();1632}16331634this._register(this.tree);1635}16361637private renderGettingStartedList(container: HTMLElement): void {1638const listContainer = DOM.append(container, DOM.$('.getting-started-list-container'));1639const items: IGettingStartedItem[] = [1640{1641id: 'install-extensions',1642label: nls.localize('chatSessions.installExtensions', "Install Chat Extensions"),1643icon: Codicon.extensions,1644commandId: 'chat.sessions.gettingStarted'1645},1646{1647id: 'learn-more',1648label: nls.localize('chatSessions.learnMoreGHCodingAgent', "Learn More About GitHub Copilot coding agent"),1649commandId: 'vscode.open',1650icon: Codicon.book,1651args: [URI.parse('https://aka.ms/coding-agent-docs')]1652}1653];1654const delegate = new GettingStartedDelegate();16551656// Create ResourceLabels instance for the renderer1657const labels = this.instantiationService.createInstance(ResourceLabels, { onDidChangeVisibility: this.onDidChangeBodyVisibility });1658this._register(labels);16591660const renderer = new GettingStartedRenderer(labels);1661this.list = this.instantiationService.createInstance(1662WorkbenchList<IGettingStartedItem>,1663'GettingStarted',1664listContainer,1665delegate,1666[renderer],1667{1668horizontalScrolling: false,1669}1670);1671this.list.splice(0, 0, items);1672this._register(this.list.onDidOpen(e => {1673if (e.element) {1674this.commandService.executeCommand(e.element.commandId, ...e.element.args ?? []);1675}1676}));16771678this._register(this.list);1679}16801681protected override layoutBody(height: number, width: number): void {1682super.layoutBody(height, width);1683if (this.tree) {1684this.tree.layout(height, width);1685}1686if (this.list) {1687this.list.layout(height, width);1688}1689}16901691private async openChatSession(element: ChatSessionItemWithProvider) {1692if (!element || !element.id) {1693return;1694}16951696try {1697if (element.id === historyNode.id) {1698// Don't try to open the "Show history..." node itself1699return;1700}17011702// Handle history items first1703if (element.id.startsWith('history-')) {1704const sessionId = element.id.substring('history-'.length);1705const sessionWithProvider = element as ChatSessionItemWithProvider;17061707// For local history sessions, use ChatEditorInput approach1708if (sessionWithProvider.provider.chatSessionType === 'local') {1709const options: IChatEditorOptions = {1710target: { sessionId },1711pinned: true,1712// Add a marker to indicate this session was opened from history1713ignoreInView: true,1714preserveFocus: true,1715};1716await this.editorService.openEditor({ resource: ChatEditorInput.getNewEditorUri(), options });1717} else {1718// For external provider sessions, use ChatSessionUri approach1719const providerType = sessionWithProvider.provider.chatSessionType;1720const options: IChatEditorOptions = {1721pinned: true,1722preferredTitle: truncate(element.label, 30),1723preserveFocus: true,1724};1725await this.editorService.openEditor({1726resource: ChatSessionUri.forSession(providerType, sessionId),1727options,1728});1729}1730return;1731}17321733// Handle local session items (active editors/widgets)1734if (this.isLocalChatSessionItem(element)) {1735if (element.sessionType === 'editor' && element.editor && element.group) {1736// Focus the existing editor1737await element.group.openEditor(element.editor, { pinned: true });1738return;1739} else if (element.sessionType === 'widget') {1740// Focus the chat widget1741const chatViewPane = await this.viewsService.openView(ChatViewId) as ChatViewPane;1742if (chatViewPane && element?.widget?.viewModel?.model) {1743await chatViewPane.loadSession(element.widget.viewModel.model.sessionId);1744}1745return;1746}1747}17481749// For other session types, open as a new chat editor1750const sessionWithProvider = element as ChatSessionItemWithProvider;1751const sessionId = element.id;1752const providerType = sessionWithProvider.provider.chatSessionType;17531754const options: IChatEditorOptions = {1755pinned: true,1756ignoreInView: true,1757preferredTitle: truncate(element.label, 30),1758preserveFocus: true,1759};1760await this.editorService.openEditor({1761resource: ChatSessionUri.forSession(providerType, sessionId),1762options,1763});17641765} catch (error) {1766this.logService.error('[SessionsViewPane] Failed to open chat session:', error);1767}1768}17691770private showContextMenu(e: ITreeContextMenuEvent<ChatSessionItemWithProvider>) {1771if (!e.element) {1772return;1773}17741775const session = e.element;1776const sessionWithProvider = session as ChatSessionItemWithProvider;17771778// Create context overlay for this specific session item1779const contextOverlay = getSessionItemContextOverlay(1780session,1781sessionWithProvider.provider,1782this.chatWidgetService,1783this.chatService,1784this.editorGroupsService1785);1786const contextKeyService = this.contextKeyService.createOverlay(contextOverlay);17871788// Create marshalled context for command execution1789const marshalledSession = {1790session: session,1791$mid: MarshalledId.ChatSessionContext1792};17931794// Create menu for this session item to get actions1795const menu = this.menuService.createMenu(MenuId.ChatSessionsMenu, contextKeyService);17961797// Get actions and filter for context menu (all actions that are NOT inline)1798const actions = menu.getActions({ arg: marshalledSession, shouldForwardArgs: true });17991800const { secondary } = getActionBarActions(actions, 'inline'); this.contextMenuService.showContextMenu({1801getActions: () => secondary,1802getAnchor: () => e.anchor,1803getActionsContext: () => marshalledSession,1804});18051806menu.dispose();1807}1808}18091810class ChatSessionsGettingStartedAction extends Action2 {1811static readonly ID = 'chat.sessions.gettingStarted';18121813constructor() {1814super({1815id: ChatSessionsGettingStartedAction.ID,1816title: nls.localize2('chat.sessions.gettingStarted.action', "Getting Started with Chat Sessions"),1817icon: Codicon.sendToRemoteAgent,1818f1: false,1819});1820}18211822override async run(accessor: ServicesAccessor): Promise<void> {1823const productService = accessor.get(IProductService);1824const quickInputService = accessor.get(IQuickInputService);1825const extensionManagementService = accessor.get(IWorkbenchExtensionManagementService);1826const extensionGalleryService = accessor.get(IExtensionGalleryService);18271828const recommendations = productService.chatSessionRecommendations;1829if (!recommendations || recommendations.length === 0) {1830return;1831}18321833const installedExtensions = await extensionManagementService.getInstalled();1834const isExtensionAlreadyInstalled = (extensionId: string) => {1835return installedExtensions.find(installed => installed.identifier.id === extensionId);1836};18371838const quickPickItems = recommendations.map((recommendation: IChatSessionRecommendation) => {1839const extensionInstalled = !!isExtensionAlreadyInstalled(recommendation.extensionId);1840return {1841label: recommendation.displayName,1842description: recommendation.description,1843detail: extensionInstalled1844? nls.localize('chatSessions.extensionAlreadyInstalled', "'{0}' is already installed", recommendation.extensionName)1845: nls.localize('chatSessions.installExtension', "Installs '{0}'", recommendation.extensionName),1846extensionId: recommendation.extensionId,1847disabled: extensionInstalled,1848};1849});18501851const selected = await quickInputService.pick(quickPickItems, {1852title: nls.localize('chatSessions.selectExtension', "Install Chat Extensions"),1853placeHolder: nls.localize('chatSessions.pickPlaceholder', "Choose extensions to enhance your chat experience"),1854canPickMany: true,1855});18561857if (!selected) {1858return;1859}18601861const galleryExtensions = await extensionGalleryService.getExtensions(selected.map(item => ({ id: item.extensionId })), CancellationToken.None);1862if (!galleryExtensions) {1863return;1864}1865await extensionManagementService.installGalleryExtensions(galleryExtensions.map(extension => ({ extension, options: { preRelease: productService.quality !== 'stable' } })));1866}1867}18681869registerAction2(ChatSessionsGettingStartedAction);18701871MenuRegistry.appendMenuItem(MenuId.ViewTitle, {1872command: {1873id: 'workbench.action.openChat',1874title: nls.localize2('interactiveSession.open', "New Chat Editor"),1875icon: Codicon.plus1876},1877group: 'navigation',1878order: 1,1879when: ContextKeyExpr.equals('view', `${VIEWLET_ID}.local`),1880});188118821883