authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-01 00:55:28-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-01 22:36:35-08:00
logb59c6d6ab1b71295bf8c77e611522989e1e97fc8
tree6c9af7fdfede05ab08cccb21817347be5965a661
parentc9995995a42317676290ca1b953dfeabdedd8920

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`)

6 files changed, 180 insertions(+), 123 deletions(-)

apps/web/src/components/ChatView.tsx+3-1
......@@ -118,7 +118,6 @@ import {
118118 useComposerDraftStore,
119119 useComposerThreadDraft,
120120} from "../composerDraftStore";
121import { clearProjectDraftThreadById } from "../projectDraftThreads";
122121import { clamp } from "effect/Number";
123122
124123function formatMessageMeta(createdAt: string, duration: string | null): string {
......@@ -429,6 +428,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
429428 (store) => store.syncPersistedAttachments,
430429 );
431430 const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent);
431 const clearProjectDraftThreadById = useComposerDraftStore(
432 (store) => store.clearProjectDraftThreadById,
433 );
432434 const promptRef = useRef(prompt);
433435 const [isDragOverComposer, setIsDragOverComposer] = useState(false);
434436 const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null);
apps/web/src/components/Sidebar.tsx+21-11
......@@ -21,12 +21,6 @@ import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/
2121import { serverConfigQueryOptions } from "../lib/serverReactQuery";
2222import { readNativeApi } from "../nativeApi";
2323import { useComposerDraftStore } from "../composerDraftStore";
24import {
25 clearProjectDraftThreadById,
26 clearProjectDraftThreadId,
27 readProjectDraftThreadId,
28 writeProjectDraftThreadId,
29} from "../projectDraftThreads";
3024import { toastManager } from "./ui/toast";
3125import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible";
3226import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
......@@ -240,6 +234,12 @@ function ProjectFavicon({ cwd }: { cwd: string }) {
240234export default function Sidebar() {
241235 const { state, dispatch } = useStore();
242236 const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearThreadDraft);
237 const readProjectDraftThreadId = useComposerDraftStore((store) => store.readProjectDraftThreadId);
238 const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId);
239 const clearProjectDraftThreadId = useComposerDraftStore((store) => store.clearProjectDraftThreadId);
240 const clearProjectDraftThreadById = useComposerDraftStore(
241 (store) => store.clearProjectDraftThreadById,
242 );
243243 const navigate = useNavigate();
244244 const { settings: appSettings } = useAppSettings();
245245 const routeThreadId = useParams({
......@@ -388,7 +388,7 @@ export default function Sidebar() {
388388 if (reusableStoredDraftThread) {
389389 return (async () => {
390390 await applyThreadOptions(reusableStoredDraftThread);
391 writeProjectDraftThreadId(projectId, reusableStoredDraftThread.id);
391 setProjectDraftThreadId(projectId, reusableStoredDraftThread.id);
392392 if (routeThreadId === reusableStoredDraftThread.id) {
393393 return;
394394 }
......@@ -414,7 +414,7 @@ export default function Sidebar() {
414414 if (reusableDraftThread) {
415415 return (async () => {
416416 await applyThreadOptions(reusableDraftThread);
417 writeProjectDraftThreadId(projectId, reusableDraftThread.id);
417 setProjectDraftThreadId(projectId, reusableDraftThread.id);
418418 if (routeThreadId === reusableDraftThread.id) {
419419 return;
420420 }
......@@ -440,7 +440,7 @@ export default function Sidebar() {
440440 worktreePath: options?.worktreePath ?? null,
441441 createdAt,
442442 });
443 writeProjectDraftThreadId(projectId, threadId);
443 setProjectDraftThreadId(projectId, threadId);
444444
445445 // Ensure route guards can see the new thread before navigating.
446446 try {
......@@ -456,7 +456,16 @@ export default function Sidebar() {
456456 });
457457 })();
458458 },
459 [dispatch, navigate, routeThreadId, state.projects, state.threads],
459 [
460 clearProjectDraftThreadId,
461 dispatch,
462 navigate,
463 readProjectDraftThreadId,
464 routeThreadId,
465 setProjectDraftThreadId,
466 state.projects,
467 state.threads,
468 ],
460469 );
461470
462471 const focusMostRecentThreadForProject = useCallback(
......@@ -648,6 +657,7 @@ export default function Sidebar() {
648657 [
649658 appSettings.confirmThreadDelete,
650659 clearComposerDraftForThread,
660 clearProjectDraftThreadById,
651661 dispatch,
652662 navigate,
653663 removeWorktreeMutation,
......@@ -709,7 +719,7 @@ export default function Sidebar() {
709719 });
710720 }
711721 },
712 [clearComposerDraftForThread, state.projects, state.threads],
722 [clearComposerDraftForThread, clearProjectDraftThreadId, state.projects, state.threads],
713723 );
714724
715725 useEffect(() => {
apps/web/src/composerDraftStore.test.ts+70-3
......@@ -1,4 +1,4 @@
1import { ThreadId } from "@t3tools/contracts";
1import { ProjectId, ThreadId } from "@t3tools/contracts";
22import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
33
44import { type ComposerImageAttachment, useComposerDraftStore } from "./composerDraftStore";
......@@ -33,7 +33,7 @@ describe("composerDraftStore addImages", () => {
3333 let revokeSpy: ReturnType<typeof vi.fn<(url: string) => void>>;
3434
3535 beforeEach(() => {
36 useComposerDraftStore.setState({ draftsByThreadId: {} });
36 useComposerDraftStore.setState({ draftsByThreadId: {}, projectDraftThreadIdByProjectId: {} });
3737 originalRevokeObjectUrl = URL.revokeObjectURL;
3838 revokeSpy = vi.fn();
3939 URL.revokeObjectURL = revokeSpy;
......@@ -83,7 +83,7 @@ describe("composerDraftStore addImages", () => {
8383 name: "same.png",
8484 mimeType: "image/png",
8585 sizeBytes: 9,
86 lastModified: 777,
86 lastModified: 999,
8787 });
8888
8989 useComposerDraftStore.getState().addImage(threadId, first);
......@@ -111,3 +111,70 @@ describe("composerDraftStore addImages", () => {
111111 expect(revokeSpy).not.toHaveBeenCalledWith("blob:shared");
112112 });
113113});
114
115describe("composerDraftStore clearComposerContent", () => {
116 const threadId = ThreadId.makeUnsafe("thread-clear");
117 let originalRevokeObjectUrl: typeof URL.revokeObjectURL;
118 let revokeSpy: ReturnType<typeof vi.fn<(url: string) => void>>;
119
120 beforeEach(() => {
121 useComposerDraftStore.setState({ draftsByThreadId: {}, projectDraftThreadIdByProjectId: {} });
122 originalRevokeObjectUrl = URL.revokeObjectURL;
123 revokeSpy = vi.fn();
124 URL.revokeObjectURL = revokeSpy;
125 });
126
127 afterEach(() => {
128 URL.revokeObjectURL = originalRevokeObjectUrl;
129 });
130
131 it("does not revoke blob preview URLs when clearing composer content", () => {
132 const first = makeImage({
133 id: "img-optimistic",
134 previewUrl: "blob:optimistic",
135 });
136 useComposerDraftStore.getState().addImage(threadId, first);
137
138 useComposerDraftStore.getState().clearComposerContent(threadId);
139
140 const draft = useComposerDraftStore.getState().draftsByThreadId[threadId];
141 expect(draft).toBeUndefined();
142 expect(revokeSpy).not.toHaveBeenCalledWith("blob:optimistic");
143 });
144});
145
146describe("composerDraftStore project draft thread mapping", () => {
147 const projectId = ProjectId.makeUnsafe("project-a");
148 const threadId = ThreadId.makeUnsafe("thread-a");
149 const otherThreadId = ThreadId.makeUnsafe("thread-b");
150
151 beforeEach(() => {
152 useComposerDraftStore.setState({ draftsByThreadId: {}, projectDraftThreadIdByProjectId: {} });
153 });
154
155 it("stores and reads project draft thread ids via actions", () => {
156 const store = useComposerDraftStore.getState();
157 expect(store.readProjectDraftThreadId(projectId)).toBeNull();
158
159 store.setProjectDraftThreadId(projectId, threadId);
160 expect(useComposerDraftStore.getState().readProjectDraftThreadId(projectId)).toBe(threadId);
161 });
162
163 it("clears only matching project draft mapping entries", () => {
164 const store = useComposerDraftStore.getState();
165 store.setProjectDraftThreadId(projectId, threadId);
166
167 store.clearProjectDraftThreadById(projectId, otherThreadId);
168 expect(useComposerDraftStore.getState().readProjectDraftThreadId(projectId)).toBe(threadId);
169
170 store.clearProjectDraftThreadById(projectId, threadId);
171 expect(useComposerDraftStore.getState().readProjectDraftThreadId(projectId)).toBeNull();
172 });
173
174 it("clears project draft mapping by project id", () => {
175 const store = useComposerDraftStore.getState();
176 store.setProjectDraftThreadId(projectId, threadId);
177 store.clearProjectDraftThreadId(projectId);
178 expect(useComposerDraftStore.getState().readProjectDraftThreadId(projectId)).toBeNull();
179 });
180});
apps/web/src/composerDraftStore.ts+86-14
......@@ -1,5 +1,6 @@
11import {
22 DEFAULT_REASONING,
3 ProjectId,
34 REASONING_OPTIONS,
45 ThreadId,
56 normalizeModelSlug,
......@@ -67,6 +68,7 @@ interface PersistedComposerThreadDraftState {
6768
6869interface PersistedComposerDraftStoreState {
6970 draftsByThreadId: Record<ThreadId, PersistedComposerThreadDraftState>;
71 projectDraftThreadIdByProjectId: Record<ProjectId, ThreadId>;
7072}
7173
7274export interface ComposerThreadDraftState {
......@@ -81,6 +83,11 @@ export interface ComposerThreadDraftState {
8183
8284interface ComposerDraftStoreState {
8385 draftsByThreadId: Record<ThreadId, ComposerThreadDraftState>;
86 projectDraftThreadIdByProjectId: Record<ProjectId, ThreadId>;
87 readProjectDraftThreadId: (projectId: ProjectId) => ThreadId | null;
88 setProjectDraftThreadId: (projectId: ProjectId, threadId: ThreadId) => void;
89 clearProjectDraftThreadId: (projectId: ProjectId) => void;
90 clearProjectDraftThreadById: (projectId: ProjectId, threadId: ThreadId) => void;
8491 setPrompt: (threadId: ThreadId, prompt: string) => void;
8592 setCursor: (threadId: ThreadId, cursor: number) => void;
8693 setModel: (threadId: ThreadId, model: string | null | undefined) => void;
......@@ -122,8 +129,9 @@ function createEmptyThreadDraft(): ComposerThreadDraftState {
122129}
123130
124131function 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}`;
132 // Keep this independent from File.lastModified so dedupe is stable for hydrated
133 // images reconstructed from localStorage (which get a fresh lastModified value).
134 return `${image.mimeType}\u0000${image.sizeBytes}\u0000${image.name}`;
127135}
128136
129137function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean {
......@@ -179,12 +187,29 @@ function normalizePersistedAttachment(value: unknown): PersistedComposerImageAtt
179187
180188function normalizePersistedComposerDraftState(value: unknown): PersistedComposerDraftStoreState {
181189 if (!value || typeof value !== "object") {
182 return { draftsByThreadId: {} };
190 return { draftsByThreadId: {}, projectDraftThreadIdByProjectId: {} };
183191 }
184192 const candidate = value as Record<string, unknown>;
185193 const rawDraftMap = candidate.draftsByThreadId;
194 const rawProjectDraftThreadIdByProjectId = candidate.projectDraftThreadIdByProjectId;
195 const projectDraftThreadIdByProjectId: PersistedComposerDraftStoreState["projectDraftThreadIdByProjectId"] =
196 {};
197 if (rawProjectDraftThreadIdByProjectId && typeof rawProjectDraftThreadIdByProjectId === "object") {
198 for (const [projectId, threadId] of Object.entries(
199 rawProjectDraftThreadIdByProjectId as Record<string, unknown>,
200 )) {
201 if (
202 typeof projectId === "string" &&
203 projectId.length > 0 &&
204 typeof threadId === "string" &&
205 threadId.length > 0
206 ) {
207 projectDraftThreadIdByProjectId[projectId as ProjectId] = threadId as ThreadId;
208 }
209 }
210 }
186211 if (!rawDraftMap || typeof rawDraftMap !== "object") {
187 return { draftsByThreadId: {} };
212 return { draftsByThreadId: {}, projectDraftThreadIdByProjectId };
188213 }
189214 const nextDraftsByThreadId: PersistedComposerDraftStoreState["draftsByThreadId"] = {};
190215 for (const [threadId, draftValue] of Object.entries(rawDraftMap as Record<string, unknown>)) {
......@@ -225,7 +250,7 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer
225250 ...(effort ? { effort } : {}),
226251 };
227252 }
228 return { draftsByThreadId: nextDraftsByThreadId };
253 return { draftsByThreadId: nextDraftsByThreadId, projectDraftThreadIdByProjectId };
229254}
230255
231256function fileFromDataUrl(dataUrl: string, name: string, mimeType: string): File | null {
......@@ -279,12 +304,12 @@ function hydrateImagesFromPersisted(
279304
280305function readPersistedDraftStateFromLocalStorage(): PersistedComposerDraftStoreState {
281306 if (typeof window === "undefined") {
282 return { draftsByThreadId: {} };
307 return { draftsByThreadId: {}, projectDraftThreadIdByProjectId: {} };
283308 }
284309 try {
285310 const raw = window.localStorage.getItem(COMPOSER_DRAFT_STORAGE_KEY);
286311 if (!raw) {
287 return { draftsByThreadId: {} };
312 return { draftsByThreadId: {}, projectDraftThreadIdByProjectId: {} };
288313 }
289314 const parsed = JSON.parse(raw) as unknown;
290315 if (parsed && typeof parsed === "object" && "state" in parsed) {
......@@ -292,7 +317,7 @@ function readPersistedDraftStateFromLocalStorage(): PersistedComposerDraftStoreS
292317 }
293318 return normalizePersistedComposerDraftState(parsed);
294319 } catch {
295 return { draftsByThreadId: {} };
320 return { draftsByThreadId: {}, projectDraftThreadIdByProjectId: {} };
296321 }
297322}
298323
......@@ -314,6 +339,57 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
314339 persist(
315340 (set, get) => ({
316341 draftsByThreadId: {},
342 projectDraftThreadIdByProjectId: {},
343 readProjectDraftThreadId: (projectId) => {
344 if (projectId.length === 0) {
345 return null;
346 }
347 return get().projectDraftThreadIdByProjectId[projectId] ?? null;
348 },
349 setProjectDraftThreadId: (projectId, threadId) => {
350 if (projectId.length === 0 || threadId.length === 0) {
351 return;
352 }
353 set((state) => {
354 if (state.projectDraftThreadIdByProjectId[projectId] === threadId) {
355 return state;
356 }
357 return {
358 projectDraftThreadIdByProjectId: {
359 ...state.projectDraftThreadIdByProjectId,
360 [projectId]: threadId,
361 },
362 };
363 });
364 },
365 clearProjectDraftThreadId: (projectId) => {
366 if (projectId.length === 0) {
367 return;
368 }
369 set((state) => {
370 if (state.projectDraftThreadIdByProjectId[projectId] === undefined) {
371 return state;
372 }
373 const { [projectId]: _removed, ...rest } = state.projectDraftThreadIdByProjectId;
374 return {
375 projectDraftThreadIdByProjectId: rest,
376 };
377 });
378 },
379 clearProjectDraftThreadById: (projectId, threadId) => {
380 if (projectId.length === 0 || threadId.length === 0) {
381 return;
382 }
383 set((state) => {
384 if (state.projectDraftThreadIdByProjectId[projectId] !== threadId) {
385 return state;
386 }
387 const { [projectId]: _removed, ...rest } = state.projectDraftThreadIdByProjectId;
388 return {
389 projectDraftThreadIdByProjectId: rest,
390 };
391 });
392 },
317393 setPrompt: (threadId, prompt) => {
318394 if (threadId.length === 0) {
319395 return;
......@@ -579,12 +655,6 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
579655 if (threadId.length === 0) {
580656 return;
581657 }
582 const existing = get().draftsByThreadId[threadId];
583 if (existing) {
584 for (const image of existing.images) {
585 revokeObjectPreviewUrl(image.previewUrl);
586 }
587 }
588658 set((state) => {
589659 const current = state.draftsByThreadId[threadId];
590660 if (!current) {
......@@ -659,6 +729,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
659729 }
660730 return {
661731 draftsByThreadId: persistedDraftsByThreadId,
732 projectDraftThreadIdByProjectId: state.projectDraftThreadIdByProjectId,
662733 };
663734 },
664735 merge: (persistedState, currentState) => {
......@@ -672,6 +743,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
672743 return {
673744 ...currentState,
674745 draftsByThreadId,
746 projectDraftThreadIdByProjectId: normalizedPersisted.projectDraftThreadIdByProjectId,
675747 };
676748 },
677749 },
apps/web/src/projectDraftThreads.ts deleted-91
......@@ -1,91 +0,0 @@
1import { ProjectId, ThreadId } from "@t3tools/contracts";
2
3const PROJECT_DRAFT_THREADS_STORAGE_KEY = "t3code:project-draft-threads:v1";
4
5type ProjectDraftThreadMap = Record<ProjectId, ThreadId>;
6
7function readAllProjectDraftThreads(): ProjectDraftThreadMap {
8 if (typeof window === "undefined") {
9 return {};
10 }
11 try {
12 const raw = window.localStorage.getItem(PROJECT_DRAFT_THREADS_STORAGE_KEY);
13 if (!raw) {
14 return {};
15 }
16 const parsed = JSON.parse(raw) as unknown;
17 if (!parsed || typeof parsed !== "object") {
18 return {};
19 }
20 const next: ProjectDraftThreadMap = {};
21 for (const [projectId, threadId] of Object.entries(parsed as Record<string, unknown>)) {
22 if (typeof projectId !== "string" || typeof threadId !== "string") {
23 continue;
24 }
25 if (projectId.length === 0 || threadId.length === 0) {
26 continue;
27 }
28 next[projectId as ProjectId] = threadId as ThreadId;
29 }
30 return next;
31 } catch {
32 return {};
33 }
34}
35
36function persistAllProjectDraftThreads(next: ProjectDraftThreadMap): void {
37 if (typeof window === "undefined") {
38 return;
39 }
40 try {
41 const entries = Object.entries(next);
42 if (entries.length === 0) {
43 window.localStorage.removeItem(PROJECT_DRAFT_THREADS_STORAGE_KEY);
44 return;
45 }
46 window.localStorage.setItem(PROJECT_DRAFT_THREADS_STORAGE_KEY, JSON.stringify(next));
47 } catch {
48 // Best-effort persistence only.
49 }
50}
51
52export function readProjectDraftThreadId(projectId: ProjectId): ThreadId | null {
53 if (projectId.length === 0) {
54 return null;
55 }
56 const all = readAllProjectDraftThreads();
57 return all[projectId] ?? null;
58}
59
60export function writeProjectDraftThreadId(projectId: ProjectId, threadId: ThreadId): void {
61 if (projectId.length === 0 || threadId.length === 0) {
62 return;
63 }
64 const all = readAllProjectDraftThreads();
65 all[projectId] = threadId;
66 persistAllProjectDraftThreads(all);
67}
68
69export function clearProjectDraftThreadId(projectId: ProjectId): void {
70 if (projectId.length === 0) {
71 return;
72 }
73 const all = readAllProjectDraftThreads();
74 if (all[projectId] === undefined) {
75 return;
76 }
77 const { [projectId]: _removed, ...rest } = all;
78 persistAllProjectDraftThreads(rest);
79}
80
81export function clearProjectDraftThreadById(projectId: ProjectId, threadId: ThreadId): void {
82 if (projectId.length === 0 || threadId.length === 0) {
83 return;
84 }
85 const all = readAllProjectDraftThreads();
86 if (all[projectId] !== threadId) {
87 return;
88 }
89 const { [projectId]: _removed, ...rest } = all;
90 persistAllProjectDraftThreads(rest);
91}
packages/contracts/src/model.ts-3
......@@ -11,11 +11,8 @@ export type ModelSlug = string;
1111export const DEFAULT_MODEL = "gpt-5.3-codex";
1212
1313export const MODEL_SLUG_ALIASES: Record<string, BuiltInModelSlug> = {
14 "gpt-5": "gpt-5.3-codex",
15 "gpt-5-codex": "gpt-5.3-codex",
1614 "5.3": "gpt-5.3-codex",
1715 "gpt-5.3": "gpt-5.3-codex",
18 "gpt-5-codex-spark": "gpt-5.3-codex-spark",
1916 "5.3-spark": "gpt-5.3-codex-spark",
2017 "gpt-5.3-spark": "gpt-5.3-codex-spark",
2118};