From a051a6a796c137defd0d0cf2fcc65490af9aaf92 Mon Sep 17 00:00:00 2001 From: jianyx1 <166109249+jianyx1@users.noreply.github.com> Date: Fri, 6 Nov 2026 18:28:24 +0800 Subject: [PATCH] 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 --- .../daemon-client-adapters/web-shell.md | 14 + .../components/SessionOverviewPanel.test.tsx | 423 ++++++++++++++++++ .../components/SessionOverviewPanel.tsx | 52 ++- .../web-shell/client/constants/sessions.ts | 2 + .../client/e2e/visuals/screenshots.spec.ts | 137 ++++++ 5 files changed, 612 insertions(+), 16 deletions(-) diff --git a/docs/developers/daemon-client-adapters/web-shell.md b/docs/developers/daemon-client-adapters/web-shell.md index d89967afa76..b469611e30d 100644 --- a/docs/developers/daemon-client-adapters/web-shell.md +++ b/docs/developers/daemon-client-adapters/web-shell.md @@ -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 diff --git a/packages/web-shell/client/components/SessionOverviewPanel.test.tsx b/packages/web-shell/client/components/SessionOverviewPanel.test.tsx index d7bc80aca95..06549132d30 100644 --- a/packages/web-shell/client/components/SessionOverviewPanel.test.tsx +++ b/packages/web-shell/client/components/SessionOverviewPanel.test.tsx @@ -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((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( + '[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 = [ diff --git a/packages/web-shell/client/components/SessionOverviewPanel.tsx b/packages/web-shell/client/components/SessionOverviewPanel.tsx index 787b980ada6..578a61ccf2c 100644 --- a/packages/web-shell/client/components/SessionOverviewPanel.tsx +++ b/packages/web-shell/client/components/SessionOverviewPanel.tsx @@ -872,6 +872,7 @@ function SessionOverviewPanelInner({ byCwd.set(key, [...(byCwd.get(key) ?? []), card]); } const succeededIdentities = new Set(); + 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, diff --git a/packages/web-shell/client/constants/sessions.ts b/packages/web-shell/client/constants/sessions.ts index 4629125d868..2f7026df1b1 100644 --- a/packages/web-shell/client/constants/sessions.ts +++ b/packages/web-shell/client/constants/sessions.ts @@ -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'; diff --git a/packages/web-shell/client/e2e/visuals/screenshots.spec.ts b/packages/web-shell/client/e2e/visuals/screenshots.spec.ts index a84b55b3eae..f0863e0f39e 100644 --- a/packages/web-shell/client/e2e/visuals/screenshots.spec.ts +++ b/packages/web-shell/client/e2e/visuals/screenshots.spec.ts @@ -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}.`, {