From c9995995a42317676290ca1b953dfeabdedd8920 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 28 Feb 2026 22:23:19 -0800 Subject: [PATCH] Preserve draft metadata and dedupe composer image retries - Reuse hidden draft threads while applying requested branch/worktree updates before navigation - Export composer draft storage key and clone blob preview URLs when re-adding images after retry - Deduplicate composer images by file signature (within and across calls) and add store tests for URL revoke behavior --- apps/web/src/components/ChatView.tsx | 17 +++- apps/web/src/components/Sidebar.tsx | 70 +++++++++++---- apps/web/src/composerDraftStore.test.ts | 113 ++++++++++++++++++++++++ apps/web/src/composerDraftStore.ts | 19 +++- 4 files changed, 200 insertions(+), 19 deletions(-) create mode 100644 apps/web/src/composerDraftStore.test.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 840a810cad46660f0aced8b50f2f91bec07bb3b1..cd2f4689498f0267bf71ae24adc0d78c5ede1e34 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -111,6 +111,7 @@ import { newCommandId, newMessageId } from "~/lib/utils"; import { readNativeApi } from "~/nativeApi"; import { getAppModelOptions, useAppSettings } from "../appSettings"; import { + COMPOSER_DRAFT_STORAGE_KEY, type ComposerImageAttachment, type PersistedComposerImageAttachment, readPersistedComposerDraftAttachments, @@ -243,6 +244,20 @@ function buildTemporaryWorktreeBranchName(): string { return `${WORKTREE_BRANCH_PREFIX}/${token}`; } +function cloneComposerImageForRetry(image: ComposerImageAttachment): ComposerImageAttachment { + if (typeof URL === "undefined" || !image.previewUrl.startsWith("blob:")) { + return image; + } + try { + return { + ...image, + previewUrl: URL.createObjectURL(image.file), + }; + } catch { + return image; + } +} + function readComposerDraftStorageChars(): number | null { if (typeof window === "undefined") { return null; @@ -1765,7 +1780,7 @@ export default function ChatView({ threadId }: ChatViewProps) { ); promptRef.current = trimmed; setPrompt(trimmed); - addComposerImagesToDraft(composerImagesSnapshot); + addComposerImagesToDraft(composerImagesSnapshot.map(cloneComposerImageForRetry)); setComposerCursor(trimmed.length); } setThreadError( diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index f3ea208631027ebbc150b1cc80bc2ffd9017836c..ec36da30bfd237c052eabfba0d607716b84fd37d 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -348,24 +348,55 @@ export default function Sidebar() { ): Promise => { const api = readNativeApi(); if (!api) return Promise.resolve(); + const hasBranchOption = options?.branch !== undefined; + const hasWorktreePathOption = options?.worktreePath !== undefined; + const applyThreadOptions = async (thread: Thread): Promise => { + if (!hasBranchOption && !hasWorktreePathOption) { + return; + } + const nextBranch = hasBranchOption ? (options?.branch ?? null) : thread.branch; + const nextWorktreePath = hasWorktreePathOption + ? (options?.worktreePath ?? null) + : thread.worktreePath; + if (thread.branch === nextBranch && thread.worktreePath === nextWorktreePath) { + return; + } + await api.orchestration.dispatchCommand({ + type: "thread.meta.update", + commandId: newCommandId(), + threadId: thread.id, + ...(hasBranchOption ? { branch: nextBranch } : {}), + ...(hasWorktreePathOption ? { worktreePath: nextWorktreePath } : {}), + }); + dispatch({ + type: "SET_THREAD_BRANCH", + threadId: thread.id, + branch: nextBranch, + worktreePath: nextWorktreePath, + }); + }; const storedDraftThreadId = readProjectDraftThreadId(projectId); const storedDraftThread = storedDraftThreadId ? state.threads.find((thread) => thread.id === storedDraftThreadId) : undefined; - const reusableStoredDraftThreadId = + const reusableStoredDraftThread = storedDraftThread && storedDraftThread.projectId === projectId && !shouldShowThreadInSidebar(storedDraftThread) - ? storedDraftThread.id + ? storedDraftThread : null; - if (reusableStoredDraftThreadId) { - if (routeThreadId === reusableStoredDraftThreadId) { - return Promise.resolve(); - } - return navigate({ - to: "/$threadId", - params: { threadId: reusableStoredDraftThreadId }, - }); + if (reusableStoredDraftThread) { + return (async () => { + await applyThreadOptions(reusableStoredDraftThread); + writeProjectDraftThreadId(projectId, reusableStoredDraftThread.id); + if (routeThreadId === reusableStoredDraftThread.id) { + return; + } + await navigate({ + to: "/$threadId", + params: { threadId: reusableStoredDraftThread.id }, + }); + })(); } if (storedDraftThreadId) { clearProjectDraftThreadId(projectId); @@ -374,15 +405,24 @@ export default function Sidebar() { const activeThread = routeThreadId ? state.threads.find((thread) => thread.id === routeThreadId) : undefined; - const reusableDraftThreadId = + const reusableDraftThread = activeThread && activeThread.projectId === projectId && !shouldShowThreadInSidebar(activeThread) - ? activeThread.id + ? activeThread : null; - if (reusableDraftThreadId) { - writeProjectDraftThreadId(projectId, reusableDraftThreadId); - return Promise.resolve(); + if (reusableDraftThread) { + return (async () => { + await applyThreadOptions(reusableDraftThread); + writeProjectDraftThreadId(projectId, reusableDraftThread.id); + if (routeThreadId === reusableDraftThread.id) { + return; + } + await navigate({ + to: "/$threadId", + params: { threadId: reusableDraftThread.id }, + }); + })(); } const threadId = newThreadId(); const createdAt = new Date().toISOString(); diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..a9d350dd12824f77f1f878fa1d34a42ba6246354 --- /dev/null +++ b/apps/web/src/composerDraftStore.test.ts @@ -0,0 +1,113 @@ +import { ThreadId } from "@t3tools/contracts"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { type ComposerImageAttachment, useComposerDraftStore } from "./composerDraftStore"; + +function makeImage(input: { + id: string; + previewUrl: string; + name?: string; + mimeType?: string; + sizeBytes?: number; + lastModified?: number; +}): ComposerImageAttachment { + const name = input.name ?? "image.png"; + const mimeType = input.mimeType ?? "image/png"; + const sizeBytes = input.sizeBytes ?? 4; + const lastModified = input.lastModified ?? 1_700_000_000_000; + const file = new File([new Uint8Array(sizeBytes).fill(1)], name, { type: mimeType, lastModified }); + return { + type: "image", + id: input.id, + name, + mimeType, + sizeBytes: file.size, + previewUrl: input.previewUrl, + file, + }; +} + +describe("composerDraftStore addImages", () => { + const threadId = ThreadId.makeUnsafe("thread-dedupe"); + let originalRevokeObjectUrl: typeof URL.revokeObjectURL; + let revokeSpy: ReturnType void>>; + + beforeEach(() => { + useComposerDraftStore.setState({ draftsByThreadId: {} }); + originalRevokeObjectUrl = URL.revokeObjectURL; + revokeSpy = vi.fn(); + URL.revokeObjectURL = revokeSpy; + }); + + afterEach(() => { + URL.revokeObjectURL = originalRevokeObjectUrl; + }); + + it("deduplicates identical images in one batch by file signature", () => { + const first = makeImage({ + id: "img-1", + previewUrl: "blob:first", + name: "same.png", + mimeType: "image/png", + sizeBytes: 12, + lastModified: 12345, + }); + const duplicate = makeImage({ + id: "img-2", + previewUrl: "blob:duplicate", + name: "same.png", + mimeType: "image/png", + sizeBytes: 12, + lastModified: 12345, + }); + + useComposerDraftStore.getState().addImages(threadId, [first, duplicate]); + + const draft = useComposerDraftStore.getState().draftsByThreadId[threadId]; + expect(draft?.images.map((image) => image.id)).toEqual(["img-1"]); + expect(revokeSpy).toHaveBeenCalledWith("blob:duplicate"); + }); + + it("deduplicates against existing images across calls by file signature", () => { + const first = makeImage({ + id: "img-a", + previewUrl: "blob:a", + name: "same.png", + mimeType: "image/png", + sizeBytes: 9, + lastModified: 777, + }); + const duplicateLater = makeImage({ + id: "img-b", + previewUrl: "blob:b", + name: "same.png", + mimeType: "image/png", + sizeBytes: 9, + lastModified: 777, + }); + + useComposerDraftStore.getState().addImage(threadId, first); + useComposerDraftStore.getState().addImage(threadId, duplicateLater); + + const draft = useComposerDraftStore.getState().draftsByThreadId[threadId]; + expect(draft?.images.map((image) => image.id)).toEqual(["img-a"]); + expect(revokeSpy).toHaveBeenCalledWith("blob:b"); + }); + + it("does not revoke blob URLs that are still used by an accepted duplicate image", () => { + const first = makeImage({ + id: "img-shared", + previewUrl: "blob:shared", + }); + const duplicateSameUrl = makeImage({ + id: "img-shared", + previewUrl: "blob:shared", + }); + + useComposerDraftStore.getState().addImages(threadId, [first, duplicateSameUrl]); + + const draft = useComposerDraftStore.getState().draftsByThreadId[threadId]; + expect(draft?.images.map((image) => image.id)).toEqual(["img-shared"]); + expect(revokeSpy).not.toHaveBeenCalledWith("blob:shared"); + }); +}); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 2dc39b8bde3c654305be39b6c8f82f01efafc385..bbd4c1cce0d6613b37dc0cbd2b730a475c5270e4 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -9,7 +9,7 @@ import type { ChatImageAttachment } from "./types"; import { create } from "zustand"; import { createJSONStorage, persist, type StateStorage } from "zustand/middleware"; -const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1"; +export const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1"; const SAFE_LOCAL_STORAGE: StateStorage = { getItem: (key) => { @@ -121,6 +121,11 @@ function createEmptyThreadDraft(): ComposerThreadDraftState { }; } +function composerImageDedupKey(image: ComposerImageAttachment): string { + const lastModified = Number.isFinite(image.file.lastModified) ? image.file.lastModified : 0; + return `${image.mimeType}\u0000${image.sizeBytes}\u0000${lastModified}\u0000${image.name}`; +} + function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean { return ( draft.prompt.length === 0 && @@ -427,14 +432,22 @@ export const useComposerDraftStore = create()( set((state) => { const existing = state.draftsByThreadId[threadId] ?? createEmptyThreadDraft(); const existingIds = new Set(existing.images.map((image) => image.id)); + const existingDedupKeys = new Set(existing.images.map((image) => composerImageDedupKey(image))); + const acceptedPreviewUrls = new Set(existing.images.map((image) => image.previewUrl)); const dedupedIncoming: ComposerImageAttachment[] = []; for (const image of images) { - if (existingIds.has(image.id)) { - revokeObjectPreviewUrl(image.previewUrl); + const dedupKey = composerImageDedupKey(image); + if (existingIds.has(image.id) || existingDedupKeys.has(dedupKey)) { + // Avoid revoking a blob URL that's still referenced by an accepted image. + if (!acceptedPreviewUrls.has(image.previewUrl)) { + revokeObjectPreviewUrl(image.previewUrl); + } continue; } dedupedIncoming.push(image); existingIds.add(image.id); + existingDedupKeys.add(dedupKey); + acceptedPreviewUrls.add(image.previewUrl); } if (dedupedIncoming.length === 0) { return state; -- 2.54.0