sessions: support multi-session delete via session capability (#322310)

* sessions: support multi-session delete via session capability

Enable deleting multiple sessions at once and consolidate the delete
action into the sessions workbench core instead of each provider.

- Add required `deleteSessions(sessionIds)` batch API to `ISessionsProvider`
  and `deleteSessions(sessions)` to the sessions management service, which
  groups sessions by provider before delegating.
- Implement a real batch delete in the agent host base provider that
  resolves all targets synchronously up front before disposing, avoiding a
  reconcile dropping not-yet-processed cached sessions.
- Introduce a `supportsDelete` session capability and a
  `chatSessionSupportsDelete` context key, mirroring the `supportsRename`
  pattern. A single core `DeleteSessionAction` (gated on the capability)
  replaces the per-provider delete actions and the delete-session helper.
- Move the delete confirmation dialog into the action and remove it from
  the provider implementations.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* sessions: address CCR feedback on multi-session delete

- Filter the action's incoming context to sessions that advertise
  `capabilities.supportsDelete`, so a mixed multi-selection never deletes
  sessions that don't support it.
- Use a singular vs plural delete error message and distinct localization
  keys instead of a plural-only message.
- Make the management service batch delete best-effort: continue deleting
  other providers' sessions if one provider rejects, then surface the first
  error. Emit `onDidDeleteSession` only for providers that succeeded.
- Clarify the `deleteSessions` JSDoc to say implementations may batch or
  delegate to `deleteSession`.
- Revert the unrelated "Copilot CLI" -> "Copilot" session type label change.
- Add a best-effort batch delete test to the management service suite.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Sandeep Somavarapu
2026-06-22 03:12:34 +02:00
committed by GitHub
parent 88313d3dcc
commit 5bcfb040f4
22 changed files with 238 additions and 172 deletions
+1
View File
@@ -19,6 +19,7 @@ export const ActiveSessionUsesCombinedConfigPickerContext = new RawContextKey<bo
export const ChatSessionProviderIdContext = new RawContextKey<string>('chatSessionProviderId', '', localize('chatSessionProviderId', "The provider ID of a session in context menu overlays"));
export const ChatSessionTypeContext = new RawContextKey<string>('chatSessionType', '', localize('chatSessionType', "The session type of a session in context menu overlays"));
export const ChatSessionSupportsRenameContext = new RawContextKey<boolean>('chatSessionSupportsRename', false, localize('chatSessionSupportsRename', "Whether a session in context menu overlays can be renamed"));
export const ChatSessionSupportsDeleteContext = new RawContextKey<boolean>('chatSessionSupportsDelete', false, localize('chatSessionSupportsDelete', "Whether a session in context menu overlays can be deleted"));
//#endregion
@@ -109,6 +109,7 @@ function createMockProvider(id: string, opts?: {
archiveSession: async () => { },
unarchiveSession: async () => { },
deleteSession: async () => { },
deleteSessions: async () => { },
deleteChat: async () => { },
createNewChat: async () => { throw new Error('Not implemented'); },
sendRequest: async (_sessionId: string, _chatResource: URI, _options: ISendRequestOptions) => { throw new Error('Not implemented'); },
@@ -134,7 +134,7 @@ sidebar list.
## CRUD & Stubbed Operations
- `archiveSession` / `unarchiveSession` / `deleteSession` — round-trip to the backend.
- `archiveSession` / `unarchiveSession` / `deleteSession` — round-trip to the backend. `deleteSessions` is the batch variant (used when multiple sessions are selected): it disposes each backend session and emits a single removal change event. Sessions advertise `capabilities.supportsDelete`, so the shared sessions-list "Delete..." action (contributed by the sessions workbench, gated on `ChatSessionSupportsDeleteContext`) confirms and invokes deletion — there is no provider-specific delete action.
- `renameChat` — updates the session title.
- `deleteChat` — no-op (agent host sessions don't model individually deletable chats).
@@ -153,7 +153,6 @@ The provider ships a rich set of session-scoped UI in `browser/`:
| `agentHostSkillButtons.ts` | Built-in skill toolbar buttons; defines the `sessions.isAgentHostSession` (`IsAgentHostSession`) context key bound to the active session's provider. |
| `agentHostSessionChangesets.ts` / `agentHostDiffs.ts` | Changeset model and diff conversion (`mapProtocolStatus` maps the protocol status bitset → `SessionStatus`). |
| `agentHostSessionBranchActions.ts` | Branch-related session actions. |
| `agentHostSessionDeleteAction.ts` | "Delete..." session context-menu action (gated on `ANY_AGENT_HOST_PROVIDER_RE`); delegates to the shared `confirmAndDeleteSessions` helper which confirms and calls `ISessionsManagementService.deleteSession`. |
| `exportDebugLogsAction.ts` | "Export debug logs" developer action. |
| `openSessionEventsFileActions.ts` | "Open Copilot CLI State File" — Sessions-app variant resolving the session via `ISessionsManagementService.activeSession`. |
| `mobile/` | Phone-layout variants: `mobileAgentHostModePicker.ts`, `mobileChatInputConfigPicker.ts`, `mobileChatPhoneInputPresenter.ts`. |
@@ -1,33 +0,0 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { localize2 } from '../../../../../nls.js';
import { Action2, registerAction2 } from '../../../../../platform/actions/common/actions.js';
import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js';
import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js';
import { ANY_AGENT_HOST_PROVIDER_RE } from '../../../../common/agentHostSessionsProvider.js';
import { ChatSessionProviderIdContext } from '../../../../common/contextkeys.js';
import { ISession } from '../../../../services/sessions/common/session.js';
import { confirmAndDeleteSessions } from '../../../sessions/browser/deleteSessionHelper.js';
import { SessionItemContextMenuId } from '../../../sessions/browser/views/sessionsList.js';
registerAction2(class DeleteAgentHostSessionAction extends Action2 {
constructor() {
super({
id: 'sessionsViewPane.agentHost.deleteSession',
title: localize2('deleteAgentHostSession', "Delete..."),
menu: [{
id: SessionItemContextMenuId,
group: '1_edit',
order: 4,
when: ContextKeyExpr.regex(ChatSessionProviderIdContext.key, ANY_AGENT_HOST_PROVIDER_RE),
}]
});
}
run(accessor: ServicesAccessor, context?: ISession | ISession[]): Promise<void> {
return confirmAndDeleteSessions(accessor, context);
}
});
@@ -319,7 +319,7 @@ export class AgentHostSessionAdapter extends Disposable implements ISession {
this.sessionId = toSessionId(providerId, this.resource);
this.providerId = providerId;
this.sessionType = logicalSessionType;
this.capabilities = { supportsMultipleChats: logicalSessionType === CopilotCLISessionType.id, supportsRename: true };
this.capabilities = { supportsMultipleChats: logicalSessionType === CopilotCLISessionType.id, supportsRename: true, supportsDelete: true };
this.icon = _options.icon;
this.createdAt = new Date(metadata.startTime);
this.title = observableValue('title', metadata.summary || `Session ${rawId.substring(0, 8)}`);
@@ -1005,7 +1005,7 @@ class NewSession extends Disposable {
lastTurnEnd,
mainChat: this._mainChat,
chats,
capabilities: { supportsMultipleChats: false, supportsRename: true },
capabilities: { supportsMultipleChats: false, supportsRename: true, supportsDelete: true },
};
this.sessionId = this.session.sessionId;
@@ -2294,15 +2294,34 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement
}
async deleteSession(sessionId: string): Promise<void> {
const rawId = this._rawIdFromChatId(sessionId);
const cached = rawId ? this._sessionCache.get(rawId) : undefined;
await this.deleteSessions([sessionId]);
}
async deleteSessions(sessionIds: readonly string[]): Promise<void> {
const connection = this.connection;
if (cached && rawId && connection) {
if (!connection) {
return;
}
const targets: { rawId: string; sessionId: string; cached: AgentHostSessionAdapter }[] = [];
for (const sessionId of sessionIds) {
const rawId = this._rawIdFromChatId(sessionId);
const cached = rawId ? this._sessionCache.get(rawId) : undefined;
if (cached && rawId) {
targets.push({ rawId, sessionId, cached });
}
}
if (targets.length === 0) {
return;
}
for (const { rawId, sessionId, cached } of targets) {
await connection.disposeSession(AgentSession.uri(cached.agentProvider, rawId));
this._sessionCache.delete(rawId);
this._runningSessionConfigs.delete(sessionId);
this._runningSessionConfigResolveSeq.delete(sessionId);
this._onDidChangeSessions.fire({ added: [], removed: [cached], changed: [] });
}
const removed = targets.map(target => target.cached);
this._onDidChangeSessions.fire({ added: [], removed, changed: [] });
for (const cached of removed) {
cached.dispose();
}
}
@@ -1671,6 +1671,24 @@ suite('LocalAgentHostSessionsProvider', () => {
assert.strictEqual(provider.getSessions().find(s => s.title.get() === 'To Delete'), undefined);
});
test('deleteSessions disposes all sessions and removes them from cache', async () => {
const provider = createProvider(disposables, agentHost);
fireSessionAdded(agentHost, 'del-1', { title: 'First' });
fireSessionAdded(agentHost, 'del-2', { title: 'Second' });
const first = provider.getSessions().find(s => s.title.get() === 'First');
const second = provider.getSessions().find(s => s.title.get() === 'Second');
assert.ok(first);
assert.ok(second);
await provider.deleteSessions([first!.sessionId, second!.sessionId]);
assert.strictEqual(agentHost.disposedSessions.length, 2);
assert.deepStrictEqual(agentHost.disposedSessions.map(uri => AgentSession.id(uri)).sort(), ['del-1', 'del-2']);
assert.strictEqual(provider.getSessions().find(s => s.title.get() === 'First'), undefined);
assert.strictEqual(provider.getSessions().find(s => s.title.get() === 'Second'), undefined);
});
// ---- Rename -------
test('renameSession dispatches SessionTitleChanged on the session channel', async () => {
@@ -11,18 +11,15 @@ import { localize2 } from '../../../../../nls.js';
import { IActionViewItemService } from '../../../../../platform/actions/browser/actionViewItemService.js';
import { Action2, registerAction2 } from '../../../../../platform/actions/common/actions.js';
import { ContextKeyExpr, IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js';
import { IInstantiationService, ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js';
import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';
import { IWorkbenchContribution, WorkbenchPhase, registerWorkbenchContribution2 } from '../../../../../workbench/common/contributions.js';
import { Menus } from '../../../../browser/menus.js';
import { ActiveSessionHasGitRepositoryContext, ActiveSessionProviderIdContext, ActiveSessionTypeContext, ChatSessionProviderIdContext, IsNewChatSessionContext } from '../../../../common/contextkeys.js';
import { ActiveSessionHasGitRepositoryContext, ActiveSessionProviderIdContext, ActiveSessionTypeContext, IsNewChatSessionContext } from '../../../../common/contextkeys.js';
import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js';
import { ISession } from '../../../../services/sessions/common/session.js';
import { ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js';
import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js';
import { SessionItemContextMenuId } from '../../../sessions/browser/views/sessionsList.js';
import { BranchPicker } from './branchPicker.js';
import { ClaudePermissionModePicker } from './claudePermissionModePicker.js';
import { ClaudeCodeSessionType, COPILOT_PROVIDER_ID, CopilotChatSessionsProvider, CopilotCloudSessionType } from './copilotChatSessionsProvider.js';
import { ClaudeCodeSessionType, COPILOT_PROVIDER_ID, CopilotChatSessionsProvider } from './copilotChatSessionsProvider.js';
import { LocalSessionType } from '../../localChatSessions/browser/localChatSessionsProvider.js';
import { IsolationPicker } from './isolationPicker.js';
import { ModePicker, ModePickerModel } from './modePicker.js';
@@ -281,33 +278,3 @@ class CopilotActiveSessionContribution extends Disposable implements IWorkbenchC
registerWorkbenchContribution2(CopilotPickerActionViewItemContribution.ID, CopilotPickerActionViewItemContribution, WorkbenchPhase.AfterRestored);
registerWorkbenchContribution2(CopilotActiveSessionContribution.ID, CopilotActiveSessionContribution, WorkbenchPhase.AfterRestored);
registerAction2(class DeleteSessionAction extends Action2 {
constructor() {
super({
id: 'sessionsViewPane.copilot.deleteSession',
title: localize2('deleteSession', "Delete..."),
menu: [{
id: SessionItemContextMenuId,
group: '1_edit',
order: 4,
when: ContextKeyExpr.and(
ContextKeyExpr.equals(ChatSessionProviderIdContext.key, COPILOT_PROVIDER_ID),
ContextKeyExpr.notEquals('chatSessionType', ClaudeCodeSessionType.id),
ContextKeyExpr.notEquals('chatSessionType', LocalSessionType.id),
ContextKeyExpr.notEquals('chatSessionType', CopilotCloudSessionType.id),
),
}]
});
}
async run(accessor: ServicesAccessor, context?: ISession | ISession[]): Promise<void> {
if (!context) {
return;
}
const sessions = Array.isArray(context) ? context : [context];
const sessionsManagementService = accessor.get(ISessionsManagementService);
for (const session of sessions) {
await sessionsManagementService.deleteSession(session);
}
}
});
@@ -1816,24 +1816,18 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions
return;
}
// Confirm deletion
const confirmed = await this.dialogService.confirm({
message: localize('deleteSession.confirm', "Are you sure you want to delete this session?"),
detail: agentSessions.length > 1
? localize('deleteSession.detailMultiple', "This will delete all {0} chats in this session. This action cannot be undone.", agentSessions.length)
: localize('deleteSession.detail', "This action cannot be undone."),
primaryButton: localize('deleteSession.delete', "Delete")
});
if (!confirmed.confirmed) {
return;
}
await this._deleteAgentSessions(agentSessions);
this._sessionGroupCache.delete(sessionId);
this._refreshSessionCache();
}
async deleteSessions(sessionIds: readonly string[]): Promise<void> {
for (const sessionId of sessionIds) {
await this.deleteSession(sessionId);
}
}
async renameChat(sessionId: string, chatUri: URI, title: string): Promise<void> {
const agentSession = this.agentSessionsService.getSession(chatUri);
if (agentSession?.providerType === CopilotCLISessionType.id) {
@@ -2938,6 +2932,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions
capabilities: {
supportsMultipleChats: primaryChat.sessionType === CopilotCLISessionType.id && this._isMultiChatEnabled(),
supportsRename: this._sessionTypeSupportsRename(primaryChat.sessionType),
supportsDelete: this._sessionTypeSupportsDelete(primaryChat.sessionType),
// Cloud-agent sessions run worktreeCreated tasks server-side during
// environment provisioning, so the agents-window dispatcher must
// not re-run them. CLI / local sessions don't.
@@ -2978,6 +2973,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions
capabilities: {
supportsMultipleChats: false,
supportsRename: this._sessionTypeSupportsRename(chat.sessionType),
supportsDelete: this._sessionTypeSupportsDelete(chat.sessionType),
runsWorktreeCreatedTasks: chat.sessionType === CopilotCloudSessionType.id,
},
};
@@ -2991,6 +2987,10 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions
return sessionType === CopilotCLISessionType.id || sessionType === AgentSessionProviders.Claude;
}
private _sessionTypeSupportsDelete(sessionType: string): boolean {
return sessionType === CopilotCLISessionType.id;
}
private _toChat(chat: ICopilotChatSession, resource?: URI): IChat {
return {
resource: resource ?? chat.resource,
@@ -123,7 +123,7 @@ A `MutableDisposable` on `LocalSession` ensures repeated `trackModel` calls don'
- **`createNewChat`** — for the current new session, returns the already-prepared `IChat` and updates `mainChat`. For an existing committed session, creates a subsequent (child) chat linked to the primary via `parentResource`.
- **`deleteChat`** — removes a single child chat from a multi-chat session after a confirmation dialog; deleting the primary (or the last remaining chat) removes the whole session. An unknown/stale chat URI is a no-op.
A **"Delete..."** session context-menu action (registered in `localChatSessions.contribution.ts`, gated on `LOCAL_PROVIDER_ID`) delegates to the shared `confirmAndDeleteSessions` helper, which confirms and then calls `ISessionsManagementService.deleteSession` (routing to `deleteSession` above).
Local sessions advertise `capabilities.supportsDelete`, so the shared sessions-list **"Delete..."** action (contributed by the sessions workbench, gated on `ChatSessionSupportsDeleteContext`) confirms and then calls `ISessionsManagementService.deleteSessions` (routing to `deleteSession`/`deleteSessions` above). There is no provider-specific delete action.
## Multi-Chat Support
@@ -4,26 +4,21 @@
*--------------------------------------------------------------------------------------------*/
import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../../workbench/common/contributions.js';
import { IInstantiationService, ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js';
import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';
import { Disposable, IDisposable } from '../../../../../base/common/lifecycle.js';
import { LocalChatSessionsProvider, LOCAL_PROVIDER_ID, LOCAL_SESSION_ENABLED_SETTING } from './localChatSessionsProvider.js';
import { LocalChatSessionsProvider, LOCAL_SESSION_ENABLED_SETTING } from './localChatSessionsProvider.js';
import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js';
import { Registry } from '../../../../../platform/registry/common/platform.js';
import { IConfigurationRegistry, Extensions as ConfigurationExtensions } from '../../../../../platform/configuration/common/configurationRegistry.js';
import { localize, localize2 } from '../../../../../nls.js';
import { localize } from '../../../../../nls.js';
import { ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js';
import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js';
import { ForkConversationAction } from '../../../../../workbench/contrib/chat/browser/actions/chatForkActions.js';
import { Action2, registerAction2 } from '../../../../../platform/actions/common/actions.js';
import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js';
import { registerAction2 } from '../../../../../platform/actions/common/actions.js';
import { URI } from '../../../../../base/common/uri.js';
import { ILogService } from '../../../../../platform/log/common/log.js';
import { raceTimeout } from '../../../../../base/common/async.js';
import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';
import { ChatSessionProviderIdContext } from '../../../../common/contextkeys.js';
import { ISession } from '../../../../services/sessions/common/session.js';
import { confirmAndDeleteSessions } from '../../../sessions/browser/deleteSessionHelper.js';
import { SessionItemContextMenuId } from '../../../sessions/browser/views/sessionsList.js';
Registry.as<IConfigurationRegistry>(ConfigurationExtensions.Configuration).registerConfiguration({
id: 'sessions',
@@ -100,22 +95,3 @@ registerAction2(class extends ForkConversationAction {
}
});
registerAction2(class DeleteLocalSessionAction extends Action2 {
constructor() {
super({
id: 'sessionsViewPane.local.deleteSession',
title: localize2('deleteLocalSession', "Delete..."),
menu: [{
id: SessionItemContextMenuId,
group: '1_edit',
order: 4,
when: ContextKeyExpr.equals(ChatSessionProviderIdContext.key, LOCAL_PROVIDER_ID),
}]
});
}
run(accessor: ServicesAccessor, context?: ISession | ISession[]): Promise<void> {
return confirmAndDeleteSessions(accessor, context);
}
});
@@ -805,6 +805,12 @@ export class LocalChatSessionsProvider extends Disposable implements ISessionsPr
this._onDidChangeSessions.fire({ added: [], removed: [groupISession], changed: [] });
}
async deleteSessions(sessionIds: readonly string[]): Promise<void> {
for (const sessionId of sessionIds) {
await this.deleteSession(sessionId);
}
}
async deleteChat(sessionId: string, chatUri: URI): Promise<void> {
const primary = this._findSession(sessionId);
if (!primary || primary.parentResource) {
@@ -1203,6 +1209,7 @@ export class LocalChatSessionsProvider extends Disposable implements ISessionsPr
capabilities: {
supportsMultipleChats: true,
supportsRename: true,
supportsDelete: true,
},
};
}
@@ -1,50 +0,0 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { toErrorMessage } from '../../../../base/common/errorMessage.js';
import { localize } from '../../../../nls.js';
import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js';
import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js';
import { ISession } from '../../../services/sessions/common/session.js';
import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js';
/**
* Confirms with the user, then permanently deletes the given sessions via
* {@link ISessionsManagementService.deleteSession}. Used by provider-specific
* "Delete..." context-menu actions whose backends do not surface their own
* confirmation dialog (agent host and local providers). Failures are surfaced
* via an error dialog and do not abort the remaining deletions.
*/
export async function confirmAndDeleteSessions(accessor: ServicesAccessor, context: ISession | ISession[] | undefined): Promise<void> {
if (!context) {
return;
}
const sessions = Array.isArray(context) ? context : [context];
if (sessions.length === 0) {
return;
}
const dialogService = accessor.get(IDialogService);
const sessionsManagementService = accessor.get(ISessionsManagementService);
const confirmed = await dialogService.confirm({
message: sessions.length === 1
? localize('deleteSession.confirm', "Are you sure you want to delete this session?")
: localize('deleteSessions.confirm', "Are you sure you want to delete {0} sessions?", sessions.length),
detail: localize('deleteSession.detail', "This action cannot be undone."),
primaryButton: localize('deleteSession.delete', "Delete")
});
if (!confirmed.confirmed) {
return;
}
for (const session of sessions) {
try {
await sessionsManagementService.deleteSession(session);
} catch (err) {
dialogService.error(localize('deleteSession.error', "Failed to delete session: {0}", toErrorMessage(err)));
}
}
}
@@ -26,7 +26,7 @@ import { MenuWorkbenchToolBar } from '../../../../../platform/actions/browser/to
import { ICommandService } from '../../../../../platform/commands/common/commands.js';
import { IContextKeyService, RawContextKey } from '../../../../../platform/contextkey/common/contextkey.js';
import { MarshalledId } from '../../../../../base/common/marshallingIds.js';
import { ChatSessionProviderIdContext, ChatSessionSupportsRenameContext, ChatSessionTypeContext, IsPhoneLayoutContext, SessionIsArchivedContext, SessionIsReadContext } from '../../../../common/contextkeys.js';
import { ChatSessionProviderIdContext, ChatSessionSupportsDeleteContext, ChatSessionSupportsRenameContext, ChatSessionTypeContext, IsPhoneLayoutContext, SessionIsArchivedContext, SessionIsReadContext } from '../../../../common/contextkeys.js';
import { IContextMenuService } from '../../../../../platform/contextview/browser/contextView.js';
import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';
import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js';
@@ -1476,6 +1476,7 @@ export class SessionsList extends Disposable implements ISessionsList {
[ChatSessionTypeContext.key, element.sessionType],
[ChatSessionProviderIdContext.key, element.providerId],
[ChatSessionSupportsRenameContext.key, element.capabilities.supportsRename ?? false],
[ChatSessionSupportsDeleteContext.key, element.capabilities.supportsDelete ?? false],
];
const menu = this.menuService.createMenu(SessionItemContextMenuId, this.contextKeyService.createOverlay(contextOverlay));
@@ -4,6 +4,7 @@
*--------------------------------------------------------------------------------------------*/
import { Codicon } from '../../../../../base/common/codicons.js';
import { toErrorMessage } from '../../../../../base/common/errorMessage.js';
import { KeyCode, KeyMod } from '../../../../../base/common/keyCodes.js';
import { isMobile, isWeb } from '../../../../../base/common/platform.js';
import { localize, localize2 } from '../../../../../nls.js';
@@ -19,7 +20,7 @@ import { IViewsService } from '../../../../../workbench/services/views/common/vi
import { CLOSE_MOBILE_SIDEBAR_DRAWER_COMMAND_ID } from '../../../../browser/workbench.js';
import { EditorsVisibleContext, EditorAreaFocusContext, IsSessionsWindowContext } from '../../../../../workbench/common/contextkeys.js';
import { SessionsCategories } from '../../../../common/categories.js';
import { ChatSessionSupportsRenameContext, IsActiveSessionArchivedContext, IsNewChatSessionContext, SessionIsArchivedContext, SessionIsCreatedContext, SessionIsReadContext } from '../../../../common/contextkeys.js';
import { ChatSessionSupportsDeleteContext, ChatSessionSupportsRenameContext, IsActiveSessionArchivedContext, IsNewChatSessionContext, SessionIsArchivedContext, SessionIsCreatedContext, SessionIsReadContext } from '../../../../common/contextkeys.js';
import { SessionItemToolbarMenuId, SessionItemContextMenuId, SessionSectionToolbarMenuId, SessionSectionTypeContext, IsSessionPinnedContext, SessionsGrouping, SessionsSorting, ISessionSection } from './sessionsList.js';
import { ISession, SessionStatus } from '../../../../services/sessions/common/session.js';
import { IsWorkspaceGroupCappedContext, SessionsViewFilterOptionsSubMenu, SessionsViewFilterSubMenu, SessionsViewGroupingContext, SessionsViewId, SessionsView, SessionsViewSortingContext, openSessionToTheSide } from './sessionsView.js';
@@ -733,6 +734,52 @@ registerAction2(class RenameSessionAction extends Action2 {
}
});
registerAction2(class DeleteSessionAction extends Action2 {
constructor() {
super({
id: 'sessionsViewPane.deleteSession',
title: localize2('deleteSession', "Delete..."),
menu: [{
id: SessionItemContextMenuId,
group: '1_edit',
order: 4,
when: ChatSessionSupportsDeleteContext,
}]
});
}
async run(accessor: ServicesAccessor, context?: ISession | ISession[]): Promise<void> {
if (!context) {
return;
}
const sessions = (Array.isArray(context) ? context : [context]).filter(session => session.capabilities.supportsDelete);
if (sessions.length === 0) {
return;
}
const dialogService = accessor.get(IDialogService);
const sessionsManagementService = accessor.get(ISessionsManagementService);
const confirmed = await dialogService.confirm({
message: sessions.length === 1
? localize('deleteSession.confirm', "Are you sure you want to delete this session?")
: localize('deleteSessions.confirm', "Are you sure you want to delete {0} sessions?", sessions.length),
detail: localize('deleteSession.detail', "This action cannot be undone."),
primaryButton: localize('deleteSession.delete', "Delete")
});
if (!confirmed.confirmed) {
return;
}
try {
await sessionsManagementService.deleteSessions(sessions);
} catch (err) {
dialogService.error(sessions.length === 1
? localize('deleteSession.error', "Failed to delete the session: {0}", toErrorMessage(err))
: localize('deleteSessions.error', "Failed to delete the sessions: {0}", toErrorMessage(err)));
}
}
});
registerAction2(class MarkSessionReadAction extends Action2 {
constructor() {
super({
@@ -501,6 +501,38 @@ export class SessionsManagementService extends Disposable implements ISessionsMa
this._onDidDeleteSession.fire(session);
}
async deleteSessions(sessions: readonly ISession[]): Promise<void> {
const byProvider = new Map<ISessionsProvider, ISession[]>();
for (const session of sessions) {
const provider = this._getProvider(session);
if (!provider) {
continue;
}
const group = byProvider.get(provider);
if (group) {
group.push(session);
} else {
byProvider.set(provider, [session]);
}
}
let firstError: unknown;
for (const [provider, providerSessions] of byProvider) {
try {
await provider.deleteSessions(providerSessions.map(session => session.sessionId));
for (const session of providerSessions) {
this._onDidDeleteSession.fire(session);
}
} catch (error) {
firstError ??= error;
}
}
if (firstError !== undefined) {
throw firstError;
}
}
async deleteChat(session: ISession, chatUri: URI): Promise<void> {
await this._getProvider(session)?.deleteChat(session.sessionId, chatUri);
this._onDidDeleteChat.fire(session);
@@ -381,6 +381,13 @@ export interface ISessionCapabilities {
* Defaults to falsy (not renameable) when omitted.
*/
readonly supportsRename?: boolean;
/**
* Whether this session can be deleted. The agents-window sessions-list
* `Delete...` action gates on this flag rather than on the provider id,
* so delete is offered exactly where the backing provider supports it.
* Defaults to falsy (not deletable) when omitted.
*/
readonly supportsDelete?: boolean;
/**
* Whether the session's underlying runtime (e.g. a cloud agent host)
* already runs `runOptions.runOn === 'worktreeCreated'` tasks during
@@ -280,6 +280,15 @@ export interface ISessionsManagementService {
/** Delete a session. */
deleteSession(session: ISession): Promise<void>;
/**
* Delete multiple sessions at once.
*
* Groups the sessions by provider and deletes each group through its
* provider's batch {@link ISessionsProvider.deleteSessions}. Fires
* {@link onDidDeleteSession} once per deleted session.
*/
deleteSessions(sessions: readonly ISession[]): Promise<void>;
/** Delete a single chat from a session by its URI. */
deleteChat(session: ISession, chatUri: URI): Promise<void>;
@@ -231,6 +231,14 @@ export interface ISessionsProvider {
*/
deleteSession(sessionId: string): Promise<void>;
/**
* Delete multiple sessions at once. Implementations may delete the
* sessions more efficiently in a batch, or simply delegate to
* {@link deleteSession} for each id.
* @param sessionIds The IDs of the sessions to delete.
*/
deleteSessions(sessionIds: readonly string[]): Promise<void>;
/**
* Delete a single chat from a session.
* @param sessionId The ID of the session containing the chat to delete.
@@ -210,6 +210,7 @@ class MockSessionStore implements ISessionsManagementService {
archiveSession(_session: ISession): Promise<void> { throw new Error('not implemented'); }
unarchiveSession(_session: ISession): Promise<void> { throw new Error('not implemented'); }
deleteSession(_session: ISession): Promise<void> { throw new Error('not implemented'); }
deleteSessions(_sessions: readonly ISession[]): Promise<void> { throw new Error('not implemented'); }
deleteChat(_session: ISession, _chatUri: URI): Promise<void> { throw new Error('not implemented'); }
renameChat(_session: ISession, _chatUri: URI, _title: string): Promise<void> { throw new Error('not implemented'); }
renameSession(_session: ISession, _title: string): Promise<void> { throw new Error('not implemented'); }
@@ -153,6 +153,7 @@ class TestSessionsProvider extends mock<ISessionsProvider>() {
override async archiveSession(): Promise<void> { }
override async unarchiveSession(): Promise<void> { }
override async deleteSession(): Promise<void> { }
override async deleteSessions(_sessionIds: readonly string[]): Promise<void> { }
override async deleteChat(): Promise<void> { }
override deleteNewSession(): void { }
override async sendRequest(_sessionId: string, _chatResource: URI, _options: ISendRequestOptions): Promise<ISession> { return this._session; }
@@ -902,6 +903,63 @@ suite('SessionsManagementService', () => {
assert.strictEqual(view.activeSession.get()?.sessionId, 'b');
});
suite('deleteSessions', () => {
class RecordingProvider extends TestSessionsProvider {
readonly deleted: string[][] = [];
constructor(public override readonly id: string, private readonly _fail: boolean, session: ISession) {
super(session);
}
override async deleteSessions(sessionIds: readonly string[]): Promise<void> {
this.deleted.push([...sessionIds]);
if (this._fail) {
throw new Error(`${this.id} failed`);
}
}
}
function createService(providers: ISessionsProvider[]): ISessionsManagementService {
const instantiationService = disposables.add(new TestInstantiationService());
instantiationService.stub(IStorageService, disposables.add(new InMemoryStorageService()));
instantiationService.stub(ILogService, new NullLogService());
instantiationService.stub(IContextKeyService, disposables.add(new MockContextKeyService()));
instantiationService.stub(ISessionsProvidersService, new TestSessionsProvidersService(providers));
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;
});
instantiationService.stub(IChatWidgetHistoryService, new class extends mock<IChatWidgetHistoryService>() {
override moveHistory(): void { }
});
return disposables.add(instantiationService.createInstance(SessionsManagementService));
}
test('groups sessions by provider and continues when one provider fails (best-effort)', async () => {
const s1 = stubSession({ sessionId: 's1', providerId: 'p1' });
const s2 = stubSession({ sessionId: 's2', providerId: 'p2' });
const failing = new RecordingProvider('p1', true, s1);
const succeeding = new RecordingProvider('p2', false, s2);
const service = createService([failing, succeeding]);
const deleted: string[] = [];
disposables.add(service.onDidDeleteSession(session => deleted.push(session.sessionId)));
await assert.rejects(service.deleteSessions([s1, s2]), /p1 failed/);
assert.deepStrictEqual({
failingDeleted: failing.deleted,
succeedingDeleted: succeeding.deleted,
eventsFired: deleted,
}, {
failingDeleted: [['s1']],
succeedingDeleted: [['s2']],
eventsFired: ['s2'],
});
});
});
suite('createNewChatInSession', () => {
test('reuses an existing untitled chat instead of creating a new one', async () => {
-1
View File
@@ -223,7 +223,6 @@ import './contrib/providers/agentHost/browser/localAgentHost.contribution.js';
import './contrib/providers/agentHost/browser/agentSessionSettings.contribution.js';
import './contrib/providers/agentHost/browser/agentHostSettings.contribution.js';
import './contrib/providers/agentHost/browser/agentHostSessionBranchActions.js';
import './contrib/providers/agentHost/browser/agentHostSessionDeleteAction.js';
import './contrib/providers/agentHost/browser/agentHostSkillButtons.js';
import './contrib/providers/agentHost/electron-browser/agentHost.contribution.js';
-1
View File
@@ -163,7 +163,6 @@ import './contrib/providers/remoteAgentHost/browser/remoteAgentHostActions.js';
import './contrib/providers/agentHost/browser/agentSessionSettings.contribution.js';
import './contrib/providers/agentHost/browser/agentHostSettings.contribution.js';
import './contrib/providers/agentHost/browser/agentHostSessionBranchActions.js';
import './contrib/providers/agentHost/browser/agentHostSessionDeleteAction.js';
import './contrib/providers/agentHost/browser/agentHostSkillButtons.js';
// Host filter dropdown in the titlebar (scopes the sessions list to a host)