import { z } from "zod"; import { DEFAULT_MODEL, resolveModelSlug } from "./model-logic"; import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_RUNTIME_MODE, type Project, type RuntimeMode, type Thread, } from "./types"; const LEGACY_DEFAULT_MODEL = "gpt-5.2-codex"; const persistedProjectSchema = z.object({ id: z.string().min(1), name: z.string().min(1), cwd: z.string().min(1), model: z.string().min(1), expanded: z.boolean(), }); const persistedMessageSchema = z.object({ id: z.string().min(1), role: z.enum(["user", "assistant"]), text: z.string(), attachments: z .array( z.object({ type: z.literal("image"), id: z.string().min(1), name: z.string().min(1), mimeType: z.string().min(1), sizeBytes: z.number().int().min(1), }), ) .optional(), createdAt: z.string().min(1), streaming: z.boolean(), }); const persistedThreadSchema = z.object({ id: z.string().min(1), codexThreadId: z.string().min(1).nullable().default(null), projectId: z.string().min(1), title: z.string().min(1), model: z.string().min(1), terminalOpen: z.boolean().default(false), terminalHeight: z.number().int().min(120).max(4_096).default( DEFAULT_THREAD_TERMINAL_HEIGHT, ), messages: z.array(persistedMessageSchema), createdAt: z.string().min(1), lastVisitedAt: z.string().min(1).optional(), branch: z.string().min(1).nullable().optional(), worktreePath: z.string().min(1).nullable().optional(), }); const persistedStateBodySchema = z.object({ projects: z.array(persistedProjectSchema), threads: z.array(persistedThreadSchema), activeThreadId: z.string().min(1).nullable(), }); const runtimeModeSchema = z.enum(["approval-required", "full-access"]); export const persistedStateV1Schema = persistedStateBodySchema.extend({ version: z.literal(1).optional(), }); export const persistedStateV2Schema = persistedStateBodySchema.extend({ version: z.literal(2).optional(), }); export const persistedStateV3Schema = persistedStateBodySchema.extend({ runtimeMode: runtimeModeSchema.default(DEFAULT_RUNTIME_MODE), version: z.literal(3).optional(), }); export const persistedStateV4Schema = persistedStateBodySchema.extend({ runtimeMode: runtimeModeSchema.default(DEFAULT_RUNTIME_MODE), version: z.literal(4).optional(), }); export const persistedStateV6Schema = persistedStateBodySchema.extend({ runtimeMode: runtimeModeSchema.default(DEFAULT_RUNTIME_MODE), version: z.literal(6).optional(), }); export const persistedStateV5Schema = persistedStateBodySchema.extend({ runtimeMode: runtimeModeSchema.default(DEFAULT_RUNTIME_MODE), version: z.literal(5).optional(), }); const persistedStateSchema = z.union([ persistedStateV6Schema, persistedStateV5Schema, persistedStateV4Schema, persistedStateV3Schema, persistedStateV2Schema, persistedStateV1Schema, ]); export interface PersistedStoreSnapshot { projects: Project[]; threads: Thread[]; activeThreadId: string | null; runtimeMode: RuntimeMode; } function maybeMigrateLegacyModel(model: string, isLegacyPayload: boolean): string { if (!isLegacyPayload) { return model; } return model === LEGACY_DEFAULT_MODEL ? DEFAULT_MODEL : model; } function hydrateProject( project: z.infer, isLegacyPayload: boolean, ): Project { return { ...project, model: resolveModelSlug(maybeMigrateLegacyModel(project.model, isLegacyPayload)), }; } function hydrateThread( thread: z.infer, isLegacyPayload: boolean, ): Thread { return { id: thread.id, codexThreadId: thread.codexThreadId, projectId: thread.projectId, title: thread.title, model: resolveModelSlug(maybeMigrateLegacyModel(thread.model, isLegacyPayload)), terminalOpen: thread.terminalOpen ?? false, terminalHeight: thread.terminalHeight ?? DEFAULT_THREAD_TERMINAL_HEIGHT, session: null, messages: thread.messages.map((message) => { const hydratedAttachments = message.attachments?.map((attachment) => ({ ...attachment })); return { id: message.id, role: message.role, text: message.text, ...(hydratedAttachments && hydratedAttachments.length > 0 ? { attachments: hydratedAttachments } : {}), createdAt: message.createdAt, streaming: false, }; }), events: [], error: null, createdAt: thread.createdAt, lastVisitedAt: thread.lastVisitedAt, branch: thread.branch ?? null, worktreePath: thread.worktreePath ?? null, }; } export function hydratePersistedState( raw: string, isLegacyPayload: boolean, ): PersistedStoreSnapshot | null { let parsedJson: unknown; try { parsedJson = JSON.parse(raw); } catch { return null; } const parsedState = persistedStateSchema.safeParse(parsedJson); if (!parsedState.success) { return null; } const projects = parsedState.data.projects.map((project) => hydrateProject(project, isLegacyPayload), ); const projectIds = new Set(projects.map((project) => project.id)); const threads = parsedState.data.threads .map((thread) => hydrateThread(thread, isLegacyPayload)) .filter((thread) => projectIds.has(thread.projectId)); const hasActiveThread = Boolean( parsedState.data.activeThreadId && threads.some((thread) => thread.id === parsedState.data.activeThreadId), ); return { projects, threads, activeThreadId: hasActiveThread ? parsedState.data.activeThreadId : (threads[0]?.id ?? null), runtimeMode: "runtimeMode" in parsedState.data ? parsedState.data.runtimeMode : DEFAULT_RUNTIME_MODE, }; } export function toPersistedState( state: PersistedStoreSnapshot, ): z.infer { return { version: 6, projects: state.projects, threads: state.threads.map((thread) => ({ id: thread.id, codexThreadId: thread.codexThreadId, projectId: thread.projectId, title: thread.title, model: thread.model, terminalOpen: thread.terminalOpen, terminalHeight: thread.terminalHeight, messages: thread.messages.map((message) => ({ id: message.id, role: message.role, text: message.text, ...(message.attachments && message.attachments.length > 0 ? { attachments: message.attachments.map((attachment) => ({ type: attachment.type, id: attachment.id, name: attachment.name, mimeType: attachment.mimeType, sizeBytes: attachment.sizeBytes, })), } : {}), createdAt: message.createdAt, streaming: message.streaming, })), createdAt: thread.createdAt, ...(thread.lastVisitedAt ? { lastVisitedAt: thread.lastVisitedAt } : {}), branch: thread.branch, worktreePath: thread.worktreePath, })), activeThreadId: state.activeThreadId, runtimeMode: state.runtimeMode, }; }