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
-
+ {canRetry && (
+
+ )}