| 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,6 +111,7 @@ import { newCommandId, newMessageId } from "~/lib/utils"; |
| 111 | import { readNativeApi } from "~/nativeApi"; | 111 | import { readNativeApi } from "~/nativeApi"; |
| 112 | import { getAppModelOptions, useAppSettings } from "../appSettings"; | 112 | import { getAppModelOptions, useAppSettings } from "../appSettings"; |
| 113 | import { | 113 | import { |
| 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 | } |
| 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 | function readComposerDraftStorageChars(): number | null { | 261 | function 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 = storedDraftThreadId | 379 | 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.id | 386 | ? 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 = routeThreadId | 405 | 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.id | 412 | ? 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 @@ | ||
| 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,7 +9,7 @@ import type { ChatImageAttachment } from "./types"; |
| 9 | import { create } from "zustand"; | 9 | import { create } from "zustand"; |
| 10 | import { createJSONStorage, persist, type StateStorage } from "zustand/middleware"; | 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 | const SAFE_LOCAL_STORAGE: StateStorage = { | 14 | const 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 | } |
| 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 | function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean { | 129 | function 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; |