From 33cfc66ac40bedbe2ac314064ea57db36fb1be06 Mon Sep 17 00:00:00 2001
From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com>
Date: Sat, 2 May 2026 19:10:29 +0200
Subject: [PATCH] fix(web): boot rehydrate normalizes transfers; paused has its
own visual
---
docs/systems/uploads.md | 14 ++-
.../components/chat/AttachmentProgress.tsx | 26 +++++-
packages/web/src/stores/transferStore.ts | 86 +++++++++++++++++++
packages/web/src/utils/idbHandles.ts | 17 ++++
4 files changed, 138 insertions(+), 5 deletions(-)
diff --git a/docs/systems/uploads.md b/docs/systems/uploads.md
index 777da120..e0f7e244 100644
--- a/docs/systems/uploads.md
+++ b/docs/systems/uploads.md
@@ -481,11 +481,23 @@ Three stores with strict separation of concerns:
### Reload Survival
- `transferStore`, `composerStore`, `pendingMessageStore` all use Zustand `persist` against versioned `localStorage` keys (`transferStore@v1`, `composerStore@v1`, `pendingMessageStore@v1`).
-- File handles (Chrome/Edge picker + drag-drop only) are persisted to IndexedDB via `idbHandles.ts` keyed by `transfer.fileHandleId`. Permission is re-prompted on resume.
+- File handles (Chrome/Edge picker + drag-drop only) are persisted to IndexedDB via `idbHandles.ts` keyed by `transfer.fileHandleId`. Permission is re-prompted on resume only when the explicit Resume click provides a user-gesture; the boot path queries silently and never prompts.
- Bytes themselves never persist. On unsupported browsers the user re-picks (uploads) or restarts (downloads).
- TTL: pending bubbles whose `tusExpiresAt` is past are dropped on rehydrate with a one-time toast.
- All-transfers-already-complete branch: if every transferId in a rehydrated pending bubble already has an `attachmentId`, the deferred `POST /messages` fires immediately on app start.
+#### Boot-time normalization
+
+On store rehydrate (`onRehydrateStorage` in `transferStore.ts` -> `normalizeRehydratedTransfers`):
+
+1. Any transfer left in `'active'` state (defensive — `partialize` already filters most) is demoted to `'paused'`. No live worker exists post-reload.
+2. For each `'paused'` transfer, if the bytes are unrecoverable (upload with no `fileHandleId`; download with no `destFileHandleId`), the transfer is marked `'failed'` with an actionable message ("File no longer available — discard and re-upload" / "Download cannot resume — bytes lost. Restart the download."). The UI then surfaces the discard control instead of a misleading paused state.
+3. For each `'paused'` transfer with a stored handle, the rehydrate path *silently* queries permission via `queryHandlePermission` (never calls `requestPermission`, so no user-gesture is required and no prompt appears). If `'granted'`, the transfer auto-resumes on the next tick. If `'prompt'` or `'denied'`, it stays paused — the user's click on Resume provides the user-gesture for `requestPermission`.
+
+Idempotent: re-running is harmless (already-resumed transfers move to `'active'`, no-op for completed/failed).
+
+The paused state is visually distinct in `AttachmentProgress.tsx`: desaturated grey conic-gradient ring with a centered pause-icon disk (instead of the mint ring + percentage text used while active), so the user can immediately tell "nothing is happening" from "in progress".
+
### Attachment Rendering (`AttachmentRenderer.tsx`)
URL resolution for attachment/thumbnail:
diff --git a/packages/web/src/components/chat/AttachmentProgress.tsx b/packages/web/src/components/chat/AttachmentProgress.tsx
index 06f8e826..d7388130 100644
--- a/packages/web/src/components/chat/AttachmentProgress.tsx
+++ b/packages/web/src/components/chat/AttachmentProgress.tsx
@@ -23,17 +23,35 @@ function fmt(bytes: number): string {
export function AttachmentProgress({ loaded, total, state, filename, error, onPause, onResume, onAbort, size = 'tile' }: Props) {
const pct = total > 0 ? Math.min(100, Math.round((loaded / total) * 100)) : 0;
- const bg = state === 'failed' ? 'bg-accent-rose/30' : 'bg-accent-mint/30';
+ const bg = state === 'failed' ? 'bg-accent-rose/30' : state === 'paused' ? 'bg-white/5' : 'bg-accent-mint/30';
const isFinal = state === 'completed' || state === 'aborted';
+ // Desaturate the conic-gradient ring when paused so it can't be mistaken for
+ // active progress. Active uses mint; paused uses a muted grey.
+ const ringColor = state === 'paused' ? 'rgba(180,180,190,.5)' : 'rgba(180,220,200,.85)';
+ const ringTrack = 'rgba(255,255,255,.15)';
+ const ringTitle = state === 'failed'
+ ? error
+ : state === 'paused'
+ ? `Paused — ${pct}%`
+ : undefined;
return (