From 84dfc319c315326b1d0f7f21af2b092eb307050f Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 1 May 2026 08:17:21 +0000
Subject: [PATCH 1/8] Initial plan
From a17b0af52542ffb627161c5f1af7031ff012abfa Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 1 May 2026 08:21:54 +0000
Subject: [PATCH 2/8] Initial plan for multi-download grouping
Agent-Logs-Url: https://github.com/AllenInstitute/biofile-finder/sessions/19d8fc8b-c988-4eaa-a37a-00f8ff4bf75e
Co-authored-by: SeanDuHare <41307451+SeanDuHare@users.noreply.github.com>
---
package-lock.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/package-lock.json b/package-lock.json
index 2bff5ad0a..1494be715 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -21469,7 +21469,7 @@
},
"packages/desktop": {
"name": "fms-file-explorer-desktop",
- "version": "8.9.1",
+ "version": "8.9.3",
"license": "SEE LICENSE IN LICENSE.txt",
"dependencies": {
"@aics/frontend-insights": "0.2.x",
From e7cd4399b29e679aea6c0769cb33c9461b7dfd4f Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 1 May 2026 08:27:31 +0000
Subject: [PATCH 3/8] Enable multi-download: group downloads into one status
notification with Show more toggle
Agent-Logs-Url: https://github.com/AllenInstitute/biofile-finder/sessions/19d8fc8b-c988-4eaa-a37a-00f8ff4bf75e
Co-authored-by: SeanDuHare <41307451+SeanDuHare@users.noreply.github.com>
---
.../StatusMessage/StatusMessage.module.css | 24 +++
.../core/components/StatusMessage/index.tsx | 47 ++++-
packages/core/state/interaction/actions.ts | 9 +-
packages/core/state/interaction/logics.ts | 167 +++++++++++-------
.../state/interaction/test/logics.test.ts | 25 ++-
5 files changed, 190 insertions(+), 82 deletions(-)
diff --git a/packages/core/components/StatusMessage/StatusMessage.module.css b/packages/core/components/StatusMessage/StatusMessage.module.css
index 3376d5d85..1e06713d8 100644
--- a/packages/core/components/StatusMessage/StatusMessage.module.css
+++ b/packages/core/components/StatusMessage/StatusMessage.module.css
@@ -101,3 +101,27 @@
height: 3px;
background-color: var(--info-status-text-color);
}
+
+.file-list {
+ margin: 4px 0 2px 0;
+ padding-left: 18px;
+ font-size: var(--s-paragraph-size);
+ list-style-type: disc;
+}
+
+.file-list li {
+ line-height: 1.4;
+ word-break: break-word;
+ overflow-wrap: break-word;
+}
+
+.view-more-button {
+ background: none;
+ border: none;
+ padding: 0;
+ cursor: pointer;
+ font-size: var(--s-paragraph-size);
+ color: inherit;
+ text-decoration: underline;
+ text-decoration-thickness: 1px;
+}
diff --git a/packages/core/components/StatusMessage/index.tsx b/packages/core/components/StatusMessage/index.tsx
index 869fa0415..eef37be1b 100644
--- a/packages/core/components/StatusMessage/index.tsx
+++ b/packages/core/components/StatusMessage/index.tsx
@@ -32,6 +32,48 @@ const verticalStackProps = {
},
};
+const MAX_FILES_SHOWN = 3;
+
+interface FileListProps {
+ fileNames: string[];
+}
+
+/**
+ * Renders a truncated list of file names with a "Show more / Show less" toggle.
+ */
+function FileList({ fileNames }: FileListProps) {
+ const [expanded, setExpanded] = React.useState(false);
+
+ if (fileNames.length <= MAX_FILES_SHOWN) {
+ return (
+
+ {fileNames.map((name, index) => (
+ - {name}
+ ))}
+
+ );
+ }
+
+ const visibleNames = expanded ? fileNames : fileNames.slice(0, MAX_FILES_SHOWN);
+ const hiddenCount = fileNames.length - MAX_FILES_SHOWN;
+
+ return (
+ <>
+
+ {visibleNames.map((name, index) => (
+ - {name}
+ ))}
+
+
+ >
+ );
+}
+
/**
* Pop-up banners that display status messages of processes, such as the download of a CSV manifest. They
* stack vertically at the top of the window.
@@ -45,7 +87,7 @@ export default function StatusMessage() {
useSelector(interaction.selectors.getProcessStatuses),
(statusUpdate: StatusUpdate) => {
const {
- data: { msg, status = ProcessStatus.NOT_SET, progress },
+ data: { msg, status = ProcessStatus.NOT_SET, progress, fileNames },
onCancel,
} = statusUpdate;
let onDismiss; // If has cancel option, don't show dismiss button
@@ -88,6 +130,9 @@ export default function StatusMessage() {
style={{ userSelect: "text" }}
>
+ {fileNames && fileNames.length > 0 && (
+
+ )}
{progress !== undefined && (
void,
- fileId?: string[]
+ fileId?: string[],
+ fileNames?: string[]
): ProcessStartAction {
return {
type: SET_STATUS,
payload: {
data: {
fileId,
+ fileNames,
msg,
status: ProcessStatus.STARTED,
},
@@ -497,13 +500,15 @@ export function processProgress(
progress: number,
msg: string,
onCancel: () => void,
- fileId?: string[]
+ fileId?: string[],
+ fileNames?: string[]
): ProcessProgressAction {
return {
type: SET_STATUS,
payload: {
data: {
fileId,
+ fileNames,
msg,
status: ProcessStatus.PROGRESS,
progress,
diff --git a/packages/core/state/interaction/logics.ts b/packages/core/state/interaction/logics.ts
index 2df1660eb..38c20831e 100644
--- a/packages/core/state/interaction/logics.ts
+++ b/packages/core/state/interaction/logics.ts
@@ -322,81 +322,118 @@ const downloadFilesLogic = createLogic({
const totalBytesToDownload = sumBy(filesToDownload, "size") || 0;
const totalBytesDisplay = getBytesDisplay(totalBytesToDownload, someFilesHaveUnknownSize);
- // TODO: Download these into a zip using new streamsaver zipped code
- await Promise.allSettled(
- filesToDownload.map(async (file) => {
- let isCancelled = false;
- const downloadRequestId = uniqueId();
+ const allFileIds = filesToDownload.map((f) => f.id);
+ const allFileNames = filesToDownload.map((f) => f.name);
+ const fileWord = filesToDownload.length === 1 ? "file" : "files";
+
+ // Use a single process ID for the entire batch of downloads
+ const groupProcessId = uniqueId();
+ // Map each file to its own download request ID (used by the download service)
+ const downloadRequestIds = filesToDownload.map(() => uniqueId());
+
+ let groupCancelled = false;
+ const onGroupCancel = () => {
+ groupCancelled = true;
+ downloadRequestIds.forEach((id) => dispatch(cancelFileDownload(id)));
+ };
- const onCancel = () => {
- isCancelled = true;
- dispatch(cancelFileDownload(downloadRequestId));
- };
+ // Track overall bytes downloaded across all files
+ let groupTotalBytesDownloaded = 0;
+ const throttledGroupProgressDispatcher = throttle(() => {
+ if (groupCancelled) return;
+ const updatedBytesDisplay = numberFormatter.displayValue(
+ groupTotalBytesDownloaded,
+ "bytes"
+ );
+ const progressMsg = `Downloading ${filesToDownload.length} ${fileWord}.
${updatedBytesDisplay} out of ${totalBytesDisplay} set to download`;
+ dispatch(
+ processProgress(
+ groupProcessId,
+ totalBytesToDownload ? groupTotalBytesDownloaded / totalBytesToDownload : 0,
+ progressMsg,
+ onGroupCancel,
+ allFileIds,
+ allFileNames
+ )
+ );
+ }, 1000);
- // A function that dispatches progress events, throttled
- // to only be invokable at most once/second
- let totalBytesDownloaded = 0;
- const throttledProgressDispatcher = throttle((progressMsg: string) => {
- if (isCancelled) return;
- dispatch(
- processProgress(
- downloadRequestId,
- totalBytesToDownload ? totalBytesDownloaded / totalBytesToDownload : 0,
- progressMsg,
- onCancel,
- [file.id]
- )
- );
- }, 1000);
+ // TODO: Download these into a zip using new streamsaver zipped code
+ if (!someFilesHaveUnknownSize) {
+ const msg = `Downloading ${filesToDownload.length} ${fileWord}.
${totalBytesDisplay} set to download`;
+ dispatch(processStart(groupProcessId, msg, onGroupCancel, allFileIds, allFileNames));
+ }
- const onProgress = (transferredBytes: number) => {
- totalBytesDownloaded += transferredBytes;
+ const settledResults = await Promise.allSettled(
+ filesToDownload.map(async (file, index) => {
+ const downloadRequestId = downloadRequestIds[index];
- // Generate new message
- const updatedBytesDisplay = numberFormatter.displayValue(
- totalBytesDownloaded,
- "bytes"
- );
- const progressMsg = `Downloading ${file.name}.
${updatedBytesDisplay} out of ${totalBytesDisplay} set to download`;
- throttledProgressDispatcher(progressMsg);
+ const onProgress = (transferredBytes: number) => {
+ groupTotalBytesDownloaded += transferredBytes;
+ throttledGroupProgressDispatcher();
};
- try {
- // Start the download and handle progress reporting
- if (!someFilesHaveUnknownSize) {
- const fileByteDisplay = getBytesDisplay(file.size || 0);
- const msg = `Downloading ${file.name}.
${fileByteDisplay} out of ${totalBytesDisplay} set to download`;
- dispatch(processStart(downloadRequestId, msg, onCancel, [file.id]));
- }
-
- const result = await fileDownloadService.download(
- file,
- downloadRequestId,
- onProgress
- );
+ const result = await fileDownloadService.download(
+ file,
+ downloadRequestId,
+ onProgress
+ );
- if (!someFilesHaveUnknownSize) {
- if (result.resolution === DownloadResolution.CANCELLED) {
- onCancel();
- } else {
- // This gets sent before some large files are complete
- dispatch(
- processSuccess(
- downloadRequestId,
- result.msg || "Download started successfully."
- )
- );
- }
- }
- } catch (err) {
- const errorMsg = `File download failed for file ${file.name}. Details:
${
- err instanceof Error ? err.message : err
- }`;
- dispatch(processError(downloadRequestId, errorMsg));
- }
+ return { file, result };
})
);
+ if (!someFilesHaveUnknownSize) {
+ if (groupCancelled) {
+ dispatch(removeStatus(groupProcessId));
+ } else {
+ const failed = settledResults.filter(
+ (r) =>
+ r.status === "rejected" ||
+ (r.status === "fulfilled" &&
+ r.value.result.resolution !== DownloadResolution.SUCCESS &&
+ r.value.result.resolution !== DownloadResolution.CANCELLED)
+ );
+ const cancelled = settledResults.filter(
+ (r) =>
+ r.status === "fulfilled" &&
+ r.value.result.resolution === DownloadResolution.CANCELLED
+ );
+
+ if (failed.length > 0) {
+ const failedNames = failed.map((r) =>
+ r.status === "fulfilled" ? r.value.file.name : "unknown"
+ );
+ const errorMsg =
+ filesToDownload.length === 1
+ ? `File download failed for file ${allFileNames[0]}. Details:
${
+ failed[0].status === "rejected"
+ ? failed[0].reason instanceof Error
+ ? failed[0].reason.message
+ : failed[0].reason
+ : "Unknown error"
+ }`
+ : `Download failed for ${failed.length} of ${filesToDownload.length} ${fileWord}:
${failedNames.join(", ")}`;
+ dispatch(processError(groupProcessId, errorMsg));
+ } else if (cancelled.length === filesToDownload.length) {
+ // All cancelled
+ dispatch(removeStatus(groupProcessId));
+ } else {
+ const succeededCount = filesToDownload.length - cancelled.length;
+ const successWord = succeededCount === 1 ? "file" : "files";
+ const firstResult =
+ settledResults[0].status === "fulfilled"
+ ? settledResults[0].value.result
+ : undefined;
+ const msg =
+ filesToDownload.length === 1
+ ? firstResult?.msg || "Download started successfully."
+ : `Successfully downloaded ${succeededCount} ${successWord}.`;
+ dispatch(processSuccess(groupProcessId, msg));
+ }
+ }
+ }
+
done();
},
});
diff --git a/packages/core/state/interaction/test/logics.test.ts b/packages/core/state/interaction/test/logics.test.ts
index a99c2a8f9..53f0cfadb 100644
--- a/packages/core/state/interaction/test/logics.test.ts
+++ b/packages/core/state/interaction/test/logics.test.ts
@@ -321,7 +321,7 @@ describe("Interaction logics", () => {
).to.equal(true);
});
- it("downloads multiple files", async () => {
+ it("downloads multiple files grouped into one status notification", async () => {
// Arrange
const state = mergeState(initialState, {
interaction: {
@@ -361,25 +361,22 @@ describe("Interaction logics", () => {
store.dispatch(downloadFiles([file1, file2]));
await logicMiddleware.whenComplete();
- // Assert
- expect(
- actions.includesMatch({
- type: SET_STATUS,
- payload: {
- data: {
- status: ProcessStatus.STARTED,
- fileId: [file1.id],
- },
- },
- })
- ).to.be.true;
+ // Assert: only one STARTED status for the entire batch (not one per file)
+ const startedActions = actions.list.filter(
+ (a: { type: string; payload?: { data?: { status?: ProcessStatus } } }) =>
+ a.type === SET_STATUS && a.payload?.data?.status === ProcessStatus.STARTED
+ );
+ expect(startedActions).to.have.length(1);
+
+ // The single status update should reference both files
expect(
actions.includesMatch({
type: SET_STATUS,
payload: {
data: {
status: ProcessStatus.STARTED,
- fileId: [file2.id],
+ fileId: [file1.id, file2.id],
+ fileNames: [file1.name, file2.name],
},
},
})
From 8370cc07eacb9d1bec49c0292ac66e57bbf4bfcb Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 1 May 2026 21:02:20 +0000
Subject: [PATCH 4/8] Refactor: use msg/fullMsg pattern instead of fileNames
array for grouped download notifications
Agent-Logs-Url: https://github.com/AllenInstitute/biofile-finder/sessions/32fbf7b9-d2fc-43cb-b23a-25058fee49e9
Co-authored-by: SeanDuHare <41307451+SeanDuHare@users.noreply.github.com>
---
.../StatusMessage/StatusMessage.module.css | 23 +++---
.../core/components/StatusMessage/index.tsx | 72 +++++++------------
packages/core/state/interaction/actions.ts | 13 ++--
packages/core/state/interaction/logics.ts | 38 ++++++++--
.../state/interaction/test/logics.test.ts | 1 -
5 files changed, 75 insertions(+), 72 deletions(-)
diff --git a/packages/core/components/StatusMessage/StatusMessage.module.css b/packages/core/components/StatusMessage/StatusMessage.module.css
index 1e06713d8..682dac027 100644
--- a/packages/core/components/StatusMessage/StatusMessage.module.css
+++ b/packages/core/components/StatusMessage/StatusMessage.module.css
@@ -102,26 +102,23 @@
background-color: var(--info-status-text-color);
}
-.file-list {
- margin: 4px 0 2px 0;
- padding-left: 18px;
- font-size: var(--s-paragraph-size);
- list-style-type: disc;
-}
-
-.file-list li {
- line-height: 1.4;
- word-break: break-word;
- overflow-wrap: break-word;
+.view-more-container {
+ display: flex;
+ justify-content: right;
+ margin-top: 8px;
}
.view-more-button {
background: none;
border: none;
- padding: 0;
+ color: inherit;
cursor: pointer;
font-size: var(--s-paragraph-size);
- color: inherit;
+ padding: 4px 0;
text-decoration: underline;
text-decoration-thickness: 1px;
}
+
+.view-more-button:hover {
+ opacity: 0.8;
+}
diff --git a/packages/core/components/StatusMessage/index.tsx b/packages/core/components/StatusMessage/index.tsx
index eef37be1b..07c6b74e9 100644
--- a/packages/core/components/StatusMessage/index.tsx
+++ b/packages/core/components/StatusMessage/index.tsx
@@ -32,54 +32,25 @@ const verticalStackProps = {
},
};
-const MAX_FILES_SHOWN = 3;
-
-interface FileListProps {
- fileNames: string[];
-}
-
-/**
- * Renders a truncated list of file names with a "Show more / Show less" toggle.
- */
-function FileList({ fileNames }: FileListProps) {
- const [expanded, setExpanded] = React.useState(false);
-
- if (fileNames.length <= MAX_FILES_SHOWN) {
- return (
-
- {fileNames.map((name, index) => (
- - {name}
- ))}
-
- );
- }
-
- const visibleNames = expanded ? fileNames : fileNames.slice(0, MAX_FILES_SHOWN);
- const hiddenCount = fileNames.length - MAX_FILES_SHOWN;
-
- return (
- <>
-
- {visibleNames.map((name, index) => (
- - {name}
- ))}
-
-
- >
- );
-}
-
/**
* Pop-up banners that display status messages of processes, such as the download of a CSV manifest. They
* stack vertically at the top of the window.
*/
export default function StatusMessage() {
const dispatch = useDispatch();
+ const [expandedIds, setExpandedIds] = React.useState>(new Set());
+
+ const toggleExpanded = (processId: string) => {
+ setExpandedIds((prev) => {
+ const next = new Set(prev);
+ if (next.has(processId)) {
+ next.delete(processId);
+ } else {
+ next.add(processId);
+ }
+ return next;
+ });
+ };
return (
@@ -87,9 +58,11 @@ export default function StatusMessage() {
useSelector(interaction.selectors.getProcessStatuses),
(statusUpdate: StatusUpdate) => {
const {
- data: { msg, status = ProcessStatus.NOT_SET, progress, fileNames },
+ data: { msg, fullMsg, status = ProcessStatus.NOT_SET, progress },
onCancel,
} = statusUpdate;
+ const isExpanded = expandedIds.has(statusUpdate.processId);
+ const displayMsg = isExpanded && fullMsg ? fullMsg : msg;
let onDismiss; // If has cancel option, don't show dismiss button
let cancelButton;
if (onCancel) {
@@ -126,12 +99,19 @@ export default function StatusMessage() {
)}
- {fileNames && fileNames.length > 0 && (
-
+ {fullMsg && (
+
+
+
)}
{progress !== undefined && (
void,
fileId?: string[],
- fileNames?: string[]
+ fullMsg?: string
): ProcessStartAction {
return {
type: SET_STATUS,
payload: {
data: {
fileId,
- fileNames,
+ fullMsg,
msg,
status: ProcessStatus.STARTED,
},
@@ -501,14 +501,14 @@ export function processProgress(
msg: string,
onCancel: () => void,
fileId?: string[],
- fileNames?: string[]
+ fullMsg?: string
): ProcessProgressAction {
return {
type: SET_STATUS,
payload: {
data: {
fileId,
- fileNames,
+ fullMsg,
msg,
status: ProcessStatus.PROGRESS,
progress,
@@ -553,12 +553,13 @@ export interface ProcessErrorAction {
payload: StatusUpdate;
}
-export function processError(processId: string, msg: string): ProcessErrorAction {
+export function processError(processId: string, msg: string, fullMsg?: string): ProcessErrorAction {
return {
type: SET_STATUS,
payload: {
data: {
msg,
+ fullMsg,
status: ProcessStatus.ERROR,
},
processId,
diff --git a/packages/core/state/interaction/logics.ts b/packages/core/state/interaction/logics.ts
index 38c20831e..a0e9f2d54 100644
--- a/packages/core/state/interaction/logics.ts
+++ b/packages/core/state/interaction/logics.ts
@@ -69,6 +69,8 @@ import { fetchWithTimeout } from "../../hooks/useRemoteFileUpload";
export const DEFAULT_QUERY_NAME = "New Query";
+const MAX_FILES_IN_MSG = 3;
+
/**
* Function for creating a message to display representing the total bytes to download
*/
@@ -326,6 +328,22 @@ const downloadFilesLogic = createLogic({
const allFileNames = filesToDownload.map((f) => f.name);
const fileWord = filesToDownload.length === 1 ? "file" : "files";
+ /**
+ * Build a file list HTML snippet, showing up to `limit` names.
+ * Returns undefined when there is only one file (name is already in the header).
+ */
+ const buildFileListHtml = (names: string[]) => {
+ if (names.length <= 1) return undefined;
+ return names.map((n) => `• ${n}`).join("
");
+ };
+
+ const truncatedFileListHtml =
+ allFileNames.length > 1
+ ? buildFileListHtml(allFileNames.slice(0, MAX_FILES_IN_MSG))
+ : undefined;
+ const fullFileListHtml =
+ allFileNames.length > MAX_FILES_IN_MSG ? buildFileListHtml(allFileNames) : undefined;
+
// Use a single process ID for the entire batch of downloads
const groupProcessId = uniqueId();
// Map each file to its own download request ID (used by the download service)
@@ -352,16 +370,20 @@ const downloadFilesLogic = createLogic({
totalBytesToDownload ? groupTotalBytesDownloaded / totalBytesToDownload : 0,
progressMsg,
onGroupCancel,
- allFileIds,
- allFileNames
+ allFileIds
)
);
}, 1000);
// TODO: Download these into a zip using new streamsaver zipped code
if (!someFilesHaveUnknownSize) {
- const msg = `Downloading ${filesToDownload.length} ${fileWord}.
${totalBytesDisplay} set to download`;
- dispatch(processStart(groupProcessId, msg, onGroupCancel, allFileIds, allFileNames));
+ const header =
+ filesToDownload.length === 1
+ ? `Downloading ${allFileNames[0]}.
${totalBytesDisplay} set to download`
+ : `Downloading ${filesToDownload.length} ${fileWord}.
${totalBytesDisplay} set to download`;
+ const msg = truncatedFileListHtml ? `${header}
${truncatedFileListHtml}` : header;
+ const fullMsg = fullFileListHtml ? `${header}
${fullFileListHtml}` : undefined;
+ dispatch(processStart(groupProcessId, msg, onGroupCancel, allFileIds, fullMsg));
}
const settledResults = await Promise.allSettled(
@@ -413,8 +435,12 @@ const downloadFilesLogic = createLogic({
: failed[0].reason
: "Unknown error"
}`
- : `Download failed for ${failed.length} of ${filesToDownload.length} ${fileWord}:
${failedNames.join(", ")}`;
- dispatch(processError(groupProcessId, errorMsg));
+ : `Download failed for ${failed.length} of ${filesToDownload.length} ${fileWord}:
${failedNames.slice(0, MAX_FILES_IN_MSG).join(", ")}`;
+ const errorFullMsg =
+ failedNames.length > MAX_FILES_IN_MSG
+ ? `Download failed for ${failed.length} of ${filesToDownload.length} ${fileWord}:
${failedNames.join(", ")}`
+ : undefined;
+ dispatch(processError(groupProcessId, errorMsg, errorFullMsg));
} else if (cancelled.length === filesToDownload.length) {
// All cancelled
dispatch(removeStatus(groupProcessId));
diff --git a/packages/core/state/interaction/test/logics.test.ts b/packages/core/state/interaction/test/logics.test.ts
index 53f0cfadb..50196eab2 100644
--- a/packages/core/state/interaction/test/logics.test.ts
+++ b/packages/core/state/interaction/test/logics.test.ts
@@ -376,7 +376,6 @@ describe("Interaction logics", () => {
data: {
status: ProcessStatus.STARTED,
fileId: [file1.id, file2.id],
- fileNames: [file1.name, file2.name],
},
},
})
From 63f868b207ebd754acb19f51c816c10f09e889f4 Mon Sep 17 00:00:00 2001
From: SeanLeRoy
Date: Mon, 4 May 2026 10:23:03 -0700
Subject: [PATCH 5/8] Remove duplicate style
---
.../StatusMessage/StatusMessage.module.css | 15 ---------------
1 file changed, 15 deletions(-)
diff --git a/packages/core/components/StatusMessage/StatusMessage.module.css b/packages/core/components/StatusMessage/StatusMessage.module.css
index 5888c9692..43db02b13 100644
--- a/packages/core/components/StatusMessage/StatusMessage.module.css
+++ b/packages/core/components/StatusMessage/StatusMessage.module.css
@@ -128,18 +128,3 @@
justify-content: right;
margin-top: 8px;
}
-
-.view-more-button {
- background: none;
- border: none;
- color: inherit;
- cursor: pointer;
- font-size: var(--s-paragraph-size);
- padding: 4px 0;
- text-decoration: underline;
- text-decoration-thickness: 1px;
-}
-
-.view-more-button:hover {
- opacity: 0.8;
-}
From 98b92e87dd82688dcc43759fb5a81c23f52929ca Mon Sep 17 00:00:00 2001
From: SeanLeRoy
Date: Mon, 4 May 2026 10:23:16 -0700
Subject: [PATCH 6/8] Add files to progress msg
---
packages/core/state/interaction/logics.ts | 20 ++++++++++++++------
1 file changed, 14 insertions(+), 6 deletions(-)
diff --git a/packages/core/state/interaction/logics.ts b/packages/core/state/interaction/logics.ts
index a0e9f2d54..18906a809 100644
--- a/packages/core/state/interaction/logics.ts
+++ b/packages/core/state/interaction/logics.ts
@@ -363,19 +363,21 @@ const downloadFilesLogic = createLogic({
groupTotalBytesDownloaded,
"bytes"
);
- const progressMsg = `Downloading ${filesToDownload.length} ${fileWord}.
${updatedBytesDisplay} out of ${totalBytesDisplay} set to download`;
+ const header = `Downloading ${filesToDownload.length} ${fileWord}.
${updatedBytesDisplay} out of ${totalBytesDisplay} set to download`;
+ const msg = truncatedFileListHtml ? `${header}
${truncatedFileListHtml}` : header;
+ const fullMsg = fullFileListHtml ? `${header}
${fullFileListHtml}` : undefined;
dispatch(
processProgress(
groupProcessId,
totalBytesToDownload ? groupTotalBytesDownloaded / totalBytesToDownload : 0,
- progressMsg,
+ msg,
onGroupCancel,
- allFileIds
+ allFileIds,
+ fullMsg
)
);
}, 1000);
- // TODO: Download these into a zip using new streamsaver zipped code
if (!someFilesHaveUnknownSize) {
const header =
filesToDownload.length === 1
@@ -435,10 +437,16 @@ const downloadFilesLogic = createLogic({
: failed[0].reason
: "Unknown error"
}`
- : `Download failed for ${failed.length} of ${filesToDownload.length} ${fileWord}:
${failedNames.slice(0, MAX_FILES_IN_MSG).join(", ")}`;
+ : `Download failed for ${failed.length} of ${
+ filesToDownload.length
+ } ${fileWord}:
${failedNames
+ .slice(0, MAX_FILES_IN_MSG)
+ .join(", ")}`;
const errorFullMsg =
failedNames.length > MAX_FILES_IN_MSG
- ? `Download failed for ${failed.length} of ${filesToDownload.length} ${fileWord}:
${failedNames.join(", ")}`
+ ? `Download failed for ${failed.length} of ${
+ filesToDownload.length
+ } ${fileWord}:
${failedNames.join(", ")}`
: undefined;
dispatch(processError(groupProcessId, errorMsg, errorFullMsg));
} else if (cancelled.length === filesToDownload.length) {
From 675bc821bc40831320bacfa90e1ced7856890a8d Mon Sep 17 00:00:00 2001
From: SeanLeRoy
Date: Mon, 4 May 2026 10:28:13 -0700
Subject: [PATCH 7/8] Remove duplicate style
---
.../core/components/StatusMessage/StatusMessage.module.css | 6 ------
1 file changed, 6 deletions(-)
diff --git a/packages/core/components/StatusMessage/StatusMessage.module.css b/packages/core/components/StatusMessage/StatusMessage.module.css
index 43db02b13..8ac5ae965 100644
--- a/packages/core/components/StatusMessage/StatusMessage.module.css
+++ b/packages/core/components/StatusMessage/StatusMessage.module.css
@@ -122,9 +122,3 @@
height: 3px;
background-color: var(--info-status-text-color);
}
-
-.view-more-container {
- display: flex;
- justify-content: right;
- margin-top: 8px;
-}
From a822132717f21a977b3b3e4b7e43375838e764f5 Mon Sep 17 00:00:00 2001
From: SeanLeRoy
Date: Mon, 4 May 2026 14:03:18 -0700
Subject: [PATCH 8/8] WIP: Add util for download
---
.../state/interaction/downloadFileUtils.ts | 281 ++++++++++++++++++
1 file changed, 281 insertions(+)
create mode 100644 packages/core/state/interaction/downloadFileUtils.ts
diff --git a/packages/core/state/interaction/downloadFileUtils.ts b/packages/core/state/interaction/downloadFileUtils.ts
new file mode 100644
index 000000000..5caf6b041
--- /dev/null
+++ b/packages/core/state/interaction/downloadFileUtils.ts
@@ -0,0 +1,281 @@
+import { sumBy, throttle, uniqueId } from "lodash";
+import { Dispatch } from "redux";
+
+import {
+ cancelFileDownload,
+ processError,
+ processProgress,
+ processStart,
+ processSuccess,
+ removeStatus,
+} from "./actions";
+import annotationFormatterFactory, { AnnotationType } from "../../entity/AnnotationFormatter";
+import ConcurrentTaskQueue from "../../entity/ConcurrentTaskQueue";
+import { DownloadResolution, DownloadResult, FileInfo } from "../../services/FileDownloadService";
+import FileDownloadService from "../../services/FileDownloadService";
+import S3StorageService from "../../services/S3StorageService";
+
+const MAX_FILES_IN_MSG = 3;
+const MAX_PARALLEL_DOWNLOADS = 5;
+
+const numberFormatter = annotationFormatterFactory(AnnotationType.NUMBER);
+
+// ── Display helpers ──────────────────────────────────────────────────
+
+function formatBytes(bytes: number, hasUnknownSize = false): string {
+ const display = numberFormatter.displayValue(bytes, "bytes");
+ return hasUnknownSize ? `Unknown, but at least ${display}` : display;
+}
+
+function buildFileListHtml(names: string[]): string | undefined {
+ if (names.length <= 1) return undefined;
+ return names.map((n) => `• ${n}`).join("
");
+}
+
+// ── File resolution ──────────────────────────────────────────────────
+
+export async function resolveFileSizes(
+ files: FileInfo[],
+ s3StorageService: S3StorageService
+): Promise {
+ let someUnknown = false;
+ await Promise.all(
+ files.map(async (file) => {
+ if (!file.size) {
+ file.size = await s3StorageService.getCloudObjectSize(file.path);
+ if (file.size === undefined) someUnknown = true;
+ }
+ })
+ );
+ return someUnknown;
+}
+
+// ── Result classification ────────────────────────────────────────────
+
+export type FileDownloadSettledResult = PromiseSettledResult<{
+ file: FileInfo;
+ result: DownloadResult;
+}>;
+
+function classifyResults(results: FileDownloadSettledResult[]) {
+ const failed = results.filter(
+ (r) =>
+ r.status === "rejected" ||
+ (r.status === "fulfilled" &&
+ r.value.result.resolution !== DownloadResolution.SUCCESS &&
+ r.value.result.resolution !== DownloadResolution.CANCELLED)
+ );
+ const cancelled = results.filter(
+ (r) =>
+ r.status === "fulfilled" &&
+ r.value.result.resolution === DownloadResolution.CANCELLED
+ );
+ return { failed, cancelled };
+}
+
+function buildErrorMessage(
+ failed: FileDownloadSettledResult[],
+ totalCount: number,
+ allFileNames: string[]
+): { errorMsg: string; errorFullMsg?: string } {
+ const fileWord = totalCount === 1 ? "file" : "files";
+ const failedNames = failed.map((r) =>
+ r.status === "fulfilled" ? r.value.file.name : "unknown"
+ );
+ const errorMsg =
+ totalCount === 1
+ ? `File download failed for file ${allFileNames[0]}. Details:
${
+ failed[0].status === "rejected"
+ ? failed[0].reason instanceof Error
+ ? failed[0].reason.message
+ : failed[0].reason
+ : "Unknown error"
+ }`
+ : `Download failed for ${failed.length} of ${totalCount} ${fileWord}:
${failedNames
+ .slice(0, MAX_FILES_IN_MSG)
+ .join(", ")}`;
+ const errorFullMsg =
+ failedNames.length > MAX_FILES_IN_MSG
+ ? `Download failed for ${failed.length} of ${totalCount} ${fileWord}:
${failedNames.join(", ")}`
+ : undefined;
+ return { errorMsg, errorFullMsg };
+}
+
+export function dispatchResultNotification(
+ dispatch: Dispatch,
+ groupProcessId: string,
+ settledResults: FileDownloadSettledResult[],
+ totalCount: number,
+ allFileNames: string[],
+ wasCancelled: boolean
+): void {
+ if (wasCancelled) {
+ dispatch(removeStatus(groupProcessId));
+ return;
+ }
+
+ const { failed, cancelled } = classifyResults(settledResults);
+
+ if (failed.length > 0) {
+ const { errorMsg, errorFullMsg } = buildErrorMessage(failed, totalCount, allFileNames);
+ dispatch(processError(groupProcessId, errorMsg, errorFullMsg));
+ } else if (cancelled.length === totalCount) {
+ dispatch(removeStatus(groupProcessId));
+ } else {
+ const succeededCount = totalCount - cancelled.length;
+ const firstResult =
+ settledResults[0]?.status === "fulfilled"
+ ? settledResults[0].value.result
+ : undefined;
+ const msg =
+ totalCount === 1
+ ? firstResult?.msg || "Download started successfully."
+ : `Successfully downloaded ${succeededCount} ${
+ succeededCount === 1 ? "file" : "files"
+ }.`;
+ dispatch(processSuccess(groupProcessId, msg));
+ }
+}
+
+// ── Progress tracking ────────────────────────────────────────────────
+
+export interface DownloadProgressTracker {
+ groupProcessId: string;
+ downloadRequestIds: string[];
+ reportProgress(transferredBytes: number): void;
+ dispatchStart(): void;
+ cancel(): void;
+ readonly isCancelled: boolean;
+ /** @internal Used by executeBatchedDownloads to wire up queue cancellation */
+ set queue(q: ConcurrentTaskQueue);
+}
+
+export function createProgressTracker(
+ dispatch: Dispatch,
+ files: FileInfo[],
+ someFilesHaveUnknownSize: boolean
+): DownloadProgressTracker {
+ const totalBytes = sumBy(files, "size") || 0;
+ const totalBytesDisplay = formatBytes(totalBytes, someFilesHaveUnknownSize);
+ const allFileIds = files.map((f) => f.id);
+ const allFileNames = files.map((f) => f.name);
+ const fileWord = files.length === 1 ? "file" : "files";
+
+ const truncatedFileListHtml =
+ allFileNames.length > 1
+ ? buildFileListHtml(allFileNames.slice(0, MAX_FILES_IN_MSG))
+ : undefined;
+ const fullFileListHtml =
+ allFileNames.length > MAX_FILES_IN_MSG
+ ? buildFileListHtml(allFileNames)
+ : undefined;
+
+ const groupProcessId = uniqueId();
+ const downloadRequestIds = files.map(() => uniqueId());
+
+ let cancelled = false;
+ let downloadQueue: ConcurrentTaskQueue | undefined;
+ let bytesDownloaded = 0;
+
+ const throttledDispatch = throttle(() => {
+ if (cancelled) return;
+ const bytesDisplay = numberFormatter.displayValue(bytesDownloaded, "bytes");
+ const header = `Downloading ${files.length} ${fileWord}.
${bytesDisplay} out of ${totalBytesDisplay} set to download`;
+ const msg = truncatedFileListHtml ? `${header}
${truncatedFileListHtml}` : header;
+ const fullMsg = fullFileListHtml ? `${header}
${fullFileListHtml}` : undefined;
+ dispatch(
+ processProgress(
+ groupProcessId,
+ totalBytes ? bytesDownloaded / totalBytes : 0,
+ msg,
+ doCancel,
+ allFileIds,
+ fullMsg
+ )
+ );
+ }, 1000);
+
+ function doCancel() {
+ cancelled = true;
+ throttledDispatch.cancel();
+ downloadQueue?.cancel();
+ dispatch(removeStatus(groupProcessId));
+ downloadRequestIds.forEach((id) => dispatch(cancelFileDownload(id)));
+ }
+
+ return {
+ groupProcessId,
+ downloadRequestIds,
+ get isCancelled() {
+ return cancelled;
+ },
+ reportProgress(transferredBytes: number) {
+ bytesDownloaded += transferredBytes;
+ throttledDispatch();
+ },
+ dispatchStart() {
+ if (someFilesHaveUnknownSize) return;
+ const header =
+ files.length === 1
+ ? `Downloading ${allFileNames[0]}.
${totalBytesDisplay} set to download`
+ : `Downloading ${files.length} ${fileWord}.
${totalBytesDisplay} set to download`;
+ const msg = truncatedFileListHtml ? `${header}
${truncatedFileListHtml}` : header;
+ const fullMsg = fullFileListHtml ? `${header}
${fullFileListHtml}` : undefined;
+ dispatch(processStart(groupProcessId, msg, doCancel, allFileIds, fullMsg));
+ },
+ cancel: doCancel,
+ // This enables executeBatchedDownloads() to set the queue after the tracker is created
+ // allowing the tracker to cancel the queue if the user cancels the download before
+ // all tasks have been added to the queue
+ /** @internal Allow the tracker to cancel the download queue */
+ set queue(q: ConcurrentTaskQueue) {
+ downloadQueue = q;
+ },
+ } as DownloadProgressTracker;
+}
+
+// ── Batched execution ────────────────────────────────────────────────
+
+export async function executeBatchedDownloads(
+ files: FileInfo[],
+ fileDownloadService: FileDownloadService,
+ tracker: DownloadProgressTracker
+): Promise {
+ const queue = new ConcurrentTaskQueue(MAX_PARALLEL_DOWNLOADS);
+ tracker.queue = queue;
+
+ const results: FileDownloadSettledResult[] = files.map();
+
+ const results = files.map((file, index) => {
+ queue.push(async () => {
+ if (tracker.isCancelled) {
+ results[index] = {
+ status: "fulfilled",
+ value: {
+ file,
+ result: {
+ downloadRequestId: tracker.downloadRequestIds[index],
+ resolution: DownloadResolution.CANCELLED,
+ },
+ },
+ };
+ return;
+ }
+
+ try {
+ const result = await fileDownloadService.download(
+ file,
+ tracker.downloadRequestIds[index],
+ (bytes) => tracker.reportProgress(bytes)
+ );
+ results[index] = { status: "fulfilled", value: { file, result } };
+ } catch (reason) {
+ results[index] = { status: "rejected", reason };
+ }
+ });
+ });
+
+ await queue.drain();
+ console.log(results);
+ return results;
+}