Skip to content

Commit ecea647

Browse files
sandy081Copilot
andauthored
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>
1 parent 5334b03 commit ecea647

11 files changed

Lines changed: 312 additions & 46 deletions

File tree

src/vs/sessions/browser/parts/chatCompositeBar.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import { IKeyboardEvent } from '../../../base/browser/keyboardEvent.js';
2626
import { KeyCode } from '../../../base/common/keyCodes.js';
2727
import { onUnexpectedError } from '../../../base/common/errors.js';
2828
import { localize } from '../../../nls.js';
29-
import { ChatInteractivity, IChat, SessionStatus } from '../../services/sessions/common/session.js';
29+
import { ChatInteractivity, getChatCapabilities, IChat, SessionStatus } from '../../services/sessions/common/session.js';
3030
import { IActiveSession, ISessionsManagementService } from '../../services/sessions/common/sessionsManagement.js';
3131
import { ISessionsService } from '../../services/sessions/browser/sessionsService.js';
3232
import { ISessionsPartService } from '../../services/sessions/browser/sessionsPartService.js';
@@ -368,7 +368,7 @@ export class ChatCompositeBar extends Disposable {
368368

369369
// Double-click the tab to start an inline rename, mirroring the session title.
370370
this._tabDisposables.add(addDisposableListener(tab, EventType.DBLCLICK, (e: MouseEvent) => {
371-
if (chat.status.get() === SessionStatus.Untitled) {
371+
if (chat.status.get() === SessionStatus.Untitled || !getChatCapabilities(chat, session, undefined).canRename) {
372372
return;
373373
}
374374
e.preventDefault();
@@ -387,9 +387,17 @@ export class ChatCompositeBar extends Disposable {
387387
const event = new StandardMouseEvent(getWindow(tab), e);
388388
this._contextMenuService.showContextMenu({
389389
getAnchor: () => event,
390-
getActions: () => isMainChat
391-
? [renameAction]
392-
: [renameAction, deleteAction]
390+
getActions: () => {
391+
const capabilities = getChatCapabilities(chat, session, undefined);
392+
const actions = [];
393+
if (capabilities.canRename) {
394+
actions.push(renameAction);
395+
}
396+
if (capabilities.canDelete) {
397+
actions.push(deleteAction);
398+
}
399+
return actions;
400+
}
393401
});
394402
}));
395403

src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ import { ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from
4444
import { buildMutableConfigSchema, IAgentHostMcpServer, IAgentHostSessionsProvider, resolvedConfigsEqual } from '../../../../common/agentHostSessionsProvider.js';
4545
import { agentHostSessionWorkspaceKey } from '../../../../common/agentHostSessionWorkspace.js';
4646
import { isSessionConfigComplete } from '../../../../common/sessionConfig.js';
47-
import { ChatInteractivity, ChatOriginKind, IChat, IGitHubInfo, ISession, ISessionAgentRef, ISessionCapabilities, ISessionChangeset, ISessionChangesSummary, ISessionFile, ISessionFileChange, ISessionType, ISessionWorkspace, ISessionWorkspaceBrowseAction, sessionFileChangesEqual, SessionStatus, toSessionId } from '../../../../services/sessions/common/session.js';
47+
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';
4848
import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js';
4949
import { IDeleteChatOptions, ISendRequestOptions, ISessionChangeEvent, ISessionModelPickerOptions } from '../../../../services/sessions/common/sessionsProvider.js';
5050
import { IGitHubService } from '../../../github/browser/githubService.js';
@@ -264,6 +264,12 @@ class AdditionalChat extends Disposable {
264264
description: this._description,
265265
lastTurnEnd: this._lastTurnEnd,
266266
origin: summary.origin ? { kind: toSessionChatOriginKind(summary.origin.kind), parentChat } : undefined,
267+
// Subagent (tool-origin) worker chats are transient children and can be
268+
// neither renamed nor deleted; other peer chats are fully manageable.
269+
capabilities: constObservable<IChatCapabilities>(
270+
summary.origin?.kind === ProtocolChatOriginKind.Tool
271+
? { canRename: false, canDelete: false }
272+
: DEFAULT_CHAT_CAPABILITIES),
267273
};
268274
}
269275

src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ import { ChatModeKind } from '../../../../../../workbench/contrib/chat/common/co
3535
import { ILanguageModelsService, type ILanguageModelChatMetadata } from '../../../../../../workbench/contrib/chat/common/languageModels.js';
3636
import type { IChatModel, IChatModelInputState, IInputModel } from '../../../../../../workbench/contrib/chat/common/model/chatModel.js';
3737
import { ISessionChangeEvent } from '../../../../../services/sessions/common/sessionsProvider.js';
38-
import { ChatInteractivity, ChatOriginKind, ISession, SessionStatus } from '../../../../../services/sessions/common/session.js';
38+
import { ChatInteractivity, ChatOriginKind, getChatCapabilities, ISession, SessionStatus } from '../../../../../services/sessions/common/session.js';
3939
import { IActiveSession } from '../../../../../services/sessions/common/sessionsManagement.js';
4040
import { ISessionsService } from '../../../../../services/sessions/browser/sessionsService.js';
4141
import { IAgentHostActiveClientService } from '../../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostActiveClientService.js';
@@ -2169,11 +2169,38 @@ suite('LocalAgentHostSessionsProvider', () => {
21692169
// The subagent records its parent chat (the default chat) so the
21702170
// "Agents" row can list it under the chat that spawned it.
21712171
subagentParentIsMain: !!chats[1]?.origin?.parentChat && isEqual(chats[1].origin.parentChat, chats[0].resource),
2172+
// A subagent worker chat is neither renameable nor deletable.
2173+
subagentCapabilities: getChatCapabilities(chats[1], session, undefined),
21722174
}, {
21732175
titles: ['Session', 'Code Reviewer'],
21742176
interactivity: [ChatInteractivity.Full, ChatInteractivity.ReadOnly],
21752177
subagentOrigin: ChatOriginKind.Tool,
21762178
subagentParentIsMain: true,
2179+
subagentCapabilities: { canRename: false, canDelete: false },
2180+
});
2181+
});
2182+
2183+
test('the main chat is renameable but never deletable via capabilities', () => {
2184+
const provider = createProvider(disposables, agentHost);
2185+
const session = setupMultiChatSession(provider, 'main-caps');
2186+
const sessionUri = AgentSession.uri('copilotcli', 'main-caps').toString();
2187+
const defaultChat = buildDefaultChatUri(sessionUri);
2188+
const peerChat = buildChatUri(sessionUri, 'peer-1');
2189+
2190+
agentHost.setSessionState('main-caps', 'copilotcli', makeState([
2191+
makeChatSummary(defaultChat, ''),
2192+
{ ...makeChatSummary(peerChat, 'Peer'), origin: { kind: ProtocolChatOriginKind.User } },
2193+
], { defaultChat }));
2194+
2195+
const chats = session.chats.get();
2196+
assert.deepStrictEqual({
2197+
// The main (default) chat: renameable, never deletable.
2198+
main: getChatCapabilities(chats[0], session, undefined),
2199+
// A regular user peer chat: fully manageable.
2200+
peer: getChatCapabilities(chats[1], session, undefined),
2201+
}, {
2202+
main: { canRename: true, canDelete: false },
2203+
peer: { canRename: true, canDelete: true },
21772204
});
21782205
});
21792206

src/vs/sessions/contrib/sessions/browser/sessionsActions.ts

Lines changed: 57 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import { CanGoBackContext, CanGoForwardContext, SessionProviderIdContext, Multip
3131
import { ANY_AGENT_HOST_PROVIDER_RE } from '../../../common/agentHostSessionsProvider.js';
3232
import { IActiveSession, ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js';
3333
import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js';
34-
import { ChatOriginKind, getUntitledSessionTitle, IChat, ISession, SessionStatus } from '../../../services/sessions/common/session.js';
34+
import { ChatOriginKind, getChatCapabilities, getUntitledSessionTitle, IChat, ISession, SessionStatus } from '../../../services/sessions/common/session.js';
3535
import { ISessionsPartService } from '../../../services/sessions/browser/sessionsPartService.js';
3636
import { ISessionsListModelService } from '../../../services/sessions/browser/sessionsListModelService.js';
3737
import { SessionHeaderMetaActionViewItem } from '../../../browser/parts/sessionHeaderMetaActionViewItem.js';
@@ -567,6 +567,55 @@ registerAction2(class CloseChatAction extends Action2 {
567567
}
568568
});
569569

570+
registerAction2(class CloseAllChatsAction extends Action2 {
571+
constructor() {
572+
super({
573+
id: 'sessions.chatCompositeBar.closeAllChats',
574+
title: localize2('closeAllChats', "Close All Chats"),
575+
f1: true,
576+
category: SessionsCategories.Sessions,
577+
// Enabled (palette + keybinding) only while the active session has more
578+
// than one open chat, so the chord targets the focused session and
579+
// stays inert for single-chat sessions.
580+
precondition: SessionHasMultipleOpenChatsContext,
581+
keybinding: {
582+
weight: CHAT_TAB_KEYBINDING_WEIGHT,
583+
when: ContextKeyExpr.and(
584+
IsSessionsWindowContext,
585+
// While a modal editor has focus, let VS Code's own
586+
// closeEditorsInGroup (same chord) act on the editor group.
587+
EditorAreaFocusContext.toNegated(),
588+
SessionHasMultipleOpenChatsContext
589+
),
590+
// Mirror VS Code's "Close All Editors in Group" chord (Ctrl/Cmd+K W):
591+
// a session is the Agents-window analogue of an editor group. Note
592+
// "Close All Sessions" already owns Ctrl/Cmd+K Ctrl/Cmd+W.
593+
primary: KeyChord(KeyMod.CtrlCmd | KeyCode.KeyK, KeyCode.KeyW),
594+
},
595+
});
596+
}
597+
598+
override async run(accessor: ServicesAccessor): Promise<void> {
599+
const sessionsService = accessor.get(ISessionsService);
600+
const sessionsManagementService = accessor.get(ISessionsManagementService);
601+
const extUri = accessor.get(IUriIdentityService).extUri;
602+
const session = sessionsService.activeSession.get();
603+
if (!session) {
604+
return;
605+
}
606+
607+
const mainResource = session.mainChat.get().resource;
608+
const chatsToClose = session.openChats.get().filter(chat => !extUri.isEqual(chat.resource, mainResource));
609+
for (const chat of chatsToClose) {
610+
if (chat.status.get() === SessionStatus.Untitled) {
611+
await sessionsManagementService.deleteChat(session, chat.resource, { skipConfirmation: true });
612+
} else {
613+
await sessionsService.closeChat(session, chat);
614+
}
615+
}
616+
}
617+
});
618+
570619
registerAction2(class DeleteChatAction extends Action2 {
571620
constructor() {
572621
super({
@@ -592,18 +641,13 @@ registerAction2(class DeleteChatAction extends Action2 {
592641
override async run(accessor: ServicesAccessor): Promise<void> {
593642
const sessionsService = accessor.get(ISessionsService);
594643
const sessionsManagementService = accessor.get(ISessionsManagementService);
595-
const extUri = accessor.get(IUriIdentityService).extUri;
596644
const session = sessionsService.activeSession.get();
597645
if (!session) {
598646
return;
599647
}
600648
const chat = session.activeChat.get();
601-
if (!chat || extUri.isEqual(chat.resource, session.mainChat.get().resource)) {
602-
return;
603-
}
604-
// Tool-spawned subagent chats are transient children (re-derived from the
605-
// parent's tool call), so they are closeable but must not be deleted.
606-
if (chat.origin?.kind === ChatOriginKind.Tool) {
649+
// The main chat and worker (subagent) chats report `canDelete: false`.
650+
if (!chat || !getChatCapabilities(chat, session, undefined).canDelete) {
607651
return;
608652
}
609653
await sessionsManagementService.deleteChat(session, chat.resource);
@@ -950,6 +994,11 @@ export class SessionConversationsMenuContribution extends Disposable implements
950994
if (chat.status.read(reader) === SessionStatus.Untitled) {
951995
return;
952996
}
997+
// Subagent (tool-origin) chats are surfaced via the Subagents dropdown,
998+
// not the Conversations submenu.
999+
if (chat.origin?.kind === ChatOriginKind.Tool) {
1000+
return;
1001+
}
9531002
const chatResource = chat.resource;
9541003
const isOpen = openChats.some(c => extUri.isEqual(c.resource, chatResource));
9551004
const isMain = extUri.isEqual(chatResource, mainResource);

src/vs/sessions/services/sessions/browser/sessionsService.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { InstantiationType, registerSingleton } from '../../../../platform/insta
1515
import { ILogService } from '../../../../platform/log/common/log.js';
1616
import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js';
1717
import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js';
18-
import { ChatInteractivity, IChat, ISession, SessionStatus } from '../common/session.js';
18+
import { ChatInteractivity, ChatOriginKind, IChat, ISession, SessionStatus } from '../common/session.js';
1919
import { IActiveSession, ICreateNewChatInSessionOptions, ICreateNewSessionOptions, IRecentlyOpenedSessions, ISessionsChangeEvent, ISessionsManagementService, IToggleSessionStickinessEvent } from '../common/sessionsManagement.js';
2020
import { ISessionsProvidersService } from './sessionsProvidersService.js';
2121
import { SessionsNavigation } from './sessionNavigation.js';
@@ -626,6 +626,12 @@ export class SessionsService extends Disposable implements ISessionsService {
626626
if (this.uriIdentityService.extUri.isEqual(chat.resource, session.mainChat.get().resource)) {
627627
return;
628628
}
629+
// Subagent (tool-origin) chats are hidden by default and toggled via an
630+
// in-memory shown set, not the persisted closed set, so they never
631+
// participate in closed-chat persistence.
632+
if (chat.origin?.kind === ChatOriginKind.Tool) {
633+
return;
634+
}
629635
const existing = this._sessionStates.get(session.resource);
630636
const closedSet = new Set(existing?.closedChatResources ?? []);
631637
const chatResource = chat.resource.toString();

src/vs/sessions/services/sessions/browser/visibleSessions.ts

Lines changed: 71 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,12 @@ export class VisibleSession extends Disposable implements IActiveSession {
4343

4444
/** Resource strings of chats that have been closed (hidden from the tab strip). */
4545
private readonly _closedChatUris: ISettableObservable<ReadonlySet<string>>;
46+
/**
47+
* Resource strings of subagent (tool-origin) chats the user explicitly opened,
48+
* so they surface as tabs. Subagents are hidden from the tab strip by default;
49+
* this set is not persisted, so they revert to hidden on reload.
50+
*/
51+
private readonly _shownSubagentUris: ISettableObservable<ReadonlySet<string>>;
4652
/** Append-only list tracking close order; last element is the most recently closed. */
4753
private readonly _closedChatOrder: IChat[] = [];
4854
readonly openChats: IObservable<readonly IChat[]>;
@@ -73,6 +79,14 @@ export class VisibleSession extends Disposable implements IActiveSession {
7379
}
7480
this._closedChatUris = observableValue<ReadonlySet<string>>('closedChatUris', seed);
7581

82+
// Subagents are hidden by default; if the restored active chat is one,
83+
// surface its tab so the session opens where the user left off.
84+
const shownSubagents = new Set<string>();
85+
if (initialChat?.origin?.kind === ChatOriginKind.Tool) {
86+
shownSubagents.add(initialChat.resource.toString());
87+
}
88+
this._shownSubagentUris = observableValue<ReadonlySet<string>>('shownSubagentUris', shownSubagents);
89+
7690
this._isCreated = _session.status.map(status => status !== SessionStatus.Untitled);
7791
this.isCreated = this._isCreated;
7892

@@ -92,15 +106,20 @@ export class VisibleSession extends Disposable implements IActiveSession {
92106
}
93107
return this._session.chats.read(reader).filter(c => closed.has(c.resource.toString()));
94108
});
95-
// Tab strip contents: the open chats in the provider's order. Subagent
96-
// (tool-origin) chats are surfaced as read-only tabs alongside the rest,
97-
// so opening one (e.g. from the Subagents dropdown) shows it as the
98-
// active tab. Hidden and closed chats are already excluded by `openChats`.
99-
this.visibleChatTabs = this.openChats;
109+
// Tab strip contents: the open chats in the provider's order, with subagent
110+
// (tool-origin) chats hidden by default. A subagent surfaces as a tab only
111+
// once explicitly opened (e.g. from the Subagents dropdown), tracked in
112+
// `_shownSubagentUris`. Hidden and closed chats are excluded by `openChats`.
113+
this.visibleChatTabs = derived(this, reader => {
114+
const shownSubagents = this._shownSubagentUris.read(reader);
115+
return this.openChats.read(reader).filter(c =>
116+
c.origin?.kind !== ChatOriginKind.Tool ||
117+
shownSubagents.has(c.resource.toString()));
118+
});
100119
// Shown for more than one real (non-tool) chat — counting closed ones —
101-
// or a single chat whose title diverged from the session title. Surfaced
102-
// subagent (tool-origin) tabs also warrant showing the strip, so any time
103-
// there is more than one visible tab the strip is shown.
120+
// or a single chat whose title diverged from the session title. An opened
121+
// subagent tab also warrants showing the strip, so any time there is more
122+
// than one visible tab the strip is shown.
104123
this.shouldShowChatTabs = derived(this, reader => {
105124
const tabChats = this._session.chats.read(reader).filter(c =>
106125
c.origin?.kind !== ChatOriginKind.Tool &&
@@ -128,6 +147,24 @@ export class VisibleSession extends Disposable implements IActiveSession {
128147
if (chatUri === this._session.mainChat.get().resource.toString()) {
129148
return;
130149
}
150+
// Closing a subagent (tool-origin) tab just hides it again; it stays
151+
// reachable from the Subagents dropdown and is not added to the
152+
// reopenable closed set.
153+
if (chat.origin?.kind === ChatOriginKind.Tool) {
154+
const shown = this._shownSubagentUris.get();
155+
if (!shown.has(chatUri)) {
156+
return;
157+
}
158+
const nextShown = new Set(shown);
159+
nextShown.delete(chatUri);
160+
transaction(tx => {
161+
this._shownSubagentUris.set(nextShown, tx);
162+
if (this._activeChat.get().resource.toString() === chatUri) {
163+
this._activeChat.set(this._defaultActiveChat(this._closedChatUris.get(), nextShown), tx);
164+
}
165+
});
166+
return;
167+
}
131168
const closed = this._closedChatUris.get();
132169
if (closed.has(chatUri)) {
133170
return;
@@ -137,18 +174,25 @@ export class VisibleSession extends Disposable implements IActiveSession {
137174
this._closedChatOrder.push(chat);
138175
transaction(tx => {
139176
this._closedChatUris.set(next, tx);
140-
// If the closed chat was active, fall back to another open chat.
141-
// Skip hidden chats so the active chat is always user-visible.
177+
// If the closed chat was active, fall back to another visible tab.
142178
if (this._activeChat.get().resource.toString() === chatUri) {
143-
const open = this._session.chats.get().filter(c =>
144-
c.interactivity.get() !== ChatInteractivity.Hidden &&
145-
!next.has(c.resource.toString()));
146-
this._activeChat.set(open[open.length - 1] ?? this._session.mainChat.get(), tx);
179+
this._activeChat.set(this._defaultActiveChat(next, this._shownSubagentUris.get()), tx);
147180
}
148181
});
149182
}
150183

151184
openChat(chat: IChat): void {
185+
// Opening a subagent (tool-origin) chat surfaces it as a tab.
186+
if (chat.origin?.kind === ChatOriginKind.Tool) {
187+
const shown = this._shownSubagentUris.get();
188+
if (shown.has(chat.resource.toString())) {
189+
return;
190+
}
191+
const next = new Set(shown);
192+
next.add(chat.resource.toString());
193+
this._shownSubagentUris.set(next, undefined);
194+
return;
195+
}
152196
const closed = this._closedChatUris.get();
153197
if (!closed.has(chat.resource.toString())) {
154198
return;
@@ -162,6 +206,19 @@ export class VisibleSession extends Disposable implements IActiveSession {
162206
}
163207
}
164208

209+
/**
210+
* Pick the active chat to fall back to when the current one is closed: the
211+
* last chat that would appear as a visible tab given the closed and shown-
212+
* subagent sets, or the main chat.
213+
*/
214+
private _defaultActiveChat(closed: ReadonlySet<string>, shownSubagents: ReadonlySet<string>): IChat {
215+
const candidates = this._session.chats.get().filter(c =>
216+
c.interactivity.get() !== ChatInteractivity.Hidden &&
217+
!closed.has(c.resource.toString()) &&
218+
(c.origin?.kind !== ChatOriginKind.Tool || shownSubagents.has(c.resource.toString())));
219+
return candidates[candidates.length - 1] ?? this._session.mainChat.get();
220+
}
221+
165222
get lastClosedChat(): IChat | undefined {
166223
// Filter out stale entries whose chat has since been deleted from the session.
167224
const currentChats = this._session.chats.get();

0 commit comments

Comments
 (0)