| author | |
| committer | |
| log | c9995995a42317676290ca1b953dfeabdedd8920 |
| tree | 20555b5b13255fac663d70cd4516b54eba896402 |
| parent | 545a9193e2add9995bb03ccec39538e5315349b6 |
- 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 behavior4 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 | 111 | import { readNativeApi } from "~/nativeApi"; |
| 112 | 112 | import { getAppModelOptions, useAppSettings } from "../appSettings"; |
| 113 | 113 | import { |
| 114 | COMPOSER_DRAFT_STORAGE_KEY, | |
| 114 | 115 | type ComposerImageAttachment, |
| 115 | 116 | type PersistedComposerImageAttachment, |
| 116 | 117 | readPersistedComposerDraftAttachments, |
| ... | ... | @@ -243,6 +244,20 @@ function buildTemporaryWorktreeBranchName(): string { |
| 243 | 244 | return `${WORKTREE_BRANCH_PREFIX}/${token}`; |
| 244 | 245 | } |
| 245 | 246 | |
| 247 | function 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 | ||
| 246 | 261 | function readComposerDraftStorageChars(): number | null { |
| 247 | 262 | if (typeof window === "undefined") { |
| 248 | 263 | return null; |
| ... | ... | @@ -1765,7 +1780,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1765 | 1780 | ); |
| 1766 | 1781 | promptRef.current = trimmed; |
| 1767 | 1782 | setPrompt(trimmed); |
| 1768 | addComposerImagesToDraft(composerImagesSnapshot); | |
| 1783 | addComposerImagesToDraft(composerImagesSnapshot.map(cloneComposerImageForRetry)); | |
| 1769 | 1784 | setComposerCursor(trimmed.length); |
| 1770 | 1785 | } |
| 1771 | 1786 | setThreadError( |
apps/web/src/components/Sidebar.tsx+55-15| ... | ... | @@ -348,24 +348,55 @@ export default function Sidebar() { |
| 348 | 348 | ): Promise<void> => { |
| 349 | 349 | const api = readNativeApi(); |
| 350 | 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 | 378 | const storedDraftThreadId = readProjectDraftThreadId(projectId); |
| 352 | 379 | const storedDraftThread = storedDraftThreadId |
| 353 | 380 | ? state.threads.find((thread) => thread.id === storedDraftThreadId) |
| 354 | 381 | : undefined; |
| 355 | const reusableStoredDraftThreadId = | |
| 382 | const reusableStoredDraftThread = | |
| 356 | 383 | storedDraftThread && |
| 357 | 384 | storedDraftThread.projectId === projectId && |
| 358 | 385 | !shouldShowThreadInSidebar(storedDraftThread) |
| 359 | ? storedDraftThread.id | |
| 386 | ? storedDraftThread | |
| 360 | 387 | : 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 | })(); | |
| 369 | 400 | } |
| 370 | 401 | if (storedDraftThreadId) { |
| 371 | 402 | clearProjectDraftThreadId(projectId); |
| ... | ... | @@ -374,15 +405,24 @@ export default function Sidebar() { |
| 374 | 405 | const activeThread = routeThreadId |
| 375 | 406 | ? state.threads.find((thread) => thread.id === routeThreadId) |
| 376 | 407 | : undefined; |
| 377 | const reusableDraftThreadId = | |
| 408 | const reusableDraftThread = | |
| 378 | 409 | activeThread && |
| 379 | 410 | activeThread.projectId === projectId && |
| 380 | 411 | !shouldShowThreadInSidebar(activeThread) |
| 381 | ? activeThread.id | |
| 412 | ? activeThread | |
| 382 | 413 | : 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 | })(); | |
| 386 | 426 | } |
| 387 | 427 | const threadId = newThreadId(); |
| 388 | 428 | const createdAt = new Date().toISOString(); |
apps/web/src/composerDraftStore.test.ts created+113| ... | ... | @@ -0,0 +1,113 @@ |
| 1 | import { ThreadId } from "@t3tools/contracts"; | |
| 2 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | |
| 3 | ||
| 4 | import { type ComposerImageAttachment, useComposerDraftStore } from "./composerDraftStore"; | |
| 5 | ||
| 6 | function 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 | ||
| 30 | describe("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 | 9 | import { create } from "zustand"; |
| 10 | 10 | import { createJSONStorage, persist, type StateStorage } from "zustand/middleware"; |
| 11 | 11 | |
| 12 | const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1"; | |
| 12 | export const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1"; | |
| 13 | 13 | |
| 14 | 14 | const SAFE_LOCAL_STORAGE: StateStorage = { |
| 15 | 15 | getItem: (key) => { |
| ... | ... | @@ -121,6 +121,11 @@ function createEmptyThreadDraft(): ComposerThreadDraftState { |
| 121 | 121 | }; |
| 122 | 122 | } |
| 123 | 123 | |
| 124 | function 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 | ||
| 124 | 129 | function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean { |
| 125 | 130 | return ( |
| 126 | 131 | draft.prompt.length === 0 && |
| ... | ... | @@ -427,14 +432,22 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( |
| 427 | 432 | set((state) => { |
| 428 | 433 | const existing = state.draftsByThreadId[threadId] ?? createEmptyThreadDraft(); |
| 429 | 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 | 437 | const dedupedIncoming: ComposerImageAttachment[] = []; |
| 431 | 438 | 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 | } | |
| 434 | 445 | continue; |
| 435 | 446 | } |
| 436 | 447 | dedupedIncoming.push(image); |
| 437 | 448 | existingIds.add(image.id); |
| 449 | existingDedupKeys.add(dedupKey); | |
| 450 | acceptedPreviewUrls.add(image.previewUrl); | |
| 438 | 451 | } |
| 439 | 452 | if (dedupedIncoming.length === 0) { |
| 440 | 453 | return state; |