authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-28 22:23:19-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-01 22:36:35-08:00
logc9995995a42317676290ca1b953dfeabdedd8920
tree20555b5b13255fac663d70cd4516b54eba896402
parent545a9193e2add9995bb03ccec39538e5315349b6

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

4 files changed, 200 insertions(+), 19 deletions(-)

apps/web/src/components/ChatView.tsx+16-1
......@@ -111,6 +111,7 @@ import { newCommandId, newMessageId } from "~/lib/utils";
111111import { readNativeApi } from "~/nativeApi";
112112import { getAppModelOptions, useAppSettings } from "../appSettings";
113113import {
114 COMPOSER_DRAFT_STORAGE_KEY,
114115 type ComposerImageAttachment,
115116 type PersistedComposerImageAttachment,
116117 readPersistedComposerDraftAttachments,
......@@ -243,6 +244,20 @@ function buildTemporaryWorktreeBranchName(): string {
243244 return `${WORKTREE_BRANCH_PREFIX}/${token}`;
244245}
245246
247function cloneComposerImageForRetry(image: ComposerImageAttachment): ComposerImageAttachment {
248 if (typeof URL === "undefined" || !image.previewUrl.startsWith("blob:")) {
249 return image;
250 }
251 try {
252 return {
253 ...image,
254 previewUrl: URL.createObjectURL(image.file),
255 };
256 } catch {
257 return image;
258 }
259}
260
246261function readComposerDraftStorageChars(): number | null {
247262 if (typeof window === "undefined") {
248263 return null;
......@@ -1765,7 +1780,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
17651780 );
17661781 promptRef.current = trimmed;
17671782 setPrompt(trimmed);
1768 addComposerImagesToDraft(composerImagesSnapshot);
1783 addComposerImagesToDraft(composerImagesSnapshot.map(cloneComposerImageForRetry));
17691784 setComposerCursor(trimmed.length);
17701785 }
17711786 setThreadError(
apps/web/src/components/Sidebar.tsx+55-15
......@@ -348,24 +348,55 @@ export default function Sidebar() {
348348 ): Promise<void> => {
349349 const api = readNativeApi();
350350 if (!api) return Promise.resolve();
351 const hasBranchOption = options?.branch !== undefined;
352 const hasWorktreePathOption = options?.worktreePath !== undefined;
353 const applyThreadOptions = async (thread: Thread): Promise<void> => {
354 if (!hasBranchOption && !hasWorktreePathOption) {
355 return;
356 }
357 const nextBranch = hasBranchOption ? (options?.branch ?? null) : thread.branch;
358 const nextWorktreePath = hasWorktreePathOption
359 ? (options?.worktreePath ?? null)
360 : thread.worktreePath;
361 if (thread.branch === nextBranch && thread.worktreePath === nextWorktreePath) {
362 return;
363 }
364 await api.orchestration.dispatchCommand({
365 type: "thread.meta.update",
366 commandId: newCommandId(),
367 threadId: thread.id,
368 ...(hasBranchOption ? { branch: nextBranch } : {}),
369 ...(hasWorktreePathOption ? { worktreePath: nextWorktreePath } : {}),
370 });
371 dispatch({
372 type: "SET_THREAD_BRANCH",
373 threadId: thread.id,
374 branch: nextBranch,
375 worktreePath: nextWorktreePath,
376 });
377 };
351378 const storedDraftThreadId = readProjectDraftThreadId(projectId);
352379 const storedDraftThread = storedDraftThreadId
353380 ? state.threads.find((thread) => thread.id === storedDraftThreadId)
354381 : undefined;
355 const reusableStoredDraftThreadId =
382 const reusableStoredDraftThread =
356383 storedDraftThread &&
357384 storedDraftThread.projectId === projectId &&
358385 !shouldShowThreadInSidebar(storedDraftThread)
359 ? storedDraftThread.id
386 ? storedDraftThread
360387 : null;
361 if (reusableStoredDraftThreadId) {
362 if (routeThreadId === reusableStoredDraftThreadId) {
363 return Promise.resolve();
364 }
365 return navigate({
366 to: "/$threadId",
367 params: { threadId: reusableStoredDraftThreadId },
368 });
388 if (reusableStoredDraftThread) {
389 return (async () => {
390 await applyThreadOptions(reusableStoredDraftThread);
391 writeProjectDraftThreadId(projectId, reusableStoredDraftThread.id);
392 if (routeThreadId === reusableStoredDraftThread.id) {
393 return;
394 }
395 await navigate({
396 to: "/$threadId",
397 params: { threadId: reusableStoredDraftThread.id },
398 });
399 })();
369400 }
370401 if (storedDraftThreadId) {
371402 clearProjectDraftThreadId(projectId);
......@@ -374,15 +405,24 @@ export default function Sidebar() {
374405 const activeThread = routeThreadId
375406 ? state.threads.find((thread) => thread.id === routeThreadId)
376407 : undefined;
377 const reusableDraftThreadId =
408 const reusableDraftThread =
378409 activeThread &&
379410 activeThread.projectId === projectId &&
380411 !shouldShowThreadInSidebar(activeThread)
381 ? activeThread.id
412 ? activeThread
382413 : null;
383 if (reusableDraftThreadId) {
384 writeProjectDraftThreadId(projectId, reusableDraftThreadId);
385 return Promise.resolve();
414 if (reusableDraftThread) {
415 return (async () => {
416 await applyThreadOptions(reusableDraftThread);
417 writeProjectDraftThreadId(projectId, reusableDraftThread.id);
418 if (routeThreadId === reusableDraftThread.id) {
419 return;
420 }
421 await navigate({
422 to: "/$threadId",
423 params: { threadId: reusableDraftThread.id },
424 });
425 })();
386426 }
387427 const threadId = newThreadId();
388428 const createdAt = new Date().toISOString();
apps/web/src/composerDraftStore.test.ts created+113
......@@ -0,0 +1,113 @@
1import { ThreadId } from "@t3tools/contracts";
2import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
4import { type ComposerImageAttachment, useComposerDraftStore } from "./composerDraftStore";
5
6function makeImage(input: {
7 id: string;
8 previewUrl: string;
9 name?: string;
10 mimeType?: string;
11 sizeBytes?: number;
12 lastModified?: number;
13}): ComposerImageAttachment {
14 const name = input.name ?? "image.png";
15 const mimeType = input.mimeType ?? "image/png";
16 const sizeBytes = input.sizeBytes ?? 4;
17 const lastModified = input.lastModified ?? 1_700_000_000_000;
18 const file = new File([new Uint8Array(sizeBytes).fill(1)], name, { type: mimeType, lastModified });
19 return {
20 type: "image",
21 id: input.id,
22 name,
23 mimeType,
24 sizeBytes: file.size,
25 previewUrl: input.previewUrl,
26 file,
27 };
28}
29
30describe("composerDraftStore addImages", () => {
31 const threadId = ThreadId.makeUnsafe("thread-dedupe");
32 let originalRevokeObjectUrl: typeof URL.revokeObjectURL;
33 let revokeSpy: ReturnType<typeof vi.fn<(url: string) => void>>;
34
35 beforeEach(() => {
36 useComposerDraftStore.setState({ draftsByThreadId: {} });
37 originalRevokeObjectUrl = URL.revokeObjectURL;
38 revokeSpy = vi.fn();
39 URL.revokeObjectURL = revokeSpy;
40 });
41
42 afterEach(() => {
43 URL.revokeObjectURL = originalRevokeObjectUrl;
44 });
45
46 it("deduplicates identical images in one batch by file signature", () => {
47 const first = makeImage({
48 id: "img-1",
49 previewUrl: "blob:first",
50 name: "same.png",
51 mimeType: "image/png",
52 sizeBytes: 12,
53 lastModified: 12345,
54 });
55 const duplicate = makeImage({
56 id: "img-2",
57 previewUrl: "blob:duplicate",
58 name: "same.png",
59 mimeType: "image/png",
60 sizeBytes: 12,
61 lastModified: 12345,
62 });
63
64 useComposerDraftStore.getState().addImages(threadId, [first, duplicate]);
65
66 const draft = useComposerDraftStore.getState().draftsByThreadId[threadId];
67 expect(draft?.images.map((image) => image.id)).toEqual(["img-1"]);
68 expect(revokeSpy).toHaveBeenCalledWith("blob:duplicate");
69 });
70
71 it("deduplicates against existing images across calls by file signature", () => {
72 const first = makeImage({
73 id: "img-a",
74 previewUrl: "blob:a",
75 name: "same.png",
76 mimeType: "image/png",
77 sizeBytes: 9,
78 lastModified: 777,
79 });
80 const duplicateLater = makeImage({
81 id: "img-b",
82 previewUrl: "blob:b",
83 name: "same.png",
84 mimeType: "image/png",
85 sizeBytes: 9,
86 lastModified: 777,
87 });
88
89 useComposerDraftStore.getState().addImage(threadId, first);
90 useComposerDraftStore.getState().addImage(threadId, duplicateLater);
91
92 const draft = useComposerDraftStore.getState().draftsByThreadId[threadId];
93 expect(draft?.images.map((image) => image.id)).toEqual(["img-a"]);
94 expect(revokeSpy).toHaveBeenCalledWith("blob:b");
95 });
96
97 it("does not revoke blob URLs that are still used by an accepted duplicate image", () => {
98 const first = makeImage({
99 id: "img-shared",
100 previewUrl: "blob:shared",
101 });
102 const duplicateSameUrl = makeImage({
103 id: "img-shared",
104 previewUrl: "blob:shared",
105 });
106
107 useComposerDraftStore.getState().addImages(threadId, [first, duplicateSameUrl]);
108
109 const draft = useComposerDraftStore.getState().draftsByThreadId[threadId];
110 expect(draft?.images.map((image) => image.id)).toEqual(["img-shared"]);
111 expect(revokeSpy).not.toHaveBeenCalledWith("blob:shared");
112 });
113});
apps/web/src/composerDraftStore.ts+16-3
......@@ -9,7 +9,7 @@ import type { ChatImageAttachment } from "./types";
99import { create } from "zustand";
1010import { createJSONStorage, persist, type StateStorage } from "zustand/middleware";
1111
12const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1";
12export const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1";
1313
1414const SAFE_LOCAL_STORAGE: StateStorage = {
1515 getItem: (key) => {
......@@ -121,6 +121,11 @@ function createEmptyThreadDraft(): ComposerThreadDraftState {
121121 };
122122}
123123
124function composerImageDedupKey(image: ComposerImageAttachment): string {
125 const lastModified = Number.isFinite(image.file.lastModified) ? image.file.lastModified : 0;
126 return `${image.mimeType}\u0000${image.sizeBytes}\u0000${lastModified}\u0000${image.name}`;
127}
128
124129function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean {
125130 return (
126131 draft.prompt.length === 0 &&
......@@ -427,14 +432,22 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
427432 set((state) => {
428433 const existing = state.draftsByThreadId[threadId] ?? createEmptyThreadDraft();
429434 const existingIds = new Set(existing.images.map((image) => image.id));
435 const existingDedupKeys = new Set(existing.images.map((image) => composerImageDedupKey(image)));
436 const acceptedPreviewUrls = new Set(existing.images.map((image) => image.previewUrl));
430437 const dedupedIncoming: ComposerImageAttachment[] = [];
431438 for (const image of images) {
432 if (existingIds.has(image.id)) {
433 revokeObjectPreviewUrl(image.previewUrl);
439 const dedupKey = composerImageDedupKey(image);
440 if (existingIds.has(image.id) || existingDedupKeys.has(dedupKey)) {
441 // Avoid revoking a blob URL that's still referenced by an accepted image.
442 if (!acceptedPreviewUrls.has(image.previewUrl)) {
443 revokeObjectPreviewUrl(image.previewUrl);
444 }
434445 continue;
435446 }
436447 dedupedIncoming.push(image);
437448 existingIds.add(image.id);
449 existingDedupKeys.add(dedupKey);
450 acceptedPreviewUrls.add(image.previewUrl);
438451 }
439452 if (dedupedIncoming.length === 0) {
440453 return state;