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";...@@ -111,6 +111,7 @@ import { newCommandId, newMessageId } from "~/lib/utils";
111import { readNativeApi } from "~/nativeApi";111import { readNativeApi } from "~/nativeApi";
112import { getAppModelOptions, useAppSettings } from "../appSettings";112import { getAppModelOptions, useAppSettings } from "../appSettings";
113import {113import {
114 COMPOSER_DRAFT_STORAGE_KEY,
114 type ComposerImageAttachment,115 type ComposerImageAttachment,
115 type PersistedComposerImageAttachment,116 type PersistedComposerImageAttachment,
116 readPersistedComposerDraftAttachments,117 readPersistedComposerDraftAttachments,
...@@ -243,6 +244,20 @@ function buildTemporaryWorktreeBranchName(): string {...@@ -243,6 +244,20 @@ function buildTemporaryWorktreeBranchName(): string {
243 return `${WORKTREE_BRANCH_PREFIX}/${token}`;244 return `${WORKTREE_BRANCH_PREFIX}/${token}`;
244}245}
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
246function readComposerDraftStorageChars(): number | null {261function readComposerDraftStorageChars(): number | null {
247 if (typeof window === "undefined") {262 if (typeof window === "undefined") {
248 return null;263 return null;
...@@ -1765,7 +1780,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1765,7 +1780,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
1765 );1780 );
1766 promptRef.current = trimmed;1781 promptRef.current = trimmed;
1767 setPrompt(trimmed);1782 setPrompt(trimmed);
1768 addComposerImagesToDraft(composerImagesSnapshot);1783 addComposerImagesToDraft(composerImagesSnapshot.map(cloneComposerImageForRetry));
1769 setComposerCursor(trimmed.length);1784 setComposerCursor(trimmed.length);
1770 }1785 }
1771 setThreadError(1786 setThreadError(
apps/web/src/components/Sidebar.tsx+55-15
...@@ -348,24 +348,55 @@ export default function Sidebar() {...@@ -348,24 +348,55 @@ export default function Sidebar() {
348 ): Promise<void> => {348 ): Promise<void> => {
349 const api = readNativeApi();349 const api = readNativeApi();
350 if (!api) return Promise.resolve();350 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 };
351 const storedDraftThreadId = readProjectDraftThreadId(projectId);378 const storedDraftThreadId = readProjectDraftThreadId(projectId);
352 const storedDraftThread = storedDraftThreadId379 const storedDraftThread = storedDraftThreadId
353 ? state.threads.find((thread) => thread.id === storedDraftThreadId)380 ? state.threads.find((thread) => thread.id === storedDraftThreadId)
354 : undefined;381 : undefined;
355 const reusableStoredDraftThreadId =382 const reusableStoredDraftThread =
356 storedDraftThread &&383 storedDraftThread &&
357 storedDraftThread.projectId === projectId &&384 storedDraftThread.projectId === projectId &&
358 !shouldShowThreadInSidebar(storedDraftThread)385 !shouldShowThreadInSidebar(storedDraftThread)
359 ? storedDraftThread.id386 ? storedDraftThread
360 : null;387 : null;
361 if (reusableStoredDraftThreadId) {388 if (reusableStoredDraftThread) {
362 if (routeThreadId === reusableStoredDraftThreadId) {389 return (async () => {
363 return Promise.resolve();390 await applyThreadOptions(reusableStoredDraftThread);
364 }391 writeProjectDraftThreadId(projectId, reusableStoredDraftThread.id);
365 return navigate({392 if (routeThreadId === reusableStoredDraftThread.id) {
366 to: "/$threadId",393 return;
367 params: { threadId: reusableStoredDraftThreadId },394 }
368 });395 await navigate({
396 to: "/$threadId",
397 params: { threadId: reusableStoredDraftThread.id },
398 });
399 })();
369 }400 }
370 if (storedDraftThreadId) {401 if (storedDraftThreadId) {
371 clearProjectDraftThreadId(projectId);402 clearProjectDraftThreadId(projectId);
...@@ -374,15 +405,24 @@ export default function Sidebar() {...@@ -374,15 +405,24 @@ export default function Sidebar() {
374 const activeThread = routeThreadId405 const activeThread = routeThreadId
375 ? state.threads.find((thread) => thread.id === routeThreadId)406 ? state.threads.find((thread) => thread.id === routeThreadId)
376 : undefined;407 : undefined;
377 const reusableDraftThreadId =408 const reusableDraftThread =
378 activeThread &&409 activeThread &&
379 activeThread.projectId === projectId &&410 activeThread.projectId === projectId &&
380 !shouldShowThreadInSidebar(activeThread)411 !shouldShowThreadInSidebar(activeThread)
381 ? activeThread.id412 ? activeThread
382 : null;413 : null;
383 if (reusableDraftThreadId) {414 if (reusableDraftThread) {
384 writeProjectDraftThreadId(projectId, reusableDraftThreadId);415 return (async () => {
385 return Promise.resolve();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 })();
386 }426 }
387 const threadId = newThreadId();427 const threadId = newThreadId();
388 const createdAt = new Date().toISOString();428 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";...@@ -9,7 +9,7 @@ import type { ChatImageAttachment } from "./types";
9import { create } from "zustand";9import { create } from "zustand";
10import { createJSONStorage, persist, type StateStorage } from "zustand/middleware";10import { 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
14const SAFE_LOCAL_STORAGE: StateStorage = {14const SAFE_LOCAL_STORAGE: StateStorage = {
15 getItem: (key) => {15 getItem: (key) => {
...@@ -121,6 +121,11 @@ function createEmptyThreadDraft(): ComposerThreadDraftState {...@@ -121,6 +121,11 @@ function createEmptyThreadDraft(): ComposerThreadDraftState {
121 };121 };
122}122}
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
124function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean {129function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean {
125 return (130 return (
126 draft.prompt.length === 0 &&131 draft.prompt.length === 0 &&
...@@ -427,14 +432,22 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(...@@ -427,14 +432,22 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
427 set((state) => {432 set((state) => {
428 const existing = state.draftsByThreadId[threadId] ?? createEmptyThreadDraft();433 const existing = state.draftsByThreadId[threadId] ?? createEmptyThreadDraft();
429 const existingIds = new Set(existing.images.map((image) => image.id));434 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));
430 const dedupedIncoming: ComposerImageAttachment[] = [];437 const dedupedIncoming: ComposerImageAttachment[] = [];
431 for (const image of images) {438 for (const image of images) {
432 if (existingIds.has(image.id)) {439 const dedupKey = composerImageDedupKey(image);
433 revokeObjectPreviewUrl(image.previewUrl);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 }
434 continue;445 continue;
435 }446 }
436 dedupedIncoming.push(image);447 dedupedIncoming.push(image);
437 existingIds.add(image.id);448 existingIds.add(image.id);
449 existingDedupKeys.add(dedupKey);
450 acceptedPreviewUrls.add(image.previewUrl);
438 }451 }
439 if (dedupedIncoming.length === 0) {452 if (dedupedIncoming.length === 0) {
440 return state;453 return state;