diff --git a/web/src/components/AssistantChat/HappyComposer.tsx b/web/src/components/AssistantChat/HappyComposer.tsx index 9e28c9a7..0c79e921 100644 --- a/web/src/components/AssistantChat/HappyComposer.tsx +++ b/web/src/components/AssistantChat/HappyComposer.tsx @@ -306,7 +306,24 @@ export function HappyComposer(props: { const textareaRef = useRef(null) const prevControlledByUser = useRef(controlledByUser) - useComposerDraft(sessionId, composerText, (text) => api.composer().setText(text)) + const attachmentDrafts = attachments.flatMap((attachment) => { + if (!attachment.file) return [] + const upload = attachment as typeof attachment & { path?: string; previewUrl?: string } + return [{ + id: attachment.id, + file: attachment.file, + path: upload.path, + previewUrl: upload.previewUrl, + }] + }) + useComposerDraft( + sessionId, + composerText, + attachmentDrafts, + active, + (text) => api.composer().setText(text), + (file) => api.composer().addAttachment(file), + ) // assistant-ui clears `composer.text` synchronously the moment a send is // invoked AND `SessionChat.handleSend` clears `pendingSchedule` the diff --git a/web/src/hooks/useComposerDraft.test.ts b/web/src/hooks/useComposerDraft.test.ts index e0eb6b04..b266b938 100644 --- a/web/src/hooks/useComposerDraft.test.ts +++ b/web/src/hooks/useComposerDraft.test.ts @@ -6,18 +6,27 @@ vi.mock('@/lib/composer-drafts', () => ({ getDraft: vi.fn(() => ''), saveDraft: vi.fn(), })) +vi.mock('@/lib/composer-attachment-drafts', () => ({ + getDraftAttachments: vi.fn(async () => []), + saveDraftAttachments: vi.fn(), +})) import { getDraft, saveDraft } from '@/lib/composer-drafts' +import { getDraftAttachments, saveDraftAttachments } from '@/lib/composer-attachment-drafts' import { useComposerDraft } from './useComposerDraft' const mockGetDraft = vi.mocked(getDraft) const mockSaveDraft = vi.mocked(saveDraft) +const mockGetDraftAttachments = vi.mocked(getDraftAttachments) +const mockSaveDraftAttachments = vi.mocked(saveDraftAttachments) describe('useComposerDraft', () => { let rAFCallbacks: Array<() => void> beforeEach(() => { vi.clearAllMocks() + mockGetDraft.mockReturnValue('') + mockGetDraftAttachments.mockResolvedValue([]) rAFCallbacks = [] vi.stubGlobal('requestAnimationFrame', vi.fn((cb: () => void) => { rAFCallbacks.push(cb) @@ -30,61 +39,60 @@ describe('useComposerDraft', () => { vi.unstubAllGlobals() }) - function flushRAF() { + async function flushRAF() { const cbs = [...rAFCallbacks] rAFCallbacks = [] cbs.forEach(cb => cb()) + await Promise.resolve() + await Promise.resolve() } - it('restores saved draft on mount via requestAnimationFrame', () => { + it('restores saved draft on mount via requestAnimationFrame', async () => { mockGetDraft.mockReturnValue('saved text') const setText = vi.fn() - renderHook(() => useComposerDraft('session-1', '', setText)) + renderHook(() => useComposerDraft('session-1', '', [], true, setText, vi.fn())) // Before rAF fires, setText should not have been called expect(setText).not.toHaveBeenCalled() // Flush rAF - act(() => flushRAF()) - + await act(async () => flushRAF()) expect(mockGetDraft).toHaveBeenCalledWith('session-1') expect(setText).toHaveBeenCalledWith('saved text') }) - it('does not restore draft if composer already has text', () => { + it('does not restore draft if composer already has text', async () => { mockGetDraft.mockReturnValue('saved text') const setText = vi.fn() - renderHook(() => useComposerDraft('session-1', 'user is typing', setText)) - - act(() => flushRAF()) + renderHook(() => useComposerDraft('session-1', 'user is typing', [], true, setText, vi.fn())) + await act(async () => flushRAF()) expect(setText).not.toHaveBeenCalled() }) - it('does not restore if draft is empty', () => { + it('does not restore if draft is empty', async () => { mockGetDraft.mockReturnValue('') const setText = vi.fn() - renderHook(() => useComposerDraft('session-1', '', setText)) - - act(() => flushRAF()) + renderHook(() => useComposerDraft('session-1', '', [], true, setText, vi.fn())) + await act(async () => flushRAF()) expect(setText).not.toHaveBeenCalled() }) - it('saves draft on unmount after rAF has fired', () => { + it('saves draft on unmount after rAF has fired', async () => { mockGetDraft.mockReturnValue('') const setText = vi.fn() const { unmount, rerender } = renderHook( - ({ text }) => useComposerDraft('session-1', text, setText), + ({ text }) => useComposerDraft('session-1', text, [], true, setText, vi.fn()), { initialProps: { text: '' } }, ) // Fire rAF to set draftReady = true - act(() => flushRAF()) + await act(async () => flushRAF()) // Simulate user typing rerender({ text: 'my draft' }) @@ -92,6 +100,7 @@ describe('useComposerDraft', () => { unmount() expect(mockSaveDraft).toHaveBeenCalledWith('session-1', 'my draft') + expect(mockSaveDraftAttachments).toHaveBeenCalledWith('session-1', []) }) it('does not save draft on unmount before rAF has fired', () => { @@ -99,7 +108,7 @@ describe('useComposerDraft', () => { const setText = vi.fn() const { unmount } = renderHook( - () => useComposerDraft('session-1', 'some text', setText), + () => useComposerDraft('session-1', 'some text', [], true, setText, vi.fn()), ) // Unmount before rAF fires (draftReady is still false) @@ -109,18 +118,57 @@ describe('useComposerDraft', () => { expect(vi.mocked(cancelAnimationFrame)).toHaveBeenCalled() }) - it('does nothing when sessionId is undefined', () => { + it('does nothing when sessionId is undefined', async () => { const setText = vi.fn() const { unmount } = renderHook( - () => useComposerDraft(undefined, 'text', setText), + () => useComposerDraft(undefined, 'text', [], true, setText, vi.fn()), ) - act(() => flushRAF()) + await act(async () => flushRAF()) unmount() expect(mockGetDraft).not.toHaveBeenCalled() expect(mockSaveDraft).not.toHaveBeenCalled() expect(setText).not.toHaveBeenCalled() }) + + it('restores saved attachments when the composer is empty', async () => { + const file = new File(['image'], 'image.png', { type: 'image/png' }) + mockGetDraftAttachments.mockResolvedValue([file]) + const addAttachment = vi.fn(async () => {}) + + renderHook(() => useComposerDraft('session-1', '', [], true, vi.fn(), addAttachment)) + await act(async () => flushRAF()) + + expect(addAttachment).toHaveBeenCalledWith(file) + }) + + it('does not duplicate saved attachments when the composer already has files', async () => { + const current = new File(['current'], 'current.png', { type: 'image/png' }) + const saved = new File(['saved'], 'saved.png', { type: 'image/png' }) + mockGetDraftAttachments.mockResolvedValue([saved]) + const addAttachment = vi.fn(async () => {}) + + renderHook(() => useComposerDraft('session-1', '', [{ id: 'current', file: current }], true, vi.fn(), addAttachment)) + await act(async () => flushRAF()) + + expect(addAttachment).not.toHaveBeenCalled() + }) + + it('preserves saved attachments while the attachment adapter is unavailable', async () => { + const saved = new File(['saved'], 'saved.png', { type: 'image/png' }) + mockGetDraftAttachments.mockResolvedValue([saved]) + const addAttachment = vi.fn(async () => {}) + + const { unmount } = renderHook(() => ( + useComposerDraft('session-1', '', [], false, vi.fn(), addAttachment) + )) + await act(async () => flushRAF()) + unmount() + + expect(mockGetDraftAttachments).not.toHaveBeenCalled() + expect(addAttachment).not.toHaveBeenCalled() + expect(mockSaveDraftAttachments).not.toHaveBeenCalled() + }) }) diff --git a/web/src/hooks/useComposerDraft.ts b/web/src/hooks/useComposerDraft.ts index 431a04c4..04ffa958 100644 --- a/web/src/hooks/useComposerDraft.ts +++ b/web/src/hooks/useComposerDraft.ts @@ -1,41 +1,73 @@ import { useEffect, useRef } from 'react' import { getDraft, saveDraft } from '@/lib/composer-drafts' +import { + getDraftAttachments, + saveDraftAttachments, + type AttachmentDraftInput, +} from '@/lib/composer-attachment-drafts' /** * Manages draft save/restore lifecycle for a composer. * * - On mount: restores saved draft via `setText` (deferred by one animation frame) - * - On unmount: saves current text as draft + * - On mount: restores saved attachment files through the composer adapter + * - On unmount: saves current text and attachment files as a draft * - The `draftReady` guard prevents saving before the initial restore completes, * avoiding the case where the runtime's empty initial text overwrites a real draft. */ export function useComposerDraft( sessionId: string | undefined, composerText: string, + attachments: readonly AttachmentDraftInput[], + canRestoreAttachments: boolean, setText: (text: string) => void, + addAttachment: (file: File) => Promise, ): void { const composerTextRef = useRef(composerText) composerTextRef.current = composerText + const attachmentsRef = useRef(attachments) + attachmentsRef.current = attachments const draftReadyRef = useRef(false) + const attachmentsReadyRef = useRef(false) useEffect(() => { if (!sessionId) return + let disposed = false const frame = requestAnimationFrame(() => { const draft = getDraft(sessionId) if (draft && !composerTextRef.current) { setText(draft) } draftReadyRef.current = true + if (canRestoreAttachments) { + void getDraftAttachments(sessionId).then(async (files) => { + if (!disposed && attachmentsRef.current.length === 0) { + for (const file of files) { + if (disposed) break + await addAttachment(file) + } + } + }).catch(() => { + // Attachment draft restoration is best effort. + }).finally(() => { + if (!disposed) attachmentsReadyRef.current = true + }) + } }) return () => { + disposed = true cancelAnimationFrame(frame) if (draftReadyRef.current) { saveDraft(sessionId, composerTextRef.current) } + if (attachmentsRef.current.length > 0 || (canRestoreAttachments && attachmentsReadyRef.current)) { + saveDraftAttachments(sessionId, [...attachmentsRef.current]) + } draftReadyRef.current = false + attachmentsReadyRef.current = false } - }, [sessionId]) // eslint-disable-line react-hooks/exhaustive-deps + }, [sessionId, canRestoreAttachments]) // eslint-disable-line react-hooks/exhaustive-deps } diff --git a/web/src/lib/attachmentAdapter.test.ts b/web/src/lib/attachmentAdapter.test.ts new file mode 100644 index 00000000..4a0e39b0 --- /dev/null +++ b/web/src/lib/attachmentAdapter.test.ts @@ -0,0 +1,42 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +describe('attachmentAdapter restored uploads', () => { + beforeEach(() => { + vi.stubGlobal('indexedDB', undefined) + vi.resetModules() + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('restores an uploaded draft without uploading it again', async () => { + const drafts = await import('./composer-attachment-drafts') + const { createAttachmentAdapter } = await import('./attachmentAdapter') + const file = new File(['image'], 'ready.png', { type: 'image/png' }) + drafts.saveDraftAttachments('session-1', [{ + id: 'attachment-ready', + file, + path: '/uploads/ready.png', + previewUrl: 'data:image/png;base64,aW1hZ2U=', + }]) + const [restored] = await drafts.getDraftAttachments('session-1') + expect(restored).toBeDefined() + + const uploadFile = vi.fn() + const adapter = createAttachmentAdapter({ uploadFile } as never, 'session-1') + const emitted = [] + const additions = adapter.add({ file: restored! }) as AsyncIterable + for await (const attachment of additions) { + emitted.push(attachment) + } + + expect(uploadFile).not.toHaveBeenCalled() + expect(emitted).toEqual([expect.objectContaining({ + id: 'attachment-ready', + path: '/uploads/ready.png', + previewUrl: 'data:image/png;base64,aW1hZ2U=', + status: { type: 'requires-action', reason: 'composer-send' }, + })]) + }) +}) diff --git a/web/src/lib/attachmentAdapter.ts b/web/src/lib/attachmentAdapter.ts index 57c28097..f98e4d14 100644 --- a/web/src/lib/attachmentAdapter.ts +++ b/web/src/lib/attachmentAdapter.ts @@ -3,6 +3,7 @@ import type { ApiClient } from '@/api/client' import type { AttachmentMetadata } from '@/types/api' import { isImageMimeType } from '@/lib/fileAttachments' import { randomId } from '@/lib/randomId' +import { getRestoredUploadMetadata } from '@/lib/composer-attachment-drafts' const MAX_UPLOAD_BYTES = 50 * 1024 * 1024 const MAX_PREVIEW_BYTES = 5 * 1024 * 1024 @@ -28,6 +29,21 @@ export function createAttachmentAdapter(api: ApiClient, sessionId: string): Atta accept: '*/*', async *add({ file }): AsyncGenerator { + const restored = getRestoredUploadMetadata(file) + if (restored) { + yield { + id: restored.id, + type: 'file', + name: file.name, + contentType: file.type || 'application/octet-stream', + file, + status: { type: 'requires-action', reason: 'composer-send' }, + path: restored.path, + previewUrl: restored.previewUrl, + } as PendingUploadAttachment + return + } + const id = randomId() const contentType = file.type || 'application/octet-stream' diff --git a/web/src/lib/clearDraftsAfterSend.test.ts b/web/src/lib/clearDraftsAfterSend.test.ts index 6d1fd0dc..0561a8b3 100644 --- a/web/src/lib/clearDraftsAfterSend.test.ts +++ b/web/src/lib/clearDraftsAfterSend.test.ts @@ -3,11 +3,16 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' vi.mock('@/lib/composer-drafts', () => ({ clearDraft: vi.fn(), })) +vi.mock('@/lib/composer-attachment-drafts', () => ({ + clearDraftAttachments: vi.fn(), +})) import { clearDraft } from '@/lib/composer-drafts' +import { clearDraftAttachments } from '@/lib/composer-attachment-drafts' import { clearDraftsAfterSend } from './clearDraftsAfterSend' const mockClearDraft = vi.mocked(clearDraft) +const mockClearDraftAttachments = vi.mocked(clearDraftAttachments) describe('clearDraftsAfterSend', () => { beforeEach(() => { @@ -18,6 +23,7 @@ describe('clearDraftsAfterSend', () => { clearDraftsAfterSend('session-A', 'session-A') expect(mockClearDraft).toHaveBeenCalledWith('session-A') expect(mockClearDraft).toHaveBeenCalledTimes(1) + expect(mockClearDraftAttachments).toHaveBeenCalledWith('session-A') }) it('clears both drafts when session was resolved to a different ID', () => { @@ -25,11 +31,15 @@ describe('clearDraftsAfterSend', () => { expect(mockClearDraft).toHaveBeenCalledWith('resolved-B') expect(mockClearDraft).toHaveBeenCalledWith('session-A') expect(mockClearDraft).toHaveBeenCalledTimes(2) + expect(mockClearDraftAttachments).toHaveBeenCalledWith('resolved-B') + expect(mockClearDraftAttachments).toHaveBeenCalledWith('session-A') + expect(mockClearDraftAttachments).toHaveBeenCalledTimes(2) }) it('only clears sent session when route session is null', () => { clearDraftsAfterSend('session-A', null) expect(mockClearDraft).toHaveBeenCalledWith('session-A') expect(mockClearDraft).toHaveBeenCalledTimes(1) + expect(mockClearDraftAttachments).toHaveBeenCalledWith('session-A') }) }) diff --git a/web/src/lib/clearDraftsAfterSend.ts b/web/src/lib/clearDraftsAfterSend.ts index c35c057d..c192c6b5 100644 --- a/web/src/lib/clearDraftsAfterSend.ts +++ b/web/src/lib/clearDraftsAfterSend.ts @@ -1,4 +1,10 @@ import { clearDraft } from '@/lib/composer-drafts' +import { clearDraftAttachments } from '@/lib/composer-attachment-drafts' + +function clearComposerDraft(sessionId: string): void { + clearDraft(sessionId) + clearDraftAttachments(sessionId) +} /** * Clear draft(s) after a successful send. @@ -9,8 +15,8 @@ export function clearDraftsAfterSend( sentSessionId: string, routeSessionId: string | null, ): void { - clearDraft(sentSessionId) + clearComposerDraft(sentSessionId) if (routeSessionId && sentSessionId !== routeSessionId) { - clearDraft(routeSessionId) + clearComposerDraft(routeSessionId) } } diff --git a/web/src/lib/composer-attachment-drafts.test.ts b/web/src/lib/composer-attachment-drafts.test.ts new file mode 100644 index 00000000..3bea41a7 --- /dev/null +++ b/web/src/lib/composer-attachment-drafts.test.ts @@ -0,0 +1,82 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +describe('composer-attachment-drafts', () => { + beforeEach(() => { + vi.stubGlobal('indexedDB', undefined) + vi.resetModules() + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('keeps files available in memory when IndexedDB is unavailable', async () => { + const mod = await import('./composer-attachment-drafts') + const file = new File(['image bytes'], 'pasted.png', { + type: 'image/png', + lastModified: 123, + }) + + mod.saveDraftAttachments('session-1', [{ id: 'attachment-1', file }]) + const restored = await mod.getDraftAttachments('session-1') + + expect(restored).toHaveLength(1) + expect(restored[0]).not.toBe(file) + expect(restored[0]?.name).toBe('pasted.png') + expect(restored[0]?.type).toBe('image/png') + expect(restored[0]?.lastModified).toBe(123) + expect(restored[0]?.size).toBe(file.size) + }) + + it('isolates attachment drafts by session', async () => { + const mod = await import('./composer-attachment-drafts') + mod.saveDraftAttachments('session-a', [{ id: 'a', file: new File(['a'], 'a.txt') }]) + mod.saveDraftAttachments('session-b', [{ id: 'b', file: new File(['b'], 'b.txt') }]) + + expect((await mod.getDraftAttachments('session-a'))[0]?.name).toBe('a.txt') + expect((await mod.getDraftAttachments('session-b'))[0]?.name).toBe('b.txt') + }) + + it('clears cached attachment drafts', async () => { + const mod = await import('./composer-attachment-drafts') + mod.saveDraftAttachments('session-1', [{ id: 'x', file: new File(['x'], 'x.txt') }]) + + mod.clearDraftAttachments('session-1') + + expect(await mod.getDraftAttachments('session-1')).toEqual([]) + }) + + it('does not read stale IndexedDB data while a clear is being persisted', async () => { + const mod = await import('./composer-attachment-drafts') + mod.saveDraftAttachments('session-1', [{ id: 'x', file: new File(['x'], 'x.txt') }]) + mod.clearDraftAttachments('session-1') + await new Promise((resolve) => setTimeout(resolve, 0)) + + const open = vi.fn(() => { + throw new Error('cleared drafts must be served from the cache tombstone') + }) + vi.stubGlobal('indexedDB', { open }) + + expect(await mod.getDraftAttachments('session-1')).toEqual([]) + expect(open).not.toHaveBeenCalled() + }) + + it('retains completed upload metadata on restored files', async () => { + const mod = await import('./composer-attachment-drafts') + const file = new File(['image'], 'ready.png', { type: 'image/png' }) + mod.saveDraftAttachments('session-1', [{ + id: 'attachment-ready', + file, + path: '/uploads/ready.png', + previewUrl: 'data:image/png;base64,aW1hZ2U=', + }]) + + const [restored] = await mod.getDraftAttachments('session-1') + + expect(restored && mod.getRestoredUploadMetadata(restored)).toEqual({ + id: 'attachment-ready', + path: '/uploads/ready.png', + previewUrl: 'data:image/png;base64,aW1hZ2U=', + }) + }) +}) diff --git a/web/src/lib/composer-attachment-drafts.ts b/web/src/lib/composer-attachment-drafts.ts new file mode 100644 index 00000000..d676dad7 --- /dev/null +++ b/web/src/lib/composer-attachment-drafts.ts @@ -0,0 +1,195 @@ +const DB_NAME = 'hapi-composer-drafts' +const DB_VERSION = 1 +const STORE = 'attachments' +const MAX_DRAFTS = 50 + +type StoredAttachment = { + id: string + name: string + type: string + lastModified: number + blob: Blob + path?: string + previewUrl?: string +} + +type StoredAttachmentDraft = { + sessionId: string + files: StoredAttachment[] + updatedAt: number +} + +const cache = new Map() +const restoredUploadMetadata = new WeakMap() +const pendingWrites = new Map>() + +export type AttachmentDraftInput = { + id: string + file: File + path?: string + previewUrl?: string +} + +export type RestoredUploadMetadata = { + id: string + path: string + previewUrl?: string +} + +function openDb(): Promise { + return new Promise((resolve, reject) => { + if (typeof indexedDB === 'undefined') { + reject(new Error('IndexedDB is unavailable')) + return + } + const request = indexedDB.open(DB_NAME, DB_VERSION) + request.onupgradeneeded = () => { + const db = request.result + if (!db.objectStoreNames.contains(STORE)) { + db.createObjectStore(STORE, { keyPath: 'sessionId' }) + } + } + request.onsuccess = () => resolve(request.result) + request.onerror = () => reject(request.error ?? new Error('Failed to open composer draft DB')) + }) +} + +function copyFile(file: File): File { + const copy = new File([file], file.name, { + type: file.type, + lastModified: file.lastModified, + }) + const metadata = restoredUploadMetadata.get(file) + if (metadata) restoredUploadMetadata.set(copy, metadata) + return copy +} + +function toStoredFile(attachment: AttachmentDraftInput): StoredAttachment { + const file = attachment.file + return { + id: attachment.id, + name: file.name, + type: file.type, + lastModified: file.lastModified, + blob: file, + path: attachment.path, + previewUrl: attachment.previewUrl, + } +} + +function toFile(file: StoredAttachment): File { + const restored = new File([file.blob], file.name, { + type: file.type, + lastModified: file.lastModified, + }) + if (file.path) { + restoredUploadMetadata.set(restored, { + id: file.id, + path: file.path, + previewUrl: file.previewUrl, + }) + } + return restored +} + +async function writeDraft(record: StoredAttachmentDraft | null, sessionId: string): Promise { + const db = await openDb() + await new Promise((resolve, reject) => { + const transaction = db.transaction(STORE, 'readwrite') + const store = transaction.objectStore(STORE) + if (record) { + store.put(record) + const allRequest = store.getAll() + allRequest.onsuccess = () => { + const drafts = (allRequest.result as StoredAttachmentDraft[]) + .sort((a, b) => b.updatedAt - a.updatedAt) + for (const stale of drafts.slice(MAX_DRAFTS)) { + store.delete(stale.sessionId) + } + } + } else { + store.delete(sessionId) + } + transaction.oncomplete = () => { + db.close() + resolve() + } + transaction.onerror = () => { + db.close() + reject(transaction.error ?? new Error('Composer draft transaction failed')) + } + transaction.onabort = transaction.onerror + }) +} + +function queueWrite(record: StoredAttachmentDraft | null, sessionId: string): void { + const previous = pendingWrites.get(sessionId) ?? Promise.resolve() + const next = previous.catch(() => {}).then(() => writeDraft(record, sessionId)) + pendingWrites.set(sessionId, next) + void next.catch(() => {}).finally(() => { + if (pendingWrites.get(sessionId) === next) pendingWrites.delete(sessionId) + }) +} + +function setCachedFiles(sessionId: string, files: File[]): void { + cache.delete(sessionId) + cache.set(sessionId, files) + while (cache.size > MAX_DRAFTS) { + const oldest = cache.keys().next().value as string | undefined + if (!oldest) break + cache.delete(oldest) + } +} + +export async function getDraftAttachments(sessionId: string): Promise { + const cached = cache.get(sessionId) + if (cached) return cached.map(copyFile) + + try { + const db = await openDb() + const record = await new Promise((resolve, reject) => { + const transaction = db.transaction(STORE, 'readonly') + const request = transaction.objectStore(STORE).get(sessionId) + transaction.oncomplete = () => { + db.close() + resolve(request.result as StoredAttachmentDraft | undefined) + } + transaction.onerror = () => { + db.close() + reject(transaction.error ?? new Error('Composer draft transaction failed')) + } + }) + const files = record?.files.map(toFile) ?? [] + if (files.length > 0) setCachedFiles(sessionId, files) + return files + } catch { + return [] + } +} + +export function saveDraftAttachments(sessionId: string, attachments: AttachmentDraftInput[]): void { + if (attachments.length === 0) { + // Keep an empty cache entry as a tombstone until the queued IndexedDB + // delete completes, so a fast remount cannot read and restore stale files. + setCachedFiles(sessionId, []) + queueWrite(null, sessionId) + return + } + + const storedFiles = attachments.map(toStoredFile) + const copies = storedFiles.map(toFile) + setCachedFiles(sessionId, copies) + queueWrite({ + sessionId, + files: storedFiles, + updatedAt: Date.now(), + }, sessionId) +} + +export function clearDraftAttachments(sessionId: string): void { + saveDraftAttachments(sessionId, []) +} + +export function getRestoredUploadMetadata(file: File): RestoredUploadMetadata | undefined { + return restoredUploadMetadata.get(file) +}