mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
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:
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}.`, {
|
||||
|
||||
Reference in new issue
Block a user