mirror of
https://github.com/microsoft/vscode.git
synced 2026-10-05 15:36:22 +01:00
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:
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;
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
|
||||
+7
-1
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+28
-1
@@ -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', () => {
|
||||
|
||||
Reference in new issue
Block a user