fix(web): hide Retry when blob unavailable; failed-state surfaces actionable error
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).
This commit is contained in:
@@ -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
|
||||
<div
|
||||
className={`w-9 h-9 rounded-full ${bg} flex items-center justify-center`}
|
||||
style={state !== 'failed' ? { background: `conic-gradient(rgba(180,220,200,.85) ${pct}%, rgba(255,255,255,.15) ${pct}%)` } : undefined}
|
||||
title={state === 'failed' ? error : undefined}
|
||||
>
|
||||
<div className="w-7 h-7 rounded-full bg-surface-overlay text-[10px] text-txt-primary flex items-center justify-center font-medium">
|
||||
{state === 'failed' ? '!' : `${pct}%`}
|
||||
|
||||
@@ -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
|
||||
</span>
|
||||
<span className="w-px h-3.5 bg-accent-rose/25" aria-hidden="true" />
|
||||
<button
|
||||
onClick={async () => {
|
||||
const transfers = useTransferStore.getState().transfers;
|
||||
const retryIds = pending.transferIds.filter((tid) => {
|
||||
const s = transfers.get(tid)?.state;
|
||||
return s === 'failed' || s === 'aborted';
|
||||
});
|
||||
// Flip the bubble back to 'sending' first so the orchestrator
|
||||
// re-evaluates after the resumed transfers complete.
|
||||
usePendingMessageStore.getState().markSending(pending.clientId);
|
||||
for (const tid of retryIds) {
|
||||
await useTransferStore.getState().resumeUpload(tid);
|
||||
}
|
||||
}}
|
||||
className="px-2 py-0.5 rounded-md text-[11.5px] font-medium text-accent-mint bg-accent-mint/10 hover:bg-accent-mint/20 transition-colors"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
{canRetry && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
const transfers = useTransferStore.getState().transfers;
|
||||
const retryIds = pending.transferIds.filter((tid) => {
|
||||
const s = transfers.get(tid)?.state;
|
||||
return s === 'failed' || s === 'aborted';
|
||||
});
|
||||
// Flip the bubble back to 'sending' first so the orchestrator
|
||||
// re-evaluates after the resumed transfers complete.
|
||||
usePendingMessageStore.getState().markSending(pending.clientId);
|
||||
for (const tid of retryIds) {
|
||||
await useTransferStore.getState().resumeUpload(tid);
|
||||
}
|
||||
}}
|
||||
className="px-2 py-0.5 rounded-md text-[11.5px] font-medium text-accent-mint bg-accent-mint/10 hover:bg-accent-mint/20 transition-colors"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
const transfers = useTransferStore.getState().transfers;
|
||||
|
||||
Reference in New Issue
Block a user