authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-02-08 00:15:07-08:00
committergravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-02-08 00:15:07-08:00
logfdb654de45d8bcac6bfee3d79199fb9bdcde844d
treedde4839a5ee64e7941ba836e4b4677d28bfb7a13
parent733c4633bfafd9b96208f8f39480b5f7ccb49677
signature Commit is signed but in an unrecognized format.

progress


16 files changed, 695 insertions(+), 458 deletions(-)

.agents/skills/btca-cli/SKILL.md created+20
......@@ -0,0 +1,20 @@
1---
2name: btca-cli
3description: btca is a cli for getting information from other git repositories by querying them with natural language. Spawns an external subagent to explore external codebases (i.e. a github repo) and answer questions about them. Great when integrating complex libraries, frameworks, or tools.
4---
5
6btca is a cli for asking questions about various folders, primarily git repositories. It's built to give you the exact information you need without bloating your context window. btca uses agents under the hood to answer your question, so prompt it the way you would prompt a tool similar to yourself.
7
8For example, assuming you have a resource named `codex`, you can ask questions like:
9
10```bash
11btca ask -r codex -q "What is the return format for Codex App Server responses? Are there any edge cases worth knowing?"
12```
13
14Your prompt may include specific names for resources to use, if they do then you should try those first. If you need to check available resources, you can use the `btca resources` command.
15
16New resources can be created if the ones you need do not yet exist.
17
18```bash
19btca add -n svelte-dev https://github.com/sveltejs/svelte.dev
20```
.agents/skills/btca-cli/agents/openai.yaml created+3
......@@ -0,0 +1,3 @@
1interface:
2 display_name: "BTCA CLI"
3 short_description: "Help with BTCA CLI setup and usage workflows"
apps/desktop/src/codexAppServerManager.test.ts+22-1
......@@ -1,6 +1,9 @@
11import { describe, expect, it } from "vitest";
22
3import { classifyCodexStderrLine } from "./codexAppServerManager";
3import {
4 classifyCodexStderrLine,
5 normalizeCodexModelSlug,
6} from "./codexAppServerManager";
47
58describe("classifyCodexStderrLine", () => {
69 it("ignores empty lines", () => {
......@@ -34,3 +37,21 @@ describe("classifyCodexStderrLine", () => {
3437 });
3538 });
3639});
40
41describe("normalizeCodexModelSlug", () => {
42 it("maps 5.3 aliases to gpt-5.3-codex", () => {
43 expect(normalizeCodexModelSlug("5.3")).toBe("gpt-5.3-codex");
44 expect(normalizeCodexModelSlug("gpt-5.3")).toBe("gpt-5.3-codex");
45 });
46
47 it("prefers codex id when model differs", () => {
48 expect(normalizeCodexModelSlug("gpt-5.3", "gpt-5.3-codex")).toBe(
49 "gpt-5.3-codex",
50 );
51 });
52
53 it("keeps non-aliased models as-is", () => {
54 expect(normalizeCodexModelSlug("gpt-5.2-codex")).toBe("gpt-5.2-codex");
55 expect(normalizeCodexModelSlug("gpt-5.2")).toBe("gpt-5.2");
56 });
57});
apps/desktop/src/codexAppServerManager.ts+37-236
......@@ -5,7 +5,6 @@ import readline from "node:readline";
55
66import type {
77 ProviderEvent,
8 ProviderModel,
98 ProviderSendTurnInput,
109 ProviderSession,
1110 ProviderSessionStartInput,
......@@ -60,6 +59,34 @@ const BENIGN_ERROR_LOG_SNIPPETS = [
6059 "state db missing rollout path for thread",
6160 "state db record_discrepancy: find_thread_path_by_id_str_in_subdir, falling_back",
6261];
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(
68 model: string | undefined | null,
69 preferredId?: string,
70): string | undefined {
71 if (typeof model !== "string") {
72 return undefined;
73 }
74
75 const trimmed = model.trim();
76 if (!trimmed) {
77 return undefined;
78 }
79
80 if (
81 preferredId &&
82 preferredId.endsWith("-codex") &&
83 preferredId !== trimmed
84 ) {
85 return preferredId;
86 }
87
88 return MODEL_SLUG_ALIASES[trimmed] ?? trimmed;
89}
6390
6491export function classifyCodexStderrLine(
6592 rawLine: string,
......@@ -104,7 +131,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
104131 sessionId,
105132 provider: "codex",
106133 status: "connecting",
107 model: input.model,
134 model: normalizeCodexModelSlug(input.model),
108135 cwd: input.cwd,
109136 createdAt: now,
110137 updatedAt: now,
......@@ -150,7 +177,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
150177 this.writeMessage(context, { method: "initialized" });
151178
152179 const threadStart = await this.sendRequest(context, "thread/start", {
153 model: input.model ?? null,
180 model: null,
154181 cwd: input.cwd ?? null,
155182 approvalPolicy: input.approvalPolicy,
156183 sandbox: input.sandboxMode,
......@@ -202,6 +229,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
202229 threadId: string;
203230 input: Array<{ type: "text"; text: string; text_elements: [] }>;
204231 model?: string;
232 effort?: string;
205233 } = {
206234 threadId: context.session.threadId,
207235 input: [
......@@ -212,8 +240,12 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
212240 },
213241 ],
214242 };
215 if (input.model) {
216 turnStartParams.model = input.model;
243 const normalizedModel = normalizeCodexModelSlug(input.model);
244 if (normalizedModel) {
245 turnStartParams.model = normalizedModel;
246 }
247 if (input.effort) {
248 turnStartParams.effort = input.effort;
217249 }
218250
219251 const response = await this.sendRequest(
......@@ -239,177 +271,6 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
239271 };
240272 }
241273
242 async listModels(cwd?: string): Promise<ProviderModel[]> {
243 const child = spawn("codex", ["app-server"], {
244 cwd,
245 env: process.env,
246 stdio: ["pipe", "pipe", "pipe"],
247 });
248 const output = readline.createInterface({ input: child.stdout });
249 const pending = new Map<PendingRequestKey, PendingRequest>();
250 let nextRequestId = 1;
251 let exited = false;
252 let exitError: Error | null = null;
253
254 const cleanup = () => {
255 for (const request of pending.values()) {
256 clearTimeout(request.timeout);
257 }
258 pending.clear();
259 output.close();
260 if (!child.killed) {
261 child.kill();
262 }
263 };
264
265 const writeMessage = (message: unknown) => {
266 if (!child.stdin.writable) {
267 throw new Error("Cannot write to codex app-server stdin.");
268 }
269 child.stdin.write(`${JSON.stringify(message)}\n`);
270 };
271
272 const sendRequest = async <TResponse>(
273 method: string,
274 params: unknown,
275 timeoutMs = 20_000,
276 ): Promise<TResponse> => {
277 if (exited) {
278 throw (
279 exitError ??
280 new Error("codex app-server exited before request could be sent.")
281 );
282 }
283
284 const id = nextRequestId;
285 nextRequestId += 1;
286
287 const result = await new Promise<unknown>((resolve, reject) => {
288 const timeout = setTimeout(() => {
289 pending.delete(String(id));
290 reject(new Error(`Timed out waiting for ${method}.`));
291 }, timeoutMs);
292
293 pending.set(String(id), {
294 timeout,
295 resolve,
296 reject,
297 method,
298 });
299 writeMessage({ id, method, params });
300 });
301
302 return result as TResponse;
303 };
304
305 output.on("line", (line) => {
306 let parsed: unknown;
307 try {
308 parsed = JSON.parse(line);
309 } catch {
310 return;
311 }
312
313 if (this.isResponse(parsed)) {
314 const key = String(parsed.id);
315 const request = pending.get(key);
316 if (!request) {
317 return;
318 }
319
320 clearTimeout(request.timeout);
321 pending.delete(key);
322
323 if (parsed.error?.message) {
324 request.reject(
325 new Error(
326 `${request.method} failed: ${String(parsed.error.message)}`,
327 ),
328 );
329 return;
330 }
331
332 request.resolve(parsed.result);
333 return;
334 }
335
336 if (this.isServerRequest(parsed)) {
337 writeMessage({
338 id: parsed.id,
339 error: {
340 code: -32601,
341 message: `Unsupported server request: ${parsed.method}`,
342 },
343 });
344 }
345 });
346
347 child.on("error", (error) => {
348 exited = true;
349 exitError = error;
350 for (const request of pending.values()) {
351 clearTimeout(request.timeout);
352 request.reject(error);
353 }
354 pending.clear();
355 });
356
357 child.on("exit", (code, signal) => {
358 exited = true;
359 if (!exitError && code !== 0) {
360 exitError = new Error(
361 `codex app-server exited (code=${code ?? "null"}, signal=${signal ?? "null"}).`,
362 );
363 }
364 for (const request of pending.values()) {
365 clearTimeout(request.timeout);
366 request.reject(
367 exitError ??
368 new Error("codex app-server exited before request completed."),
369 );
370 }
371 pending.clear();
372 });
373
374 try {
375 await sendRequest("initialize", {
376 clientInfo: {
377 name: "codething_desktop",
378 title: "CodeThing Desktop",
379 version: "0.1.0",
380 },
381 capabilities: {
382 experimentalApi: false,
383 },
384 });
385
386 writeMessage({ method: "initialized" });
387
388 const deduped = new Map<string, ProviderModel>();
389 let cursor: string | null = null;
390
391 while (true) {
392 const response = await sendRequest<unknown>("model/list", {
393 cursor,
394 limit: 100,
395 });
396 const parsed = this.parseModelListResponse(response);
397 for (const model of parsed.models) {
398 deduped.set(model.model, model);
399 }
400
401 if (!parsed.nextCursor) {
402 break;
403 }
404 cursor = parsed.nextCursor;
405 }
406
407 return Array.from(deduped.values());
408 } finally {
409 cleanup();
410 }
411 }
412
413274 async interruptTurn(sessionId: string, turnId?: string): Promise<void> {
414275 const context = this.requireSession(sessionId);
415276 const effectiveTurnId = turnId ?? context.session.activeTurnId;
......@@ -807,66 +668,6 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
807668 };
808669 }
809670
810 private parseModelListResponse(response: unknown): {
811 models: ProviderModel[];
812 nextCursor: string | null;
813 } {
814 const object = this.readObject(response);
815 if (!object) {
816 throw new Error("model/list response was not an object.");
817 }
818
819 const data = object.data;
820 if (!Array.isArray(data)) {
821 throw new Error("model/list response missing data array.");
822 }
823
824 const models: ProviderModel[] = [];
825 for (const entry of data) {
826 const item = this.readObject(entry);
827 if (!item) {
828 continue;
829 }
830
831 const model = this.readString(item, "model");
832 if (!model) {
833 continue;
834 }
835
836 const id = this.readString(item, "id") ?? model;
837 const displayName = this.readString(item, "displayName") ?? model;
838 const description = this.readString(item, "description");
839 const isDefault =
840 typeof item.isDefault === "boolean" ? item.isDefault : undefined;
841 const rawUpgrade = item.upgrade;
842 const upgrade =
843 typeof rawUpgrade === "string"
844 ? rawUpgrade
845 : rawUpgrade === null
846 ? null
847 : undefined;
848
849 models.push({
850 id,
851 model,
852 displayName,
853 ...(description ? { description } : {}),
854 ...(isDefault === undefined ? {} : { isDefault }),
855 ...(upgrade === undefined ? {} : { upgrade }),
856 });
857 }
858
859 const rawCursor = object.nextCursor;
860 const nextCursor =
861 typeof rawCursor === "string"
862 ? rawCursor
863 : rawCursor === null || rawCursor === undefined
864 ? null
865 : null;
866
867 return { models, nextCursor };
868 }
869
870671 private isServerRequest(value: unknown): value is JsonRpcRequest {
871672 if (!value || typeof value !== "object") {
872673 return false;
apps/desktop/src/main.ts-9
......@@ -139,15 +139,6 @@ function registerIpcHandlers(): void {
139139 ipcMain.handle(IPC_CHANNELS.providerSessionList, async () => {
140140 return providerManager.listSessions();
141141 });
142
143 ipcMain.handle(
144 IPC_CHANNELS.providerModelList,
145 async (_event, payload: unknown) => {
146 return providerManager.listModels(
147 payload as Parameters<typeof providerManager.listModels>[0],
148 );
149 },
150 );
151142}
152143
153144async function runTerminalCommand(
apps/desktop/src/preload.ts-2
......@@ -41,8 +41,6 @@ const nativeApi: NativeApi = {
4141 stopSession: (input) =>
4242 ipcRenderer.invoke(IPC_CHANNELS.providerSessionStop, input),
4343 listSessions: () => ipcRenderer.invoke(IPC_CHANNELS.providerSessionList),
44 listModels: (input) =>
45 ipcRenderer.invoke(IPC_CHANNELS.providerModelList, input),
4644 onEvent: (callback) => {
4745 const listener = (_event: Electron.IpcRendererEvent, payload: unknown) =>
4846 callback(payload as Parameters<typeof callback>[0]);
apps/desktop/src/providerManager.ts-14
......@@ -3,15 +3,12 @@ import { EventEmitter } from "node:events";
33import {
44 type ProviderEvent,
55 type ProviderInterruptTurnInput,
6 type ProviderListModelsInput,
7 type ProviderModel,
86 type ProviderSendTurnInput,
97 type ProviderSession,
108 type ProviderSessionStartInput,
119 type ProviderStopSessionInput,
1210 type ProviderTurnStartResult,
1311 providerInterruptTurnInputSchema,
14 providerListModelsInputSchema,
1512 providerSendTurnInputSchema,
1613 providerSessionStartInputSchema,
1714 providerStopSessionInputSchema,
......@@ -66,17 +63,6 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> {
6663 return this.codex.listSessions();
6764 }
6865
69 async listModels(
70 raw: ProviderListModelsInput = {},
71 ): Promise<ProviderModel[]> {
72 const input = providerListModelsInputSchema.parse(raw);
73 if (input.provider !== "codex") {
74 throw new Error(`Provider '${input.provider}' is not implemented yet.`);
75 }
76
77 return this.codex.listModels(input.cwd);
78 }
79
8066 stopAll(): void {
8167 this.codex.stopAll();
8268 }
apps/renderer/src/components/ChatView.tsx+236-148
......@@ -1,4 +1,3 @@
1import type { ProviderModel } from "@acme/contracts";
21import {
32 type FormEvent,
43 type KeyboardEvent,
......@@ -8,10 +7,40 @@ import {
87 useState,
98} from "react";
109
11import { derivePhase, formatTimestamp, readNativeApi } from "../session-logic";
10import {
11 derivePhase,
12 deriveWorkLogEntries,
13 formatElapsed,
14 formatTimestamp,
15 readNativeApi,
16} from "../session-logic";
17import {
18 DEFAULT_MODEL,
19 DEFAULT_REASONING,
20 MODEL_OPTIONS,
21 REASONING_OPTIONS,
22 resolveModelSlug,
23} from "../model-logic";
1224import { useStore } from "../store";
1325
14const DEFAULT_MODEL = "gpt-5.2-codex";
26function formatMessageMeta(createdAt: string, duration: string | null): string {
27 if (!duration) return formatTimestamp(createdAt);
28 return `${formatTimestamp(createdAt)} • ${duration}`;
29}
30
31function statusLabel(phase: string): string {
32 if (phase === "running") return "Thinking / working";
33 if (phase === "connecting") return "Connecting";
34 if (phase === "ready") return "Ready";
35 return "Disconnected";
36}
37
38function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string {
39 if (tone === "thinking") return "text-sky-100";
40 if (tone === "tool") return "text-emerald-100";
41 if (tone === "error") return "text-rose-100";
42 return "text-[#d8d8d8]";
43}
1544
1645export default function ChatView() {
1746 const { state, dispatch } = useStore();
......@@ -20,9 +49,10 @@ export default function ChatView() {
2049 const [isSending, setIsSending] = useState(false);
2150 const [isConnecting, setIsConnecting] = useState(false);
2251 const [isModelMenuOpen, setIsModelMenuOpen] = useState(false);
23 const [isLoadingModels, setIsLoadingModels] = useState(false);
24 const [modelLoadError, setModelLoadError] = useState<string | null>(null);
25 const [availableModels, setAvailableModels] = useState<ProviderModel[]>([]);
52 const [selectedEffort, setSelectedEffort] = useState<string>(
53 DEFAULT_REASONING,
54 );
55 const [nowTick, setNowTick] = useState(() => Date.now());
2656 const messagesEndRef = useRef<HTMLDivElement>(null);
2757 const textareaRef = useRef<HTMLTextAreaElement>(null);
2858 const modelMenuRef = useRef<HTMLDivElement>(null);
......@@ -31,31 +61,42 @@ export default function ChatView() {
3161 const activeProject = state.projects.find(
3262 (p) => p.id === activeThread?.projectId,
3363 );
34 const selectedModel =
35 activeThread?.model || activeProject?.model || DEFAULT_MODEL;
64 const selectedModel = resolveModelSlug(
65 activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL,
66 );
3667 const phase = derivePhase(activeThread?.session ?? null);
37 const modelOptions = useMemo(() => {
38 const deduped = new Map<string, ProviderModel>();
39 for (const model of availableModels) {
40 deduped.set(model.model, model);
41 }
42 if (!deduped.has(selectedModel)) {
43 deduped.set(selectedModel, {
44 id: selectedModel,
45 model: selectedModel,
46 displayName: selectedModel,
47 description: "Current model for this thread.",
48 });
68 const isWorking = phase === "running" || isSending || isConnecting;
69 const activeTurnId = activeThread?.session?.activeTurnId;
70 const nowIso = new Date(nowTick).toISOString();
71 const modelOptions = MODEL_OPTIONS;
72 const workLogEntries = useMemo(
73 () => deriveWorkLogEntries(activeThread?.events ?? [], activeTurnId),
74 [activeThread?.events, activeTurnId],
75 );
76 const assistantCompletionByItemId = useMemo(() => {
77 const map = new Map<string, string>();
78 const ordered = [...(activeThread?.events ?? [])].reverse();
79 for (const event of ordered) {
80 if (event.method !== "item/completed") continue;
81 if (!event.itemId) continue;
82 map.set(event.itemId, event.createdAt);
4983 }
50 return Array.from(deduped.values());
51 }, [availableModels, selectedModel]);
84 return map;
85 }, [activeThread?.events]);
86 const recentWorkLogEntries = workLogEntries.slice(-12);
5287
5388 // Auto-scroll on new messages
5489 const messageCount = activeThread?.messages.length ?? 0;
90 const workLogCount = recentWorkLogEntries.length;
5591 // biome-ignore lint/correctness/useExhaustiveDependencies: trigger on message count change
5692 useEffect(() => {
5793 messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
5894 }, [messageCount]);
95 // biome-ignore lint/correctness/useExhaustiveDependencies: auto-scroll while active work-log events stream in
96 useEffect(() => {
97 if (phase !== "running") return;
98 messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
99 }, [phase, workLogCount]);
59100
60101 // Auto-resize textarea
61102 // biome-ignore lint/correctness/useExhaustiveDependencies: trigger on prompt change
......@@ -66,6 +107,16 @@ export default function ChatView() {
66107 ta.style.height = `${Math.min(ta.scrollHeight, 200)}px`;
67108 }, [prompt]);
68109
110 useEffect(() => {
111 if (phase !== "running") return;
112 const timer = window.setInterval(() => {
113 setNowTick(Date.now());
114 }, 250);
115 return () => {
116 window.clearInterval(timer);
117 };
118 }, [phase]);
119
69120 useEffect(() => {
70121 if (!isModelMenuOpen) return;
71122
......@@ -85,48 +136,6 @@ export default function ChatView() {
85136 };
86137 }, [isModelMenuOpen]);
87138
88 useEffect(() => {
89 if (!api || !state.activeThreadId) return;
90
91 let cancelled = false;
92
93 const loadModels = async () => {
94 setIsLoadingModels(true);
95 setModelLoadError(null);
96 try {
97 const models = await api.providers.listModels(
98 activeProject?.cwd ? { cwd: activeProject.cwd } : undefined,
99 );
100 if (cancelled) return;
101
102 const sorted = [...models].sort((a, b) => {
103 if ((a.isDefault ?? false) && !(b.isDefault ?? false)) return -1;
104 if ((b.isDefault ?? false) && !(a.isDefault ?? false)) return 1;
105 return a.displayName.localeCompare(b.displayName);
106 });
107 setAvailableModels(sorted);
108 } catch (err) {
109 if (cancelled) return;
110 setAvailableModels([]);
111 setModelLoadError(
112 err instanceof Error
113 ? err.message
114 : "Could not load available models from Codex.",
115 );
116 } finally {
117 if (!cancelled) {
118 setIsLoadingModels(false);
119 }
120 }
121 };
122
123 void loadModels();
124
125 return () => {
126 cancelled = true;
127 };
128 }, [api, activeProject?.cwd, state.activeThreadId]);
129
130139 const ensureSession = async (): Promise<string | null> => {
131140 if (!api || !activeThread || !activeProject) return null;
132141 if (activeThread.session && activeThread.session.status !== "closed") {
......@@ -197,6 +206,7 @@ export default function ChatView() {
197206 sessionId,
198207 input: trimmed,
199208 model: selectedModel || undefined,
209 effort: selectedEffort || undefined,
200210 });
201211 } catch (err) {
202212 dispatch({
......@@ -222,7 +232,7 @@ export default function ChatView() {
222232 dispatch({
223233 type: "SET_THREAD_MODEL",
224234 threadId: activeThread.id,
225 model,
235 model: resolveModelSlug(model),
226236 });
227237 setIsModelMenuOpen(false);
228238 };
......@@ -266,19 +276,38 @@ export default function ChatView() {
266276 </div>
267277 <div className="flex items-center gap-3">
268278 {/* Status indicator */}
269 <div className="flex items-center gap-1.5">
270 <div
271 className={`h-2 w-2 rounded-full ${
272 phase === "running"
273 ? "animate-pulse bg-emerald-400"
279 <div
280 className={`inline-flex items-center gap-2 rounded-full border px-2.5 py-1 text-[10px] ${
281 phase === "running"
282 ? "border-sky-400/35 bg-sky-500/[0.08] text-sky-100"
283 : phase === "connecting"
284 ? "border-amber-400/35 bg-amber-500/[0.08] text-amber-100"
274285 : phase === "ready"
275 ? "bg-emerald-400"
286 ? "border-emerald-400/35 bg-emerald-500/[0.08] text-emerald-100"
287 : "border-white/[0.08] bg-white/[0.04] text-[#a0a0a0]/70"
288 }`}
289 >
290 <span className="relative inline-flex h-2.5 w-2.5">
291 {(phase === "running" || phase === "connecting") && (
292 <span
293 className={`absolute inline-flex h-full w-full animate-ping rounded-full ${
294 phase === "running" ? "bg-sky-300/70" : "bg-amber-300/70"
295 }`}
296 />
297 )}
298 <span
299 className={`relative inline-flex h-2.5 w-2.5 rounded-full ${
300 phase === "running"
301 ? "bg-sky-200"
276302 : phase === "connecting"
277 ? "animate-pulse bg-amber-400"
278 : "bg-[#a0a0a0]/30"
279 }`}
280 />
281 <span className="text-[10px] text-[#a0a0a0]/50">{phase}</span>
303 ? "bg-amber-200"
304 : phase === "ready"
305 ? "bg-emerald-200"
306 : "bg-[#a0a0a0]/40"
307 }`}
308 />
309 </span>
310 <span>{statusLabel(phase)}</span>
282311 </div>
283312 {/* Diff toggle */}
284313 <button
......@@ -304,7 +333,7 @@ export default function ChatView() {
304333
305334 {/* Messages */}
306335 <div className="flex-1 overflow-y-auto px-5 py-4">
307 {activeThread.messages.length === 0 ? (
336 {activeThread.messages.length === 0 && !isWorking ? (
308337 <div className="flex h-full items-center justify-center">
309338 <p className="text-sm text-[#a0a0a0]/30">
310339 Send a message to start the conversation.
......@@ -312,6 +341,53 @@ export default function ChatView() {
312341 </div>
313342 ) : (
314343 <div className="mx-auto max-w-3xl space-y-4">
344 {isWorking && (
345 <div className="rounded-2xl border border-sky-400/20 bg-sky-500/[0.06] px-3 py-2.5">
346 <div className="flex items-center justify-between gap-2">
347 <div className="inline-flex items-center gap-2 text-xs text-sky-100">
348 <span className="relative inline-flex h-2.5 w-2.5">
349 <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-sky-300/70" />
350 <span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-sky-200" />
351 </span>
352 <span>Model is working</span>
353 </div>
354 <span className="text-[10px] text-sky-100/70">
355 {recentWorkLogEntries.length} event
356 {recentWorkLogEntries.length === 1 ? "" : "s"}
357 </span>
358 </div>
359 {recentWorkLogEntries.length === 0 ? (
360 <p className="pt-2 text-[11px] text-sky-100/70">
361 Waiting for tool/preamble updates...
362 </p>
363 ) : (
364 <div className="mt-2 max-h-48 space-y-1.5 overflow-y-auto pr-1">
365 {recentWorkLogEntries.map((entry) => (
366 <div
367 key={entry.id}
368 className="rounded-lg border border-white/[0.08] bg-black/20 px-2 py-1.5"
369 >
370 <div className="flex items-start justify-between gap-2">
371 <p
372 className={`text-[11px] ${workToneClass(entry.tone)}`}
373 >
374 {entry.label}
375 </p>
376 <span className="shrink-0 text-[10px] text-[#a0a0a0]/60">
377 {formatTimestamp(entry.createdAt)}
378 </span>
379 </div>
380 {entry.detail && (
381 <p className="pt-0.5 font-mono text-[11px] text-[#d0d0d0]/75">
382 {entry.detail}
383 </p>
384 )}
385 </div>
386 ))}
387 </div>
388 )}
389 </div>
390 )}
315391 {activeThread.messages.map((msg) => (
316392 <div key={msg.id}>
317393 {msg.role === "user" ? (
......@@ -331,17 +407,28 @@ export default function ChatView() {
331407 {msg.text || (msg.streaming ? "" : "(empty response)")}
332408 </pre>
333409 {msg.streaming && (
334 <span className="inline-flex gap-1 pt-1">
335 <span className="h-1.5 w-1.5 animate-pulse rounded-full bg-white/50" />
336 <span className="h-1.5 w-1.5 animate-pulse rounded-full bg-white/50 [animation-delay:150ms]" />
337 <span className="h-1.5 w-1.5 animate-pulse rounded-full bg-white/50 [animation-delay:300ms]" />
338 </span>
339 )}
340 {!msg.streaming && (
341 <p className="mt-1.5 text-[10px] text-[#a0a0a0]/30">
342 {formatTimestamp(msg.createdAt)}
343 </p>
410 <div className="pt-1.5">
411 <span className="inline-flex items-center gap-2 rounded-full border border-sky-400/25 bg-sky-500/[0.08] px-2 py-0.5 text-[10px] text-sky-100/90">
412 <span className="inline-flex gap-1">
413 <span className="h-1.5 w-1.5 animate-pulse rounded-full bg-sky-100/80" />
414 <span className="h-1.5 w-1.5 animate-pulse rounded-full bg-sky-100/80 [animation-delay:150ms]" />
415 <span className="h-1.5 w-1.5 animate-pulse rounded-full bg-sky-100/80 [animation-delay:300ms]" />
416 </span>
417 <span>Thinking</span>
418 </span>
419 </div>
344420 )}
421 <p className="mt-1.5 text-[10px] text-[#a0a0a0]/30">
422 {formatMessageMeta(
423 msg.createdAt,
424 msg.streaming
425 ? formatElapsed(msg.createdAt, nowIso)
426 : formatElapsed(
427 msg.createdAt,
428 assistantCompletionByItemId.get(msg.id),
429 ),
430 )}
431 </p>
345432 </div>
346433 )}
347434 </div>
......@@ -373,70 +460,71 @@ export default function ChatView() {
373460 disabled={isSending || isConnecting}
374461 />
375462 <div className="mt-2 flex items-center justify-between gap-2">
376 <div className="relative" ref={modelMenuRef}>
377 <button
378 type="button"
379 className="inline-flex items-center gap-2 rounded-full border border-white/[0.1] bg-white/[0.04] px-3 py-1.5 text-xs text-[#d8d8d8] transition-colors duration-150 hover:bg-white/[0.08]"
380 onClick={() => setIsModelMenuOpen((open) => !open)}
381 >
382 <span className="max-w-[180px] truncate">
383 {selectedModel}
384 </span>
385 <span className="text-[10px] text-[#a0a0a0]/70">▼</span>
386 </button>
387 {isModelMenuOpen && (
388 <div className="absolute bottom-full left-0 z-20 mb-2 w-[340px] rounded-2xl border border-white/[0.1] bg-[#1b1b1d]/95 p-2 shadow-[0_16px_40px_rgba(0,0,0,0.55)] backdrop-blur">
389 <p className="px-2 py-1 text-[11px] text-[#a0a0a0]/70">
390 Select model
391 </p>
392 <div className="max-h-72 overflow-y-auto">
393 {modelOptions.map((model) => {
394 const isSelected = model.model === selectedModel;
395 return (
396 <button
397 key={model.id}
398 type="button"
399 className={`mb-0.5 flex w-full items-start justify-between gap-2 rounded-xl px-2 py-2 text-left transition-colors duration-150 ${
400 isSelected
401 ? "bg-white/[0.08] text-white"
402 : "text-[#d4d4d4] hover:bg-white/[0.05]"
403 }`}
404 onClick={() => onModelSelect(model.model)}
405 >
406 <span className="min-w-0">
407 <span className="block truncate text-sm">
408 {model.displayName}
409 </span>
410 {model.description && (
411 <span className="block pt-0.5 text-[10px] text-[#a0a0a0]/65">
412 {model.description}
413 </span>
414 )}
415 </span>
416 <span
417 className={`pt-0.5 text-sm ${
418 isSelected ? "text-white" : "text-transparent"
463 <div className="flex items-center gap-2">
464 <div className="relative" ref={modelMenuRef}>
465 <button
466 type="button"
467 className="inline-flex items-center gap-2 rounded-full border border-white/[0.1] bg-white/[0.04] px-3 py-1.5 text-xs text-[#d8d8d8] transition-colors duration-150 hover:bg-white/[0.08]"
468 onClick={() => setIsModelMenuOpen((open) => !open)}
469 >
470 <span className="max-w-[180px] truncate font-mono">
471 {selectedModel}
472 </span>
473 <span className="text-[10px] text-[#a0a0a0]/70">▼</span>
474 </button>
475 {isModelMenuOpen && (
476 <div className="absolute bottom-full left-0 z-20 mb-2 w-[320px] rounded-2xl border border-white/[0.1] bg-[#1b1b1d]/95 p-2 shadow-[0_16px_40px_rgba(0,0,0,0.55)] backdrop-blur">
477 <p className="px-2 py-1 text-[11px] text-[#a0a0a0]/70">
478 Select model
479 </p>
480 <div className="max-h-72 overflow-y-auto">
481 {modelOptions.map((model) => {
482 const isSelected = model === selectedModel;
483 return (
484 <button
485 key={model}
486 type="button"
487 className={`mb-0.5 flex w-full items-center justify-between gap-2 rounded-xl px-2 py-2 text-left font-mono text-sm transition-colors duration-150 ${
488 isSelected
489 ? "bg-white/[0.08] text-white"
490 : "text-[#d4d4d4] hover:bg-white/[0.05]"
419491 }`}
492 onClick={() => onModelSelect(model)}
420493 >
421 ✓
422 </span>
423 </button>
424 );
425 })}
494 <span className="truncate">{model}</span>
495 <span
496 className={`pt-0.5 text-sm ${
497 isSelected ? "text-white" : "text-transparent"
498 }`}
499 >
500 ✓
501 </span>
502 </button>
503 );
504 })}
505 </div>
426506 </div>
427 {isLoadingModels && (
428 <p className="px-2 pt-2 text-[10px] text-[#a0a0a0]/60">
429 Refreshing available models...
430 </p>
431 )}
432 {modelLoadError && (
433 <p className="px-2 pt-2 text-[10px] text-amber-200/80">
434 Could not load full model list from Codex:{" "}
435 {modelLoadError}
436 </p>
437 )}
438 </div>
439 )}
507 )}
508 </div>
509 <label
510 className="inline-flex items-center gap-2 rounded-full border border-white/[0.1] bg-white/[0.04] px-3 py-1.5 text-xs text-[#d8d8d8]"
511 htmlFor="reasoning-effort"
512 >
513 <span>Reasoning</span>
514 <select
515 id="reasoning-effort"
516 className="bg-transparent font-mono text-xs text-[#d8d8d8] outline-none"
517 value={selectedEffort}
518 onChange={(event) => setSelectedEffort(event.target.value)}
519 >
520 {REASONING_OPTIONS.map((effort) => (
521 <option key={effort} value={effort} className="bg-[#1b1b1d]">
522 {effort}
523 {effort === DEFAULT_REASONING ? " (default)" : ""}
524 </option>
525 ))}
526 </select>
527 </label>
440528 </div>
441529 {activeProject && (
442530 <span className="text-[10px] text-[#a0a0a0]/35">
apps/renderer/src/components/Sidebar.tsx+11-6
......@@ -1,9 +1,8 @@
11import { useState } from "react";
2import { DEFAULT_MODEL, MODEL_OPTIONS, resolveModelSlug } from "../model-logic";
23import { useStore } from "../store";
34import type { Project } from "../types";
45
5const DEFAULT_MODEL = "gpt-5.2-codex";
6
76function formatRelativeTime(iso: string): string {
87 const diff = Date.now() - new Date(iso).getTime();
98 const minutes = Math.floor(diff / 60_000);
......@@ -24,11 +23,12 @@ export default function Sidebar() {
2423 const cwd = newCwd.trim();
2524 if (!cwd) return;
2625 const name = cwd.split("/").filter(Boolean).pop() ?? "project";
26 const normalizedModel = resolveModelSlug(newModel);
2727 const project: Project = {
2828 id: crypto.randomUUID(),
2929 name,
3030 cwd,
31 model: newModel.trim() || DEFAULT_MODEL,
31 model: normalizedModel,
3232 expanded: true,
3333 };
3434 dispatch({ type: "ADD_PROJECT", project });
......@@ -186,16 +186,21 @@ export default function Sidebar() {
186186 if (e.key === "Escape") setAddingProject(false);
187187 }}
188188 />
189 <input
189 <select
190190 className="mb-2 w-full rounded-md border border-white/[0.1] bg-white/[0.04] px-2 py-1.5 font-mono text-xs text-[#e0e0e0] placeholder:text-[#a0a0a0]/30 focus:border-white/30 focus:outline-none"
191 placeholder={`model (default: ${DEFAULT_MODEL})`}
192191 value={newModel}
193192 onChange={(e) => setNewModel(e.target.value)}
194193 onKeyDown={(e) => {
195194 if (e.key === "Enter") handleAddProject();
196195 if (e.key === "Escape") setAddingProject(false);
197196 }}
198 />
197 >
198 {MODEL_OPTIONS.map((model) => (
199 <option key={model} value={model} className="bg-[#141414]">
200 {model}
201 </option>
202 ))}
203 </select>
199204 <div className="flex gap-2">
200205 <button
201206 type="button"
apps/renderer/src/model-logic.test.ts created+33
......@@ -0,0 +1,33 @@
1import { describe, expect, it } from "vitest";
2import { DEFAULT_MODEL, normalizeModelSlug, resolveModelSlug } from "./model-logic";
3
4describe("normalizeModelSlug", () => {
5 it("maps 5.3 aliases to canonical codex slug", () => {
6 expect(normalizeModelSlug("5.3")).toBe("gpt-5.3-codex");
7 expect(normalizeModelSlug("gpt-5.3")).toBe("gpt-5.3-codex");
8 });
9
10 it("preserves non-aliased slugs", () => {
11 expect(normalizeModelSlug("gpt-5.3-codex")).toBe("gpt-5.3-codex");
12 expect(normalizeModelSlug("gpt-5.2-codex")).toBe("gpt-5.2-codex");
13 });
14
15 it("returns null for empty input", () => {
16 expect(normalizeModelSlug("")).toBeNull();
17 expect(normalizeModelSlug(" ")).toBeNull();
18 expect(normalizeModelSlug(null)).toBeNull();
19 expect(normalizeModelSlug(undefined)).toBeNull();
20 });
21});
22
23describe("resolveModelSlug", () => {
24 it("falls back to default for unknown models", () => {
25 expect(resolveModelSlug("gpt-4.1")).toBe(DEFAULT_MODEL);
26 });
27
28 it("resolves supported hard-coded models", () => {
29 expect(resolveModelSlug("gpt-5.3-codex")).toBe("gpt-5.3-codex");
30 expect(resolveModelSlug("gpt-5.2-codex")).toBe("gpt-5.2-codex");
31 expect(resolveModelSlug("gpt-5.2")).toBe("gpt-5.2");
32 });
33});
apps/renderer/src/model-logic.ts created+39
......@@ -0,0 +1,39 @@
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 REASONING_OPTIONS = ["xhigh", "high", "medium", "low"] as const;
10export 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(model: string | null | undefined): string | null {
18 if (typeof model !== "string") {
19 return null;
20 }
21
22 const trimmed = model.trim();
23 if (!trimmed) {
24 return null;
25 }
26
27 return MODEL_SLUG_ALIASES[trimmed] ?? trimmed;
28}
29
30export function resolveModelSlug(model: string | null | undefined): string {
31 const normalized = normalizeModelSlug(model);
32 if (!normalized) {
33 return DEFAULT_MODEL;
34 }
35
36 return MODEL_OPTIONS.includes(normalized as (typeof MODEL_OPTIONS)[number])
37 ? normalized
38 : DEFAULT_MODEL;
39}
apps/renderer/src/session-logic.ts+241
......@@ -36,6 +36,247 @@ export function formatTimestamp(isoDate: string): string {
3636 }).format(new Date(isoDate));
3737}
3838
39export function formatDuration(durationMs: number): string {
40 if (!Number.isFinite(durationMs) || durationMs < 0) return "0ms";
41 if (durationMs < 1_000) return `${Math.max(1, Math.round(durationMs))}ms`;
42 if (durationMs < 10_000) return `${(durationMs / 1_000).toFixed(1)}s`;
43 if (durationMs < 60_000) return `${Math.round(durationMs / 1_000)}s`;
44
45 const minutes = Math.floor(durationMs / 60_000);
46 const seconds = Math.round((durationMs % 60_000) / 1_000);
47 if (seconds === 0) return `${minutes}m`;
48 if (seconds === 60) return `${minutes + 1}m`;
49 return `${minutes}m ${seconds}s`;
50}
51
52export function formatElapsed(
53 startIso: string,
54 endIso: string | undefined,
55): string | null {
56 if (!endIso) return null;
57
58 const startedAt = Date.parse(startIso);
59 const endedAt = Date.parse(endIso);
60 if (Number.isNaN(startedAt) || Number.isNaN(endedAt) || endedAt < startedAt) {
61 return null;
62 }
63
64 return formatDuration(endedAt - startedAt);
65}
66
67export interface WorkLogEntry {
68 id: string;
69 createdAt: string;
70 label: string;
71 detail?: string;
72 tone: "thinking" | "tool" | "info" | "error";
73}
74
75function truncateLine(value: string, limit = 140): string {
76 if (value.length <= limit) return value;
77 return `${value.slice(0, limit - 1)}…`;
78}
79
80function normalizeItemType(raw: string | undefined): string {
81 if (!raw) return "item";
82 return raw.replace(/[_-]/g, " ").replace(/\s+/g, " ").trim().toLowerCase();
83}
84
85function itemTypeMeta(type: string): {
86 label: string;
87 tone: WorkLogEntry["tone"];
88} {
89 if (
90 type.includes("preamble") ||
91 type.includes("reasoning") ||
92 type.includes("thought")
93 ) {
94 return { label: "Preamble", tone: "thinking" };
95 }
96 if (
97 type.includes("tool") ||
98 type.includes("command") ||
99 type.includes("file change")
100 ) {
101 return { label: "Tool call", tone: "tool" };
102 }
103 if (type.includes("agent message")) {
104 return { label: "Drafting response", tone: "thinking" };
105 }
106 return { label: "Work item", tone: "info" };
107}
108
109function extractDetail(
110 payload: Record<string, unknown> | undefined,
111 item: Record<string, unknown> | undefined,
112): string | undefined {
113 const candidates = [
114 asString(item?.title),
115 asString(item?.summary),
116 asString(item?.text),
117 asString(item?.command),
118 asString(payload?.message),
119 asString(payload?.prompt),
120 asString(payload?.command),
121 ];
122
123 for (const candidate of candidates) {
124 if (candidate && candidate.trim().length > 0) {
125 return truncateLine(candidate.trim());
126 }
127 }
128 return undefined;
129}
130
131function entryFromItemLifecycle(event: ProviderEvent): WorkLogEntry | null {
132 const payload = asObject(event.payload);
133 const item = asObject(payload?.item);
134 const normalizedType = normalizeItemType(asString(item?.type));
135 const meta = itemTypeMeta(normalizedType);
136 const isStarted = event.method === "item/started";
137 const isCompleted = event.method === "item/completed";
138 if (!isStarted && !isCompleted) {
139 return null;
140 }
141
142 const label = isCompleted ? `${meta.label} complete` : meta.label;
143 const detail = extractDetail(payload, item);
144 return {
145 id: event.id,
146 createdAt: event.createdAt,
147 label,
148 ...(detail ? { detail } : {}),
149 tone: meta.tone,
150 };
151}
152
153function entryFromRequest(event: ProviderEvent): WorkLogEntry | null {
154 if (event.kind !== "request") return null;
155
156 if (event.method.includes("commandExecution")) {
157 return {
158 id: event.id,
159 createdAt: event.createdAt,
160 label: "Command approval requested",
161 tone: "tool",
162 };
163 }
164
165 if (event.method.includes("fileChange")) {
166 return {
167 id: event.id,
168 createdAt: event.createdAt,
169 label: "File-change approval requested",
170 tone: "tool",
171 };
172 }
173
174 if (event.method.includes("requestUserInput")) {
175 return {
176 id: event.id,
177 createdAt: event.createdAt,
178 label: "Tool requested user input",
179 tone: "tool",
180 };
181 }
182
183 return {
184 id: event.id,
185 createdAt: event.createdAt,
186 label: `Request: ${event.method}`,
187 tone: "info",
188 };
189}
190
191function entryFromNotification(event: ProviderEvent): WorkLogEntry | null {
192 if (event.kind !== "notification") return null;
193 if (event.method === "item/agentMessage/delta") return null;
194 if (event.method === "turn/started" || event.method === "turn/completed") {
195 return null;
196 }
197 if (event.method === "thread/started") return null;
198
199 const lifecycleEntry = entryFromItemLifecycle(event);
200 if (lifecycleEntry) return lifecycleEntry;
201
202 if (event.method.startsWith("item/")) {
203 return {
204 id: event.id,
205 createdAt: event.createdAt,
206 label: `Event: ${event.method.replace(/^item\//, "")}`,
207 tone: "info",
208 };
209 }
210
211 return null;
212}
213
214function entryFromError(event: ProviderEvent): WorkLogEntry | null {
215 if (event.kind !== "error") return null;
216
217 return {
218 id: event.id,
219 createdAt: event.createdAt,
220 label: "Runtime error",
221 ...(event.message ? { detail: truncateLine(event.message) } : {}),
222 tone: "error",
223 };
224}
225
226export function deriveWorkLogEntries(
227 events: ProviderEvent[],
228 turnId: string | undefined,
229): WorkLogEntry[] {
230 const ordered = [...events].reverse();
231 const entries: WorkLogEntry[] = [];
232 const turnStartedAtIso = turnId
233 ? ordered.find((event) => {
234 if (event.method !== "turn/started") return false;
235 const payload = asObject(event.payload);
236 const turn = asObject(payload?.turn);
237 const startedTurnId = event.turnId ?? asString(turn?.id);
238 return startedTurnId === turnId;
239 })?.createdAt
240 : undefined;
241 const turnStartedAt = turnStartedAtIso
242 ? Date.parse(turnStartedAtIso)
243 : Number.NaN;
244
245 for (const event of ordered) {
246 if (turnId) {
247 if (event.turnId && event.turnId !== turnId) {
248 continue;
249 }
250
251 if (!event.turnId && !Number.isNaN(turnStartedAt)) {
252 const eventAt = Date.parse(event.createdAt);
253 if (!Number.isNaN(eventAt) && eventAt < turnStartedAt) {
254 continue;
255 }
256 }
257 }
258
259 const fromRequest = entryFromRequest(event);
260 if (fromRequest) {
261 entries.push(fromRequest);
262 continue;
263 }
264
265 const fromNotification = entryFromNotification(event);
266 if (fromNotification) {
267 entries.push(fromNotification);
268 continue;
269 }
270
271 const fromError = entryFromError(event);
272 if (fromError) {
273 entries.push(fromError);
274 }
275 }
276
277 return entries;
278}
279
39280export function derivePhase(session: ProviderSession | null): SessionPhase {
40281 if (!session || session.status === "closed") return "disconnected";
41282 if (session.status === "connecting") return "connecting";
apps/renderer/src/store.ts+49-11
......@@ -9,6 +9,7 @@ import {
99} from "react";
1010
1111import type { ProviderEvent, ProviderSession } from "@acme/contracts";
12import { DEFAULT_MODEL, resolveModelSlug } from "./model-logic";
1213import { applyEventToMessages, evolveSession } from "./session-logic";
1314import type { Project, Thread } from "./types";
1415
......@@ -40,7 +41,9 @@ export interface AppState {
4041 diffOpen: boolean;
4142}
4243
43const PERSISTED_STATE_KEY = "codething:renderer-state:v1";
44const PERSISTED_STATE_KEY = "codething:renderer-state:v2";
45const LEGACY_PERSISTED_STATE_KEY = "codething:renderer-state:v1";
46const LEGACY_DEFAULT_MODEL = "gpt-5.2-codex";
4447
4548const initialState: AppState = {
4649 projects: [],
......@@ -86,7 +89,8 @@ function sanitizeProjects(input: unknown): Project[] {
8689 const id = asString(project.id);
8790 const name = asString(project.name);
8891 const cwd = asString(project.cwd);
89 const model = asString(project.model);
92 const rawModel = asString(project.model);
93 const model = resolveModelSlug(rawModel);
9094 const expanded = project.expanded;
9195 if (!id || !name || !cwd || !model || !isBoolean(expanded)) return null;
9296
......@@ -101,6 +105,10 @@ function sanitizeProjects(input: unknown): Project[] {
101105 .filter((project): project is Project => project !== null);
102106}
103107
108function migrateLegacyDefaultModel(model: string): string {
109 return model === LEGACY_DEFAULT_MODEL ? DEFAULT_MODEL : model;
110}
111
104112function sanitizeMessages(input: unknown): Thread["messages"] {
105113 if (!Array.isArray(input)) return [];
106114
......@@ -149,7 +157,8 @@ function sanitizeThreads(input: unknown): AppState["threads"] {
149157 const id = asString(thread.id);
150158 const projectId = asString(thread.projectId);
151159 const title = asString(thread.title);
152 const model = asString(thread.model);
160 const rawModel = asString(thread.model);
161 const model = resolveModelSlug(rawModel);
153162 const createdAt = asString(thread.createdAt);
154163 if (!id || !projectId || !title || !model || !createdAt) return null;
155164
......@@ -173,17 +182,30 @@ function readPersistedState(): AppState {
173182 if (typeof window === "undefined") return initialState;
174183
175184 try {
176 const raw = window.localStorage.getItem(PERSISTED_STATE_KEY);
185 const rawCurrent = window.localStorage.getItem(PERSISTED_STATE_KEY);
186 const rawLegacy = window.localStorage.getItem(LEGACY_PERSISTED_STATE_KEY);
187 const raw = rawCurrent ?? rawLegacy;
177188 if (!raw) return initialState;
189 const isLegacyPayload = !rawCurrent && Boolean(rawLegacy);
178190
179191 const parsed = asObject(JSON.parse(raw));
180192 if (!parsed) return initialState;
181193
182 const projects = sanitizeProjects(parsed.projects);
194 const projects = sanitizeProjects(parsed.projects).map((project) => ({
195 ...project,
196 model: isLegacyPayload
197 ? migrateLegacyDefaultModel(project.model)
198 : project.model,
199 }));
183200 const projectIds = new Set(projects.map((project) => project.id));
184 const threads = sanitizeThreads(parsed.threads).filter((thread) =>
185 projectIds.has(thread.projectId),
186 );
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));
187209 const activeThreadId = asString(parsed.activeThreadId);
188210 const hasActiveThread = Boolean(
189211 activeThreadId && threads.some((thread) => thread.id === activeThreadId),
......@@ -227,6 +249,7 @@ function persistState(state: AppState): void {
227249 PERSISTED_STATE_KEY,
228250 JSON.stringify(toPersistedState(state)),
229251 );
252 window.localStorage.removeItem(LEGACY_PERSISTED_STATE_KEY);
230253 } catch {
231254 // Ignore quota/storage errors to avoid breaking chat UX.
232255 }
......@@ -252,7 +275,16 @@ function findThreadBySessionId(
252275function reducer(state: AppState, action: Action): AppState {
253276 switch (action.type) {
254277 case "ADD_PROJECT":
255 return { ...state, projects: [...state.projects, action.project] };
278 return {
279 ...state,
280 projects: [
281 ...state.projects,
282 {
283 ...action.project,
284 model: resolveModelSlug(action.project.model),
285 },
286 ],
287 };
256288
257289 case "TOGGLE_PROJECT":
258290 return {
......@@ -265,7 +297,13 @@ function reducer(state: AppState, action: Action): AppState {
265297 case "ADD_THREAD":
266298 return {
267299 ...state,
268 threads: [...state.threads, action.thread],
300 threads: [
301 ...state.threads,
302 {
303 ...action.thread,
304 model: resolveModelSlug(action.thread.model),
305 },
306 ],
269307 activeThreadId: action.thread.id,
270308 };
271309
......@@ -349,7 +387,7 @@ function reducer(state: AppState, action: Action): AppState {
349387 ...state,
350388 threads: updateThread(state.threads, action.threadId, (t) => ({
351389 ...t,
352 model: action.model,
390 model: resolveModelSlug(action.model),
353391 })),
354392 };
355393
packages/contracts/src/ipc.ts-4
......@@ -2,8 +2,6 @@ import type { AgentConfig, AgentExit, OutputChunk } from "./agent";
22import type {
33 ProviderEvent,
44 ProviderInterruptTurnInput,
5 ProviderListModelsInput,
6 ProviderModel,
75 ProviderSendTurnInput,
86 ProviderSession,
97 ProviderSessionStartInput,
......@@ -29,7 +27,6 @@ export const IPC_CHANNELS = {
2927 providerTurnInterrupt: "provider:turn:interrupt",
3028 providerSessionStop: "provider:session:stop",
3129 providerSessionList: "provider:session:list",
32 providerModelList: "provider:model:list",
3330 providerEvent: "provider:event",
3431} as const;
3532
......@@ -60,7 +57,6 @@ export interface NativeApi {
6057 interruptTurn: (input: ProviderInterruptTurnInput) => Promise<void>;
6158 stopSession: (input: ProviderStopSessionInput) => Promise<void>;
6259 listSessions: () => Promise<ProviderSession[]>;
63 listModels: (input?: ProviderListModelsInput) => Promise<ProviderModel[]>;
6460 onEvent: (callback: (event: ProviderEvent) => void) => () => void;
6561 };
6662}
packages/contracts/src/provider.test.ts+3-9
......@@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest";
22
33import {
44 providerEventSchema,
5 providerListModelsInputSchema,
65 providerSendTurnInputSchema,
76 providerSessionStartInputSchema,
87} from "./provider";
......@@ -17,21 +16,16 @@ describe("providerSessionStartInputSchema", () => {
1716});
1817
1918describe("providerSendTurnInputSchema", () => {
20 it("trims input text and optional model", () => {
19 it("trims input text and optional model/effort", () => {
2120 const parsed = providerSendTurnInputSchema.parse({
2221 sessionId: "sess_1",
2322 input: " summarize this repo ",
2423 model: " gpt-5.2-codex ",
24 effort: " high ",
2525 });
2626 expect(parsed.input).toBe("summarize this repo");
2727 expect(parsed.model).toBe("gpt-5.2-codex");
28 });
29});
30
31describe("providerListModelsInputSchema", () => {
32 it("defaults provider to codex", () => {
33 const parsed = providerListModelsInputSchema.parse({});
34 expect(parsed.provider).toBe("codex");
28 expect(parsed.effort).toBe("high");
3529 });
3630});
3731
packages/contracts/src/provider.ts+1-18
......@@ -48,6 +48,7 @@ export const providerSendTurnInputSchema = z.object({
4848 sessionId: z.string().min(1),
4949 input: z.string().trim().min(1).max(120_000),
5050 model: z.string().trim().min(1).optional(),
51 effort: z.string().trim().min(1).optional(),
5152});
5253
5354export const providerTurnStartResultSchema = z.object({
......@@ -64,20 +65,6 @@ export const providerStopSessionInputSchema = z.object({
6465 sessionId: z.string().min(1),
6566});
6667
67export const providerModelSchema = z.object({
68 id: z.string().min(1),
69 model: z.string().min(1),
70 displayName: z.string().min(1),
71 description: z.string().min(1).optional(),
72 isDefault: z.boolean().optional(),
73 upgrade: z.string().min(1).nullable().optional(),
74});
75
76export const providerListModelsInputSchema = z.object({
77 provider: providerKindSchema.default("codex"),
78 cwd: z.string().min(1).optional(),
79});
80
8168export const providerEventKindSchema = z.enum([
8269 "session",
8370 "notification",
......@@ -120,9 +107,5 @@ export type ProviderInterruptTurnInput = z.input<
120107export type ProviderStopSessionInput = z.input<
121108 typeof providerStopSessionInputSchema
122109>;
123export type ProviderModel = z.infer<typeof providerModelSchema>;
124export type ProviderListModelsInput = z.input<
125 typeof providerListModelsInputSchema
126>;
127110export type ProviderEventKind = z.infer<typeof providerEventKindSchema>;
128111export type ProviderEvent = z.infer<typeof providerEventSchema>;