| author | |
| committer | |
| log | b59c6d6ab1b71295bf8c77e611522989e1e97fc8 |
| tree | 6c9af7fdfede05ab08cccb21817347be5965a661 |
| parent | c9995995a42317676290ca1b953dfeabdedd8920 |
- 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"; |
| 121 | import { clearProjectDraftThreadById } from "../projectDraftThreads"; | ||
| 122 | import { clamp } from "effect/Number"; | 121 | import { clamp } from "effect/Number"; |
| 123 | 122 | ||
| 124 | function formatMessageMeta(createdAt: string, duration: string | null): string { | 123 | function 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/ |
| 21 | import { serverConfigQueryOptions } from "../lib/serverReactQuery"; | 21 | import { serverConfigQueryOptions } from "../lib/serverReactQuery"; |
| 22 | import { readNativeApi } from "../nativeApi"; | 22 | import { readNativeApi } from "../nativeApi"; |
| 23 | import { useComposerDraftStore } from "../composerDraftStore"; | 23 | import { useComposerDraftStore } from "../composerDraftStore"; |
| 24 | import { | ||
| 25 | clearProjectDraftThreadById, | ||
| 26 | clearProjectDraftThreadId, | ||
| 27 | readProjectDraftThreadId, | ||
| 28 | writeProjectDraftThreadId, | ||
| 29 | } from "../projectDraftThreads"; | ||
| 30 | import { toastManager } from "./ui/toast"; | 24 | import { toastManager } from "./ui/toast"; |
| 31 | import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible"; | 25 | import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible"; |
| 32 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; | 26 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; |
| ... | @@ -240,6 +234,12 @@ function ProjectFavicon({ cwd }: { cwd: string }) { | ... | @@ -240,6 +234,12 @@ function ProjectFavicon({ cwd }: { cwd: string }) { |
| 240 | export default function Sidebar() { | 234 | export 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); |
| 444 | 444 | ||
| 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 | ); |
| 461 | 470 | ||
| 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 | ); |
| 714 | 724 | ||
| 715 | useEffect(() => { | 725 | useEffect(() => { |
apps/web/src/composerDraftStore.test.ts+70-3| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import { ThreadId } from "@t3tools/contracts"; | 1 | import { ProjectId, ThreadId } from "@t3tools/contracts"; |
| 2 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | 2 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| 3 | 3 | ||
| 4 | import { type ComposerImageAttachment, useComposerDraftStore } from "./composerDraftStore"; | 4 | import { 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>>; |
| 34 | 34 | ||
| 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 | }); |
| 88 | 88 | ||
| 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 | |||
| 115 | describe("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 | |||
| 146 | describe("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 @@ |
| 1 | import { | 1 | import { |
| 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 { |
| 67 | 68 | ||
| 68 | interface PersistedComposerDraftStoreState { | 69 | interface PersistedComposerDraftStoreState { |
| 69 | draftsByThreadId: Record<ThreadId, PersistedComposerThreadDraftState>; | 70 | draftsByThreadId: Record<ThreadId, PersistedComposerThreadDraftState>; |
| 71 | projectDraftThreadIdByProjectId: Record<ProjectId, ThreadId>; | ||
| 70 | } | 72 | } |
| 71 | 73 | ||
| 72 | export interface ComposerThreadDraftState { | 74 | export interface ComposerThreadDraftState { |
| ... | @@ -81,6 +83,11 @@ export interface ComposerThreadDraftState { | ... | @@ -81,6 +83,11 @@ export interface ComposerThreadDraftState { |
| 81 | 83 | ||
| 82 | interface ComposerDraftStoreState { | 84 | interface 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 | } |
| 123 | 130 | ||
| 124 | function composerImageDedupKey(image: ComposerImageAttachment): string { | 131 | function 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 | } |
| 128 | 136 | ||
| 129 | function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean { | 137 | function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean { |
| ... | @@ -179,12 +187,29 @@ function normalizePersistedAttachment(value: unknown): PersistedComposerImageAtt | ... | @@ -179,12 +187,29 @@ function normalizePersistedAttachment(value: unknown): PersistedComposerImageAtt |
| 179 | 187 | ||
| 180 | function normalizePersistedComposerDraftState(value: unknown): PersistedComposerDraftStoreState { | 188 | function 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 | } |
| 230 | 255 | ||
| 231 | function fileFromDataUrl(dataUrl: string, name: string, mimeType: string): File | null { | 256 | function fileFromDataUrl(dataUrl: string, name: string, mimeType: string): File | null { |
| ... | @@ -279,12 +304,12 @@ function hydrateImagesFromPersisted( | ... | @@ -279,12 +304,12 @@ function hydrateImagesFromPersisted( |
| 279 | 304 | ||
| 280 | function readPersistedDraftStateFromLocalStorage(): PersistedComposerDraftStoreState { | 305 | function 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 | } |
| 298 | 323 | ||
| ... | @@ -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 @@ | ||
| 1 | import { ProjectId, ThreadId } from "@t3tools/contracts"; | ||
| 2 | |||
| 3 | const PROJECT_DRAFT_THREADS_STORAGE_KEY = "t3code:project-draft-threads:v1"; | ||
| 4 | |||
| 5 | type ProjectDraftThreadMap = Record<ProjectId, ThreadId>; | ||
| 6 | |||
| 7 | function 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 | |||
| 36 | function 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 | |||
| 52 | export 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 | |||
| 60 | export 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 | |||
| 69 | export 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 | |||
| 81 | export 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; |
| 11 | export const DEFAULT_MODEL = "gpt-5.3-codex"; | 11 | export const DEFAULT_MODEL = "gpt-5.3-codex"; |
| 12 | 12 | ||
| 13 | export const MODEL_SLUG_ALIASES: Record<string, BuiltInModelSlug> = { | 13 | export 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 | }; |