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 @@...@@ -1,6 +1,9 @@
1import { describe, expect, it } from "vitest";1import { describe, expect, it } from "vitest";
22
3import { classifyCodexStderrLine } from "./codexAppServerManager";3import {
4 classifyCodexStderrLine,
5 normalizeCodexModelSlug,
6} from "./codexAppServerManager";
47
5describe("classifyCodexStderrLine", () => {8describe("classifyCodexStderrLine", () => {
6 it("ignores empty lines", () => {9 it("ignores empty lines", () => {
...@@ -34,3 +37,21 @@ describe("classifyCodexStderrLine", () => {...@@ -34,3 +37,21 @@ describe("classifyCodexStderrLine", () => {
34 });37 });
35 });38 });
36});39});
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";...@@ -5,7 +5,6 @@ import readline from "node:readline";
55
6import type {6import type {
7 ProviderEvent,7 ProviderEvent,
8 ProviderModel,
9 ProviderSendTurnInput,8 ProviderSendTurnInput,
10 ProviderSession,9 ProviderSession,
11 ProviderSessionStartInput,10 ProviderSessionStartInput,
...@@ -60,6 +59,34 @@ const BENIGN_ERROR_LOG_SNIPPETS = [...@@ -60,6 +59,34 @@ const BENIGN_ERROR_LOG_SNIPPETS = [
60 "state db missing rollout path for thread",59 "state db missing rollout path for thread",
61 "state db record_discrepancy: find_thread_path_by_id_str_in_subdir, falling_back",60 "state db record_discrepancy: find_thread_path_by_id_str_in_subdir, falling_back",
62];61];
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
64export function classifyCodexStderrLine(91export function classifyCodexStderrLine(
65 rawLine: string,92 rawLine: string,
...@@ -104,7 +131,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -104,7 +131,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
104 sessionId,131 sessionId,
105 provider: "codex",132 provider: "codex",
106 status: "connecting",133 status: "connecting",
107 model: input.model,134 model: normalizeCodexModelSlug(input.model),
108 cwd: input.cwd,135 cwd: input.cwd,
109 createdAt: now,136 createdAt: now,
110 updatedAt: now,137 updatedAt: now,
...@@ -150,7 +177,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -150,7 +177,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
150 this.writeMessage(context, { method: "initialized" });177 this.writeMessage(context, { method: "initialized" });
151178
152 const threadStart = await this.sendRequest(context, "thread/start", {179 const threadStart = await this.sendRequest(context, "thread/start", {
153 model: input.model ?? null,180 model: null,
154 cwd: input.cwd ?? null,181 cwd: input.cwd ?? null,
155 approvalPolicy: input.approvalPolicy,182 approvalPolicy: input.approvalPolicy,
156 sandbox: input.sandboxMode,183 sandbox: input.sandboxMode,
...@@ -202,6 +229,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -202,6 +229,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
202 threadId: string;229 threadId: string;
203 input: Array<{ type: "text"; text: string; text_elements: [] }>;230 input: Array<{ type: "text"; text: string; text_elements: [] }>;
204 model?: string;231 model?: string;
232 effort?: string;
205 } = {233 } = {
206 threadId: context.session.threadId,234 threadId: context.session.threadId,
207 input: [235 input: [
...@@ -212,8 +240,12 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -212,8 +240,12 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
212 },240 },
213 ],241 ],
214 };242 };
215 if (input.model) {243 const normalizedModel = normalizeCodexModelSlug(input.model);
216 turnStartParams.model = input.model;244 if (normalizedModel) {
245 turnStartParams.model = normalizedModel;
246 }
247 if (input.effort) {
248 turnStartParams.effort = input.effort;
217 }249 }
218250
219 const response = await this.sendRequest(251 const response = await this.sendRequest(
...@@ -239,177 +271,6 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -239,177 +271,6 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
239 };271 };
240 }272 }
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
413 async interruptTurn(sessionId: string, turnId?: string): Promise<void> {274 async interruptTurn(sessionId: string, turnId?: string): Promise<void> {
414 const context = this.requireSession(sessionId);275 const context = this.requireSession(sessionId);
415 const effectiveTurnId = turnId ?? context.session.activeTurnId;276 const effectiveTurnId = turnId ?? context.session.activeTurnId;
...@@ -807,66 +668,6 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -807,66 +668,6 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
807 };668 };
808 }669 }
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
870 private isServerRequest(value: unknown): value is JsonRpcRequest {671 private isServerRequest(value: unknown): value is JsonRpcRequest {
871 if (!value || typeof value !== "object") {672 if (!value || typeof value !== "object") {
872 return false;673 return false;
apps/desktop/src/main.ts-9
...@@ -139,15 +139,6 @@ function registerIpcHandlers(): void {...@@ -139,15 +139,6 @@ function registerIpcHandlers(): void {
139 ipcMain.handle(IPC_CHANNELS.providerSessionList, async () => {139 ipcMain.handle(IPC_CHANNELS.providerSessionList, async () => {
140 return providerManager.listSessions();140 return providerManager.listSessions();
141 });141 });
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 );
151}142}
152143
153async function runTerminalCommand(144async function runTerminalCommand(
apps/desktop/src/preload.ts-2
...@@ -41,8 +41,6 @@ const nativeApi: NativeApi = {...@@ -41,8 +41,6 @@ const nativeApi: NativeApi = {
41 stopSession: (input) =>41 stopSession: (input) =>
42 ipcRenderer.invoke(IPC_CHANNELS.providerSessionStop, input),42 ipcRenderer.invoke(IPC_CHANNELS.providerSessionStop, input),
43 listSessions: () => ipcRenderer.invoke(IPC_CHANNELS.providerSessionList),43 listSessions: () => ipcRenderer.invoke(IPC_CHANNELS.providerSessionList),
44 listModels: (input) =>
45 ipcRenderer.invoke(IPC_CHANNELS.providerModelList, input),
46 onEvent: (callback) => {44 onEvent: (callback) => {
47 const listener = (_event: Electron.IpcRendererEvent, payload: unknown) =>45 const listener = (_event: Electron.IpcRendererEvent, payload: unknown) =>
48 callback(payload as Parameters<typeof callback>[0]);46 callback(payload as Parameters<typeof callback>[0]);
apps/desktop/src/providerManager.ts-14
...@@ -3,15 +3,12 @@ import { EventEmitter } from "node:events";...@@ -3,15 +3,12 @@ import { EventEmitter } from "node:events";
3import {3import {
4 type ProviderEvent,4 type ProviderEvent,
5 type ProviderInterruptTurnInput,5 type ProviderInterruptTurnInput,
6 type ProviderListModelsInput,
7 type ProviderModel,
8 type ProviderSendTurnInput,6 type ProviderSendTurnInput,
9 type ProviderSession,7 type ProviderSession,
10 type ProviderSessionStartInput,8 type ProviderSessionStartInput,
11 type ProviderStopSessionInput,9 type ProviderStopSessionInput,
12 type ProviderTurnStartResult,10 type ProviderTurnStartResult,
13 providerInterruptTurnInputSchema,11 providerInterruptTurnInputSchema,
14 providerListModelsInputSchema,
15 providerSendTurnInputSchema,12 providerSendTurnInputSchema,
16 providerSessionStartInputSchema,13 providerSessionStartInputSchema,
17 providerStopSessionInputSchema,14 providerStopSessionInputSchema,
...@@ -66,17 +63,6 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> {...@@ -66,17 +63,6 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> {
66 return this.codex.listSessions();63 return this.codex.listSessions();
67 }64 }
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
80 stopAll(): void {66 stopAll(): void {
81 this.codex.stopAll();67 this.codex.stopAll();
82 }68 }
apps/renderer/src/components/ChatView.tsx+236-148
...@@ -1,4 +1,3 @@...@@ -1,4 +1,3 @@
1import type { ProviderModel } from "@acme/contracts";
2import {1import {
3 type FormEvent,2 type FormEvent,
4 type KeyboardEvent,3 type KeyboardEvent,
...@@ -8,10 +7,40 @@ import {...@@ -8,10 +7,40 @@ import {
8 useState,7 useState,
9} from "react";8} 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";
12import { useStore } from "../store";24import { 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
16export default function ChatView() {45export default function ChatView() {
17 const { state, dispatch } = useStore();46 const { state, dispatch } = useStore();
...@@ -20,9 +49,10 @@ export default function ChatView() {...@@ -20,9 +49,10 @@ export default function ChatView() {
20 const [isSending, setIsSending] = useState(false);49 const [isSending, setIsSending] = useState(false);
21 const [isConnecting, setIsConnecting] = useState(false);50 const [isConnecting, setIsConnecting] = useState(false);
22 const [isModelMenuOpen, setIsModelMenuOpen] = useState(false);51 const [isModelMenuOpen, setIsModelMenuOpen] = useState(false);
23 const [isLoadingModels, setIsLoadingModels] = useState(false);52 const [selectedEffort, setSelectedEffort] = useState<string>(
24 const [modelLoadError, setModelLoadError] = useState<string | null>(null);53 DEFAULT_REASONING,
25 const [availableModels, setAvailableModels] = useState<ProviderModel[]>([]);54 );
55 const [nowTick, setNowTick] = useState(() => Date.now());
26 const messagesEndRef = useRef<HTMLDivElement>(null);56 const messagesEndRef = useRef<HTMLDivElement>(null);
27 const textareaRef = useRef<HTMLTextAreaElement>(null);57 const textareaRef = useRef<HTMLTextAreaElement>(null);
28 const modelMenuRef = useRef<HTMLDivElement>(null);58 const modelMenuRef = useRef<HTMLDivElement>(null);
...@@ -31,31 +61,42 @@ export default function ChatView() {...@@ -31,31 +61,42 @@ export default function ChatView() {
31 const activeProject = state.projects.find(61 const activeProject = state.projects.find(
32 (p) => p.id === activeThread?.projectId,62 (p) => p.id === activeThread?.projectId,
33 );63 );
34 const selectedModel =64 const selectedModel = resolveModelSlug(
35 activeThread?.model || activeProject?.model || DEFAULT_MODEL;65 activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL,
66 );
36 const phase = derivePhase(activeThread?.session ?? null);67 const phase = derivePhase(activeThread?.session ?? null);
37 const modelOptions = useMemo(() => {68 const isWorking = phase === "running" || isSending || isConnecting;
38 const deduped = new Map<string, ProviderModel>();69 const activeTurnId = activeThread?.session?.activeTurnId;
39 for (const model of availableModels) {70 const nowIso = new Date(nowTick).toISOString();
40 deduped.set(model.model, model);71 const modelOptions = MODEL_OPTIONS;
41 }72 const workLogEntries = useMemo(
42 if (!deduped.has(selectedModel)) {73 () => deriveWorkLogEntries(activeThread?.events ?? [], activeTurnId),
43 deduped.set(selectedModel, {74 [activeThread?.events, activeTurnId],
44 id: selectedModel,75 );
45 model: selectedModel,76 const assistantCompletionByItemId = useMemo(() => {
46 displayName: selectedModel,77 const map = new Map<string, string>();
47 description: "Current model for this thread.",78 const ordered = [...(activeThread?.events ?? [])].reverse();
48 });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);
49 }83 }
50 return Array.from(deduped.values());84 return map;
51 }, [availableModels, selectedModel]);85 }, [activeThread?.events]);
86 const recentWorkLogEntries = workLogEntries.slice(-12);
5287
53 // Auto-scroll on new messages88 // Auto-scroll on new messages
54 const messageCount = activeThread?.messages.length ?? 0;89 const messageCount = activeThread?.messages.length ?? 0;
90 const workLogCount = recentWorkLogEntries.length;
55 // biome-ignore lint/correctness/useExhaustiveDependencies: trigger on message count change91 // biome-ignore lint/correctness/useExhaustiveDependencies: trigger on message count change
56 useEffect(() => {92 useEffect(() => {
57 messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });93 messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
58 }, [messageCount]);94 }, [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
60 // Auto-resize textarea101 // Auto-resize textarea
61 // biome-ignore lint/correctness/useExhaustiveDependencies: trigger on prompt change102 // biome-ignore lint/correctness/useExhaustiveDependencies: trigger on prompt change
...@@ -66,6 +107,16 @@ export default function ChatView() {...@@ -66,6 +107,16 @@ export default function ChatView() {
66 ta.style.height = `${Math.min(ta.scrollHeight, 200)}px`;107 ta.style.height = `${Math.min(ta.scrollHeight, 200)}px`;
67 }, [prompt]);108 }, [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
69 useEffect(() => {120 useEffect(() => {
70 if (!isModelMenuOpen) return;121 if (!isModelMenuOpen) return;
71122
...@@ -85,48 +136,6 @@ export default function ChatView() {...@@ -85,48 +136,6 @@ export default function ChatView() {
85 };136 };
86 }, [isModelMenuOpen]);137 }, [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
130 const ensureSession = async (): Promise<string | null> => {139 const ensureSession = async (): Promise<string | null> => {
131 if (!api || !activeThread || !activeProject) return null;140 if (!api || !activeThread || !activeProject) return null;
132 if (activeThread.session && activeThread.session.status !== "closed") {141 if (activeThread.session && activeThread.session.status !== "closed") {
...@@ -197,6 +206,7 @@ export default function ChatView() {...@@ -197,6 +206,7 @@ export default function ChatView() {
197 sessionId,206 sessionId,
198 input: trimmed,207 input: trimmed,
199 model: selectedModel || undefined,208 model: selectedModel || undefined,
209 effort: selectedEffort || undefined,
200 });210 });
201 } catch (err) {211 } catch (err) {
202 dispatch({212 dispatch({
...@@ -222,7 +232,7 @@ export default function ChatView() {...@@ -222,7 +232,7 @@ export default function ChatView() {
222 dispatch({232 dispatch({
223 type: "SET_THREAD_MODEL",233 type: "SET_THREAD_MODEL",
224 threadId: activeThread.id,234 threadId: activeThread.id,
225 model,235 model: resolveModelSlug(model),
226 });236 });
227 setIsModelMenuOpen(false);237 setIsModelMenuOpen(false);
228 };238 };
...@@ -266,19 +276,38 @@ export default function ChatView() {...@@ -266,19 +276,38 @@ export default function ChatView() {
266 </div>276 </div>
267 <div className="flex items-center gap-3">277 <div className="flex items-center gap-3">
268 {/* Status indicator */}278 {/* Status indicator */}
269 <div className="flex items-center gap-1.5">279 <div
270 <div280 className={`inline-flex items-center gap-2 rounded-full border px-2.5 py-1 text-[10px] ${
271 className={`h-2 w-2 rounded-full ${281 phase === "running"
272 phase === "running"282 ? "border-sky-400/35 bg-sky-500/[0.08] text-sky-100"
273 ? "animate-pulse bg-emerald-400"283 : phase === "connecting"
284 ? "border-amber-400/35 bg-amber-500/[0.08] text-amber-100"
274 : phase === "ready"285 : 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"
276 : phase === "connecting"302 : phase === "connecting"
277 ? "animate-pulse bg-amber-400"303 ? "bg-amber-200"
278 : "bg-[#a0a0a0]/30"304 : phase === "ready"
279 }`}305 ? "bg-emerald-200"
280 />306 : "bg-[#a0a0a0]/40"
281 <span className="text-[10px] text-[#a0a0a0]/50">{phase}</span>307 }`}
308 />
309 </span>
310 <span>{statusLabel(phase)}</span>
282 </div>311 </div>
283 {/* Diff toggle */}312 {/* Diff toggle */}
284 <button313 <button
...@@ -304,7 +333,7 @@ export default function ChatView() {...@@ -304,7 +333,7 @@ export default function ChatView() {
304333
305 {/* Messages */}334 {/* Messages */}
306 <div className="flex-1 overflow-y-auto px-5 py-4">335 <div className="flex-1 overflow-y-auto px-5 py-4">
307 {activeThread.messages.length === 0 ? (336 {activeThread.messages.length === 0 && !isWorking ? (
308 <div className="flex h-full items-center justify-center">337 <div className="flex h-full items-center justify-center">
309 <p className="text-sm text-[#a0a0a0]/30">338 <p className="text-sm text-[#a0a0a0]/30">
310 Send a message to start the conversation.339 Send a message to start the conversation.
...@@ -312,6 +341,53 @@ export default function ChatView() {...@@ -312,6 +341,53 @@ export default function ChatView() {
312 </div>341 </div>
313 ) : (342 ) : (
314 <div className="mx-auto max-w-3xl space-y-4">343 <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 )}
315 {activeThread.messages.map((msg) => (391 {activeThread.messages.map((msg) => (
316 <div key={msg.id}>392 <div key={msg.id}>
317 {msg.role === "user" ? (393 {msg.role === "user" ? (
...@@ -331,17 +407,28 @@ export default function ChatView() {...@@ -331,17 +407,28 @@ export default function ChatView() {
331 {msg.text || (msg.streaming ? "" : "(empty response)")}407 {msg.text || (msg.streaming ? "" : "(empty response)")}
332 </pre>408 </pre>
333 {msg.streaming && (409 {msg.streaming && (
334 <span className="inline-flex gap-1 pt-1">410 <div className="pt-1.5">
335 <span className="h-1.5 w-1.5 animate-pulse rounded-full bg-white/50" />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">
336 <span className="h-1.5 w-1.5 animate-pulse rounded-full bg-white/50 [animation-delay:150ms]" />412 <span className="inline-flex gap-1">
337 <span className="h-1.5 w-1.5 animate-pulse rounded-full bg-white/50 [animation-delay:300ms]" />413 <span className="h-1.5 w-1.5 animate-pulse rounded-full bg-sky-100/80" />
338 </span>414 <span className="h-1.5 w-1.5 animate-pulse rounded-full bg-sky-100/80 [animation-delay:150ms]" />
339 )}415 <span className="h-1.5 w-1.5 animate-pulse rounded-full bg-sky-100/80 [animation-delay:300ms]" />
340 {!msg.streaming && (416 </span>
341 <p className="mt-1.5 text-[10px] text-[#a0a0a0]/30">417 <span>Thinking</span>
342 {formatTimestamp(msg.createdAt)}418 </span>
343 </p>419 </div>
344 )}420 )}
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>
345 </div>432 </div>
346 )}433 )}
347 </div>434 </div>
...@@ -373,70 +460,71 @@ export default function ChatView() {...@@ -373,70 +460,71 @@ export default function ChatView() {
373 disabled={isSending || isConnecting}460 disabled={isSending || isConnecting}
374 />461 />
375 <div className="mt-2 flex items-center justify-between gap-2">462 <div className="mt-2 flex items-center justify-between gap-2">
376 <div className="relative" ref={modelMenuRef}>463 <div className="flex items-center gap-2">
377 <button464 <div className="relative" ref={modelMenuRef}>
378 type="button"465 <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]"466 type="button"
380 onClick={() => setIsModelMenuOpen((open) => !open)}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]"
381 >468 onClick={() => setIsModelMenuOpen((open) => !open)}
382 <span className="max-w-[180px] truncate">469 >
383 {selectedModel}470 <span className="max-w-[180px] truncate font-mono">
384 </span>471 {selectedModel}
385 <span className="text-[10px] text-[#a0a0a0]/70">â–¼</span>472 </span>
386 </button>473 <span className="text-[10px] text-[#a0a0a0]/70">â–¼</span>
387 {isModelMenuOpen && (474 </button>
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">475 {isModelMenuOpen && (
389 <p className="px-2 py-1 text-[11px] text-[#a0a0a0]/70">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">
390 Select model477 <p className="px-2 py-1 text-[11px] text-[#a0a0a0]/70">
391 </p>478 Select model
392 <div className="max-h-72 overflow-y-auto">479 </p>
393 {modelOptions.map((model) => {480 <div className="max-h-72 overflow-y-auto">
394 const isSelected = model.model === selectedModel;481 {modelOptions.map((model) => {
395 return (482 const isSelected = model === selectedModel;
396 <button483 return (
397 key={model.id}484 <button
398 type="button"485 key={model}
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 ${486 type="button"
400 isSelected487 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 ${
401 ? "bg-white/[0.08] text-white"488 isSelected
402 : "text-[#d4d4d4] hover:bg-white/[0.05]"489 ? "bg-white/[0.08] text-white"
403 }`}490 : "text-[#d4d4d4] hover:bg-white/[0.05]"
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"
419 }`}491 }`}
492 onClick={() => onModelSelect(model)}
420 >493 >
421 ✓494 <span className="truncate">{model}</span>
422 </span>495 <span
423 </button>496 className={`pt-0.5 text-sm ${
424 );497 isSelected ? "text-white" : "text-transparent"
425 })}498 }`}
499 >
500 ✓
501 </span>
502 </button>
503 );
504 })}
505 </div>
426 </div>506 </div>
427 {isLoadingModels && (507 )}
428 <p className="px-2 pt-2 text-[10px] text-[#a0a0a0]/60">508 </div>
429 Refreshing available models...509 <label
430 </p>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]"
431 )}511 htmlFor="reasoning-effort"
432 {modelLoadError && (512 >
433 <p className="px-2 pt-2 text-[10px] text-amber-200/80">513 <span>Reasoning</span>
434 Could not load full model list from Codex:{" "}514 <select
435 {modelLoadError}515 id="reasoning-effort"
436 </p>516 className="bg-transparent font-mono text-xs text-[#d8d8d8] outline-none"
437 )}517 value={selectedEffort}
438 </div>518 onChange={(event) => setSelectedEffort(event.target.value)}
439 )}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>
440 </div>528 </div>
441 {activeProject && (529 {activeProject && (
442 <span className="text-[10px] text-[#a0a0a0]/35">530 <span className="text-[10px] text-[#a0a0a0]/35">
apps/renderer/src/components/Sidebar.tsx+11-6
...@@ -1,9 +1,8 @@...@@ -1,9 +1,8 @@
1import { useState } from "react";1import { useState } from "react";
2import { DEFAULT_MODEL, MODEL_OPTIONS, resolveModelSlug } from "../model-logic";
2import { useStore } from "../store";3import { useStore } from "../store";
3import type { Project } from "../types";4import type { Project } from "../types";
45
5const DEFAULT_MODEL = "gpt-5.2-codex";
6
7function formatRelativeTime(iso: string): string {6function formatRelativeTime(iso: string): string {
8 const diff = Date.now() - new Date(iso).getTime();7 const diff = Date.now() - new Date(iso).getTime();
9 const minutes = Math.floor(diff / 60_000);8 const minutes = Math.floor(diff / 60_000);
...@@ -24,11 +23,12 @@ export default function Sidebar() {...@@ -24,11 +23,12 @@ export default function Sidebar() {
24 const cwd = newCwd.trim();23 const cwd = newCwd.trim();
25 if (!cwd) return;24 if (!cwd) return;
26 const name = cwd.split("/").filter(Boolean).pop() ?? "project";25 const name = cwd.split("/").filter(Boolean).pop() ?? "project";
26 const normalizedModel = resolveModelSlug(newModel);
27 const project: Project = {27 const project: Project = {
28 id: crypto.randomUUID(),28 id: crypto.randomUUID(),
29 name,29 name,
30 cwd,30 cwd,
31 model: newModel.trim() || DEFAULT_MODEL,31 model: normalizedModel,
32 expanded: true,32 expanded: true,
33 };33 };
34 dispatch({ type: "ADD_PROJECT", project });34 dispatch({ type: "ADD_PROJECT", project });
...@@ -186,16 +186,21 @@ export default function Sidebar() {...@@ -186,16 +186,21 @@ export default function Sidebar() {
186 if (e.key === "Escape") setAddingProject(false);186 if (e.key === "Escape") setAddingProject(false);
187 }}187 }}
188 />188 />
189 <input189 <select
190 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"190 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})`}
192 value={newModel}191 value={newModel}
193 onChange={(e) => setNewModel(e.target.value)}192 onChange={(e) => setNewModel(e.target.value)}
194 onKeyDown={(e) => {193 onKeyDown={(e) => {
195 if (e.key === "Enter") handleAddProject();194 if (e.key === "Enter") handleAddProject();
196 if (e.key === "Escape") setAddingProject(false);195 if (e.key === "Escape") setAddingProject(false);
197 }}196 }}
198 />197 >
198 {MODEL_OPTIONS.map((model) => (
199 <option key={model} value={model} className="bg-[#141414]">
200 {model}
201 </option>
202 ))}
203 </select>
199 <div className="flex gap-2">204 <div className="flex gap-2">
200 <button205 <button
201 type="button"206 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 {...@@ -36,6 +36,247 @@ export function formatTimestamp(isoDate: string): string {
36 }).format(new Date(isoDate));36 }).format(new Date(isoDate));
37}37}
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
39export function derivePhase(session: ProviderSession | null): SessionPhase {280export function derivePhase(session: ProviderSession | null): SessionPhase {
40 if (!session || session.status === "closed") return "disconnected";281 if (!session || session.status === "closed") return "disconnected";
41 if (session.status === "connecting") return "connecting";282 if (session.status === "connecting") return "connecting";
apps/renderer/src/store.ts+49-11
...@@ -9,6 +9,7 @@ import {...@@ -9,6 +9,7 @@ import {
9} from "react";9} from "react";
1010
11import type { ProviderEvent, ProviderSession } from "@acme/contracts";11import type { ProviderEvent, ProviderSession } from "@acme/contracts";
12import { DEFAULT_MODEL, resolveModelSlug } from "./model-logic";
12import { applyEventToMessages, evolveSession } from "./session-logic";13import { applyEventToMessages, evolveSession } from "./session-logic";
13import type { Project, Thread } from "./types";14import type { Project, Thread } from "./types";
1415
...@@ -40,7 +41,9 @@ export interface AppState {...@@ -40,7 +41,9 @@ export interface AppState {
40 diffOpen: boolean;41 diffOpen: boolean;
41}42}
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
45const initialState: AppState = {48const initialState: AppState = {
46 projects: [],49 projects: [],
...@@ -86,7 +89,8 @@ function sanitizeProjects(input: unknown): Project[] {...@@ -86,7 +89,8 @@ function sanitizeProjects(input: unknown): Project[] {
86 const id = asString(project.id);89 const id = asString(project.id);
87 const name = asString(project.name);90 const name = asString(project.name);
88 const cwd = asString(project.cwd);91 const cwd = asString(project.cwd);
89 const model = asString(project.model);92 const rawModel = asString(project.model);
93 const model = resolveModelSlug(rawModel);
90 const expanded = project.expanded;94 const expanded = project.expanded;
91 if (!id || !name || !cwd || !model || !isBoolean(expanded)) return null;95 if (!id || !name || !cwd || !model || !isBoolean(expanded)) return null;
9296
...@@ -101,6 +105,10 @@ function sanitizeProjects(input: unknown): Project[] {...@@ -101,6 +105,10 @@ function sanitizeProjects(input: unknown): Project[] {
101 .filter((project): project is Project => project !== null);105 .filter((project): project is Project => project !== null);
102}106}
103107
108function migrateLegacyDefaultModel(model: string): string {
109 return model === LEGACY_DEFAULT_MODEL ? DEFAULT_MODEL : model;
110}
111
104function sanitizeMessages(input: unknown): Thread["messages"] {112function sanitizeMessages(input: unknown): Thread["messages"] {
105 if (!Array.isArray(input)) return [];113 if (!Array.isArray(input)) return [];
106114
...@@ -149,7 +157,8 @@ function sanitizeThreads(input: unknown): AppState["threads"] {...@@ -149,7 +157,8 @@ function sanitizeThreads(input: unknown): AppState["threads"] {
149 const id = asString(thread.id);157 const id = asString(thread.id);
150 const projectId = asString(thread.projectId);158 const projectId = asString(thread.projectId);
151 const title = asString(thread.title);159 const title = asString(thread.title);
152 const model = asString(thread.model);160 const rawModel = asString(thread.model);
161 const model = resolveModelSlug(rawModel);
153 const createdAt = asString(thread.createdAt);162 const createdAt = asString(thread.createdAt);
154 if (!id || !projectId || !title || !model || !createdAt) return null;163 if (!id || !projectId || !title || !model || !createdAt) return null;
155164
...@@ -173,17 +182,30 @@ function readPersistedState(): AppState {...@@ -173,17 +182,30 @@ function readPersistedState(): AppState {
173 if (typeof window === "undefined") return initialState;182 if (typeof window === "undefined") return initialState;
174183
175 try {184 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;
177 if (!raw) return initialState;188 if (!raw) return initialState;
189 const isLegacyPayload = !rawCurrent && Boolean(rawLegacy);
178190
179 const parsed = asObject(JSON.parse(raw));191 const parsed = asObject(JSON.parse(raw));
180 if (!parsed) return initialState;192 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 }));
183 const projectIds = new Set(projects.map((project) => project.id));200 const projectIds = new Set(projects.map((project) => project.id));
184 const threads = sanitizeThreads(parsed.threads).filter((thread) =>201 const threads = sanitizeThreads(parsed.threads)
185 projectIds.has(thread.projectId),202 .map((thread) => ({
186 );203 ...thread,
204 model: isLegacyPayload
205 ? migrateLegacyDefaultModel(thread.model)
206 : thread.model,
207 }))
208 .filter((thread) => projectIds.has(thread.projectId));
187 const activeThreadId = asString(parsed.activeThreadId);209 const activeThreadId = asString(parsed.activeThreadId);
188 const hasActiveThread = Boolean(210 const hasActiveThread = Boolean(
189 activeThreadId && threads.some((thread) => thread.id === activeThreadId),211 activeThreadId && threads.some((thread) => thread.id === activeThreadId),
...@@ -227,6 +249,7 @@ function persistState(state: AppState): void {...@@ -227,6 +249,7 @@ function persistState(state: AppState): void {
227 PERSISTED_STATE_KEY,249 PERSISTED_STATE_KEY,
228 JSON.stringify(toPersistedState(state)),250 JSON.stringify(toPersistedState(state)),
229 );251 );
252 window.localStorage.removeItem(LEGACY_PERSISTED_STATE_KEY);
230 } catch {253 } catch {
231 // Ignore quota/storage errors to avoid breaking chat UX.254 // Ignore quota/storage errors to avoid breaking chat UX.
232 }255 }
...@@ -252,7 +275,16 @@ function findThreadBySessionId(...@@ -252,7 +275,16 @@ function findThreadBySessionId(
252function reducer(state: AppState, action: Action): AppState {275function reducer(state: AppState, action: Action): AppState {
253 switch (action.type) {276 switch (action.type) {
254 case "ADD_PROJECT":277 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
257 case "TOGGLE_PROJECT":289 case "TOGGLE_PROJECT":
258 return {290 return {
...@@ -265,7 +297,13 @@ function reducer(state: AppState, action: Action): AppState {...@@ -265,7 +297,13 @@ function reducer(state: AppState, action: Action): AppState {
265 case "ADD_THREAD":297 case "ADD_THREAD":
266 return {298 return {
267 ...state,299 ...state,
268 threads: [...state.threads, action.thread],300 threads: [
301 ...state.threads,
302 {
303 ...action.thread,
304 model: resolveModelSlug(action.thread.model),
305 },
306 ],
269 activeThreadId: action.thread.id,307 activeThreadId: action.thread.id,
270 };308 };
271309
...@@ -349,7 +387,7 @@ function reducer(state: AppState, action: Action): AppState {...@@ -349,7 +387,7 @@ function reducer(state: AppState, action: Action): AppState {
349 ...state,387 ...state,
350 threads: updateThread(state.threads, action.threadId, (t) => ({388 threads: updateThread(state.threads, action.threadId, (t) => ({
351 ...t,389 ...t,
352 model: action.model,390 model: resolveModelSlug(action.model),
353 })),391 })),
354 };392 };
355393
packages/contracts/src/ipc.ts-4
...@@ -2,8 +2,6 @@ import type { AgentConfig, AgentExit, OutputChunk } from "./agent";...@@ -2,8 +2,6 @@ import type { AgentConfig, AgentExit, OutputChunk } from "./agent";
2import type {2import type {
3 ProviderEvent,3 ProviderEvent,
4 ProviderInterruptTurnInput,4 ProviderInterruptTurnInput,
5 ProviderListModelsInput,
6 ProviderModel,
7 ProviderSendTurnInput,5 ProviderSendTurnInput,
8 ProviderSession,6 ProviderSession,
9 ProviderSessionStartInput,7 ProviderSessionStartInput,
...@@ -29,7 +27,6 @@ export const IPC_CHANNELS = {...@@ -29,7 +27,6 @@ export const IPC_CHANNELS = {
29 providerTurnInterrupt: "provider:turn:interrupt",27 providerTurnInterrupt: "provider:turn:interrupt",
30 providerSessionStop: "provider:session:stop",28 providerSessionStop: "provider:session:stop",
31 providerSessionList: "provider:session:list",29 providerSessionList: "provider:session:list",
32 providerModelList: "provider:model:list",
33 providerEvent: "provider:event",30 providerEvent: "provider:event",
34} as const;31} as const;
3532
...@@ -60,7 +57,6 @@ export interface NativeApi {...@@ -60,7 +57,6 @@ export interface NativeApi {
60 interruptTurn: (input: ProviderInterruptTurnInput) => Promise<void>;57 interruptTurn: (input: ProviderInterruptTurnInput) => Promise<void>;
61 stopSession: (input: ProviderStopSessionInput) => Promise<void>;58 stopSession: (input: ProviderStopSessionInput) => Promise<void>;
62 listSessions: () => Promise<ProviderSession[]>;59 listSessions: () => Promise<ProviderSession[]>;
63 listModels: (input?: ProviderListModelsInput) => Promise<ProviderModel[]>;
64 onEvent: (callback: (event: ProviderEvent) => void) => () => void;60 onEvent: (callback: (event: ProviderEvent) => void) => () => void;
65 };61 };
66}62}
packages/contracts/src/provider.test.ts+3-9
...@@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest";...@@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest";
22
3import {3import {
4 providerEventSchema,4 providerEventSchema,
5 providerListModelsInputSchema,
6 providerSendTurnInputSchema,5 providerSendTurnInputSchema,
7 providerSessionStartInputSchema,6 providerSessionStartInputSchema,
8} from "./provider";7} from "./provider";
...@@ -17,21 +16,16 @@ describe("providerSessionStartInputSchema", () => {...@@ -17,21 +16,16 @@ describe("providerSessionStartInputSchema", () => {
17});16});
1817
19describe("providerSendTurnInputSchema", () => {18describe("providerSendTurnInputSchema", () => {
20 it("trims input text and optional model", () => {19 it("trims input text and optional model/effort", () => {
21 const parsed = providerSendTurnInputSchema.parse({20 const parsed = providerSendTurnInputSchema.parse({
22 sessionId: "sess_1",21 sessionId: "sess_1",
23 input: " summarize this repo ",22 input: " summarize this repo ",
24 model: " gpt-5.2-codex ",23 model: " gpt-5.2-codex ",
24 effort: " high ",
25 });25 });
26 expect(parsed.input).toBe("summarize this repo");26 expect(parsed.input).toBe("summarize this repo");
27 expect(parsed.model).toBe("gpt-5.2-codex");27 expect(parsed.model).toBe("gpt-5.2-codex");
28 });28 expect(parsed.effort).toBe("high");
29});
30
31describe("providerListModelsInputSchema", () => {
32 it("defaults provider to codex", () => {
33 const parsed = providerListModelsInputSchema.parse({});
34 expect(parsed.provider).toBe("codex");
35 });29 });
36});30});
3731
packages/contracts/src/provider.ts+1-18
...@@ -48,6 +48,7 @@ export const providerSendTurnInputSchema = z.object({...@@ -48,6 +48,7 @@ export const providerSendTurnInputSchema = z.object({
48 sessionId: z.string().min(1),48 sessionId: z.string().min(1),
49 input: z.string().trim().min(1).max(120_000),49 input: z.string().trim().min(1).max(120_000),
50 model: z.string().trim().min(1).optional(),50 model: z.string().trim().min(1).optional(),
51 effort: z.string().trim().min(1).optional(),
51});52});
5253
53export const providerTurnStartResultSchema = z.object({54export const providerTurnStartResultSchema = z.object({
...@@ -64,20 +65,6 @@ export const providerStopSessionInputSchema = z.object({...@@ -64,20 +65,6 @@ export const providerStopSessionInputSchema = z.object({
64 sessionId: z.string().min(1),65 sessionId: z.string().min(1),
65});66});
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
81export const providerEventKindSchema = z.enum([68export const providerEventKindSchema = z.enum([
82 "session",69 "session",
83 "notification",70 "notification",
...@@ -120,9 +107,5 @@ export type ProviderInterruptTurnInput = z.input<...@@ -120,9 +107,5 @@ export type ProviderInterruptTurnInput = z.input<
120export type ProviderStopSessionInput = z.input<107export type ProviderStopSessionInput = z.input<
121 typeof providerStopSessionInputSchema108 typeof providerStopSessionInputSchema
122>;109>;
123export type ProviderModel = z.infer<typeof providerModelSchema>;
124export type ProviderListModelsInput = z.input<
125 typeof providerListModelsInputSchema
126>;
127export type ProviderEventKind = z.infer<typeof providerEventKindSchema>;110export type ProviderEventKind = z.infer<typeof providerEventKindSchema>;
128export type ProviderEvent = z.infer<typeof providerEventSchema>;111export type ProviderEvent = z.infer<typeof providerEventSchema>;