Path: blob/main/src/vs/workbench/contrib/chat/browser/actions/chatSessionActions.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 { localize } from '../../../../../nls.js';6import { Action2, MenuId, MenuRegistry } from '../../../../../platform/actions/common/actions.js';7import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js';8import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js';9import { KeyCode } from '../../../../../base/common/keyCodes.js';10import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js';11import { IChatService } from '../../common/chatService.js';12import { IChatSessionItem, IChatSessionsService } from '../../common/chatSessionsService.js';13import { ILogService } from '../../../../../platform/log/common/log.js';14import Severity from '../../../../../base/common/severity.js';15import { ChatContextKeys } from '../../common/chatContextKeys.js';16import { MarshalledId } from '../../../../../base/common/marshallingIds.js';17import { ChatEditorInput } from '../chatEditorInput.js';18import { CHAT_CATEGORY } from './chatActions.js';19import { AUX_WINDOW_GROUP, IEditorService, SIDE_GROUP } from '../../../../services/editor/common/editorService.js';20import { IChatEditorOptions } from '../chatEditor.js';21import { ChatSessionUri } from '../../common/chatUri.js';22import { ILocalChatSessionItem, VIEWLET_ID } from '../chatSessions.js';23import { IViewsService } from '../../../../services/views/common/viewsService.js';24import { ChatViewId } from '../chat.js';25import { ChatViewPane } from '../chatViewPane.js';26import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';27import { ChatConfiguration } from '../../common/constants.js';28import { Codicon } from '../../../../../base/common/codicons.js';29import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js';3031export interface IChatSessionContext {32sessionId: string;33sessionType: 'editor' | 'widget';34currentTitle: string;35editorInput?: any;36editorGroup?: any;37widget?: any;38}3940interface IMarshalledChatSessionContext {41$mid: MarshalledId.ChatSessionContext;42session: {43id: string;44label: string;45editor?: ChatEditorInput;46widget?: any;47sessionType?: 'editor' | 'widget';48};49}5051function isLocalChatSessionItem(item: IChatSessionItem): item is ILocalChatSessionItem {52return ('editor' in item && 'group' in item) || ('widget' in item && 'sessionType' in item);53}5455function isMarshalledChatSessionContext(obj: unknown): obj is IMarshalledChatSessionContext {56return !!obj &&57typeof obj === 'object' &&58'$mid' in obj &&59(obj as any).$mid === MarshalledId.ChatSessionContext &&60'session' in obj;61}6263export class RenameChatSessionAction extends Action2 {64static readonly id = 'workbench.action.chat.renameSession';6566constructor() {67super({68id: RenameChatSessionAction.id,69title: localize('renameSession', "Rename"),70f1: false,71category: CHAT_CATEGORY,72icon: Codicon.pencil,73keybinding: {74weight: KeybindingWeight.WorkbenchContrib,75primary: KeyCode.F2,76when: ContextKeyExpr.equals('focusedView', 'workbench.view.chat.sessions.local')77}78});79}8081async run(accessor: ServicesAccessor, context?: IChatSessionContext | IMarshalledChatSessionContext): Promise<void> {82if (!context) {83return;84}8586// Handle marshalled context from menu actions87let sessionContext: IChatSessionContext;88if (isMarshalledChatSessionContext(context)) {89const session = context.session;90// Extract actual session ID based on session type91let actualSessionId: string | undefined;92const currentTitle = session.label;9394// For history sessions, we need to extract the actual session ID95if (session.id.startsWith('history-')) {96actualSessionId = session.id.replace('history-', '');97} else if (session.sessionType === 'editor' && session.editor instanceof ChatEditorInput) {98actualSessionId = session.editor.sessionId;99} else if (session.sessionType === 'widget' && session.widget) {100actualSessionId = session.widget.viewModel?.model.sessionId;101} else {102// Fall back to using the session ID directly103actualSessionId = session.id;104}105106if (!actualSessionId) {107return; // Can't proceed without a session ID108}109110sessionContext = {111sessionId: actualSessionId,112sessionType: session.sessionType || 'editor',113currentTitle: currentTitle,114editorInput: session.editor,115widget: session.widget116};117} else {118sessionContext = context;119}120121const chatSessionsService = accessor.get(IChatSessionsService);122const logService = accessor.get(ILogService);123const chatService = accessor.get(IChatService);124125try {126// Find the chat sessions view and trigger inline rename mode127// This is similar to how file renaming works in the explorer128await chatSessionsService.setEditableSession(sessionContext.sessionId, {129validationMessage: (value: string) => {130if (!value || value.trim().length === 0) {131return { content: localize('renameSession.emptyName', "Name cannot be empty"), severity: Severity.Error };132}133if (value.length > 100) {134return { content: localize('renameSession.nameTooLong', "Name is too long (maximum 100 characters)"), severity: Severity.Error };135}136return null;137},138placeholder: localize('renameSession.placeholder', "Enter new name for chat session"),139startingValue: sessionContext.currentTitle,140onFinish: async (value: string, success: boolean) => {141if (success && value && value.trim() !== sessionContext.currentTitle) {142try {143const newTitle = value.trim();144chatService.setChatSessionTitle(sessionContext.sessionId, newTitle);145// Notify the local sessions provider that items have changed146chatSessionsService.notifySessionItemsChanged('local');147} catch (error) {148logService.error(149localize('renameSession.error', "Failed to rename chat session: {0}",150(error instanceof Error ? error.message : String(error)))151);152}153}154await chatSessionsService.setEditableSession(sessionContext.sessionId, null);155}156});157} catch (error) {158logService.error('Failed to rename chat session', error instanceof Error ? error.message : String(error));159}160}161}162163/**164* Action to delete a chat session from history165*/166export class DeleteChatSessionAction extends Action2 {167static readonly id = 'workbench.action.chat.deleteSession';168169constructor() {170super({171id: DeleteChatSessionAction.id,172title: localize('deleteSession', "Delete"),173f1: false,174category: CHAT_CATEGORY,175icon: Codicon.x,176});177}178179async run(accessor: ServicesAccessor, context?: IChatSessionContext | IMarshalledChatSessionContext): Promise<void> {180if (!context) {181return;182}183184// Handle marshalled context from menu actions185let sessionContext: IChatSessionContext;186if (isMarshalledChatSessionContext(context)) {187const session = context.session;188// Extract actual session ID based on session type189let actualSessionId: string | undefined;190const currentTitle = session.label;191192// For history sessions, we need to extract the actual session ID193if (session.id.startsWith('history-')) {194actualSessionId = session.id.replace('history-', '');195} else if (session.sessionType === 'editor' && session.editor instanceof ChatEditorInput) {196actualSessionId = session.editor.sessionId;197} else if (session.sessionType === 'widget' && session.widget) {198actualSessionId = session.widget.viewModel?.model.sessionId;199} else {200// Fall back to using the session ID directly201actualSessionId = session.id;202}203204if (!actualSessionId) {205return; // Can't proceed without a session ID206}207208sessionContext = {209sessionId: actualSessionId,210sessionType: session.sessionType || 'editor',211currentTitle: currentTitle,212editorInput: session.editor,213widget: session.widget214};215} else {216sessionContext = context;217}218219const chatService = accessor.get(IChatService);220const dialogService = accessor.get(IDialogService);221const logService = accessor.get(ILogService);222const chatSessionsService = accessor.get(IChatSessionsService);223224try {225// Show confirmation dialog226const result = await dialogService.confirm({227message: localize('deleteSession.confirm', "Are you sure you want to delete this chat session?"),228detail: localize('deleteSession.detail', "This action cannot be undone."),229primaryButton: localize('deleteSession.delete', "Delete"),230type: 'warning'231});232233if (result.confirmed) {234await chatService.removeHistoryEntry(sessionContext.sessionId);235// Notify the local sessions provider that items have changed236chatSessionsService.notifySessionItemsChanged('local');237}238} catch (error) {239logService.error('Failed to delete chat session', error instanceof Error ? error.message : String(error));240}241}242}243244/**245* Action to open a chat session in a new window246*/247export class OpenChatSessionInNewWindowAction extends Action2 {248static readonly id = 'workbench.action.chat.openSessionInNewWindow';249250constructor() {251super({252id: OpenChatSessionInNewWindowAction.id,253title: localize('chat.openSessionInNewWindow.label', "Open Chat in New Window"),254category: CHAT_CATEGORY,255f1: false,256});257}258259async run(accessor: ServicesAccessor, context?: IChatSessionContext | IMarshalledChatSessionContext): Promise<void> {260if (!context) {261return;262}263264const editorService = accessor.get(IEditorService);265let sessionId: string;266let sessionItem: IChatSessionItem | undefined;267268if (isMarshalledChatSessionContext(context)) {269const session = context.session;270sessionItem = session;271272// For local sessions, extract the actual session ID from editor or widget273if (isLocalChatSessionItem(session)) {274if (session.sessionType === 'editor' && session.editor instanceof ChatEditorInput) {275sessionId = session.editor.sessionId || session.id;276} else if (session.sessionType === 'widget' && session.widget) {277sessionId = session.widget.viewModel?.model.sessionId || session.id;278} else {279sessionId = session.id;280}281} else {282// For external provider sessions, use the session ID directly283sessionId = session.id;284}285} else {286sessionId = context.sessionId;287}288289if (sessionItem && (isLocalChatSessionItem(sessionItem) || sessionId.startsWith('history-'))) {290// For history session remove the `history` prefix291const sessionIdWithoutHistory = sessionId.replace('history-', '');292const options: IChatEditorOptions = {293target: { sessionId: sessionIdWithoutHistory },294pinned: true,295auxiliary: { compact: false },296ignoreInView: true297};298// For local sessions, create a new chat editor in the auxiliary window299await editorService.openEditor({300resource: ChatEditorInput.getNewEditorUri(),301options,302}, AUX_WINDOW_GROUP);303} else {304// For external provider sessions, open the existing session in the auxiliary window305const providerType = sessionItem && (sessionItem as any).provider?.chatSessionType || 'external';306await editorService.openEditor({307resource: ChatSessionUri.forSession(providerType, sessionId),308options: {309pinned: true,310auxiliary: { compact: false }311} satisfies IChatEditorOptions312}, AUX_WINDOW_GROUP);313}314}315}316317/**318* Action to open a chat session in a new editor group to the side319*/320export class OpenChatSessionInNewEditorGroupAction extends Action2 {321static readonly id = 'workbench.action.chat.openSessionInNewEditorGroup';322323constructor() {324super({325id: OpenChatSessionInNewEditorGroupAction.id,326title: localize('chat.openSessionInNewEditorGroup.label', "Open Chat to the Side"),327category: CHAT_CATEGORY,328f1: false,329});330}331332async run(accessor: ServicesAccessor, context?: IChatSessionContext | IMarshalledChatSessionContext): Promise<void> {333if (!context) {334return;335}336337const editorService = accessor.get(IEditorService);338let sessionId: string;339let sessionItem: IChatSessionItem | undefined;340341if (isMarshalledChatSessionContext(context)) {342const session = context.session;343sessionItem = session;344345if (isLocalChatSessionItem(session)) {346if (session.sessionType === 'editor' && session.editor instanceof ChatEditorInput) {347sessionId = session.editor.sessionId || session.id;348} else if (session.sessionType === 'widget' && session.widget) {349sessionId = session.widget.viewModel?.model.sessionId || session.id;350} else {351sessionId = session.id;352}353} else {354sessionId = session.id;355}356} else {357sessionId = context.sessionId;358}359360// Open editor to the side using VS Code's standard pattern361if (sessionItem && (isLocalChatSessionItem(sessionItem) || sessionId.startsWith('history-'))) {362const sessionIdWithoutHistory = sessionId.replace('history-', '');363const options: IChatEditorOptions = {364target: { sessionId: sessionIdWithoutHistory },365pinned: true,366ignoreInView: true,367};368// For local sessions, create a new chat editor369await editorService.openEditor({370resource: ChatEditorInput.getNewEditorUri(),371options,372}, SIDE_GROUP);373} else {374// For external provider sessions, open the existing session375const providerType = sessionItem && (sessionItem as any).provider?.chatSessionType || 'external';376await editorService.openEditor({377resource: ChatSessionUri.forSession(providerType, sessionId),378options: { pinned: true } satisfies IChatEditorOptions379}, SIDE_GROUP);380}381}382}383384/**385* Action to open a chat session in the sidebar (chat widget)386*/387export class OpenChatSessionInSidebarAction extends Action2 {388static readonly id = 'workbench.action.chat.openSessionInSidebar';389390constructor() {391super({392id: OpenChatSessionInSidebarAction.id,393title: localize('chat.openSessionInSidebar.label', "Open Chat in Sidebar"),394category: CHAT_CATEGORY,395f1: false,396});397}398399async run(accessor: ServicesAccessor, context?: IChatSessionContext | IMarshalledChatSessionContext): Promise<void> {400if (!context) {401return;402}403404const viewsService = accessor.get(IViewsService);405let sessionId: string;406let sessionItem: IChatSessionItem | undefined;407408if (isMarshalledChatSessionContext(context)) {409const session = context.session;410sessionItem = session;411412if (isLocalChatSessionItem(session)) {413if (session.sessionType === 'editor' && session.editor instanceof ChatEditorInput) {414sessionId = session.editor.sessionId || session.id;415} else if (session.sessionType === 'widget' && session.widget) {416sessionId = session.widget.viewModel?.model.sessionId || session.id;417} else {418sessionId = session.id;419}420} else {421sessionId = session.id;422}423} else {424sessionId = context.sessionId;425}426427// Open the chat view in the sidebar428const chatViewPane = await viewsService.openView(ChatViewId) as ChatViewPane;429if (chatViewPane) {430// Handle different session types431if (sessionItem && (isLocalChatSessionItem(sessionItem) || sessionId.startsWith('history-'))) {432// For local sessions and history sessions, remove the 'history-' prefix if present433const sessionIdWithoutHistory = sessionId.replace('history-', '');434// Load using the session ID directly435await chatViewPane.loadSession(sessionIdWithoutHistory);436} else {437// For external provider sessions, create a URI and load using that438const providerType = sessionItem && (sessionItem as any).provider?.chatSessionType || 'external';439const sessionUri = ChatSessionUri.forSession(providerType, sessionId);440await chatViewPane.loadSession(sessionUri);441}442443// Focus the chat input444chatViewPane.focusInput();445}446}447}448449/**450* Action to toggle the description display mode for Chat Sessions451*/452export class ToggleChatSessionsDescriptionDisplayAction extends Action2 {453static readonly id = 'workbench.action.chatSessions.toggleDescriptionDisplay';454455constructor() {456super({457id: ToggleChatSessionsDescriptionDisplayAction.id,458title: localize('chatSessions.toggleDescriptionDisplay.label', "Show Rich Descriptions"),459category: CHAT_CATEGORY,460f1: false,461toggled: ContextKeyExpr.equals(`config.${ChatConfiguration.ShowAgentSessionsViewDescription}`, true)462});463}464465async run(accessor: ServicesAccessor): Promise<void> {466const configurationService = accessor.get(IConfigurationService);467const currentValue = configurationService.getValue(ChatConfiguration.ShowAgentSessionsViewDescription);468469await configurationService.updateValue(470ChatConfiguration.ShowAgentSessionsViewDescription,471!currentValue472);473}474}475476// Register the menu item - show for all local chat sessions (including history items)477MenuRegistry.appendMenuItem(MenuId.ChatSessionsMenu, {478command: {479id: RenameChatSessionAction.id,480title: localize('renameSession', "Rename"),481icon: Codicon.pencil482},483group: 'inline',484order: 1,485when: ChatContextKeys.sessionType.isEqualTo('local')486});487488// Register delete menu item - only show for non-active sessions (history items)489MenuRegistry.appendMenuItem(MenuId.ChatSessionsMenu, {490command: {491id: DeleteChatSessionAction.id,492title: localize('deleteSession', "Delete"),493icon: Codicon.x494},495group: 'inline',496order: 2,497when: ContextKeyExpr.and(498ChatContextKeys.isHistoryItem.isEqualTo(true),499ChatContextKeys.isActiveSession.isEqualTo(false)500)501});502503MenuRegistry.appendMenuItem(MenuId.ChatSessionsMenu, {504command: {505id: OpenChatSessionInNewEditorGroupAction.id,506title: localize('openToSide', "Open to the Side")507},508group: 'navigation',509order: 2,510});511512MenuRegistry.appendMenuItem(MenuId.ChatSessionsMenu, {513command: {514id: OpenChatSessionInSidebarAction.id,515title: localize('openSessionInSidebar', "Open in Sidebar")516},517group: 'navigation',518order: 3,519});520521// Register the toggle command for the ViewTitle menu522MenuRegistry.appendMenuItem(MenuId.ViewContainerTitle, {523command: {524id: ToggleChatSessionsDescriptionDisplayAction.id,525title: localize('chatSessions.toggleDescriptionDisplay.label', "Show Rich Descriptions"),526toggled: ContextKeyExpr.equals(`config.${ChatConfiguration.ShowAgentSessionsViewDescription}`, true)527},528group: '1_config',529order: 1,530when: ContextKeyExpr.equals('viewContainer', VIEWLET_ID),531});532533534535