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:
3333- `bun run typecheck`: strict TypeScript checks for all packages.
3434- `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
3640Optional:
3741- `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(
7777 return undefined;
7878 }
7979
80 if (
81 preferredId &&
82 preferredId.endsWith("-codex") &&
83 preferredId !== trimmed
84 ) {
80 if (preferredId?.endsWith("-codex") && preferredId !== trimmed) {
8581 return preferredId;
8682 }
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 {
1212 agentConfigSchema,
1313 agentSessionIdSchema,
1414 newTodoInputSchema,
15 providerInterruptTurnInputSchema,
16 providerSendTurnInputSchema,
17 providerSessionStartInputSchema,
18 providerStopSessionInputSchema,
1519 terminalCommandInputSchema,
1620 todoIdSchema,
1721} from "@acme/contracts";
22import { withParsedArgs, withParsedPayload } from "./ipcHelpers";
1823import { ProcessManager } from "./processManager";
1924import { ProviderManager } from "./providerManager";
2025import { TodoStore } from "./todoStore";
......@@ -24,6 +29,16 @@ const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL);
2429let todoStore: TodoStore;
2530const processManager = new ProcessManager();
2631const 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
2843function createWindow(): BrowserWindow {
2944 const window = new BrowserWindow({
......@@ -66,17 +81,26 @@ function registerIpcHandlers(): void {
6681 return todoStore.list();
6782 });
6883
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 );
7290
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 );
7697
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 );
80104
81105 ipcMain.handle(IPC_CHANNELS.dialogPickFolder, async () => {
82106 const owner =
......@@ -94,61 +118,71 @@ function registerIpcHandlers(): void {
94118 });
95119
96120 // 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 );
100127
101128 // 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 );
105135
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 );
109142
110143 ipcMain.handle(
111144 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 }),
115148 );
116149
117150 // Provider handlers
118151 ipcMain.handle(
119152 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 ),
125159 );
126160
127161 ipcMain.handle(
128162 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 }),
134166 );
135167
136168 ipcMain.handle(
137169 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 ),
143176 );
144177
145178 ipcMain.handle(
146179 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 ),
152186 );
153187
154188 ipcMain.handle(IPC_CHANNELS.providerSessionList, async () => {
apps/desktop/src/providerManager.ts+3-4
......@@ -30,10 +30,9 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> {
3030
3131 const logsDir = path.resolve(process.cwd(), ".logs");
3232 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 });
3736
3837 this.codex.on("event", (event) => {
3938 this.logStream.write(`${JSON.stringify(event)}\n`);
apps/renderer/package.json+2-1
......@@ -18,7 +18,8 @@
1818 "react": "^19.0.0",
1919 "react-dom": "^19.0.0",
2020 "rehype-highlight": "^7.0.2",
21 "remark-gfm": "^4.0.1"
21 "remark-gfm": "^4.0.1",
22 "zod": "^3.24.1"
2223 },
2324 "devDependencies": {
2425 "@tailwindcss/vite": "^4.0.0",
apps/renderer/src/model-logic.test.ts+5-1
......@@ -1,5 +1,9 @@
11import { 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
48describe("normalizeModelSlug", () => {
59 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> = {
1414 "gpt-5.3": "gpt-5.3-codex",
1515};
1616
17export function normalizeModelSlug(model: string | null | undefined): string | null {
17export function normalizeModelSlug(
18 model: string | null | undefined,
19): string | null {
1820 if (typeof model !== "string") {
1921 return null;
2022 }
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 {
99} from "react";
1010
1111import type { ProviderEvent, ProviderSession } from "@acme/contracts";
12import { DEFAULT_MODEL, resolveModelSlug } from "./model-logic";
12import { resolveModelSlug } from "./model-logic";
13import { hydratePersistedState, toPersistedState } from "./persistenceSchema";
1314import { applyEventToMessages, evolveSession } from "./session-logic";
1415import type { Project, Thread } from "./types";
1516
......@@ -43,7 +44,6 @@ export interface AppState {
4344
4445const PERSISTED_STATE_KEY = "codething:renderer-state:v2";
4546const LEGACY_PERSISTED_STATE_KEY = "codething:renderer-state:v1";
46const LEGACY_DEFAULT_MODEL = "gpt-5.2-codex";
4747
4848const initialState: AppState = {
4949 projects: [],
......@@ -54,130 +54,6 @@ const initialState: AppState = {
5454
5555// ── 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
18157function readPersistedState(): AppState {
18258 if (typeof window === "undefined") return initialState;
18359
......@@ -186,61 +62,18 @@ function readPersistedState(): AppState {
18662 const rawLegacy = window.localStorage.getItem(LEGACY_PERSISTED_STATE_KEY);
18763 const raw = rawCurrent ?? rawLegacy;
18864 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),
21268 );
69 if (!hydrated) return initialState;
21370
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 };
22472 } catch {
22573 return initialState;
22674 }
22775}
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
24477function persistState(state: AppState): void {
24578 if (typeof window === "undefined") return;
24679
bun.lock+1
......@@ -40,6 +40,7 @@
4040 "react-markdown": "^10.1.0",
4141 "rehype-highlight": "^7.0.2",
4242 "remark-gfm": "^4.0.1",
43 "zod": "^3.24.1",
4344 },
4445 "devDependencies": {
4546 "@tailwindcss/vite": "^4.0.0",