diff --git a/docs/users-and-requests.md b/docs/users-and-requests.md
index 7db97da7..3d4ae8f9 100644
--- a/docs/users-and-requests.md
+++ b/docs/users-and-requests.md
@@ -69,6 +69,10 @@ Admins can override the default ebook/audiobook modes and request rules for indi
---
+## Clearing the activity list
+
+**Clear Completed** in the activity list clears finished downloads and their requests, and also finished requests that never had a download, such as a request closed because the book was already in the library. Pending and rejected requests are left alone.
+
## Requests
The request system controls whether users can download directly or need admin approval first.
diff --git a/src/frontend/src/components/activity/ActivitySidebar.tsx b/src/frontend/src/components/activity/ActivitySidebar.tsx
index 2c5ef884..847f46c1 100644
--- a/src/frontend/src/components/activity/ActivitySidebar.tsx
+++ b/src/frontend/src/components/activity/ActivitySidebar.tsx
@@ -379,17 +379,6 @@ export const ActivitySidebar = ({
};
}, [downloadItems, visibleRequestItems]);
- const hasTerminalDownloadItems = useMemo(
- () =>
- mergedDownloadItems.some(
- (item) =>
- item.visualStatus === 'complete' ||
- item.visualStatus === 'error' ||
- item.visualStatus === 'cancelled',
- ),
- [mergedDownloadItems],
- );
-
const allItems = useMemo(() => {
const combined = dedupeById([...mergedDownloadItems, ...mergedRequestItems]);
return combined.toSorted((a, b) => b.timestamp - a.timestamp);
@@ -958,8 +947,9 @@ export const ActivitySidebar = ({
})()}
- {(effectiveActiveTab === 'downloads' || effectiveActiveTab === 'all') &&
- hasTerminalDownloadItems &&
+ {(effectiveActiveTab === 'downloads' ||
+ effectiveActiveTab === 'all' ||
+ effectiveActiveTab === 'requests') &&
clearCompletedTargets.length > 0 && (
();
visibleItems.forEach((item) => {
+ if (item.kind === 'request' && typeof item.requestId === 'number') {
+ const record = item.requestRecord;
+ const finished =
+ record?.status === 'cancelled' ||
+ (record?.status === 'fulfilled' &&
+ !ACTIVE_DELIVERY_STATES.has(record.delivery_state ?? ''));
+ const requestKey = `request:${item.requestId}`;
+ if (finished && !pendingRequestIds.has(item.requestId) && !seen.has(requestKey)) {
+ seen.add(requestKey);
+ targets.push({ itemType: 'request', itemKey: requestKey });
+ }
+ return;
+ }
+
const isTerminalDownload =
item.kind === 'download' &&
(item.visualStatus === 'complete' ||
diff --git a/src/frontend/src/tests/clearCompleted.test.ts b/src/frontend/src/tests/clearCompleted.test.ts
index 02e89e89..5d9c9f64 100644
--- a/src/frontend/src/tests/clearCompleted.test.ts
+++ b/src/frontend/src/tests/clearCompleted.test.ts
@@ -98,4 +98,32 @@ describe('getClearCompletedTargets', () => {
expect(targets).toEqual([]);
});
+
+ it('clears a finished request that never had a download', () => {
+ const closed = makeRequestItem(30, 'fulfilled');
+ closed.requestRecord = Object.assign({}, closed.requestRecord, {
+ delivery_state: 'complete' as const,
+ admin_note: '[auto] Already in the library.',
+ });
+ const cancelled = makeRequestItem(31, 'cancelled');
+
+ const targets = getClearCompletedTargets([closed, cancelled], [closed, cancelled]);
+
+ expect(targets).toEqual([
+ { itemType: 'request', itemKey: 'request:30' },
+ { itemType: 'request', itemKey: 'request:31' },
+ ]);
+ });
+
+ it('leaves pending, rejected and still-downloading requests alone', () => {
+ const pending = makeRequestItem(40, 'pending');
+ const rejected = makeRequestItem(41, 'rejected');
+ const downloading = makeRequestItem(42, 'fulfilled');
+ downloading.requestRecord = Object.assign({}, downloading.requestRecord, {
+ delivery_state: 'downloading' as const,
+ });
+
+ const items = [pending, rejected, downloading];
+ expect(getClearCompletedTargets(items, items)).toEqual([]);
+ });
});