| ... | ... | @@ -8,43 +8,10 @@ import { |
| 8 | 8 | } from "@t3tools/contracts"; |
| 9 | 9 | import type { ChatImageAttachment } from "./types"; |
| 10 | 10 | import { create } from "zustand"; |
| 11 | | import { createJSONStorage, persist, type StateStorage } from "zustand/middleware"; |
| 11 | import { createJSONStorage, persist } from "zustand/middleware"; |
| 12 | 12 | |
| 13 | 13 | export const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1"; |
| 14 | 14 | |
| 15 | | const SAFE_LOCAL_STORAGE: StateStorage = { |
| 16 | | getItem: (key) => { |
| 17 | | if (typeof window === "undefined") { |
| 18 | | return null; |
| 19 | | } |
| 20 | | try { |
| 21 | | return window.localStorage.getItem(key); |
| 22 | | } catch { |
| 23 | | return null; |
| 24 | | } |
| 25 | | }, |
| 26 | | setItem: (key, value) => { |
| 27 | | if (typeof window === "undefined") { |
| 28 | | return; |
| 29 | | } |
| 30 | | try { |
| 31 | | window.localStorage.setItem(key, value); |
| 32 | | } catch { |
| 33 | | // Swallow quota/storage errors so draft state stays usable in-memory. |
| 34 | | } |
| 35 | | }, |
| 36 | | removeItem: (key) => { |
| 37 | | if (typeof window === "undefined") { |
| 38 | | return; |
| 39 | | } |
| 40 | | try { |
| 41 | | window.localStorage.removeItem(key); |
| 42 | | } catch { |
| 43 | | // Best-effort cleanup. |
| 44 | | } |
| 45 | | }, |
| 46 | | }; |
| 47 | | |
| 48 | 15 | export interface PersistedComposerImageAttachment { |
| 49 | 16 | id: string; |
| 50 | 17 | name: string; |
| ... | ... | @@ -79,7 +46,7 @@ interface PersistedComposerDraftStoreState { |
| 79 | 46 | projectDraftThreadIdByProjectId: Record<ProjectId, ThreadId>; |
| 80 | 47 | } |
| 81 | 48 | |
| 82 | | export interface ComposerThreadDraftState { |
| 49 | interface ComposerThreadDraftState { |
| 83 | 50 | prompt: string; |
| 84 | 51 | cursor: number; |
| 85 | 52 | images: ComposerImageAttachment[]; |
| ... | ... | @@ -96,13 +63,16 @@ export interface DraftThreadState { |
| 96 | 63 | worktreePath: string | null; |
| 97 | 64 | } |
| 98 | 65 | |
| 66 | interface ProjectDraftThread extends DraftThreadState { |
| 67 | threadId: ThreadId; |
| 68 | } |
| 69 | |
| 99 | 70 | interface ComposerDraftStoreState { |
| 100 | 71 | draftsByThreadId: Record<ThreadId, ComposerThreadDraftState>; |
| 101 | 72 | draftThreadsByThreadId: Record<ThreadId, DraftThreadState>; |
| 102 | 73 | projectDraftThreadIdByProjectId: Record<ProjectId, ThreadId>; |
| 103 | | readDraftThread: (threadId: ThreadId) => DraftThreadState | null; |
| 104 | | isDraftThread: (threadId: ThreadId) => boolean; |
| 105 | | readProjectDraftThreadId: (projectId: ProjectId) => ThreadId | null; |
| 74 | getDraftThreadByProjectId: (projectId: ProjectId) => ProjectDraftThread | null; |
| 75 | getDraftThread: (threadId: ThreadId) => DraftThreadState | null; |
| 106 | 76 | setProjectDraftThreadId: ( |
| 107 | 77 | projectId: ProjectId, |
| 108 | 78 | threadId: ThreadId, |
| ... | ... | @@ -132,11 +102,23 @@ interface ComposerDraftStoreState { |
| 132 | 102 | addImages: (threadId: ThreadId, images: ComposerImageAttachment[]) => void; |
| 133 | 103 | removeImage: (threadId: ThreadId, imageId: string) => void; |
| 134 | 104 | clearPersistedAttachments: (threadId: ThreadId) => void; |
| 135 | | syncPersistedAttachments: (threadId: ThreadId, attachments: PersistedComposerImageAttachment[]) => void; |
| 105 | syncPersistedAttachments: ( |
| 106 | threadId: ThreadId, |
| 107 | attachments: PersistedComposerImageAttachment[], |
| 108 | ) => void; |
| 136 | 109 | clearComposerContent: (threadId: ThreadId) => void; |
| 137 | 110 | clearThreadDraft: (threadId: ThreadId) => void; |
| 138 | 111 | } |
| 139 | 112 | |
| 113 | const EMPTY_PERSISTED_DRAFT_STORE_STATE: PersistedComposerDraftStoreState = { |
| 114 | draftsByThreadId: {}, |
| 115 | draftThreadsByThreadId: {}, |
| 116 | projectDraftThreadIdByProjectId: {}, |
| 117 | }; |
| 118 | |
| 119 | let persistedDraftStateCache: PersistedComposerDraftStoreState = EMPTY_PERSISTED_DRAFT_STORE_STATE; |
| 120 | let persistedDraftStorageChars: number | null = null; |
| 121 | |
| 140 | 122 | const EMPTY_IMAGES: ComposerImageAttachment[] = []; |
| 141 | 123 | const EMPTY_IDS: string[] = []; |
| 142 | 124 | const EMPTY_PERSISTED_ATTACHMENTS: PersistedComposerImageAttachment[] = []; |
| ... | ... | @@ -223,7 +205,7 @@ function normalizePersistedAttachment(value: unknown): PersistedComposerImageAtt |
| 223 | 205 | |
| 224 | 206 | function normalizePersistedComposerDraftState(value: unknown): PersistedComposerDraftStoreState { |
| 225 | 207 | if (!value || typeof value !== "object") { |
| 226 | | return { draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {} }; |
| 208 | return EMPTY_PERSISTED_DRAFT_STORE_STATE; |
| 227 | 209 | } |
| 228 | 210 | const candidate = value as Record<string, unknown>; |
| 229 | 211 | const rawDraftMap = candidate.draftsByThreadId; |
| ... | ... | @@ -250,7 +232,10 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer |
| 250 | 232 | } |
| 251 | 233 | draftThreadsByThreadId[threadId as ThreadId] = { |
| 252 | 234 | projectId: projectId as ProjectId, |
| 253 | | createdAt: typeof createdAt === "string" && createdAt.length > 0 ? createdAt : new Date().toISOString(), |
| 235 | createdAt: |
| 236 | typeof createdAt === "string" && createdAt.length > 0 |
| 237 | ? createdAt |
| 238 | : new Date().toISOString(), |
| 254 | 239 | branch: typeof branch === "string" ? branch : null, |
| 255 | 240 | worktreePath: typeof worktreePath === "string" ? worktreePath : null, |
| 256 | 241 | }; |
| ... | ... | @@ -258,7 +243,10 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer |
| 258 | 243 | } |
| 259 | 244 | const projectDraftThreadIdByProjectId: PersistedComposerDraftStoreState["projectDraftThreadIdByProjectId"] = |
| 260 | 245 | {}; |
| 261 | | if (rawProjectDraftThreadIdByProjectId && typeof rawProjectDraftThreadIdByProjectId === "object") { |
| 246 | if ( |
| 247 | rawProjectDraftThreadIdByProjectId && |
| 248 | typeof rawProjectDraftThreadIdByProjectId === "object" |
| 249 | ) { |
| 262 | 250 | for (const [projectId, threadId] of Object.entries( |
| 263 | 251 | rawProjectDraftThreadIdByProjectId as Record<string, unknown>, |
| 264 | 252 | )) { |
| ... | ... | @@ -327,7 +315,35 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer |
| 327 | 315 | ...(effort ? { effort } : {}), |
| 328 | 316 | }; |
| 329 | 317 | } |
| 330 | | return { draftsByThreadId: nextDraftsByThreadId, draftThreadsByThreadId, projectDraftThreadIdByProjectId }; |
| 318 | return { |
| 319 | draftsByThreadId: nextDraftsByThreadId, |
| 320 | draftThreadsByThreadId, |
| 321 | projectDraftThreadIdByProjectId, |
| 322 | }; |
| 323 | } |
| 324 | |
| 325 | function parsePersistedDraftStateRaw(raw: string | null): PersistedComposerDraftStoreState { |
| 326 | if (!raw) { |
| 327 | return EMPTY_PERSISTED_DRAFT_STORE_STATE; |
| 328 | } |
| 329 | try { |
| 330 | const parsed = JSON.parse(raw) as unknown; |
| 331 | if (parsed && typeof parsed === "object" && "state" in parsed) { |
| 332 | return normalizePersistedComposerDraftState((parsed as { state?: unknown }).state); |
| 333 | } |
| 334 | return normalizePersistedComposerDraftState(parsed); |
| 335 | } catch { |
| 336 | return EMPTY_PERSISTED_DRAFT_STORE_STATE; |
| 337 | } |
| 338 | } |
| 339 | |
| 340 | function readPersistedAttachmentIdsFromCache(threadId: ThreadId): string[] { |
| 341 | if (threadId.length === 0) { |
| 342 | return []; |
| 343 | } |
| 344 | return (persistedDraftStateCache.draftsByThreadId[threadId]?.attachments ?? []).map( |
| 345 | (attachment) => attachment.id, |
| 346 | ); |
| 331 | 347 | } |
| 332 | 348 | |
| 333 | 349 | function fileFromDataUrl(dataUrl: string, name: string, mimeType: string): File | null { |
| ... | ... | @@ -379,25 +395,6 @@ function hydrateImagesFromPersisted( |
| 379 | 395 | }); |
| 380 | 396 | } |
| 381 | 397 | |
| 382 | | function readPersistedDraftStateFromLocalStorage(): PersistedComposerDraftStoreState { |
| 383 | | if (typeof window === "undefined") { |
| 384 | | return { draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {} }; |
| 385 | | } |
| 386 | | try { |
| 387 | | const raw = window.localStorage.getItem(COMPOSER_DRAFT_STORAGE_KEY); |
| 388 | | if (!raw) { |
| 389 | | return { draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {} }; |
| 390 | | } |
| 391 | | const parsed = JSON.parse(raw) as unknown; |
| 392 | | if (parsed && typeof parsed === "object" && "state" in parsed) { |
| 393 | | return normalizePersistedComposerDraftState((parsed as { state?: unknown }).state); |
| 394 | | } |
| 395 | | return normalizePersistedComposerDraftState(parsed); |
| 396 | | } catch { |
| 397 | | return { draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {} }; |
| 398 | | } |
| 399 | | } |
| 400 | | |
| 401 | 398 | function toHydratedThreadDraft( |
| 402 | 399 | persistedDraft: PersistedComposerThreadDraftState, |
| 403 | 400 | ): ComposerThreadDraftState { |
| ... | ... | @@ -412,29 +409,80 @@ function toHydratedThreadDraft( |
| 412 | 409 | }; |
| 413 | 410 | } |
| 414 | 411 | |
| 412 | const composerDraftStorage = createJSONStorage(() => ({ |
| 413 | getItem: (name: string): string | null => { |
| 414 | if (typeof window === "undefined") { |
| 415 | persistedDraftStateCache = EMPTY_PERSISTED_DRAFT_STORE_STATE; |
| 416 | persistedDraftStorageChars = null; |
| 417 | return null; |
| 418 | } |
| 419 | try { |
| 420 | const raw = window.localStorage.getItem(name); |
| 421 | persistedDraftStateCache = parsePersistedDraftStateRaw(raw); |
| 422 | persistedDraftStorageChars = raw?.length ?? 0; |
| 423 | return raw; |
| 424 | } catch { |
| 425 | persistedDraftStateCache = EMPTY_PERSISTED_DRAFT_STORE_STATE; |
| 426 | persistedDraftStorageChars = null; |
| 427 | return null; |
| 428 | } |
| 429 | }, |
| 430 | setItem: (name: string, value: string): void => { |
| 431 | if (typeof window === "undefined") { |
| 432 | return; |
| 433 | } |
| 434 | try { |
| 435 | window.localStorage.setItem(name, value); |
| 436 | persistedDraftStateCache = parsePersistedDraftStateRaw(value); |
| 437 | persistedDraftStorageChars = value.length; |
| 438 | } catch { |
| 439 | // Quota/storage errors are expected for large drafts. Keep last successful snapshot. |
| 440 | } |
| 441 | }, |
| 442 | removeItem: (name: string): void => { |
| 443 | if (typeof window === "undefined") { |
| 444 | persistedDraftStateCache = EMPTY_PERSISTED_DRAFT_STORE_STATE; |
| 445 | persistedDraftStorageChars = null; |
| 446 | return; |
| 447 | } |
| 448 | try { |
| 449 | window.localStorage.removeItem(name); |
| 450 | } catch { |
| 451 | // Ignore remove failures; clear in-memory cache regardless. |
| 452 | } |
| 453 | persistedDraftStateCache = EMPTY_PERSISTED_DRAFT_STORE_STATE; |
| 454 | persistedDraftStorageChars = 0; |
| 455 | }, |
| 456 | })); |
| 457 | |
| 415 | 458 | export const useComposerDraftStore = create<ComposerDraftStoreState>()( |
| 416 | 459 | persist( |
| 417 | 460 | (set, get) => ({ |
| 418 | 461 | draftsByThreadId: {}, |
| 419 | 462 | draftThreadsByThreadId: {}, |
| 420 | 463 | projectDraftThreadIdByProjectId: {}, |
| 421 | | readDraftThread: (threadId) => { |
| 422 | | if (threadId.length === 0) { |
| 464 | getDraftThreadByProjectId: (projectId) => { |
| 465 | if (projectId.length === 0) { |
| 423 | 466 | return null; |
| 424 | 467 | } |
| 425 | | return get().draftThreadsByThreadId[threadId] ?? null; |
| 426 | | }, |
| 427 | | isDraftThread: (threadId) => { |
| 428 | | if (threadId.length === 0) { |
| 429 | | return false; |
| 468 | const threadId = get().projectDraftThreadIdByProjectId[projectId]; |
| 469 | if (!threadId) { |
| 470 | return null; |
| 430 | 471 | } |
| 431 | | return get().draftThreadsByThreadId[threadId] !== undefined; |
| 472 | const draftThread = get().draftThreadsByThreadId[threadId]; |
| 473 | if (!draftThread || draftThread.projectId !== projectId) { |
| 474 | return null; |
| 475 | } |
| 476 | return { |
| 477 | threadId, |
| 478 | ...draftThread, |
| 479 | }; |
| 432 | 480 | }, |
| 433 | | readProjectDraftThreadId: (projectId) => { |
| 434 | | if (projectId.length === 0) { |
| 481 | getDraftThread: (threadId) => { |
| 482 | if (threadId.length === 0) { |
| 435 | 483 | return null; |
| 436 | 484 | } |
| 437 | | return get().projectDraftThreadIdByProjectId[projectId] ?? null; |
| 485 | return get().draftThreadsByThreadId[threadId] ?? null; |
| 438 | 486 | }, |
| 439 | 487 | setProjectDraftThreadId: (projectId, threadId, options) => { |
| 440 | 488 | if (projectId.length === 0 || threadId.length === 0) { |
| ... | ... | @@ -502,7 +550,9 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( |
| 502 | 550 | const nextDraftThread: DraftThreadState = { |
| 503 | 551 | projectId: nextProjectId, |
| 504 | 552 | createdAt: |
| 505 | | options.createdAt === undefined ? existing.createdAt : (options.createdAt || existing.createdAt), |
| 553 | options.createdAt === undefined |
| 554 | ? existing.createdAt |
| 555 | : options.createdAt || existing.createdAt, |
| 506 | 556 | branch: options.branch === undefined ? existing.branch : (options.branch ?? null), |
| 507 | 557 | worktreePath: |
| 508 | 558 | options.worktreePath === undefined |
| ... | ... | @@ -544,7 +594,8 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( |
| 544 | 594 | if (threadId === undefined) { |
| 545 | 595 | return state; |
| 546 | 596 | } |
| 547 | | const { [projectId]: _removed, ...restProjectMappingsRaw } = state.projectDraftThreadIdByProjectId; |
| 597 | const { [projectId]: _removed, ...restProjectMappingsRaw } = |
| 598 | state.projectDraftThreadIdByProjectId; |
| 548 | 599 | const restProjectMappings = restProjectMappingsRaw as Record<ProjectId, ThreadId>; |
| 549 | 600 | const nextDraftThreadsByThreadId: Record<ThreadId, DraftThreadState> = { |
| 550 | 601 | ...state.draftThreadsByThreadId, |
| ... | ... | @@ -566,7 +617,8 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( |
| 566 | 617 | if (state.projectDraftThreadIdByProjectId[projectId] !== threadId) { |
| 567 | 618 | return state; |
| 568 | 619 | } |
| 569 | | const { [projectId]: _removed, ...restProjectMappingsRaw } = state.projectDraftThreadIdByProjectId; |
| 620 | const { [projectId]: _removed, ...restProjectMappingsRaw } = |
| 621 | state.projectDraftThreadIdByProjectId; |
| 570 | 622 | const restProjectMappings = restProjectMappingsRaw as Record<ProjectId, ThreadId>; |
| 571 | 623 | const nextDraftThreadsByThreadId: Record<ThreadId, DraftThreadState> = { |
| 572 | 624 | ...state.draftThreadsByThreadId, |
| ... | ... | @@ -586,7 +638,9 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( |
| 586 | 638 | } |
| 587 | 639 | set((state) => { |
| 588 | 640 | const hasDraftThread = state.draftThreadsByThreadId[threadId] !== undefined; |
| 589 | | const hasProjectMapping = Object.values(state.projectDraftThreadIdByProjectId).includes(threadId); |
| 641 | const hasProjectMapping = Object.values(state.projectDraftThreadIdByProjectId).includes( |
| 642 | threadId, |
| 643 | ); |
| 590 | 644 | if (!hasDraftThread && !hasProjectMapping) { |
| 591 | 645 | return state; |
| 592 | 646 | } |
| ... | ... | @@ -681,9 +735,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( |
| 681 | 735 | return; |
| 682 | 736 | } |
| 683 | 737 | const nextEffort = |
| 684 | | effort && |
| 685 | | REASONING_EFFORT_VALUES.has(effort) && |
| 686 | | effort !== DEFAULT_REASONING |
| 738 | effort && REASONING_EFFORT_VALUES.has(effort) && effort !== DEFAULT_REASONING |
| 687 | 739 | ? effort |
| 688 | 740 | : null; |
| 689 | 741 | set((state) => { |
| ... | ... | @@ -721,7 +773,9 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( |
| 721 | 773 | set((state) => { |
| 722 | 774 | const existing = state.draftsByThreadId[threadId] ?? createEmptyThreadDraft(); |
| 723 | 775 | const existingIds = new Set(existing.images.map((image) => image.id)); |
| 724 | | const existingDedupKeys = new Set(existing.images.map((image) => composerImageDedupKey(image))); |
| 776 | const existingDedupKeys = new Set( |
| 777 | existing.images.map((image) => composerImageDedupKey(image)), |
| 778 | ); |
| 725 | 779 | const acceptedPreviewUrls = new Set(existing.images.map((image) => image.previewUrl)); |
| 726 | 780 | const dedupedIncoming: ComposerImageAttachment[] = []; |
| 727 | 781 | for (const image of images) { |
| ... | ... | @@ -823,8 +877,8 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( |
| 823 | 877 | ...current, |
| 824 | 878 | // Stage attempted attachments so persist middleware can try writing them. |
| 825 | 879 | persistedAttachments: attachments, |
| 826 | | nonPersistedImageIds: current.nonPersistedImageIds.filter((id) => |
| 827 | | !attachmentIdSet.has(id), |
| 880 | nonPersistedImageIds: current.nonPersistedImageIds.filter( |
| 881 | (id) => !attachmentIdSet.has(id), |
| 828 | 882 | ), |
| 829 | 883 | }; |
| 830 | 884 | const nextDraftsByThreadId = { ...state.draftsByThreadId }; |
| ... | ... | @@ -836,7 +890,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( |
| 836 | 890 | return { draftsByThreadId: nextDraftsByThreadId }; |
| 837 | 891 | }); |
| 838 | 892 | Promise.resolve().then(() => { |
| 839 | | const persistedIdSet = new Set(readPersistedComposerDraftAttachmentIds(threadId)); |
| 893 | const persistedIdSet = new Set(readPersistedAttachmentIdsFromCache(threadId)); |
| 840 | 894 | set((state) => { |
| 841 | 895 | const current = state.draftsByThreadId[threadId]; |
| 842 | 896 | if (!current) { |
| ... | ... | @@ -903,7 +957,9 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( |
| 903 | 957 | set((state) => { |
| 904 | 958 | const hasComposerDraft = state.draftsByThreadId[threadId] !== undefined; |
| 905 | 959 | const hasDraftThread = state.draftThreadsByThreadId[threadId] !== undefined; |
| 906 | | const hasProjectMapping = Object.values(state.projectDraftThreadIdByProjectId).includes(threadId); |
| 960 | const hasProjectMapping = Object.values(state.projectDraftThreadIdByProjectId).includes( |
| 961 | threadId, |
| 962 | ); |
| 907 | 963 | if (!hasComposerDraft && !hasDraftThread && !hasProjectMapping) { |
| 908 | 964 | return state; |
| 909 | 965 | } |
| ... | ... | @@ -927,7 +983,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( |
| 927 | 983 | { |
| 928 | 984 | name: COMPOSER_DRAFT_STORAGE_KEY, |
| 929 | 985 | version: 1, |
| 930 | | storage: createJSONStorage(() => SAFE_LOCAL_STORAGE), |
| 986 | storage: composerDraftStorage, |
| 931 | 987 | partialize: (state) => { |
| 932 | 988 | const persistedDraftsByThreadId: PersistedComposerDraftStoreState["draftsByThreadId"] = {}; |
| 933 | 989 | for (const [threadId, draft] of Object.entries(state.draftsByThreadId)) { |
| ... | ... | @@ -963,6 +1019,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( |
| 963 | 1019 | }, |
| 964 | 1020 | merge: (persistedState, currentState) => { |
| 965 | 1021 | const normalizedPersisted = normalizePersistedComposerDraftState(persistedState); |
| 1022 | persistedDraftStateCache = normalizedPersisted; |
| 966 | 1023 | const draftsByThreadId = Object.fromEntries( |
| 967 | 1024 | Object.entries(normalizedPersisted.draftsByThreadId).map(([threadId, draft]) => [ |
| 968 | 1025 | threadId, |
| ... | ... | @@ -984,19 +1041,6 @@ export function useComposerThreadDraft(threadId: ThreadId): ComposerThreadDraftS |
| 984 | 1041 | return useComposerDraftStore((state) => state.draftsByThreadId[threadId] ?? EMPTY_THREAD_DRAFT); |
| 985 | 1042 | } |
| 986 | 1043 | |
| 987 | | export function readPersistedComposerDraftAttachmentIds(threadId: ThreadId): string[] { |
| 988 | | if (threadId.length === 0) { |
| 989 | | return []; |
| 990 | | } |
| 991 | | return readPersistedComposerDraftAttachments(threadId).map((attachment) => attachment.id); |
| 992 | | } |
| 993 | | |
| 994 | | export function readPersistedComposerDraftAttachments( |
| 995 | | threadId: ThreadId, |
| 996 | | ): PersistedComposerImageAttachment[] { |
| 997 | | if (threadId.length === 0) { |
| 998 | | return []; |
| 999 | | } |
| 1000 | | const persisted = readPersistedDraftStateFromLocalStorage(); |
| 1001 | | return persisted.draftsByThreadId[threadId]?.attachments ?? []; |
| 1044 | export function readComposerDraftPersistStorageChars(): number | null { |
| 1045 | return persistedDraftStorageChars; |
| 1002 | 1046 | } |