authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-02-08 15:04:13-08:00
committergravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-02-08 15:04:13-08:00
log2fc933652828b51f35b48579c86b71858224b10c
tree3a5ffd6d88a9c95ffdbfb80e3b0d9efcbc150300
parent87483c3a95e1c0a6c86e636671eeea442216d771
signature Commit is signed but in an unrecognized format.

more cleanup


14 files changed, 572 insertions(+), 227 deletions(-)

.github/workflows/ci.yml created+42
...@@ -0,0 +1,42 @@
1name: CI
2
3on:
4 pull_request:
5 push:
6 branches:
7 - main
8
9jobs:
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,6 +33,10 @@ CodeThing is a desktop shell for coding agents. This first implementation is:
33- `bun run typecheck`: strict TypeScript checks for all packages.33- `bun run typecheck`: strict TypeScript checks for all packages.
34- `bun run test`: runs workspace tests.34- `bun run test`: runs workspace tests.
3535
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
36Optional:40Optional:
37- `ELECTRON_RENDERER_PORT=5180 bun run dev` if `5173` is already in use.41- `ELECTRON_RENDERER_PORT=5180 bun run dev` if `5173` is already in use.
3842
apps/desktop/src/codexAppServerManager.ts+1-5
...@@ -77,11 +77,7 @@ export function normalizeCodexModelSlug(...@@ -77,11 +77,7 @@ export function normalizeCodexModelSlug(
77 return undefined;77 return undefined;
78 }78 }
7979
80 if (80 if (preferredId?.endsWith("-codex") && preferredId !== trimmed) {
81 preferredId &&
82 preferredId.endsWith("-codex") &&
83 preferredId !== trimmed
84 ) {
85 return preferredId;81 return preferredId;
86 }82 }
8783
apps/desktop/src/ipcHelpers.test.ts created+106
...@@ -0,0 +1,106 @@
1import { describe, expect, it, vi } from "vitest";
2
3import { withParsedArgs, withParsedPayload } from "./ipcHelpers";
4
5describe("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
56describe("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 @@
1type MaybePromise<T> = T | Promise<T>;
2type Parser<T> = {
3 parse: (value: unknown) => T;
4};
5type ArgsParser<T extends unknown[]> = {
6 parse: (value: unknown[]) => T;
7};
8
9export 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
16export 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,9 +12,14 @@ import {
12 agentConfigSchema,12 agentConfigSchema,
13 agentSessionIdSchema,13 agentSessionIdSchema,
14 newTodoInputSchema,14 newTodoInputSchema,
15 providerInterruptTurnInputSchema,
16 providerSendTurnInputSchema,
17 providerSessionStartInputSchema,
18 providerStopSessionInputSchema,
15 terminalCommandInputSchema,19 terminalCommandInputSchema,
16 todoIdSchema,20 todoIdSchema,
17} from "@acme/contracts";21} from "@acme/contracts";
22import { withParsedArgs, withParsedPayload } from "./ipcHelpers";
18import { ProcessManager } from "./processManager";23import { ProcessManager } from "./processManager";
19import { ProviderManager } from "./providerManager";24import { ProviderManager } from "./providerManager";
20import { TodoStore } from "./todoStore";25import { TodoStore } from "./todoStore";
...@@ -24,6 +29,16 @@ const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL);...@@ -24,6 +29,16 @@ const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL);
24let todoStore: TodoStore;29let todoStore: TodoStore;
25const processManager = new ProcessManager();30const processManager = new ProcessManager();
26const providerManager = new ProviderManager();31const providerManager = new ProviderManager();
32const 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};
2742
28function createWindow(): BrowserWindow {43function createWindow(): BrowserWindow {
29 const window = new BrowserWindow({44 const window = new BrowserWindow({
...@@ -66,17 +81,26 @@ function registerIpcHandlers(): void {...@@ -66,17 +81,26 @@ function registerIpcHandlers(): void {
66 return todoStore.list();81 return todoStore.list();
67 });82 });
6883
69 ipcMain.handle(IPC_CHANNELS.todosAdd, async (_event, payload: unknown) => {84 ipcMain.handle(
70 return todoStore.add(newTodoInputSchema.parse(payload));85 IPC_CHANNELS.todosAdd,
71 });86 withParsedPayload(newTodoInputSchema, async (_event, payload) => {
87 return todoStore.add(payload);
88 }),
89 );
7290
73 ipcMain.handle(IPC_CHANNELS.todosToggle, async (_event, id: unknown) => {91 ipcMain.handle(
74 return todoStore.toggle(todoIdSchema.parse(id));92 IPC_CHANNELS.todosToggle,
75 });93 withParsedPayload(todoIdSchema, async (_event, id) => {
94 return todoStore.toggle(id);
95 }),
96 );
7697
77 ipcMain.handle(IPC_CHANNELS.todosRemove, async (_event, id: unknown) => {98 ipcMain.handle(
78 return todoStore.remove(todoIdSchema.parse(id));99 IPC_CHANNELS.todosRemove,
79 });100 withParsedPayload(todoIdSchema, async (_event, id) => {
101 return todoStore.remove(id);
102 }),
103 );
80104
81 ipcMain.handle(IPC_CHANNELS.dialogPickFolder, async () => {105 ipcMain.handle(IPC_CHANNELS.dialogPickFolder, async () => {
82 const owner =106 const owner =
...@@ -94,61 +118,71 @@ function registerIpcHandlers(): void {...@@ -94,61 +118,71 @@ function registerIpcHandlers(): void {
94 });118 });
95119
96 // Terminal handlers120 // Terminal handlers
97 ipcMain.handle(IPC_CHANNELS.terminalRun, async (_event, payload: unknown) => {121 ipcMain.handle(
98 return runTerminalCommand(terminalCommandInputSchema.parse(payload));122 IPC_CHANNELS.terminalRun,
99 });123 withParsedPayload(terminalCommandInputSchema, async (_event, payload) => {
124 return runTerminalCommand(payload);
125 }),
126 );
100127
101 // Agent handlers128 // Agent handlers
102 ipcMain.handle(IPC_CHANNELS.agentSpawn, async (_event, config: unknown) => {129 ipcMain.handle(
103 return processManager.spawn(agentConfigSchema.parse(config));130 IPC_CHANNELS.agentSpawn,
104 });131 withParsedPayload(agentConfigSchema, async (_event, config) => {
132 return processManager.spawn(config);
133 }),
134 );
105135
106 ipcMain.handle(IPC_CHANNELS.agentKill, async (_event, sessionId: unknown) => {136 ipcMain.handle(
107 processManager.kill(agentSessionIdSchema.parse(sessionId));137 IPC_CHANNELS.agentKill,
108 });138 withParsedPayload(agentSessionIdSchema, async (_event, sessionId) => {
139 processManager.kill(sessionId);
140 }),
141 );
109142
110 ipcMain.handle(143 ipcMain.handle(
111 IPC_CHANNELS.agentWrite,144 IPC_CHANNELS.agentWrite,
112 async (_event, sessionId: unknown, data: unknown) => {145 withParsedArgs(agentWriteArgsParser, async (_event, sessionId, data) => {
113 processManager.write(agentSessionIdSchema.parse(sessionId), String(data));146 processManager.write(sessionId, data);
114 },147 }),
115 );148 );
116149
117 // Provider handlers150 // Provider handlers
118 ipcMain.handle(151 ipcMain.handle(
119 IPC_CHANNELS.providerSessionStart,152 IPC_CHANNELS.providerSessionStart,
120 async (_event, payload: unknown) => {153 withParsedPayload(
121 return providerManager.startSession(154 providerSessionStartInputSchema,
122 payload as Parameters<typeof providerManager.startSession>[0],155 async (_event, payload) => {
123 );156 return providerManager.startSession(payload);
124 },157 },
158 ),
125 );159 );
126160
127 ipcMain.handle(161 ipcMain.handle(
128 IPC_CHANNELS.providerTurnStart,162 IPC_CHANNELS.providerTurnStart,
129 async (_event, payload: unknown) => {163 withParsedPayload(providerSendTurnInputSchema, async (_event, payload) => {
130 return providerManager.sendTurn(164 return providerManager.sendTurn(payload);
131 payload as Parameters<typeof providerManager.sendTurn>[0],165 }),
132 );
133 },
134 );166 );
135167
136 ipcMain.handle(168 ipcMain.handle(
137 IPC_CHANNELS.providerTurnInterrupt,169 IPC_CHANNELS.providerTurnInterrupt,
138 async (_event, payload: unknown) => {170 withParsedPayload(
139 await providerManager.interruptTurn(171 providerInterruptTurnInputSchema,
140 payload as Parameters<typeof providerManager.interruptTurn>[0],172 async (_event, payload) => {
141 );173 await providerManager.interruptTurn(payload);
142 },174 },
175 ),
143 );176 );
144177
145 ipcMain.handle(178 ipcMain.handle(
146 IPC_CHANNELS.providerSessionStop,179 IPC_CHANNELS.providerSessionStop,
147 async (_event, payload: unknown) => {180 withParsedPayload(
148 providerManager.stopSession(181 providerStopSessionInputSchema,
149 payload as Parameters<typeof providerManager.stopSession>[0],182 async (_event, payload) => {
150 );183 providerManager.stopSession(payload);
151 },184 },
185 ),
152 );186 );
153187
154 ipcMain.handle(IPC_CHANNELS.providerSessionList, async () => {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,10 +30,9 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> {
3030
31 const logsDir = path.resolve(process.cwd(), ".logs");31 const logsDir = path.resolve(process.cwd(), ".logs");
32 fs.mkdirSync(logsDir, { recursive: true });32 fs.mkdirSync(logsDir, { recursive: true });
33 this.logStream = fs.createWriteStream(33 this.logStream = fs.createWriteStream(path.join(logsDir, "events.txt"), {
34 path.join(logsDir, "events.txt"),34 flags: "a",
35 { flags: "a" },35 });
36 );
3736
38 this.codex.on("event", (event) => {37 this.codex.on("event", (event) => {
39 this.logStream.write(`${JSON.stringify(event)}\n`);38 this.logStream.write(`${JSON.stringify(event)}\n`);
apps/renderer/package.json+2-1
...@@ -18,7 +18,8 @@...@@ -18,7 +18,8 @@
18 "react": "^19.0.0",18 "react": "^19.0.0",
19 "react-dom": "^19.0.0",19 "react-dom": "^19.0.0",
20 "rehype-highlight": "^7.0.2",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 "devDependencies": {24 "devDependencies": {
24 "@tailwindcss/vite": "^4.0.0",25 "@tailwindcss/vite": "^4.0.0",
apps/renderer/src/model-logic.test.ts+5-1
...@@ -1,5 +1,9 @@...@@ -1,5 +1,9 @@
1import { describe, expect, it } from "vitest";1import { describe, expect, it } from "vitest";
2import { DEFAULT_MODEL, normalizeModelSlug, resolveModelSlug } from "./model-logic";2import {
3 DEFAULT_MODEL,
4 normalizeModelSlug,
5 resolveModelSlug,
6} from "./model-logic";
37
4describe("normalizeModelSlug", () => {8describe("normalizeModelSlug", () => {
5 it("maps 5.3 aliases to canonical codex slug", () => {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,7 +14,9 @@ const MODEL_SLUG_ALIASES: Record<string, string> = {
14 "gpt-5.3": "gpt-5.3-codex",14 "gpt-5.3": "gpt-5.3-codex",
15};15};
1616
17export function normalizeModelSlug(model: string | null | undefined): string | null {17export function normalizeModelSlug(
18 model: string | null | undefined,
19): string | null {
18 if (typeof model !== "string") {20 if (typeof model !== "string") {
19 return null;21 return null;
20 }22 }
apps/renderer/src/persistenceSchema.test.ts created+143
...@@ -0,0 +1,143 @@
1import { describe, expect, it } from "vitest";
2
3import { DEFAULT_MODEL } from "./model-logic";
4import { hydratePersistedState, toPersistedState } from "./persistenceSchema";
5import type { Thread } from "./types";
6
7describe("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
91describe("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 @@
1import { z } from "zod";
2
3import { DEFAULT_MODEL, resolveModelSlug } from "./model-logic";
4import type { Project, Thread } from "./types";
5
6const LEGACY_DEFAULT_MODEL = "gpt-5.2-codex";
7
8const 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
16const 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
24const 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
33const persistedStateBodySchema = z.object({
34 projects: z.array(persistedProjectSchema),
35 threads: z.array(persistedThreadSchema),
36 activeThreadId: z.string().min(1).nullable(),
37});
38
39export const persistedStateV1Schema = persistedStateBodySchema.extend({
40 version: z.literal(1).optional(),
41});
42
43export const persistedStateV2Schema = persistedStateBodySchema.extend({
44 version: z.literal(2).optional(),
45});
46
47const persistedStateSchema = z.union([
48 persistedStateV2Schema,
49 persistedStateV1Schema,
50]);
51
52export interface PersistedStoreSnapshot {
53 projects: Project[];
54 threads: Thread[];
55 activeThreadId: string | null;
56}
57
58function 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
69function 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
81function 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
103export 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
140export 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,7 +9,8 @@ import {
9} from "react";9} from "react";
1010
11import type { ProviderEvent, ProviderSession } from "@acme/contracts";11import type { ProviderEvent, ProviderSession } from "@acme/contracts";
12import { DEFAULT_MODEL, resolveModelSlug } from "./model-logic";12import { resolveModelSlug } from "./model-logic";
13import { hydratePersistedState, toPersistedState } from "./persistenceSchema";
13import { applyEventToMessages, evolveSession } from "./session-logic";14import { applyEventToMessages, evolveSession } from "./session-logic";
14import type { Project, Thread } from "./types";15import type { Project, Thread } from "./types";
1516
...@@ -43,7 +44,6 @@ export interface AppState {...@@ -43,7 +44,6 @@ export interface AppState {
4344
44const PERSISTED_STATE_KEY = "codething:renderer-state:v2";45const PERSISTED_STATE_KEY = "codething:renderer-state:v2";
45const LEGACY_PERSISTED_STATE_KEY = "codething:renderer-state:v1";46const LEGACY_PERSISTED_STATE_KEY = "codething:renderer-state:v1";
46const LEGACY_DEFAULT_MODEL = "gpt-5.2-codex";
4747
48const initialState: AppState = {48const initialState: AppState = {
49 projects: [],49 projects: [],
...@@ -54,130 +54,6 @@ const initialState: AppState = {...@@ -54,130 +54,6 @@ const initialState: AppState = {
5454
55// ── Helpers ──────────────────────────────────────────────────────────55// ── Helpers ──────────────────────────────────────────────────────────
5656
57interface 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
68function asObject(value: unknown): Record<string, unknown> | null {
69 if (!value || typeof value !== "object") return null;
70 return value as Record<string, unknown>;
71}
72
73function asString(value: unknown): string | null {
74 return typeof value === "string" ? value : null;
75}
76
77function isBoolean(value: unknown): value is boolean {
78 return typeof value === "boolean";
79}
80
81function 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
108function migrateLegacyDefaultModel(model: string): string {
109 return model === LEGACY_DEFAULT_MODEL ? DEFAULT_MODEL : model;
110}
111
112function 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
149function 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
181function readPersistedState(): AppState {57function readPersistedState(): AppState {
182 if (typeof window === "undefined") return initialState;58 if (typeof window === "undefined") return initialState;
18359
...@@ -186,61 +62,18 @@ function readPersistedState(): AppState {...@@ -186,61 +62,18 @@ function readPersistedState(): AppState {
186 const rawLegacy = window.localStorage.getItem(LEGACY_PERSISTED_STATE_KEY);62 const rawLegacy = window.localStorage.getItem(LEGACY_PERSISTED_STATE_KEY);
187 const raw = rawCurrent ?? rawLegacy;63 const raw = rawCurrent ?? rawLegacy;
188 if (!raw) return initialState;64 if (!raw) return initialState;
189 const isLegacyPayload = !rawCurrent && Boolean(rawLegacy);65 const hydrated = hydratePersistedState(
19066 raw,
191 const parsed = asObject(JSON.parse(raw));67 !rawCurrent && Boolean(rawLegacy),
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),
212 );68 );
69 if (!hydrated) return initialState;
21370
214 return {71 return { ...hydrated, diffOpen: false };
215 projects,
216 threads,
217 activeThreadId: hasActiveThread
218 ? activeThreadId
219 : threads[0]?.id
220 ? threads[0].id
221 : null,
222 diffOpen: false,
223 };
224 } catch {72 } catch {
225 return initialState;73 return initialState;
226 }74 }
227}75}
22876
229function 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
244function persistState(state: AppState): void {77function persistState(state: AppState): void {
245 if (typeof window === "undefined") return;78 if (typeof window === "undefined") return;
24679
bun.lock+1
...@@ -40,6 +40,7 @@...@@ -40,6 +40,7 @@
40 "react-markdown": "^10.1.0",40 "react-markdown": "^10.1.0",
41 "rehype-highlight": "^7.0.2",41 "rehype-highlight": "^7.0.2",
42 "remark-gfm": "^4.0.1",42 "remark-gfm": "^4.0.1",
43 "zod": "^3.24.1",
43 },44 },
44 "devDependencies": {45 "devDependencies": {
45 "@tailwindcss/vite": "^4.0.0",46 "@tailwindcss/vite": "^4.0.0",