From 9e531d1229279901477b176b0afb9a8b52128a4c Mon Sep 17 00:00:00 2001 From: paisley <8197966+su8su@users.noreply.github.com> Date: Wed, 3 Jun 2026 14:54:09 +0800 Subject: [PATCH] fix image load (#1097) --- src/stores/chat.ts | 56 +++++++++++++++++++++++ src/stores/chat/history-actions.ts | 56 +++++++++++++++++++++++ tests/unit/chat-history-actions.test.ts | 59 +++++++++++++++++++++++++ 3 files changed, 171 insertions(+) diff --git a/src/stores/chat.ts b/src/stores/chat.ts index e19c9639..1aa7bbc9 100644 --- a/src/stores/chat.ts +++ b/src/stores/chat.ts @@ -2878,6 +2878,61 @@ export const useChatStore = create((set, get) => ({ : message; }); }; + type AttachedFile = NonNullable[number]; + const getAttachmentMergeKey = (file: AttachedFile): string | null => ( + file.filePath || file.gatewayUrl || null + ); + const preserveExistingAttachmentPreviews = ( + currentMessages: RawMessage[], + nextMessages: RawMessage[], + ): RawMessage[] => { + const currentFilesByMessageKey = new Map>(); + for (const message of currentMessages) { + if (!message._attachedFiles?.length) continue; + const filesByKey = new Map(); + for (const file of message._attachedFiles) { + const key = getAttachmentMergeKey(file); + if (!key) continue; + if (!file.preview && !file.fileSize && !file.previewStatus) continue; + filesByKey.set(key, file); + } + if (filesByKey.size > 0) { + currentFilesByMessageKey.set(getPreviewMergeKey(message), filesByKey); + } + } + + if (currentFilesByMessageKey.size === 0) return nextMessages; + + return nextMessages.map((message) => { + if (!message._attachedFiles?.length) return message; + const currentFiles = currentFilesByMessageKey.get(getPreviewMergeKey(message)); + if (!currentFiles) return message; + + let changed = false; + const attachedFiles = message._attachedFiles.map((file) => { + const key = getAttachmentMergeKey(file); + const currentFile = key ? currentFiles.get(key) : undefined; + if (!currentFile) return file; + + let nextFile = file; + if (!nextFile.preview && currentFile.preview) { + nextFile = { ...nextFile, preview: currentFile.preview }; + changed = true; + } + if (!nextFile.fileSize && currentFile.fileSize) { + nextFile = { ...nextFile, fileSize: currentFile.fileSize }; + changed = true; + } + if (!nextFile.previewStatus && currentFile.previewStatus) { + nextFile = { ...nextFile, previewStatus: currentFile.previewStatus }; + changed = true; + } + return nextFile; + }); + + return changed ? { ...message, _attachedFiles: attachedFiles } : message; + }); + }; const applyLoadFailure = (errorMessage: string | null) => { if (!isCurrentSession()) return; @@ -2920,6 +2975,7 @@ export const useChatStore = create((set, get) => ({ } } finalMessages = dropRedundantOptimisticUserMessages(currentSessionKey, finalMessages); + finalMessages = preserveExistingAttachmentPreviews(get().messages, finalMessages); const { pendingFinal, lastUserMessageAt, sending: isSendingNow } = get(); const userMsTs = lastUserMessageAt != null ? toMs(lastUserMessageAt) : 0; diff --git a/src/stores/chat/history-actions.ts b/src/stores/chat/history-actions.ts index e50ac4aa..c884940e 100644 --- a/src/stores/chat/history-actions.ts +++ b/src/stores/chat/history-actions.ts @@ -95,6 +95,61 @@ export function createHistoryActions( : message; }); }; + type AttachedFile = NonNullable[number]; + const getAttachmentMergeKey = (file: AttachedFile): string | null => ( + file.filePath || file.gatewayUrl || null + ); + const preserveExistingAttachmentPreviews = ( + currentMessages: RawMessage[], + nextMessages: RawMessage[], + ): RawMessage[] => { + const currentFilesByMessageKey = new Map>(); + for (const message of currentMessages) { + if (!message._attachedFiles?.length) continue; + const filesByKey = new Map(); + for (const file of message._attachedFiles) { + const key = getAttachmentMergeKey(file); + if (!key) continue; + if (!file.preview && !file.fileSize && !file.previewStatus) continue; + filesByKey.set(key, file); + } + if (filesByKey.size > 0) { + currentFilesByMessageKey.set(getPreviewMergeKey(message), filesByKey); + } + } + + if (currentFilesByMessageKey.size === 0) return nextMessages; + + return nextMessages.map((message) => { + if (!message._attachedFiles?.length) return message; + const currentFiles = currentFilesByMessageKey.get(getPreviewMergeKey(message)); + if (!currentFiles) return message; + + let changed = false; + const attachedFiles = message._attachedFiles.map((file) => { + const key = getAttachmentMergeKey(file); + const currentFile = key ? currentFiles.get(key) : undefined; + if (!currentFile) return file; + + let nextFile = file; + if (!nextFile.preview && currentFile.preview) { + nextFile = { ...nextFile, preview: currentFile.preview }; + changed = true; + } + if (!nextFile.fileSize && currentFile.fileSize) { + nextFile = { ...nextFile, fileSize: currentFile.fileSize }; + changed = true; + } + if (!nextFile.previewStatus && currentFile.previewStatus) { + nextFile = { ...nextFile, previewStatus: currentFile.previewStatus }; + changed = true; + } + return nextFile; + }); + + return changed ? { ...message, _attachedFiles: attachedFiles } : message; + }); + }; const applyLoadFailure = (errorMessage: string | null) => { if (!isCurrentSession()) return; @@ -133,6 +188,7 @@ export function createHistoryActions( } } finalMessages = dropRedundantOptimisticUserMessages(currentSessionKey, finalMessages); + finalMessages = preserveExistingAttachmentPreviews(get().messages, finalMessages); const { pendingFinal, lastUserMessageAt, sending: isSendingNow } = get(); const userMsTs = lastUserMessageAt ? toMs(lastUserMessageAt) : 0; diff --git a/tests/unit/chat-history-actions.test.ts b/tests/unit/chat-history-actions.test.ts index e60464df..7e4b13b1 100644 --- a/tests/unit/chat-history-actions.test.ts +++ b/tests/unit/chat-history-actions.test.ts @@ -749,6 +749,65 @@ describe('chat history actions', () => { expect(h.read().messages[0]?._attachedFiles?.[0]?.preview).toBe('data:image/png;base64,abc'); }); + it('preserves existing image previews when quiet history refresh rebuilds attachments', async () => { + const { createHistoryActions } = await import('@/stores/chat/history-actions'); + const gatewayUrl = '/api/chat/media/outgoing/agent%3Amain%3As-1/image-1/full'; + const h = makeHarness({ + currentSessionKey: 'agent:main:main', + messages: [ + { + id: 'generated-image', + role: 'assistant', + content: 'Generated image', + timestamp: 1000, + _attachedFiles: [ + { + fileName: 'generated.png', + mimeType: 'image/png', + fileSize: 42, + preview: 'data:image/png;base64,already-loaded', + gatewayUrl, + source: 'gateway-media', + }, + ], + }, + ], + }); + const actions = createHistoryActions(h.set as never, h.get as never); + + invokeIpcMock.mockResolvedValueOnce({ + success: true, + result: { + messages: [ + { + id: 'generated-image', + role: 'assistant', + content: 'Generated image', + timestamp: 1000, + _attachedFiles: [ + { + fileName: 'generated.png', + mimeType: 'image/png', + fileSize: 0, + preview: null, + gatewayUrl, + source: 'gateway-media', + }, + ], + }, + ], + }, + }); + + await actions.loadHistory(true); + + expect(h.read().messages[0]?._attachedFiles?.[0]).toMatchObject({ + fileSize: 42, + preview: 'data:image/png;base64,already-loaded', + gatewayUrl, + }); + }); + it('does not append an optimistic duplicate when history already includes the user message without timestamp', async () => { const { createHistoryActions } = await import('@/stores/chat/history-actions'); const h = makeHarness({