fix(web): in-session pause/resume preserves File ref; aborted transfers flip bubble to failed

This commit is contained in:
Jannis Braun
2026-05-02 18:03:12 +02:00
parent 403c6e9075
commit a58b7459fb
3 changed files with 44 additions and 5 deletions
+8 -1
View File
@@ -132,6 +132,13 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
const pending = isPendingMessage(message) ? message.__pending : null; const pending = isPendingMessage(message) ? message.__pending : null;
const showInteractions = !pending; const showInteractions = !pending;
const transfersForRow = useTransferStore((s) => s.transfers);
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;
const channelKey: string = isPendingMessage(message) const channelKey: string = isPendingMessage(message)
? message.channelId || message.dmChannelId || '' ? message.channelId || message.dmChannelId || ''
: message.channelId || (message as MessageWithUser & { dmChannelId?: string }).dmChannelId || ''; : message.channelId || (message as MessageWithUser & { dmChannelId?: string }).dmChannelId || '';
@@ -503,7 +510,7 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
)} )}
{/* Failed-state retry/discard row */} {/* Failed-state retry/discard row */}
{pending?.state === 'failed' && ( {showRetryDiscardRow && pending && (
<div className="mt-1 flex gap-2 px-2 py-1 rounded-md bg-accent-rose/15 text-xs"> <div className="mt-1 flex gap-2 px-2 py-1 rounded-md bg-accent-rose/15 text-xs">
<button <button
onClick={async () => { onClick={async () => {
@@ -43,6 +43,23 @@ export function startPendingMessageOrchestrator(): void {
if (sig === lastStateSig) return; if (sig === lastStateSig) return;
lastStateSig = sig; lastStateSig = sig;
// Mark bubbles as 'failed' when any of their transfers are in a terminal-failure
// state (aborted or failed). This surfaces the retry/discard row in Message.tsx
// instead of leaving the bubble stuck in 'sending' forever.
const allTransfers = useTransferStore.getState().transfers;
for (const list of usePendingMessageStore.getState().bubbles.values()) {
for (const b of list) {
if (b.state !== 'sending') continue;
const anyBad = b.transferIds.some((tid) => {
const t = allTransfers.get(tid);
return t && (t.state === 'failed' || t.state === 'aborted');
});
if (anyBad) {
usePendingMessageStore.getState().markFailed(b.clientId);
}
}
}
const fresh = usePendingMessageStore.getState().listReadyForDeferredSend(); const fresh = usePendingMessageStore.getState().listReadyForDeferredSend();
for (const b of fresh) { for (const b of fresh) {
if (!sentClientIds.has(b.clientId)) { if (!sentClientIds.has(b.clientId)) {
+19 -4
View File
@@ -87,6 +87,12 @@ function uuid(): string {
// Live tus Upload instances — keyed by transferId. Not serializable, never persisted. // Live tus Upload instances — keyed by transferId. Not serializable, never persisted.
const liveUploads = new Map<string, Upload>(); const liveUploads = new Map<string, Upload>();
// Original File/Blob references — keyed by transferId. Survive pause; cleared
// on abort or success. Used for in-session resume when no FileSystemFileHandle exists
// (file picker, paste, or drag-drop on Firefox/Safari). Cross-reload resume still
// requires a handle — that path uses idbHandles.
const liveUploadFiles = new Map<string, Blob>();
// Live download AbortControllers — keyed by transferId. Not serializable, never persisted. // Live download AbortControllers — keyed by transferId. Not serializable, never persisted.
const liveDownloads = new Map<string, AbortController>(); const liveDownloads = new Map<string, AbortController>();
@@ -254,6 +260,7 @@ export const useTransferStore = create<TransferStore>()(
get().setError(id, { message: msg, permanent: true }); get().setError(id, { message: msg, permanent: true });
} finally { } finally {
liveUploads.delete(id); liveUploads.delete(id);
liveUploadFiles.delete(id);
} }
}, },
onError: (err: Error) => { onError: (err: Error) => {
@@ -267,6 +274,7 @@ export const useTransferStore = create<TransferStore>()(
const upload = new Upload(file as File, tusOpts); const upload = new Upload(file as File, tusOpts);
liveUploads.set(id, upload); liveUploads.set(id, upload);
liveUploadFiles.set(id, file);
upload.start(); upload.start();
return id; return id;
}, },
@@ -279,6 +287,7 @@ export const useTransferStore = create<TransferStore>()(
void u.abort(true).catch(() => { /* server may be unreachable; that's OK */ }); void u.abort(true).catch(() => { /* server may be unreachable; that's OK */ });
liveUploads.delete(id); liveUploads.delete(id);
} }
liveUploadFiles.delete(id);
get().setState_(id, 'aborted'); get().setState_(id, 'aborted');
}, },
@@ -310,9 +319,12 @@ export const useTransferStore = create<TransferStore>()(
return; return;
} }
// Try to reacquire the file via the persisted FileSystemFileHandle. // Try in-memory File (same-session resume — file picker, paste, browsers
let blob: Blob | undefined; // without getAsFileSystemHandle).
if (t.fileHandleId) { let blob: Blob | undefined = liveUploadFiles.get(id);
// Fall back to persisted FileSystemFileHandle (cross-reload resume on Chrome/Edge).
if (!blob && t.fileHandleId) {
const { getHandle, ensurePermission } = await import('../utils/idbHandles'); const { getHandle, ensurePermission } = await import('../utils/idbHandles');
const handle = await getHandle(t.fileHandleId); const handle = await getHandle(t.fileHandleId);
if (handle) { if (handle) {
@@ -327,7 +339,9 @@ export const useTransferStore = create<TransferStore>()(
} }
if (!blob) { if (!blob) {
// No handle path — UI surfaces "re-pick to resume". Stay paused. // No handle, no in-memory file — surface "re-pick to resume" to the user.
// (Cross-reload on Firefox/Safari or after MessageInput unmount that cleared
// its preview-URL ref will land here.)
get().setState_(id, 'paused'); get().setState_(id, 'paused');
return; return;
} }
@@ -354,6 +368,7 @@ export const useTransferStore = create<TransferStore>()(
get().setError(id, { message: msg, permanent: true }); get().setError(id, { message: msg, permanent: true });
} finally { } finally {
liveUploads.delete(id); liveUploads.delete(id);
liveUploadFiles.delete(id);
} }
}, },
onError: (err: Error) => { onError: (err: Error) => {