From b59c6d6ab1b71295bf8c77e611522989e1e97fc8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 1 Mar 2026 00:55:28 -0800 Subject: [PATCH] Unify project draft thread state in composer draft store - move project draft thread ID persistence/actions into `composerDraftStore` - remove legacy `projectDraftThreads` module and update `ChatView`/`Sidebar` callers - keep image dedupe stable across hydration and avoid revoking blob URLs on composer clear - add store tests for project-thread mapping and composer clear behavior - drop deprecated model slug aliases (`gpt-5`, `gpt-5-codex`, `gpt-5-codex-spark`) --- apps/web/src/components/ChatView.tsx | 4 +- apps/web/src/components/Sidebar.tsx | 32 +++++--- apps/web/src/composerDraftStore.test.ts | 73 ++++++++++++++++- apps/web/src/composerDraftStore.ts | 100 ++++++++++++++++++++---- apps/web/src/projectDraftThreads.ts | 91 --------------------- packages/contracts/src/model.ts | 3 - 6 files changed, 180 insertions(+), 123 deletions(-) delete mode 100644 apps/web/src/projectDraftThreads.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index cd2f4689498f0267bf71ae24adc0d78c5ede1e34..c9c074b045e255b8aa74968ac220e912a3a1e304 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -118,7 +118,6 @@ import { useComposerDraftStore, useComposerThreadDraft, } from "../composerDraftStore"; -import { clearProjectDraftThreadById } from "../projectDraftThreads"; import { clamp } from "effect/Number"; function formatMessageMeta(createdAt: string, duration: string | null): string { @@ -429,6 +428,9 @@ export default function ChatView({ threadId }: ChatViewProps) { (store) => store.syncPersistedAttachments, ); const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent); + const clearProjectDraftThreadById = useComposerDraftStore( + (store) => store.clearProjectDraftThreadById, + ); const promptRef = useRef(prompt); const [isDragOverComposer, setIsDragOverComposer] = useState(false); const [expandedImage, setExpandedImage] = useState(null); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index ec36da30bfd237c052eabfba0d607716b84fd37d..f3863bff7770200cfe654291316d94b4d1fc0c13 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -21,12 +21,6 @@ import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/ import { serverConfigQueryOptions } from "../lib/serverReactQuery"; import { readNativeApi } from "../nativeApi"; import { useComposerDraftStore } from "../composerDraftStore"; -import { - clearProjectDraftThreadById, - clearProjectDraftThreadId, - readProjectDraftThreadId, - writeProjectDraftThreadId, -} from "../projectDraftThreads"; import { toastManager } from "./ui/toast"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; @@ -240,6 +234,12 @@ function ProjectFavicon({ cwd }: { cwd: string }) { export default function Sidebar() { const { state, dispatch } = useStore(); const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearThreadDraft); + const readProjectDraftThreadId = useComposerDraftStore((store) => store.readProjectDraftThreadId); + const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId); + const clearProjectDraftThreadId = useComposerDraftStore((store) => store.clearProjectDraftThreadId); + const clearProjectDraftThreadById = useComposerDraftStore( + (store) => store.clearProjectDraftThreadById, + ); const navigate = useNavigate(); const { settings: appSettings } = useAppSettings(); const routeThreadId = useParams({ @@ -388,7 +388,7 @@ export default function Sidebar() { if (reusableStoredDraftThread) { return (async () => { await applyThreadOptions(reusableStoredDraftThread); - writeProjectDraftThreadId(projectId, reusableStoredDraftThread.id); + setProjectDraftThreadId(projectId, reusableStoredDraftThread.id); if (routeThreadId === reusableStoredDraftThread.id) { return; } @@ -414,7 +414,7 @@ export default function Sidebar() { if (reusableDraftThread) { return (async () => { await applyThreadOptions(reusableDraftThread); - writeProjectDraftThreadId(projectId, reusableDraftThread.id); + setProjectDraftThreadId(projectId, reusableDraftThread.id); if (routeThreadId === reusableDraftThread.id) { return; } @@ -440,7 +440,7 @@ export default function Sidebar() { worktreePath: options?.worktreePath ?? null, createdAt, }); - writeProjectDraftThreadId(projectId, threadId); + setProjectDraftThreadId(projectId, threadId); // Ensure route guards can see the new thread before navigating. try { @@ -456,7 +456,16 @@ export default function Sidebar() { }); })(); }, - [dispatch, navigate, routeThreadId, state.projects, state.threads], + [ + clearProjectDraftThreadId, + dispatch, + navigate, + readProjectDraftThreadId, + routeThreadId, + setProjectDraftThreadId, + state.projects, + state.threads, + ], ); const focusMostRecentThreadForProject = useCallback( @@ -648,6 +657,7 @@ export default function Sidebar() { [ appSettings.confirmThreadDelete, clearComposerDraftForThread, + clearProjectDraftThreadById, dispatch, navigate, removeWorktreeMutation, @@ -709,7 +719,7 @@ export default function Sidebar() { }); } }, - [clearComposerDraftForThread, state.projects, state.threads], + [clearComposerDraftForThread, clearProjectDraftThreadId, state.projects, state.threads], ); useEffect(() => { diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index a9d350dd12824f77f1f878fa1d34a42ba6246354..a54832dcd4ac33d6e1afb3da424a74d3ecd43831 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -1,4 +1,4 @@ -import { ThreadId } from "@t3tools/contracts"; +import { ProjectId, ThreadId } from "@t3tools/contracts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { type ComposerImageAttachment, useComposerDraftStore } from "./composerDraftStore"; @@ -33,7 +33,7 @@ describe("composerDraftStore addImages", () => { let revokeSpy: ReturnType void>>; beforeEach(() => { - useComposerDraftStore.setState({ draftsByThreadId: {} }); + useComposerDraftStore.setState({ draftsByThreadId: {}, projectDraftThreadIdByProjectId: {} }); originalRevokeObjectUrl = URL.revokeObjectURL; revokeSpy = vi.fn(); URL.revokeObjectURL = revokeSpy; @@ -83,7 +83,7 @@ describe("composerDraftStore addImages", () => { name: "same.png", mimeType: "image/png", sizeBytes: 9, - lastModified: 777, + lastModified: 999, }); useComposerDraftStore.getState().addImage(threadId, first); @@ -111,3 +111,70 @@ describe("composerDraftStore addImages", () => { expect(revokeSpy).not.toHaveBeenCalledWith("blob:shared"); }); }); + +describe("composerDraftStore clearComposerContent", () => { + const threadId = ThreadId.makeUnsafe("thread-clear"); + let originalRevokeObjectUrl: typeof URL.revokeObjectURL; + let revokeSpy: ReturnType void>>; + + beforeEach(() => { + useComposerDraftStore.setState({ draftsByThreadId: {}, projectDraftThreadIdByProjectId: {} }); + originalRevokeObjectUrl = URL.revokeObjectURL; + revokeSpy = vi.fn(); + URL.revokeObjectURL = revokeSpy; + }); + + afterEach(() => { + URL.revokeObjectURL = originalRevokeObjectUrl; + }); + + it("does not revoke blob preview URLs when clearing composer content", () => { + const first = makeImage({ + id: "img-optimistic", + previewUrl: "blob:optimistic", + }); + useComposerDraftStore.getState().addImage(threadId, first); + + useComposerDraftStore.getState().clearComposerContent(threadId); + + const draft = useComposerDraftStore.getState().draftsByThreadId[threadId]; + expect(draft).toBeUndefined(); + expect(revokeSpy).not.toHaveBeenCalledWith("blob:optimistic"); + }); +}); + +describe("composerDraftStore project draft thread mapping", () => { + const projectId = ProjectId.makeUnsafe("project-a"); + const threadId = ThreadId.makeUnsafe("thread-a"); + const otherThreadId = ThreadId.makeUnsafe("thread-b"); + + beforeEach(() => { + useComposerDraftStore.setState({ draftsByThreadId: {}, projectDraftThreadIdByProjectId: {} }); + }); + + it("stores and reads project draft thread ids via actions", () => { + const store = useComposerDraftStore.getState(); + expect(store.readProjectDraftThreadId(projectId)).toBeNull(); + + store.setProjectDraftThreadId(projectId, threadId); + expect(useComposerDraftStore.getState().readProjectDraftThreadId(projectId)).toBe(threadId); + }); + + it("clears only matching project draft mapping entries", () => { + const store = useComposerDraftStore.getState(); + store.setProjectDraftThreadId(projectId, threadId); + + store.clearProjectDraftThreadById(projectId, otherThreadId); + expect(useComposerDraftStore.getState().readProjectDraftThreadId(projectId)).toBe(threadId); + + store.clearProjectDraftThreadById(projectId, threadId); + expect(useComposerDraftStore.getState().readProjectDraftThreadId(projectId)).toBeNull(); + }); + + it("clears project draft mapping by project id", () => { + const store = useComposerDraftStore.getState(); + store.setProjectDraftThreadId(projectId, threadId); + store.clearProjectDraftThreadId(projectId); + expect(useComposerDraftStore.getState().readProjectDraftThreadId(projectId)).toBeNull(); + }); +}); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index bbd4c1cce0d6613b37dc0cbd2b730a475c5270e4..49fba01777539afd6e1040cfd9782cba66018d75 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -1,5 +1,6 @@ import { DEFAULT_REASONING, + ProjectId, REASONING_OPTIONS, ThreadId, normalizeModelSlug, @@ -67,6 +68,7 @@ interface PersistedComposerThreadDraftState { interface PersistedComposerDraftStoreState { draftsByThreadId: Record; + projectDraftThreadIdByProjectId: Record; } export interface ComposerThreadDraftState { @@ -81,6 +83,11 @@ export interface ComposerThreadDraftState { interface ComposerDraftStoreState { draftsByThreadId: Record; + projectDraftThreadIdByProjectId: Record; + readProjectDraftThreadId: (projectId: ProjectId) => ThreadId | null; + setProjectDraftThreadId: (projectId: ProjectId, threadId: ThreadId) => void; + clearProjectDraftThreadId: (projectId: ProjectId) => void; + clearProjectDraftThreadById: (projectId: ProjectId, threadId: ThreadId) => void; setPrompt: (threadId: ThreadId, prompt: string) => void; setCursor: (threadId: ThreadId, cursor: number) => void; setModel: (threadId: ThreadId, model: string | null | undefined) => void; @@ -122,8 +129,9 @@ 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}`; + // Keep this independent from File.lastModified so dedupe is stable for hydrated + // images reconstructed from localStorage (which get a fresh lastModified value). + return `${image.mimeType}\u0000${image.sizeBytes}\u0000${image.name}`; } function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean { @@ -179,12 +187,29 @@ function normalizePersistedAttachment(value: unknown): PersistedComposerImageAtt function normalizePersistedComposerDraftState(value: unknown): PersistedComposerDraftStoreState { if (!value || typeof value !== "object") { - return { draftsByThreadId: {} }; + return { draftsByThreadId: {}, projectDraftThreadIdByProjectId: {} }; } const candidate = value as Record; const rawDraftMap = candidate.draftsByThreadId; + const rawProjectDraftThreadIdByProjectId = candidate.projectDraftThreadIdByProjectId; + const projectDraftThreadIdByProjectId: PersistedComposerDraftStoreState["projectDraftThreadIdByProjectId"] = + {}; + if (rawProjectDraftThreadIdByProjectId && typeof rawProjectDraftThreadIdByProjectId === "object") { + for (const [projectId, threadId] of Object.entries( + rawProjectDraftThreadIdByProjectId as Record, + )) { + if ( + typeof projectId === "string" && + projectId.length > 0 && + typeof threadId === "string" && + threadId.length > 0 + ) { + projectDraftThreadIdByProjectId[projectId as ProjectId] = threadId as ThreadId; + } + } + } if (!rawDraftMap || typeof rawDraftMap !== "object") { - return { draftsByThreadId: {} }; + return { draftsByThreadId: {}, projectDraftThreadIdByProjectId }; } const nextDraftsByThreadId: PersistedComposerDraftStoreState["draftsByThreadId"] = {}; for (const [threadId, draftValue] of Object.entries(rawDraftMap as Record)) { @@ -225,7 +250,7 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer ...(effort ? { effort } : {}), }; } - return { draftsByThreadId: nextDraftsByThreadId }; + return { draftsByThreadId: nextDraftsByThreadId, projectDraftThreadIdByProjectId }; } function fileFromDataUrl(dataUrl: string, name: string, mimeType: string): File | null { @@ -279,12 +304,12 @@ function hydrateImagesFromPersisted( function readPersistedDraftStateFromLocalStorage(): PersistedComposerDraftStoreState { if (typeof window === "undefined") { - return { draftsByThreadId: {} }; + return { draftsByThreadId: {}, projectDraftThreadIdByProjectId: {} }; } try { const raw = window.localStorage.getItem(COMPOSER_DRAFT_STORAGE_KEY); if (!raw) { - return { draftsByThreadId: {} }; + return { draftsByThreadId: {}, projectDraftThreadIdByProjectId: {} }; } const parsed = JSON.parse(raw) as unknown; if (parsed && typeof parsed === "object" && "state" in parsed) { @@ -292,7 +317,7 @@ function readPersistedDraftStateFromLocalStorage(): PersistedComposerDraftStoreS } return normalizePersistedComposerDraftState(parsed); } catch { - return { draftsByThreadId: {} }; + return { draftsByThreadId: {}, projectDraftThreadIdByProjectId: {} }; } } @@ -314,6 +339,57 @@ export const useComposerDraftStore = create()( persist( (set, get) => ({ draftsByThreadId: {}, + projectDraftThreadIdByProjectId: {}, + readProjectDraftThreadId: (projectId) => { + if (projectId.length === 0) { + return null; + } + return get().projectDraftThreadIdByProjectId[projectId] ?? null; + }, + setProjectDraftThreadId: (projectId, threadId) => { + if (projectId.length === 0 || threadId.length === 0) { + return; + } + set((state) => { + if (state.projectDraftThreadIdByProjectId[projectId] === threadId) { + return state; + } + return { + projectDraftThreadIdByProjectId: { + ...state.projectDraftThreadIdByProjectId, + [projectId]: threadId, + }, + }; + }); + }, + clearProjectDraftThreadId: (projectId) => { + if (projectId.length === 0) { + return; + } + set((state) => { + if (state.projectDraftThreadIdByProjectId[projectId] === undefined) { + return state; + } + const { [projectId]: _removed, ...rest } = state.projectDraftThreadIdByProjectId; + return { + projectDraftThreadIdByProjectId: rest, + }; + }); + }, + clearProjectDraftThreadById: (projectId, threadId) => { + if (projectId.length === 0 || threadId.length === 0) { + return; + } + set((state) => { + if (state.projectDraftThreadIdByProjectId[projectId] !== threadId) { + return state; + } + const { [projectId]: _removed, ...rest } = state.projectDraftThreadIdByProjectId; + return { + projectDraftThreadIdByProjectId: rest, + }; + }); + }, setPrompt: (threadId, prompt) => { if (threadId.length === 0) { return; @@ -579,12 +655,6 @@ export const useComposerDraftStore = create()( if (threadId.length === 0) { return; } - const existing = get().draftsByThreadId[threadId]; - if (existing) { - for (const image of existing.images) { - revokeObjectPreviewUrl(image.previewUrl); - } - } set((state) => { const current = state.draftsByThreadId[threadId]; if (!current) { @@ -659,6 +729,7 @@ export const useComposerDraftStore = create()( } return { draftsByThreadId: persistedDraftsByThreadId, + projectDraftThreadIdByProjectId: state.projectDraftThreadIdByProjectId, }; }, merge: (persistedState, currentState) => { @@ -672,6 +743,7 @@ export const useComposerDraftStore = create()( return { ...currentState, draftsByThreadId, + projectDraftThreadIdByProjectId: normalizedPersisted.projectDraftThreadIdByProjectId, }; }, }, diff --git a/apps/web/src/projectDraftThreads.ts b/apps/web/src/projectDraftThreads.ts deleted file mode 100644 index 9548b0e1d2708fa2a14df74f71ed6412face2118..0000000000000000000000000000000000000000 --- a/apps/web/src/projectDraftThreads.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { ProjectId, ThreadId } from "@t3tools/contracts"; - -const PROJECT_DRAFT_THREADS_STORAGE_KEY = "t3code:project-draft-threads:v1"; - -type ProjectDraftThreadMap = Record; - -function readAllProjectDraftThreads(): ProjectDraftThreadMap { - if (typeof window === "undefined") { - return {}; - } - try { - const raw = window.localStorage.getItem(PROJECT_DRAFT_THREADS_STORAGE_KEY); - if (!raw) { - return {}; - } - const parsed = JSON.parse(raw) as unknown; - if (!parsed || typeof parsed !== "object") { - return {}; - } - const next: ProjectDraftThreadMap = {}; - for (const [projectId, threadId] of Object.entries(parsed as Record)) { - if (typeof projectId !== "string" || typeof threadId !== "string") { - continue; - } - if (projectId.length === 0 || threadId.length === 0) { - continue; - } - next[projectId as ProjectId] = threadId as ThreadId; - } - return next; - } catch { - return {}; - } -} - -function persistAllProjectDraftThreads(next: ProjectDraftThreadMap): void { - if (typeof window === "undefined") { - return; - } - try { - const entries = Object.entries(next); - if (entries.length === 0) { - window.localStorage.removeItem(PROJECT_DRAFT_THREADS_STORAGE_KEY); - return; - } - window.localStorage.setItem(PROJECT_DRAFT_THREADS_STORAGE_KEY, JSON.stringify(next)); - } catch { - // Best-effort persistence only. - } -} - -export function readProjectDraftThreadId(projectId: ProjectId): ThreadId | null { - if (projectId.length === 0) { - return null; - } - const all = readAllProjectDraftThreads(); - return all[projectId] ?? null; -} - -export function writeProjectDraftThreadId(projectId: ProjectId, threadId: ThreadId): void { - if (projectId.length === 0 || threadId.length === 0) { - return; - } - const all = readAllProjectDraftThreads(); - all[projectId] = threadId; - persistAllProjectDraftThreads(all); -} - -export function clearProjectDraftThreadId(projectId: ProjectId): void { - if (projectId.length === 0) { - return; - } - const all = readAllProjectDraftThreads(); - if (all[projectId] === undefined) { - return; - } - const { [projectId]: _removed, ...rest } = all; - persistAllProjectDraftThreads(rest); -} - -export function clearProjectDraftThreadById(projectId: ProjectId, threadId: ThreadId): void { - if (projectId.length === 0 || threadId.length === 0) { - return; - } - const all = readAllProjectDraftThreads(); - if (all[projectId] !== threadId) { - return; - } - const { [projectId]: _removed, ...rest } = all; - persistAllProjectDraftThreads(rest); -} diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 5a268c6baf4e2d6ea40f3887339cd166571bdd25..cf019e98bd611795c3fab42d257507c64c1f9bfb 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -11,11 +11,8 @@ export type ModelSlug = string; export const DEFAULT_MODEL = "gpt-5.3-codex"; export const MODEL_SLUG_ALIASES: Record = { - "gpt-5": "gpt-5.3-codex", - "gpt-5-codex": "gpt-5.3-codex", "5.3": "gpt-5.3-codex", "gpt-5.3": "gpt-5.3-codex", - "gpt-5-codex-spark": "gpt-5.3-codex-spark", "5.3-spark": "gpt-5.3-codex-spark", "gpt-5.3-spark": "gpt-5.3-codex-spark", }; -- 2.54.0