From b2ec09bd0ce0ff95d6dc4b2faca07734d96ff8e8 Mon Sep 17 00:00:00 2001 From: Ananovo Date: Fri, 24 Jul 2026 10:55:42 +0800 Subject: [PATCH] fix(web): preserve composer attachments across session switches (#1110) * fix(web): preserve composer attachments across session switches Persist composer files per session and restore completed uploads without uploading them again. Clear attachment drafts alongside text after send and cover restoration, isolation, and adapter reuse with regression tests. Fixes #465 * fix(web): keep cleared attachment drafts tombstoned Retain an empty in-memory cache entry until the queued IndexedDB delete completes so a fast remount cannot restore stale files. Add regression coverage for the clear/remount race. * fix(web): defer attachment restore for inactive sessions Only restore or clear attachment drafts while the session attachment adapter is available. Preserve saved files when an inactive session mounts without attachment support and add regression coverage. --- .../AssistantChat/HappyComposer.tsx | 19 +- web/src/hooks/useComposerDraft.test.ts | 88 ++++++-- web/src/hooks/useComposerDraft.ts | 36 +++- web/src/lib/attachmentAdapter.test.ts | 42 ++++ web/src/lib/attachmentAdapter.ts | 16 ++ web/src/lib/clearDraftsAfterSend.test.ts | 10 + web/src/lib/clearDraftsAfterSend.ts | 10 +- .../lib/composer-attachment-drafts.test.ts | 82 ++++++++ web/src/lib/composer-attachment-drafts.ts | 195 ++++++++++++++++++ 9 files changed, 473 insertions(+), 25 deletions(-) create mode 100644 web/src/lib/attachmentAdapter.test.ts create mode 100644 web/src/lib/composer-attachment-drafts.test.ts create mode 100644 web/src/lib/composer-attachment-drafts.ts 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) +}