From 5036e3fd08753fa31bc2c5ebaf2690138b2b552b Mon Sep 17 00:00:00 2001 From: Yahia Elramal Date: Wed, 8 Apr 2026 20:19:41 +0200 Subject: [PATCH 1/2] fix(web): reuse persisted draft image bytes on send --- .../web/src/components/ChatView.logic.test.ts | 89 +++++++++++++++++++ apps/web/src/components/ChatView.logic.ts | 46 +++++++++- apps/web/src/components/ChatView.tsx | 16 ++-- 3 files changed, 140 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 8d49bc07f2..f29e79eb27 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -5,6 +5,7 @@ import { useStore } from "../store"; import { MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, buildExpiredTerminalContextToastCopy, + buildTurnImageAttachments, createLocalDispatchSnapshot, deriveComposerSendState, hasServerAcknowledgedLocalDispatch, @@ -77,6 +78,94 @@ describe("buildExpiredTerminalContextToastCopy", () => { }); }); +describe("buildTurnImageAttachments", () => { + it("uses the live file bytes when the file is still readable", async () => { + const image = { + type: "image" as const, + id: "image-1", + name: "simulator_screenshot_123.png", + mimeType: "image/png", + sizeBytes: 4, + previewUrl: "blob:image-1", + file: new File(["live"], "simulator_screenshot_123.png", { type: "image/png" }), + }; + + await expect( + buildTurnImageAttachments({ + images: [image], + readFile: vi.fn(async () => "data:image/png;base64,bGl2ZQ=="), + }), + ).resolves.toEqual([ + { + type: "image", + name: "simulator_screenshot_123.png", + mimeType: "image/png", + sizeBytes: 4, + dataUrl: "data:image/png;base64,bGl2ZQ==", + }, + ]); + }); + + it("falls back to persisted draft bytes when the live file is gone", async () => { + const image = { + type: "image" as const, + id: "image-1", + name: "simulator_screenshot_123.png", + mimeType: "image/png", + sizeBytes: 4, + previewUrl: "blob:image-1", + file: new File(["live"], "simulator_screenshot_123.png", { type: "image/png" }), + }; + + await expect( + buildTurnImageAttachments({ + images: [image], + persistedAttachments: [ + { + id: "image-1", + name: "simulator_screenshot_123.png", + mimeType: "image/png", + sizeBytes: 4, + dataUrl: "data:image/png;base64,c2F2ZWQ=", + }, + ], + readFile: vi.fn(async () => { + throw new Error("ENOENT"); + }), + }), + ).resolves.toEqual([ + { + type: "image", + name: "simulator_screenshot_123.png", + mimeType: "image/png", + sizeBytes: 4, + dataUrl: "data:image/png;base64,c2F2ZWQ=", + }, + ]); + }); + + it("still fails when neither the live file nor a persisted draft copy is available", async () => { + const image = { + type: "image" as const, + id: "image-1", + name: "simulator_screenshot_123.png", + mimeType: "image/png", + sizeBytes: 4, + previewUrl: "blob:image-1", + file: new File(["live"], "simulator_screenshot_123.png", { type: "image/png" }), + }; + + await expect( + buildTurnImageAttachments({ + images: [image], + readFile: vi.fn(async () => { + throw new Error("ENOENT"); + }), + }), + ).rejects.toThrow("ENOENT"); + }); +}); + describe("reconcileMountedTerminalThreadIds", () => { it("keeps previously mounted open threads and adds the active open thread", () => { expect( diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index ca2a671c11..db8d191f45 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -1,7 +1,17 @@ -import { ProjectId, type ModelSelection, type ThreadId, type TurnId } from "@t3tools/contracts"; +import { + ProjectId, + type ModelSelection, + type ThreadId, + type TurnId, + type UploadChatAttachment, +} from "@t3tools/contracts"; import { type ChatMessage, type SessionPhase, type Thread, type ThreadSession } from "../types"; import { randomUUID } from "~/lib/utils"; -import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; +import { + type ComposerImageAttachment, + type DraftThreadState, + type PersistedComposerImageAttachment, +} from "../composerDraftStore"; import { Schema } from "effect"; import { useStore } from "../store"; import { @@ -129,6 +139,38 @@ export function readFileAsDataUrl(file: File): Promise { }); } +export async function buildTurnImageAttachments(input: { + images: ReadonlyArray; + persistedAttachments?: ReadonlyArray; + readFile?: (file: File) => Promise; +}): Promise { + const persistedAttachmentById = new Map( + (input.persistedAttachments ?? []).map((attachment) => [attachment.id, attachment] as const), + ); + const readFile = input.readFile ?? readFileAsDataUrl; + return await Promise.all( + input.images.map(async (image) => { + let dataUrl: string; + try { + dataUrl = await readFile(image.file); + } catch (error) { + const persistedAttachment = persistedAttachmentById.get(image.id); + if (!persistedAttachment) { + throw error; + } + dataUrl = persistedAttachment.dataUrl; + } + return { + type: "image" as const, + name: image.name, + mimeType: image.mimeType, + sizeBytes: image.sizeBytes, + dataUrl, + }; + }), + ); +} + export function buildTemporaryWorktreeBranchName(): string { // Keep the 8-hex suffix shape for backend temporary-branch detection. const token = randomUUID().slice(0, 8).toLowerCase(); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 7c649b5003..0cd52b03d3 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -174,6 +174,7 @@ import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; import { MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, buildExpiredTerminalContextToastCopy, + buildTurnImageAttachments, buildLocalDraftThread, buildTemporaryWorktreeBranchName, cloneComposerImageForRetry, @@ -2933,6 +2934,7 @@ export default function ChatView({ threadId }: ChatViewProps) { beginLocalDispatch({ preparingWorktree: Boolean(baseBranchForWorktree) }); const composerImagesSnapshot = [...composerImages]; + const persistedComposerAttachmentsSnapshot = [...composerDraft.persistedAttachments]; const composerTerminalContextsSnapshot = [...sendableComposerTerminalContexts]; const messageTextForSend = appendTerminalContextsToPrompt( promptForSend, @@ -2947,15 +2949,11 @@ export default function ChatView({ threadId }: ChatViewProps) { effort: selectedPromptEffort, text: messageTextForSend || IMAGE_ONLY_BOOTSTRAP_PROMPT, }); - const turnAttachmentsPromise = Promise.all( - composerImagesSnapshot.map(async (image) => ({ - type: "image" as const, - name: image.name, - mimeType: image.mimeType, - sizeBytes: image.sizeBytes, - dataUrl: await readFileAsDataUrl(image.file), - })), - ); + const turnAttachmentsPromise = buildTurnImageAttachments({ + images: composerImagesSnapshot, + persistedAttachments: persistedComposerAttachmentsSnapshot, + readFile: readFileAsDataUrl, + }); const optimisticAttachments = composerImagesSnapshot.map((image) => ({ type: "image" as const, id: image.id, From e33777f54bef63e4142ec038045d303dd1647bb4 Mon Sep 17 00:00:00 2001 From: Yahia Elramal Date: Wed, 8 Apr 2026 20:52:02 +0200 Subject: [PATCH 2/2] fix(web): restore persisted image fallback on send retry --- apps/web/src/components/ChatView.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 0cd52b03d3..46d559b69b 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3112,6 +3112,12 @@ export default function ChatView({ threadId }: ChatViewProps) { setPrompt(promptForSend); setComposerCursor(collapseExpandedComposerCursor(promptForSend, promptForSend.length)); addComposerImagesToDraft(composerImagesSnapshot.map(cloneComposerImageForRetry)); + if (persistedComposerAttachmentsSnapshot.length > 0) { + syncComposerDraftPersistedAttachments( + threadIdForSend, + persistedComposerAttachmentsSnapshot, + ); + } addComposerTerminalContextsToDraft(composerTerminalContextsSnapshot); setComposerTrigger(detectComposerTrigger(promptForSend, promptForSend.length)); }