fix(web-shell): batch session deletions at the route limit (#14637)

* fix(web-shell): batch session deletions at the route limit

* test(web-shell): cover bulk delete in visual previews

* fix(web-shell): retain bulk delete interruption errors

* fix(web-shell): retain deletion failures across workspace batches

Collect per-session and request failures throughout a deletion without losing
later causes or earlier successful current-session cleanup. Keep archive
behavior unchanged and document the daemon request-limit coupling.

Add gated regressions for both cross-workspace failure orders and multiple
per-session errors across batches. The new gates reject parallel-workspace
and per-workspace-error-reset mutations.

* test(web-shell): cover bulk delete merge boundaries

Preserve workspace and standalone failure causes for per-session and rejected requests. Verify each deletion batch captures its UI owner before the request and invokes its own invalidation callback with that batch result.

---------

Co-authored-by: qwen-code-dev-bot <[email protected]>
This commit is contained in:
jianyx1andqwen-code-dev-bot authored and GitHub committed 2026-11-06 10:28:24 +00:00
1 parent 666d0cae7b
commit a051a6a796
5 files changed
+612 -16

No files matched your search

@@ -108,6 +108,20 @@ last event id on `DaemonSessionClient`, and reconnects the stream by default.
Callers can disable that with `autoReconnect={false}` for tests or custom
connection management.
### Deleting selected sessions
Session Overview supports deleting more than 100 selected sessions. It groups
the selection by owning workspace and sends sequential requests of at most
100 session IDs through each workspace's client. Successful deletions remain
accounted for if a later request fails, including cleanup of a deleted current
session, while the failure is shown in the overview.
The batch size follows the daemon's shared 100-ID request validation for
session deletion and archiving, including workspace-qualified routes. During
deletion, per-session and request failures are retained across batches and
workspaces, regardless of their order. A rejected request stops the remaining
batches in its workspace; other workspace groups are still processed.
### Removing composer attachments
In the desktop Web Shell composer, when the text body is empty and the caret
@@ -3015,6 +3015,361 @@ describe('SessionOverviewPanel', () => {
expect(container!.textContent).not.toContain('Failed to delete session');
});
it.each([
{ count: 100, batchSizes: [100] },
{ count: 101, batchSizes: [100, 1] },
{ count: 201, batchSizes: [100, 100, 1] },
])(
'keeps delete requests within the route limit for $count selected sessions',
async ({ count, batchSizes }) => {
const many = Array.from({ length: count }, (_, index) =>
session(`bulk-${index}`, { displayName: `Bulk ${index}` }),
);
sessionsState.sessions = many;
const invalidators = batchSizes.map(() => vi.fn());
const capture = vi.fn();
for (const invalidate of invalidators) {
capture.mockReturnValueOnce(invalidate);
}
generatedUiOwner = deletionOwner(capture);
const completeBatches: Array<() => void> = [];
workspaceClient.deleteSessionsData.mockImplementation(
async (ids: string[]) => {
if (ids.length > 100) throw new Error('max 100 session IDs');
await new Promise<void>((resolve) => completeBatches.push(resolve));
return { removed: ids, notFound: [], errors: [] };
},
);
render();
act(() => click(selectAllCheckbox()));
act(() => click(footerButton('Delete')!));
expect(document.body.textContent).toContain(`Delete ${count} sessions?`);
const confirm = document.querySelector(
'[data-slot="alert-dialog-action"]',
) as HTMLElement;
act(() => click(confirm));
await flushAsync();
for (let index = 0; index < batchSizes.length; index += 1) {
expect(workspaceClient.deleteSessionsData).toHaveBeenCalledTimes(
index + 1,
);
expect(completeBatches).toHaveLength(index + 1);
expect(capture).toHaveBeenCalledTimes(index + 1);
expect(capture).toHaveBeenNthCalledWith(index + 1, '/w');
expect(capture.mock.invocationCallOrder[index]).toBeLessThan(
workspaceClient.deleteSessionsData.mock.invocationCallOrder[index]!,
);
expect(invalidators[index]).not.toHaveBeenCalled();
await act(async () => completeBatches[index]!());
await flushAsync();
expect(invalidators[index]).toHaveBeenCalledExactlyOnceWith(
many
.slice(index * 100, (index + 1) * 100)
.map((item) => item.sessionId),
);
}
for (const invalidate of invalidators) {
expect(invalidate).toHaveBeenCalledOnce();
}
const batches = workspaceClient.deleteSessionsData.mock.calls.map(
([ids]) => ids as string[],
);
expect(batches.map((ids) => ids.length)).toEqual(batchSizes);
expect(batches.flat()).toEqual(many.map((item) => item.sessionId));
expect(container!.textContent).not.toContain('Failed to delete session');
expect(sessionCatalogController.refreshWorkspace).toHaveBeenCalledOnce();
expect(sessionCatalogController.refreshWorkspace).toHaveBeenCalledWith(
'/w',
);
},
);
it('splits qualified delete batches without mixing workspace scopes', async () => {
connectionState.capabilities = {
features: ['workspace_qualified_rest_core'],
workspaceCwd: '/w',
workspaces: [
{ id: 'w0', cwd: '/w', primary: true, trusted: true },
{ id: 'w1', cwd: '/wsB', primary: false, trusted: true },
],
};
sessionsState.sessions = [session('primary')];
const many = Array.from({ length: 101 }, (_, index) =>
session(`secondary-${index}`, { workspaceCwd: '/wsB' }),
);
otherWorkspaceSessions['/wsB'] = many;
const qualified = workspaceClient.workspaceByCwd('/wsB');
workspaceClient.workspaceByCwd.mockReturnValue(qualified);
render();
await flushAsync();
act(() => click(selectAllCheckbox()));
act(() => click(footerButton('Delete')!));
const confirm = document.querySelector(
'[data-slot="alert-dialog-action"]',
) as HTMLElement;
act(() => click(confirm));
await flushAsync();
expect(workspaceClient.deleteSessionsData).toHaveBeenCalledOnce();
expect(workspaceClient.deleteSessionsData).toHaveBeenCalledWith([
'primary',
]);
expect(qualified.deleteSessionsData).toHaveBeenCalledTimes(2);
expect(qualified.deleteSessionsData).toHaveBeenNthCalledWith(
1,
many.slice(0, 100).map((item) => item.sessionId),
);
expect(qualified.deleteSessionsData).toHaveBeenNthCalledWith(2, [
many[100]!.sessionId,
]);
expect(sessionCatalogController.refreshWorkspace).toHaveBeenCalledTimes(2);
expect(sessionCatalogController.refreshWorkspace).toHaveBeenCalledWith(
'/w',
);
expect(sessionCatalogController.refreshWorkspace).toHaveBeenCalledWith(
'/wsB',
);
});
it.each(['request', 'per-session'])(
'retains cross-workspace delete failures when the first is %s',
async (firstFailure) => {
connectionState.capabilities = {
features: ['workspace_qualified_rest_core'],
workspaceCwd: '/w',
workspaces: [
{ id: 'w0', cwd: '/w', primary: true, trusted: true },
{ id: 'w1', cwd: '/wsB', primary: false, trusted: true },
],
};
sessionsState.sessions = [
session('primary', { updatedAt: '2026-01-02T00:00:00.000Z' }),
];
otherWorkspaceSessions['/wsB'] = [
session('secondary', {
workspaceCwd: '/wsB',
updatedAt: '2026-01-01T00:00:00.000Z',
}),
];
const qualified = workspaceClient.workspaceByCwd('/wsB');
workspaceClient.workspaceByCwd.mockReturnValue(qualified);
let completePrimary!: () => void;
workspaceClient.deleteSessionsData.mockImplementationOnce(
() =>
new Promise((resolve, reject) => {
completePrimary = () => {
if (firstFailure === 'request') {
reject(new Error('request failed'));
} else {
resolve({
removed: [],
notFound: [],
errors: [{ sessionId: 'primary', error: 'locked' }],
});
}
};
}),
);
if (firstFailure === 'request') {
qualified.deleteSessionsData.mockResolvedValueOnce({
removed: [],
notFound: [],
errors: [{ sessionId: 'secondary', error: 'locked' }],
});
} else {
qualified.deleteSessionsData.mockRejectedValueOnce(
new Error('request failed'),
);
}
render();
await flushAsync();
act(() => click(selectAllCheckbox()));
act(() => click(footerButton('Delete')!));
act(() =>
click(
document.querySelector(
'[data-slot="alert-dialog-action"]',
) as HTMLElement,
),
);
await flushAsync();
expect(workspaceClient.deleteSessionsData).toHaveBeenCalledOnce();
expect(workspaceClient.deleteSessionsData).toHaveBeenCalledWith([
'primary',
]);
expect(qualified.deleteSessionsData).not.toHaveBeenCalled();
await act(async () => completePrimary());
await flushAsync();
expect(qualified.deleteSessionsData).toHaveBeenCalledOnce();
expect(qualified.deleteSessionsData).toHaveBeenCalledWith(['secondary']);
expect(container!.textContent).toContain('locked');
expect(container!.textContent).toContain('request failed');
expect(sessionCatalogController.refreshWorkspace).toHaveBeenCalledTimes(
2,
);
expect(sessionCatalogController.refreshWorkspace).toHaveBeenCalledWith(
'/w',
);
expect(sessionCatalogController.refreshWorkspace).toHaveBeenCalledWith(
'/wsB',
);
},
);
it('reports distinct per-session failures from consecutive delete batches', async () => {
const many = Array.from({ length: 101 }, (_, index) =>
session(`review-${index}`),
);
sessionsState.sessions = many;
connectionState.sessionId = many[0]!.sessionId;
workspaceClient.deleteSessionsData
.mockResolvedValueOnce({
removed: many.slice(0, 98).map((item) => item.sessionId),
notFound: [],
errors: [
{ sessionId: many[98]!.sessionId, error: 'locked' },
{ sessionId: many[99]!.sessionId, error: 'permission denied' },
],
})
.mockResolvedValueOnce({
removed: [],
notFound: [],
errors: [{ sessionId: many[100]!.sessionId, error: 'busy' }],
});
render({ onCurrentSessionRemoved });
act(() => click(selectAllCheckbox()));
act(() => click(footerButton('Delete')!));
act(() =>
click(
document.querySelector(
'[data-slot="alert-dialog-action"]',
) as HTMLElement,
),
);
await flushAsync();
expect(workspaceClient.deleteSessionsData).toHaveBeenCalledTimes(2);
expect(workspaceClient.deleteSessionsData).toHaveBeenNthCalledWith(
1,
many.slice(0, 100).map((item) => item.sessionId),
);
expect(workspaceClient.deleteSessionsData).toHaveBeenNthCalledWith(2, [
many[100]!.sessionId,
]);
expect(container!.textContent).toContain(
'Failed to delete session: locked; permission denied; busy',
);
expect(onCurrentSessionRemoved).toHaveBeenCalledOnce();
expect(onCurrentSessionRemoved).toHaveBeenCalledWith(
expect.objectContaining({ sessionId: many[0]!.sessionId }),
);
});
it('keeps partial delete results and clears a current session removed in a later batch', async () => {
const many = Array.from({ length: 201 }, (_, index) =>
session(`bulk-${index}`, { displayName: `Bulk ${index}` }),
);
sessionsState.sessions = many;
connectionState.sessionId = many[200]!.sessionId;
workspaceClient.deleteSessionsData.mockResolvedValueOnce({
removed: many.slice(0, 98).map((item) => item.sessionId),
notFound: [many[98]!.sessionId],
errors: [{ sessionId: many[99]!.sessionId, error: 'locked' }],
});
render({ onCurrentSessionRemoved });
act(() => click(selectAllCheckbox()));
act(() => click(footerButton('Delete')!));
const confirm = document.querySelector(
'[data-slot="alert-dialog-action"]',
) as HTMLElement;
act(() => click(confirm));
await flushAsync();
expect(workspaceClient.deleteSessionsData).toHaveBeenCalledTimes(3);
expect(onCurrentSessionRemoved).toHaveBeenCalledOnce();
expect(onCurrentSessionRemoved).toHaveBeenCalledWith(
expect.objectContaining({ sessionId: many[200]!.sessionId }),
);
expect(container!.textContent).toContain(
'Failed to delete session: locked',
);
expect(sessionsReload).toHaveBeenCalled();
});
it('keeps a successful deletion when a later batch request fails', async () => {
const many = Array.from({ length: 101 }, (_, index) =>
session(`bulk-${index}`, { displayName: `Bulk ${index}` }),
);
sessionsState.sessions = many;
connectionState.sessionId = many[0]!.sessionId;
workspaceClient.deleteSessionsData
.mockResolvedValueOnce({
removed: many.slice(0, 100).map((item) => item.sessionId),
notFound: [],
errors: [],
})
.mockRejectedValueOnce(new Error('request failed'));
render({ onCurrentSessionRemoved });
act(() => click(selectAllCheckbox()));
act(() => click(footerButton('Delete')!));
const confirm = document.querySelector(
'[data-slot="alert-dialog-action"]',
) as HTMLElement;
act(() => click(confirm));
await flushAsync();
expect(workspaceClient.deleteSessionsData).toHaveBeenCalledTimes(2);
expect(onCurrentSessionRemoved).toHaveBeenCalledOnce();
expect(container!.textContent).toContain(
'Failed to delete session: request failed',
);
expect(sessionCatalogController.refreshWorkspace).toHaveBeenCalledWith(
'/w',
);
expect(sessionsReload).toHaveBeenCalled();
});
it('reports a later delete request failure after a per-session error', async () => {
const many = Array.from({ length: 201 }, (_, index) =>
session(`bulk-${index}`, { displayName: `Bulk ${index}` }),
);
sessionsState.sessions = many;
connectionState.sessionId = many[0]!.sessionId;
workspaceClient.deleteSessionsData
.mockResolvedValueOnce({
removed: many.slice(0, 99).map((item) => item.sessionId),
notFound: [],
errors: [{ sessionId: many[99]!.sessionId, error: 'locked' }],
})
.mockRejectedValueOnce(new Error('live_session_active'));
render({ onCurrentSessionRemoved });
act(() => click(selectAllCheckbox()));
act(() => click(footerButton('Delete')!));
const confirm = document.querySelector(
'[data-slot="alert-dialog-action"]',
) as HTMLElement;
act(() => click(confirm));
await flushAsync();
expect(workspaceClient.deleteSessionsData).toHaveBeenCalledTimes(2);
expect(workspaceClient.deleteSessionsData).toHaveBeenNthCalledWith(
2,
many.slice(100, 200).map((item) => item.sessionId),
);
expect(onCurrentSessionRemoved).toHaveBeenCalledOnce();
expect(onCurrentSessionRemoved).toHaveBeenCalledWith(
expect.objectContaining({ sessionId: many[0]!.sessionId }),
);
expect(container!.textContent).toContain(
'Failed to delete session: locked',
);
expect(container!.textContent).toContain('live_session_active');
expect(sessionCatalogController.refreshWorkspace).toHaveBeenCalledWith(
'/w',
);
});
it('clears a deleted current session after a partial batch failure', async () => {
connectionState.sessionId = 'current';
connectionState.capabilities = {
@@ -4081,6 +4436,74 @@ describe('SessionOverviewPanel standalone sessions', () => {
}
});
it.each(['per-session', 'request'] as const)(
'retains workspace and standalone delete failures for %s failures',
async (failure) => {
useConversationsRuntimeCapabilities();
sessionsState.sessions = [
session('primary', { displayName: 'Workspace row' }),
];
standaloneListPage.mockResolvedValue({
sessions: [
standaloneSession('st1', {
workspaceCwd: '/conv',
displayName: 'Standalone one',
}),
standaloneSession('st2', {
workspaceCwd: '/conv',
displayName: 'Standalone two',
}),
],
});
workspaceClient.deleteSessionsData.mockResolvedValueOnce({
removed: [],
notFound: [],
errors: [{ sessionId: 'primary', error: 'workspace locked' }],
});
if (failure === 'request') {
workspaceClient.deleteStandaloneSessions.mockRejectedValueOnce(
new Error('solo unavailable'),
);
} else {
workspaceClient.deleteStandaloneSessions.mockResolvedValueOnce({
removed: [],
notFound: [],
errors: [
{ sessionId: 'st1', message: 'solo locked' },
{ sessionId: 'st2', message: 'solo busy' },
],
fileCleanupPending: [],
});
}
render();
await flushAsync();
expect(rows()).toHaveLength(3);
act(() => click(selectAllCheckbox()));
act(() => click(footerButton('Delete')!));
act(() =>
click(
document.querySelector<HTMLElement>(
'[data-slot="alert-dialog-action"]',
)!,
),
);
await flushAsync();
expect(
workspaceClient.deleteSessionsData,
).toHaveBeenCalledExactlyOnceWith(['primary']);
expect(
workspaceClient.deleteStandaloneSessions,
).toHaveBeenCalledExactlyOnceWith(['st1', 'st2']);
expect(container!.textContent).toContain(
`Failed to delete session: workspace locked; ${
failure === 'request' ? 'solo unavailable' : 'solo locked; solo busy'
}`,
);
expect(rows()).toHaveLength(3);
},
);
it('labels a standalone row "No workspace" instead of the internal root', async () => {
useConversationsRuntimeCapabilities();
standalonePages = [
@@ -872,6 +872,7 @@ function SessionOverviewPanelInner({
byCwd.set(key, [...(byCwd.get(key) ?? []), card]);
}
const succeededIdentities = new Set<string>();
const deleteFailures: string[] = [];
let firstError: Error | undefined;
for (const [cwd, group] of byCwd) {
const ids = group.map((card) => card.sessionId);
@@ -905,21 +906,32 @@ function SessionOverviewPanelInner({
: undefined;
}
} else {
const invalidateDeleted =
generatedUiOwner?.captureSessionDeletion(ownerCwd);
const result = await client.deleteSessionsData(ids);
invalidateDeleted?.([...result.removed, ...result.notFound]);
for (const id of [...result.removed, ...result.notFound]) {
const card = cardsById.get(id);
if (card) succeededIdentities.add(getSessionIdentity(card));
for (
let offset = 0;
offset < ids.length;
offset += SESSION_ARCHIVE_BATCH_SIZE
) {
const invalidateDeleted =
generatedUiOwner?.captureSessionDeletion(ownerCwd);
const result = await client.deleteSessionsData(
ids.slice(offset, offset + SESSION_ARCHIVE_BATCH_SIZE),
);
invalidateDeleted?.([...result.removed, ...result.notFound]);
for (const id of [...result.removed, ...result.notFound]) {
const card = cardsById.get(id);
if (card) succeededIdentities.add(getSessionIdentity(card));
}
deleteFailures.push(...result.errors.map(({ error }) => error));
}
firstError ??= result.errors[0]
? new Error(result.errors[0].error)
: undefined;
}
} catch (error) {
firstError ??=
const requestError =
error instanceof Error ? error : new Error(String(error));
if (mutation === 'delete') {
deleteFailures.push(requestError.message);
} else {
firstError ??= requestError;
}
} finally {
if (ownerCwd) {
sessionCatalogController.refreshWorkspace(ownerCwd);
@@ -965,16 +977,24 @@ function SessionOverviewPanelInner({
id,
);
}
firstError ??= result.errors[0]
? new Error(result.errors[0].message)
: undefined;
deleteFailures.push(...result.errors.map(({ message }) => message));
}
} catch (error) {
firstError ??=
const requestError =
error instanceof Error ? error : new Error(String(error));
if (mutation === 'delete') {
deleteFailures.push(requestError.message);
} else {
firstError ??= requestError;
}
}
}
return { succeededIdentities, error: firstError };
return {
succeededIdentities,
error: deleteFailures.length
? new Error(deleteFailures.join('; '))
: firstError,
};
},
[
canArchiveCard,
@@ -9,6 +9,8 @@
* retention limits cannot drift between the main and split views.
*/
export const SESSION_LIST_PAGE_SIZE = 1000;
// Keep within parseSessionIdsBody's bound in packages/cli/src/serve/routes/session.ts,
// shared by the primary and workspace-qualified delete/archive routes.
export const SESSION_ARCHIVE_BATCH_SIZE = 100;
export const SIDEBAR_SESSION_PREVIEW_LIMIT = 5;
export const SESSION_ORGANIZATION_FEATURE = 'session_organization';
@@ -767,6 +767,143 @@ for (const theme of THEMES) {
await captureScreenshot(page, `session-overview-details-${theme}`);
});
test('session overview bulk delete batches at the route limit', async ({
page,
}, testInfo) => {
const workspaceCwd = '/workspace/session-overview-bulk-delete';
// The mock catalog deliberately exceeds the real daemon's 100-row page cap.
const sessions = Array.from({ length: 101 }, (_, index) => ({
sessionId: `bulk-delete-${index}`,
displayName: `Bulk delete fixture ${index}`,
workspaceCwd,
clientCount: 0,
hasActivePrompt: false,
updatedAt: '2026-07-01T12:00:00.000Z',
}));
const scenario = createWebShellDaemonScenario({
workspaceCwd,
sessions: [
...sessions,
{
sessionId: 'web-shell-e2e-session',
displayName: 'Current session to keep',
workspaceCwd,
clientCount: 1,
hasActivePrompt: true,
},
],
});
const daemon = await installScenario(
page,
scenario,
resolveBaseURL(testInfo),
);
const batches: string[][] = [];
const browserErrors: string[] = [];
page.on('pageerror', (error) => browserErrors.push(error.message));
page.on('console', (message) => {
if (['error', 'warning'].includes(message.type())) {
browserErrors.push(message.text());
}
});
await page.route('**/workspace/models', (route) =>
route.fulfill({ json: { models: [] } }),
);
await page.route('**/daemon/status?*', (route) =>
route.fulfill({
json: {
v: 1,
detail: 'full',
generatedAt: '2026-07-01T12:00:00.000Z',
status: 'ok',
issues: [],
daemon: { pid: 0, uptimeMs: 0, mode: 'http-bridge', workspaceCwd },
full: {
sessions: scenario.sessions.map((session) => ({
...session,
subscriberCount: 0,
attachCount: 0,
pendingPromptCount: 0,
pendingPermissionCount: 0,
lastEventId: 0,
})),
},
},
}),
);
await page.route('**/sessions/delete', async (route) => {
const { sessionIds } = route.request().postDataJSON() as {
sessionIds: string[];
};
batches.push(sessionIds);
if (sessionIds.length > 100) {
await route.fulfill({
status: 400,
json: { error: 'max 100 session IDs', code: 'invalid_request' },
});
return;
}
const removed = scenario.sessions
.filter((session) => sessionIds.includes(session.sessionId))
.map((session) => session.sessionId);
scenario.sessions = scenario.sessions.filter(
(session) => !removed.includes(session.sessionId),
);
await route.fulfill({
json: {
removed,
notFound: sessionIds.filter((id) => !removed.includes(id)),
errors: [],
},
});
});
await gotoSession(page, scenario, daemon, theme);
await page.getByRole('button', { name: 'More', exact: true }).click();
await page
.getByRole('button', { name: 'Session Overview', exact: true })
.click();
const panel = page.locator('[data-web-shell-session-panel]');
await panel
.getByRole('textbox', { name: 'Search title, branch, PR or ID…' })
.fill('Bulk delete fixture');
await expect(panel).toContainText('101 session(s)');
await panel
.getByRole('checkbox', { name: 'Select all', exact: true })
.check();
await expect(panel).toContainText('101 of 101 row(s) selected');
await panel
.locator('[data-web-shell-session-footer]')
.getByRole('button', { name: 'Delete', exact: true })
.click();
await expect(page.getByRole('alertdialog')).toContainText(
'Delete 101 sessions?',
);
await captureScreenshot(
page,
`session-overview-bulk-delete-confirm-${theme}`,
);
await page.locator('[data-slot="alert-dialog-action"]').click();
await expect(
panel
.getByText('No data', { exact: true })
.or(panel.getByText(/Failed to delete session/)),
).toBeVisible();
await captureScreenshot(
page,
`session-overview-bulk-delete-result-${theme}`,
);
expect(batches.map((ids) => ids.length)).toEqual([100, 1]);
expect(batches.flat().sort()).toEqual(
sessions.map((session) => session.sessionId).sort(),
);
expect(scenario.sessions.map((session) => session.sessionId)).toEqual([
scenario.sessionId,
]);
await expect(panel.getByText('No data', { exact: true })).toBeVisible();
await expect(panel).not.toContainText('Failed to delete session');
expect(browserErrors).toEqual([]);
});
test('conversation search', async ({ page }, testInfo) => {
const events = Array.from({ length: 12 }, (_, index) => [
userTextEvent(`Explain synthetic example ${index + 1}.`, {