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 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)
? message.channelId || message.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 */}
{pending?.state === 'failed' && (
{showRetryDiscardRow && pending && (
<div className="mt-1 flex gap-2 px-2 py-1 rounded-md bg-accent-rose/15 text-xs">
<button
onClick={async () => {
@@ -43,6 +43,23 @@ export function startPendingMessageOrchestrator(): void {
if (sig === lastStateSig) return;
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();
for (const b of fresh) {
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.
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.
const liveDownloads = new Map<string, AbortController>();
@@ -254,6 +260,7 @@ export const useTransferStore = create<TransferStore>()(
get().setError(id, { message: msg, permanent: true });
} finally {
liveUploads.delete(id);
liveUploadFiles.delete(id);
}
},
onError: (err: Error) => {
@@ -267,6 +274,7 @@ export const useTransferStore = create<TransferStore>()(
const upload = new Upload(file as File, tusOpts);
liveUploads.set(id, upload);
liveUploadFiles.set(id, file);
upload.start();
return id;
},
@@ -279,6 +287,7 @@ export const useTransferStore = create<TransferStore>()(
void u.abort(true).catch(() => { /* server may be unreachable; that's OK */ });
liveUploads.delete(id);
}
liveUploadFiles.delete(id);
get().setState_(id, 'aborted');
},
@@ -310,9 +319,12 @@ export const useTransferStore = create<TransferStore>()(
return;
}
// Try to reacquire the file via the persisted FileSystemFileHandle.
let blob: Blob | undefined;
if (t.fileHandleId) {
// Try in-memory File (same-session resume — file picker, paste, browsers
// without getAsFileSystemHandle).
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 handle = await getHandle(t.fileHandleId);
if (handle) {
@@ -327,7 +339,9 @@ export const useTransferStore = create<TransferStore>()(
}
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');
return;
}
@@ -354,6 +368,7 @@ export const useTransferStore = create<TransferStore>()(
get().setError(id, { message: msg, permanent: true });
} finally {
liveUploads.delete(id);
liveUploadFiles.delete(id);
}
},
onError: (err: Error) => {