import { DEFAULT_REASONING, ProjectId, REASONING_OPTIONS, ThreadId, normalizeModelSlug, type ReasoningEffort,} from "@t3tools/contracts";import type { ChatImageAttachment } from "./types";import { create } from "zustand";import { createJSONStorage, persist } from "zustand/middleware"; export const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1";export type DraftThreadEnvMode = "local" | "worktree"; export interface PersistedComposerImageAttachment { id: string; name: string; mimeType: string; sizeBytes: number; dataUrl: string;} export interface ComposerImageAttachment extends Omit<ChatImageAttachment, "previewUrl"> { previewUrl: string; file: File;} interface PersistedComposerThreadDraftState { prompt: string; attachments: PersistedComposerImageAttachment[]; model?: string | null; effort?: ReasoningEffort | null;} interface PersistedDraftThreadState { projectId: ProjectId; createdAt: string; branch: string | null; worktreePath: string | null; envMode: DraftThreadEnvMode;} interface PersistedComposerDraftStoreState { draftsByThreadId: Record<ThreadId, PersistedComposerThreadDraftState>; draftThreadsByThreadId: Record<ThreadId, PersistedDraftThreadState>; projectDraftThreadIdByProjectId: Record<ProjectId, ThreadId>;} interface ComposerThreadDraftState { prompt: string; images: ComposerImageAttachment[]; nonPersistedImageIds: string[]; persistedAttachments: PersistedComposerImageAttachment[]; model: string | null; effort: ReasoningEffort | null;} export interface DraftThreadState { projectId: ProjectId; createdAt: string; branch: string | null; worktreePath: string | null; envMode: DraftThreadEnvMode;} interface ProjectDraftThread extends DraftThreadState { threadId: ThreadId;} interface ComposerDraftStoreState { draftsByThreadId: Record<ThreadId, ComposerThreadDraftState>; draftThreadsByThreadId: Record<ThreadId, DraftThreadState>; projectDraftThreadIdByProjectId: Record<ProjectId, ThreadId>; getDraftThreadByProjectId: (projectId: ProjectId) => ProjectDraftThread | null; getDraftThread: (threadId: ThreadId) => DraftThreadState | null; setProjectDraftThreadId: ( projectId: ProjectId, threadId: ThreadId, options?: { branch?: string | null; worktreePath?: string | null; createdAt?: string; envMode?: DraftThreadEnvMode; }, ) => void; setDraftThreadContext: ( threadId: ThreadId, options: { branch?: string | null; worktreePath?: string | null; projectId?: ProjectId; createdAt?: string; envMode?: DraftThreadEnvMode; }, ) => void; clearProjectDraftThreadId: (projectId: ProjectId) => void; clearProjectDraftThreadById: (projectId: ProjectId, threadId: ThreadId) => void; clearDraftThread: (threadId: ThreadId) => void; setPrompt: (threadId: ThreadId, prompt: string) => void; setModel: (threadId: ThreadId, model: string | null | undefined) => void; setEffort: (threadId: ThreadId, effort: ReasoningEffort | null | undefined) => void; addImage: (threadId: ThreadId, image: ComposerImageAttachment) => void; addImages: (threadId: ThreadId, images: ComposerImageAttachment[]) => void; removeImage: (threadId: ThreadId, imageId: string) => void; clearPersistedAttachments: (threadId: ThreadId) => void; syncPersistedAttachments: ( threadId: ThreadId, attachments: PersistedComposerImageAttachment[], ) => void; clearComposerContent: (threadId: ThreadId) => void; clearThreadDraft: (threadId: ThreadId) => void;} const EMPTY_PERSISTED_DRAFT_STORE_STATE: PersistedComposerDraftStoreState = { draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {},}; const EMPTY_IMAGES: ComposerImageAttachment[] = [];const EMPTY_IDS: string[] = [];const EMPTY_PERSISTED_ATTACHMENTS: PersistedComposerImageAttachment[] = [];Object.freeze(EMPTY_IMAGES);Object.freeze(EMPTY_IDS);Object.freeze(EMPTY_PERSISTED_ATTACHMENTS);const EMPTY_THREAD_DRAFT = Object.freeze({ prompt: "", images: EMPTY_IMAGES, nonPersistedImageIds: EMPTY_IDS, persistedAttachments: EMPTY_PERSISTED_ATTACHMENTS, model: null, effort: null,}) as ComposerThreadDraftState; const REASONING_EFFORT_VALUES = new Set<ReasoningEffort>(REASONING_OPTIONS); function createEmptyThreadDraft(): ComposerThreadDraftState { return { prompt: "", images: [], nonPersistedImageIds: [], persistedAttachments: [], model: null, effort: null, };} function composerImageDedupKey(image: ComposerImageAttachment): string { // Keep this independent from File.lastModified so dedupe is stable for hydrated // images reconstructed from localStorage (which get a fresh lastModified value). return `${image.mimeType}\u0000${image.sizeBytes}\u0000${image.name}`;} function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean { return ( draft.prompt.length === 0 && draft.images.length === 0 && draft.persistedAttachments.length === 0 && draft.model === null && draft.effort === null );} function revokeObjectPreviewUrl(previewUrl: string): void { if (typeof URL === "undefined") { return; } if (!previewUrl.startsWith("blob:")) { return; } URL.revokeObjectURL(previewUrl);} function normalizePersistedAttachment(value: unknown): PersistedComposerImageAttachment | null { if (!value || typeof value !== "object") { return null; } const candidate = value as Record<string, unknown>; const id = candidate.id; const name = candidate.name; const mimeType = candidate.mimeType; const sizeBytes = candidate.sizeBytes; const dataUrl = candidate.dataUrl; if ( typeof id !== "string" || typeof name !== "string" || typeof mimeType !== "string" || typeof sizeBytes !== "number" || !Number.isFinite(sizeBytes) || typeof dataUrl !== "string" || id.length === 0 || dataUrl.length === 0 ) { return null; } return { id, name, mimeType, sizeBytes, dataUrl, };} function normalizeDraftThreadEnvMode( value: unknown, fallbackWorktreePath: string | null,): DraftThreadEnvMode { if (value === "local" || value === "worktree") { return value; } return fallbackWorktreePath ? "worktree" : "local";} function normalizePersistedComposerDraftState(value: unknown): PersistedComposerDraftStoreState { if (!value || typeof value !== "object") { return EMPTY_PERSISTED_DRAFT_STORE_STATE; } const candidate = value as Record<string, unknown>; const rawDraftMap = candidate.draftsByThreadId; const rawDraftThreadsByThreadId = candidate.draftThreadsByThreadId; const rawProjectDraftThreadIdByProjectId = candidate.projectDraftThreadIdByProjectId; const draftThreadsByThreadId: PersistedComposerDraftStoreState["draftThreadsByThreadId"] = {}; if (rawDraftThreadsByThreadId && typeof rawDraftThreadsByThreadId === "object") { for (const [threadId, rawDraftThread] of Object.entries( rawDraftThreadsByThreadId as Record<string, unknown>, )) { if (typeof threadId !== "string" || threadId.length === 0) { continue; } if (!rawDraftThread || typeof rawDraftThread !== "object") { continue; } const candidateDraftThread = rawDraftThread as Record<string, unknown>; const projectId = candidateDraftThread.projectId; const createdAt = candidateDraftThread.createdAt; const branch = candidateDraftThread.branch; const worktreePath = candidateDraftThread.worktreePath; const normalizedWorktreePath = typeof worktreePath === "string" ? worktreePath : null; if (typeof projectId !== "string" || projectId.length === 0) { continue; } draftThreadsByThreadId[threadId as ThreadId] = { projectId: projectId as ProjectId, createdAt: typeof createdAt === "string" && createdAt.length > 0 ? createdAt : new Date().toISOString(), branch: typeof branch === "string" ? branch : null, worktreePath: normalizedWorktreePath, envMode: normalizeDraftThreadEnvMode(candidateDraftThread.envMode, normalizedWorktreePath), }; } } const projectDraftThreadIdByProjectId: PersistedComposerDraftStoreState["projectDraftThreadIdByProjectId"] = {}; if ( rawProjectDraftThreadIdByProjectId && typeof rawProjectDraftThreadIdByProjectId === "object" ) { for (const [projectId, threadId] of Object.entries( rawProjectDraftThreadIdByProjectId as Record<string, unknown>, )) { if ( typeof projectId === "string" && projectId.length > 0 && typeof threadId === "string" && threadId.length > 0 ) { projectDraftThreadIdByProjectId[projectId as ProjectId] = threadId as ThreadId; if (!draftThreadsByThreadId[threadId as ThreadId]) { draftThreadsByThreadId[threadId as ThreadId] = { projectId: projectId as ProjectId, createdAt: new Date().toISOString(), branch: null, worktreePath: null, envMode: "local", }; } else if (draftThreadsByThreadId[threadId as ThreadId]?.projectId !== projectId) { draftThreadsByThreadId[threadId as ThreadId] = { ...draftThreadsByThreadId[threadId as ThreadId]!, projectId: projectId as ProjectId, }; } } } } if (!rawDraftMap || typeof rawDraftMap !== "object") { return { draftsByThreadId: {}, draftThreadsByThreadId, projectDraftThreadIdByProjectId }; } const nextDraftsByThreadId: PersistedComposerDraftStoreState["draftsByThreadId"] = {}; for (const [threadId, draftValue] of Object.entries(rawDraftMap as Record<string, unknown>)) { if (typeof threadId !== "string" || threadId.length === 0) { continue; } if (!draftValue || typeof draftValue !== "object") { continue; } const draftCandidate = draftValue as Record<string, unknown>; const prompt = typeof draftCandidate.prompt === "string" ? draftCandidate.prompt : ""; const attachments = Array.isArray(draftCandidate.attachments) ? draftCandidate.attachments.flatMap((entry) => { const normalized = normalizePersistedAttachment(entry); return normalized ? [normalized] : []; }) : []; const model = typeof draftCandidate.model === "string" ? normalizeModelSlug(draftCandidate.model) : null; const effortCandidate = typeof draftCandidate.effort === "string" ? draftCandidate.effort : null; const effort = effortCandidate && REASONING_EFFORT_VALUES.has(effortCandidate as ReasoningEffort) ? (effortCandidate as ReasoningEffort) : null; if (prompt.length === 0 && attachments.length === 0 && !model && !effort) { continue; } nextDraftsByThreadId[threadId as ThreadId] = { prompt, attachments, ...(model ? { model } : {}), ...(effort ? { effort } : {}), }; } return { draftsByThreadId: nextDraftsByThreadId, draftThreadsByThreadId, projectDraftThreadIdByProjectId, };} function parsePersistedDraftStateRaw(raw: string | null): PersistedComposerDraftStoreState { if (!raw) { return EMPTY_PERSISTED_DRAFT_STORE_STATE; } try { const parsed = JSON.parse(raw) as unknown; if (parsed && typeof parsed === "object" && "state" in parsed) { return normalizePersistedComposerDraftState((parsed as { state?: unknown }).state); } return normalizePersistedComposerDraftState(parsed); } catch { return EMPTY_PERSISTED_DRAFT_STORE_STATE; }} function readPersistedAttachmentIdsFromStorage(threadId: ThreadId): string[] { if (threadId.length === 0) { return []; } try { const raw = localStorage.getItem(COMPOSER_DRAFT_STORAGE_KEY); const persisted = parsePersistedDraftStateRaw(raw); return (persisted.draftsByThreadId[threadId]?.attachments ?? []).map( (attachment) => attachment.id, ); } catch { return []; }} function hydreatePersistedComposerImageAttachment( attachment: PersistedComposerImageAttachment,): File | null { const commaIndex = attachment.dataUrl.indexOf(","); const header = commaIndex === -1 ? attachment.dataUrl : attachment.dataUrl.slice(0, commaIndex); const payload = commaIndex === -1 ? "" : attachment.dataUrl.slice(commaIndex + 1); if (payload.length === 0) { return null; } try { const isBase64 = header.includes(";base64"); if (!isBase64) { const decodedText = decodeURIComponent(payload); const inferredMimeType = header.startsWith("data:") && header.includes(";") ? header.slice("data:".length, header.indexOf(";")) : attachment.mimeType; return new File([decodedText], attachment.name, { type: inferredMimeType || attachment.mimeType, }); } const binary = atob(payload); const bytes = new Uint8Array(binary.length); for (let index = 0; index < binary.length; index += 1) { bytes[index] = binary.charCodeAt(index); } return new File([bytes], attachment.name, { type: attachment.mimeType }); } catch { return null; }} function hydrateImagesFromPersisted( attachments: PersistedComposerImageAttachment[],): ComposerImageAttachment[] { return attachments.flatMap((attachment) => { const file = hydreatePersistedComposerImageAttachment(attachment); if (!file) return []; return [ { type: "image" as const, id: attachment.id, name: attachment.name, mimeType: attachment.mimeType, sizeBytes: attachment.sizeBytes, previewUrl: attachment.dataUrl, file, } satisfies ComposerImageAttachment, ]; });} function toHydratedThreadDraft( persistedDraft: PersistedComposerThreadDraftState,): ComposerThreadDraftState { return { prompt: persistedDraft.prompt, images: hydrateImagesFromPersisted(persistedDraft.attachments), nonPersistedImageIds: [], persistedAttachments: persistedDraft.attachments, model: persistedDraft.model ?? null, effort: persistedDraft.effort ?? null, };} export const useComposerDraftStore = create<ComposerDraftStoreState>()( persist( (set, get) => ({ draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {}, getDraftThreadByProjectId: (projectId) => { if (projectId.length === 0) { return null; } const threadId = get().projectDraftThreadIdByProjectId[projectId]; if (!threadId) { return null; } const draftThread = get().draftThreadsByThreadId[threadId]; if (!draftThread || draftThread.projectId !== projectId) { return null; } return { threadId, ...draftThread, }; }, getDraftThread: (threadId) => { if (threadId.length === 0) { return null; } return get().draftThreadsByThreadId[threadId] ?? null; }, setProjectDraftThreadId: (projectId, threadId, options) => { if (projectId.length === 0 || threadId.length === 0) { return; } set((state) => { const existingThread = state.draftThreadsByThreadId[threadId]; const previousThreadIdForProject = state.projectDraftThreadIdByProjectId[projectId]; const nextWorktreePath = options?.worktreePath === undefined ? (existingThread?.worktreePath ?? null) : (options.worktreePath ?? null); const nextDraftThread: DraftThreadState = { projectId, createdAt: options?.createdAt ?? existingThread?.createdAt ?? new Date().toISOString(), branch: options?.branch === undefined ? (existingThread?.branch ?? null) : (options.branch ?? null), worktreePath: nextWorktreePath, envMode: options?.envMode ?? (nextWorktreePath ? "worktree" : (existingThread?.envMode ?? "local")), }; const hasSameProjectMapping = previousThreadIdForProject === threadId; const hasSameDraftThread = existingThread && existingThread.projectId === nextDraftThread.projectId && existingThread.createdAt === nextDraftThread.createdAt && existingThread.branch === nextDraftThread.branch && existingThread.worktreePath === nextDraftThread.worktreePath && existingThread.envMode === nextDraftThread.envMode; if (hasSameProjectMapping && hasSameDraftThread) { return state; } const nextProjectDraftThreadIdByProjectId: Record<ProjectId, ThreadId> = { ...state.projectDraftThreadIdByProjectId, [projectId]: threadId, }; const nextDraftThreadsByThreadId: Record<ThreadId, DraftThreadState> = { ...state.draftThreadsByThreadId, [threadId]: nextDraftThread, };Lines 1–500 of 1010. Load more
import { DEFAULT_REASONING, ProjectId, REASONING_OPTIONS, ThreadId, normalizeModelSlug, type ReasoningEffort, } from "@t3tools/contracts"; import type { ChatImageAttachment } from "./types"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; export const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1"; export type DraftThreadEnvMode = "local" | "worktree"; export interface PersistedComposerImageAttachment { id: string; name: string; mimeType: string; sizeBytes: number; dataUrl: string; } export interface ComposerImageAttachment extends Omit<ChatImageAttachment, "previewUrl"> { previewUrl: string; file: File; } interface PersistedComposerThreadDraftState { prompt: string; attachments: PersistedComposerImageAttachment[]; model?: string | null; effort?: ReasoningEffort | null; } interface PersistedDraftThreadState { projectId: ProjectId; createdAt: string; branch: string | null; worktreePath: string | null; envMode: DraftThreadEnvMode; } interface PersistedComposerDraftStoreState { draftsByThreadId: Record<ThreadId, PersistedComposerThreadDraftState>; draftThreadsByThreadId: Record<ThreadId, PersistedDraftThreadState>; projectDraftThreadIdByProjectId: Record<ProjectId, ThreadId>; } interface ComposerThreadDraftState { prompt: string; images: ComposerImageAttachment[]; nonPersistedImageIds: string[]; persistedAttachments: PersistedComposerImageAttachment[]; model: string | null; effort: ReasoningEffort | null; } export interface DraftThreadState { projectId: ProjectId; createdAt: string; branch: string | null; worktreePath: string | null; envMode: DraftThreadEnvMode; } interface ProjectDraftThread extends DraftThreadState { threadId: ThreadId; } interface ComposerDraftStoreState { draftsByThreadId: Record<ThreadId, ComposerThreadDraftState>; draftThreadsByThreadId: Record<ThreadId, DraftThreadState>; projectDraftThreadIdByProjectId: Record<ProjectId, ThreadId>; getDraftThreadByProjectId: (projectId: ProjectId) => ProjectDraftThread | null; getDraftThread: (threadId: ThreadId) => DraftThreadState | null; setProjectDraftThreadId: ( projectId: ProjectId, threadId: ThreadId, options?: { branch?: string | null; worktreePath?: string | null; createdAt?: string; envMode?: DraftThreadEnvMode; }, ) => void; setDraftThreadContext: ( threadId: ThreadId, options: { branch?: string | null; worktreePath?: string | null; projectId?: ProjectId; createdAt?: string; envMode?: DraftThreadEnvMode; }, ) => void; clearProjectDraftThreadId: (projectId: ProjectId) => void; clearProjectDraftThreadById: (projectId: ProjectId, threadId: ThreadId) => void; clearDraftThread: (threadId: ThreadId) => void; setPrompt: (threadId: ThreadId, prompt: string) => void; setModel: (threadId: ThreadId, model: string | null | undefined) => void; setEffort: (threadId: ThreadId, effort: ReasoningEffort | null | undefined) => void; addImage: (threadId: ThreadId, image: ComposerImageAttachment) => void; addImages: (threadId: ThreadId, images: ComposerImageAttachment[]) => void; removeImage: (threadId: ThreadId, imageId: string) => void; clearPersistedAttachments: (threadId: ThreadId) => void; syncPersistedAttachments: ( threadId: ThreadId, attachments: PersistedComposerImageAttachment[], ) => void; clearComposerContent: (threadId: ThreadId) => void; clearThreadDraft: (threadId: ThreadId) => void; } const EMPTY_PERSISTED_DRAFT_STORE_STATE: PersistedComposerDraftStoreState = { draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {}, }; const EMPTY_IMAGES: ComposerImageAttachment[] = []; const EMPTY_IDS: string[] = []; const EMPTY_PERSISTED_ATTACHMENTS: PersistedComposerImageAttachment[] = []; Object.freeze(EMPTY_IMAGES); Object.freeze(EMPTY_IDS); Object.freeze(EMPTY_PERSISTED_ATTACHMENTS); const EMPTY_THREAD_DRAFT = Object.freeze({ prompt: "", images: EMPTY_IMAGES, nonPersistedImageIds: EMPTY_IDS, persistedAttachments: EMPTY_PERSISTED_ATTACHMENTS, model: null, effort: null, }) as ComposerThreadDraftState; const REASONING_EFFORT_VALUES = new Set<ReasoningEffort>(REASONING_OPTIONS); function createEmptyThreadDraft(): ComposerThreadDraftState { return { prompt: "", images: [], nonPersistedImageIds: [], persistedAttachments: [], model: null, effort: null, }; } function composerImageDedupKey(image: ComposerImageAttachment): string { // Keep this independent from File.lastModified so dedupe is stable for hydrated // images reconstructed from localStorage (which get a fresh lastModified value). return `${image.mimeType}\u0000${image.sizeBytes}\u0000${image.name}`; } function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean { return ( draft.prompt.length === 0 && draft.images.length === 0 && draft.persistedAttachments.length === 0 && draft.model === null && draft.effort === null ); } function revokeObjectPreviewUrl(previewUrl: string): void { if (typeof URL === "undefined") { return; } if (!previewUrl.startsWith("blob:")) { return; } URL.revokeObjectURL(previewUrl); } function normalizePersistedAttachment(value: unknown): PersistedComposerImageAttachment | null { if (!value || typeof value !== "object") { return null; } const candidate = value as Record<string, unknown>; const id = candidate.id; const name = candidate.name; const mimeType = candidate.mimeType; const sizeBytes = candidate.sizeBytes; const dataUrl = candidate.dataUrl; if ( typeof id !== "string" || typeof name !== "string" || typeof mimeType !== "string" || typeof sizeBytes !== "number" || !Number.isFinite(sizeBytes) || typeof dataUrl !== "string" || id.length === 0 || dataUrl.length === 0 ) { return null; } return { id, name, mimeType, sizeBytes, dataUrl, }; } function normalizeDraftThreadEnvMode( value: unknown, fallbackWorktreePath: string | null, ): DraftThreadEnvMode { if (value === "local" || value === "worktree") { return value; } return fallbackWorktreePath ? "worktree" : "local"; } function normalizePersistedComposerDraftState(value: unknown): PersistedComposerDraftStoreState { if (!value || typeof value !== "object") { return EMPTY_PERSISTED_DRAFT_STORE_STATE; } const candidate = value as Record<string, unknown>; const rawDraftMap = candidate.draftsByThreadId; const rawDraftThreadsByThreadId = candidate.draftThreadsByThreadId; const rawProjectDraftThreadIdByProjectId = candidate.projectDraftThreadIdByProjectId; const draftThreadsByThreadId: PersistedComposerDraftStoreState["draftThreadsByThreadId"] = {}; if (rawDraftThreadsByThreadId && typeof rawDraftThreadsByThreadId === "object") { for (const [threadId, rawDraftThread] of Object.entries( rawDraftThreadsByThreadId as Record<string, unknown>, )) { if (typeof threadId !== "string" || threadId.length === 0) { continue; } if (!rawDraftThread || typeof rawDraftThread !== "object") { continue; } const candidateDraftThread = rawDraftThread as Record<string, unknown>; const projectId = candidateDraftThread.projectId; const createdAt = candidateDraftThread.createdAt; const branch = candidateDraftThread.branch; const worktreePath = candidateDraftThread.worktreePath; const normalizedWorktreePath = typeof worktreePath === "string" ? worktreePath : null; if (typeof projectId !== "string" || projectId.length === 0) { continue; } draftThreadsByThreadId[threadId as ThreadId] = { projectId: projectId as ProjectId, createdAt: typeof createdAt === "string" && createdAt.length > 0 ? createdAt : new Date().toISOString(), branch: typeof branch === "string" ? branch : null, worktreePath: normalizedWorktreePath, envMode: normalizeDraftThreadEnvMode(candidateDraftThread.envMode, normalizedWorktreePath), }; } } const projectDraftThreadIdByProjectId: PersistedComposerDraftStoreState["projectDraftThreadIdByProjectId"] = {}; if ( rawProjectDraftThreadIdByProjectId && typeof rawProjectDraftThreadIdByProjectId === "object" ) { for (const [projectId, threadId] of Object.entries( rawProjectDraftThreadIdByProjectId as Record<string, unknown>, )) { if ( typeof projectId === "string" && projectId.length > 0 && typeof threadId === "string" && threadId.length > 0 ) { projectDraftThreadIdByProjectId[projectId as ProjectId] = threadId as ThreadId; if (!draftThreadsByThreadId[threadId as ThreadId]) { draftThreadsByThreadId[threadId as ThreadId] = { projectId: projectId as ProjectId, createdAt: new Date().toISOString(), branch: null, worktreePath: null, envMode: "local", }; } else if (draftThreadsByThreadId[threadId as ThreadId]?.projectId !== projectId) { draftThreadsByThreadId[threadId as ThreadId] = { ...draftThreadsByThreadId[threadId as ThreadId]!, projectId: projectId as ProjectId, }; } } } } if (!rawDraftMap || typeof rawDraftMap !== "object") { return { draftsByThreadId: {}, draftThreadsByThreadId, projectDraftThreadIdByProjectId }; } const nextDraftsByThreadId: PersistedComposerDraftStoreState["draftsByThreadId"] = {}; for (const [threadId, draftValue] of Object.entries(rawDraftMap as Record<string, unknown>)) { if (typeof threadId !== "string" || threadId.length === 0) { continue; } if (!draftValue || typeof draftValue !== "object") { continue; } const draftCandidate = draftValue as Record<string, unknown>; const prompt = typeof draftCandidate.prompt === "string" ? draftCandidate.prompt : ""; const attachments = Array.isArray(draftCandidate.attachments) ? draftCandidate.attachments.flatMap((entry) => { const normalized = normalizePersistedAttachment(entry); return normalized ? [normalized] : []; }) : []; const model = typeof draftCandidate.model === "string" ? normalizeModelSlug(draftCandidate.model) : null; const effortCandidate = typeof draftCandidate.effort === "string" ? draftCandidate.effort : null; const effort = effortCandidate && REASONING_EFFORT_VALUES.has(effortCandidate as ReasoningEffort) ? (effortCandidate as ReasoningEffort) : null; if (prompt.length === 0 && attachments.length === 0 && !model && !effort) { continue; } nextDraftsByThreadId[threadId as ThreadId] = { prompt, attachments, ...(model ? { model } : {}), ...(effort ? { effort } : {}), }; } return { draftsByThreadId: nextDraftsByThreadId, draftThreadsByThreadId, projectDraftThreadIdByProjectId, }; } function parsePersistedDraftStateRaw(raw: string | null): PersistedComposerDraftStoreState { if (!raw) { return EMPTY_PERSISTED_DRAFT_STORE_STATE; } try { const parsed = JSON.parse(raw) as unknown; if (parsed && typeof parsed === "object" && "state" in parsed) { return normalizePersistedComposerDraftState((parsed as { state?: unknown }).state); } return normalizePersistedComposerDraftState(parsed); } catch { return EMPTY_PERSISTED_DRAFT_STORE_STATE; } } function readPersistedAttachmentIdsFromStorage(threadId: ThreadId): string[] { if (threadId.length === 0) { return []; } try { const raw = localStorage.getItem(COMPOSER_DRAFT_STORAGE_KEY); const persisted = parsePersistedDraftStateRaw(raw); return (persisted.draftsByThreadId[threadId]?.attachments ?? []).map( (attachment) => attachment.id, ); } catch { return []; } } function hydreatePersistedComposerImageAttachment( attachment: PersistedComposerImageAttachment, ): File | null { const commaIndex = attachment.dataUrl.indexOf(","); const header = commaIndex === -1 ? attachment.dataUrl : attachment.dataUrl.slice(0, commaIndex); const payload = commaIndex === -1 ? "" : attachment.dataUrl.slice(commaIndex + 1); if (payload.length === 0) { return null; } try { const isBase64 = header.includes(";base64"); if (!isBase64) { const decodedText = decodeURIComponent(payload); const inferredMimeType = header.startsWith("data:") && header.includes(";") ? header.slice("data:".length, header.indexOf(";")) : attachment.mimeType; return new File([decodedText], attachment.name, { type: inferredMimeType || attachment.mimeType, }); } const binary = atob(payload); const bytes = new Uint8Array(binary.length); for (let index = 0; index < binary.length; index += 1) { bytes[index] = binary.charCodeAt(index); } return new File([bytes], attachment.name, { type: attachment.mimeType }); } catch { return null; } } function hydrateImagesFromPersisted( attachments: PersistedComposerImageAttachment[], ): ComposerImageAttachment[] { return attachments.flatMap((attachment) => { const file = hydreatePersistedComposerImageAttachment(attachment); if (!file) return []; return [ { type: "image" as const, id: attachment.id, name: attachment.name, mimeType: attachment.mimeType, sizeBytes: attachment.sizeBytes, previewUrl: attachment.dataUrl, file, } satisfies ComposerImageAttachment, ]; }); } function toHydratedThreadDraft( persistedDraft: PersistedComposerThreadDraftState, ): ComposerThreadDraftState { return { prompt: persistedDraft.prompt, images: hydrateImagesFromPersisted(persistedDraft.attachments), nonPersistedImageIds: [], persistedAttachments: persistedDraft.attachments, model: persistedDraft.model ?? null, effort: persistedDraft.effort ?? null, }; } export const useComposerDraftStore = create<ComposerDraftStoreState>()( persist( (set, get) => ({ draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {}, getDraftThreadByProjectId: (projectId) => { if (projectId.length === 0) { return null; } const threadId = get().projectDraftThreadIdByProjectId[projectId]; if (!threadId) { return null; } const draftThread = get().draftThreadsByThreadId[threadId]; if (!draftThread || draftThread.projectId !== projectId) { return null; } return { threadId, ...draftThread, }; }, getDraftThread: (threadId) => { if (threadId.length === 0) { return null; } return get().draftThreadsByThreadId[threadId] ?? null; }, setProjectDraftThreadId: (projectId, threadId, options) => { if (projectId.length === 0 || threadId.length === 0) { return; } set((state) => { const existingThread = state.draftThreadsByThreadId[threadId]; const previousThreadIdForProject = state.projectDraftThreadIdByProjectId[projectId]; const nextWorktreePath = options?.worktreePath === undefined ? (existingThread?.worktreePath ?? null) : (options.worktreePath ?? null); const nextDraftThread: DraftThreadState = { projectId, createdAt: options?.createdAt ?? existingThread?.createdAt ?? new Date().toISOString(), branch: options?.branch === undefined ? (existingThread?.branch ?? null) : (options.branch ?? null), worktreePath: nextWorktreePath, envMode: options?.envMode ?? (nextWorktreePath ? "worktree" : (existingThread?.envMode ?? "local")), }; const hasSameProjectMapping = previousThreadIdForProject === threadId; const hasSameDraftThread = existingThread && existingThread.projectId === nextDraftThread.projectId && existingThread.createdAt === nextDraftThread.createdAt && existingThread.branch === nextDraftThread.branch && existingThread.worktreePath === nextDraftThread.worktreePath && existingThread.envMode === nextDraftThread.envMode; if (hasSameProjectMapping && hasSameDraftThread) { return state; } const nextProjectDraftThreadIdByProjectId: Record<ProjectId, ThreadId> = { ...state.projectDraftThreadIdByProjectId, [projectId]: threadId, }; const nextDraftThreadsByThreadId: Record<ThreadId, DraftThreadState> = { ...state.draftThreadsByThreadId, [threadId]: nextDraftThread, };