Skip to content

feat: download manager#256

Open
iamEvanYT wants to merge 16 commits intomainfrom
evan/download-manager
Open

feat: download manager#256
iamEvanYT wants to merge 16 commits intomainfrom
evan/download-manager

Conversation

@iamEvanYT
Copy link
Copy Markdown
Member

@iamEvanYT iamEvanYT commented Apr 7, 2026

Summary by CodeRabbit

  • New Features
    • Download manager with pause, resume, cancel and persistent state across sessions
    • Resume interrupted downloads and comprehensive progress tracking (including macOS native progress)
    • Sidebar downloads popover showing recent items and active count
    • Dedicated Downloads page with search, grouping by day, and management UI
    • Open file / show in folder / clear completed actions
    • File icons for downloaded items

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 7, 2026

Walkthrough

Adds a full download manager: DB schema and migration, main-process controller with resume/progress and macOS integration, IPC surface and preload exposure, renderer UI (popover + manager), route, types, and Drizzle snapshot/journal entries.

Changes

Cohort / File(s) Summary
Database Schema & Migrations
drizzle/0004_add_download_manager.sql, drizzle/meta/0004_snapshot.json, drizzle/meta/_journal.json
New downloads table with metadata, progress, state, resume/cache fields, timestamps, and indexes on state and updated_at; added snapshot and journal entry.
Persistence Layer
src/main/saving/db/schema.ts, src/main/saving/downloads.ts
Drizzle table schema & types; CRUD/upsert helpers, list/get/delete, and reconcileDownloadsOnStartup() to mark in-progress downloads as interrupted.
Main Controller & macOS Progress
src/main/controllers/downloads-controller/index.ts, src/main/controllers/downloads-controller/macos-progress.ts
New DownloadsController coordinating will-download, progress throttling, persistence, pending resume queue with 30s timeout, macOS NSProgress integration and cancellation hooks, and exported singleton.
Session Integration
src/main/controllers/sessions-controller/default-session/index.ts, src/main/controllers/sessions-controller/handlers/index.ts
Registers downloads controller with default and custom sessions via registerSession(session) during session initialization.
Protocol Routes & Static Domains
src/main/controllers/sessions-controller/protocols/_protocols/flow/file-icon.ts, src/main/controllers/sessions-controller/protocols/_protocols/flow/index.ts, src/main/controllers/sessions-controller/protocols/static-domains/config.ts
Added flow://file-icon route to serve PNG icons with path-allowlist checks and flow://downloads static domain mapping.
IPC & Preload Exposure
src/main/ipc/browser/downloads.ts, src/main/ipc/index.ts, src/preload/index.ts, src/shared/flow/interfaces/browser/downloads.ts, src/shared/flow/flow.ts
IPC handlers for list/get/pause/resume/cancel/show/open/remove/clear/check-exist and downloads:on-changed; flow.downloads API exposed to renderer via preload and typed interface.
Types
src/shared/types/downloads.ts
Added DOWNLOAD_STATES, DownloadState union, and DownloadRecord interface describing persisted download shape.
Renderer — Provider & Manager UI
src/renderer/src/components/downloads/manager/provider.tsx, src/renderer/src/components/downloads/manager/main.tsx, src/renderer/src/components/downloads/manager/download-card.tsx, src/renderer/src/components/downloads/manager/file-icon.tsx, src/renderer/src/components/downloads/manager/utils.ts
Context provider, main manager page with search/grouping, download card and file-icon component, utilities for formatting/grouping, and actions wired to flow.downloads.
Renderer — Sidebar Popover & Route
src/renderer/src/components/browser-ui/browser-sidebar/_components/bottom/downloads-popover.tsx, src/renderer/src/components/browser-ui/browser-sidebar/inner.tsx, src/renderer/src/routes/downloads/page.tsx, src/renderer/src/routes/downloads/config.tsx
Downloads popover replacing placeholder button, showing recent items and active indicator; dedicated downloads route with provider and dark theme wrapper.
IPC / Output / Debug
src/main/modules/output.ts, src/main/ipc/webauthn/index.ts
Added DOWNLOADS debug area; minor WebAuthn handler typing assertion change.
Build / Tooling / Config
eslint.config.mjs, package.json, tsconfig.*.json
ESLint plugin wiring and rule overrides adjusted; package dependency bumps (TypeScript, Vite, others); path/baseUrl tweaks in tsconfig files.
Misc Renderer Changes
src/renderer/... (various hooks/components)
Multiple small UI/hook adjustments: removed several eslint-disable comments, refactored state vs memo for skeleton widths, removed unused hook.

Sequence Diagram(s)

sequenceDiagram
    participant Electron as Electron
    participant Controller as DownloadsController
    participant DB as Database
    participant IPC as IPC Handler
    participant Renderer as Renderer

    Electron->>Controller: registerSession(session)
    Controller->>Controller: Install will-download handler

    Electron->>Controller: will-download (new item)
    Controller->>DB: upsertDownloadRecord()
    DB-->>Controller: OK
    Controller->>Controller: track activeDownloads
    Controller->>IPC: fireDownloadsChanged()
    IPC->>Renderer: "downloads:on-changed"

    Electron->>Controller: item.updated (progress)
    Controller->>DB: updateDownloadRecord() [throttled]
    Controller->>IPC: fireDownloadsChanged()
    IPC->>Renderer: "downloads:on-changed"

    Electron->>Controller: item.done
    Controller->>DB: updateDownloadRecord(state=completed)
    Controller->>IPC: fireDownloadsChanged()
    IPC->>Renderer: "downloads:on-changed"
Loading
sequenceDiagram
    participant Renderer as Renderer
    participant IPC as IPC Handler
    participant Controller as DownloadsController
    participant DB as Database
    participant Electron as Electron Download

    Renderer->>IPC: resumeDownload(downloadId)
    IPC->>Controller: resumeDownload()
    Controller->>DB: getDownloadRecord(downloadId)
    DB-->>Controller: record
    Controller->>Controller: resolve session for originProfileId
    Controller->>Controller: enqueue PendingResumeRequest (30s)
    Controller->>Electron: createInterruptedDownload(urlChain, offsets)

    Electron->>Controller: will-download (session matches)
    Controller->>Controller: consume PendingResumeRequest
    Controller->>Electron: autoResume? item.resume()
    Controller->>DB: updateDownloadRecord()
    Controller->>IPC: fireDownloadsChanged()
    IPC->>Renderer: "downloads:on-changed"
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested labels

enhancement

Poem

🐰 A rabbit hops through bytes and trails of files,
Persisting states and resumable wiles.
Progress bars hum, and icons softly gleam,
Sidebar whispers what's downloaded in our dream.
Hooray for downloads — neat, resilient, and small!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 4.55% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: download manager' directly and clearly summarizes the main change—a comprehensive download manager feature implementation across database, backend, UI, and API layers.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch evan/download-manager

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 7, 2026

Build artifacts for all platforms are ready! 🚀

Download the artifacts for:

One-line installer (Unstable):
bunx flow-debug-build@1.2.1 --open 24091651794

(execution 24091651794 / attempt 1)

@iamEvanYT iamEvanYT marked this pull request as ready for review April 7, 2026 14:01
@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented Apr 7, 2026

Greptile Summary

This PR introduces a full-featured download manager for the Flow browser: a persistent SQLite-backed download store (via Drizzle ORM), an Electron DownloadItem lifecycle controller with pause/resume/cancel support, macOS Finder progress integration via NSProgress, IPC handlers, a renderer-side React context provider, a complete downloads full-page view, and a sidebar popover. The overall architecture is well-designed and the implementation quality is high throughout.

Highlights:

  • DownloadsController uses WeakMap/WeakSet keyed on Electron objects to avoid memory leaks, and throttles DB writes (DOWNLOAD_PROGRESS_PERSIST_INTERVAL_MS) during active downloads
  • reconcileDownloadsOnStartup correctly resets in-flight states after an unexpected shutdown
  • Interrupted-download restoration via createInterruptedDownload with a timeout-guarded pending-resume queue is a thoughtful addition
  • The flow://file-icon protocol handler has solid path-traversal protection (resolves both paths, checks for separator suffix to prevent partial directory name matches)
  • The macOS NSProgress module is lazy-loaded only on Darwin and all native calls are wrapped in try/catch

