| author | |
| committer | |
| log | 2fc933652828b51f35b48579c86b71858224b10c |
| tree | 3a5ffd6d88a9c95ffdbfb80e3b0d9efcbc150300 |
| parent | 87483c3a95e1c0a6c86e636671eeea442216d771 |
| signature | Commit is signed but in an unrecognized format. |
14 files changed, 572 insertions(+), 227 deletions(-)
.github/workflows/ci.yml created+42| ... | ... | @@ -0,0 +1,42 @@ |
| 1 | name: CI | |
| 2 | ||
| 3 | on: | |
| 4 | pull_request: | |
| 5 | push: | |
| 6 | branches: | |
| 7 | - main | |
| 8 | ||
| 9 | jobs: | |
| 10 | quality: | |
| 11 | name: Lint, Typecheck, Test | |
| 12 | runs-on: ubuntu-latest | |
| 13 | steps: | |
| 14 | - name: Checkout | |
| 15 | uses: actions/checkout@v4 | |
| 16 | ||
| 17 | - name: Setup Bun | |
| 18 | uses: oven-sh/setup-bun@v2 | |
| 19 | with: | |
| 20 | bun-version: 1.1.38 | |
| 21 | ||
| 22 | - name: Cache Bun and Turbo | |
| 23 | uses: actions/cache@v4 | |
| 24 | with: | |
| 25 | path: | | |
| 26 | ~/.bun/install/cache | |
| 27 | .turbo | |
| 28 | key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}-${{ hashFiles('turbo.json') }} | |
| 29 | restore-keys: | | |
| 30 | ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}- | |
| 31 | ||
| 32 | - name: Install dependencies | |
| 33 | run: bun install --frozen-lockfile | |
| 34 | ||
| 35 | - name: Lint | |
| 36 | run: bun run lint | |
| 37 | ||
| 38 | - name: Typecheck | |
| 39 | run: bun run typecheck | |
| 40 | ||
| 41 | - name: Test | |
| 42 | run: bun run test |
README.md+4| ... | ... | @@ -33,6 +33,10 @@ CodeThing is a desktop shell for coding agents. This first implementation is: |
| 33 | 33 | - `bun run typecheck`: strict TypeScript checks for all packages. |
| 34 | 34 | - `bun run test`: runs workspace tests. |
| 35 | 35 | |
| 36 | ## CI quality gates | |
| 37 | ||
| 38 | - `.github/workflows/ci.yml` runs `bun run lint`, `bun run typecheck`, and `bun run test` on pull requests and pushes to `main`. | |
| 39 | ||
| 36 | 40 | Optional: |
| 37 | 41 | - `ELECTRON_RENDERER_PORT=5180 bun run dev` if `5173` is already in use. |
| 38 | 42 |
apps/desktop/src/codexAppServerManager.ts+1-5| ... | ... | @@ -77,11 +77,7 @@ export function normalizeCodexModelSlug( |
| 77 | 77 | return undefined; |
| 78 | 78 | } |
| 79 | 79 | |
| 80 | if ( | |
| 81 | preferredId && | |
| 82 | preferredId.endsWith("-codex") && | |
| 83 | preferredId !== trimmed | |
| 84 | ) { | |
| 80 | if (preferredId?.endsWith("-codex") && preferredId !== trimmed) { | |
| 85 | 81 | return preferredId; |
| 86 | 82 | } |
| 87 | 83 |
apps/desktop/src/ipcHelpers.test.ts created+106| ... | ... | @@ -0,0 +1,106 @@ |
| 1 | import { describe, expect, it, vi } from "vitest"; | |
| 2 | ||
| 3 | import { withParsedArgs, withParsedPayload } from "./ipcHelpers"; | |
| 4 | ||
| 5 | describe("withParsedPayload", () => { | |
| 6 | it("parses payload and passes typed input to handler", async () => { | |
| 7 | const handler = vi.fn(async (_event: unknown, payload: { value: string }) => | |
| 8 | payload.value.toUpperCase(), | |
| 9 | ); | |
| 10 | const wrapped = withParsedPayload( | |
| 11 | { | |
| 12 | parse(payload: unknown): { value: string } { | |
| 13 | if ( | |
| 14 | !payload || | |
| 15 | typeof payload !== "object" || | |
| 16 | typeof (payload as { value?: unknown }).value !== "string" | |
| 17 | ) { | |
| 18 | throw new Error("Invalid payload"); | |
| 19 | } | |
| 20 | ||
| 21 | return { value: (payload as { value: string }).value }; | |
| 22 | }, | |
| 23 | }, | |
| 24 | handler, | |
| 25 | ); | |
| 26 | ||
| 27 | const result = await wrapped({}, { value: "hello" }); | |
| 28 | expect(result).toBe("HELLO"); | |
| 29 | expect(handler).toHaveBeenCalledWith({}, { value: "hello" }); | |
| 30 | }); | |
| 31 | ||
| 32 | it("throws and does not call handler on invalid payload", async () => { | |
| 33 | const handler = vi.fn(async () => "ok"); | |
| 34 | const wrapped = withParsedPayload( | |
| 35 | { | |
| 36 | parse(payload: unknown): { value: string } { | |
| 37 | if ( | |
| 38 | !payload || | |
| 39 | typeof payload !== "object" || | |
| 40 | typeof (payload as { value?: unknown }).value !== "string" | |
| 41 | ) { | |
| 42 | throw new Error("Invalid payload"); | |
| 43 | } | |
| 44 | ||
| 45 | return { value: (payload as { value: string }).value }; | |
| 46 | }, | |
| 47 | }, | |
| 48 | handler, | |
| 49 | ); | |
| 50 | ||
| 51 | expect(() => wrapped({}, { value: 123 })).toThrow(); | |
| 52 | expect(handler).not.toHaveBeenCalled(); | |
| 53 | }); | |
| 54 | }); | |
| 55 | ||
| 56 | describe("withParsedArgs", () => { | |
| 57 | it("parses tuple arguments before invoking handler", () => { | |
| 58 | const handler = vi.fn( | |
| 59 | (_event: unknown, sessionId: string, data: string) => { | |
| 60 | return `${sessionId}:${data}`; | |
| 61 | }, | |
| 62 | ); | |
| 63 | const wrapped = withParsedArgs( | |
| 64 | { | |
| 65 | parse(args: unknown[]): [string, string] { | |
| 66 | const [sessionId, data] = args; | |
| 67 | if (typeof sessionId !== "string" || sessionId.length === 0) { | |
| 68 | throw new Error("Invalid sessionId"); | |
| 69 | } | |
| 70 | if (typeof data !== "string") { | |
| 71 | throw new Error("Invalid data"); | |
| 72 | } | |
| 73 | ||
| 74 | return [sessionId, data]; | |
| 75 | }, | |
| 76 | }, | |
| 77 | handler, | |
| 78 | ); | |
| 79 | ||
| 80 | expect(wrapped({}, "abc", "input")).toBe("abc:input"); | |
| 81 | expect(handler).toHaveBeenCalledWith({}, "abc", "input"); | |
| 82 | }); | |
| 83 | ||
| 84 | it("throws and does not call handler when args are invalid", () => { | |
| 85 | const handler = vi.fn(); | |
| 86 | const wrapped = withParsedArgs( | |
| 87 | { | |
| 88 | parse(args: unknown[]): [string, string] { | |
| 89 | const [sessionId, data] = args; | |
| 90 | if (typeof sessionId !== "string" || sessionId.length === 0) { | |
| 91 | throw new Error("Invalid sessionId"); | |
| 92 | } | |
| 93 | if (typeof data !== "string") { | |
| 94 | throw new Error("Invalid data"); | |
| 95 | } | |
| 96 | ||
| 97 | return [sessionId, data]; | |
| 98 | }, | |
| 99 | }, | |
| 100 | handler, | |
| 101 | ); | |
| 102 | ||
| 103 | expect(() => wrapped({}, 123, "input")).toThrow(); | |
| 104 | expect(handler).not.toHaveBeenCalled(); | |
| 105 | }); | |
| 106 | }); |
apps/desktop/src/ipcHelpers.ts created+24| ... | ... | @@ -0,0 +1,24 @@ |
| 1 | type MaybePromise<T> = T | Promise<T>; | |
| 2 | type Parser<T> = { | |
| 3 | parse: (value: unknown) => T; | |
| 4 | }; | |
| 5 | type ArgsParser<T extends unknown[]> = { | |
| 6 | parse: (value: unknown[]) => T; | |
| 7 | }; | |
| 8 | ||
| 9 | export function withParsedPayload<TPayload, TResult>( | |
| 10 | schema: Parser<TPayload>, | |
| 11 | handler: (event: unknown, payload: TPayload) => MaybePromise<TResult>, | |
| 12 | ): (event: unknown, payload: unknown) => MaybePromise<TResult> { | |
| 13 | return (event, payload) => handler(event, schema.parse(payload)); | |
| 14 | } | |
| 15 | ||
| 16 | export function withParsedArgs<TArgs extends unknown[], TResult>( | |
| 17 | schema: ArgsParser<TArgs>, | |
| 18 | handler: (event: unknown, ...args: TArgs) => MaybePromise<TResult>, | |
| 19 | ): (event: unknown, ...args: unknown[]) => MaybePromise<TResult> { | |
| 20 | return (event, ...args) => { | |
| 21 | const parsedArgs = schema.parse(args); | |
| 22 | return handler(event, ...parsedArgs); | |
| 23 | }; | |
| 24 | } |
apps/desktop/src/main.ts+75-41| ... | ... | @@ -12,9 +12,14 @@ import { |
| 12 | 12 | agentConfigSchema, |
| 13 | 13 | agentSessionIdSchema, |
| 14 | 14 | newTodoInputSchema, |
| 15 | providerInterruptTurnInputSchema, | |
| 16 | providerSendTurnInputSchema, | |
| 17 | providerSessionStartInputSchema, | |
| 18 | providerStopSessionInputSchema, | |
| 15 | 19 | terminalCommandInputSchema, |
| 16 | 20 | todoIdSchema, |
| 17 | 21 | } from "@acme/contracts"; |
| 22 | import { withParsedArgs, withParsedPayload } from "./ipcHelpers"; | |
| 18 | 23 | import { ProcessManager } from "./processManager"; |
| 19 | 24 | import { ProviderManager } from "./providerManager"; |
| 20 | 25 | import { TodoStore } from "./todoStore"; |
| ... | ... | @@ -24,6 +29,16 @@ const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); |
| 24 | 29 | let todoStore: TodoStore; |
| 25 | 30 | const processManager = new ProcessManager(); |
| 26 | 31 | const providerManager = new ProviderManager(); |
| 32 | const agentWriteArgsParser = { | |
| 33 | parse(args: unknown[]): [string, string] { | |
| 34 | const [sessionId, data] = args; | |
| 35 | if (typeof data !== "string") { | |
| 36 | throw new Error("agent:write data must be a string"); | |
| 37 | } | |
| 38 | ||
| 39 | return [agentSessionIdSchema.parse(sessionId), data]; | |
| 40 | }, | |
| 41 | }; | |
| 27 | 42 | |
| 28 | 43 | function createWindow(): BrowserWindow { |
| 29 | 44 | const window = new BrowserWindow({ |
| ... | ... | @@ -66,17 +81,26 @@ function registerIpcHandlers(): void { |
| 66 | 81 | return todoStore.list(); |
| 67 | 82 | }); |
| 68 | 83 | |
| 69 | ipcMain.handle(IPC_CHANNELS.todosAdd, async (_event, payload: unknown) => { | |
| 70 | return todoStore.add(newTodoInputSchema.parse(payload)); | |
| 71 | }); | |
| 84 | ipcMain.handle( | |
| 85 | IPC_CHANNELS.todosAdd, | |
| 86 | withParsedPayload(newTodoInputSchema, async (_event, payload) => { | |
| 87 | return todoStore.add(payload); | |
| 88 | }), | |
| 89 | ); | |
| 72 | 90 | |
| 73 | ipcMain.handle(IPC_CHANNELS.todosToggle, async (_event, id: unknown) => { | |
| 74 | return todoStore.toggle(todoIdSchema.parse(id)); | |
| 75 | }); | |
| 91 | ipcMain.handle( | |
| 92 | IPC_CHANNELS.todosToggle, | |
| 93 | withParsedPayload(todoIdSchema, async (_event, id) => { | |
| 94 | return todoStore.toggle(id); | |
| 95 | }), | |
| 96 | ); | |
| 76 | 97 | |
| 77 | ipcMain.handle(IPC_CHANNELS.todosRemove, async (_event, id: unknown) => { | |
| 78 | return todoStore.remove(todoIdSchema.parse(id)); | |
| 79 | }); | |
| 98 | ipcMain.handle( | |
| 99 | IPC_CHANNELS.todosRemove, | |
| 100 | withParsedPayload(todoIdSchema, async (_event, id) => { | |
| 101 | return todoStore.remove(id); | |
| 102 | }), | |
| 103 | ); | |
| 80 | 104 | |
| 81 | 105 | ipcMain.handle(IPC_CHANNELS.dialogPickFolder, async () => { |
| 82 | 106 | const owner = |
| ... | ... | @@ -94,61 +118,71 @@ function registerIpcHandlers(): void { |
| 94 | 118 | }); |
| 95 | 119 | |
| 96 | 120 | // Terminal handlers |
| 97 | ipcMain.handle(IPC_CHANNELS.terminalRun, async (_event, payload: unknown) => { | |
| 98 | return runTerminalCommand(terminalCommandInputSchema.parse(payload)); | |
| 99 | }); | |
| 121 | ipcMain.handle( | |
| 122 | IPC_CHANNELS.terminalRun, | |
| 123 | withParsedPayload(terminalCommandInputSchema, async (_event, payload) => { | |
| 124 | return runTerminalCommand(payload); | |
| 125 | }), | |
| 126 | ); | |
| 100 | 127 | |
| 101 | 128 | // Agent handlers |
| 102 | ipcMain.handle(IPC_CHANNELS.agentSpawn, async (_event, config: unknown) => { | |
| 103 | return processManager.spawn(agentConfigSchema.parse(config)); | |
| 104 | }); | |
| 129 | ipcMain.handle( | |
| 130 | IPC_CHANNELS.agentSpawn, | |
| 131 | withParsedPayload(agentConfigSchema, async (_event, config) => { | |
| 132 | return processManager.spawn(config); | |
| 133 | }), | |
| 134 | ); | |
| 105 | 135 | |
| 106 | ipcMain.handle(IPC_CHANNELS.agentKill, async (_event, sessionId: unknown) => { | |
| 107 | processManager.kill(agentSessionIdSchema.parse(sessionId)); | |
| 108 | }); | |
| 136 | ipcMain.handle( | |
| 137 | IPC_CHANNELS.agentKill, | |
| 138 | withParsedPayload(agentSessionIdSchema, async (_event, sessionId) => { | |
| 139 | processManager.kill(sessionId); | |
| 140 | }), | |
| 141 | ); | |
| 109 | 142 | |
| 110 | 143 | ipcMain.handle( |
| 111 | 144 | IPC_CHANNELS.agentWrite, |
| 112 | async (_event, sessionId: unknown, data: unknown) => { | |
| 113 | processManager.write(agentSessionIdSchema.parse(sessionId), String(data)); | |
| 114 | }, | |
| 145 | withParsedArgs(agentWriteArgsParser, async (_event, sessionId, data) => { | |
| 146 | processManager.write(sessionId, data); | |
| 147 | }), | |
| 115 | 148 | ); |
| 116 | 149 | |
| 117 | 150 | // Provider handlers |
| 118 | 151 | ipcMain.handle( |
| 119 | 152 | IPC_CHANNELS.providerSessionStart, |
| 120 | async (_event, payload: unknown) => { | |
| 121 | return providerManager.startSession( | |
| 122 | payload as Parameters<typeof providerManager.startSession>[0], | |
| 123 | ); | |
| 124 | }, | |
| 153 | withParsedPayload( | |
| 154 | providerSessionStartInputSchema, | |
| 155 | async (_event, payload) => { | |
| 156 | return providerManager.startSession(payload); | |
| 157 | }, | |
| 158 | ), | |
| 125 | 159 | ); |
| 126 | 160 | |
| 127 | 161 | ipcMain.handle( |
| 128 | 162 | IPC_CHANNELS.providerTurnStart, |
| 129 | async (_event, payload: unknown) => { | |
| 130 | return providerManager.sendTurn( | |
| 131 | payload as Parameters<typeof providerManager.sendTurn>[0], | |
| 132 | ); | |
| 133 | }, | |
| 163 | withParsedPayload(providerSendTurnInputSchema, async (_event, payload) => { | |
| 164 | return providerManager.sendTurn(payload); | |
| 165 | }), | |
| 134 | 166 | ); |
| 135 | 167 | |
| 136 | 168 | ipcMain.handle( |
| 137 | 169 | IPC_CHANNELS.providerTurnInterrupt, |
| 138 | async (_event, payload: unknown) => { | |
| 139 | await providerManager.interruptTurn( | |
| 140 | payload as Parameters<typeof providerManager.interruptTurn>[0], | |
| 141 | ); | |
| 142 | }, | |
| 170 | withParsedPayload( | |
| 171 | providerInterruptTurnInputSchema, | |
| 172 | async (_event, payload) => { | |
| 173 | await providerManager.interruptTurn(payload); | |
| 174 | }, | |
| 175 | ), | |
| 143 | 176 | ); |
| 144 | 177 | |
| 145 | 178 | ipcMain.handle( |
| 146 | 179 | IPC_CHANNELS.providerSessionStop, |
| 147 | async (_event, payload: unknown) => { | |
| 148 | providerManager.stopSession( | |
| 149 | payload as Parameters<typeof providerManager.stopSession>[0], | |
| 150 | ); | |
| 151 | }, | |
| 180 | withParsedPayload( | |
| 181 | providerStopSessionInputSchema, | |
| 182 | async (_event, payload) => { | |
| 183 | providerManager.stopSession(payload); | |
| 184 | }, | |
| 185 | ), | |
| 152 | 186 | ); |
| 153 | 187 | |
| 154 | 188 | ipcMain.handle(IPC_CHANNELS.providerSessionList, async () => { |
apps/desktop/src/providerManager.ts+3-4| ... | ... | @@ -30,10 +30,9 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> { |
| 30 | 30 | |
| 31 | 31 | const logsDir = path.resolve(process.cwd(), ".logs"); |
| 32 | 32 | fs.mkdirSync(logsDir, { recursive: true }); |
| 33 | this.logStream = fs.createWriteStream( | |
| 34 | path.join(logsDir, "events.txt"), | |
| 35 | { flags: "a" }, | |
| 36 | ); | |
| 33 | this.logStream = fs.createWriteStream(path.join(logsDir, "events.txt"), { | |
| 34 | flags: "a", | |
| 35 | }); | |
| 37 | 36 | |
| 38 | 37 | this.codex.on("event", (event) => { |
| 39 | 38 | this.logStream.write(`${JSON.stringify(event)}\n`); |
apps/renderer/package.json+2-1| ... | ... | @@ -18,7 +18,8 @@ |
| 18 | 18 | "react": "^19.0.0", |
| 19 | 19 | "react-dom": "^19.0.0", |
| 20 | 20 | "rehype-highlight": "^7.0.2", |
| 21 | "remark-gfm": "^4.0.1" | |
| 21 | "remark-gfm": "^4.0.1", | |
| 22 | "zod": "^3.24.1" | |
| 22 | 23 | }, |
| 23 | 24 | "devDependencies": { |
| 24 | 25 | "@tailwindcss/vite": "^4.0.0", |
apps/renderer/src/model-logic.test.ts+5-1| ... | ... | @@ -1,5 +1,9 @@ |
| 1 | 1 | import { describe, expect, it } from "vitest"; |
| 2 | import { DEFAULT_MODEL, normalizeModelSlug, resolveModelSlug } from "./model-logic"; | |
| 2 | import { | |
| 3 | DEFAULT_MODEL, | |
| 4 | normalizeModelSlug, | |
| 5 | resolveModelSlug, | |
| 6 | } from "./model-logic"; | |
| 3 | 7 | |
| 4 | 8 | describe("normalizeModelSlug", () => { |
| 5 | 9 | it("maps 5.3 aliases to canonical codex slug", () => { |
apps/renderer/src/model-logic.ts+3-1| ... | ... | @@ -14,7 +14,9 @@ const MODEL_SLUG_ALIASES: Record<string, string> = { |
| 14 | 14 | "gpt-5.3": "gpt-5.3-codex", |
| 15 | 15 | }; |
| 16 | 16 | |
| 17 | export function normalizeModelSlug(model: string | null | undefined): string | null { | |
| 17 | export function normalizeModelSlug( | |
| 18 | model: string | null | undefined, | |
| 19 | ): string | null { | |
| 18 | 20 | if (typeof model !== "string") { |
| 19 | 21 | return null; |
| 20 | 22 | } |
apps/renderer/src/persistenceSchema.test.ts created+143| ... | ... | @@ -0,0 +1,143 @@ |
| 1 | import { describe, expect, it } from "vitest"; | |
| 2 | ||
| 3 | import { DEFAULT_MODEL } from "./model-logic"; | |
| 4 | import { hydratePersistedState, toPersistedState } from "./persistenceSchema"; | |
| 5 | import type { Thread } from "./types"; | |
| 6 | ||
| 7 | describe("hydratePersistedState", () => { | |
| 8 | it("returns null for invalid payloads", () => { | |
| 9 | expect(hydratePersistedState('{"projects":"bad"}', false)).toBeNull(); | |
| 10 | expect(hydratePersistedState("not-json", false)).toBeNull(); | |
| 11 | }); | |
| 12 | ||
| 13 | it("migrates the legacy default model to the current default", () => { | |
| 14 | const payload = JSON.stringify({ | |
| 15 | projects: [ | |
| 16 | { | |
| 17 | id: "p-1", | |
| 18 | name: "Project", | |
| 19 | cwd: "/tmp/project", | |
| 20 | model: "gpt-5.2-codex", | |
| 21 | expanded: true, | |
| 22 | }, | |
| 23 | ], | |
| 24 | threads: [ | |
| 25 | { | |
| 26 | id: "t-1", | |
| 27 | projectId: "p-1", | |
| 28 | title: "Thread", | |
| 29 | model: "gpt-5.2-codex", | |
| 30 | messages: [ | |
| 31 | { | |
| 32 | id: "m-1", | |
| 33 | role: "assistant", | |
| 34 | text: "Hello", | |
| 35 | createdAt: "2026-02-08T10:00:00.000Z", | |
| 36 | streaming: true, | |
| 37 | }, | |
| 38 | ], | |
| 39 | createdAt: "2026-02-08T10:00:00.000Z", | |
| 40 | }, | |
| 41 | ], | |
| 42 | activeThreadId: "t-1", | |
| 43 | }); | |
| 44 | ||
| 45 | const hydrated = hydratePersistedState(payload, true); | |
| 46 | expect(hydrated).not.toBeNull(); | |
| 47 | expect(hydrated?.projects[0]?.model).toBe(DEFAULT_MODEL); | |
| 48 | expect(hydrated?.threads[0]?.model).toBe(DEFAULT_MODEL); | |
| 49 | expect(hydrated?.threads[0]?.messages[0]?.streaming).toBe(false); | |
| 50 | }); | |
| 51 | ||
| 52 | it("filters unknown project references and repairs active thread", () => { | |
| 53 | const payload = JSON.stringify({ | |
| 54 | projects: [ | |
| 55 | { | |
| 56 | id: "p-1", | |
| 57 | name: "Project", | |
| 58 | cwd: "/tmp/project", | |
| 59 | model: "gpt-5.3-codex", | |
| 60 | expanded: false, | |
| 61 | }, | |
| 62 | ], | |
| 63 | threads: [ | |
| 64 | { | |
| 65 | id: "t-1", | |
| 66 | projectId: "p-1", | |
| 67 | title: "Valid thread", | |
| 68 | model: "gpt-5.3-codex", | |
| 69 | messages: [], | |
| 70 | createdAt: "2026-02-08T10:00:00.000Z", | |
| 71 | }, | |
| 72 | { | |
| 73 | id: "t-2", | |
| 74 | projectId: "p-missing", | |
| 75 | title: "Dangling thread", | |
| 76 | model: "gpt-5.3-codex", | |
| 77 | messages: [], | |
| 78 | createdAt: "2026-02-08T10:00:00.000Z", | |
| 79 | }, | |
| 80 | ], | |
| 81 | activeThreadId: "t-2", | |
| 82 | }); | |
| 83 | ||
| 84 | const hydrated = hydratePersistedState(payload, false); | |
| 85 | expect(hydrated).not.toBeNull(); | |
| 86 | expect(hydrated?.threads.map((thread) => thread.id)).toEqual(["t-1"]); | |
| 87 | expect(hydrated?.activeThreadId).toBe("t-1"); | |
| 88 | }); | |
| 89 | }); | |
| 90 | ||
| 91 | describe("toPersistedState", () => { | |
| 92 | it("writes v2 payload and strips non-persisted thread fields", () => { | |
| 93 | const thread: Thread = { | |
| 94 | id: "t-1", | |
| 95 | projectId: "p-1", | |
| 96 | title: "Thread", | |
| 97 | model: "gpt-5.3-codex", | |
| 98 | session: null, | |
| 99 | messages: [ | |
| 100 | { | |
| 101 | id: "m-1", | |
| 102 | role: "user", | |
| 103 | text: "Hi", | |
| 104 | createdAt: "2026-02-08T10:00:00.000Z", | |
| 105 | streaming: false, | |
| 106 | }, | |
| 107 | ], | |
| 108 | events: [], | |
| 109 | error: "boom", | |
| 110 | createdAt: "2026-02-08T10:00:00.000Z", | |
| 111 | }; | |
| 112 | ||
| 113 | const persisted = toPersistedState({ | |
| 114 | projects: [ | |
| 115 | { | |
| 116 | id: "p-1", | |
| 117 | name: "Project", | |
| 118 | cwd: "/tmp/project", | |
| 119 | model: "gpt-5.3-codex", | |
| 120 | expanded: true, | |
| 121 | }, | |
| 122 | ], | |
| 123 | threads: [thread], | |
| 124 | activeThreadId: "t-1", | |
| 125 | }); | |
| 126 | ||
| 127 | expect(persisted.version).toBe(2); | |
| 128 | expect(persisted.threads[0]).toEqual({ | |
| 129 | id: "t-1", | |
| 130 | projectId: "p-1", | |
| 131 | title: "Thread", | |
| 132 | model: "gpt-5.3-codex", | |
| 133 | messages: thread.messages, | |
| 134 | createdAt: thread.createdAt, | |
| 135 | }); | |
| 136 | const persistedThread = persisted.threads[0]; | |
| 137 | expect(persistedThread).toBeDefined(); | |
| 138 | if (!persistedThread) return; | |
| 139 | ||
| 140 | expect("error" in persistedThread).toBe(false); | |
| 141 | expect("session" in persistedThread).toBe(false); | |
| 142 | }); | |
| 143 | }); |
apps/renderer/src/persistenceSchema.ts created+156| ... | ... | @@ -0,0 +1,156 @@ |
| 1 | import { z } from "zod"; | |
| 2 | ||
| 3 | import { DEFAULT_MODEL, resolveModelSlug } from "./model-logic"; | |
| 4 | import type { Project, Thread } from "./types"; | |
| 5 | ||
| 6 | const LEGACY_DEFAULT_MODEL = "gpt-5.2-codex"; | |
| 7 | ||
| 8 | const persistedProjectSchema = z.object({ | |
| 9 | id: z.string().min(1), | |
| 10 | name: z.string().min(1), | |
| 11 | cwd: z.string().min(1), | |
| 12 | model: z.string().min(1), | |
| 13 | expanded: z.boolean(), | |
| 14 | }); | |
| 15 | ||
| 16 | const persistedMessageSchema = z.object({ | |
| 17 | id: z.string().min(1), | |
| 18 | role: z.enum(["user", "assistant"]), | |
| 19 | text: z.string(), | |
| 20 | createdAt: z.string().min(1), | |
| 21 | streaming: z.boolean(), | |
| 22 | }); | |
| 23 | ||
| 24 | const persistedThreadSchema = z.object({ | |
| 25 | id: z.string().min(1), | |
| 26 | projectId: z.string().min(1), | |
| 27 | title: z.string().min(1), | |
| 28 | model: z.string().min(1), | |
| 29 | messages: z.array(persistedMessageSchema), | |
| 30 | createdAt: z.string().min(1), | |
| 31 | }); | |
| 32 | ||
| 33 | const persistedStateBodySchema = z.object({ | |
| 34 | projects: z.array(persistedProjectSchema), | |
| 35 | threads: z.array(persistedThreadSchema), | |
| 36 | activeThreadId: z.string().min(1).nullable(), | |
| 37 | }); | |
| 38 | ||
| 39 | export const persistedStateV1Schema = persistedStateBodySchema.extend({ | |
| 40 | version: z.literal(1).optional(), | |
| 41 | }); | |
| 42 | ||
| 43 | export const persistedStateV2Schema = persistedStateBodySchema.extend({ | |
| 44 | version: z.literal(2).optional(), | |
| 45 | }); | |
| 46 | ||
| 47 | const persistedStateSchema = z.union([ | |
| 48 | persistedStateV2Schema, | |
| 49 | persistedStateV1Schema, | |
| 50 | ]); | |
| 51 | ||
| 52 | export interface PersistedStoreSnapshot { | |
| 53 | projects: Project[]; | |
| 54 | threads: Thread[]; | |
| 55 | activeThreadId: string | null; | |
| 56 | } | |
| 57 | ||
| 58 | function maybeMigrateLegacyModel( | |
| 59 | model: string, | |
| 60 | isLegacyPayload: boolean, | |
| 61 | ): string { | |
| 62 | if (!isLegacyPayload) { | |
| 63 | return model; | |
| 64 | } | |
| 65 | ||
| 66 | return model === LEGACY_DEFAULT_MODEL ? DEFAULT_MODEL : model; | |
| 67 | } | |
| 68 | ||
| 69 | function hydrateProject( | |
| 70 | project: z.infer<typeof persistedProjectSchema>, | |
| 71 | isLegacyPayload: boolean, | |
| 72 | ): Project { | |
| 73 | return { | |
| 74 | ...project, | |
| 75 | model: resolveModelSlug( | |
| 76 | maybeMigrateLegacyModel(project.model, isLegacyPayload), | |
| 77 | ), | |
| 78 | }; | |
| 79 | } | |
| 80 | ||
| 81 | function hydrateThread( | |
| 82 | thread: z.infer<typeof persistedThreadSchema>, | |
| 83 | isLegacyPayload: boolean, | |
| 84 | ): Thread { | |
| 85 | return { | |
| 86 | id: thread.id, | |
| 87 | projectId: thread.projectId, | |
| 88 | title: thread.title, | |
| 89 | model: resolveModelSlug( | |
| 90 | maybeMigrateLegacyModel(thread.model, isLegacyPayload), | |
| 91 | ), | |
| 92 | session: null, | |
| 93 | messages: thread.messages.map((message) => ({ | |
| 94 | ...message, | |
| 95 | streaming: false, | |
| 96 | })), | |
| 97 | events: [], | |
| 98 | error: null, | |
| 99 | createdAt: thread.createdAt, | |
| 100 | }; | |
| 101 | } | |
| 102 | ||
| 103 | export function hydratePersistedState( | |
| 104 | raw: string, | |
| 105 | isLegacyPayload: boolean, | |
| 106 | ): PersistedStoreSnapshot | null { | |
| 107 | let parsedJson: unknown; | |
| 108 | try { | |
| 109 | parsedJson = JSON.parse(raw); | |
| 110 | } catch { | |
| 111 | return null; | |
| 112 | } | |
| 113 | ||
| 114 | const parsedState = persistedStateSchema.safeParse(parsedJson); | |
| 115 | if (!parsedState.success) { | |
| 116 | return null; | |
| 117 | } | |
| 118 | ||
| 119 | const projects = parsedState.data.projects.map((project) => | |
| 120 | hydrateProject(project, isLegacyPayload), | |
| 121 | ); | |
| 122 | const projectIds = new Set(projects.map((project) => project.id)); | |
| 123 | const threads = parsedState.data.threads | |
| 124 | .map((thread) => hydrateThread(thread, isLegacyPayload)) | |
| 125 | .filter((thread) => projectIds.has(thread.projectId)); | |
| 126 | const hasActiveThread = Boolean( | |
| 127 | parsedState.data.activeThreadId && | |
| 128 | threads.some((thread) => thread.id === parsedState.data.activeThreadId), | |
| 129 | ); | |
| 130 | ||
| 131 | return { | |
| 132 | projects, | |
| 133 | threads, | |
| 134 | activeThreadId: hasActiveThread | |
| 135 | ? parsedState.data.activeThreadId | |
| 136 | : (threads[0]?.id ?? null), | |
| 137 | }; | |
| 138 | } | |
| 139 | ||
| 140 | export function toPersistedState( | |
| 141 | state: PersistedStoreSnapshot, | |
| 142 | ): z.infer<typeof persistedStateV2Schema> { | |
| 143 | return { | |
| 144 | version: 2, | |
| 145 | projects: state.projects, | |
| 146 | threads: state.threads.map((thread) => ({ | |
| 147 | id: thread.id, | |
| 148 | projectId: thread.projectId, | |
| 149 | title: thread.title, | |
| 150 | model: thread.model, | |
| 151 | messages: thread.messages, | |
| 152 | createdAt: thread.createdAt, | |
| 153 | })), | |
| 154 | activeThreadId: state.activeThreadId, | |
| 155 | }; | |
| 156 | } |
apps/renderer/src/store.ts+7-174| ... | ... | @@ -9,7 +9,8 @@ import { |
| 9 | 9 | } from "react"; |
| 10 | 10 | |
| 11 | 11 | import type { ProviderEvent, ProviderSession } from "@acme/contracts"; |
| 12 | import { DEFAULT_MODEL, resolveModelSlug } from "./model-logic"; | |
| 12 | import { resolveModelSlug } from "./model-logic"; | |
| 13 | import { hydratePersistedState, toPersistedState } from "./persistenceSchema"; | |
| 13 | 14 | import { applyEventToMessages, evolveSession } from "./session-logic"; |
| 14 | 15 | import type { Project, Thread } from "./types"; |
| 15 | 16 | |
| ... | ... | @@ -43,7 +44,6 @@ export interface AppState { |
| 43 | 44 | |
| 44 | 45 | const PERSISTED_STATE_KEY = "codething:renderer-state:v2"; |
| 45 | 46 | const LEGACY_PERSISTED_STATE_KEY = "codething:renderer-state:v1"; |
| 46 | const LEGACY_DEFAULT_MODEL = "gpt-5.2-codex"; | |
| 47 | 47 | |
| 48 | 48 | const initialState: AppState = { |
| 49 | 49 | projects: [], |
| ... | ... | @@ -54,130 +54,6 @@ const initialState: AppState = { |
| 54 | 54 | |
| 55 | 55 | // ── Helpers ────────────────────────────────────────────────────────── |
| 56 | 56 | |
| 57 | interface PersistedStateV1 { | |
| 58 | projects: Project[]; | |
| 59 | threads: Array< | |
| 60 | Pick< | |
| 61 | Thread, | |
| 62 | "id" | "projectId" | "title" | "model" | "messages" | "createdAt" | |
| 63 | > | |
| 64 | >; | |
| 65 | activeThreadId: string | null; | |
| 66 | } | |
| 67 | ||
| 68 | function asObject(value: unknown): Record<string, unknown> | null { | |
| 69 | if (!value || typeof value !== "object") return null; | |
| 70 | return value as Record<string, unknown>; | |
| 71 | } | |
| 72 | ||
| 73 | function asString(value: unknown): string | null { | |
| 74 | return typeof value === "string" ? value : null; | |
| 75 | } | |
| 76 | ||
| 77 | function isBoolean(value: unknown): value is boolean { | |
| 78 | return typeof value === "boolean"; | |
| 79 | } | |
| 80 | ||
| 81 | function sanitizeProjects(input: unknown): Project[] { | |
| 82 | if (!Array.isArray(input)) return []; | |
| 83 | ||
| 84 | return input | |
| 85 | .map((raw) => { | |
| 86 | const project = asObject(raw); | |
| 87 | if (!project) return null; | |
| 88 | ||
| 89 | const id = asString(project.id); | |
| 90 | const name = asString(project.name); | |
| 91 | const cwd = asString(project.cwd); | |
| 92 | const rawModel = asString(project.model); | |
| 93 | const model = resolveModelSlug(rawModel); | |
| 94 | const expanded = project.expanded; | |
| 95 | if (!id || !name || !cwd || !model || !isBoolean(expanded)) return null; | |
| 96 | ||
| 97 | return { | |
| 98 | id, | |
| 99 | name, | |
| 100 | cwd, | |
| 101 | model, | |
| 102 | expanded, | |
| 103 | } satisfies Project; | |
| 104 | }) | |
| 105 | .filter((project): project is Project => project !== null); | |
| 106 | } | |
| 107 | ||
| 108 | function migrateLegacyDefaultModel(model: string): string { | |
| 109 | return model === LEGACY_DEFAULT_MODEL ? DEFAULT_MODEL : model; | |
| 110 | } | |
| 111 | ||
| 112 | function sanitizeMessages(input: unknown): Thread["messages"] { | |
| 113 | if (!Array.isArray(input)) return []; | |
| 114 | ||
| 115 | return input | |
| 116 | .map((raw) => { | |
| 117 | const message = asObject(raw); | |
| 118 | if (!message) return null; | |
| 119 | ||
| 120 | const id = asString(message.id); | |
| 121 | const role = message.role; | |
| 122 | const text = asString(message.text); | |
| 123 | const createdAt = asString(message.createdAt); | |
| 124 | const streaming = message.streaming; | |
| 125 | if ( | |
| 126 | !id || | |
| 127 | (role !== "user" && role !== "assistant") || | |
| 128 | text === null || | |
| 129 | !createdAt || | |
| 130 | !isBoolean(streaming) | |
| 131 | ) { | |
| 132 | return null; | |
| 133 | } | |
| 134 | ||
| 135 | const hydratedMessage: Thread["messages"][number] = { | |
| 136 | id, | |
| 137 | role, | |
| 138 | text, | |
| 139 | createdAt, | |
| 140 | streaming: false, | |
| 141 | }; | |
| 142 | return hydratedMessage; | |
| 143 | }) | |
| 144 | .filter( | |
| 145 | (message): message is Thread["messages"][number] => message !== null, | |
| 146 | ); | |
| 147 | } | |
| 148 | ||
| 149 | function sanitizeThreads(input: unknown): AppState["threads"] { | |
| 150 | if (!Array.isArray(input)) return []; | |
| 151 | ||
| 152 | return input | |
| 153 | .map((raw) => { | |
| 154 | const thread = asObject(raw); | |
| 155 | if (!thread) return null; | |
| 156 | ||
| 157 | const id = asString(thread.id); | |
| 158 | const projectId = asString(thread.projectId); | |
| 159 | const title = asString(thread.title); | |
| 160 | const rawModel = asString(thread.model); | |
| 161 | const model = resolveModelSlug(rawModel); | |
| 162 | const createdAt = asString(thread.createdAt); | |
| 163 | if (!id || !projectId || !title || !model || !createdAt) return null; | |
| 164 | ||
| 165 | const hydratedThread: Thread = { | |
| 166 | id, | |
| 167 | projectId, | |
| 168 | title, | |
| 169 | model, | |
| 170 | session: null, | |
| 171 | messages: sanitizeMessages(thread.messages), | |
| 172 | events: [], | |
| 173 | error: null, | |
| 174 | createdAt, | |
| 175 | }; | |
| 176 | return hydratedThread; | |
| 177 | }) | |
| 178 | .filter((thread): thread is Thread => thread !== null); | |
| 179 | } | |
| 180 | ||
| 181 | 57 | function readPersistedState(): AppState { |
| 182 | 58 | if (typeof window === "undefined") return initialState; |
| 183 | 59 | |
| ... | ... | @@ -186,61 +62,18 @@ function readPersistedState(): AppState { |
| 186 | 62 | const rawLegacy = window.localStorage.getItem(LEGACY_PERSISTED_STATE_KEY); |
| 187 | 63 | const raw = rawCurrent ?? rawLegacy; |
| 188 | 64 | if (!raw) return initialState; |
| 189 | const isLegacyPayload = !rawCurrent && Boolean(rawLegacy); | |
| 190 | ||
| 191 | const parsed = asObject(JSON.parse(raw)); | |
| 192 | if (!parsed) return initialState; | |
| 193 | ||
| 194 | const projects = sanitizeProjects(parsed.projects).map((project) => ({ | |
| 195 | ...project, | |
| 196 | model: isLegacyPayload | |
| 197 | ? migrateLegacyDefaultModel(project.model) | |
| 198 | : project.model, | |
| 199 | })); | |
| 200 | const projectIds = new Set(projects.map((project) => project.id)); | |
| 201 | const threads = sanitizeThreads(parsed.threads) | |
| 202 | .map((thread) => ({ | |
| 203 | ...thread, | |
| 204 | model: isLegacyPayload | |
| 205 | ? migrateLegacyDefaultModel(thread.model) | |
| 206 | : thread.model, | |
| 207 | })) | |
| 208 | .filter((thread) => projectIds.has(thread.projectId)); | |
| 209 | const activeThreadId = asString(parsed.activeThreadId); | |
| 210 | const hasActiveThread = Boolean( | |
| 211 | activeThreadId && threads.some((thread) => thread.id === activeThreadId), | |
| 65 | const hydrated = hydratePersistedState( | |
| 66 | raw, | |
| 67 | !rawCurrent && Boolean(rawLegacy), | |
| 212 | 68 | ); |
| 69 | if (!hydrated) return initialState; | |
| 213 | 70 | |
| 214 | return { | |
| 215 | projects, | |
| 216 | threads, | |
| 217 | activeThreadId: hasActiveThread | |
| 218 | ? activeThreadId | |
| 219 | : threads[0]?.id | |
| 220 | ? threads[0].id | |
| 221 | : null, | |
| 222 | diffOpen: false, | |
| 223 | }; | |
| 71 | return { ...hydrated, diffOpen: false }; | |
| 224 | 72 | } catch { |
| 225 | 73 | return initialState; |
| 226 | 74 | } |
| 227 | 75 | } |
| 228 | 76 | |
| 229 | function toPersistedState(state: AppState): PersistedStateV1 { | |
| 230 | return { | |
| 231 | projects: state.projects, | |
| 232 | threads: state.threads.map((thread) => ({ | |
| 233 | id: thread.id, | |
| 234 | projectId: thread.projectId, | |
| 235 | title: thread.title, | |
| 236 | model: thread.model, | |
| 237 | messages: thread.messages, | |
| 238 | createdAt: thread.createdAt, | |
| 239 | })), | |
| 240 | activeThreadId: state.activeThreadId, | |
| 241 | }; | |
| 242 | } | |
| 243 | ||
| 244 | 77 | function persistState(state: AppState): void { |
| 245 | 78 | if (typeof window === "undefined") return; |
| 246 | 79 |
bun.lock+1| ... | ... | @@ -40,6 +40,7 @@ |
| 40 | 40 | "react-markdown": "^10.1.0", |
| 41 | 41 | "rehype-highlight": "^7.0.2", |
| 42 | 42 | "remark-gfm": "^4.0.1", |
| 43 | "zod": "^3.24.1", | |
| 43 | 44 | }, |
| 44 | 45 | "devDependencies": { |
| 45 | 46 | "@tailwindcss/vite": "^4.0.0", |