From 61be9d013fdc4f5dfdc44168c223820f41fed9df Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sat, 2 May 2026 18:58:05 +0200 Subject: [PATCH] fix(web): hide Retry when blob unavailable; failed-state surfaces actionable error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pending bubbles that survive a reload (or post-redeploy refresh) without a FileSystemFileHandle had no way to recover the original File bytes, so the Retry button silently no-op'd: resumeUpload set state back to 'paused' with no surfaced error, leaving the user stuck. - transferStore: add reactive hasInMemoryFile Set mirroring liveUploadFiles. setInMemoryRef helper keeps both in sync at every set/delete site (startUpload, resumeUpload, onSuccess, remove). Persisted shape is unchanged. - resumeUpload: when no blob is reachable (no in-memory File and no FS handle), call setError with an actionable "File no longer available — discard and re-upload" message instead of silently flipping back to 'paused'. - Message.tsx: compute canRetry reactively from transfersForRow + hasInMemoryFile; hide the Retry button when retry is infeasible. Discard remains. - AttachmentProgress: optional error prop surfaces transfer.error.message via title= on the failed-state ring for hover context. - Tests: existing 3 resumeUpload tests now assert state==='failed' with the actionable message; +2 new tests for hasInMemoryFile lifecycle (start/remove, abort retains). --- .../components/chat/AttachmentProgress.tsx | 5 +- packages/web/src/components/chat/Message.tsx | 50 ++++++++----- packages/web/src/stores/transferStore.ts | 61 ++++++++++++--- .../src/stores/transferStore.upload.test.ts | 75 ++++++++++++++++--- 4 files changed, 149 insertions(+), 42 deletions(-) diff --git a/packages/web/src/components/chat/AttachmentProgress.tsx b/packages/web/src/components/chat/AttachmentProgress.tsx index 668289f1..06f8e826 100644 --- a/packages/web/src/components/chat/AttachmentProgress.tsx +++ b/packages/web/src/components/chat/AttachmentProgress.tsx @@ -6,6 +6,8 @@ interface Props { total: number; state: TransferState; filename: string; + /** Optional human-readable error surfaced as a hover tooltip when state==='failed'. */ + error?: string; onPause?: () => void; onResume?: () => void; onAbort?: () => void; @@ -19,7 +21,7 @@ function fmt(bytes: number): string { return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`; } -export function AttachmentProgress({ loaded, total, state, filename, onPause, onResume, onAbort, size = 'tile' }: Props) { +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 isFinal = state === 'completed' || state === 'aborted'; @@ -28,6 +30,7 @@ export function AttachmentProgress({ loaded, total, state, filename, onPause, on
{state === 'failed' ? '!' : `${pct}%`} diff --git a/packages/web/src/components/chat/Message.tsx b/packages/web/src/components/chat/Message.tsx index 6c046bc2..c32dc605 100644 --- a/packages/web/src/components/chat/Message.tsx +++ b/packages/web/src/components/chat/Message.tsx @@ -51,6 +51,7 @@ function PendingAttachmentTile({ transferId }: PendingAttachmentTileProps) { total={transfer.progress.total} state={transfer.state} filename={transfer.file.name} + error={transfer.error?.message} onPause={transfer.state === 'active' ? () => pauseUpload(transfer.id) : undefined} onResume={transfer.state === 'paused' ? () => resumeUpload(transfer.id) : undefined} onAbort={() => abortUpload(transfer.id)} @@ -133,11 +134,22 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId const showInteractions = !pending; const transfersForRow = useTransferStore((s) => s.transfers); + const inMemoryFiles = useTransferStore((s) => s.hasInMemoryFile); const anyTransferTerminallyBad = !!pending && pending.transferIds.some((tid) => { const t = transfersForRow.get(tid); return t && (t.state === 'failed' || t.state === 'aborted'); }); const showRetryDiscardRow = pending?.state === 'failed' || anyTransferTerminallyBad; + // Retry is only feasible when we can re-source the bytes for every transfer in the + // pending row — either the in-memory File ref still exists (same-session retry) + // or a persisted FileSystemFileHandle can reacquire the bytes (Chrome/Edge drag-drop). + // After a reload (or post-redeploy refresh) without a handle, both are gone, and + // showing a Retry button that silently no-ops would strand the user. Hide it instead. + const canRetry = !!pending && pending.transferIds.every((tid) => { + const t = transfersForRow.get(tid); + if (!t || t.type !== 'upload') return false; + return inMemoryFiles.has(tid) || !!t.fileHandleId; + }); const channelKey: string = isPendingMessage(message) ? message.channelId || message.dmChannelId || '' @@ -519,24 +531,26 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId Upload failed