Issues found:

  • downloads:open-file IPC handler does not await shell.openPath() — the promise is fire-and-forget, OS errors are silently swallowed, and the handler always returns true when a record is found (P1 — users receive incorrect success feedback when a file fails to open)
  • formatBytes utility has an out-of-bounds array access for files ≥ 1 PB, producing "1.0 undefined" in the UI (P2)

Confidence Score: 4/5

Safe to merge after fixing the silent shell.openPath error; the formatBytes out-of-bounds fix is minor but also easy to apply.

One P1 bug (openFile always reports success even when the OS fails to open the file, giving users false-positive feedback) and one P2 rendering bug (formatBytes produces 'undefined' unit for PB-scale files). All other components — the controller, persistence layer, macOS NSProgress integration, protocol handler, and renderer components — are clean and well-implemented.

src/main/ipc/browser/downloads.ts (shell.openPath not awaited) and src/renderer/src/components/downloads/manager/utils.ts (formatBytes array out-of-bounds)

Important Files Changed

Filename Overview
src/main/ipc/browser/downloads.ts IPC handlers for download management; downloads:open-file silently swallows shell.openPath errors by not awaiting the promise
src/main/controllers/downloads-controller/index.ts Core download lifecycle controller with WeakMap cleanup, throttled DB persistence, and robust interrupted-download restore logic
src/renderer/src/components/downloads/manager/utils.ts Download utility helpers; formatBytes has an out-of-bounds array access for files ≥ 1 PB producing 'undefined' unit string
src/main/saving/downloads.ts Download persistence layer using Drizzle ORM; clean implementation with correct upsert, bulk reconcile on startup, and empty-patch guard
src/main/saving/db/schema.ts Downloads table added with correct nullable columns, boolean mode for can_resume, JSON array for url_chain, and both required indexes
src/renderer/src/components/downloads/manager/provider.tsx React context provider with mountedRef cleanup, stable useCallback memoization, and scoped file-existence checks for non-active downloads
src/renderer/src/components/downloads/manager/download-card.tsx Download item card UI with consistent inline and context-menu actions, correct fileMissing detection, accessible icon buttons
src/renderer/src/components/browser-ui/browser-sidebar/_components/bottom/downloads-popover.tsx Sidebar popover subscribing to onChanged only while open, showing top 5 by updatedAt with animation and active-download badge
src/main/controllers/downloads-controller/macos-progress.ts macOS NSProgress integration with try/catch guards, proper cancel-callback cleanup, and seamless progress migration on path change
src/main/controllers/sessions-controller/protocols/_protocols/flow/file-icon.ts File-icon protocol handler with path-traversal protection: resolves both sides, checks separator suffix, restricts to downloads and data dirs
drizzle/0004_add_download_manager.sql Migration SQL matches drizzle schema exactly with correct nullable columns, default values, and both required indexes

Sequence Diagram

sequenceDiagram
    participant R as Renderer
    participant IPC as IPC (downloads.ts)
    participant DC as DownloadsController
    participant E as Electron DownloadItem
    participant DB as SQLite
    participant MP as macOS NSProgress

    R->>IPC: flow.downloads.list()
    IPC->>DC: listDownloads()
    DC->>DB: SELECT * ORDER BY updated_at DESC
    DB-->>R: DownloadRecord[]

    E->>DC: session will-download
    DC->>DB: UPSERT (state=progressing)
    DC->>IPC: fireDownloadsChanged()
    IPC-->>R: downloads:on-changed

    loop Every updated event (throttled 1 s)
        E->>DC: item.updated (progressing)
        DC->>MP: updateFileProgress()
        DC->>DB: UPDATE received_bytes, state
        DC->>IPC: fireDownloadsChanged()
        IPC-->>R: downloads:on-changed
    end

    E->>DC: item.done (completed / cancelled / interrupted)
    DC->>MP: completeFileProgress / cancelFileProgress
    DC->>DB: UPDATE final state + end_time
    DC->>IPC: fireDownloadsChanged()
    IPC-->>R: downloads:on-changed

    R->>IPC: flow.downloads.resume(id)
    IPC->>DC: resumeDownload(id)
    DC->>DB: getDownloadRecord(id)
    DC->>E: session.createInterruptedDownload()
    Note over DC,E: triggers will-download again with pending-resume match
    E->>DC: session will-download (restored)
    DC->>E: item.resume() via queueMicrotask
Loading

Reviews (1): Last reviewed commit: "fix: issues" | Re-trigger Greptile

