sessions: hide subagent chats by default, persist closed chats, add chat capabilities (#324021)

sessions: hide subagent chats by default, persist closed chats, and add chat capabilities

Improves chat management in the Agents window session view:

- Subagent (tool-origin) worker chats are hidden from the chat tab strip by
  default and surface as a tab only when explicitly opened (e.g. from the
  Subagents dropdown); closing one hides it again without adding it to the
  reopenable closed set. Reverts on reload.
- Closing a chat is remembered across reload/restart (closed-chat set is
  persisted per session and restored on startup); subagents are excluded from
  the persisted set.
- Adds "Close All Chats" command (Ctrl/Cmd+K W, mirroring "Close All Editors in
  Group"), gated on SessionHasMultipleOpenChats so it targets the focused
  session and does not collide with "Close All Sessions" (Ctrl/Cmd+K Ctrl/Cmd+W).
- Introduces per-chat IChatCapabilities (canRename/canDelete) with a central
  getChatCapabilities resolver that folds in the main-chat invariant (never
  deletable). Subagent chats report neither rename nor delete; the tab context
  menu, delete keybinding, and context keys now go through capabilities instead
  of ad-hoc origin/main-chat checks.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Sandeep SomavarapuandCopilot authored and GitHub committed 2026-07-02 09:44:13 +00:00
1 parent 5334b03810
commit ecea6479fc
11 files changed
+312 -46

No files matched your search

@@ -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;
}
});
}));
@@ -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<IChatCapabilities>(
summary.origin?.kind === ProtocolChatOriginKind.Tool
? { canRename: false, canDelete: false }
: DEFAULT_CHAT_CAPABILITIES),
};
}
@@ -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 },
});
});
@@ -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<void> {
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<void> {
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);
@@ -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();
@@ -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<ReadonlySet<string>>;
/**
* 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<ReadonlySet<string>>;
/** Append-only list tracking close order; last element is the most recently closed. */
private readonly _closedChatOrder: IChat[] = [];
readonly openChats: IObservable<readonly IChat[]>;
@@ -73,6 +79,14 @@ export class VisibleSession extends Disposable implements IActiveSession {
}
this._closedChatUris = observableValue<ReadonlySet<string>>('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<string>();
if (initialChat?.origin?.kind === ChatOriginKind.Tool) {
shownSubagents.add(initialChat.resource.toString());
}
this._shownSubagentUris = observableValue<ReadonlySet<string>>('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<string>, shownSubagents: ReadonlySet<string>): 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();
@@ -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<Date | undefined>;
/** 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<IChatCapabilities>;
}
/**
* 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;
}
/**
@@ -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);
}
@@ -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<readonly IChat[]>;
@@ -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<IChatService>() {
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', () => {
@@ -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', () => {