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 ( + + ); + } + + const visibleNames = expanded ? fileNames : fileNames.slice(0, MAX_FILES_SHOWN); + const hiddenCount = fileNames.length - MAX_FILES_SHOWN; + + return ( + <> + + + + ); +} + /** * 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 ( - - ); - } - - const visibleNames = expanded ? fileNames : fileNames.slice(0, MAX_FILES_SHOWN); - const hiddenCount = fileNames.length - MAX_FILES_SHOWN; - - return ( - <> - - - - ); -} - /** * 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; +}