diff --git a/src/vs/sessions/browser/parts/chatCompositeBar.ts b/src/vs/sessions/browser/parts/chatCompositeBar.ts index ff6e8d3a814c..3e1175a196a4 100644 --- a/src/vs/sessions/browser/parts/chatCompositeBar.ts +++ b/src/vs/sessions/browser/parts/chatCompositeBar.ts @@ -26,7 +26,7 @@ import { IKeyboardEvent } from '../../../base/browser/keyboardEvent.js'; import { KeyCode } from '../../../base/common/keyCodes.js'; import { onUnexpectedError } from '../../../base/common/errors.js'; import { localize } from '../../../nls.js'; -import { ChatInteractivity, IChat, SessionStatus } from '../../services/sessions/common/session.js'; +import { ChatInteractivity, getChatCapabilities, IChat, SessionStatus } from '../../services/sessions/common/session.js'; import { IActiveSession, ISessionsManagementService } from '../../services/sessions/common/sessionsManagement.js'; import { ISessionsService } from '../../services/sessions/browser/sessionsService.js'; import { ISessionsPartService } from '../../services/sessions/browser/sessionsPartService.js'; @@ -368,7 +368,7 @@ export class ChatCompositeBar extends Disposable { // Double-click the tab to start an inline rename, mirroring the session title. this._tabDisposables.add(addDisposableListener(tab, EventType.DBLCLICK, (e: MouseEvent) => { - if (chat.status.get() === SessionStatus.Untitled) { + if (chat.status.get() === SessionStatus.Untitled || !getChatCapabilities(chat, session, undefined).canRename) { return; } e.preventDefault(); @@ -387,9 +387,17 @@ export class ChatCompositeBar extends Disposable { const event = new StandardMouseEvent(getWindow(tab), e); this._contextMenuService.showContextMenu({ getAnchor: () => event, - getActions: () => isMainChat - ? [renameAction] - : [renameAction, deleteAction] + getActions: () => { + const capabilities = getChatCapabilities(chat, session, undefined); + const actions = []; + if (capabilities.canRename) { + actions.push(renameAction); + } + if (capabilities.canDelete) { + actions.push(deleteAction); + } + return actions; + } }); })); diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index 897dd71003a8..4403e890db79 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -44,7 +44,7 @@ import { ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from import { buildMutableConfigSchema, IAgentHostMcpServer, IAgentHostSessionsProvider, resolvedConfigsEqual } from '../../../../common/agentHostSessionsProvider.js'; import { agentHostSessionWorkspaceKey } from '../../../../common/agentHostSessionWorkspace.js'; import { isSessionConfigComplete } from '../../../../common/sessionConfig.js'; -import { ChatInteractivity, ChatOriginKind, IChat, IGitHubInfo, ISession, ISessionAgentRef, ISessionCapabilities, ISessionChangeset, ISessionChangesSummary, ISessionFile, ISessionFileChange, ISessionType, ISessionWorkspace, ISessionWorkspaceBrowseAction, sessionFileChangesEqual, SessionStatus, toSessionId } from '../../../../services/sessions/common/session.js'; +import { ChatInteractivity, ChatOriginKind, DEFAULT_CHAT_CAPABILITIES, IChat, IChatCapabilities, IGitHubInfo, ISession, ISessionAgentRef, ISessionCapabilities, ISessionChangeset, ISessionChangesSummary, ISessionFile, ISessionFileChange, ISessionType, ISessionWorkspace, ISessionWorkspaceBrowseAction, sessionFileChangesEqual, SessionStatus, toSessionId } from '../../../../services/sessions/common/session.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; import { IDeleteChatOptions, ISendRequestOptions, ISessionChangeEvent, ISessionModelPickerOptions } from '../../../../services/sessions/common/sessionsProvider.js'; import { IGitHubService } from '../../../github/browser/githubService.js'; @@ -264,6 +264,12 @@ class AdditionalChat extends Disposable { description: this._description, lastTurnEnd: this._lastTurnEnd, origin: summary.origin ? { kind: toSessionChatOriginKind(summary.origin.kind), parentChat } : undefined, + // Subagent (tool-origin) worker chats are transient children and can be + // neither renamed nor deleted; other peer chats are fully manageable. + capabilities: constObservable( + summary.origin?.kind === ProtocolChatOriginKind.Tool + ? { canRename: false, canDelete: false } + : DEFAULT_CHAT_CAPABILITIES), }; } diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts index f8547d1bf7fe..cd5acb7fe601 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts @@ -35,7 +35,7 @@ import { ChatModeKind } from '../../../../../../workbench/contrib/chat/common/co import { ILanguageModelsService, type ILanguageModelChatMetadata } from '../../../../../../workbench/contrib/chat/common/languageModels.js'; import type { IChatModel, IChatModelInputState, IInputModel } from '../../../../../../workbench/contrib/chat/common/model/chatModel.js'; import { ISessionChangeEvent } from '../../../../../services/sessions/common/sessionsProvider.js'; -import { ChatInteractivity, ChatOriginKind, ISession, SessionStatus } from '../../../../../services/sessions/common/session.js'; +import { ChatInteractivity, ChatOriginKind, getChatCapabilities, ISession, SessionStatus } from '../../../../../services/sessions/common/session.js'; import { IActiveSession } from '../../../../../services/sessions/common/sessionsManagement.js'; import { ISessionsService } from '../../../../../services/sessions/browser/sessionsService.js'; import { IAgentHostActiveClientService } from '../../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostActiveClientService.js'; @@ -2169,11 +2169,38 @@ suite('LocalAgentHostSessionsProvider', () => { // The subagent records its parent chat (the default chat) so the // "Agents" row can list it under the chat that spawned it. subagentParentIsMain: !!chats[1]?.origin?.parentChat && isEqual(chats[1].origin.parentChat, chats[0].resource), + // A subagent worker chat is neither renameable nor deletable. + subagentCapabilities: getChatCapabilities(chats[1], session, undefined), }, { titles: ['Session', 'Code Reviewer'], interactivity: [ChatInteractivity.Full, ChatInteractivity.ReadOnly], subagentOrigin: ChatOriginKind.Tool, subagentParentIsMain: true, + subagentCapabilities: { canRename: false, canDelete: false }, + }); + }); + + test('the main chat is renameable but never deletable via capabilities', () => { + const provider = createProvider(disposables, agentHost); + const session = setupMultiChatSession(provider, 'main-caps'); + const sessionUri = AgentSession.uri('copilotcli', 'main-caps').toString(); + const defaultChat = buildDefaultChatUri(sessionUri); + const peerChat = buildChatUri(sessionUri, 'peer-1'); + + agentHost.setSessionState('main-caps', 'copilotcli', makeState([ + makeChatSummary(defaultChat, ''), + { ...makeChatSummary(peerChat, 'Peer'), origin: { kind: ProtocolChatOriginKind.User } }, + ], { defaultChat })); + + const chats = session.chats.get(); + assert.deepStrictEqual({ + // The main (default) chat: renameable, never deletable. + main: getChatCapabilities(chats[0], session, undefined), + // A regular user peer chat: fully manageable. + peer: getChatCapabilities(chats[1], session, undefined), + }, { + main: { canRename: true, canDelete: false }, + peer: { canRename: true, canDelete: true }, }); }); diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts b/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts index 987eb7ffcf15..4f3032614887 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts @@ -31,7 +31,7 @@ import { CanGoBackContext, CanGoForwardContext, SessionProviderIdContext, Multip import { ANY_AGENT_HOST_PROVIDER_RE } from '../../../common/agentHostSessionsProvider.js'; import { IActiveSession, ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; -import { ChatOriginKind, getUntitledSessionTitle, IChat, ISession, SessionStatus } from '../../../services/sessions/common/session.js'; +import { ChatOriginKind, getChatCapabilities, getUntitledSessionTitle, IChat, ISession, SessionStatus } from '../../../services/sessions/common/session.js'; import { ISessionsPartService } from '../../../services/sessions/browser/sessionsPartService.js'; import { ISessionsListModelService } from '../../../services/sessions/browser/sessionsListModelService.js'; import { SessionHeaderMetaActionViewItem } from '../../../browser/parts/sessionHeaderMetaActionViewItem.js'; @@ -567,6 +567,55 @@ registerAction2(class CloseChatAction extends Action2 { } }); +registerAction2(class CloseAllChatsAction extends Action2 { + constructor() { + super({ + id: 'sessions.chatCompositeBar.closeAllChats', + title: localize2('closeAllChats', "Close All Chats"), + f1: true, + category: SessionsCategories.Sessions, + // Enabled (palette + keybinding) only while the active session has more + // than one open chat, so the chord targets the focused session and + // stays inert for single-chat sessions. + precondition: SessionHasMultipleOpenChatsContext, + keybinding: { + weight: CHAT_TAB_KEYBINDING_WEIGHT, + when: ContextKeyExpr.and( + IsSessionsWindowContext, + // While a modal editor has focus, let VS Code's own + // closeEditorsInGroup (same chord) act on the editor group. + EditorAreaFocusContext.toNegated(), + SessionHasMultipleOpenChatsContext + ), + // Mirror VS Code's "Close All Editors in Group" chord (Ctrl/Cmd+K W): + // a session is the Agents-window analogue of an editor group. Note + // "Close All Sessions" already owns Ctrl/Cmd+K Ctrl/Cmd+W. + primary: KeyChord(KeyMod.CtrlCmd | KeyCode.KeyK, KeyCode.KeyW), + }, + }); + } + + override async run(accessor: ServicesAccessor): Promise { + const sessionsService = accessor.get(ISessionsService); + const sessionsManagementService = accessor.get(ISessionsManagementService); + const extUri = accessor.get(IUriIdentityService).extUri; + const session = sessionsService.activeSession.get(); + if (!session) { + return; + } + + const mainResource = session.mainChat.get().resource; + const chatsToClose = session.openChats.get().filter(chat => !extUri.isEqual(chat.resource, mainResource)); + for (const chat of chatsToClose) { + if (chat.status.get() === SessionStatus.Untitled) { + await sessionsManagementService.deleteChat(session, chat.resource, { skipConfirmation: true }); + } else { + await sessionsService.closeChat(session, chat); + } + } + } +}); + registerAction2(class DeleteChatAction extends Action2 { constructor() { super({ @@ -592,18 +641,13 @@ registerAction2(class DeleteChatAction extends Action2 { override async run(accessor: ServicesAccessor): Promise { const sessionsService = accessor.get(ISessionsService); const sessionsManagementService = accessor.get(ISessionsManagementService); - const extUri = accessor.get(IUriIdentityService).extUri; const session = sessionsService.activeSession.get(); if (!session) { return; } const chat = session.activeChat.get(); - if (!chat || extUri.isEqual(chat.resource, session.mainChat.get().resource)) { - return; - } - // Tool-spawned subagent chats are transient children (re-derived from the - // parent's tool call), so they are closeable but must not be deleted. - if (chat.origin?.kind === ChatOriginKind.Tool) { + // The main chat and worker (subagent) chats report `canDelete: false`. + if (!chat || !getChatCapabilities(chat, session, undefined).canDelete) { return; } await sessionsManagementService.deleteChat(session, chat.resource); @@ -950,6 +994,11 @@ export class SessionConversationsMenuContribution extends Disposable implements if (chat.status.read(reader) === SessionStatus.Untitled) { return; } + // Subagent (tool-origin) chats are surfaced via the Subagents dropdown, + // not the Conversations submenu. + if (chat.origin?.kind === ChatOriginKind.Tool) { + return; + } const chatResource = chat.resource; const isOpen = openChats.some(c => extUri.isEqual(c.resource, chatResource)); const isMain = extUri.isEqual(chatResource, mainResource); diff --git a/src/vs/sessions/services/sessions/browser/sessionsService.ts b/src/vs/sessions/services/sessions/browser/sessionsService.ts index 09a8edf40ce7..d28754f32763 100644 --- a/src/vs/sessions/services/sessions/browser/sessionsService.ts +++ b/src/vs/sessions/services/sessions/browser/sessionsService.ts @@ -15,7 +15,7 @@ import { InstantiationType, registerSingleton } from '../../../../platform/insta import { ILogService } from '../../../../platform/log/common/log.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; -import { ChatInteractivity, IChat, ISession, SessionStatus } from '../common/session.js'; +import { ChatInteractivity, ChatOriginKind, IChat, ISession, SessionStatus } from '../common/session.js'; import { IActiveSession, ICreateNewChatInSessionOptions, ICreateNewSessionOptions, IRecentlyOpenedSessions, ISessionsChangeEvent, ISessionsManagementService, IToggleSessionStickinessEvent } from '../common/sessionsManagement.js'; import { ISessionsProvidersService } from './sessionsProvidersService.js'; import { SessionsNavigation } from './sessionNavigation.js'; @@ -626,6 +626,12 @@ export class SessionsService extends Disposable implements ISessionsService { if (this.uriIdentityService.extUri.isEqual(chat.resource, session.mainChat.get().resource)) { return; } + // Subagent (tool-origin) chats are hidden by default and toggled via an + // in-memory shown set, not the persisted closed set, so they never + // participate in closed-chat persistence. + if (chat.origin?.kind === ChatOriginKind.Tool) { + return; + } const existing = this._sessionStates.get(session.resource); const closedSet = new Set(existing?.closedChatResources ?? []); const chatResource = chat.resource.toString(); diff --git a/src/vs/sessions/services/sessions/browser/visibleSessions.ts b/src/vs/sessions/services/sessions/browser/visibleSessions.ts index dbed4cfbf7c8..7d89cf881604 100644 --- a/src/vs/sessions/services/sessions/browser/visibleSessions.ts +++ b/src/vs/sessions/services/sessions/browser/visibleSessions.ts @@ -43,6 +43,12 @@ export class VisibleSession extends Disposable implements IActiveSession { /** Resource strings of chats that have been closed (hidden from the tab strip). */ private readonly _closedChatUris: ISettableObservable>; + /** + * Resource strings of subagent (tool-origin) chats the user explicitly opened, + * so they surface as tabs. Subagents are hidden from the tab strip by default; + * this set is not persisted, so they revert to hidden on reload. + */ + private readonly _shownSubagentUris: ISettableObservable>; /** Append-only list tracking close order; last element is the most recently closed. */ private readonly _closedChatOrder: IChat[] = []; readonly openChats: IObservable; @@ -73,6 +79,14 @@ export class VisibleSession extends Disposable implements IActiveSession { } this._closedChatUris = observableValue>('closedChatUris', seed); + // Subagents are hidden by default; if the restored active chat is one, + // surface its tab so the session opens where the user left off. + const shownSubagents = new Set(); + if (initialChat?.origin?.kind === ChatOriginKind.Tool) { + shownSubagents.add(initialChat.resource.toString()); + } + this._shownSubagentUris = observableValue>('shownSubagentUris', shownSubagents); + this._isCreated = _session.status.map(status => status !== SessionStatus.Untitled); this.isCreated = this._isCreated; @@ -92,15 +106,20 @@ export class VisibleSession extends Disposable implements IActiveSession { } return this._session.chats.read(reader).filter(c => closed.has(c.resource.toString())); }); - // Tab strip contents: the open chats in the provider's order. Subagent - // (tool-origin) chats are surfaced as read-only tabs alongside the rest, - // so opening one (e.g. from the Subagents dropdown) shows it as the - // active tab. Hidden and closed chats are already excluded by `openChats`. - this.visibleChatTabs = this.openChats; + // Tab strip contents: the open chats in the provider's order, with subagent + // (tool-origin) chats hidden by default. A subagent surfaces as a tab only + // once explicitly opened (e.g. from the Subagents dropdown), tracked in + // `_shownSubagentUris`. Hidden and closed chats are excluded by `openChats`. + this.visibleChatTabs = derived(this, reader => { + const shownSubagents = this._shownSubagentUris.read(reader); + return this.openChats.read(reader).filter(c => + c.origin?.kind !== ChatOriginKind.Tool || + shownSubagents.has(c.resource.toString())); + }); // Shown for more than one real (non-tool) chat — counting closed ones — - // or a single chat whose title diverged from the session title. Surfaced - // subagent (tool-origin) tabs also warrant showing the strip, so any time - // there is more than one visible tab the strip is shown. + // or a single chat whose title diverged from the session title. An opened + // subagent tab also warrants showing the strip, so any time there is more + // than one visible tab the strip is shown. this.shouldShowChatTabs = derived(this, reader => { const tabChats = this._session.chats.read(reader).filter(c => c.origin?.kind !== ChatOriginKind.Tool && @@ -128,6 +147,24 @@ export class VisibleSession extends Disposable implements IActiveSession { if (chatUri === this._session.mainChat.get().resource.toString()) { return; } + // Closing a subagent (tool-origin) tab just hides it again; it stays + // reachable from the Subagents dropdown and is not added to the + // reopenable closed set. + if (chat.origin?.kind === ChatOriginKind.Tool) { + const shown = this._shownSubagentUris.get(); + if (!shown.has(chatUri)) { + return; + } + const nextShown = new Set(shown); + nextShown.delete(chatUri); + transaction(tx => { + this._shownSubagentUris.set(nextShown, tx); + if (this._activeChat.get().resource.toString() === chatUri) { + this._activeChat.set(this._defaultActiveChat(this._closedChatUris.get(), nextShown), tx); + } + }); + return; + } const closed = this._closedChatUris.get(); if (closed.has(chatUri)) { return; @@ -137,18 +174,25 @@ export class VisibleSession extends Disposable implements IActiveSession { this._closedChatOrder.push(chat); transaction(tx => { this._closedChatUris.set(next, tx); - // If the closed chat was active, fall back to another open chat. - // Skip hidden chats so the active chat is always user-visible. + // If the closed chat was active, fall back to another visible tab. if (this._activeChat.get().resource.toString() === chatUri) { - const open = this._session.chats.get().filter(c => - c.interactivity.get() !== ChatInteractivity.Hidden && - !next.has(c.resource.toString())); - this._activeChat.set(open[open.length - 1] ?? this._session.mainChat.get(), tx); + this._activeChat.set(this._defaultActiveChat(next, this._shownSubagentUris.get()), tx); } }); } openChat(chat: IChat): void { + // Opening a subagent (tool-origin) chat surfaces it as a tab. + if (chat.origin?.kind === ChatOriginKind.Tool) { + const shown = this._shownSubagentUris.get(); + if (shown.has(chat.resource.toString())) { + return; + } + const next = new Set(shown); + next.add(chat.resource.toString()); + this._shownSubagentUris.set(next, undefined); + return; + } const closed = this._closedChatUris.get(); if (!closed.has(chat.resource.toString())) { return; @@ -162,6 +206,19 @@ export class VisibleSession extends Disposable implements IActiveSession { } } + /** + * Pick the active chat to fall back to when the current one is closed: the + * last chat that would appear as a visible tab given the closed and shown- + * subagent sets, or the main chat. + */ + private _defaultActiveChat(closed: ReadonlySet, shownSubagents: ReadonlySet): IChat { + const candidates = this._session.chats.get().filter(c => + c.interactivity.get() !== ChatInteractivity.Hidden && + !closed.has(c.resource.toString()) && + (c.origin?.kind !== ChatOriginKind.Tool || shownSubagents.has(c.resource.toString()))); + return candidates[candidates.length - 1] ?? this._session.mainChat.get(); + } + get lastClosedChat(): IChat | undefined { // Filter out stale entries whose chat has since been deleted from the session. const currentChats = this._session.chats.get(); diff --git a/src/vs/sessions/services/sessions/common/session.ts b/src/vs/sessions/services/sessions/common/session.ts index abecd960ec56..366191d41c04 100644 --- a/src/vs/sessions/services/sessions/common/session.ts +++ b/src/vs/sessions/services/sessions/common/session.ts @@ -5,7 +5,7 @@ import { CancellationToken } from '../../../../base/common/cancellation.js'; import { IMarkdownString } from '../../../../base/common/htmlContent.js'; -import { IObservable } from '../../../../base/common/observable.js'; +import { IObservable, IReader } from '../../../../base/common/observable.js'; import { isEqual } from '../../../../base/common/resources.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { URI } from '../../../../base/common/uri.js'; @@ -337,6 +337,22 @@ export interface IChatOrigin { readonly parentChat?: URI; } +/** + * Per-chat capabilities. Consumers gate chat-management UI (rename, delete) on + * these flags rather than on the chat's origin/provider, so the affordances are + * offered exactly where the backing chat supports them. A worker (subagent) + * chat, for example, is neither renameable nor deletable. + */ +export interface IChatCapabilities { + /** Whether this chat's title can be renamed. */ + readonly canRename: boolean; + /** Whether this chat can be permanently deleted. */ + readonly canDelete: boolean; +} + +/** Capabilities assumed for a chat that does not advertise its own. */ +export const DEFAULT_CHAT_CAPABILITIES: IChatCapabilities = { canRename: true, canDelete: true }; + /** * A single chat within a session, produced by the sessions management layer. */ @@ -384,6 +400,27 @@ export interface IChat { readonly lastTurnEnd: IObservable; /** How the chat came into existence, if provided by the backend. */ readonly origin?: IChatOrigin; + /** + * Capabilities of this chat (rename/delete). Absent means the chat inherits + * {@link DEFAULT_CHAT_CAPABILITIES} (fully capable); read via + * {@link getChatCapabilities}. + */ + readonly capabilities?: IObservable; +} + +/** + * Resolve a chat's effective capabilities. Combines the chat's own advertised + * {@link IChat.capabilities} (falling back to {@link DEFAULT_CHAT_CAPABILITIES}) + * with the session-level invariant that a session's main chat can never be + * deleted — it lives and dies with the session. Pass the owning session so the + * main-chat rule applies; omit it to read only the chat's own capabilities. + */ +export function getChatCapabilities(chat: IChat, session: ISession | undefined, reader: IReader | undefined): IChatCapabilities { + const own = chat.capabilities?.read(reader) ?? DEFAULT_CHAT_CAPABILITIES; + if (session && isEqual(chat.resource, session.mainChat.read(reader).resource)) { + return own.canDelete ? { ...own, canDelete: false } : own; + } + return own; } /** diff --git a/src/vs/sessions/services/sessions/common/sessionContextKeys.ts b/src/vs/sessions/services/sessions/common/sessionContextKeys.ts index aa1e87199b7a..e157a9539fef 100644 --- a/src/vs/sessions/services/sessions/common/sessionContextKeys.ts +++ b/src/vs/sessions/services/sessions/common/sessionContextKeys.ts @@ -29,7 +29,7 @@ import { SessionActiveChatIsClosableContext, SessionActiveChatIsDeletableContext, } from '../../../common/contextkeys.js'; -import { ChatOriginKind, ISession, SessionStatus } from './session.js'; +import { ChatOriginKind, getChatCapabilities, ISession, SessionStatus } from './session.js'; import { IActiveSession } from './sessionsManagement.js'; /** @@ -188,8 +188,8 @@ export function setActiveSessionContextKeys(session: IActiveSession | undefined, const mainResource = session?.mainChat.read(reader).resource; const isNonMainChat = !!activeChat && !!mainResource && !isEqual(activeChat.resource, mainResource); keys.activeChatIsClosable.set(isNonMainChat); - // It can be permanently deleted only when it is additionally a real, - // user-created chat: tool-spawned subagent chats are transient children - // (re-derived from the parent), so they are closeable but not deletable. - keys.activeChatIsDeletable.set(isNonMainChat && activeChat.origin?.kind !== ChatOriginKind.Tool); + // It can be permanently deleted only when its effective capabilities allow + // it: the main chat and worker (subagent) chats report `canDelete: false`, + // so they are closeable but not deletable. + keys.activeChatIsDeletable.set(!!activeChat && getChatCapabilities(activeChat, session, reader).canDelete); } diff --git a/src/vs/sessions/services/sessions/common/sessionsManagement.ts b/src/vs/sessions/services/sessions/common/sessionsManagement.ts index 00ce39b9f7ed..beb2fd585a43 100644 --- a/src/vs/sessions/services/sessions/common/sessionsManagement.ts +++ b/src/vs/sessions/services/sessions/common/sessionsManagement.ts @@ -153,8 +153,9 @@ export interface IActiveSession extends ISession { readonly lastClosedChat: IChat | undefined; /** - * The chats shown as tabs in the tab strip: {@link openChats} with tool-origin - * chats (subagents) hidden, in the provider's order. + * The chats shown as tabs in the tab strip: {@link openChats} with subagent + * (tool-origin) chats hidden by default, in the provider's order. A subagent + * surfaces as a tab only once explicitly opened. */ readonly visibleChatTabs: IObservable; diff --git a/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts b/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts index 05d41c1b0b5b..4dd446a0af1b 100644 --- a/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts +++ b/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts @@ -1361,6 +1361,49 @@ suite('SessionsManagementService', () => { assert.deepStrictEqual(closedTitles(view), []); }); + + test('a closed chat stays closed across a restart', async () => { + const mainA = chat('mainA'); + const chatB = chat('b'); + const sessionA = stubSession({ + sessionId: 'A', providerId: 'test', + status: constObservable(SessionStatus.Completed), + chats: constObservable([mainA, chatB]), + mainChat: constObservable(mainA), + capabilities: constObservable({ supportsMultipleChats: true }), + }); + const storage = disposables.add(new InMemoryStorageService()); + const provider = new class extends TestSessionsProvider { + constructor() { super(sessionA); } + override getSessions(): ISession[] { return [sessionA]; } + }; + const makeView = () => { + const instantiationService = disposables.add(new TestInstantiationService()); + instantiationService.stub(IStorageService, storage); + instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(IContextKeyService, disposables.add(new MockContextKeyService())); + instantiationService.stub(ISessionsProvidersService, new TestSessionsProvidersService([provider])); + instantiationService.stub(IUriIdentityService, { extUri: extUriBiasedIgnorePathCase }); + instantiationService.stub(IChatWidgetService, new TestChatWidgetService()); + instantiationService.stub(IProgressService, new TestProgressService()); + instantiationService.stub(IChatService, new class extends mock() { + override readonly onDidSubmitRequest = Event.None; + }); + const service = disposables.add(instantiationService.createInstance(SessionsManagementService)); + return createView(instantiationService, service, disposables); + }; + + // First window: close chat B, then simulate shutdown (flush storage). + const first = makeView(); + await first.openSession(sessionA.resource); + await first.closeChat(first.activeSession.get()!, chatB); + await storage.flush(); + + // Second window: restore and confirm B is still closed. + const second = makeView(); + await second.restoreVisibleSessions(); + assert.deepStrictEqual((second.activeSession.get()?.closedChats.get() ?? []).map(c => c.title.get()), ['b']); + }); }); suite('createQuickChat', () => { diff --git a/src/vs/sessions/services/sessions/test/browser/visibleSessions.test.ts b/src/vs/sessions/services/sessions/test/browser/visibleSessions.test.ts index eb90df4121b6..52feac51f5b1 100644 --- a/src/vs/sessions/services/sessions/test/browser/visibleSessions.test.ts +++ b/src/vs/sessions/services/sessions/test/browser/visibleSessions.test.ts @@ -1106,7 +1106,7 @@ suite('VisibleSession - visibleChatTabs', () => { return disposables.add(new VisibleSession(session, chats[0])); } - test('keeps provider order and surfaces tool-origin (subagent) chats as tabs', () => { + test('keeps provider order and hides tool-origin (subagent) chats by default', () => { const visible = createSession([ makeChat('main'), makeChat('draft', SessionStatus.Untitled), @@ -1114,9 +1114,41 @@ suite('VisibleSession - visibleChatTabs', () => { makeChat('second'), ]); - // Subagent (tool-origin) chats are surfaced as read-only tabs alongside - // the rest, so opening one (e.g. from the Subagents dropdown) shows it. - assert.deepStrictEqual(visible.visibleChatTabs.get().map(c => c.title.get()), ['main', 'draft', 'tool', 'second']); + // Subagent (tool-origin) chats are hidden from the tab strip by default. + assert.deepStrictEqual(visible.visibleChatTabs.get().map(c => c.title.get()), ['main', 'draft', 'second']); + }); + + test('surfaces a subagent tab once it is explicitly opened, and hides it again on close', () => { + const chats = [ + makeChat('main'), + makeChat('tool', SessionStatus.Completed, ChatOriginKind.Tool), + ]; + const visible = createSession(chats); + const tool = chats[1]; + + visible.openChat(tool); + const afterOpen = visible.visibleChatTabs.get().map(c => c.title.get()); + visible.closeChat(tool); + const afterClose = visible.visibleChatTabs.get().map(c => c.title.get()); + + assert.deepStrictEqual({ afterOpen, afterClose }, { + afterOpen: ['main', 'tool'], + afterClose: ['main'], + }); + }); + + test('a closed subagent tab is not added to the reopenable closed chats', () => { + const chats = [ + makeChat('main'), + makeChat('tool', SessionStatus.Completed, ChatOriginKind.Tool), + ]; + const visible = createSession(chats); + const tool = chats[1]; + + visible.openChat(tool); + visible.closeChat(tool); + + assert.deepStrictEqual(visible.closedChats.get().map(c => c.title.get()), []); }); }); @@ -1155,14 +1187,14 @@ suite('VisibleSession - shouldShowChatTabs', () => { assert.strictEqual(visible.shouldShowChatTabs.get(), true); }); - test('shown when a tool-origin subagent surfaces as a read-only tab', () => { + test('hidden for a single non-tool chat matching the session title with an unopened subagent', () => { const visible = createSession('Title', [ makeChat('main', 'Title'), makeChat('tool', 'tool', ChatOriginKind.Tool), ]); - // Subagent (tool-origin) chats surface as read-only tabs, so the strip - // shows even when the single non-tool chat's title matches the session. - assert.strictEqual(visible.shouldShowChatTabs.get(), true); + // Subagents are hidden by default, so an unopened subagent does not force + // the strip to show when the single non-tool chat matches the session title. + assert.strictEqual(visible.shouldShowChatTabs.get(), false); }); test('hidden when there are no tab chats', () => {