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 {...@@ -118,7 +118,6 @@ import {
118 useComposerDraftStore,118 useComposerDraftStore,
119 useComposerThreadDraft,119 useComposerThreadDraft,
120} from "../composerDraftStore";120} from "../composerDraftStore";
121import { clearProjectDraftThreadById } from "../projectDraftThreads";
122import { clamp } from "effect/Number";121import { clamp } from "effect/Number";
123122
124function formatMessageMeta(createdAt: string, duration: string | null): string {123function formatMessageMeta(createdAt: string, duration: string | null): string {
...@@ -429,6 +428,9 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -429,6 +428,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
429 (store) => store.syncPersistedAttachments,428 (store) => store.syncPersistedAttachments,
430 );429 );
431 const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent);430 const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent);
431 const clearProjectDraftThreadById = useComposerDraftStore(
432 (store) => store.clearProjectDraftThreadById,
433 );
432 const promptRef = useRef(prompt);434 const promptRef = useRef(prompt);
433 const [isDragOverComposer, setIsDragOverComposer] = useState(false);435 const [isDragOverComposer, setIsDragOverComposer] = useState(false);
434 const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null);436 const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null);
apps/web/src/components/Sidebar.tsx+21-11
...@@ -21,12 +21,6 @@ import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/...@@ -21,12 +21,6 @@ import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/
21import { serverConfigQueryOptions } from "../lib/serverReactQuery";21import { serverConfigQueryOptions } from "../lib/serverReactQuery";
22import { readNativeApi } from "../nativeApi";22import { readNativeApi } from "../nativeApi";
23import { useComposerDraftStore } from "../composerDraftStore";23import { useComposerDraftStore } from "../composerDraftStore";
24import {
25 clearProjectDraftThreadById,
26 clearProjectDraftThreadId,
27 readProjectDraftThreadId,
28 writeProjectDraftThreadId,
29} from "../projectDraftThreads";
30import { toastManager } from "./ui/toast";24import { toastManager } from "./ui/toast";
31import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible";25import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible";
32import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";26import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
...@@ -240,6 +234,12 @@ function ProjectFavicon({ cwd }: { cwd: string }) {...@@ -240,6 +234,12 @@ function ProjectFavicon({ cwd }: { cwd: string }) {
240export default function Sidebar() {234export default function Sidebar() {
241 const { state, dispatch } = useStore();235 const { state, dispatch } = useStore();
242 const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearThreadDraft);236 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 );
243 const navigate = useNavigate();243 const navigate = useNavigate();
244 const { settings: appSettings } = useAppSettings();244 const { settings: appSettings } = useAppSettings();
245 const routeThreadId = useParams({245 const routeThreadId = useParams({
...@@ -388,7 +388,7 @@ export default function Sidebar() {...@@ -388,7 +388,7 @@ export default function Sidebar() {
388 if (reusableStoredDraftThread) {388 if (reusableStoredDraftThread) {
389 return (async () => {389 return (async () => {
390 await applyThreadOptions(reusableStoredDraftThread);390 await applyThreadOptions(reusableStoredDraftThread);
391 writeProjectDraftThreadId(projectId, reusableStoredDraftThread.id);391 setProjectDraftThreadId(projectId, reusableStoredDraftThread.id);
392 if (routeThreadId === reusableStoredDraftThread.id) {392 if (routeThreadId === reusableStoredDraftThread.id) {
393 return;393 return;
394 }394 }
...@@ -414,7 +414,7 @@ export default function Sidebar() {...@@ -414,7 +414,7 @@ export default function Sidebar() {
414 if (reusableDraftThread) {414 if (reusableDraftThread) {
415 return (async () => {415 return (async () => {
416 await applyThreadOptions(reusableDraftThread);416 await applyThreadOptions(reusableDraftThread);
417 writeProjectDraftThreadId(projectId, reusableDraftThread.id);417 setProjectDraftThreadId(projectId, reusableDraftThread.id);
418 if (routeThreadId === reusableDraftThread.id) {418 if (routeThreadId === reusableDraftThread.id) {
419 return;419 return;
420 }420 }
...@@ -440,7 +440,7 @@ export default function Sidebar() {...@@ -440,7 +440,7 @@ export default function Sidebar() {
440 worktreePath: options?.worktreePath ?? null,440 worktreePath: options?.worktreePath ?? null,
441 createdAt,441 createdAt,
442 });442 });
443 writeProjectDraftThreadId(projectId, threadId);443 setProjectDraftThreadId(projectId, threadId);
444444
445 // Ensure route guards can see the new thread before navigating.445 // Ensure route guards can see the new thread before navigating.
446 try {446 try {
...@@ -456,7 +456,16 @@ export default function Sidebar() {...@@ -456,7 +456,16 @@ export default function Sidebar() {
456 });456 });
457 })();457 })();
458 },458 },
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 ],
460 );469 );
461470
462 const focusMostRecentThreadForProject = useCallback(471 const focusMostRecentThreadForProject = useCallback(
...@@ -648,6 +657,7 @@ export default function Sidebar() {...@@ -648,6 +657,7 @@ export default function Sidebar() {
648 [657 [
649 appSettings.confirmThreadDelete,658 appSettings.confirmThreadDelete,
650 clearComposerDraftForThread,659 clearComposerDraftForThread,
660 clearProjectDraftThreadById,
651 dispatch,661 dispatch,
652 navigate,662 navigate,
653 removeWorktreeMutation,663 removeWorktreeMutation,
...@@ -709,7 +719,7 @@ export default function Sidebar() {...@@ -709,7 +719,7 @@ export default function Sidebar() {
709 });719 });
710 }720 }
711 },721 },
712 [clearComposerDraftForThread, state.projects, state.threads],722 [clearComposerDraftForThread, clearProjectDraftThreadId, state.projects, state.threads],
713 );723 );
714724
715 useEffect(() => {725 useEffect(() => {
apps/web/src/composerDraftStore.test.ts+70-3
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { ThreadId } from "@t3tools/contracts";1import { ProjectId, ThreadId } from "@t3tools/contracts";
2import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";2import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
33
4import { type ComposerImageAttachment, useComposerDraftStore } from "./composerDraftStore";4import { type ComposerImageAttachment, useComposerDraftStore } from "./composerDraftStore";
...@@ -33,7 +33,7 @@ describe("composerDraftStore addImages", () => {...@@ -33,7 +33,7 @@ describe("composerDraftStore addImages", () => {
33 let revokeSpy: ReturnType<typeof vi.fn<(url: string) => void>>;33 let revokeSpy: ReturnType<typeof vi.fn<(url: string) => void>>;
3434
35 beforeEach(() => {35 beforeEach(() => {
36 useComposerDraftStore.setState({ draftsByThreadId: {} });36 useComposerDraftStore.setState({ draftsByThreadId: {}, projectDraftThreadIdByProjectId: {} });
37 originalRevokeObjectUrl = URL.revokeObjectURL;37 originalRevokeObjectUrl = URL.revokeObjectURL;
38 revokeSpy = vi.fn();38 revokeSpy = vi.fn();
39 URL.revokeObjectURL = revokeSpy;39 URL.revokeObjectURL = revokeSpy;
...@@ -83,7 +83,7 @@ describe("composerDraftStore addImages", () => {...@@ -83,7 +83,7 @@ describe("composerDraftStore addImages", () => {
83 name: "same.png",83 name: "same.png",
84 mimeType: "image/png",84 mimeType: "image/png",
85 sizeBytes: 9,85 sizeBytes: 9,
86 lastModified: 777,86 lastModified: 999,
87 });87 });
8888
89 useComposerDraftStore.getState().addImage(threadId, first);89 useComposerDraftStore.getState().addImage(threadId, first);
...@@ -111,3 +111,70 @@ describe("composerDraftStore addImages", () => {...@@ -111,3 +111,70 @@ describe("composerDraftStore addImages", () => {
111 expect(revokeSpy).not.toHaveBeenCalledWith("blob:shared");111 expect(revokeSpy).not.toHaveBeenCalledWith("blob:shared");
112 });112 });
113});113});
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 @@...@@ -1,5 +1,6 @@
1import {1import {
2 DEFAULT_REASONING,2 DEFAULT_REASONING,
3 ProjectId,
3 REASONING_OPTIONS,4 REASONING_OPTIONS,
4 ThreadId,5 ThreadId,
5 normalizeModelSlug,6 normalizeModelSlug,
...@@ -67,6 +68,7 @@ interface PersistedComposerThreadDraftState {...@@ -67,6 +68,7 @@ interface PersistedComposerThreadDraftState {
6768
68interface PersistedComposerDraftStoreState {69interface PersistedComposerDraftStoreState {
69 draftsByThreadId: Record<ThreadId, PersistedComposerThreadDraftState>;70 draftsByThreadId: Record<ThreadId, PersistedComposerThreadDraftState>;
71 projectDraftThreadIdByProjectId: Record<ProjectId, ThreadId>;
70}72}
7173
72export interface ComposerThreadDraftState {74export interface ComposerThreadDraftState {
...@@ -81,6 +83,11 @@ export interface ComposerThreadDraftState {...@@ -81,6 +83,11 @@ export interface ComposerThreadDraftState {
8183
82interface ComposerDraftStoreState {84interface ComposerDraftStoreState {
83 draftsByThreadId: Record<ThreadId, ComposerThreadDraftState>;85 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;
84 setPrompt: (threadId: ThreadId, prompt: string) => void;91 setPrompt: (threadId: ThreadId, prompt: string) => void;
85 setCursor: (threadId: ThreadId, cursor: number) => void;92 setCursor: (threadId: ThreadId, cursor: number) => void;
86 setModel: (threadId: ThreadId, model: string | null | undefined) => void;93 setModel: (threadId: ThreadId, model: string | null | undefined) => void;
...@@ -122,8 +129,9 @@ function createEmptyThreadDraft(): ComposerThreadDraftState {...@@ -122,8 +129,9 @@ function createEmptyThreadDraft(): ComposerThreadDraftState {
122}129}
123130
124function composerImageDedupKey(image: ComposerImageAttachment): string {131function composerImageDedupKey(image: ComposerImageAttachment): string {
125 const lastModified = Number.isFinite(image.file.lastModified) ? image.file.lastModified : 0;132 // Keep this independent from File.lastModified so dedupe is stable for hydrated
126 return `${image.mimeType}\u0000${image.sizeBytes}\u0000${lastModified}\u0000${image.name}`;133 // images reconstructed from localStorage (which get a fresh lastModified value).
134 return `${image.mimeType}\u0000${image.sizeBytes}\u0000${image.name}`;
127}135}
128136
129function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean {137function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean {
...@@ -179,12 +187,29 @@ function normalizePersistedAttachment(value: unknown): PersistedComposerImageAtt...@@ -179,12 +187,29 @@ function normalizePersistedAttachment(value: unknown): PersistedComposerImageAtt
179187
180function normalizePersistedComposerDraftState(value: unknown): PersistedComposerDraftStoreState {188function normalizePersistedComposerDraftState(value: unknown): PersistedComposerDraftStoreState {
181 if (!value || typeof value !== "object") {189 if (!value || typeof value !== "object") {
182 return { draftsByThreadId: {} };190 return { draftsByThreadId: {}, projectDraftThreadIdByProjectId: {} };
183 }191 }
184 const candidate = value as Record<string, unknown>;192 const candidate = value as Record<string, unknown>;
185 const rawDraftMap = candidate.draftsByThreadId;193 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 }
186 if (!rawDraftMap || typeof rawDraftMap !== "object") {211 if (!rawDraftMap || typeof rawDraftMap !== "object") {
187 return { draftsByThreadId: {} };212 return { draftsByThreadId: {}, projectDraftThreadIdByProjectId };
188 }213 }
189 const nextDraftsByThreadId: PersistedComposerDraftStoreState["draftsByThreadId"] = {};214 const nextDraftsByThreadId: PersistedComposerDraftStoreState["draftsByThreadId"] = {};
190 for (const [threadId, draftValue] of Object.entries(rawDraftMap as Record<string, unknown>)) {215 for (const [threadId, draftValue] of Object.entries(rawDraftMap as Record<string, unknown>)) {
...@@ -225,7 +250,7 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer...@@ -225,7 +250,7 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer
225 ...(effort ? { effort } : {}),250 ...(effort ? { effort } : {}),
226 };251 };
227 }252 }
228 return { draftsByThreadId: nextDraftsByThreadId };253 return { draftsByThreadId: nextDraftsByThreadId, projectDraftThreadIdByProjectId };
229}254}
230255
231function fileFromDataUrl(dataUrl: string, name: string, mimeType: string): File | null {256function fileFromDataUrl(dataUrl: string, name: string, mimeType: string): File | null {
...@@ -279,12 +304,12 @@ function hydrateImagesFromPersisted(...@@ -279,12 +304,12 @@ function hydrateImagesFromPersisted(
279304
280function readPersistedDraftStateFromLocalStorage(): PersistedComposerDraftStoreState {305function readPersistedDraftStateFromLocalStorage(): PersistedComposerDraftStoreState {
281 if (typeof window === "undefined") {306 if (typeof window === "undefined") {
282 return { draftsByThreadId: {} };307 return { draftsByThreadId: {}, projectDraftThreadIdByProjectId: {} };
283 }308 }
284 try {309 try {
285 const raw = window.localStorage.getItem(COMPOSER_DRAFT_STORAGE_KEY);310 const raw = window.localStorage.getItem(COMPOSER_DRAFT_STORAGE_KEY);
286 if (!raw) {311 if (!raw) {
287 return { draftsByThreadId: {} };312 return { draftsByThreadId: {}, projectDraftThreadIdByProjectId: {} };
288 }313 }
289 const parsed = JSON.parse(raw) as unknown;314 const parsed = JSON.parse(raw) as unknown;
290 if (parsed && typeof parsed === "object" && "state" in parsed) {315 if (parsed && typeof parsed === "object" && "state" in parsed) {
...@@ -292,7 +317,7 @@ function readPersistedDraftStateFromLocalStorage(): PersistedComposerDraftStoreS...@@ -292,7 +317,7 @@ function readPersistedDraftStateFromLocalStorage(): PersistedComposerDraftStoreS
292 }317 }
293 return normalizePersistedComposerDraftState(parsed);318 return normalizePersistedComposerDraftState(parsed);
294 } catch {319 } catch {
295 return { draftsByThreadId: {} };320 return { draftsByThreadId: {}, projectDraftThreadIdByProjectId: {} };
296 }321 }
297}322}
298323
...@@ -314,6 +339,57 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(...@@ -314,6 +339,57 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
314 persist(339 persist(
315 (set, get) => ({340 (set, get) => ({
316 draftsByThreadId: {},341 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 },
317 setPrompt: (threadId, prompt) => {393 setPrompt: (threadId, prompt) => {
318 if (threadId.length === 0) {394 if (threadId.length === 0) {
319 return;395 return;
...@@ -579,12 +655,6 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(...@@ -579,12 +655,6 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
579 if (threadId.length === 0) {655 if (threadId.length === 0) {
580 return;656 return;
581 }657 }
582 const existing = get().draftsByThreadId[threadId];
583 if (existing) {
584 for (const image of existing.images) {
585 revokeObjectPreviewUrl(image.previewUrl);
586 }
587 }
588 set((state) => {658 set((state) => {
589 const current = state.draftsByThreadId[threadId];659 const current = state.draftsByThreadId[threadId];
590 if (!current) {660 if (!current) {
...@@ -659,6 +729,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(...@@ -659,6 +729,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
659 }729 }
660 return {730 return {
661 draftsByThreadId: persistedDraftsByThreadId,731 draftsByThreadId: persistedDraftsByThreadId,
732 projectDraftThreadIdByProjectId: state.projectDraftThreadIdByProjectId,
662 };733 };
663 },734 },
664 merge: (persistedState, currentState) => {735 merge: (persistedState, currentState) => {
...@@ -672,6 +743,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(...@@ -672,6 +743,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
672 return {743 return {
673 ...currentState,744 ...currentState,
674 draftsByThreadId,745 draftsByThreadId,
746 projectDraftThreadIdByProjectId: normalizedPersisted.projectDraftThreadIdByProjectId,
675 };747 };
676 },748 },
677 },749 },
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;...@@ -11,11 +11,8 @@ export type ModelSlug = string;
11export const DEFAULT_MODEL = "gpt-5.3-codex";11export const DEFAULT_MODEL = "gpt-5.3-codex";
1212
13export const MODEL_SLUG_ALIASES: Record<string, BuiltInModelSlug> = {13export const MODEL_SLUG_ALIASES: Record<string, BuiltInModelSlug> = {
14 "gpt-5": "gpt-5.3-codex",
15 "gpt-5-codex": "gpt-5.3-codex",
16 "5.3": "gpt-5.3-codex",14 "5.3": "gpt-5.3-codex",
17 "gpt-5.3": "gpt-5.3-codex",15 "gpt-5.3": "gpt-5.3-codex",
18 "gpt-5-codex-spark": "gpt-5.3-codex-spark",
19 "5.3-spark": "gpt-5.3-codex-spark",16 "5.3-spark": "gpt-5.3-codex-spark",
20 "gpt-5.3-spark": "gpt-5.3-codex-spark",17 "gpt-5.3-spark": "gpt-5.3-codex-spark",
21};18};