fix(web): in-session pause/resume preserves File ref; aborted transfers flip bubble to failed
This commit is contained in:
@@ -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)) {
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user