authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-02-08 15:46:00-08:00
committergravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-02-08 15:46:00-08:00
log153a130a09e9d2d71b9b1ff0c477c07b7bd81c9a
tree749da4a81efd2b0f44ea5b798c71fb85496500a4
parent2fc933652828b51f35b48579c86b71858224b10c
signature Commit is signed but in an unrecognized format.

more cleanup


10 files changed, 419 insertions(+), 67 deletions(-)

TODO.md+8-10
...@@ -1,11 +1,9 @@...@@ -1,11 +1,9 @@
1- [x] Folder picker for new projects1## TODO
2- [x] Projects should be persisted so they're still visible after closing
3- [x] Remove model selection from project creation
4- [x] First message vanishes when thread is created?? Awful ux
5- [x] Show more clearly when thinking is happening
6- [x] Enable react compiler
72
8## Bigger overhauls3- [ ] Fix sort order for threads (newest first)
94- [ ] Indicate in thread list which threads are currently working
10- [x] Markdown rendering5- [ ] Logs should be per-thread, not a single events.txt
11- [x] Code block rendering6- [ ] Audit the logs to identify which events are (and aren't) worth showing to the user, i.e. drop "preamble" and "work" events
7- [ ] Make the "end message" stand out from the work done prior (maybe just a bar between the two that says "worked for 10 minutes 31 seconds" or however long it was)
8- [ ] Better logging of "time to complete" (I want this more than the timestamps tbh)
9- [ ] Remove the left bars on messages to make UI feel "flatter"
apps/desktop/src/codexAppServerManager.ts+5-13
...@@ -10,6 +10,7 @@ import type {...@@ -10,6 +10,7 @@ import type {
10 ProviderSessionStartInput,10 ProviderSessionStartInput,
11 ProviderTurnStartResult,11 ProviderTurnStartResult,
12} from "@acme/contracts";12} from "@acme/contracts";
13import { normalizeModelSlug } from "@acme/contracts";
1314
14type PendingRequestKey = string;15type PendingRequestKey = string;
1516
...@@ -59,29 +60,20 @@ const BENIGN_ERROR_LOG_SNIPPETS = [...@@ -59,29 +60,20 @@ const BENIGN_ERROR_LOG_SNIPPETS = [
59 "state db missing rollout path for thread",60 "state db missing rollout path for thread",
60 "state db record_discrepancy: find_thread_path_by_id_str_in_subdir, falling_back",61 "state db record_discrepancy: find_thread_path_by_id_str_in_subdir, falling_back",
61];62];
62const MODEL_SLUG_ALIASES: Record<string, string> = {
63 "5.3": "gpt-5.3-codex",
64 "gpt-5.3": "gpt-5.3-codex",
65};
66
67export function normalizeCodexModelSlug(63export function normalizeCodexModelSlug(
68 model: string | undefined | null,64 model: string | undefined | null,
69 preferredId?: string,65 preferredId?: string,
70): string | undefined {66): string | undefined {
71 if (typeof model !== "string") {67 const normalized = normalizeModelSlug(model);
72 return undefined;68 if (!normalized) {
73 }
74
75 const trimmed = model.trim();
76 if (!trimmed) {
77 return undefined;69 return undefined;
78 }70 }
7971
80 if (preferredId?.endsWith("-codex") && preferredId !== trimmed) {72 if (preferredId?.endsWith("-codex") && preferredId !== normalized) {
81 return preferredId;73 return preferredId;
82 }74 }
8375
84 return MODEL_SLUG_ALIASES[trimmed] ?? trimmed;76 return normalized;
85}77}
8678
87export function classifyCodexStderrLine(79export function classifyCodexStderrLine(
apps/desktop/src/main.ts+1
...@@ -314,6 +314,7 @@ async function bootstrap(): Promise<void> {...@@ -314,6 +314,7 @@ async function bootstrap(): Promise<void> {
314app.on("before-quit", () => {314app.on("before-quit", () => {
315 processManager.killAll();315 processManager.killAll();
316 providerManager.stopAll();316 providerManager.stopAll();
317 providerManager.dispose();
317});318});
318319
319app.whenReady().then(() => {320app.whenReady().then(() => {
apps/desktop/src/providerManager.test.ts created+28
...@@ -0,0 +1,28 @@
1import { describe, expect, it } from "vitest";
2
3import { ProviderManager } from "./providerManager";
4
5describe("ProviderManager", () => {
6 it("detaches provider event listener and ends log stream on dispose", () => {
7 const manager = new ProviderManager();
8 const internals = manager as unknown as {
9 codex: { listenerCount: (event: string) => number };
10 logStream: { writableEnded: boolean; destroyed: boolean };
11 };
12
13 expect(internals.codex.listenerCount("event")).toBe(1);
14 manager.dispose();
15
16 expect(internals.codex.listenerCount("event")).toBe(0);
17 expect(
18 internals.logStream.writableEnded || internals.logStream.destroyed,
19 ).toBe(true);
20 });
21
22 it("allows multiple dispose calls", () => {
23 const manager = new ProviderManager();
24
25 manager.dispose();
26 expect(() => manager.dispose()).not.toThrow();
27 });
28});
apps/desktop/src/providerManager.ts+20-4
...@@ -24,6 +24,15 @@ export interface ProviderManagerEvents {...@@ -24,6 +24,15 @@ export interface ProviderManagerEvents {
24export class ProviderManager extends EventEmitter<ProviderManagerEvents> {24export class ProviderManager extends EventEmitter<ProviderManagerEvents> {
25 private readonly codex = new CodexAppServerManager();25 private readonly codex = new CodexAppServerManager();
26 private readonly logStream: fs.WriteStream;26 private readonly logStream: fs.WriteStream;
27 private disposed = false;
28 private readonly onCodexEvent = (event: ProviderEvent) => {
29 if (this.disposed) {
30 return;
31 }
32
33 this.logStream.write(`${JSON.stringify(event)}\n`);
34 this.emit("event", event);
35 };
2736
28 constructor() {37 constructor() {
29 super();38 super();
...@@ -34,10 +43,7 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> {...@@ -34,10 +43,7 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> {
34 flags: "a",43 flags: "a",
35 });44 });
3645
37 this.codex.on("event", (event) => {46 this.codex.on("event", this.onCodexEvent);
38 this.logStream.write(`${JSON.stringify(event)}\n`);
39 this.emit("event", event);
40 });
41 }47 }
4248
43 async startSession(raw: ProviderSessionStartInput): Promise<ProviderSession> {49 async startSession(raw: ProviderSessionStartInput): Promise<ProviderSession> {
...@@ -79,4 +85,14 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> {...@@ -79,4 +85,14 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> {
79 stopAll(): void {85 stopAll(): void {
80 this.codex.stopAll();86 this.codex.stopAll();
81 }87 }
88
89 dispose(): void {
90 if (this.disposed) {
91 return;
92 }
93
94 this.disposed = true;
95 this.codex.off("event", this.onCodexEvent);
96 this.logStream.end();
97 }
82}98}
apps/renderer/src/model-logic.ts+6-38
...@@ -1,41 +1,9 @@...@@ -1,41 +1,9 @@
1export const MODEL_OPTIONS = [1export {
2 "gpt-5.3-codex",2 DEFAULT_MODEL,
3 "gpt-5.2-codex",3 MODEL_OPTIONS,
4 "gpt-5.2",4 normalizeModelSlug,
5] as const;5 resolveModelSlug,
66} from "@acme/contracts";
7export const DEFAULT_MODEL = "gpt-5.3-codex";
87
9export const REASONING_OPTIONS = ["xhigh", "high", "medium", "low"] as const;8export const REASONING_OPTIONS = ["xhigh", "high", "medium", "low"] as const;
10export const DEFAULT_REASONING = "high";9export const DEFAULT_REASONING = "high";
11
12const MODEL_SLUG_ALIASES: Record<string, string> = {
13 "5.3": "gpt-5.3-codex",
14 "gpt-5.3": "gpt-5.3-codex",
15};
16
17export function normalizeModelSlug(
18 model: string | null | undefined,
19): string | null {
20 if (typeof model !== "string") {
21 return null;
22 }
23
24 const trimmed = model.trim();
25 if (!trimmed) {
26 return null;
27 }
28
29 return MODEL_SLUG_ALIASES[trimmed] ?? trimmed;
30}
31
32export function resolveModelSlug(model: string | null | undefined): string {
33 const normalized = normalizeModelSlug(model);
34 if (!normalized) {
35 return DEFAULT_MODEL;
36 }
37
38 return MODEL_OPTIONS.includes(normalized as (typeof MODEL_OPTIONS)[number])
39 ? normalized
40 : DEFAULT_MODEL;
41}
apps/renderer/src/session-logic.test.ts+272-2
...@@ -1,8 +1,39 @@...@@ -1,8 +1,39 @@
1import { describe, expect, it } from "vitest";1import { describe, expect, it } from "vitest";
2import type { WorkLogEntry } from "./session-logic";2
3import { deriveTimelineEntries } from "./session-logic";3import type { ProviderEvent, ProviderSession } from "@acme/contracts";
4import {
5 type WorkLogEntry,
6 applyEventToMessages,
7 deriveTimelineEntries,
8 evolveSession,
9} from "./session-logic";
4import type { ChatMessage } from "./types";10import type { ChatMessage } from "./types";
511
12function makeEvent(overrides: Partial<ProviderEvent>): ProviderEvent {
13 return {
14 id: "evt-1",
15 kind: "notification",
16 provider: "codex",
17 sessionId: "sess-1",
18 createdAt: "2026-02-08T10:00:00.000Z",
19 method: "noop",
20 ...overrides,
21 };
22}
23
24function makeSession(
25 overrides: Partial<ProviderSession> = {},
26): ProviderSession {
27 return {
28 sessionId: "sess-1",
29 provider: "codex",
30 status: "ready",
31 createdAt: "2026-02-08T09:59:00.000Z",
32 updatedAt: "2026-02-08T09:59:00.000Z",
33 ...overrides,
34 };
35}
36
6describe("deriveTimelineEntries", () => {37describe("deriveTimelineEntries", () => {
7 it("interleaves messages and work entries by timestamp", () => {38 it("interleaves messages and work entries by timestamp", () => {
8 const messages: ChatMessage[] = [39 const messages: ChatMessage[] = [
...@@ -73,3 +104,242 @@ describe("deriveTimelineEntries", () => {...@@ -73,3 +104,242 @@ describe("deriveTimelineEntries", () => {
73 ]);104 ]);
74 });105 });
75});106});
107
108describe("evolveSession", () => {
109 it("updates thread id when thread starts", () => {
110 const previous = makeSession();
111 const next = evolveSession(
112 previous,
113 makeEvent({
114 method: "thread/started",
115 createdAt: "2026-02-08T10:01:00.000Z",
116 payload: { thread: { id: "thread-1" } },
117 }),
118 );
119
120 expect(next.threadId).toBe("thread-1");
121 expect(next.updatedAt).toBe("2026-02-08T10:01:00.000Z");
122 });
123
124 it("moves to running and records active turn on turn start", () => {
125 const previous = makeSession();
126 const next = evolveSession(
127 previous,
128 makeEvent({
129 method: "turn/started",
130 turnId: "turn-1",
131 createdAt: "2026-02-08T10:02:00.000Z",
132 }),
133 );
134
135 expect(next.status).toBe("running");
136 expect(next.activeTurnId).toBe("turn-1");
137 expect(next.updatedAt).toBe("2026-02-08T10:02:00.000Z");
138 });
139
140 it("returns to ready and clears active turn on successful completion", () => {
141 const previous = makeSession({
142 status: "running",
143 activeTurnId: "turn-1",
144 lastError: "older error",
145 });
146 const next = evolveSession(
147 previous,
148 makeEvent({
149 method: "turn/completed",
150 createdAt: "2026-02-08T10:03:00.000Z",
151 payload: { turn: { id: "turn-1", status: "completed" } },
152 }),
153 );
154
155 expect(next.status).toBe("ready");
156 expect(next.activeTurnId).toBeUndefined();
157 expect(next.lastError).toBe("older error");
158 });
159
160 it("marks session as error when turn fails", () => {
161 const previous = makeSession({
162 status: "running",
163 activeTurnId: "turn-1",
164 });
165 const next = evolveSession(
166 previous,
167 makeEvent({
168 method: "turn/completed",
169 createdAt: "2026-02-08T10:03:00.000Z",
170 payload: {
171 turn: { id: "turn-1", status: "failed", error: { message: "boom" } },
172 },
173 }),
174 );
175
176 expect(next.status).toBe("error");
177 expect(next.activeTurnId).toBeUndefined();
178 expect(next.lastError).toBe("boom");
179 });
180
181 it("moves to error on runtime error events", () => {
182 const previous = makeSession();
183 const next = evolveSession(
184 previous,
185 makeEvent({
186 kind: "error",
187 method: "runtime/error",
188 createdAt: "2026-02-08T10:04:00.000Z",
189 message: "runtime failure",
190 }),
191 );
192
193 expect(next.status).toBe("error");
194 expect(next.lastError).toBe("runtime failure");
195 });
196
197 it("closes session on close lifecycle events", () => {
198 const previous = makeSession({
199 status: "running",
200 activeTurnId: "turn-1",
201 });
202 const next = evolveSession(
203 previous,
204 makeEvent({
205 method: "session/closed",
206 createdAt: "2026-02-08T10:05:00.000Z",
207 message: "closed",
208 }),
209 );
210
211 expect(next.status).toBe("closed");
212 expect(next.activeTurnId).toBeUndefined();
213 expect(next.lastError).toBe("closed");
214 });
215});
216
217describe("applyEventToMessages", () => {
218 it("handles start/delta/completed flow for assistant messages", () => {
219 const activeAssistantItemRef = { current: null as string | null };
220 const started = applyEventToMessages(
221 [],
222 makeEvent({
223 method: "item/started",
224 createdAt: "2026-02-08T10:00:00.000Z",
225 payload: {
226 item: {
227 id: "item-1",
228 type: "agentMessage",
229 text: "Hello",
230 },
231 },
232 }),
233 activeAssistantItemRef,
234 );
235
236 expect(started).toEqual([
237 {
238 id: "item-1",
239 role: "assistant",
240 text: "Hello",
241 createdAt: "2026-02-08T10:00:00.000Z",
242 streaming: true,
243 },
244 ]);
245 expect(activeAssistantItemRef.current).toBe("item-1");
246
247 const withDelta = applyEventToMessages(
248 started,
249 makeEvent({
250 method: "item/agentMessage/delta",
251 createdAt: "2026-02-08T10:00:01.000Z",
252 itemId: "item-1",
253 textDelta: " world",
254 }),
255 activeAssistantItemRef,
256 );
257 expect(withDelta[0]?.text).toBe("Hello world");
258 expect(withDelta[0]?.streaming).toBe(true);
259
260 const completed = applyEventToMessages(
261 withDelta,
262 makeEvent({
263 method: "item/completed",
264 createdAt: "2026-02-08T10:00:02.000Z",
265 payload: {
266 item: {
267 id: "item-1",
268 type: "agentMessage",
269 text: "Hello world!",
270 },
271 },
272 }),
273 activeAssistantItemRef,
274 );
275 expect(completed[0]?.text).toBe("Hello world!");
276 expect(completed[0]?.streaming).toBe(false);
277 expect(activeAssistantItemRef.current).toBeNull();
278 });
279
280 it("supports out-of-order delta before started/completed", () => {
281 const activeAssistantItemRef = { current: null as string | null };
282 const withDelta = applyEventToMessages(
283 [],
284 makeEvent({
285 method: "item/agentMessage/delta",
286 createdAt: "2026-02-08T10:00:01.000Z",
287 itemId: "item-2",
288 textDelta: "Partial",
289 }),
290 activeAssistantItemRef,
291 );
292 expect(withDelta[0]?.id).toBe("item-2");
293 expect(withDelta[0]?.text).toBe("Partial");
294 expect(withDelta[0]?.streaming).toBe(true);
295 expect(activeAssistantItemRef.current).toBe("item-2");
296
297 const completed = applyEventToMessages(
298 withDelta,
299 makeEvent({
300 method: "item/completed",
301 createdAt: "2026-02-08T10:00:02.000Z",
302 payload: {
303 item: {
304 id: "item-2",
305 type: "agentMessage",
306 text: "Partial + final",
307 },
308 },
309 }),
310 activeAssistantItemRef,
311 );
312
313 expect(completed[0]?.text).toBe("Partial + final");
314 expect(completed[0]?.streaming).toBe(false);
315 });
316
317 it("clears all streaming flags when turn completes", () => {
318 const previous: ChatMessage[] = [
319 {
320 id: "m-user",
321 role: "user",
322 text: "Hi",
323 createdAt: "2026-02-08T10:00:00.000Z",
324 streaming: false,
325 },
326 {
327 id: "m-assistant",
328 role: "assistant",
329 text: "Typing",
330 createdAt: "2026-02-08T10:00:01.000Z",
331 streaming: true,
332 },
333 ];
334
335 const next = applyEventToMessages(
336 previous,
337 makeEvent({
338 method: "turn/completed",
339 }),
340 { current: null },
341 );
342
343 expect(next.every((entry) => entry.streaming === false)).toBe(true);
344 });
345});
packages/contracts/src/index.ts+1
...@@ -3,3 +3,4 @@ export * from "./ipc";...@@ -3,3 +3,4 @@ export * from "./ipc";
3export * from "./agent";3export * from "./agent";
4export * from "./terminal";4export * from "./terminal";
5export * from "./provider";5export * from "./provider";
6export * from "./model";
packages/contracts/src/model.test.ts created+40
...@@ -0,0 +1,40 @@
1import { describe, expect, it } from "vitest";
2
3import {
4 DEFAULT_MODEL,
5 MODEL_OPTIONS,
6 normalizeModelSlug,
7 resolveModelSlug,
8} from "./model";
9
10describe("normalizeModelSlug", () => {
11 it("maps known aliases to canonical slugs", () => {
12 expect(normalizeModelSlug("5.3")).toBe("gpt-5.3-codex");
13 expect(normalizeModelSlug("gpt-5.3")).toBe("gpt-5.3-codex");
14 });
15
16 it("returns null for empty or missing values", () => {
17 expect(normalizeModelSlug("")).toBeNull();
18 expect(normalizeModelSlug(" ")).toBeNull();
19 expect(normalizeModelSlug(null)).toBeNull();
20 expect(normalizeModelSlug(undefined)).toBeNull();
21 });
22
23 it("preserves non-aliased model slugs", () => {
24 expect(normalizeModelSlug("gpt-5.2")).toBe("gpt-5.2");
25 expect(normalizeModelSlug("gpt-5.2-codex")).toBe("gpt-5.2-codex");
26 });
27});
28
29describe("resolveModelSlug", () => {
30 it("returns default for missing and unknown models", () => {
31 expect(resolveModelSlug(undefined)).toBe(DEFAULT_MODEL);
32 expect(resolveModelSlug("gpt-4.1")).toBe(DEFAULT_MODEL);
33 });
34
35 it("resolves only supported model options", () => {
36 for (const model of MODEL_OPTIONS) {
37 expect(resolveModelSlug(model)).toBe(model);
38 }
39 });
40});
packages/contracts/src/model.ts created+38
...@@ -0,0 +1,38 @@
1export const MODEL_OPTIONS = [
2 "gpt-5.3-codex",
3 "gpt-5.2-codex",
4 "gpt-5.2",
5] as const;
6
7export const DEFAULT_MODEL = "gpt-5.3-codex";
8
9export const MODEL_SLUG_ALIASES: Record<string, string> = {
10 "5.3": "gpt-5.3-codex",
11 "gpt-5.3": "gpt-5.3-codex",
12};
13
14export function normalizeModelSlug(
15 model: string | null | undefined,
16): string | null {
17 if (typeof model !== "string") {
18 return null;
19 }
20
21 const trimmed = model.trim();
22 if (!trimmed) {
23 return null;
24 }
25
26 return MODEL_SLUG_ALIASES[trimmed] ?? trimmed;
27}
28
29export function resolveModelSlug(model: string | null | undefined): string {
30 const normalized = normalizeModelSlug(model);
31 if (!normalized) {
32 return DEFAULT_MODEL;
33 }
34
35 return MODEL_OPTIONS.includes(normalized as (typeof MODEL_OPTIONS)[number])
36 ? normalized
37 : DEFAULT_MODEL;
38}