mirror of
https://github.com/microsoft/vscode.git
synced 2026-04-29 21:11:38 +01:00
1613 lines
57 KiB
TypeScript
1613 lines
57 KiB
TypeScript
/*---------------------------------------------------------------------------------------------
|
|
* Copyright (c) Microsoft Corporation. All rights reserved.
|
|
* Licensed under the MIT License. See License.txt in the project root for license information.
|
|
*--------------------------------------------------------------------------------------------*/
|
|
|
|
import './media/chatSessions.css';
|
|
import * as DOM from '../../../../base/browser/dom.js';
|
|
import { $, append, getActiveWindow } from '../../../../base/browser/dom.js';
|
|
import { ActionBar } from '../../../../base/browser/ui/actionbar/actionbar.js';
|
|
import { IListVirtualDelegate } from '../../../../base/browser/ui/list/list.js';
|
|
import { IAsyncDataSource, ITreeNode, ITreeRenderer, ITreeContextMenuEvent } from '../../../../base/browser/ui/tree/tree.js';
|
|
import { coalesce } from '../../../../base/common/arrays.js';
|
|
import { CancellationToken } from '../../../../base/common/cancellation.js';
|
|
import { Codicon } from '../../../../base/common/codicons.js';
|
|
import { fromNow } from '../../../../base/common/date.js';
|
|
import { Emitter, Event } from '../../../../base/common/event.js';
|
|
import { FuzzyScore } from '../../../../base/common/filters.js';
|
|
import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js';
|
|
import { MarshalledId } from '../../../../base/common/marshallingIds.js';
|
|
import { ThemeIcon } from '../../../../base/common/themables.js';
|
|
import { URI } from '../../../../base/common/uri.js';
|
|
import { isMarkdownString } from '../../../../base/common/htmlContent.js';
|
|
import * as nls from '../../../../nls.js';
|
|
import { getActionBarActions } from '../../../../platform/actions/browser/menuEntryActionViewItem.js';
|
|
import { IMenuService, MenuId, MenuRegistry } from '../../../../platform/actions/common/actions.js';
|
|
import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
|
|
import { ContextKeyExpr, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';
|
|
import { IContextMenuService, IContextViewService } from '../../../../platform/contextview/browser/contextView.js';
|
|
import { IHoverService } from '../../../../platform/hover/browser/hover.js';
|
|
import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js';
|
|
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
|
|
import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js';
|
|
import { WorkbenchAsyncDataTree } from '../../../../platform/list/browser/listService.js';
|
|
import { ILogService } from '../../../../platform/log/common/log.js';
|
|
import { IOpenerService } from '../../../../platform/opener/common/opener.js';
|
|
import { Registry } from '../../../../platform/registry/common/platform.js';
|
|
import { IStorageService } from '../../../../platform/storage/common/storage.js';
|
|
import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js';
|
|
import { registerIcon } from '../../../../platform/theme/common/iconRegistry.js';
|
|
import { IThemeService } from '../../../../platform/theme/common/themeService.js';
|
|
import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js';
|
|
import { IResourceLabel, ResourceLabels } from '../../../browser/labels.js';
|
|
import { IViewPaneOptions, ViewPane } from '../../../browser/parts/views/viewPane.js';
|
|
import { ViewPaneContainer } from '../../../browser/parts/views/viewPaneContainer.js';
|
|
import { IWorkbenchContribution } from '../../../common/contributions.js';
|
|
import { GroupModelChangeKind } from '../../../common/editor.js';
|
|
import { EditorInput } from '../../../common/editor/editorInput.js';
|
|
import { Extensions, IEditableData, IViewContainersRegistry, IViewDescriptor, IViewDescriptorService, IViewsRegistry, ViewContainerLocation } from '../../../common/views.js';
|
|
import { IEditorGroup, IEditorGroupsService } from '../../../services/editor/common/editorGroupsService.js';
|
|
import { IEditorService } from '../../../services/editor/common/editorService.js';
|
|
import { IExtensionService } from '../../../services/extensions/common/extensions.js';
|
|
import { IWorkbenchLayoutService } from '../../../services/layout/browser/layoutService.js';
|
|
import { IChatSessionItem, IChatSessionItemProvider, IChatSessionsExtensionPoint, IChatSessionsService, ChatSessionStatus } from '../common/chatSessionsService.js';
|
|
import { IViewsService } from '../../../services/views/common/viewsService.js';
|
|
import { ChatContextKeys } from '../common/chatContextKeys.js';
|
|
import { ChatAgentLocation, ChatConfiguration } from '../common/constants.js';
|
|
import { IChatWidget, IChatWidgetService, ChatViewId } from './chat.js';
|
|
import { ChatViewPane } from './chatViewPane.js';
|
|
import { ChatEditorInput } from './chatEditorInput.js';
|
|
import { IChatEditorOptions } from './chatEditor.js';
|
|
import { IChatService } from '../common/chatService.js';
|
|
import { ChatSessionUri } from '../common/chatUri.js';
|
|
import { InputBox, MessageType } from '../../../../base/browser/ui/inputbox/inputBox.js';
|
|
import Severity from '../../../../base/common/severity.js';
|
|
import { defaultInputBoxStyles } from '../../../../platform/theme/browser/defaultStyles.js';
|
|
import { createSingleCallFunction } from '../../../../base/common/functional.js';
|
|
import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js';
|
|
import { timeout } from '../../../../base/common/async.js';
|
|
import { KeyCode } from '../../../../base/common/keyCodes.js';
|
|
import { IProgressService } from '../../../../platform/progress/common/progress.js';
|
|
import { fillEditorsDragData } from '../../../browser/dnd.js';
|
|
import { IChatModel } from '../common/chatModel.js';
|
|
import { IObservable } from '../../../../base/common/observable.js';
|
|
|
|
export const VIEWLET_ID = 'workbench.view.chat.sessions';
|
|
|
|
type ChatSessionItemWithProvider = IChatSessionItem & {
|
|
readonly provider: IChatSessionItemProvider;
|
|
relativeTime?: string;
|
|
relativeTimeFullWord?: string;
|
|
hideRelativeTime?: boolean;
|
|
timing?: {
|
|
startTime: number;
|
|
};
|
|
};
|
|
|
|
// Helper function to update relative time for chat sessions (similar to timeline)
|
|
function updateRelativeTime(item: ChatSessionItemWithProvider, lastRelativeTime: string | undefined): string | undefined {
|
|
if (item.timing?.startTime) {
|
|
item.relativeTime = fromNow(item.timing.startTime);
|
|
item.relativeTimeFullWord = fromNow(item.timing.startTime, false, true);
|
|
if (lastRelativeTime === undefined || item.relativeTime !== lastRelativeTime) {
|
|
lastRelativeTime = item.relativeTime;
|
|
item.hideRelativeTime = false;
|
|
} else {
|
|
item.hideRelativeTime = true;
|
|
}
|
|
} else {
|
|
// Clear timestamp properties if no timestamp
|
|
item.relativeTime = undefined;
|
|
item.relativeTimeFullWord = undefined;
|
|
item.hideRelativeTime = false;
|
|
}
|
|
|
|
return lastRelativeTime;
|
|
}
|
|
|
|
// Helper function to extract timestamp from session item
|
|
function extractTimestamp(item: IChatSessionItem): number | undefined {
|
|
// Use timing.startTime if available from the API
|
|
if (item.timing?.startTime) {
|
|
return item.timing.startTime;
|
|
}
|
|
|
|
// For other items, timestamp might already be set
|
|
if ('timestamp' in item) {
|
|
return (item as any).timestamp;
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
// Helper function to sort sessions by timestamp (newest first)
|
|
function sortSessionsByTimestamp(sessions: ChatSessionItemWithProvider[]): void {
|
|
sessions.sort((a, b) => {
|
|
const aTime = a.timing?.startTime ?? 0;
|
|
const bTime = b.timing?.startTime ?? 0;
|
|
return bTime - aTime; // newest first
|
|
});
|
|
}
|
|
|
|
// Helper function to apply time grouping to a list of sessions
|
|
function applyTimeGrouping(sessions: ChatSessionItemWithProvider[]): void {
|
|
let lastRelativeTime: string | undefined;
|
|
sessions.forEach(session => {
|
|
lastRelativeTime = updateRelativeTime(session, lastRelativeTime);
|
|
});
|
|
}
|
|
|
|
// Helper function to process session items with timestamps, sorting, and grouping
|
|
function processSessionsWithTimeGrouping(sessions: ChatSessionItemWithProvider[]): void {
|
|
// Only process if we have sessions with timestamps
|
|
if (sessions.some(session => session.timing?.startTime !== undefined)) {
|
|
sortSessionsByTimestamp(sessions);
|
|
applyTimeGrouping(sessions);
|
|
}
|
|
}
|
|
|
|
// Helper function to create context overlay for session items
|
|
function getSessionItemContextOverlay(session: IChatSessionItem, provider?: IChatSessionItemProvider): [string, any][] {
|
|
const overlay: [string, any][] = [];
|
|
if (provider) {
|
|
overlay.push([ChatContextKeys.sessionType.key, provider.chatSessionType]);
|
|
}
|
|
|
|
// Mark history items
|
|
const isHistoryItem = session.id.startsWith('history-');
|
|
overlay.push([ChatContextKeys.isHistoryItem.key, isHistoryItem]);
|
|
|
|
return overlay;
|
|
}
|
|
|
|
// Extended interface for local chat session items that includes editor information or widget information
|
|
export interface ILocalChatSessionItem extends IChatSessionItem {
|
|
editor?: EditorInput;
|
|
group?: IEditorGroup;
|
|
widget?: IChatWidget;
|
|
sessionType: 'editor' | 'widget';
|
|
description?: string;
|
|
status?: ChatSessionStatus;
|
|
}
|
|
|
|
export class ChatSessionsView extends Disposable implements IWorkbenchContribution {
|
|
static readonly ID = 'workbench.contrib.chatSessions';
|
|
|
|
private isViewContainerRegistered = false;
|
|
private localProvider: LocalChatSessionsProvider | undefined;
|
|
|
|
constructor(
|
|
@IConfigurationService private readonly configurationService: IConfigurationService,
|
|
@IInstantiationService private readonly instantiationService: IInstantiationService,
|
|
@IChatSessionsService private readonly chatSessionsService: IChatSessionsService,
|
|
) {
|
|
super();
|
|
|
|
// Create and register the local chat sessions provider immediately
|
|
// This ensures it's available even when the view container is not initialized
|
|
this.localProvider = this._register(this.instantiationService.createInstance(LocalChatSessionsProvider));
|
|
this._register(this.chatSessionsService.registerChatSessionItemProvider(this.localProvider));
|
|
|
|
// Initial check
|
|
this.updateViewContainerRegistration();
|
|
|
|
// Listen for configuration changes
|
|
this._register(this.configurationService.onDidChangeConfiguration(e => {
|
|
if (e.affectsConfiguration(ChatConfiguration.AgentSessionsViewLocation)) {
|
|
this.updateViewContainerRegistration();
|
|
}
|
|
}));
|
|
}
|
|
|
|
private updateViewContainerRegistration(): void {
|
|
const location = this.configurationService.getValue<string>(ChatConfiguration.AgentSessionsViewLocation);
|
|
|
|
if (location === 'view' && !this.isViewContainerRegistered) {
|
|
this.registerViewContainer();
|
|
} else if (location !== 'view' && this.isViewContainerRegistered) {
|
|
// Note: VS Code doesn't support unregistering view containers
|
|
// Once registered, they remain registered for the session
|
|
// but you could hide them or make them conditional through 'when' clauses
|
|
}
|
|
}
|
|
|
|
private registerViewContainer(): void {
|
|
if (this.isViewContainerRegistered) {
|
|
return;
|
|
}
|
|
|
|
Registry.as<IViewContainersRegistry>(Extensions.ViewContainersRegistry).registerViewContainer(
|
|
{
|
|
id: VIEWLET_ID,
|
|
title: nls.localize2('chat.sessions', "Chat Sessions"),
|
|
ctorDescriptor: new SyncDescriptor(ChatSessionsViewPaneContainer),
|
|
hideIfEmpty: false,
|
|
icon: registerIcon('chat-sessions-icon', Codicon.commentDiscussionSparkle, 'Icon for Chat Sessions View'),
|
|
order: 10
|
|
}, ViewContainerLocation.Sidebar);
|
|
}
|
|
}
|
|
|
|
// Local Chat Sessions Provider - tracks open editors as chat sessions
|
|
class LocalChatSessionsProvider extends Disposable implements IChatSessionItemProvider {
|
|
static readonly CHAT_WIDGET_VIEW_ID = 'workbench.panel.chat.view.copilot';
|
|
readonly chatSessionType = 'local';
|
|
|
|
private readonly _onDidChange = this._register(new Emitter<void>());
|
|
readonly onDidChange: Event<void> = this._onDidChange.event;
|
|
|
|
readonly _onDidChangeChatSessionItems = this._register(new Emitter<void>());
|
|
public get onDidChangeChatSessionItems() { return this._onDidChangeChatSessionItems.event; }
|
|
|
|
// Track the current editor set to detect actual new additions
|
|
private currentEditorSet = new Set<string>();
|
|
|
|
// Maintain ordered list of editor keys to preserve consistent ordering
|
|
private editorOrder: string[] = [];
|
|
|
|
constructor(
|
|
@IEditorGroupsService private readonly editorGroupService: IEditorGroupsService,
|
|
@IChatWidgetService private readonly chatWidgetService: IChatWidgetService,
|
|
@IChatService private readonly chatService: IChatService,
|
|
) {
|
|
super();
|
|
|
|
this.initializeCurrentEditorSet();
|
|
this.registerEditorListeners();
|
|
this.registerWidgetListeners();
|
|
|
|
this._register(this.chatService.onDidDisposeSession(() => {
|
|
this._onDidChange.fire();
|
|
}));
|
|
}
|
|
|
|
private registerWidgetListeners(): void {
|
|
// Listen for new chat widgets being added/removed
|
|
this._register(this.chatWidgetService.onDidAddWidget(widget => {
|
|
// Only fire for chat view instance
|
|
if (widget.location === ChatAgentLocation.Panel &&
|
|
typeof widget.viewContext === 'object' &&
|
|
'viewId' in widget.viewContext &&
|
|
widget.viewContext.viewId === LocalChatSessionsProvider.CHAT_WIDGET_VIEW_ID) {
|
|
this._onDidChange.fire();
|
|
|
|
// Listen for view model changes on this widget
|
|
this._register(widget.onDidChangeViewModel(() => {
|
|
this._onDidChange.fire();
|
|
if (widget.viewModel) {
|
|
this.registerProgressListener(widget.viewModel.model.requestInProgressObs);
|
|
}
|
|
}));
|
|
|
|
// Listen for title changes on the current model
|
|
this.registerModelTitleListener(widget);
|
|
if (widget.viewModel) {
|
|
this.registerProgressListener(widget.viewModel.model.requestInProgressObs);
|
|
}
|
|
}
|
|
}));
|
|
|
|
// Check for existing chat widgets and register listeners
|
|
const existingWidgets = this.chatWidgetService.getWidgetsByLocations(ChatAgentLocation.Panel)
|
|
.filter(widget => typeof widget.viewContext === 'object' && 'viewId' in widget.viewContext && widget.viewContext.viewId === LocalChatSessionsProvider.CHAT_WIDGET_VIEW_ID);
|
|
|
|
existingWidgets.forEach(widget => {
|
|
this._register(widget.onDidChangeViewModel(() => {
|
|
this._onDidChange.fire();
|
|
this.registerModelTitleListener(widget);
|
|
}));
|
|
|
|
// Register title listener for existing widget
|
|
this.registerModelTitleListener(widget);
|
|
if (widget.viewModel) {
|
|
this.registerProgressListener(widget.viewModel.model.requestInProgressObs);
|
|
}
|
|
});
|
|
}
|
|
|
|
private registerProgressListener(observable: IObservable<boolean>) {
|
|
const progressEvent = Event.fromObservableLight(observable);
|
|
this._register(progressEvent(() => {
|
|
this._onDidChangeChatSessionItems.fire();
|
|
}));
|
|
}
|
|
|
|
private registerEditorProgressListener(editor: ChatEditorInput): void {
|
|
// If the editor already has a sessionId, register immediately
|
|
if (editor.sessionId) {
|
|
const model = this.chatService.getSession(editor.sessionId);
|
|
if (model) {
|
|
this.registerProgressListener(model.requestInProgressObs);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Otherwise, wait for the editor to be resolved and get its sessionId
|
|
const disposable = editor.onDidChangeLabel(() => {
|
|
if (editor.sessionId) {
|
|
const model = this.chatService.getSession(editor.sessionId);
|
|
if (model) {
|
|
this.registerProgressListener(model.requestInProgressObs);
|
|
}
|
|
disposable.dispose(); // Clean up this listener once we've registered
|
|
}
|
|
});
|
|
|
|
this._register(disposable);
|
|
}
|
|
|
|
private registerModelTitleListener(widget: IChatWidget): void {
|
|
const model = widget.viewModel?.model;
|
|
if (model) {
|
|
// Listen for model changes, specifically for title changes via setCustomTitle
|
|
this._register(model.onDidChange((e) => {
|
|
// Fire change events for all title-related changes to refresh the tree
|
|
if (!e || e.kind === 'setCustomTitle') {
|
|
this._onDidChange.fire();
|
|
}
|
|
}));
|
|
}
|
|
}
|
|
|
|
private initializeCurrentEditorSet(): void {
|
|
this.currentEditorSet.clear();
|
|
this.editorOrder = []; // Reset the order
|
|
|
|
this.editorGroupService.groups.forEach(group => {
|
|
group.editors.forEach(editor => {
|
|
if (this.isLocalChatSession(editor)) {
|
|
const key = this.getEditorKey(editor, group);
|
|
this.currentEditorSet.add(key);
|
|
this.editorOrder.push(key);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
private getEditorKey(editor: EditorInput, group: IEditorGroup): string {
|
|
return `${group.id}-${editor.typeId}-${editor.resource?.toString() || editor.getName()}`;
|
|
}
|
|
|
|
private registerEditorListeners(): void {
|
|
// Listen to all groups for editor changes
|
|
this.editorGroupService.groups.forEach(group => {
|
|
this.registerGroupListeners(group);
|
|
group.editors.forEach(editor => {
|
|
if (editor instanceof ChatEditorInput) {
|
|
this.registerEditorProgressListener(editor);
|
|
}
|
|
});
|
|
});
|
|
|
|
// Listen for new groups
|
|
this._register(this.editorGroupService.onDidAddGroup(group => {
|
|
this.registerGroupListeners(group);
|
|
this.initializeCurrentEditorSet(); // Refresh our tracking
|
|
this._onDidChange.fire();
|
|
}));
|
|
|
|
this._register(this.editorGroupService.onDidRemoveGroup(() => {
|
|
this.initializeCurrentEditorSet(); // Refresh our tracking
|
|
this._onDidChange.fire();
|
|
}));
|
|
}
|
|
|
|
private isLocalChatSession(editor?: EditorInput): boolean {
|
|
if (!(editor instanceof ChatEditorInput)) {
|
|
return false; // Only track ChatEditorInput instances
|
|
}
|
|
|
|
// Only track editors with vscode-chat-editor scheme
|
|
if (editor.resource?.scheme !== 'vscode-chat-editor') {
|
|
return false;
|
|
}
|
|
|
|
// Exclude history sessions that are opened from "Show history"
|
|
// These have a specific marker indicating they're from history
|
|
if (editor.options.ignoreInView) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private modelToStatus(model: IChatModel): ChatSessionStatus | undefined {
|
|
if (model.requestInProgress) {
|
|
return ChatSessionStatus.InProgress;
|
|
} else {
|
|
const requests = model.getRequests();
|
|
if (requests.length > 0) {
|
|
// Check if the last request was completed successfully or failed
|
|
const lastRequest = requests[requests.length - 1];
|
|
if (lastRequest && lastRequest.response) {
|
|
if (lastRequest.response.isCanceled || lastRequest.response.result?.errorDetails) {
|
|
return ChatSessionStatus.Failed;
|
|
} else if (lastRequest.response.isComplete) {
|
|
return ChatSessionStatus.Completed;
|
|
} else {
|
|
return ChatSessionStatus.InProgress;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
private registerGroupListeners(group: IEditorGroup): void {
|
|
this._register(group.onDidModelChange(e => {
|
|
if (!this.isLocalChatSession(e.editor)) {
|
|
return;
|
|
}
|
|
switch (e.kind) {
|
|
case GroupModelChangeKind.EDITOR_OPEN:
|
|
// Only fire change if this is a truly new editor
|
|
if (e.editor) {
|
|
const editorKey = this.getEditorKey(e.editor, group);
|
|
if (!this.currentEditorSet.has(editorKey)) {
|
|
this.currentEditorSet.add(editorKey);
|
|
this.editorOrder.push(editorKey); // Append to end
|
|
this._onDidChange.fire();
|
|
|
|
// Register progress listener for new chat editor sessions
|
|
if (e.editor instanceof ChatEditorInput) {
|
|
this.registerEditorProgressListener(e.editor);
|
|
}
|
|
}
|
|
}
|
|
break;
|
|
case GroupModelChangeKind.EDITOR_CLOSE:
|
|
// Remove from our tracking set and fire change
|
|
if (e.editor) {
|
|
const editorKey = this.getEditorKey(e.editor, group);
|
|
this.currentEditorSet.delete(editorKey);
|
|
const index = this.editorOrder.indexOf(editorKey);
|
|
if (index > -1) {
|
|
this.editorOrder.splice(index, 1);
|
|
}
|
|
}
|
|
this._onDidChange.fire();
|
|
this._onDidChangeChatSessionItems.fire();
|
|
break;
|
|
case GroupModelChangeKind.EDITOR_MOVE:
|
|
// Just refresh the set without resetting the order
|
|
this.currentEditorSet.clear();
|
|
this.editorGroupService.groups.forEach(group => {
|
|
group.editors.forEach(editor => {
|
|
const key = this.getEditorKey(editor, group);
|
|
this.currentEditorSet.add(key);
|
|
});
|
|
});
|
|
this._onDidChange.fire();
|
|
break;
|
|
case GroupModelChangeKind.EDITOR_ACTIVE:
|
|
// Editor became active - no need to change our list
|
|
// This happens when clicking on tabs or opening editors
|
|
break;
|
|
case GroupModelChangeKind.EDITOR_LABEL:
|
|
this._onDidChange.fire();
|
|
break;
|
|
}
|
|
}));
|
|
}
|
|
|
|
async provideChatSessionItems(token: CancellationToken): Promise<IChatSessionItem[]> {
|
|
const sessions: ChatSessionItemWithProvider[] = [];
|
|
// Create a map to quickly find editors by their key
|
|
const editorMap = new Map<string, { editor: EditorInput; group: IEditorGroup }>();
|
|
|
|
this.editorGroupService.groups.forEach(group => {
|
|
group.editors.forEach(editor => {
|
|
if (editor instanceof ChatEditorInput) {
|
|
const key = this.getEditorKey(editor, group);
|
|
editorMap.set(key, { editor, group });
|
|
}
|
|
});
|
|
});
|
|
|
|
// Add chat view instance
|
|
const chatWidget = this.chatWidgetService.getWidgetsByLocations(ChatAgentLocation.Panel)
|
|
.find(widget => typeof widget.viewContext === 'object' && 'viewId' in widget.viewContext && widget.viewContext.viewId === LocalChatSessionsProvider.CHAT_WIDGET_VIEW_ID);
|
|
let status: ChatSessionStatus | undefined;
|
|
let widgetTimestamp: number | undefined;
|
|
if (chatWidget?.viewModel?.model) {
|
|
status = this.modelToStatus(chatWidget.viewModel.model);
|
|
// Get the last interaction timestamp from the model
|
|
const requests = chatWidget.viewModel.model.getRequests();
|
|
if (requests.length > 0) {
|
|
const lastRequest = requests[requests.length - 1];
|
|
widgetTimestamp = lastRequest.timestamp;
|
|
} else {
|
|
// Fallback to current time if no requests yet
|
|
widgetTimestamp = Date.now();
|
|
}
|
|
}
|
|
if (chatWidget) {
|
|
const widgetSession: ILocalChatSessionItem & ChatSessionItemWithProvider = {
|
|
id: LocalChatSessionsProvider.CHAT_WIDGET_VIEW_ID,
|
|
label: chatWidget.viewModel?.model.title || nls.localize2('chat.sessions.chatView', "Chat").value,
|
|
description: nls.localize('chat.sessions.chatView.description', "Chat View"),
|
|
iconPath: Codicon.chatSparkle,
|
|
widget: chatWidget,
|
|
sessionType: 'widget',
|
|
status,
|
|
provider: this,
|
|
timing: {
|
|
startTime: widgetTimestamp ?? 0
|
|
}
|
|
};
|
|
sessions.push(widgetSession);
|
|
}
|
|
|
|
// Build editor-based sessions in the order specified by editorOrder
|
|
this.editorOrder.forEach((editorKey, index) => {
|
|
const editorInfo = editorMap.get(editorKey);
|
|
if (editorInfo) {
|
|
const sessionId = `local-${editorInfo.group.id}-${index}`;
|
|
|
|
// Determine status and timestamp for editor-based session
|
|
let status: ChatSessionStatus | undefined;
|
|
let timestamp: number | undefined;
|
|
if (editorInfo.editor instanceof ChatEditorInput && editorInfo.editor.sessionId) {
|
|
const model = this.chatService.getSession(editorInfo.editor.sessionId);
|
|
if (model) {
|
|
status = this.modelToStatus(model);
|
|
// Get the last interaction timestamp from the model
|
|
const requests = model.getRequests();
|
|
if (requests.length > 0) {
|
|
const lastRequest = requests[requests.length - 1];
|
|
timestamp = lastRequest.timestamp;
|
|
} else {
|
|
// Fallback to current time if no requests yet
|
|
timestamp = Date.now();
|
|
}
|
|
}
|
|
}
|
|
|
|
const editorSession: ILocalChatSessionItem & ChatSessionItemWithProvider = {
|
|
id: sessionId,
|
|
label: editorInfo.editor.getName(),
|
|
iconPath: Codicon.chatSparkle,
|
|
editor: editorInfo.editor,
|
|
group: editorInfo.group,
|
|
sessionType: 'editor',
|
|
status,
|
|
provider: this,
|
|
timing: {
|
|
startTime: timestamp ?? 0
|
|
}
|
|
};
|
|
sessions.push(editorSession);
|
|
}
|
|
});
|
|
|
|
// Sort sessions by timestamp (newest first), but keep "Show history..." at the end
|
|
const normalSessions = sessions.filter(s => s.id !== 'show-history');
|
|
processSessionsWithTimeGrouping(normalSessions);
|
|
|
|
// Add "Show history..." node at the end
|
|
const historyNode: IChatSessionItem = {
|
|
id: 'show-history',
|
|
label: nls.localize('chat.sessions.showHistory', "History"),
|
|
};
|
|
|
|
return [...normalSessions, historyNode];
|
|
}
|
|
}
|
|
|
|
// Chat sessions container
|
|
class ChatSessionsViewPaneContainer extends ViewPaneContainer {
|
|
private registeredViewDescriptors: Map<string, IViewDescriptor> = new Map();
|
|
|
|
constructor(
|
|
@IInstantiationService instantiationService: IInstantiationService,
|
|
@IConfigurationService configurationService: IConfigurationService,
|
|
@IWorkbenchLayoutService layoutService: IWorkbenchLayoutService,
|
|
@IContextMenuService contextMenuService: IContextMenuService,
|
|
@ITelemetryService telemetryService: ITelemetryService,
|
|
@IExtensionService extensionService: IExtensionService,
|
|
@IThemeService themeService: IThemeService,
|
|
@IStorageService storageService: IStorageService,
|
|
@IWorkspaceContextService contextService: IWorkspaceContextService,
|
|
@IViewDescriptorService viewDescriptorService: IViewDescriptorService,
|
|
@ILogService logService: ILogService,
|
|
@IChatSessionsService private readonly chatSessionsService: IChatSessionsService,
|
|
) {
|
|
super(
|
|
VIEWLET_ID,
|
|
{
|
|
mergeViewWithContainerWhenSingleView: false,
|
|
},
|
|
instantiationService,
|
|
configurationService,
|
|
layoutService,
|
|
contextMenuService,
|
|
telemetryService,
|
|
extensionService,
|
|
themeService,
|
|
storageService,
|
|
contextService,
|
|
viewDescriptorService,
|
|
logService
|
|
);
|
|
|
|
this.updateViewRegistration();
|
|
|
|
// Listen for provider changes and register/unregister views accordingly
|
|
this._register(this.chatSessionsService.onDidChangeItemsProviders(() => {
|
|
this.updateViewRegistration();
|
|
}));
|
|
|
|
// Listen for session items changes and refresh the appropriate provider tree
|
|
this._register(this.chatSessionsService.onDidChangeSessionItems((chatSessionType) => {
|
|
this.refreshProviderTree(chatSessionType);
|
|
}));
|
|
|
|
// Listen for contribution availability changes and update view registration
|
|
this._register(this.chatSessionsService.onDidChangeAvailability(() => {
|
|
this.updateViewRegistration();
|
|
}));
|
|
}
|
|
|
|
override getTitle(): string {
|
|
const title = nls.localize('chat.sessions.title', "Chat Sessions");
|
|
return title;
|
|
}
|
|
|
|
private getAllChatSessionItemProviders(): IChatSessionItemProvider[] {
|
|
return Array.from(this.chatSessionsService.getAllChatSessionItemProviders());
|
|
}
|
|
|
|
private refreshProviderTree(chatSessionType: string): void {
|
|
// Find the provider with the matching chatSessionType
|
|
const providers = this.getAllChatSessionItemProviders();
|
|
const targetProvider = providers.find(provider => provider.chatSessionType === chatSessionType);
|
|
|
|
if (targetProvider) {
|
|
// Find the corresponding view and refresh its tree
|
|
const viewId = `${VIEWLET_ID}.${chatSessionType}`;
|
|
const view = this.getView(viewId) as SessionsViewPane | undefined;
|
|
if (view) {
|
|
view.refreshTree();
|
|
}
|
|
}
|
|
}
|
|
|
|
private async updateViewRegistration(): Promise<void> {
|
|
// prepare all chat session providers
|
|
const contributions = this.chatSessionsService.getAllChatSessionContributions();
|
|
await Promise.all(contributions.map(contrib => this.chatSessionsService.canResolveItemProvider(contrib.type)));
|
|
const currentProviders = this.getAllChatSessionItemProviders();
|
|
const currentProviderIds = new Set(currentProviders.map(p => p.chatSessionType));
|
|
|
|
// Find views that need to be unregistered (providers that are no longer available)
|
|
const viewsToUnregister: IViewDescriptor[] = [];
|
|
for (const [providerId, viewDescriptor] of this.registeredViewDescriptors.entries()) {
|
|
if (!currentProviderIds.has(providerId)) {
|
|
viewsToUnregister.push(viewDescriptor);
|
|
this.registeredViewDescriptors.delete(providerId);
|
|
}
|
|
}
|
|
|
|
// Unregister removed views
|
|
if (viewsToUnregister.length > 0) {
|
|
const container = Registry.as<IViewContainersRegistry>(Extensions.ViewContainersRegistry).get(VIEWLET_ID);
|
|
if (container) {
|
|
Registry.as<IViewsRegistry>(Extensions.ViewsRegistry).deregisterViews(viewsToUnregister, container);
|
|
}
|
|
}
|
|
|
|
// Register new views
|
|
this.registerViews(contributions);
|
|
}
|
|
|
|
private async registerViews(extensionPointContributions: IChatSessionsExtensionPoint[]) {
|
|
const container = Registry.as<IViewContainersRegistry>(Extensions.ViewContainersRegistry).get(VIEWLET_ID);
|
|
const providers = this.getAllChatSessionItemProviders();
|
|
|
|
if (container && providers.length > 0) {
|
|
const viewDescriptorsToRegister: IViewDescriptor[] = [];
|
|
let index = 1;
|
|
|
|
providers.forEach(provider => {
|
|
// Only register if not already registered
|
|
if (!this.registeredViewDescriptors.has(provider.chatSessionType)) {
|
|
let displayName = '';
|
|
if (provider.chatSessionType === 'local') {
|
|
displayName = 'Local Chat Sessions';
|
|
} else {
|
|
const extContribution = extensionPointContributions.find(c => c.type === provider.chatSessionType);
|
|
if (!extContribution) {
|
|
this.logService.warn(`No extension contribution found for chat session type: ${provider.chatSessionType}`);
|
|
return; // Skip if no contribution found
|
|
}
|
|
displayName = extContribution.displayName;
|
|
}
|
|
const viewDescriptor: IViewDescriptor = {
|
|
id: `${VIEWLET_ID}.${provider.chatSessionType}`,
|
|
name: {
|
|
value: displayName,
|
|
original: displayName,
|
|
},
|
|
ctorDescriptor: new SyncDescriptor(SessionsViewPane, [provider]),
|
|
canToggleVisibility: true,
|
|
canMoveView: true,
|
|
order: provider.chatSessionType === 'local' ? 0 : provider.chatSessionType === 'history' ? 1 : index++,
|
|
};
|
|
|
|
viewDescriptorsToRegister.push(viewDescriptor);
|
|
this.registeredViewDescriptors.set(provider.chatSessionType, viewDescriptor);
|
|
|
|
if (provider.chatSessionType === 'local') {
|
|
const viewsRegistry = Registry.as<IViewsRegistry>(Extensions.ViewsRegistry);
|
|
this._register(viewsRegistry.registerViewWelcomeContent(viewDescriptor.id, {
|
|
content: nls.localize('chatSessions.noResults', "No local chat sessions\n[Start a Chat](command:workbench.action.openChat)"),
|
|
}));
|
|
}
|
|
}
|
|
});
|
|
|
|
if (viewDescriptorsToRegister.length > 0) {
|
|
Registry.as<IViewsRegistry>(Extensions.ViewsRegistry).registerViews(viewDescriptorsToRegister, container);
|
|
}
|
|
}
|
|
}
|
|
|
|
override dispose(): void {
|
|
// Unregister all views before disposal
|
|
if (this.registeredViewDescriptors.size > 0) {
|
|
const container = Registry.as<IViewContainersRegistry>(Extensions.ViewContainersRegistry).get(VIEWLET_ID);
|
|
if (container) {
|
|
const allRegisteredViews = Array.from(this.registeredViewDescriptors.values());
|
|
Registry.as<IViewsRegistry>(Extensions.ViewsRegistry).deregisterViews(allRegisteredViews, container);
|
|
}
|
|
this.registeredViewDescriptors.clear();
|
|
}
|
|
|
|
super.dispose();
|
|
}
|
|
}
|
|
|
|
|
|
// Chat sessions item data source for the tree
|
|
class SessionsDataSource implements IAsyncDataSource<IChatSessionItemProvider, ChatSessionItemWithProvider> {
|
|
constructor(
|
|
private readonly provider: IChatSessionItemProvider,
|
|
private readonly chatService: IChatService,
|
|
) { }
|
|
|
|
hasChildren(element: IChatSessionItemProvider | ChatSessionItemWithProvider): boolean {
|
|
const isProvider = element === this.provider;
|
|
if (isProvider) {
|
|
// Root provider always has children
|
|
return true;
|
|
}
|
|
|
|
// Check if this is the "Show history..." node
|
|
if ('id' in element && element.id === 'show-history') {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
async getChildren(element: IChatSessionItemProvider | ChatSessionItemWithProvider): Promise<ChatSessionItemWithProvider[]> {
|
|
if (element === this.provider) {
|
|
try {
|
|
const items = await this.provider.provideChatSessionItems(CancellationToken.None);
|
|
const itemsWithProvider = items.map(item => {
|
|
const itemWithProvider: ChatSessionItemWithProvider = { ...item, provider: this.provider };
|
|
|
|
// Extract timestamp using the helper function
|
|
itemWithProvider.timing = { startTime: extractTimestamp(item) ?? 0 };
|
|
|
|
return itemWithProvider;
|
|
});
|
|
|
|
// For non-local providers, apply time-based sorting and grouping
|
|
if (this.provider.chatSessionType !== 'local') {
|
|
processSessionsWithTimeGrouping(itemsWithProvider);
|
|
}
|
|
|
|
return itemsWithProvider;
|
|
} catch (error) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
// Check if this is the "Show history..." node
|
|
if ('id' in element && element.id === 'show-history') {
|
|
return this.getHistoryItems();
|
|
}
|
|
|
|
// Individual session items don't have children
|
|
return [];
|
|
}
|
|
|
|
private async getHistoryItems(): Promise<ChatSessionItemWithProvider[]> {
|
|
try {
|
|
// Get all chat history
|
|
const allHistory = await this.chatService.getHistory();
|
|
|
|
// Create history items with provider reference and timestamps
|
|
const historyItems = allHistory.map((historyDetail: any): ChatSessionItemWithProvider => ({
|
|
id: `history-${historyDetail.sessionId}`,
|
|
label: historyDetail.title,
|
|
iconPath: Codicon.chatSparkle,
|
|
provider: this.provider,
|
|
timing: {
|
|
startTime: historyDetail.lastMessageDate ?? Date.now()
|
|
}
|
|
}));
|
|
|
|
// Apply sorting and time grouping
|
|
processSessionsWithTimeGrouping(historyItems);
|
|
|
|
return historyItems;
|
|
|
|
} catch (error) {
|
|
return [];
|
|
}
|
|
}
|
|
}
|
|
|
|
// Tree delegate for session items
|
|
class SessionsDelegate implements IListVirtualDelegate<ChatSessionItemWithProvider> {
|
|
static readonly ITEM_HEIGHT = 22;
|
|
static readonly ITEM_HEIGHT_WITH_DESCRIPTION = 38; // Slightly smaller for cleaner look
|
|
|
|
getHeight(element: ChatSessionItemWithProvider): number {
|
|
// Return consistent height for all items (single-line layout)
|
|
return SessionsDelegate.ITEM_HEIGHT;
|
|
}
|
|
|
|
getTemplateId(element: ChatSessionItemWithProvider): string {
|
|
return SessionsRenderer.TEMPLATE_ID;
|
|
}
|
|
}
|
|
|
|
// Template data for session items
|
|
interface ISessionTemplateData {
|
|
container: HTMLElement;
|
|
resourceLabel: IResourceLabel;
|
|
actionBar: ActionBar;
|
|
elementDisposable: DisposableStore;
|
|
timestamp: HTMLElement;
|
|
}
|
|
|
|
// Renderer for session items in the tree
|
|
class SessionsRenderer extends Disposable implements ITreeRenderer<IChatSessionItem, FuzzyScore, ISessionTemplateData> {
|
|
static readonly TEMPLATE_ID = 'session';
|
|
private appliedIconColorStyles = new Set<string>();
|
|
|
|
constructor(
|
|
private readonly labels: ResourceLabels,
|
|
@IThemeService private readonly themeService: IThemeService,
|
|
@ILogService private readonly logService: ILogService,
|
|
@IContextViewService private readonly contextViewService: IContextViewService,
|
|
@IChatSessionsService private readonly chatSessionsService: IChatSessionsService,
|
|
@IMenuService private readonly menuService: IMenuService,
|
|
@IContextKeyService private readonly contextKeyService: IContextKeyService,
|
|
) {
|
|
super();
|
|
|
|
// Listen for theme changes to clear applied styles
|
|
this._register(this.themeService.onDidColorThemeChange(() => {
|
|
this.appliedIconColorStyles.clear();
|
|
}));
|
|
}
|
|
|
|
private applyIconColorStyle(iconId: string, colorId: string): void {
|
|
const styleKey = `${iconId}-${colorId}`;
|
|
if (this.appliedIconColorStyles.has(styleKey)) {
|
|
return; // Already applied
|
|
}
|
|
|
|
const colorTheme = this.themeService.getColorTheme();
|
|
const color = colorTheme.getColor(colorId);
|
|
|
|
if (color) {
|
|
// Target the ::before pseudo-element where the actual icon is rendered
|
|
const css = `.monaco-workbench .chat-session-item .monaco-icon-label.codicon-${iconId}::before { color: ${color} !important; }`;
|
|
const activeWindow = getActiveWindow();
|
|
|
|
const styleId = `chat-sessions-icon-${styleKey}`;
|
|
const existingStyle = activeWindow.document.getElementById(styleId);
|
|
if (existingStyle) {
|
|
existingStyle.textContent = css;
|
|
} else {
|
|
const styleElement = activeWindow.document.createElement('style');
|
|
styleElement.id = styleId;
|
|
styleElement.textContent = css;
|
|
activeWindow.document.head.appendChild(styleElement);
|
|
|
|
// Clean up on dispose
|
|
this._register({
|
|
dispose: () => {
|
|
const activeWin = getActiveWindow();
|
|
const style = activeWin.document.getElementById(styleId);
|
|
if (style) {
|
|
style.remove();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
this.appliedIconColorStyles.add(styleKey);
|
|
} else {
|
|
this.logService.debug('No color found for colorId:', colorId);
|
|
}
|
|
}
|
|
|
|
private isLocalChatSessionItem(item: IChatSessionItem): item is ILocalChatSessionItem {
|
|
return ('editor' in item && 'group' in item) || ('widget' in item && 'sessionType' in item);
|
|
}
|
|
|
|
get templateId(): string {
|
|
return SessionsRenderer.TEMPLATE_ID;
|
|
}
|
|
|
|
renderTemplate(container: HTMLElement): ISessionTemplateData {
|
|
const element = append(container, $('.chat-session-item'));
|
|
|
|
// Create a container that holds the label, timestamp, and actions
|
|
const contentContainer = append(element, $('.session-content'));
|
|
const resourceLabel = this.labels.create(contentContainer, { supportHighlights: true });
|
|
|
|
// Create timestamp container and element
|
|
const timestampContainer = append(contentContainer, $('.timestamp-container'));
|
|
const timestamp = append(timestampContainer, $('.timestamp'));
|
|
|
|
const actionsContainer = append(contentContainer, $('.actions'));
|
|
const actionBar = new ActionBar(actionsContainer);
|
|
const elementDisposable = new DisposableStore();
|
|
|
|
return {
|
|
container: element,
|
|
resourceLabel,
|
|
actionBar,
|
|
elementDisposable,
|
|
timestamp
|
|
};
|
|
}
|
|
|
|
statusToIcon(status?: ChatSessionStatus) {
|
|
switch (status) {
|
|
case ChatSessionStatus.InProgress:
|
|
return Codicon.loading;
|
|
case ChatSessionStatus.Completed:
|
|
return Codicon.pass;
|
|
case ChatSessionStatus.Failed:
|
|
return Codicon.error;
|
|
default:
|
|
return Codicon.circleOutline;
|
|
}
|
|
|
|
}
|
|
|
|
renderElement(element: ITreeNode<IChatSessionItem, FuzzyScore>, index: number, templateData: ISessionTemplateData): void {
|
|
const session = element.element;
|
|
const sessionWithProvider = session as ChatSessionItemWithProvider;
|
|
|
|
// Clear previous element disposables
|
|
templateData.elementDisposable.clear();
|
|
|
|
// Add CSS class for local sessions
|
|
if (sessionWithProvider.provider.chatSessionType === 'local') {
|
|
templateData.container.classList.add('local-session');
|
|
} else {
|
|
templateData.container.classList.remove('local-session');
|
|
}
|
|
|
|
// Clear any previous element disposables
|
|
if (templateData.elementDisposable) {
|
|
templateData.elementDisposable.dispose();
|
|
}
|
|
|
|
// Get the actual session ID for editable data lookup
|
|
let actualSessionId: string | undefined;
|
|
if (this.isLocalChatSessionItem(session)) {
|
|
if (session.sessionType === 'editor' && session.editor instanceof ChatEditorInput) {
|
|
actualSessionId = session.editor.sessionId;
|
|
} else if (session.sessionType === 'widget' && session.widget) {
|
|
actualSessionId = session.widget.viewModel?.model.sessionId;
|
|
}
|
|
}
|
|
|
|
// Check if this session is being edited using the actual session ID
|
|
const editableData = actualSessionId ? this.chatSessionsService.getEditableData(actualSessionId) : undefined;
|
|
if (editableData) {
|
|
// Render input box for editing
|
|
templateData.actionBar.clear();
|
|
const editDisposable = this.renderInputBox(templateData.container, session, editableData);
|
|
templateData.elementDisposable = editDisposable;
|
|
return;
|
|
}
|
|
|
|
// Normal rendering - clear the action bar in case it was used for editing
|
|
templateData.actionBar.clear();
|
|
|
|
// Handle different icon types
|
|
let iconResource: URI | undefined;
|
|
let iconTheme: ThemeIcon | undefined;
|
|
if (!session.iconPath && session.id !== 'show-history') {
|
|
iconTheme = this.statusToIcon(session.status);
|
|
} else {
|
|
iconTheme = session.iconPath;
|
|
}
|
|
|
|
if (iconTheme?.color?.id) {
|
|
this.applyIconColorStyle(iconTheme.id, iconTheme.color.id);
|
|
}
|
|
|
|
// Set the resource label
|
|
templateData.resourceLabel.setResource({
|
|
name: session.label,
|
|
description: 'description' in session && typeof session.description === 'string' ? session.description : '',
|
|
resource: iconResource
|
|
}, {
|
|
fileKind: undefined,
|
|
icon: iconTheme,
|
|
title: 'tooltip' in session && session.tooltip ?
|
|
(typeof session.tooltip === 'string' ? session.tooltip :
|
|
isMarkdownString(session.tooltip) ? {
|
|
markdown: session.tooltip,
|
|
markdownNotSupportedFallback: session.tooltip.value
|
|
} : undefined) :
|
|
undefined
|
|
});
|
|
|
|
// Handle timestamp display and grouping
|
|
const hasTimestamp = sessionWithProvider.timing?.startTime !== undefined;
|
|
if (hasTimestamp) {
|
|
templateData.timestamp.textContent = sessionWithProvider.relativeTime ?? '';
|
|
templateData.timestamp.ariaLabel = sessionWithProvider.relativeTimeFullWord ?? '';
|
|
templateData.timestamp.parentElement!.classList.toggle('timestamp-duplicate', sessionWithProvider.hideRelativeTime === true);
|
|
templateData.timestamp.parentElement!.style.display = '';
|
|
} else {
|
|
// Hide timestamp container if no timestamp available
|
|
templateData.timestamp.parentElement!.style.display = 'none';
|
|
}
|
|
|
|
// Create context overlay for this specific session item
|
|
const contextOverlay = getSessionItemContextOverlay(session, sessionWithProvider.provider);
|
|
|
|
const contextKeyService = this.contextKeyService.createOverlay(contextOverlay);
|
|
|
|
// Create menu for this session item
|
|
const menu = templateData.elementDisposable.add(
|
|
this.menuService.createMenu(MenuId.ChatSessionsMenu, contextKeyService)
|
|
);
|
|
|
|
// Setup action bar with contributed actions
|
|
const setupActionBar = () => {
|
|
templateData.actionBar.clear();
|
|
|
|
// Create marshalled context for command execution
|
|
const marshalledSession = {
|
|
session: session,
|
|
$mid: MarshalledId.ChatSessionContext
|
|
};
|
|
|
|
const actions = menu.getActions({ arg: marshalledSession, shouldForwardArgs: true });
|
|
|
|
const { primary } = getActionBarActions(
|
|
actions,
|
|
'inline',
|
|
);
|
|
|
|
templateData.actionBar.push(primary, { icon: true, label: false });
|
|
|
|
// Set context for the action bar
|
|
templateData.actionBar.context = session;
|
|
};
|
|
|
|
// Setup initial action bar and listen for menu changes
|
|
templateData.elementDisposable.add(menu.onDidChange(() => setupActionBar()));
|
|
setupActionBar();
|
|
}
|
|
|
|
disposeElement(_element: ITreeNode<IChatSessionItem, FuzzyScore>, _index: number, templateData: ISessionTemplateData): void {
|
|
templateData.elementDisposable.clear();
|
|
}
|
|
|
|
private renderInputBox(container: HTMLElement, session: IChatSessionItem, editableData: IEditableData): DisposableStore {
|
|
// Hide the existing resource label element and session content
|
|
const existingResourceLabelElement = container.querySelector('.monaco-icon-label') as HTMLElement;
|
|
if (existingResourceLabelElement) {
|
|
existingResourceLabelElement.style.display = 'none';
|
|
}
|
|
|
|
// Hide the session content container to avoid layout conflicts
|
|
const sessionContentElement = container.querySelector('.session-content') as HTMLElement;
|
|
if (sessionContentElement) {
|
|
sessionContentElement.style.display = 'none';
|
|
}
|
|
|
|
// Create a simple container that mimics the file explorer's structure
|
|
const editContainer = DOM.append(container, DOM.$('.explorer-item.explorer-item-edited'));
|
|
|
|
// Add the icon
|
|
const iconElement = DOM.append(editContainer, DOM.$('.codicon'));
|
|
if (session.iconPath && ThemeIcon.isThemeIcon(session.iconPath)) {
|
|
iconElement.classList.add(`codicon-${session.iconPath.id}`);
|
|
} else {
|
|
iconElement.classList.add('codicon-file'); // Default file icon
|
|
}
|
|
|
|
// Create the input box directly
|
|
const inputBox = new InputBox(editContainer, this.contextViewService, {
|
|
validationOptions: {
|
|
validation: (value) => {
|
|
const message = editableData.validationMessage(value);
|
|
if (!message || message.severity !== Severity.Error) {
|
|
return null;
|
|
}
|
|
return {
|
|
content: message.content,
|
|
formatContent: true,
|
|
type: MessageType.ERROR
|
|
};
|
|
}
|
|
},
|
|
ariaLabel: nls.localize('chatSessionInputAriaLabel', "Type session name. Press Enter to confirm or Escape to cancel."),
|
|
inputBoxStyles: defaultInputBoxStyles,
|
|
});
|
|
|
|
inputBox.value = session.label;
|
|
inputBox.focus();
|
|
inputBox.select({ start: 0, end: session.label.length });
|
|
|
|
const done = createSingleCallFunction((success: boolean, finishEditing: boolean) => {
|
|
const value = inputBox.value;
|
|
|
|
// Clean up the edit container
|
|
editContainer.style.display = 'none';
|
|
editContainer.remove();
|
|
|
|
// Restore the original resource label
|
|
if (existingResourceLabelElement) {
|
|
existingResourceLabelElement.style.display = '';
|
|
}
|
|
|
|
// Restore the session content container
|
|
const sessionContentElement = container.querySelector('.session-content') as HTMLElement;
|
|
if (sessionContentElement) {
|
|
sessionContentElement.style.display = '';
|
|
}
|
|
|
|
if (finishEditing) {
|
|
editableData.onFinish(value, success);
|
|
}
|
|
});
|
|
|
|
const showInputBoxNotification = () => {
|
|
if (inputBox.isInputValid()) {
|
|
const message = editableData.validationMessage(inputBox.value);
|
|
if (message) {
|
|
inputBox.showMessage({
|
|
content: message.content,
|
|
formatContent: true,
|
|
type: message.severity === Severity.Info ? MessageType.INFO : message.severity === Severity.Warning ? MessageType.WARNING : MessageType.ERROR
|
|
});
|
|
} else {
|
|
inputBox.hideMessage();
|
|
}
|
|
}
|
|
};
|
|
showInputBoxNotification();
|
|
|
|
const disposables: IDisposable[] = [
|
|
inputBox,
|
|
DOM.addStandardDisposableListener(inputBox.inputElement, DOM.EventType.KEY_DOWN, (e: StandardKeyboardEvent) => {
|
|
if (e.equals(KeyCode.Enter)) {
|
|
if (!inputBox.validate()) {
|
|
done(true, true);
|
|
}
|
|
} else if (e.equals(KeyCode.Escape)) {
|
|
done(false, true);
|
|
}
|
|
}),
|
|
DOM.addStandardDisposableListener(inputBox.inputElement, DOM.EventType.KEY_UP, () => {
|
|
showInputBoxNotification();
|
|
}),
|
|
DOM.addDisposableListener(inputBox.inputElement, DOM.EventType.BLUR, async () => {
|
|
while (true) {
|
|
await timeout(0);
|
|
|
|
const ownerDocument = inputBox.inputElement.ownerDocument;
|
|
if (!ownerDocument.hasFocus()) {
|
|
break;
|
|
}
|
|
if (DOM.isActiveElement(inputBox.inputElement)) {
|
|
return;
|
|
} else if (DOM.isHTMLElement(ownerDocument.activeElement) && DOM.hasParentWithClass(ownerDocument.activeElement, 'context-view')) {
|
|
// Do nothing - context menu is open
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
|
|
done(inputBox.isInputValid(), true);
|
|
})
|
|
];
|
|
|
|
const disposableStore = new DisposableStore();
|
|
disposables.forEach(d => disposableStore.add(d));
|
|
disposableStore.add(toDisposable(() => done(false, false)));
|
|
return disposableStore;
|
|
}
|
|
|
|
disposeTemplate(templateData: ISessionTemplateData): void {
|
|
templateData.elementDisposable.dispose();
|
|
templateData.resourceLabel.dispose();
|
|
templateData.actionBar.dispose();
|
|
}
|
|
}
|
|
|
|
// Identity provider for session items
|
|
class SessionsIdentityProvider {
|
|
getId(element: ChatSessionItemWithProvider): string {
|
|
return element.id;
|
|
}
|
|
}
|
|
|
|
// Accessibility provider for session items
|
|
class SessionsAccessibilityProvider {
|
|
getWidgetAriaLabel(): string {
|
|
return nls.localize('chatSessions', 'Chat Sessions');
|
|
}
|
|
|
|
getAriaLabel(element: ChatSessionItemWithProvider): string | null {
|
|
return element.label || element.id;
|
|
}
|
|
}
|
|
|
|
class SessionsViewPane extends ViewPane {
|
|
private tree: WorkbenchAsyncDataTree<IChatSessionItemProvider, ChatSessionItemWithProvider, FuzzyScore> | undefined;
|
|
private treeContainer: HTMLElement | undefined;
|
|
private messageElement?: HTMLElement;
|
|
private _isEmpty: boolean = true;
|
|
|
|
constructor(
|
|
private readonly provider: IChatSessionItemProvider,
|
|
options: IViewPaneOptions,
|
|
@IKeybindingService keybindingService: IKeybindingService,
|
|
@IContextMenuService contextMenuService: IContextMenuService,
|
|
@IConfigurationService configurationService: IConfigurationService,
|
|
@IContextKeyService contextKeyService: IContextKeyService,
|
|
@IViewDescriptorService viewDescriptorService: IViewDescriptorService,
|
|
@IInstantiationService instantiationService: IInstantiationService,
|
|
@IOpenerService openerService: IOpenerService,
|
|
@IThemeService themeService: IThemeService,
|
|
@IHoverService hoverService: IHoverService,
|
|
@IChatService private readonly chatService: IChatService,
|
|
@IEditorService private readonly editorService: IEditorService,
|
|
@IViewsService private readonly viewsService: IViewsService,
|
|
@ILogService private readonly logService: ILogService,
|
|
@IProgressService private readonly progressService: IProgressService,
|
|
@IMenuService private readonly menuService: IMenuService,
|
|
) {
|
|
super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService);
|
|
|
|
// Listen for changes in the provider if it's a LocalChatSessionsProvider
|
|
if (provider instanceof LocalChatSessionsProvider) {
|
|
this._register(provider.onDidChange(() => {
|
|
if (this.tree && this.isBodyVisible()) {
|
|
this.refreshTreeWithProgress();
|
|
}
|
|
}));
|
|
}
|
|
}
|
|
|
|
override shouldShowWelcome(): boolean {
|
|
return this._isEmpty;
|
|
}
|
|
|
|
private isLocalChatSessionItem(item: IChatSessionItem): item is ILocalChatSessionItem {
|
|
return ('editor' in item && 'group' in item) || ('widget' in item && 'sessionType' in item);
|
|
}
|
|
|
|
public refreshTree(): void {
|
|
if (this.tree && this.isBodyVisible()) {
|
|
this.refreshTreeWithProgress();
|
|
}
|
|
}
|
|
|
|
private isEmpty() {
|
|
// Check if the tree has the provider node and get its children count
|
|
if (!this.tree?.hasNode(this.provider)) {
|
|
return true;
|
|
}
|
|
const providerNode = this.tree.getNode(this.provider);
|
|
const childCount = providerNode.children?.length || 0;
|
|
|
|
return childCount === 0;
|
|
}
|
|
|
|
/**
|
|
* Updates the empty state message based on current tree data.
|
|
* Uses the tree's existing data to avoid redundant provider calls.
|
|
*/
|
|
private updateEmptyState(): void {
|
|
try {
|
|
const newEmptyState = this.isEmpty();
|
|
if (newEmptyState !== this._isEmpty) {
|
|
this._isEmpty = newEmptyState;
|
|
this._onDidChangeViewWelcomeState.fire();
|
|
}
|
|
} catch (error) {
|
|
this.logService.error('Error checking tree data for empty state:', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Refreshes the tree data with progress indication.
|
|
* Shows a progress indicator while the tree updates its children from the provider.
|
|
*/
|
|
private async refreshTreeWithProgress(): Promise<void> {
|
|
if (!this.tree) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await this.progressService.withProgress(
|
|
{
|
|
location: this.id, // Use the view ID as the progress location
|
|
title: nls.localize('chatSessions.refreshing', 'Refreshing chat sessions...'),
|
|
},
|
|
async () => {
|
|
await this.tree!.updateChildren(this.provider);
|
|
}
|
|
);
|
|
|
|
// Check for empty state after refresh using tree data
|
|
this.updateEmptyState();
|
|
} catch (error) {
|
|
// Log error but don't throw to avoid breaking the UI
|
|
this.logService.error('Error refreshing chat sessions tree:', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Loads initial tree data with progress indication.
|
|
* Shows a progress indicator while the tree loads data from the provider.
|
|
*/
|
|
private async loadDataWithProgress(): Promise<void> {
|
|
if (!this.tree) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await this.progressService.withProgress(
|
|
{
|
|
location: this.id, // Use the view ID as the progress location
|
|
title: nls.localize('chatSessions.loading', 'Loading chat sessions...'),
|
|
},
|
|
async () => {
|
|
await this.tree!.setInput(this.provider);
|
|
}
|
|
);
|
|
|
|
// Check for empty state after loading using tree data
|
|
this.updateEmptyState();
|
|
} catch (error) {
|
|
// Log error but don't throw to avoid breaking the UI
|
|
this.logService.error('Error loading chat sessions data:', error);
|
|
}
|
|
}
|
|
|
|
protected override renderBody(container: HTMLElement): void {
|
|
super.renderBody(container);
|
|
|
|
this.treeContainer = DOM.append(container, DOM.$('.chat-sessions-tree-container'));
|
|
// Create message element for empty state
|
|
this.messageElement = append(container, $('.chat-sessions-message'));
|
|
this.messageElement.style.display = 'none';
|
|
// Create the tree components
|
|
const dataSource = new SessionsDataSource(this.provider, this.chatService);
|
|
const delegate = new SessionsDelegate();
|
|
const identityProvider = new SessionsIdentityProvider();
|
|
const accessibilityProvider = new SessionsAccessibilityProvider();
|
|
|
|
// Use the existing ResourceLabels service for consistent styling
|
|
const labels = this.instantiationService.createInstance(ResourceLabels, { onDidChangeVisibility: this.onDidChangeBodyVisibility });
|
|
const renderer = this.instantiationService.createInstance(SessionsRenderer, labels);
|
|
this._register(renderer);
|
|
|
|
const getResourceForElement = (element: ChatSessionItemWithProvider): URI => {
|
|
return ChatSessionUri.forSession(element.provider.chatSessionType, element.id);
|
|
};
|
|
|
|
this.tree = this.instantiationService.createInstance(
|
|
WorkbenchAsyncDataTree,
|
|
'ChatSessions',
|
|
this.treeContainer,
|
|
delegate,
|
|
[renderer],
|
|
dataSource,
|
|
{
|
|
dnd: {
|
|
onDragStart: (data, originalEvent) => {
|
|
try {
|
|
const elements = data.getData() as ChatSessionItemWithProvider[];
|
|
const uris = coalesce(elements.map(getResourceForElement));
|
|
this.instantiationService.invokeFunction(accessor => fillEditorsDragData(accessor, uris, originalEvent));
|
|
} catch {
|
|
// noop
|
|
}
|
|
},
|
|
getDragURI: (element: ChatSessionItemWithProvider) => {
|
|
return getResourceForElement(element).toString();
|
|
},
|
|
getDragLabel: (elements: ChatSessionItemWithProvider[]) => {
|
|
if (elements.length === 1) {
|
|
return elements[0].label;
|
|
}
|
|
return nls.localize('chatSessions.dragLabel', "{0} chat sessions", elements.length);
|
|
},
|
|
drop: () => { },
|
|
onDragOver: () => false,
|
|
dispose: () => { },
|
|
},
|
|
accessibilityProvider,
|
|
identityProvider,
|
|
multipleSelectionSupport: false,
|
|
overrideStyles: {
|
|
listBackground: undefined
|
|
},
|
|
|
|
}
|
|
) as WorkbenchAsyncDataTree<IChatSessionItemProvider, ChatSessionItemWithProvider, FuzzyScore>;
|
|
|
|
// Set the input
|
|
this.tree.setInput(this.provider);
|
|
|
|
// Register tree events
|
|
this._register(this.tree.onDidOpen((e) => {
|
|
if (e.element) {
|
|
this.openChatSession(e.element);
|
|
}
|
|
}));
|
|
|
|
// Register context menu event for right-click actions
|
|
this._register(this.tree.onContextMenu((e) => {
|
|
if (e.element) {
|
|
this.showContextMenu(e);
|
|
}
|
|
}));
|
|
|
|
// Handle visibility changes to load data
|
|
this._register(this.onDidChangeBodyVisibility(async visible => {
|
|
if (visible && this.tree) {
|
|
await this.loadDataWithProgress();
|
|
}
|
|
}));
|
|
|
|
// Initially load data if visible
|
|
if (this.isBodyVisible() && this.tree) {
|
|
this.loadDataWithProgress();
|
|
}
|
|
|
|
this._register(this.tree);
|
|
}
|
|
|
|
protected override layoutBody(height: number, width: number): void {
|
|
super.layoutBody(height, width);
|
|
if (this.tree) {
|
|
this.tree.layout(height, width);
|
|
}
|
|
}
|
|
|
|
private async openChatSession(element: ChatSessionItemWithProvider) {
|
|
if (!element || !element.id) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
if (element.id === 'show-history') {
|
|
// Don't try to open the "Show history..." node itself
|
|
return;
|
|
}
|
|
|
|
// Handle history items first
|
|
if (element.id.startsWith('history-')) {
|
|
const sessionId = element.id.substring('history-'.length);
|
|
const sessionWithProvider = element as ChatSessionItemWithProvider;
|
|
|
|
// For local history sessions, use ChatEditorInput approach
|
|
if (sessionWithProvider.provider.chatSessionType === 'local') {
|
|
const options: IChatEditorOptions = {
|
|
target: { sessionId },
|
|
pinned: true,
|
|
// Add a marker to indicate this session was opened from history
|
|
ignoreInView: true
|
|
};
|
|
await this.editorService.openEditor({ resource: ChatEditorInput.getNewEditorUri(), options });
|
|
} else {
|
|
// For external provider sessions, use ChatSessionUri approach
|
|
const providerType = sessionWithProvider.provider.chatSessionType;
|
|
await this.editorService.openEditor({
|
|
resource: ChatSessionUri.forSession(providerType, sessionId),
|
|
options: { pinned: true }
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Handle local session items (active editors/widgets)
|
|
if (this.isLocalChatSessionItem(element)) {
|
|
if (element.sessionType === 'editor' && element.editor && element.group) {
|
|
// Focus the existing editor
|
|
await element.group.openEditor(element.editor, { pinned: true });
|
|
return;
|
|
} else if (element.sessionType === 'widget' && element.widget) {
|
|
// Focus the chat widget
|
|
const chatViewPane = await this.viewsService.openView(ChatViewId) as ChatViewPane;
|
|
if (chatViewPane && element.widget.viewModel?.model) {
|
|
await chatViewPane.loadSession(element.widget.viewModel.model.sessionId);
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
// For other session types, open as a new chat editor
|
|
const sessionWithProvider = element as ChatSessionItemWithProvider;
|
|
const sessionId = element.id;
|
|
const providerType = sessionWithProvider.provider.chatSessionType;
|
|
|
|
|
|
await this.editorService.openEditor({
|
|
resource: ChatSessionUri.forSession(providerType, sessionId),
|
|
options: { pinned: true }
|
|
});
|
|
|
|
} catch (error) {
|
|
this.logService.error('[SessionsViewPane] Failed to open chat session:', error);
|
|
}
|
|
}
|
|
|
|
private showContextMenu(e: ITreeContextMenuEvent<ChatSessionItemWithProvider>) {
|
|
if (!e.element) {
|
|
return;
|
|
}
|
|
|
|
const session = e.element;
|
|
const sessionWithProvider = session as ChatSessionItemWithProvider;
|
|
|
|
// Create context overlay for this specific session item
|
|
const contextOverlay = getSessionItemContextOverlay(session, sessionWithProvider.provider);
|
|
const contextKeyService = this.contextKeyService.createOverlay(contextOverlay);
|
|
|
|
// Create marshalled context for command execution
|
|
const marshalledSession = {
|
|
session: session,
|
|
$mid: MarshalledId.ChatSessionContext
|
|
};
|
|
|
|
// Create menu for this session item to get actions
|
|
const menu = this.menuService.createMenu(MenuId.ChatSessionsMenu, contextKeyService);
|
|
|
|
// Get actions and filter for context menu (all actions that are NOT inline)
|
|
const actions = menu.getActions({ arg: marshalledSession, shouldForwardArgs: true });
|
|
|
|
const { secondary } = getActionBarActions(actions, 'inline'); this.contextMenuService.showContextMenu({
|
|
getActions: () => secondary,
|
|
getAnchor: () => e.anchor,
|
|
getActionsContext: () => marshalledSession,
|
|
});
|
|
|
|
menu.dispose();
|
|
}
|
|
}
|
|
|
|
MenuRegistry.appendMenuItem(MenuId.ViewTitle, {
|
|
command: {
|
|
id: 'workbench.action.openChat',
|
|
title: nls.localize2('interactiveSession.open', "New Chat Editor"),
|
|
icon: Codicon.plus
|
|
},
|
|
group: 'navigation',
|
|
order: 1,
|
|
when: ContextKeyExpr.equals('view', `${VIEWLET_ID}.local`),
|
|
});
|