Comment on lines +38 to +43
ipcMain.handle("downloads:open-file", (_event, downloadId: string) => {
const record = getDownloadRecord(downloadId);
if (!record?.savePath || record.state !== "completed") return false;
shell.openPath(record.savePath);
return true;
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 shell.openPath result not awaited — errors silently swallowed

shell.openPath() returns a Promise<string> where an empty string means success and a non-empty string is the OS error message. Because the promise is not awaited, any failure (no associated application, permission denied, file moved/deleted since the record was saved) is silently dropped. The handler always returns true whenever it finds a completed record, so the renderer's if (!ok) toast.error("Could not open file") guard is never triggered — users get no feedback when the open fails.

Suggested change
ipcMain.handle("downloads:open-file", (_event, downloadId: string) => {
const record = getDownloadRecord(downloadId);
if (!record?.savePath || record.state !== "completed") return false;
shell.openPath(record.savePath);
return true;
});
ipcMain.handle("downloads:open-file", async (_event, downloadId: string) => {
const record = getDownloadRecord(downloadId);
if (!record?.savePath || record.state !== "completed") return false;
const err = await shell.openPath(record.savePath);
return err === "";
});

Comment on lines +3 to +8
export function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
const value = bytes / Math.pow(1024, i);
return `${value.toFixed(i > 0 ? 1 : 0)} ${units[i]}`;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 formatBytes out-of-bounds for files ≥ 1 PB

The units array has 5 elements (indices 0–4: B, KB, MB, GB, TB). For any value where bytes >= 1_125_899_906_842_624 (1 PB), Math.floor(Math.log(bytes) / Math.log(1024)) evaluates to 5, which is out of bounds — units[5] is undefined, producing a display string like "1.0 undefined". Large disk images, OS installers, and video files can realistically hit hundreds of GB; petabyte-range values are uncommon but the silent rendering corruption is worth guarding against.

Suggested change
export function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
const value = bytes / Math.pow(1024, i);
return `${value.toFixed(i > 0 ? 1 : 0)} ${units[i]}`;
export function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB", "PB"];
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
const value = bytes / Math.pow(1024, i);
return `${value.toFixed(i > 0 ? 1 : 0)} ${units[i]}`;
}

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 10

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/main/controllers/downloads-controller/index.ts`:
- Around line 496-501: getSessionForDownload currently always uses
electronSession.fromPath when originProfileId is set, which can create an
uninitialized profile session; change it to first try returning the fully
initialized session instance used by the app (e.g., the profile/session registry
created during bootstrap) and only fall back to
electronSession.fromPath(path.join(PROFILES_DIR, originProfileId)) if no
initialized session exists; update getSessionForDownload to check the app's
session manager/registry for originProfileId and return that session, otherwise
return electronSession.defaultSession or the fromPath fallback as now.
- Around line 445-460: The updateDownloadRecord call in the resume path is
overwriting persisted metadata with placeholder values from Electron (e.g. 0 or
""), so change the fields startTime, mimeType, eTag, and lastModified to
preserve existing values when Electron reports placeholders: use
getDownloadStartTimeMs(item) only if it yields a truthy/positive timestamp else
keep meta.startTime; use this.emptyToNull(item.getMimeType()),
this.emptyToNull(item.getETag()), and
this.emptyToNull(item.getLastModifiedTime()) only if they return non-null/
non-empty values else keep meta.mimeType, meta.eTag, and meta.lastModified
respectively; leave the rest of the update (including shouldSetEndTime and
received/total bytes) unchanged so buildDownloadInsert/createInterruptedDownload
validators retain original persisted values.
- Around line 336-345: The code updates meta.savePath even when
mp.recreateFileProgressAtPath(...) returns null, which stops future retries
while meta.progressId may point to a removed entry; change the logic in the
block around recreateFileProgressAtPath so that meta.savePath is only assigned
when nextProgressId is truthy (i.e., recreate succeeded), and leave
meta.savePath and meta.progressId unchanged when nextProgressId is null
(optionally add a debug/log line when recreation fails) so the next sync can
retry recreation.
- Around line 133-140: resumeDownload is queuing duplicate interrupted resumes
for the same downloadId; before calling
getSessionForDownload/registerSession/enqueuePendingResume, check for and reject
duplicates by verifying if an in-flight or pending resume already exists for the
downloadId (consult activeDownloadsById and the pending-resume queue inside
enqueuePendingResume or a new pendingResumeById map), and return early if found;
alternatively deduplicate inside enqueuePendingResume by using downloadId as the
unique key so createInterruptedDownload cannot be queued twice for the same
downloadId (refer to functions/members: resumeDownload, getSessionForDownload,
enqueuePendingResume, createInterruptedDownload, activeDownloadsById).

In `@src/main/ipc/browser/downloads.ts`:
- Around line 51-61: The clear-completed handler currently sets changed = true
regardless of whether deleteDownloadRecord(dl.id) actually succeeded; update the
loop in the ipcMain.handle("downloads:clear-completed") callback to check the
boolean result of deleteDownloadRecord for each dl and only set changed = true
when deleteDownloadRecord(...) returns true, optionally collect/log failed
deletions and avoid firing fireDownloadsChanged() if no records were actually
removed; keep references to listDownloads(), deleteDownloadRecord(), and
fireDownloadsChanged() so the change is localized to that handler.
- Around line 38-43: The handler for "downloads:open-file" currently calls
shell.openPath(record.savePath) but doesn’t await its Promise, so it always
returns true; modify the ipcMain.handle callback (the "downloads:open-file"
handler that uses getDownloadRecord) to await shell.openPath(record.savePath)
and check its returned string (empty = success, non-empty = error message) and
return true only on success and false on error (optionally log or process the
error string before returning false).

In
`@src/renderer/src/components/browser-ui/browser-sidebar/_components/bottom/downloads-popover.tsx`:
- Around line 40-47: handleClick in downloads-popover.tsx currently calls
flow.downloads.openFile(dl.id) without catching errors, so failures are silent;
update handleClick to mirror DownloadCard by awaiting or handling the promise
from flow.downloads.openFile(dl.id) and catching errors, and on catch show a
user toast (or similar UI feedback) before falling back to opening the downloads
tab and calling setOpen(false); reference handleClick, dl.id,
flow.downloads.openFile, flow.tabs.newTab and setOpen when locating the fix.
- Around line 147-159: fetchDownloads can call setDownloads/setFileExistence
after the component unmounts; add a mount guard to avoid setState-after-unmount
warnings. Inside the component create a ref like isMounted and set it true on
mount and false in a cleanup useEffect, then inside the useCallback
fetchDownloads check isMounted.current before calling setDownloads and
setFileExistence (and after awaiting flow.downloads.checkFilesExist) so no state
updates occur if the popover has closed; ensure the cleanup toggles the ref to
false to prevent updates from in-flight promises.

In `@src/renderer/src/components/downloads/manager/main.tsx`:
- Around line 44-47: The clearCompleted handler currently calls
flow.downloads.clearCompleted() then immediately shows toast.success, which will
display success even if clearCompleted() throws; wrap the await call in a
try-catch inside the clearCompleted function, await
flow.downloads.clearCompleted() first, call toast.success only on success, and
on error call toast.error with the caught error message (and optionally rethrow
or log via processLogger) so failures are handled and reported; reference the
clearCompleted function and flow.downloads.clearCompleted and
toast.success/toast.error when making the change.

In `@src/renderer/src/components/downloads/manager/provider.tsx`:
- Around line 22-40: The fetchDownloads function leaves stale entries in
fileExistence when downloads are removed; modify fetchDownloads in provider.tsx
so it clears or trims fileExistence before/when updating: before calling
flow.downloads.checkFilesExist or immediately after getting all, compute the
current ids (all.map(dl => dl.id)) and call setFileExistence with either an
empty object or a filtered object limited to those ids, then merge/apply the
fresh existence results (from checkFilesExist) so only current downloads remain;
update references to setFileExistence and ensure this runs only when
mountedRef.current is true.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 550c0b75-e488-4fef-918b-9e222e5606bf

📥 Commits

Reviewing files that changed from the base of the PR and between be7f900 and 26df532.

📒 Files selected for processing (28)
  • drizzle/0004_add_download_manager.sql
  • drizzle/meta/0004_snapshot.json
  • drizzle/meta/_journal.json
  • src/main/controllers/downloads-controller/index.ts
  • src/main/controllers/downloads-controller/macos-progress.ts
  • src/main/controllers/sessions-controller/default-session/index.ts
  • src/main/controllers/sessions-controller/handlers/index.ts
  • src/main/controllers/sessions-controller/protocols/_protocols/flow/file-icon.ts
  • src/main/controllers/sessions-controller/protocols/_protocols/flow/index.ts
  • src/main/controllers/sessions-controller/protocols/static-domains/config.ts
  • src/main/ipc/browser/downloads.ts
  • src/main/ipc/index.ts
  • src/main/modules/output.ts
  • src/main/saving/db/schema.ts
  • src/main/saving/downloads.ts
  • src/preload/index.ts
  • src/renderer/src/components/browser-ui/browser-sidebar/_components/bottom/downloads-popover.tsx
  • src/renderer/src/components/browser-ui/browser-sidebar/inner.tsx
  • src/renderer/src/components/downloads/manager/download-card.tsx
  • src/renderer/src/components/downloads/manager/file-icon.tsx
  • src/renderer/src/components/downloads/manager/main.tsx
  • src/renderer/src/components/downloads/manager/provider.tsx
  • src/renderer/src/components/downloads/manager/utils.ts
  • src/renderer/src/routes/downloads/config.tsx
  • src/renderer/src/routes/downloads/page.tsx
  • src/shared/flow/flow.ts
  • src/shared/flow/interfaces/browser/downloads.ts
  • src/shared/types/downloads.ts

Comment on lines +133 to +140
const targetSession = this.getSessionForDownload(record.originProfileId);
this.registerSession(targetSession);
this.enqueuePendingResume(targetSession, {
downloadId,
savePath: record.savePath!,
lastUrl: record.urlChain[record.urlChain.length - 1] ?? record.url,
autoResume: true
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Reject duplicate resume requests for the same download.

Until will-download arrives, this record stays paused/interrupted, so resumeDownload() can be called again and queue a second createInterruptedDownload() for the same downloadId. Once both events arrive, activeDownloadsById gets overwritten and updated/done from the first item can be applied to the second one.

💡 Suggested fix
       const targetSession = this.getSessionForDownload(record.originProfileId);
       this.registerSession(targetSession);
+      if ((this.pendingResumeRequests.get(targetSession) ?? []).some((r) => r.downloadId === downloadId)) {
+        return false;
+      }
       this.enqueuePendingResume(targetSession, {
         downloadId,
         savePath: record.savePath!,
         lastUrl: record.urlChain[record.urlChain.length - 1] ?? record.url,
         autoResume: true
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/controllers/downloads-controller/index.ts` around lines 133 - 140,
resumeDownload is queuing duplicate interrupted resumes for the same downloadId;
before calling getSessionForDownload/registerSession/enqueuePendingResume, check
for and reject duplicates by verifying if an in-flight or pending resume already
exists for the downloadId (consult activeDownloadsById and the pending-resume
queue inside enqueuePendingResume or a new pendingResumeById map), and return
early if found; alternatively deduplicate inside enqueuePendingResume by using
downloadId as the unique key so createInterruptedDownload cannot be queued twice
for the same downloadId (refer to functions/members: resumeDownload,
getSessionForDownload, enqueuePendingResume, createInterruptedDownload,
activeDownloadsById).

Comment on lines +336 to +345
if (meta.savePath && meta.savePath !== savePath) {
const nextProgressId = mp.recreateFileProgressAtPath(meta.progressId, savePath, () => {
debugPrint("DOWNLOADS", `Cancel requested from Finder for: ${item.getFilename()}`);
item.cancel();
});

if (nextProgressId) {
meta.progressId = nextProgressId;
}
meta.savePath = savePath;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Keep macOS progress recreation retryable on failure.

recreateFileProgressAtPath() can return null. This branch still advances meta.savePath, so later syncs stop retrying while meta.progressId still points at a removed progress entry.

💡 Suggested fix
       const nextProgressId = mp.recreateFileProgressAtPath(meta.progressId, savePath, () => {
         debugPrint("DOWNLOADS", `Cancel requested from Finder for: ${item.getFilename()}`);
         item.cancel();
       });
 
       if (nextProgressId) {
         meta.progressId = nextProgressId;
+        meta.savePath = savePath;
+      } else {
+        meta.progressId = null;
       }
-      meta.savePath = savePath;
     }
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (meta.savePath && meta.savePath !== savePath) {
const nextProgressId = mp.recreateFileProgressAtPath(meta.progressId, savePath, () => {
debugPrint("DOWNLOADS", `Cancel requested from Finder for: ${item.getFilename()}`);
item.cancel();
});
if (nextProgressId) {
meta.progressId = nextProgressId;
}
meta.savePath = savePath;
if (meta.savePath && meta.savePath !== savePath) {
const nextProgressId = mp.recreateFileProgressAtPath(meta.progressId, savePath, () => {
debugPrint("DOWNLOADS", `Cancel requested from Finder for: ${item.getFilename()}`);
item.cancel();
});
if (nextProgressId) {
meta.progressId = nextProgressId;
meta.savePath = savePath;
} else {
meta.progressId = null;
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/controllers/downloads-controller/index.ts` around lines 336 - 345,
The code updates meta.savePath even when mp.recreateFileProgressAtPath(...)
returns null, which stops future retries while meta.progressId may point to a
removed entry; change the logic in the block around recreateFileProgressAtPath
so that meta.savePath is only assigned when nextProgressId is truthy (i.e.,
recreate succeeded), and leave meta.savePath and meta.progressId unchanged when
nextProgressId is null (optionally add a debug/log line when recreation fails)
so the next sync can retry recreation.

Comment on lines +445 to +460
updateDownloadRecord(meta.downloadId, {
originProfileId: meta.originProfileId,
url: item.getURL(),
urlChain: this.getUrlChain(item),
suggestedFilename: item.getFilename(),
savePath: meta.savePath,
mimeType: this.emptyToNull(item.getMimeType()),
state,
receivedBytes: item.getReceivedBytes(),
totalBytes: item.getTotalBytes(),
startTime: this.getDownloadStartTimeMs(item),
endTime: this.shouldSetEndTime(state, item.canResume()) ? this.getDownloadEndTimeMs(item, now) : null,
eTag: this.emptyToNull(item.getETag()),
lastModified: this.emptyToNull(item.getLastModifiedTime()),
canResume: item.canResume(),
updatedAt: now
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't overwrite persisted metadata with placeholder values.

During resume, buildDownloadInsert() intentionally preserves startTime, mimeType, eTag, and lastModified from the existing row. This snapshot path can immediately replace them with Date.now()/null whenever Electron still reports 0 or "", which both breaks day grouping in the renderer and strips the validators later passed back into createInterruptedDownload().

💡 Suggested fix
+    const mimeType = this.emptyToNull(item.getMimeType());
+    const eTag = this.emptyToNull(item.getETag());
+    const lastModified = this.emptyToNull(item.getLastModifiedTime());
+    const startTimeSeconds = item.getStartTime();
+
     updateDownloadRecord(meta.downloadId, {
       originProfileId: meta.originProfileId,
       url: item.getURL(),
       urlChain: this.getUrlChain(item),
       suggestedFilename: item.getFilename(),
       savePath: meta.savePath,
-      mimeType: this.emptyToNull(item.getMimeType()),
+      ...(mimeType !== null ? { mimeType } : {}),
       state,
       receivedBytes: item.getReceivedBytes(),
       totalBytes: item.getTotalBytes(),
-      startTime: this.getDownloadStartTimeMs(item),
+      ...(startTimeSeconds > 0 ? { startTime: Math.round(startTimeSeconds * 1000) } : {}),
       endTime: this.shouldSetEndTime(state, item.canResume()) ? this.getDownloadEndTimeMs(item, now) : null,
-      eTag: this.emptyToNull(item.getETag()),
-      lastModified: this.emptyToNull(item.getLastModifiedTime()),
+      ...(eTag !== null ? { eTag } : {}),
+      ...(lastModified !== null ? { lastModified } : {}),
       canResume: item.canResume(),
       updatedAt: now
     });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/controllers/downloads-controller/index.ts` around lines 445 - 460,
The updateDownloadRecord call in the resume path is overwriting persisted
metadata with placeholder values from Electron (e.g. 0 or ""), so change the
fields startTime, mimeType, eTag, and lastModified to preserve existing values
when Electron reports placeholders: use getDownloadStartTimeMs(item) only if it
yields a truthy/positive timestamp else keep meta.startTime; use
this.emptyToNull(item.getMimeType()), this.emptyToNull(item.getETag()), and
this.emptyToNull(item.getLastModifiedTime()) only if they return non-null/
non-empty values else keep meta.mimeType, meta.eTag, and meta.lastModified
respectively; leave the rest of the update (including shouldSetEndTime and
received/total bytes) unchanged so buildDownloadInsert/createInterruptedDownload
validators retain original persisted values.

Comment on lines +496 to +501
private getSessionForDownload(originProfileId: string | null): Session {
if (!originProfileId) {
return electronSession.defaultSession;
}

return electronSession.fromPath(path.join(PROFILES_DIR, originProfileId));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Resume should reuse the fully initialized profile session.

electronSession.fromPath() can create a profile session that never went through the normal bootstrap path. If the user resumes a download before opening that profile in this process, the resumed request runs without the same protocol, handler, and intercept setup as the original session.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/controllers/downloads-controller/index.ts` around lines 496 - 501,
getSessionForDownload currently always uses electronSession.fromPath when
originProfileId is set, which can create an uninitialized profile session;
change it to first try returning the fully initialized session instance used by
the app (e.g., the profile/session registry created during bootstrap) and only
fall back to electronSession.fromPath(path.join(PROFILES_DIR, originProfileId))
if no initialized session exists; update getSessionForDownload to check the
app's session manager/registry for originProfileId and return that session,
otherwise return electronSession.defaultSession or the fromPath fallback as now.

Comment on lines +38 to +43
ipcMain.handle("downloads:open-file", (_event, downloadId: string) => {
const record = getDownloadRecord(downloadId);
if (!record?.savePath || record.state !== "completed") return false;
shell.openPath(record.savePath);
return true;
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

shell.openPath result is not awaited, success always returned.

shell.openPath() returns Promise<string> where an empty string indicates success and a non-empty string contains an error message. The current code returns true immediately without checking the result, so failures are silently ignored and the renderer always receives success.

Await and check the result
-ipcMain.handle("downloads:open-file", (_event, downloadId: string) => {
+ipcMain.handle("downloads:open-file", async (_event, downloadId: string) => {
   const record = getDownloadRecord(downloadId);
   if (!record?.savePath || record.state !== "completed") return false;
-  shell.openPath(record.savePath);
-  return true;
+  const error = await shell.openPath(record.savePath);
+  return error === "";
 });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
ipcMain.handle("downloads:open-file", (_event, downloadId: string) => {
const record = getDownloadRecord(downloadId);
if (!record?.savePath || record.state !== "completed") return false;
shell.openPath(record.savePath);
return true;
});
ipcMain.handle("downloads:open-file", async (_event, downloadId: string) => {
const record = getDownloadRecord(downloadId);
if (!record?.savePath || record.state !== "completed") return false;
const error = await shell.openPath(record.savePath);
return error === "";
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/ipc/browser/downloads.ts` around lines 38 - 43, The handler for
"downloads:open-file" currently calls shell.openPath(record.savePath) but
doesn’t await its Promise, so it always returns true; modify the ipcMain.handle
callback (the "downloads:open-file" handler that uses getDownloadRecord) to
await shell.openPath(record.savePath) and check its returned string (empty =
success, non-empty = error message) and return true only on success and false on
error (optionally log or process the error string before returning false).

Comment on lines +51 to +61
ipcMain.handle("downloads:clear-completed", () => {
const downloads = listDownloads();
let changed = false;
for (const dl of downloads) {
if (dl.state === "completed" || dl.state === "cancelled") {
deleteDownloadRecord(dl.id);
changed = true;
}
}
if (changed) fireDownloadsChanged();
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consider checking deletion result in clear-completed loop.

deleteDownloadRecord returns a boolean, but the loop ignores individual deletion failures. If a deletion fails, changed is still set to true. This could lead to inconsistent state notifications.

Track actual deletions
 ipcMain.handle("downloads:clear-completed", () => {
   const downloads = listDownloads();
   let changed = false;
   for (const dl of downloads) {
     if (dl.state === "completed" || dl.state === "cancelled") {
-      deleteDownloadRecord(dl.id);
-      changed = true;
+      const deleted = deleteDownloadRecord(dl.id);
+      if (deleted) changed = true;
     }
   }
   if (changed) fireDownloadsChanged();
 });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
ipcMain.handle("downloads:clear-completed", () => {
const downloads = listDownloads();
let changed = false;
for (const dl of downloads) {
if (dl.state === "completed" || dl.state === "cancelled") {
deleteDownloadRecord(dl.id);
changed = true;
}
}
if (changed) fireDownloadsChanged();
});
ipcMain.handle("downloads:clear-completed", () => {
const downloads = listDownloads();
let changed = false;
for (const dl of downloads) {
if (dl.state === "completed" || dl.state === "cancelled") {
const deleted = deleteDownloadRecord(dl.id);
if (deleted) changed = true;
}
}
if (changed) fireDownloadsChanged();
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/ipc/browser/downloads.ts` around lines 51 - 61, The clear-completed
handler currently sets changed = true regardless of whether
deleteDownloadRecord(dl.id) actually succeeded; update the loop in the
ipcMain.handle("downloads:clear-completed") callback to check the boolean result
of deleteDownloadRecord for each dl and only set changed = true when
deleteDownloadRecord(...) returns true, optionally collect/log failed deletions
and avoid firing fireDownloadsChanged() if no records were actually removed;
keep references to listDownloads(), deleteDownloadRecord(), and
fireDownloadsChanged() so the change is localized to that handler.

Comment on lines +40 to +47
const handleClick = () => {
if (dl.state === "completed" && !fileMissing) {
void flow.downloads.openFile(dl.id);
} else {
flow.tabs.newTab("flow://downloads", true);
setOpen(false);
}
};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Missing error handling for openFile action.

The DownloadCard component shows a toast on openFile failure, but DownloadRow in the popover silently ignores errors. This inconsistency could confuse users when clicking a completed download fails without feedback.

Add error handling consistent with DownloadCard
+import { toast } from "sonner";
+
 // In DownloadRow:
   const handleClick = () => {
     if (dl.state === "completed" && !fileMissing) {
-      void flow.downloads.openFile(dl.id);
+      void flow.downloads.openFile(dl.id).then((ok) => {
+        if (!ok) toast.error("Could not open file");
+      });
     } else {
       flow.tabs.newTab("flow://downloads", true);
       setOpen(false);
     }
   };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const handleClick = () => {
if (dl.state === "completed" && !fileMissing) {
void flow.downloads.openFile(dl.id);
} else {
flow.tabs.newTab("flow://downloads", true);
setOpen(false);
}
};
const handleClick = () => {
if (dl.state === "completed" && !fileMissing) {
void flow.downloads.openFile(dl.id).then((ok) => {
if (!ok) toast.error("Could not open file");
});
} else {
flow.tabs.newTab("flow://downloads", true);
setOpen(false);
}
};
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/renderer/src/components/browser-ui/browser-sidebar/_components/bottom/downloads-popover.tsx`
around lines 40 - 47, handleClick in downloads-popover.tsx currently calls
flow.downloads.openFile(dl.id) without catching errors, so failures are silent;
update handleClick to mirror DownloadCard by awaiting or handling the promise
from flow.downloads.openFile(dl.id) and catching errors, and on catch show a
user toast (or similar UI feedback) before falling back to opening the downloads
tab and calling setOpen(false); reference handleClick, dl.id,
flow.downloads.openFile, flow.tabs.newTab and setOpen when locating the fix.

Comment on lines +147 to +159
const fetchDownloads = useCallback(async () => {
try {
const all = await flow.downloads.list();
setDownloads(all);
const idsToCheck = all.filter((d) => !isActive(d.state) && d.savePath).map((d) => d.id);
if (idsToCheck.length > 0) {
const existence = await flow.downloads.checkFilesExist(idsToCheck);
setFileExistence(existence);
}
} catch {
// silently ignore
}
}, []);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Missing mount guard - potential setState after unmount.

Unlike the DownloadsProvider, this component's fetchDownloads doesn't guard against setting state after the component unmounts. If the popover closes while checkFilesExist is in flight, React will log a warning about setting state on an unmounted component.

Add mount guard
+  const mountedRef = useRef(true);
+
+  useEffect(() => {
+    mountedRef.current = true;
+    return () => {
+      mountedRef.current = false;
+    };
+  }, []);
+
   const fetchDownloads = useCallback(async () => {
     try {
       const all = await flow.downloads.list();
+      if (!mountedRef.current) return;
       setDownloads(all);
       const idsToCheck = all.filter((d) => !isActive(d.state) && d.savePath).map((d) => d.id);
       if (idsToCheck.length > 0) {
         const existence = await flow.downloads.checkFilesExist(idsToCheck);
-        setFileExistence(existence);
+        if (mountedRef.current) setFileExistence(existence);
       }
     } catch {
       // silently ignore
     }
   }, []);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const fetchDownloads = useCallback(async () => {
try {
const all = await flow.downloads.list();
setDownloads(all);
const idsToCheck = all.filter((d) => !isActive(d.state) && d.savePath).map((d) => d.id);
if (idsToCheck.length > 0) {
const existence = await flow.downloads.checkFilesExist(idsToCheck);
setFileExistence(existence);
}
} catch {
// silently ignore
}
}, []);
const mountedRef = useRef(true);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
const fetchDownloads = useCallback(async () => {
try {
const all = await flow.downloads.list();
if (!mountedRef.current) return;
setDownloads(all);
const idsToCheck = all.filter((d) => !isActive(d.state) && d.savePath).map((d) => d.id);
if (idsToCheck.length > 0) {
const existence = await flow.downloads.checkFilesExist(idsToCheck);
if (mountedRef.current) setFileExistence(existence);
}
} catch {
// silently ignore
}
}, []);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/renderer/src/components/browser-ui/browser-sidebar/_components/bottom/downloads-popover.tsx`
around lines 147 - 159, fetchDownloads can call setDownloads/setFileExistence
after the component unmounts; add a mount guard to avoid setState-after-unmount
warnings. Inside the component create a ref like isMounted and set it true on
mount and false in a cleanup useEffect, then inside the useCallback
fetchDownloads check isMounted.current before calling setDownloads and
setFileExistence (and after awaiting flow.downloads.checkFilesExist) so no state
updates occur if the popover has closed; ensure the cleanup toggles the ref to
false to prevent updates from in-flight promises.

Comment on lines +44 to +47
const clearCompleted = async () => {
await flow.downloads.clearCompleted();
toast.success("Cleared completed downloads");
};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consider awaiting the toast or handling potential errors from clearCompleted.

If flow.downloads.clearCompleted() throws, the toast will still show success. Consider wrapping in try-catch or chaining the toast.

🛡️ Suggested improvement
   const clearCompleted = async () => {
-    await flow.downloads.clearCompleted();
-    toast.success("Cleared completed downloads");
+    try {
+      await flow.downloads.clearCompleted();
+      toast.success("Cleared completed downloads");
+    } catch {
+      toast.error("Failed to clear downloads");
+    }
   };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const clearCompleted = async () => {
await flow.downloads.clearCompleted();
toast.success("Cleared completed downloads");
};
const clearCompleted = async () => {
try {
await flow.downloads.clearCompleted();
toast.success("Cleared completed downloads");
} catch {
toast.error("Failed to clear downloads");
}
};
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/renderer/src/components/downloads/manager/main.tsx` around lines 44 - 47,
The clearCompleted handler currently calls flow.downloads.clearCompleted() then
immediately shows toast.success, which will display success even if
clearCompleted() throws; wrap the await call in a try-catch inside the
clearCompleted function, await flow.downloads.clearCompleted() first, call
toast.success only on success, and on error call toast.error with the caught
error message (and optionally rethrow or log via processLogger) so failures are
handled and reported; reference the clearCompleted function and
flow.downloads.clearCompleted and toast.success/toast.error when making the
change.

Comment on lines +22 to +40
const fetchDownloads = useCallback(async () => {
try {
const all = await flow.downloads.list();
if (!mountedRef.current) return;
setDownloads(all);
setIsError(false);

// Check file existence for non-active downloads
const idsToCheck = all.filter((dl) => !isActive(dl.state) && dl.savePath).map((dl) => dl.id);
if (idsToCheck.length > 0) {
const existence = await flow.downloads.checkFilesExist(idsToCheck);
if (mountedRef.current) setFileExistence(existence);
}
} catch {
if (mountedRef.current) setIsError(true);
} finally {
if (mountedRef.current) setIsLoading(false);
}
}, []);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consider clearing stale fileExistence entries on refresh.

When fetchDownloads runs, it only updates fileExistence for downloads currently in the list. If a download is removed, its entry remains in fileExistence state. While this doesn't cause visible bugs (the download card won't render), it's a minor memory leak for long-running sessions.

Clear fileExistence before updating
   const fetchDownloads = useCallback(async () => {
     try {
       const all = await flow.downloads.list();
       if (!mountedRef.current) return;
       setDownloads(all);
       setIsError(false);

       // Check file existence for non-active downloads
       const idsToCheck = all.filter((dl) => !isActive(dl.state) && dl.savePath).map((dl) => dl.id);
       if (idsToCheck.length > 0) {
         const existence = await flow.downloads.checkFilesExist(idsToCheck);
         if (mountedRef.current) setFileExistence(existence);
+      } else {
+        if (mountedRef.current) setFileExistence({});
       }
     } catch {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const fetchDownloads = useCallback(async () => {
try {
const all = await flow.downloads.list();
if (!mountedRef.current) return;
setDownloads(all);
setIsError(false);
// Check file existence for non-active downloads
const idsToCheck = all.filter((dl) => !isActive(dl.state) && dl.savePath).map((dl) => dl.id);
if (idsToCheck.length > 0) {
const existence = await flow.downloads.checkFilesExist(idsToCheck);
if (mountedRef.current) setFileExistence(existence);
}
} catch {
if (mountedRef.current) setIsError(true);
} finally {
if (mountedRef.current) setIsLoading(false);
}
}, []);
const fetchDownloads = useCallback(async () => {
try {
const all = await flow.downloads.list();
if (!mountedRef.current) return;
setDownloads(all);
setIsError(false);
// Check file existence for non-active downloads
const idsToCheck = all.filter((dl) => !isActive(dl.state) && dl.savePath).map((dl) => dl.id);
if (idsToCheck.length > 0) {
const existence = await flow.downloads.checkFilesExist(idsToCheck);
if (mountedRef.current) setFileExistence(existence);
} else {
if (mountedRef.current) setFileExistence({});
}
} catch {
if (mountedRef.current) setIsError(true);
} finally {
if (mountedRef.current) setIsLoading(false);
}
}, []);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/renderer/src/components/downloads/manager/provider.tsx` around lines 22 -
40, The fetchDownloads function leaves stale entries in fileExistence when
downloads are removed; modify fetchDownloads in provider.tsx so it clears or
trims fileExistence before/when updating: before calling
flow.downloads.checkFilesExist or immediately after getting all, compute the
current ids (all.map(dl => dl.id)) and call setFileExistence with either an
empty object or a filtered object limited to those ids, then merge/apply the
fresh existence results (from checkFilesExist) so only current downloads remain;
update references to setFileExistence and ensure this runs only when
mountedRef.current is true.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/renderer/src/routes/pdf-viewer/pdf-viewer/index.tsx (1)

11-26: ⚠️ Potential issue | 🟠 Major

Thumbsbar pre-load toggle state is not preserved correctly.

At Line 26, isThumbsbarOpen is derived from isDocumentLoaded, but Toolbar toggles using this derived value (src/renderer/src/routes/pdf-viewer/pdf-viewer/Toolbar/index.tsx:47-56). Before load, it is always false, so user clicks do not reliably represent intent, and the sidebar can open on load unexpectedly.

Proposed fix
-  const [_isThumbsbarOpen, setIsThumbsbarOpen] = useState(true);
+  const [isThumbsbarOpen, setIsThumbsbarOpen] = useState(true);
@@
-  const isThumbsbarOpen = isDocumentLoaded && _isThumbsbarOpen;
+  const isThumbsbarVisible = isDocumentLoaded && isThumbsbarOpen;
@@
-        <Toolbar {...{ usePDFSlickStore, setIsThumbsbarOpen, isThumbsbarOpen }} />
+        <Toolbar {...{ usePDFSlickStore, setIsThumbsbarOpen, isThumbsbarOpen }} />
@@
-          <Thumbsbar {...{ thumbsRef, usePDFSlickStore, isThumbsbarOpen }} />
+          <Thumbsbar {...{ thumbsRef, usePDFSlickStore, isThumbsbarOpen: isThumbsbarVisible }} />
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/renderer/src/routes/pdf-viewer/pdf-viewer/index.tsx` around lines 11 -
26, The issue is that isThumbsbarOpen is derived from isDocumentLoaded
(isThumbsbarOpen = isDocumentLoaded && _isThumbsbarOpen), so Toolbar toggles
that derived value and clicks before load are lost; fix it by keeping the
user-intent state separate and only derive the visible state for rendering: keep
the state variables _isThumbsbarOpen and setIsThumbsbarOpen as the source of
truth, update any toggles in Toolbar to call setIsThumbsbarOpen (not modify the
derived isThumbsbarOpen), and introduce a separate visible flag (e.g.,
thumbsbarVisible = isDocumentLoaded && _isThumbsbarOpen) used only for
rendering/passing to PDFSlickViewer; update references in index.tsx and Toolbar
to use these symbols (_isThumbsbarOpen, setIsThumbsbarOpen, isDocumentLoaded,
and the new thumbsbarVisible).
src/renderer/src/hooks/use-favicon-color.ts (1)

217-221: ⚠️ Potential issue | 🟠 Major

Add cleanup guard to prevent stale favicon color updates.

The unguarded promise callback creates a race condition: if faviconUrl changes before a pending extractFaviconColors resolves, the stale callback will overwrite the newer URL's colors.

Suggested fix
   useEffect(() => {
     if (!faviconUrl) {
       return;
     }

+    let cancelled = false;
+
     // Check cache first
     if (cachedColors) {
       return;
     }

     // Extract colors
     extractFaviconColors(faviconUrl).then((extractedColors) => {
+      if (cancelled) return;
       colorCache.set(faviconUrl, extractedColors);
       setColors(extractedColors);
     });
+
+    return () => {
+      cancelled = true;
+    };
   }, [faviconUrl, cachedColors]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/renderer/src/hooks/use-favicon-color.ts` around lines 217 - 221, The
promise callback from extractFaviconColors can overwrite newer state when
faviconUrl changes; modify the effect that calls extractFaviconColors so it
captures the current faviconUrl (e.g., const current = faviconUrl) or creates a
cancelled flag/AbortController and, in the .then handler, verify the captured
current === faviconUrl (or !cancelled) before calling colorCache.set(...) and
setColors(...); also ensure the effect cleanup sets the cancelled flag (or
aborts the controller) to prevent stale updates.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@eslint.config.mjs`:
- Around line 10-18: The current rules object overwrites
eslintPluginReactHooks.configs.flat.recommended, removing critical rules; update
the config so the recommended rules are preserved by merging them into your
rules key rather than replacing it — e.g., retrieve
eslintPluginReactHooks.configs.flat.recommended.rules and spread them into the
rules object before applying your overrides for "react-hooks/refs",
"react-hooks/set-state-in-effect", "react-hooks/purity", and
"react-hooks/immutability" so that rules-of-hooks and exhaustive-deps remain
active.

In `@package.json`:
- Line 77: The package.json currently depends on "@types/node": "^24.12.2" which
mismatches the project's Node.js 22 runtime; update the dependency entry for
"@types/node" to a 22.x range (e.g., "^22.0.0" or "^22.x.x") so the TypeScript
Node types align with Node 22, then run your package manager install and
type-check to ensure no further type errors.
- Line 113: Upgrade note: after changing the "typescript" devDependency to
6.0.2, audit and update tsconfig.json and source code for TS 6 breaking
changes—ensure tsconfig explicitly sets rootDir and types (because defaults
changed), remove or replace any deprecated/removed options (target: "es5",
--downlevelIteration, moduleResolution: "node", baseUrl, outFile), migrate any
module { } declarations to namespace, update import assertion syntax to the new
"with" attribute form, and ensure any CLI workflows that run tsc foo.ts in a
directory with tsconfig.json are changed to tsc -p or an explicit file list; run
a full tsc build to identify remaining errors and fix affected files
accordingly.

In `@src/main/ipc/webauthn/index.ts`:
- Around line 88-90: Fix the comment above the cast to CreateCredentialResult:
correct the property path from "prf.first" to "prf.results.first", describe the
types error (electron-webauthn declares prf.results.first as string | undefined
but we expect string), and replace the naked "TODO" with an actionable note
linking to a tracking reference (e.g. a GitHub issue or JIRA ID) and expected
remediation (remove the cast when upstream types are fixed) so future readers
can follow up; reference the file-level symbol (the return of result.data as
CreateCredentialResult in src/main/ipc/webauthn/index.ts) and the
electron-webauthn type mismatch in the comment.

In `@src/renderer/src/hooks/use-favicon-color.ts`:
- Around line 203-205: The cache-hit path in use-favicon-color is treating
cached null as a miss and never hydrating state: change the cache check to test
for existence (cachedColors !== undefined) after calling
colorCache.get(faviconUrl), and when that condition is true call
setColors(cachedColors) to hydrate the hook state; also adjust the colors result
logic so the hook returns the cached value immediately (e.g., use cachedColors
if cachedColors !== undefined else fall back to _colors) instead of relying on
stale _colors only.

In `@tsconfig.web.json`:
- Line 12: The tsconfig.web.json currently sets compilerOptions.types to the npm
package name ("@types/chrome") which prevents TypeScript from loading ambient
lib names; change the value in the "types" array to the ambient library name
"chrome" (i.e., replace "@types/chrome" with "chrome") so compilerOptions.types
correctly references the type library.

---

Outside diff comments:
In `@src/renderer/src/hooks/use-favicon-color.ts`:
- Around line 217-221: The promise callback from extractFaviconColors can
overwrite newer state when faviconUrl changes; modify the effect that calls
extractFaviconColors so it captures the current faviconUrl (e.g., const current
= faviconUrl) or creates a cancelled flag/AbortController and, in the .then
handler, verify the captured current === faviconUrl (or !cancelled) before
calling colorCache.set(...) and setColors(...); also ensure the effect cleanup
sets the cancelled flag (or aborts the controller) to prevent stale updates.

In `@src/renderer/src/routes/pdf-viewer/pdf-viewer/index.tsx`:
- Around line 11-26: The issue is that isThumbsbarOpen is derived from
isDocumentLoaded (isThumbsbarOpen = isDocumentLoaded && _isThumbsbarOpen), so
Toolbar toggles that derived value and clicks before load are lost; fix it by
keeping the user-intent state separate and only derive the visible state for
rendering: keep the state variables _isThumbsbarOpen and setIsThumbsbarOpen as
the source of truth, update any toggles in Toolbar to call setIsThumbsbarOpen
(not modify the derived isThumbsbarOpen), and introduce a separate visible flag
(e.g., thumbsbarVisible = isDocumentLoaded && _isThumbsbarOpen) used only for
rendering/passing to PDFSlickViewer; update references in index.tsx and Toolbar
to use these symbols (_isThumbsbarOpen, setIsThumbsbarOpen, isDocumentLoaded,
and the new thumbsbarVisible).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 43b36352-f877-4e22-860e-c34dcac27e71

📥 Commits

Reviewing files that changed from the base of the PR and between 26df532 and 9aa4d99.

⛔ Files ignored due to path filters (1)
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (16)
  • eslint.config.mjs
  • package.json
  • src/main/ipc/webauthn/index.ts
  • src/renderer/src/components/browser-ui/browser-content.tsx
  • src/renderer/src/components/settings/sections/spaces/section.tsx
  • src/renderer/src/components/ui/resizable-sidebar.tsx
  • src/renderer/src/components/ui/sidebar.tsx
  • src/renderer/src/hooks/use-css-size-to-pixels.tsx
  • src/renderer/src/hooks/use-favicon-color.ts
  • src/renderer/src/routes/error/page.tsx
  • src/renderer/src/routes/pdf-viewer/pdf-viewer/Toolbar/SearchBar.tsx
  • src/renderer/src/routes/pdf-viewer/pdf-viewer/index.tsx
  • src/shared/types/fido2-types.ts
  • tsconfig.node.json
  • tsconfig.scripts.json
  • tsconfig.web.json
💤 Files with no reviewable changes (5)
  • src/renderer/src/routes/error/page.tsx
  • src/renderer/src/routes/pdf-viewer/pdf-viewer/Toolbar/SearchBar.tsx
  • src/renderer/src/components/settings/sections/spaces/section.tsx
  • src/renderer/src/hooks/use-css-size-to-pixels.tsx
  • src/renderer/src/components/browser-ui/browser-content.tsx

Comment thread eslint.config.mjs
Comment on lines 10 to 18
{
settings: {
react: {
version: "detect"
}
files: ["**/*.{ts,tsx}"],
...eslintPluginReactHooks.configs.flat.recommended,
rules: {
"react-hooks/refs": "off",
"react-hooks/set-state-in-effect": "off",
"react-hooks/purity": "off",
"react-hooks/immutability": "off"
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Critical: rules object overwrites recommended rules from spread.

When an object property is defined after a spread, it completely replaces rather than merges with the spread value. The rules object on lines 13-17 will overwrite the rules from eslintPluginReactHooks.configs.flat.recommended, losing critical rules like rules-of-hooks and exhaustive-deps.

🐛 Proposed fix to preserve recommended rules
   {
     files: ["**/*.{ts,tsx}"],
     ...eslintPluginReactHooks.configs.flat.recommended,
     rules: {
+      ...eslintPluginReactHooks.configs.flat.recommended.rules,
       "react-hooks/refs": "off",
       "react-hooks/set-state-in-effect": "off",
       "react-hooks/purity": "off",
       "react-hooks/immutability": "off"
     }
   },
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{
settings: {
react: {
version: "detect"
}
files: ["**/*.{ts,tsx}"],
...eslintPluginReactHooks.configs.flat.recommended,
rules: {
"react-hooks/refs": "off",
"react-hooks/set-state-in-effect": "off",
"react-hooks/purity": "off",
"react-hooks/immutability": "off"
}
{
files: ["**/*.{ts,tsx}"],
...eslintPluginReactHooks.configs.flat.recommended,
rules: {
...eslintPluginReactHooks.configs.flat.recommended.rules,
"react-hooks/refs": "off",
"react-hooks/set-state-in-effect": "off",
"react-hooks/purity": "off",
"react-hooks/immutability": "off"
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@eslint.config.mjs` around lines 10 - 18, The current rules object overwrites
eslintPluginReactHooks.configs.flat.recommended, removing critical rules; update
the config so the recommended rules are preserved by merging them into your
rules key rather than replacing it — e.g., retrieve
eslintPluginReactHooks.configs.flat.recommended.rules and spread them into the
rules object before applying your overrides for "react-hooks/refs",
"react-hooks/set-state-in-effect", "react-hooks/purity", and
"react-hooks/immutability" so that rules-of-hooks and exhaustive-deps remain
active.

Comment thread package.json
"@types/d3-selection": "^3.0.11",
"@types/jju": "^1.4.5",
"@types/node": "^22.19.11",
"@types/node": "^24.12.2",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if `@types/node`@24.12.2 exists on npm
curl -s "https://registry.npmjs.org/@types/node/24.12.2" | jq '.version // "NOT FOUND"'

Repository: MultiboxLabs/flow-browser

Length of output: 78


🏁 Script executed:

cat -n .nvmrc

Repository: MultiboxLabs/flow-browser

Length of output: 79


🏁 Script executed:

sed -n '77p' package.json

Repository: MultiboxLabs/flow-browser

Length of output: 99


Update @types/node version to match Node.js 22.

The project uses Node.js 22 (as specified in .nvmrc), but @types/node@^24.12.2 is intended for Node.js 24. This mismatch can cause type inconsistencies and build issues. Change the dependency to @types/node@^22.x.x to align with the Node.js version.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@package.json` at line 77, The package.json currently depends on
"@types/node": "^24.12.2" which mismatches the project's Node.js 22 runtime;
update the dependency entry for "@types/node" to a 22.x range (e.g., "^22.0.0"
or "^22.x.x") so the TypeScript Node types align with Node 22, then run your
package manager install and type-check to ensure no further type errors.

Comment thread package.json
"tw-animate-css": "^1.2.9",
"typescript": "^5.8.3",
"vite": "^8.0.5"
"typescript": "^6.0.2",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

TypeScript 6.0 release date breaking changes

💡 Result:

TypeScript 6.0 release date (stable): March 23, 2026. [1]

Breaking changes / deprecations called out for TypeScript 6.0

From the official 6.0 release notes (and mirrored in the announcement post): [1][2]

  • New defaults

    • rootDir now defaults to . (current directory). [2]
    • types now defaults to [] (no automatic @types/* inclusion unless brought in via other means). [2]
  • Deprecated / removed compiler options & modes

    • Deprecated: target: "es5". [2]
    • Deprecated: --downlevelIteration. [2]
    • Deprecated: --moduleResolution node (aka node10). [2]
    • Deprecated: module values amd, umd, and systemjs. [2]
    • Deprecated: --baseUrl. [2]
    • Deprecated: --moduleResolution classic. [2]
    • Deprecated (effectively): --esModuleInterop false and --allowSyntheticDefaultImports false — the “safer interop” behavior is always enabled in 6.0. [2]
    • Deprecated (effectively): --alwaysStrict false — 6.0 assumes JavaScript strict mode semantics. [2]
    • Removed: --outFile. [2]
  • Syntax / directive changes

    • module {} for internal namespaces is now a hard deprecation (use namespace; declare module "pkg" is still supported). [1][2]
    • Import assertions syntax is now an error: import x from "./x.json" asserts { type: "json" } → use import attributes with with. [2]
    • /// <reference no-default-lib="true"/> no longer supported (use --noLib / --libReplacement instead). [2]
  • CLI behavior change

    • Running tsc foo.ts in a directory that has a tsconfig.json is now an error (use --ignoreConfig if you intended to bypass the config). [2]

Sources: TypeScript team announcement (Microsoft DevBlogs) [1] and the official TypeScript 6.0 release notes [2].


Review TypeScript 6.0 breaking changes before upgrading.

TypeScript 6.0.2 was released on March 23, 2026 and includes significant breaking changes that may impact this codebase:

  • New defaults: rootDir now defaults to . and types defaults to [] (no automatic @types/* inclusion)
  • Deprecated/removed: es5 target, --downlevelIteration, --moduleResolution node, --baseUrl, --outFile
  • Syntax changes: module {} is now a hard deprecation (use namespace), import assertions syntax requires migration to import attributes (with)
  • CLI behavior: Running tsc foo.ts in a directory with tsconfig.json is now an error

Verify that tsconfig.json and the codebase do not rely on removed/deprecated options and update import syntax accordingly.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@package.json` at line 113, Upgrade note: after changing the "typescript"
devDependency to 6.0.2, audit and update tsconfig.json and source code for TS 6
breaking changes—ensure tsconfig explicitly sets rootDir and types (because
defaults changed), remove or replace any deprecated/removed options (target:
"es5", --downlevelIteration, moduleResolution: "node", baseUrl, outFile),
migrate any module { } declarations to namespace, update import assertion syntax
to the new "with" attribute form, and ensure any CLI workflows that run tsc
foo.ts in a directory with tsconfig.json are changed to tsc -p or an explicit
file list; run a full tsc build to identify remaining errors and fix affected
files accordingly.

Comment on lines +88 to +90
// types error in electron-webauthn (TODO: fix this)
// result.data.extensions.prf.first should be `string`, but its `string | undefined` in the package types
return result.data as CreateCredentialResult;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Typo in comment and consider more actionable tracking for the workaround.

The comment references prf.first but the actual path is prf.results.first based on the type definition.

Additionally, the TODO would benefit from linking to a tracking issue or specifying when/how this will be addressed—naked TODO comments tend to be forgotten.

📝 Suggested comment fix
-    // types error in electron-webauthn (TODO: fix this)
-    // result.data.extensions.prf.first should be `string`, but its `string | undefined` in the package types
+    // WORKAROUND: electron-webauthn types `prf.results.first` as `string | undefined`,
+    // but when `results` is present, `first` is guaranteed per WebAuthn spec.
+    // TODO(electron-webauthn): Upstream fix or add runtime validation if spec changes.
     return result.data as CreateCredentialResult;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/ipc/webauthn/index.ts` around lines 88 - 90, Fix the comment above
the cast to CreateCredentialResult: correct the property path from "prf.first"
to "prf.results.first", describe the types error (electron-webauthn declares
prf.results.first as string | undefined but we expect string), and replace the
naked "TODO" with an actionable note linking to a tracking reference (e.g. a
GitHub issue or JIRA ID) and expected remediation (remove the cast when upstream
types are fixed) so future readers can follow up; reference the file-level
symbol (the return of result.data as CreateCredentialResult in
src/main/ipc/webauthn/index.ts) and the electron-webauthn type mismatch in the
comment.

Comment on lines +203 to 205
const cachedColors = faviconUrl ? colorCache.get(faviconUrl) : null;
const colors = faviconUrl ? _colors : null;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

cat -n src/renderer/src/hooks/use-favicon-color.ts | head -230 | tail -50

Repository: MultiboxLabs/flow-browser

Length of output: 1675


Cache-hit path is broken and can return stale/null colors indefinitely.

When a favicon URL exists in the cache, the hook checks cachedColors at line 212 and exits early without calling setColors(). This means the state _colors (returned at line 204) is never hydrated with the cached value, so the hook returns null despite colors being available. Additionally, the truthy check at line 212 fails for cached null values (a valid cached result), causing redundant extraction attempts and inability to distinguish between "no cache entry" and "cached null".

The fix needs to:

  1. Distinguish "no cache entry" (undefined) from "cached null" using cachedColors !== undefined
  2. Hydrate state with setColors(cachedColors) when a cache hit occurs
  3. Return the cached value immediately via ternary logic instead of relying solely on stale state
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/renderer/src/hooks/use-favicon-color.ts` around lines 203 - 205, The
cache-hit path in use-favicon-color is treating cached null as a miss and never
hydrating state: change the cache check to test for existence (cachedColors !==
undefined) after calling colorCache.get(faviconUrl), and when that condition is
true call setColors(cachedColors) to hydrate the hook state; also adjust the
colors result logic so the hook returns the cached value immediately (e.g., use
cachedColors if cachedColors !== undefined else fall back to _colors) instead of
relying on stale _colors only.

Comment thread tsconfig.web.json
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant