From 0cda683142a51fdd3b9bfe4eb1cf6e5197203dcf Mon Sep 17 00:00:00 2001
From: Yash Sangwan
Date: Sat, 29 Aug 2026 20:24:28 +0530
Subject: [PATCH 1/4] fix: let a failed download be dismissed instead of
needing a page reload
An errored download row had no way out: the store gave completed and cancelled
rows a linger but had no case for error, the top right card rendered its button
only while a transfer was running, and the bottom right panel never mapped
download to a remove action in either its rows or its close button.
---
.changeset/stuck-failed-download-rows.md | 31 ++++++++++++
.../ui/download-progress-manager.tsx | 46 ++++++++++++------
.../features/dashboard/hooks/use-download.ts | 9 +++-
.../stores/use-download-store.test.ts | 21 +++++++++
.../dashboard/stores/use-download-store.ts | 11 +++++
.../upload/components/operations-modal.tsx | 47 ++++++++++++-------
6 files changed, 133 insertions(+), 32 deletions(-)
create mode 100644 .changeset/stuck-failed-download-rows.md
diff --git a/.changeset/stuck-failed-download-rows.md b/.changeset/stuck-failed-download-rows.md
new file mode 100644
index 00000000..906185fe
--- /dev/null
+++ b/.changeset/stuck-failed-download-rows.md
@@ -0,0 +1,31 @@
+---
+'frontend': patch
+---
+
+Let a failed download be dismissed instead of needing a page reload
+
+A download that hit a network error wrote a row into the store that nothing
+could ever remove, so both panels showing it were stuck until the page was
+reloaded.
+
+Three things were missing. The store gave `completed` and `cancelled` rows a
+delay after which they clear themselves, but had no case for `error`. The
+download card in the top right rendered its button only while a transfer was
+running, so a failed row had no control on it at all. And the operations panel
+in the bottom right mapped `upload` and `delete` to their remove actions and
+never handled `download`, in both the row's own remove and the panel's close
+button, while the panel only hides once its list is empty. So the close button
+did nothing, however many times it was pressed.
+
+An error now clears itself after 8 seconds, longer than a cancel or a completion
+because it carries a reason worth reading, and the button on the card dismisses
+the row once there is nothing left to cancel. The panel's close button goes
+through the same remove action as the rows, so a kind of operation cannot be
+handled in one place and forgotten in the other.
+
+The three copies of "which statuses count as still running" are now one, and it
+knows about downloads. They each listed upload and delete statuses only, so a
+running download read as settled. That did not matter while the close button
+ignored downloads, but it would have the moment it stopped: the button would
+have dropped the row of a transfer still in flight rather than offering to
+cancel it.
diff --git a/frontend/src/features/dashboard/components/ui/download-progress-manager.tsx b/frontend/src/features/dashboard/components/ui/download-progress-manager.tsx
index 2e47a47d..b7d319fb 100644
--- a/frontend/src/features/dashboard/components/ui/download-progress-manager.tsx
+++ b/frontend/src/features/dashboard/components/ui/download-progress-manager.tsx
@@ -7,7 +7,7 @@ import { cn } from '@/shared/utils/utils';
import { AriaLabel } from '@/shared/components/custom-aria-label';
export const DownloadProgressManager: React.FC = () => {
- const { getAllDownloads, cancelDownload } = useDownloadList();
+ const { getAllDownloads, cancelDownload, removeDownload } = useDownloadList();
const downloads = getAllDownloads();
if (downloads.length === 0) return null;
@@ -19,6 +19,7 @@ export const DownloadProgressManager: React.FC = () => {
key={download.fileId}
download={download}
onCancel={() => cancelDownload(download.fileId)}
+ onDismiss={() => removeDownload(download.fileId)}
/>
))}
@@ -35,9 +36,14 @@ interface DownloadProgressItemProps {
queuePosition?: number;
};
onCancel: () => void;
+ onDismiss: () => void;
}
-const DownloadProgressItem: React.FC = ({ download, onCancel }) => {
+const DownloadProgressItem: React.FC = ({
+ download,
+ onCancel,
+ onDismiss,
+}) => {
const getStatusIcon = () => {
switch (download.status) {
case 'completed':
@@ -70,11 +76,17 @@ const DownloadProgressItem: React.FC = ({ download, o
}
};
- const shouldShowProgress =
+ /**
+ * Still moving, so the button aborts it. Otherwise the transfer is over one
+ * way or another and the button just clears the row away.
+ */
+ const isRunning =
download.status === 'downloading' ||
download.status === 'pending' ||
download.status === 'queued';
+ const shouldShowProgress = isRunning;
+
return (
= ({ download, o
)}
- {(download.status === 'downloading' ||
- download.status === 'pending' ||
- download.status === 'queued') && (
-
-
-
- )}
+ {/* Always offered. The button used to render only while a download was
+ running, so a row that failed had no control on it at all and the
+ only way to be rid of it was to reload the page. */}
+
+
+
);
diff --git a/frontend/src/features/dashboard/hooks/use-download.ts b/frontend/src/features/dashboard/hooks/use-download.ts
index 7e81a5f9..765429cd 100644
--- a/frontend/src/features/dashboard/hooks/use-download.ts
+++ b/frontend/src/features/dashboard/hooks/use-download.ts
@@ -109,6 +109,13 @@ export const useIsFileDownloading = (fileId: string): boolean =>
export const useDownloadList = () => {
const downloads = useDownloadStore((state) => state.downloads);
const { cancelDownload } = useDownloadActions();
+ /**
+ * Drops a settled row without touching the transfer, which is what a failed
+ * download needs: cancelling one is a no-op because there is nothing left in
+ * flight to abort, so the panels had no way to clear it. Selecting the action
+ * on its own is free - zustand action identities are stable.
+ */
+ const removeDownload = useDownloadStore((state) => state.removeDownload);
const downloadProgress = useMemo(() => Array.from(downloads.values()), [downloads]);
const getAllDownloads = useCallback(
@@ -116,5 +123,5 @@ export const useDownloadList = () => {
[downloadProgress]
);
- return { downloadProgress, getAllDownloads, cancelDownload };
+ return { downloadProgress, getAllDownloads, cancelDownload, removeDownload };
};
diff --git a/frontend/src/features/dashboard/stores/use-download-store.test.ts b/frontend/src/features/dashboard/stores/use-download-store.test.ts
index 8c08d0fc..e6a36316 100644
--- a/frontend/src/features/dashboard/stores/use-download-store.test.ts
+++ b/frontend/src/features/dashboard/stores/use-download-store.test.ts
@@ -204,6 +204,27 @@ describe('startDownload', () => {
expect(store().downloads.has('file-1')).toBe(false);
});
+ it('clears a failed download, and lets it linger longest', async () => {
+ vi.useFakeTimers();
+ const service = fakeService();
+ service.downloadFile.mockImplementation(async (_f, opts) => {
+ opts.onProgress(progress({ status: 'error', error: 'Network error' }));
+ });
+ createDownloadServiceMock.mockReturnValue(service as never);
+
+ await store().startDownload(apiA, file);
+
+ // A failure has a reason worth reading, so it outstays a cancel and a
+ // completion. It used to outstay the page: there was no linger for 'error'
+ // at all, and no button on either panel would remove the row by hand.
+ expect(store().downloads.has('file-1')).toBe(true);
+ vi.advanceTimersByTime(3000);
+ expect(store().downloads.has('file-1')).toBe(true);
+
+ vi.advanceTimersByTime(5000);
+ expect(store().downloads.has('file-1')).toBe(false);
+ });
+
it('does not schedule removal for ordinary progress updates', async () => {
vi.useFakeTimers();
const service = fakeService();
diff --git a/frontend/src/features/dashboard/stores/use-download-store.ts b/frontend/src/features/dashboard/stores/use-download-store.ts
index f8263b51..7e58c44a 100644
--- a/frontend/src/features/dashboard/stores/use-download-store.ts
+++ b/frontend/src/features/dashboard/stores/use-download-store.ts
@@ -22,6 +22,14 @@ import type { FileItem } from '../types/file';
/** How long a finished row lingers before it clears itself from the list. */
const COMPLETED_LINGER_MS = 3000;
const CANCELLED_LINGER_MS = 2000;
+/**
+ * Longer than the other two, because a failure carries a reason worth reading.
+ *
+ * It does still leave. Errors had no linger at all, so a download that hit a
+ * network problem stayed in the list until the page was reloaded, and neither
+ * panel showing it offered a way to dismiss it by hand either.
+ */
+const ERROR_LINGER_MS = 8000;
interface DownloadState {
downloads: Map;
@@ -86,6 +94,9 @@ export const useDownloadStore = create((set, get) => ({
if (progress.status === 'cancelled') {
setTimeout(() => removeDownload(file.id), CANCELLED_LINGER_MS);
}
+ if (progress.status === 'error') {
+ setTimeout(() => removeDownload(file.id), ERROR_LINGER_MS);
+ }
},
onComplete: (fileId) => {
setTimeout(() => removeDownload(fileId), COMPLETED_LINGER_MS);
diff --git a/frontend/src/features/upload/components/operations-modal.tsx b/frontend/src/features/upload/components/operations-modal.tsx
index 05921b8d..842bb23e 100644
--- a/frontend/src/features/upload/components/operations-modal.tsx
+++ b/frontend/src/features/upload/components/operations-modal.tsx
@@ -32,6 +32,20 @@ interface OperationItem {
queuePosition?: number;
}
+/**
+ * The statuses that mean an operation is still going, for every kind of it.
+ *
+ * Uploads report `uploading`, deletes `deleting`, downloads `downloading` or
+ * `pending`, and any of the three can sit in `queued`. The three places that
+ * asked this question each carried their own list of upload and delete statuses
+ * only, so a running download read as settled - which mattered once the close
+ * button below learned to remove downloads, because it would otherwise have
+ * dropped the row of a transfer that was still in flight.
+ */
+const ACTIVE_STATUSES = ['uploading', 'deleting', 'downloading', 'pending', 'queued'];
+
+const isActiveOperation = (op: { status: string }) => ACTIVE_STATUSES.includes(op.status);
+
// Upload speed tracking for better time estimation
interface SpeedTracker {
speeds: number[]; // bytes per second samples
@@ -155,7 +169,7 @@ export const OperationsModal: React.FC = () => {
// Only the oldest pending duplicate is shown; answering it reveals the next.
const currentDuplicate = duplicateQueue[0] ?? null;
- const { getAllDownloads, cancelDownload } = useDownloadList();
+ const { getAllDownloads, cancelDownload, removeDownload } = useDownloadList();
const downloads = getAllDownloads();
const [isExpanded, setIsExpanded] = useState(true);
const [hoveredItem, setHoveredItem] = useState(null);
@@ -244,9 +258,15 @@ export const OperationsModal: React.FC = () => {
removeUpload(itemId);
} else if (operationType === 'delete') {
removeDeleteOperation(itemId);
+ } else if (operationType === 'download') {
+ // The branch this was missing. A download that failed could not be
+ // removed by the row's own button or by the panel's close button, and
+ // the panel only hides once the list is empty - so it stayed up, and
+ // pressing the X did nothing at all, until the page was reloaded.
+ removeDownload(itemId);
}
},
- [removeUpload, removeDeleteOperation]
+ [removeUpload, removeDeleteOperation, removeDownload]
);
const pauseOperation = useCallback(
@@ -646,20 +666,15 @@ export const OperationsModal: React.FC = () => {
)}
{status === 'downloading' && (
+ // With the percentage, which is the one thing the separate download
+ // card said that this row did not. The ring beside it has always
+ // shown the same figure as a shape; this puts a number on it.
)}
{status === 'pending' && (
From 6bc44638e91da2d464455ee9228dd038e702fc7d Mon Sep 17 00:00:00 2001
From: Yash Sangwan
Date: Sat, 29 Aug 2026 21:01:44 +0530
Subject: [PATCH 3/4] fix: give a failed row a dismiss button, and stop a
linger clearing a retry
Review of the branch turned up two real gaps. The row drew its trailing
control for active, completed and cancelled operations only, so anything that
had failed fell through to nothing and had no button at all. And the linger
timers removed by file id unconditionally, so retrying inside the window
deleted the row of the transfer now running.
---
.changeset/download-progress-in-one-place.md | 41 +++++++++++++++++++
.changeset/one-place-for-download-progress.md | 23 -----------
.changeset/stuck-failed-download-rows.md | 31 --------------
.../features/dashboard/hooks/use-download.ts | 11 +++--
.../stores/use-download-store.test.ts | 22 ++++++++++
.../dashboard/stores/use-download-store.ts | 27 ++++++++++--
.../upload/components/operation-row.tsx | 8 +++-
7 files changed, 101 insertions(+), 62 deletions(-)
create mode 100644 .changeset/download-progress-in-one-place.md
delete mode 100644 .changeset/one-place-for-download-progress.md
delete mode 100644 .changeset/stuck-failed-download-rows.md
diff --git a/.changeset/download-progress-in-one-place.md b/.changeset/download-progress-in-one-place.md
new file mode 100644
index 00000000..1b31c361
--- /dev/null
+++ b/.changeset/download-progress-in-one-place.md
@@ -0,0 +1,41 @@
+---
+'frontend': patch
+---
+
+Report a download in one place, and let a failed one be dismissed
+
+A download that hit a network error left a row nothing could remove, so the
+panel showing it stayed up until the page was reloaded. The store gave
+`completed` and `cancelled` rows a delay after which they clear themselves but
+had no case for `error`; the operations panel mapped `upload` and `delete` to
+their remove actions and never handled `download`, in both the row's remove and
+the panel's close button; and the panel only hides once its list is empty. So
+the close button did nothing, however many times it was pressed.
+
+A failed row also had no button on it at all. The row drew its trailing control
+for active, then completed, then cancelled operations, and anything that had
+gone wrong fell past all three to nothing - so the one row a reader most wants
+rid of was the only one with nothing to press. That was true of failed uploads
+and deletes too, and is fixed for all three.
+
+An error now clears itself after 8 seconds, longer than a cancel or a completion
+because it carries a reason worth reading. Those timers check that the row is
+still the one they were set for before removing it: a retry reuses the file id,
+so a leftover timer would otherwise delete the row of the transfer now running,
+and the file would read as not downloading while it was being fetched.
+
+The same download was also announcing itself three times over: a toast, a card
+of its own in the top right, and a row on the operations card in the bottom
+right. The operations card is the one that stays, since it already carried every
+status the separate card did, down to the queue position and the reason for a
+failure. The separate card is gone, along with the toasts for starting,
+cancelling and failing. One toast is left for a download that throws before the
+service can record it, which leaves no row to read. The row now shows the
+percentage while downloading, the only thing the removed card said that it did
+not.
+
+The three copies of "which statuses count as still running" are now one, and it
+knows about downloads. Each listed upload and delete statuses only, so a running
+download read as settled - which would have mattered the moment the close button
+learned to remove downloads, since it would have dropped the row of a transfer
+still in flight rather than offering to cancel it.
diff --git a/.changeset/one-place-for-download-progress.md b/.changeset/one-place-for-download-progress.md
deleted file mode 100644
index 1b0e3957..00000000
--- a/.changeset/one-place-for-download-progress.md
+++ /dev/null
@@ -1,23 +0,0 @@
----
-'frontend': patch
----
-
-Report a download in one place instead of three
-
-A single download announced itself three times over: a toast, a card of its own
-in the top right corner, and a row on the operations card in the bottom right.
-All three said the same thing, and the two cards sat in opposite corners showing
-the same transfer at the same time.
-
-The operations card is the one that stays. It already listed downloads beside
-uploads and deletes, with the same file icon, the same progress ring drawn in
-its own blue, the same cancel on hover, and it already carried every status the
-separate card did, down to the queue position and the reason a download failed.
-
-So the separate download card is gone, and with it the toasts for starting,
-cancelling and failing. One toast is left, for a download that throws before the
-service can record it: there is no row on the card in that case, and staying
-quiet would lose the failure entirely.
-
-The row now shows the percentage while downloading, which is the only thing the
-removed card said that the row did not. Uploads keep the display they had.
diff --git a/.changeset/stuck-failed-download-rows.md b/.changeset/stuck-failed-download-rows.md
deleted file mode 100644
index 906185fe..00000000
--- a/.changeset/stuck-failed-download-rows.md
+++ /dev/null
@@ -1,31 +0,0 @@
----
-'frontend': patch
----
-
-Let a failed download be dismissed instead of needing a page reload
-
-A download that hit a network error wrote a row into the store that nothing
-could ever remove, so both panels showing it were stuck until the page was
-reloaded.
-
-Three things were missing. The store gave `completed` and `cancelled` rows a
-delay after which they clear themselves, but had no case for `error`. The
-download card in the top right rendered its button only while a transfer was
-running, so a failed row had no control on it at all. And the operations panel
-in the bottom right mapped `upload` and `delete` to their remove actions and
-never handled `download`, in both the row's own remove and the panel's close
-button, while the panel only hides once its list is empty. So the close button
-did nothing, however many times it was pressed.
-
-An error now clears itself after 8 seconds, longer than a cancel or a completion
-because it carries a reason worth reading, and the button on the card dismisses
-the row once there is nothing left to cancel. The panel's close button goes
-through the same remove action as the rows, so a kind of operation cannot be
-handled in one place and forgotten in the other.
-
-The three copies of "which statuses count as still running" are now one, and it
-knows about downloads. They each listed upload and delete statuses only, so a
-running download read as settled. That did not matter while the close button
-ignored downloads, but it would have the moment it stopped: the button would
-have dropped the row of a transfer still in flight rather than offering to
-cancel it.
diff --git a/frontend/src/features/dashboard/hooks/use-download.ts b/frontend/src/features/dashboard/hooks/use-download.ts
index f8940177..db9acdef 100644
--- a/frontend/src/features/dashboard/hooks/use-download.ts
+++ b/frontend/src/features/dashboard/hooks/use-download.ts
@@ -61,10 +61,13 @@ export const useDownloadActions = () => {
*
* Starting, cancelling and failing are all written to the operations
* card as they happen, so a toast saying the same thing was the same
- * news twice, in two corners of the screen at once. This branch is
- * different: it runs only if the download threw before the service
- * could record it, which means there is no row on the card to read and
- * staying quiet here would lose the failure altogether.
+ * news twice, in two corners of the screen at once.
+ *
+ * This branch is different, and rare: the service reports its own
+ * failures through `onProgress` and never rejects, so the only way here
+ * is something throwing before it takes over - building the service for
+ * a new provider, say. That leaves no row on the card to read, and it is
+ * the one failure that would otherwise pass in silence.
*/
showError(`Failed to download ${file.name}, ${error}`);
}
diff --git a/frontend/src/features/dashboard/stores/use-download-store.test.ts b/frontend/src/features/dashboard/stores/use-download-store.test.ts
index e6a36316..cc32c566 100644
--- a/frontend/src/features/dashboard/stores/use-download-store.test.ts
+++ b/frontend/src/features/dashboard/stores/use-download-store.test.ts
@@ -225,6 +225,28 @@ describe('startDownload', () => {
expect(store().downloads.has('file-1')).toBe(false);
});
+ it('does not let a settled row clear the retry that reuses its id', async () => {
+ vi.useFakeTimers();
+ const service = fakeService();
+ service.downloadFile.mockImplementation(async (_f, opts) => {
+ opts.onProgress(progress({ status: 'error', error: 'Network error' }));
+ });
+ createDownloadServiceMock.mockReturnValue(service as never);
+
+ await store().startDownload(apiA, file);
+
+ // Retried while the failed row is still on screen, which is exactly what
+ // eight seconds of linger invites the reader to do.
+ vi.advanceTimersByTime(2000);
+ store().setProgress(progress({ status: 'downloading', progress: 10 }));
+
+ // The timer left over from the failure must not take the live row with it:
+ // the panel would drop a transfer still in flight, and the file would read
+ // as not downloading while it was.
+ vi.advanceTimersByTime(10_000);
+ expect(store().downloads.get('file-1')?.status).toBe('downloading');
+ });
+
it('does not schedule removal for ordinary progress updates', async () => {
vi.useFakeTimers();
const service = fakeService();
diff --git a/frontend/src/features/dashboard/stores/use-download-store.ts b/frontend/src/features/dashboard/stores/use-download-store.ts
index 7e58c44a..b0d41413 100644
--- a/frontend/src/features/dashboard/stores/use-download-store.ts
+++ b/frontend/src/features/dashboard/stores/use-download-store.ts
@@ -88,18 +88,39 @@ export const useDownloadStore = create((set, get) => ({
startDownload: async (api, file, handlers) => {
const { getService, setProgress, removeDownload } = get();
+ /**
+ * Clears the row when its linger is up, unless it is no longer the row this
+ * timer was set for.
+ *
+ * A retry reuses the file id, so an unguarded timer left over from an
+ * attempt that already settled would delete the row of the one now running:
+ * the panel would drop a live transfer, `isDownloading` would go false for
+ * a file still being fetched, and starting it a third time would overwrite
+ * the abort controller of the second, leaving that one uncancellable. Easy
+ * to hit now that a failure lingers eight seconds and can be retried at
+ * once, but the shorter delays could always race the same way.
+ */
+ const clearWhenSettled = (
+ fileId: string,
+ settledAs: DownloadProgress['status'],
+ delay: number
+ ) =>
+ setTimeout(() => {
+ if (get().downloads.get(fileId)?.status === settledAs) removeDownload(fileId);
+ }, delay);
+
await getService(api).downloadFile(file, {
onProgress: (progress) => {
setProgress(progress);
if (progress.status === 'cancelled') {
- setTimeout(() => removeDownload(file.id), CANCELLED_LINGER_MS);
+ clearWhenSettled(file.id, 'cancelled', CANCELLED_LINGER_MS);
}
if (progress.status === 'error') {
- setTimeout(() => removeDownload(file.id), ERROR_LINGER_MS);
+ clearWhenSettled(file.id, 'error', ERROR_LINGER_MS);
}
},
onComplete: (fileId) => {
- setTimeout(() => removeDownload(fileId), COMPLETED_LINGER_MS);
+ clearWhenSettled(fileId, 'completed', COMPLETED_LINGER_MS);
},
onError: handlers?.onError,
});
diff --git a/frontend/src/features/upload/components/operation-row.tsx b/frontend/src/features/upload/components/operation-row.tsx
index 99f1ef47..45a6269e 100644
--- a/frontend/src/features/upload/components/operation-row.tsx
+++ b/frontend/src/features/upload/components/operation-row.tsx
@@ -378,7 +378,13 @@ const OperationRowInner: React.FC = ({
- ) : status === 'cancelled' ? (
+ ) : status === 'cancelled' || status === 'error' || status === 'failed' ? (
+ // A settled row that did not succeed, which until now meant no
+ // control at all: the chain ran active, then completed, then
+ // cancelled, and anything that had gone wrong fell past all three
+ // to null. So the one row a reader most wants rid of was the only
+ // one with nothing to press. ('failed' is what a delete reports;
+ // uploads and downloads say 'error'.)
onRemove(id, operationType)}
From f305ea498f99416906f617c890e992f32b6626a9 Mon Sep 17 00:00:00 2001
From: Yash Sangwan
Date: Sat, 29 Aug 2026 21:14:04 +0530
Subject: [PATCH 4/4] fix: let a settled download wait to be dismissed instead
of clearing itself
Downloads cleared themselves on a timer, which read as tidy until a failure
needed the same treatment: a reason that takes itself off the screen is no use
to anyone who was not looking, and the panel can be collapsed. Uploads and
deletes have always waited to be dismissed, and every settled row now has a
button on it, so downloads match them.
---
.changeset/download-progress-in-one-place.md | 11 +-
.../stores/use-download-store.test.ts | 106 +++++-------------
.../dashboard/stores/use-download-store.ts | 58 ++--------
3 files changed, 46 insertions(+), 129 deletions(-)
diff --git a/.changeset/download-progress-in-one-place.md b/.changeset/download-progress-in-one-place.md
index 1b31c361..ecafbe03 100644
--- a/.changeset/download-progress-in-one-place.md
+++ b/.changeset/download-progress-in-one-place.md
@@ -18,11 +18,12 @@ gone wrong fell past all three to nothing - so the one row a reader most wants
rid of was the only one with nothing to press. That was true of failed uploads
and deletes too, and is fixed for all three.
-An error now clears itself after 8 seconds, longer than a cancel or a completion
-because it carries a reason worth reading. Those timers check that the row is
-still the one they were set for before removing it: a retry reuses the file id,
-so a leftover timer would otherwise delete the row of the transfer now running,
-and the file would read as not downloading while it was being fetched.
+Settled downloads now wait to be dismissed instead of clearing themselves. They
+used to go on a timer, three seconds for a completion and two for a cancel,
+which read as tidy until a failure needed the same treatment: a reason for a
+failure that takes itself off the screen is no use to anyone who was not looking
+at that moment, and the panel can be collapsed. Uploads and deletes have always
+waited to be dismissed, and downloads now match them.
The same download was also announcing itself three times over: a toast, a card
of its own in the top right, and a row on the operations card in the bottom
diff --git a/frontend/src/features/dashboard/stores/use-download-store.test.ts b/frontend/src/features/dashboard/stores/use-download-store.test.ts
index cc32c566..a2f21de2 100644
--- a/frontend/src/features/dashboard/stores/use-download-store.test.ts
+++ b/frontend/src/features/dashboard/stores/use-download-store.test.ts
@@ -172,82 +172,33 @@ describe('startDownload', () => {
expect(store().downloads.get('file-1')!.progress).toBe(25);
});
- it('clears a completed download after the linger delay', async () => {
- vi.useFakeTimers();
- const service = fakeService();
- service.downloadFile.mockImplementation(async (_f, opts) => {
- opts.onComplete('file-1');
- });
- createDownloadServiceMock.mockReturnValue(service as never);
-
- store().setProgress(progress({ status: 'completed', progress: 100 }));
- await store().startDownload(apiA, file);
-
- // The row stays briefly so the user sees it finish.
- expect(store().downloads.has('file-1')).toBe(true);
- vi.advanceTimersByTime(3000);
- expect(store().downloads.has('file-1')).toBe(false);
- });
-
- it('clears a cancelled download sooner than a completed one', async () => {
- vi.useFakeTimers();
- const service = fakeService();
- service.downloadFile.mockImplementation(async (_f, opts) => {
- opts.onProgress(progress({ status: 'cancelled' }));
- });
- createDownloadServiceMock.mockReturnValue(service as never);
-
- await store().startDownload(apiA, file);
-
- expect(store().downloads.has('file-1')).toBe(true);
- vi.advanceTimersByTime(2000);
- expect(store().downloads.has('file-1')).toBe(false);
- });
-
- it('clears a failed download, and lets it linger longest', async () => {
- vi.useFakeTimers();
- const service = fakeService();
- service.downloadFile.mockImplementation(async (_f, opts) => {
- opts.onProgress(progress({ status: 'error', error: 'Network error' }));
- });
- createDownloadServiceMock.mockReturnValue(service as never);
-
- await store().startDownload(apiA, file);
-
- // A failure has a reason worth reading, so it outstays a cancel and a
- // completion. It used to outstay the page: there was no linger for 'error'
- // at all, and no button on either panel would remove the row by hand.
- expect(store().downloads.has('file-1')).toBe(true);
- vi.advanceTimersByTime(3000);
- expect(store().downloads.has('file-1')).toBe(true);
-
- vi.advanceTimersByTime(5000);
- expect(store().downloads.has('file-1')).toBe(false);
- });
-
- it('does not let a settled row clear the retry that reuses its id', async () => {
- vi.useFakeTimers();
- const service = fakeService();
- service.downloadFile.mockImplementation(async (_f, opts) => {
- opts.onProgress(progress({ status: 'error', error: 'Network error' }));
- });
- createDownloadServiceMock.mockReturnValue(service as never);
-
- await store().startDownload(apiA, file);
-
- // Retried while the failed row is still on screen, which is exactly what
- // eight seconds of linger invites the reader to do.
- vi.advanceTimersByTime(2000);
- store().setProgress(progress({ status: 'downloading', progress: 10 }));
-
- // The timer left over from the failure must not take the live row with it:
- // the panel would drop a transfer still in flight, and the file would read
- // as not downloading while it was.
- vi.advanceTimersByTime(10_000);
- expect(store().downloads.get('file-1')?.status).toBe('downloading');
- });
-
- it('does not schedule removal for ordinary progress updates', async () => {
+ it.each(['completed', 'cancelled', 'error'] as const)(
+ 'keeps a %s download on the list until it is removed',
+ async (status) => {
+ vi.useFakeTimers();
+ const service = fakeService();
+ service.downloadFile.mockImplementation(async (_f, opts) => {
+ opts.onProgress(
+ progress({ status, error: status === 'error' ? 'Network error' : undefined })
+ );
+ });
+ createDownloadServiceMock.mockReturnValue(service as never);
+
+ await store().startDownload(apiA, file);
+
+ // Nothing takes it away on a timer. A row that removes itself is a
+ // reason for a failure that disappears before anyone reads it, and the
+ // panel it sits on can be collapsed at the time. Uploads and deletes
+ // have always waited to be dismissed.
+ vi.advanceTimersByTime(60_000);
+ expect(store().downloads.has('file-1')).toBe(true);
+
+ store().removeDownload('file-1');
+ expect(store().downloads.has('file-1')).toBe(false);
+ }
+ );
+
+ it('leaves an in-flight download on the list', async () => {
vi.useFakeTimers();
const service = fakeService();
service.downloadFile.mockImplementation(async (_f, opts) => {
@@ -256,9 +207,8 @@ describe('startDownload', () => {
createDownloadServiceMock.mockReturnValue(service as never);
await store().startDownload(apiA, file);
- vi.advanceTimersByTime(10_000);
+ vi.advanceTimersByTime(60_000);
- // An in-flight download must never disappear from the list on a timer.
expect(store().downloads.has('file-1')).toBe(true);
});
diff --git a/frontend/src/features/dashboard/stores/use-download-store.ts b/frontend/src/features/dashboard/stores/use-download-store.ts
index b0d41413..46124a93 100644
--- a/frontend/src/features/dashboard/stores/use-download-store.ts
+++ b/frontend/src/features/dashboard/stores/use-download-store.ts
@@ -19,18 +19,6 @@ import {
} from '../services/download-service';
import type { FileItem } from '../types/file';
-/** How long a finished row lingers before it clears itself from the list. */
-const COMPLETED_LINGER_MS = 3000;
-const CANCELLED_LINGER_MS = 2000;
-/**
- * Longer than the other two, because a failure carries a reason worth reading.
- *
- * It does still leave. Errors had no linger at all, so a download that hit a
- * network problem stayed in the list until the page was reloaded, and neither
- * panel showing it offered a way to dismiss it by hand either.
- */
-const ERROR_LINGER_MS = 8000;
-
interface DownloadState {
downloads: Map;
/**
@@ -85,43 +73,21 @@ export const useDownloadStore = create((set, get) => ({
return { downloads };
}),
+ /**
+ * A settled row stays until someone removes it.
+ *
+ * Downloads used to clear themselves on a timer - three seconds for a
+ * completion, two for a cancel - which read as tidy until a failure needed
+ * the same treatment. A reason for the failure that takes itself off the
+ * screen is no use to anyone who was not looking at that moment, and the
+ * panel can be collapsed. Uploads and deletes have always waited to be
+ * dismissed; downloads now do too, and every settled row has a button on it.
+ */
startDownload: async (api, file, handlers) => {
- const { getService, setProgress, removeDownload } = get();
-
- /**
- * Clears the row when its linger is up, unless it is no longer the row this
- * timer was set for.
- *
- * A retry reuses the file id, so an unguarded timer left over from an
- * attempt that already settled would delete the row of the one now running:
- * the panel would drop a live transfer, `isDownloading` would go false for
- * a file still being fetched, and starting it a third time would overwrite
- * the abort controller of the second, leaving that one uncancellable. Easy
- * to hit now that a failure lingers eight seconds and can be retried at
- * once, but the shorter delays could always race the same way.
- */
- const clearWhenSettled = (
- fileId: string,
- settledAs: DownloadProgress['status'],
- delay: number
- ) =>
- setTimeout(() => {
- if (get().downloads.get(fileId)?.status === settledAs) removeDownload(fileId);
- }, delay);
+ const { getService, setProgress } = get();
await getService(api).downloadFile(file, {
- onProgress: (progress) => {
- setProgress(progress);
- if (progress.status === 'cancelled') {
- clearWhenSettled(file.id, 'cancelled', CANCELLED_LINGER_MS);
- }
- if (progress.status === 'error') {
- clearWhenSettled(file.id, 'error', ERROR_LINGER_MS);
- }
- },
- onComplete: (fileId) => {
- clearWhenSettled(fileId, 'completed', COMPLETED_LINGER_MS);
- },
+ onProgress: setProgress,
onError: handlers?.onError,
});
},