diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000000000000000000000000000000000000..3a797f0da310bdd3e6f827e3f30ff3d762680563 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,42 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +jobs: + quality: + name: Lint, Typecheck, Test + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.1.38 + + - name: Cache Bun and Turbo + uses: actions/cache@v4 + with: + path: | + ~/.bun/install/cache + .turbo + key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}-${{ hashFiles('turbo.json') }} + restore-keys: | + ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}- + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Lint + run: bun run lint + + - name: Typecheck + run: bun run typecheck + + - name: Test + run: bun run test diff --git a/README.md b/README.md index 85dbd6e248b82e9317bdf4d1485faf96a02df0e6..6cabcfb0fa1cd0f991a6231f1512e78f01ed5bb4 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,10 @@ CodeThing is a desktop shell for coding agents. This first implementation is: - `bun run typecheck`: strict TypeScript checks for all packages. - `bun run test`: runs workspace tests. +## CI quality gates + +- `.github/workflows/ci.yml` runs `bun run lint`, `bun run typecheck`, and `bun run test` on pull requests and pushes to `main`. + Optional: - `ELECTRON_RENDERER_PORT=5180 bun run dev` if `5173` is already in use. diff --git a/apps/desktop/src/codexAppServerManager.ts b/apps/desktop/src/codexAppServerManager.ts index f2dafc34dc892a4475c6be504278430b8603a3aa..5b65d8b8589efc2a01a220e1bf226c39b86ae16f 100644 --- a/apps/desktop/src/codexAppServerManager.ts +++ b/apps/desktop/src/codexAppServerManager.ts @@ -77,11 +77,7 @@ export function normalizeCodexModelSlug( return undefined; } - if ( - preferredId && - preferredId.endsWith("-codex") && - preferredId !== trimmed - ) { + if (preferredId?.endsWith("-codex") && preferredId !== trimmed) { return preferredId; } diff --git a/apps/desktop/src/ipcHelpers.test.ts b/apps/desktop/src/ipcHelpers.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..33fbbc83151078d9879e3705bb7ab636ae88b0ed --- /dev/null +++ b/apps/desktop/src/ipcHelpers.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it, vi } from "vitest"; + +import { withParsedArgs, withParsedPayload } from "./ipcHelpers"; + +describe("withParsedPayload", () => { + it("parses payload and passes typed input to handler", async () => { + const handler = vi.fn(async (_event: unknown, payload: { value: string }) => + payload.value.toUpperCase(), + ); + const wrapped = withParsedPayload( + { + parse(payload: unknown): { value: string } { + if ( + !payload || + typeof payload !== "object" || + typeof (payload as { value?: unknown }).value !== "string" + ) { + throw new Error("Invalid payload"); + } + + return { value: (payload as { value: string }).value }; + }, + }, + handler, + ); + + const result = await wrapped({}, { value: "hello" }); + expect(result).toBe("HELLO"); + expect(handler).toHaveBeenCalledWith({}, { value: "hello" }); + }); + + it("throws and does not call handler on invalid payload", async () => { + const handler = vi.fn(async () => "ok"); + const wrapped = withParsedPayload( + { + parse(payload: unknown): { value: string } { + if ( + !payload || + typeof payload !== "object" || + typeof (payload as { value?: unknown }).value !== "string" + ) { + throw new Error("Invalid payload"); + } + + return { value: (payload as { value: string }).value }; + }, + }, + handler, + ); + + expect(() => wrapped({}, { value: 123 })).toThrow(); + expect(handler).not.toHaveBeenCalled(); + }); +}); + +describe("withParsedArgs", () => { + it("parses tuple arguments before invoking handler", () => { + const handler = vi.fn( + (_event: unknown, sessionId: string, data: string) => { + return `${sessionId}:${data}`; + }, + ); + const wrapped = withParsedArgs( + { + parse(args: unknown[]): [string, string] { + const [sessionId, data] = args; + if (typeof sessionId !== "string" || sessionId.length === 0) { + throw new Error("Invalid sessionId"); + } + if (typeof data !== "string") { + throw new Error("Invalid data"); + } + + return [sessionId, data]; + }, + }, + handler, + ); + + expect(wrapped({}, "abc", "input")).toBe("abc:input"); + expect(handler).toHaveBeenCalledWith({}, "abc", "input"); + }); + + it("throws and does not call handler when args are invalid", () => { + const handler = vi.fn(); + const wrapped = withParsedArgs( + { + parse(args: unknown[]): [string, string] { + const [sessionId, data] = args; + if (typeof sessionId !== "string" || sessionId.length === 0) { + throw new Error("Invalid sessionId"); + } + if (typeof data !== "string") { + throw new Error("Invalid data"); + } + + return [sessionId, data]; + }, + }, + handler, + ); + + expect(() => wrapped({}, 123, "input")).toThrow(); + expect(handler).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/ipcHelpers.ts b/apps/desktop/src/ipcHelpers.ts new file mode 100644 index 0000000000000000000000000000000000000000..6383cbfef24bd8529b36394eba9b92c053121634 --- /dev/null +++ b/apps/desktop/src/ipcHelpers.ts @@ -0,0 +1,24 @@ +type MaybePromise = T | Promise; +type Parser = { + parse: (value: unknown) => T; +}; +type ArgsParser = { + parse: (value: unknown[]) => T; +}; + +export function withParsedPayload( + schema: Parser, + handler: (event: unknown, payload: TPayload) => MaybePromise, +): (event: unknown, payload: unknown) => MaybePromise { + return (event, payload) => handler(event, schema.parse(payload)); +} + +export function withParsedArgs( + schema: ArgsParser, + handler: (event: unknown, ...args: TArgs) => MaybePromise, +): (event: unknown, ...args: unknown[]) => MaybePromise { + return (event, ...args) => { + const parsedArgs = schema.parse(args); + return handler(event, ...parsedArgs); + }; +} diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 03fc88d050b51b3723ea1a73df37f10f213a78e9..ebfb867c98d2eb3a88c506954d1836fad95e8801 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -12,9 +12,14 @@ import { agentConfigSchema, agentSessionIdSchema, newTodoInputSchema, + providerInterruptTurnInputSchema, + providerSendTurnInputSchema, + providerSessionStartInputSchema, + providerStopSessionInputSchema, terminalCommandInputSchema, todoIdSchema, } from "@acme/contracts"; +import { withParsedArgs, withParsedPayload } from "./ipcHelpers"; import { ProcessManager } from "./processManager"; import { ProviderManager } from "./providerManager"; import { TodoStore } from "./todoStore"; @@ -24,6 +29,16 @@ const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); let todoStore: TodoStore; const processManager = new ProcessManager(); const providerManager = new ProviderManager(); +const agentWriteArgsParser = { + parse(args: unknown[]): [string, string] { + const [sessionId, data] = args; + if (typeof data !== "string") { + throw new Error("agent:write data must be a string"); + } + + return [agentSessionIdSchema.parse(sessionId), data]; + }, +}; function createWindow(): BrowserWindow { const window = new BrowserWindow({ @@ -66,17 +81,26 @@ function registerIpcHandlers(): void { return todoStore.list(); }); - ipcMain.handle(IPC_CHANNELS.todosAdd, async (_event, payload: unknown) => { - return todoStore.add(newTodoInputSchema.parse(payload)); - }); + ipcMain.handle( + IPC_CHANNELS.todosAdd, + withParsedPayload(newTodoInputSchema, async (_event, payload) => { + return todoStore.add(payload); + }), + ); - ipcMain.handle(IPC_CHANNELS.todosToggle, async (_event, id: unknown) => { - return todoStore.toggle(todoIdSchema.parse(id)); - }); + ipcMain.handle( + IPC_CHANNELS.todosToggle, + withParsedPayload(todoIdSchema, async (_event, id) => { + return todoStore.toggle(id); + }), + ); - ipcMain.handle(IPC_CHANNELS.todosRemove, async (_event, id: unknown) => { - return todoStore.remove(todoIdSchema.parse(id)); - }); + ipcMain.handle( + IPC_CHANNELS.todosRemove, + withParsedPayload(todoIdSchema, async (_event, id) => { + return todoStore.remove(id); + }), + ); ipcMain.handle(IPC_CHANNELS.dialogPickFolder, async () => { const owner = @@ -94,61 +118,71 @@ function registerIpcHandlers(): void { }); // Terminal handlers - ipcMain.handle(IPC_CHANNELS.terminalRun, async (_event, payload: unknown) => { - return runTerminalCommand(terminalCommandInputSchema.parse(payload)); - }); + ipcMain.handle( + IPC_CHANNELS.terminalRun, + withParsedPayload(terminalCommandInputSchema, async (_event, payload) => { + return runTerminalCommand(payload); + }), + ); // Agent handlers - ipcMain.handle(IPC_CHANNELS.agentSpawn, async (_event, config: unknown) => { - return processManager.spawn(agentConfigSchema.parse(config)); - }); + ipcMain.handle( + IPC_CHANNELS.agentSpawn, + withParsedPayload(agentConfigSchema, async (_event, config) => { + return processManager.spawn(config); + }), + ); - ipcMain.handle(IPC_CHANNELS.agentKill, async (_event, sessionId: unknown) => { - processManager.kill(agentSessionIdSchema.parse(sessionId)); - }); + ipcMain.handle( + IPC_CHANNELS.agentKill, + withParsedPayload(agentSessionIdSchema, async (_event, sessionId) => { + processManager.kill(sessionId); + }), + ); ipcMain.handle( IPC_CHANNELS.agentWrite, - async (_event, sessionId: unknown, data: unknown) => { - processManager.write(agentSessionIdSchema.parse(sessionId), String(data)); - }, + withParsedArgs(agentWriteArgsParser, async (_event, sessionId, data) => { + processManager.write(sessionId, data); + }), ); // Provider handlers ipcMain.handle( IPC_CHANNELS.providerSessionStart, - async (_event, payload: unknown) => { - return providerManager.startSession( - payload as Parameters[0], - ); - }, + withParsedPayload( + providerSessionStartInputSchema, + async (_event, payload) => { + return providerManager.startSession(payload); + }, + ), ); ipcMain.handle( IPC_CHANNELS.providerTurnStart, - async (_event, payload: unknown) => { - return providerManager.sendTurn( - payload as Parameters[0], - ); - }, + withParsedPayload(providerSendTurnInputSchema, async (_event, payload) => { + return providerManager.sendTurn(payload); + }), ); ipcMain.handle( IPC_CHANNELS.providerTurnInterrupt, - async (_event, payload: unknown) => { - await providerManager.interruptTurn( - payload as Parameters[0], - ); - }, + withParsedPayload( + providerInterruptTurnInputSchema, + async (_event, payload) => { + await providerManager.interruptTurn(payload); + }, + ), ); ipcMain.handle( IPC_CHANNELS.providerSessionStop, - async (_event, payload: unknown) => { - providerManager.stopSession( - payload as Parameters[0], - ); - }, + withParsedPayload( + providerStopSessionInputSchema, + async (_event, payload) => { + providerManager.stopSession(payload); + }, + ), ); ipcMain.handle(IPC_CHANNELS.providerSessionList, async () => { diff --git a/apps/desktop/src/providerManager.ts b/apps/desktop/src/providerManager.ts index 0700daa65888fb6b31e58d35180bf5fd52412f2a..7083fda2ed7df835117dc562dea64033414b3444 100644 --- a/apps/desktop/src/providerManager.ts +++ b/apps/desktop/src/providerManager.ts @@ -30,10 +30,9 @@ export class ProviderManager extends EventEmitter { const logsDir = path.resolve(process.cwd(), ".logs"); fs.mkdirSync(logsDir, { recursive: true }); - this.logStream = fs.createWriteStream( - path.join(logsDir, "events.txt"), - { flags: "a" }, - ); + this.logStream = fs.createWriteStream(path.join(logsDir, "events.txt"), { + flags: "a", + }); this.codex.on("event", (event) => { this.logStream.write(`${JSON.stringify(event)}\n`); diff --git a/apps/renderer/package.json b/apps/renderer/package.json index 05b67881e5595994ca6c77ccf8508e144d63dfe4..df06a3e1e274787e21200f5cac4b641eab70defe 100644 --- a/apps/renderer/package.json +++ b/apps/renderer/package.json @@ -18,7 +18,8 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "rehype-highlight": "^7.0.2", - "remark-gfm": "^4.0.1" + "remark-gfm": "^4.0.1", + "zod": "^3.24.1" }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", diff --git a/apps/renderer/src/model-logic.test.ts b/apps/renderer/src/model-logic.test.ts index 91222e0f8d2613e724b4d864a24a65e13d6f57bd..f0e8d81bf4ea80cd8b833b1d9dafea687f329235 100644 --- a/apps/renderer/src/model-logic.test.ts +++ b/apps/renderer/src/model-logic.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { DEFAULT_MODEL, normalizeModelSlug, resolveModelSlug } from "./model-logic"; +import { + DEFAULT_MODEL, + normalizeModelSlug, + resolveModelSlug, +} from "./model-logic"; describe("normalizeModelSlug", () => { it("maps 5.3 aliases to canonical codex slug", () => { diff --git a/apps/renderer/src/model-logic.ts b/apps/renderer/src/model-logic.ts index 5daff904dfe4b736efc991816c56d2adac1726ac..180c772afb866cf20813737bf36274c9fb49cfa5 100644 --- a/apps/renderer/src/model-logic.ts +++ b/apps/renderer/src/model-logic.ts @@ -14,7 +14,9 @@ const MODEL_SLUG_ALIASES: Record = { "gpt-5.3": "gpt-5.3-codex", }; -export function normalizeModelSlug(model: string | null | undefined): string | null { +export function normalizeModelSlug( + model: string | null | undefined, +): string | null { if (typeof model !== "string") { return null; } diff --git a/apps/renderer/src/persistenceSchema.test.ts b/apps/renderer/src/persistenceSchema.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..5e8f283e2fa2f265a2baf6ced6a0db47216ec55c --- /dev/null +++ b/apps/renderer/src/persistenceSchema.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it } from "vitest"; + +import { DEFAULT_MODEL } from "./model-logic"; +import { hydratePersistedState, toPersistedState } from "./persistenceSchema"; +import type { Thread } from "./types"; + +describe("hydratePersistedState", () => { + it("returns null for invalid payloads", () => { + expect(hydratePersistedState('{"projects":"bad"}', false)).toBeNull(); + expect(hydratePersistedState("not-json", false)).toBeNull(); + }); + + it("migrates the legacy default model to the current default", () => { + const payload = JSON.stringify({ + projects: [ + { + id: "p-1", + name: "Project", + cwd: "/tmp/project", + model: "gpt-5.2-codex", + expanded: true, + }, + ], + threads: [ + { + id: "t-1", + projectId: "p-1", + title: "Thread", + model: "gpt-5.2-codex", + messages: [ + { + id: "m-1", + role: "assistant", + text: "Hello", + createdAt: "2026-02-08T10:00:00.000Z", + streaming: true, + }, + ], + createdAt: "2026-02-08T10:00:00.000Z", + }, + ], + activeThreadId: "t-1", + }); + + const hydrated = hydratePersistedState(payload, true); + expect(hydrated).not.toBeNull(); + expect(hydrated?.projects[0]?.model).toBe(DEFAULT_MODEL); + expect(hydrated?.threads[0]?.model).toBe(DEFAULT_MODEL); + expect(hydrated?.threads[0]?.messages[0]?.streaming).toBe(false); + }); + + it("filters unknown project references and repairs active thread", () => { + const payload = JSON.stringify({ + projects: [ + { + id: "p-1", + name: "Project", + cwd: "/tmp/project", + model: "gpt-5.3-codex", + expanded: false, + }, + ], + threads: [ + { + id: "t-1", + projectId: "p-1", + title: "Valid thread", + model: "gpt-5.3-codex", + messages: [], + createdAt: "2026-02-08T10:00:00.000Z", + }, + { + id: "t-2", + projectId: "p-missing", + title: "Dangling thread", + model: "gpt-5.3-codex", + messages: [], + createdAt: "2026-02-08T10:00:00.000Z", + }, + ], + activeThreadId: "t-2", + }); + + const hydrated = hydratePersistedState(payload, false); + expect(hydrated).not.toBeNull(); + expect(hydrated?.threads.map((thread) => thread.id)).toEqual(["t-1"]); + expect(hydrated?.activeThreadId).toBe("t-1"); + }); +}); + +describe("toPersistedState", () => { + it("writes v2 payload and strips non-persisted thread fields", () => { + const thread: Thread = { + id: "t-1", + projectId: "p-1", + title: "Thread", + model: "gpt-5.3-codex", + session: null, + messages: [ + { + id: "m-1", + role: "user", + text: "Hi", + createdAt: "2026-02-08T10:00:00.000Z", + streaming: false, + }, + ], + events: [], + error: "boom", + createdAt: "2026-02-08T10:00:00.000Z", + }; + + const persisted = toPersistedState({ + projects: [ + { + id: "p-1", + name: "Project", + cwd: "/tmp/project", + model: "gpt-5.3-codex", + expanded: true, + }, + ], + threads: [thread], + activeThreadId: "t-1", + }); + + expect(persisted.version).toBe(2); + expect(persisted.threads[0]).toEqual({ + id: "t-1", + projectId: "p-1", + title: "Thread", + model: "gpt-5.3-codex", + messages: thread.messages, + createdAt: thread.createdAt, + }); + const persistedThread = persisted.threads[0]; + expect(persistedThread).toBeDefined(); + if (!persistedThread) return; + + expect("error" in persistedThread).toBe(false); + expect("session" in persistedThread).toBe(false); + }); +}); diff --git a/apps/renderer/src/persistenceSchema.ts b/apps/renderer/src/persistenceSchema.ts new file mode 100644 index 0000000000000000000000000000000000000000..9f7e576c9981f95897ff6a3c9ef6ee4c27f33a93 --- /dev/null +++ b/apps/renderer/src/persistenceSchema.ts @@ -0,0 +1,156 @@ +import { z } from "zod"; + +import { DEFAULT_MODEL, resolveModelSlug } from "./model-logic"; +import type { Project, 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(), + createdAt: z.string().min(1), + streaming: z.boolean(), +}); + +const persistedThreadSchema = z.object({ + id: z.string().min(1), + projectId: z.string().min(1), + title: z.string().min(1), + model: z.string().min(1), + messages: z.array(persistedMessageSchema), + createdAt: z.string().min(1), +}); + +const persistedStateBodySchema = z.object({ + projects: z.array(persistedProjectSchema), + threads: z.array(persistedThreadSchema), + activeThreadId: z.string().min(1).nullable(), +}); + +export const persistedStateV1Schema = persistedStateBodySchema.extend({ + version: z.literal(1).optional(), +}); + +export const persistedStateV2Schema = persistedStateBodySchema.extend({ + version: z.literal(2).optional(), +}); + +const persistedStateSchema = z.union([ + persistedStateV2Schema, + persistedStateV1Schema, +]); + +export interface PersistedStoreSnapshot { + projects: Project[]; + threads: Thread[]; + activeThreadId: string | null; +} + +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, + projectId: thread.projectId, + title: thread.title, + model: resolveModelSlug( + maybeMigrateLegacyModel(thread.model, isLegacyPayload), + ), + session: null, + messages: thread.messages.map((message) => ({ + ...message, + streaming: false, + })), + events: [], + error: null, + createdAt: thread.createdAt, + }; +} + +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), + }; +} + +export function toPersistedState( + state: PersistedStoreSnapshot, +): z.infer { + return { + version: 2, + projects: state.projects, + threads: state.threads.map((thread) => ({ + id: thread.id, + projectId: thread.projectId, + title: thread.title, + model: thread.model, + messages: thread.messages, + createdAt: thread.createdAt, + })), + activeThreadId: state.activeThreadId, + }; +} diff --git a/apps/renderer/src/store.ts b/apps/renderer/src/store.ts index 7cb27f52b1dc006876fd73330ef697ff62959bb8..5ae304f4f8fc598e738df61c92c72517f32fe56d 100644 --- a/apps/renderer/src/store.ts +++ b/apps/renderer/src/store.ts @@ -9,7 +9,8 @@ import { } from "react"; import type { ProviderEvent, ProviderSession } from "@acme/contracts"; -import { DEFAULT_MODEL, resolveModelSlug } from "./model-logic"; +import { resolveModelSlug } from "./model-logic"; +import { hydratePersistedState, toPersistedState } from "./persistenceSchema"; import { applyEventToMessages, evolveSession } from "./session-logic"; import type { Project, Thread } from "./types"; @@ -43,7 +44,6 @@ export interface AppState { const PERSISTED_STATE_KEY = "codething:renderer-state:v2"; const LEGACY_PERSISTED_STATE_KEY = "codething:renderer-state:v1"; -const LEGACY_DEFAULT_MODEL = "gpt-5.2-codex"; const initialState: AppState = { projects: [], @@ -54,130 +54,6 @@ const initialState: AppState = { // ── Helpers ────────────────────────────────────────────────────────── -interface PersistedStateV1 { - projects: Project[]; - threads: Array< - Pick< - Thread, - "id" | "projectId" | "title" | "model" | "messages" | "createdAt" - > - >; - activeThreadId: string | null; -} - -function asObject(value: unknown): Record | null { - if (!value || typeof value !== "object") return null; - return value as Record; -} - -function asString(value: unknown): string | null { - return typeof value === "string" ? value : null; -} - -function isBoolean(value: unknown): value is boolean { - return typeof value === "boolean"; -} - -function sanitizeProjects(input: unknown): Project[] { - if (!Array.isArray(input)) return []; - - return input - .map((raw) => { - const project = asObject(raw); - if (!project) return null; - - const id = asString(project.id); - const name = asString(project.name); - const cwd = asString(project.cwd); - const rawModel = asString(project.model); - const model = resolveModelSlug(rawModel); - const expanded = project.expanded; - if (!id || !name || !cwd || !model || !isBoolean(expanded)) return null; - - return { - id, - name, - cwd, - model, - expanded, - } satisfies Project; - }) - .filter((project): project is Project => project !== null); -} - -function migrateLegacyDefaultModel(model: string): string { - return model === LEGACY_DEFAULT_MODEL ? DEFAULT_MODEL : model; -} - -function sanitizeMessages(input: unknown): Thread["messages"] { - if (!Array.isArray(input)) return []; - - return input - .map((raw) => { - const message = asObject(raw); - if (!message) return null; - - const id = asString(message.id); - const role = message.role; - const text = asString(message.text); - const createdAt = asString(message.createdAt); - const streaming = message.streaming; - if ( - !id || - (role !== "user" && role !== "assistant") || - text === null || - !createdAt || - !isBoolean(streaming) - ) { - return null; - } - - const hydratedMessage: Thread["messages"][number] = { - id, - role, - text, - createdAt, - streaming: false, - }; - return hydratedMessage; - }) - .filter( - (message): message is Thread["messages"][number] => message !== null, - ); -} - -function sanitizeThreads(input: unknown): AppState["threads"] { - if (!Array.isArray(input)) return []; - - return input - .map((raw) => { - const thread = asObject(raw); - if (!thread) return null; - - const id = asString(thread.id); - const projectId = asString(thread.projectId); - const title = asString(thread.title); - const rawModel = asString(thread.model); - const model = resolveModelSlug(rawModel); - const createdAt = asString(thread.createdAt); - if (!id || !projectId || !title || !model || !createdAt) return null; - - const hydratedThread: Thread = { - id, - projectId, - title, - model, - session: null, - messages: sanitizeMessages(thread.messages), - events: [], - error: null, - createdAt, - }; - return hydratedThread; - }) - .filter((thread): thread is Thread => thread !== null); -} - function readPersistedState(): AppState { if (typeof window === "undefined") return initialState; @@ -186,61 +62,18 @@ function readPersistedState(): AppState { const rawLegacy = window.localStorage.getItem(LEGACY_PERSISTED_STATE_KEY); const raw = rawCurrent ?? rawLegacy; if (!raw) return initialState; - const isLegacyPayload = !rawCurrent && Boolean(rawLegacy); - - const parsed = asObject(JSON.parse(raw)); - if (!parsed) return initialState; - - const projects = sanitizeProjects(parsed.projects).map((project) => ({ - ...project, - model: isLegacyPayload - ? migrateLegacyDefaultModel(project.model) - : project.model, - })); - const projectIds = new Set(projects.map((project) => project.id)); - const threads = sanitizeThreads(parsed.threads) - .map((thread) => ({ - ...thread, - model: isLegacyPayload - ? migrateLegacyDefaultModel(thread.model) - : thread.model, - })) - .filter((thread) => projectIds.has(thread.projectId)); - const activeThreadId = asString(parsed.activeThreadId); - const hasActiveThread = Boolean( - activeThreadId && threads.some((thread) => thread.id === activeThreadId), + const hydrated = hydratePersistedState( + raw, + !rawCurrent && Boolean(rawLegacy), ); + if (!hydrated) return initialState; - return { - projects, - threads, - activeThreadId: hasActiveThread - ? activeThreadId - : threads[0]?.id - ? threads[0].id - : null, - diffOpen: false, - }; + return { ...hydrated, diffOpen: false }; } catch { return initialState; } } -function toPersistedState(state: AppState): PersistedStateV1 { - return { - projects: state.projects, - threads: state.threads.map((thread) => ({ - id: thread.id, - projectId: thread.projectId, - title: thread.title, - model: thread.model, - messages: thread.messages, - createdAt: thread.createdAt, - })), - activeThreadId: state.activeThreadId, - }; -} - function persistState(state: AppState): void { if (typeof window === "undefined") return; diff --git a/bun.lock b/bun.lock index 684a15faf83c319bbf76458b6550a2ed20893a28..08700b4a205a39008df8199a54abc3aec3c10f73 100644 --- a/bun.lock +++ b/bun.lock @@ -40,6 +40,7 @@ "react-markdown": "^10.1.0", "rehype-highlight": "^7.0.2", "remark-gfm": "^4.0.1", + "zod": "^3.24.1", }, "devDependencies": { "@tailwindcss/vite": "^4.0.0",