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 (
- {state === 'failed' ? '!' : `${pct}%`} + {state === 'paused' ? ( + + ) : state === 'failed' ? ( + '!' + ) : ( + `${pct}%` + )}
{size === 'tile' && ( diff --git a/packages/web/src/stores/transferStore.ts b/packages/web/src/stores/transferStore.ts index 1425c6ca..9e75b028 100644 --- a/packages/web/src/stores/transferStore.ts +++ b/packages/web/src/stores/transferStore.ts @@ -688,6 +688,92 @@ export const useTransferStore = create()( ) ), }), + onRehydrateStorage: () => (state, error) => { + if (error || !state) return; + // Normalize on the next tick so the store is fully wired before we mutate + // it, and so async work (handle probe + auto-resume) doesn't block the + // rehydrate path. + queueMicrotask(() => { + void normalizeRehydratedTransfers(); + }); + }, }, ), ); + +/** + * Boot-time normalization of rehydrated transfers. + * + * 1. Demotes any leaked 'active' state to 'paused' (defensive — partialize already + * filters most, but a transfer mid-progress can still slip through if it had a + * tusUploadUrl). + * 2. Marks bytes-unrecoverable paused transfers as 'failed' with an actionable + * message — we have no in-memory File post-reload and no FS handle to reacquire + * bytes from, so showing a paused state would be misleading. + * 3. For paused transfers with a stored handle whose permission is already + * 'granted', silently auto-resumes. For 'prompt' / 'denied', leaves paused so + * the user's explicit Resume click provides the user-gesture for + * `requestPermission`. + * + * Idempotent — re-running is harmless. + */ +async function normalizeRehydratedTransfers(): Promise { + const store = useTransferStore.getState(); + const transfers = Array.from(store.transfers.values()); + + for (const t of transfers) { + // 1) Demote any leaked 'active' state — no live worker exists post-reload. + if (t.state === 'active') { + store.setState_(t.id, 'paused'); + } + + const current = useTransferStore.getState().get(t.id); + if (!current) continue; + + // 2) Bytes-unrecoverable paused transfers → immediately fail. + if (current.state === 'paused') { + const isUpload = current.type === 'upload'; + const handleId = isUpload ? current.fileHandleId : current.destFileHandleId; + if (!handleId) { + store.setError(current.id, { + message: isUpload + ? 'File no longer available — discard and re-upload' + : 'Download cannot resume — bytes lost. Restart the download.', + permanent: true, + }); + continue; + } + + // 3) Auto-resume when permission is already 'granted'. + try { + const { getHandle, queryHandlePermission } = await import('../utils/idbHandles'); + const handle = await getHandle(handleId); + if (!handle) { + store.setError(current.id, { + message: isUpload + ? 'File handle missing — discard and re-upload' + : 'Destination handle missing — restart the download', + permanent: true, + }); + continue; + } + const mode: 'read' | 'readwrite' = isUpload ? 'read' : 'readwrite'; + const perm = await queryHandlePermission(handle, mode); + if (perm === 'granted') { + // Use the live store reference so subsequent state changes are visible + // to subscribers. + if (isUpload) { + void useTransferStore.getState().resumeUpload(current.id); + } else { + void useTransferStore.getState().resumeDownload(current.id); + } + } + // perm === 'prompt' or 'denied' → user must click Resume to trigger + // requestPermission with a user-gesture. + } catch { + // Probe failed (IDB unavailable, etc.) — leave paused. User can click + // Resume to retry through the normal path. + } + } + } +} diff --git a/packages/web/src/utils/idbHandles.ts b/packages/web/src/utils/idbHandles.ts index 4018c042..61a49bfd 100644 --- a/packages/web/src/utils/idbHandles.ts +++ b/packages/web/src/utils/idbHandles.ts @@ -54,6 +54,23 @@ export function supportsDnDHandles(): boolean { && typeof (DataTransferItem.prototype as unknown as { getAsFileSystemHandle?: unknown }).getAsFileSystemHandle === 'function'; } +/** + * Silently query the current permission state on a stored handle. + * Never prompts; returns whatever the browser reports right now. Used by the + * boot-time rehydrate path so we can auto-resume only when permission is + * already 'granted' and avoid a "user gesture required" failure for 'prompt'. + */ +export async function queryHandlePermission( + handle: FileSystemHandle, + mode: 'read' | 'readwrite', +): Promise { + const handleAny = handle as unknown as { + queryPermission?: (opts: { mode: string }) => Promise; + }; + if (typeof handleAny.queryPermission !== 'function') return 'denied'; + return handleAny.queryPermission({ mode }); +} + /** * Re-prompt for permission on a stored handle. Returns 'granted', 'denied', or 'prompt'. * Some non-standard FS Access surfaces don't expose `queryPermission`/`requestPermission` —