authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-02-08 22:26:18-08:00
committergravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-02-08 22:26:18-08:00
log1bd823cd209fc67ef0bf6f31185e267fbdf8099a
tree11146c586fae852e9d1b528ae688de77724e2671
parent153a130a09e9d2d71b9b1ff0c477c07b7bd81c9a
signature Commit is signed but in an unrecognized format.

lots of small progress


9 files changed, 505 insertions(+), 123 deletions(-)

TODO.md+7-7
......@@ -1,9 +1,9 @@
11## TODO
22
3- [ ] Fix sort order for threads (newest first)
4- [ ] Indicate in thread list which threads are currently working
5- [ ] Logs should be per-thread, not a single events.txt
6- [ ] Audit the logs to identify which events are (and aren't) worth showing to the user, i.e. drop "preamble" and "work" events
7- [ ] Make the "end message" stand out from the work done prior (maybe just a bar between the two that says "worked for 10 minutes 31 seconds" or however long it was)
8- [ ] Better logging of "time to complete" (I want this more than the timestamps tbh)
9- [ ] Remove the left bars on messages to make UI feel "flatter"
3- [x] Fix sort order for threads (newest first)
4- [x] Indicate in thread list which threads are currently working
5- [x] Logs should be per-thread, not a single events.txt
6- [x] Audit the logs to identify which events are (and aren't) worth showing to the user, i.e. drop "preamble" and "work" events
7- [x] Make the "end message" stand out from the work done prior (maybe just a bar between the two that says "worked for 10 minutes 31 seconds" or however long it was)
8- [x] Better logging of "time to complete" (I want this more than the timestamps tbh)
9- [x] Remove the left bars on messages to make UI feel "flatter"
apps/desktop/src/providerManager.test.ts+28-5
......@@ -3,20 +3,43 @@ import { describe, expect, it } from "vitest";
33import { ProviderManager } from "./providerManager";
44
55describe("ProviderManager", () => {
6 it("detaches provider event listener and ends log stream on dispose", () => {
6 it("detaches provider event listener and ends thread log streams on dispose", () => {
77 const manager = new ProviderManager();
88 const internals = manager as unknown as {
99 codex: { listenerCount: (event: string) => number };
10 logStream: { writableEnded: boolean; destroyed: boolean };
10 onCodexEvent: (event: {
11 id: string;
12 kind: "notification";
13 provider: "codex";
14 sessionId: string;
15 createdAt: string;
16 method: string;
17 threadId: string;
18 }) => void;
19 threadLogStreams: Map<
20 string,
21 { writableEnded: boolean; destroyed: boolean }
22 >;
1123 };
24 internals.onCodexEvent({
25 id: "evt-1",
26 kind: "notification",
27 provider: "codex",
28 sessionId: "sess-1",
29 createdAt: new Date().toISOString(),
30 method: "thread/started",
31 threadId: "thread-1",
32 });
33 const stream = internals.threadLogStreams.get("thread-1");
34 expect(stream).toBeDefined();
35 if (!stream) return;
1236
1337 expect(internals.codex.listenerCount("event")).toBe(1);
1438 manager.dispose();
1539
1640 expect(internals.codex.listenerCount("event")).toBe(0);
17 expect(
18 internals.logStream.writableEnded || internals.logStream.destroyed,
19 ).toBe(true);
41 expect(stream.writableEnded || stream.destroyed).toBe(true);
42 expect(internals.threadLogStreams.size).toBe(0);
2043 });
2144
2245 it("allows multiple dispose calls", () => {
apps/desktop/src/providerManager.ts+104-8
......@@ -23,14 +23,17 @@ export interface ProviderManagerEvents {
2323
2424export class ProviderManager extends EventEmitter<ProviderManagerEvents> {
2525 private readonly codex = new CodexAppServerManager();
26 private readonly logStream: fs.WriteStream;
26 private readonly threadLogsDir: string;
27 private readonly threadLogStreams = new Map<string, fs.WriteStream>();
28 private readonly sessionThreadIds = new Map<string, string>();
29 private readonly pendingEventsBySession = new Map<string, ProviderEvent[]>();
2730 private disposed = false;
2831 private readonly onCodexEvent = (event: ProviderEvent) => {
2932 if (this.disposed) {
3033 return;
3134 }
3235
33 this.logStream.write(`${JSON.stringify(event)}\n`);
36 this.routeEventToThreadLog(event);
3437 this.emit("event", event);
3538 };
3639
......@@ -38,10 +41,8 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> {
3841 super();
3942
4043 const logsDir = path.resolve(process.cwd(), ".logs");
41 fs.mkdirSync(logsDir, { recursive: true });
42 this.logStream = fs.createWriteStream(path.join(logsDir, "events.txt"), {
43 flags: "a",
44 });
44 this.threadLogsDir = path.join(logsDir, "threads");
45 fs.mkdirSync(this.threadLogsDir, { recursive: true });
4546
4647 this.codex.on("event", this.onCodexEvent);
4748 }
......@@ -52,7 +53,12 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> {
5253 throw new Error(`Provider '${input.provider}' is not implemented yet.`);
5354 }
5455
55 return this.codex.startSession(input);
56 const session = await this.codex.startSession(input);
57 if (session.threadId) {
58 this.sessionThreadIds.set(session.sessionId, session.threadId);
59 this.flushPendingSessionEvents(session.sessionId, session.threadId);
60 }
61 return session;
5662 }
5763
5864 async sendTurn(raw: ProviderSendTurnInput): Promise<ProviderTurnStartResult> {
......@@ -76,6 +82,8 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> {
7682 stopSession(raw: ProviderStopSessionInput): void {
7783 const input = providerStopSessionInputSchema.parse(raw);
7884 this.codex.stopSession(input.sessionId);
85 this.sessionThreadIds.delete(input.sessionId);
86 this.pendingEventsBySession.delete(input.sessionId);
7987 }
8088
8189 listSessions(): ProviderSession[] {
......@@ -93,6 +101,94 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> {
93101
94102 this.disposed = true;
95103 this.codex.off("event", this.onCodexEvent);
96 this.logStream.end();
104 for (const stream of this.threadLogStreams.values()) {
105 stream.end();
106 }
107 this.threadLogStreams.clear();
108 this.sessionThreadIds.clear();
109 this.pendingEventsBySession.clear();
110 }
111
112 private routeEventToThreadLog(event: ProviderEvent): void {
113 const threadId = this.resolveThreadId(event);
114 if (!threadId) {
115 const pending = this.pendingEventsBySession.get(event.sessionId) ?? [];
116 pending.push(event);
117 this.pendingEventsBySession.set(event.sessionId, pending.slice(-100));
118 return;
119 }
120
121 this.flushPendingSessionEvents(event.sessionId, threadId);
122 this.writeThreadEvent(threadId, event);
123 }
124
125 private flushPendingSessionEvents(sessionId: string, threadId: string): void {
126 const pending = this.pendingEventsBySession.get(sessionId);
127 if (!pending || pending.length === 0) {
128 return;
129 }
130
131 for (const event of pending) {
132 this.writeThreadEvent(threadId, event);
133 }
134 this.pendingEventsBySession.delete(sessionId);
135 }
136
137 private resolveThreadId(event: ProviderEvent): string | undefined {
138 const fromPayload = this.readThreadIdFromPayload(event.payload);
139 const threadId =
140 event.threadId ??
141 fromPayload ??
142 this.sessionThreadIds.get(event.sessionId);
143
144 if (threadId) {
145 this.sessionThreadIds.set(event.sessionId, threadId);
146 }
147
148 return threadId;
149 }
150
151 private readThreadIdFromPayload(payload: unknown): string | undefined {
152 if (!payload || typeof payload !== "object") {
153 return undefined;
154 }
155
156 const record = payload as Record<string, unknown>;
157 const direct = record.threadId;
158 if (typeof direct === "string" && direct.length > 0) {
159 return direct;
160 }
161
162 const thread = record.thread;
163 if (!thread || typeof thread !== "object") {
164 return undefined;
165 }
166
167 const nested = (thread as Record<string, unknown>).id;
168 if (typeof nested === "string" && nested.length > 0) {
169 return nested;
170 }
171
172 return undefined;
173 }
174
175 private writeThreadEvent(threadId: string, event: ProviderEvent): void {
176 const stream = this.getOrCreateThreadLogStream(threadId);
177 stream.write(`${JSON.stringify(event)}\n`);
178 }
179
180 private getOrCreateThreadLogStream(threadId: string): fs.WriteStream {
181 const cached = this.threadLogStreams.get(threadId);
182 if (cached) {
183 return cached;
184 }
185
186 const safeThreadId = threadId.replace(/[^a-zA-Z0-9._-]/g, "_");
187 const stream = fs.createWriteStream(
188 path.join(this.threadLogsDir, `${safeThreadId}.events.ndjson`),
189 { flags: "a" },
190 );
191 this.threadLogStreams.set(threadId, stream);
192 return stream;
97193 }
98194}
apps/renderer/src/components/ChatView.tsx+137-82
......@@ -1,5 +1,6 @@
11import {
22 type FormEvent,
3 Fragment,
34 type KeyboardEvent,
45 useEffect,
56 useMemo,
......@@ -18,6 +19,7 @@ import {
1819 derivePhase,
1920 deriveTimelineEntries,
2021 deriveWorkLogEntries,
22 formatDuration,
2123 formatElapsed,
2224 formatTimestamp,
2325 readNativeApi,
......@@ -67,12 +69,13 @@ export default function ChatView() {
6769 );
6870 const phase = derivePhase(activeThread?.session ?? null);
6971 const isWorking = phase === "running" || isSending || isConnecting;
70 const activeTurnId = activeThread?.session?.activeTurnId;
72 const workLogTurnId =
73 activeThread?.latestTurnId ?? activeThread?.session?.activeTurnId;
7174 const nowIso = new Date(nowTick).toISOString();
7275 const modelOptions = MODEL_OPTIONS;
7376 const workLogEntries = useMemo(
74 () => deriveWorkLogEntries(activeThread?.events ?? [], activeTurnId),
75 [activeThread?.events, activeTurnId],
77 () => deriveWorkLogEntries(activeThread?.events ?? [], workLogTurnId),
78 [activeThread?.events, workLogTurnId],
7679 );
7780 const assistantCompletionByItemId = useMemo(() => {
7881 const map = new Map<string, string>();
......@@ -85,13 +88,54 @@ export default function ChatView() {
8588 return map;
8689 }, [activeThread?.events]);
8790 const timelineEntries = useMemo(
88 () =>
89 deriveTimelineEntries(
90 activeThread?.messages ?? [],
91 isWorking ? workLogEntries : [],
92 ),
93 [activeThread?.messages, isWorking, workLogEntries],
91 () => deriveTimelineEntries(activeThread?.messages ?? [], workLogEntries),
92 [activeThread?.messages, workLogEntries],
9493 );
94 const completionSummary = useMemo(() => {
95 if (!activeThread?.latestTurnStartedAt) return null;
96 if (!activeThread.latestTurnCompletedAt) return null;
97 if (workLogEntries.length === 0) return null;
98
99 if (
100 typeof activeThread.latestTurnDurationMs === "number" &&
101 Number.isFinite(activeThread.latestTurnDurationMs) &&
102 activeThread.latestTurnDurationMs >= 0
103 ) {
104 return `Worked for ${formatDuration(activeThread.latestTurnDurationMs)}`;
105 }
106
107 const elapsed = formatElapsed(
108 activeThread.latestTurnStartedAt,
109 activeThread.latestTurnCompletedAt,
110 );
111 return elapsed ? `Worked for ${elapsed}` : null;
112 }, [
113 activeThread?.latestTurnStartedAt,
114 activeThread?.latestTurnCompletedAt,
115 activeThread?.latestTurnDurationMs,
116 workLogEntries.length,
117 ]);
118 const completionDividerBeforeEntryId = useMemo(() => {
119 if (!activeThread?.latestTurnStartedAt) return null;
120 if (!activeThread.latestTurnCompletedAt) return null;
121 if (workLogEntries.length === 0) return null;
122
123 const turnStartedAt = Date.parse(activeThread.latestTurnStartedAt);
124 if (Number.isNaN(turnStartedAt)) return null;
125
126 const entry = timelineEntries.find((timelineEntry) => {
127 if (timelineEntry.kind !== "message") return false;
128 if (timelineEntry.message.role !== "assistant") return false;
129 const messageAt = Date.parse(timelineEntry.message.createdAt);
130 return !Number.isNaN(messageAt) && messageAt >= turnStartedAt;
131 });
132 return entry?.id ?? null;
133 }, [
134 activeThread?.latestTurnStartedAt,
135 activeThread?.latestTurnCompletedAt,
136 timelineEntries,
137 workLogEntries.length,
138 ]);
95139
96140 // Auto-scroll on new messages
97141 const messageCount = activeThread?.messages.length ?? 0;
......@@ -349,85 +393,96 @@ export default function ChatView() {
349393 </div>
350394 ) : (
351395 <div className="mx-auto max-w-3xl space-y-4">
352 {timelineEntries.map((timelineEntry) =>
353 timelineEntry.kind === "work" ? (
354 <div
355 key={timelineEntry.id}
356 className="border-l-2 border-white/[0.05] pl-4 py-1"
357 >
358 <p
359 className={`py-[2px] text-[12px] leading-relaxed ${workToneClass(timelineEntry.entry.tone)}`}
360 >
361 {timelineEntry.entry.detail ? (
362 <>
363 {timelineEntry.entry.label}
364 <span className="ml-1.5 font-mono text-[11px] opacity-60">
365 {timelineEntry.entry.detail}
366 </span>
367 </>
368 ) : (
369 timelineEntry.entry.label
370 )}
371 </p>
372 </div>
373 ) : (
374 <div key={timelineEntry.id}>
375 {timelineEntry.message.role === "user" ? (
376 <div className="flex justify-end">
377 <div className="max-w-[80%] rounded-2xl rounded-br-sm border border-white/[0.08] bg-white/[0.05] px-4 py-3">
378 <pre className="whitespace-pre-wrap break-words font-mono text-sm leading-relaxed text-[#e0e0e0]">
379 {timelineEntry.message.text}
380 </pre>
381 <p className="mt-1.5 text-right text-[10px] text-[#a0a0a0]/30">
382 {formatTimestamp(timelineEntry.message.createdAt)}
383 </p>
384 </div>
396 {timelineEntries.map((timelineEntry, index) => (
397 <Fragment key={timelineEntry.id}>
398 {timelineEntry.kind === "message" &&
399 timelineEntry.message.role === "assistant" &&
400 (completionDividerBeforeEntryId === timelineEntry.id ||
401 timelineEntries[index - 1]?.kind === "work") && (
402 <div className="my-3 flex items-center gap-3">
403 <span className="h-px flex-1 bg-white/[0.1]" />
404 <span className="rounded-full border border-white/[0.12] bg-[#121212] px-2.5 py-1 text-[10px] uppercase tracking-[0.14em] text-[#9a9a9a]/80">
405 {completionSummary
406 ? `Response • ${completionSummary}`
407 : "Response"}
408 </span>
409 <span className="h-px flex-1 bg-white/[0.1]" />
385410 </div>
386 ) : (
387 <div className="border-l-2 border-white/[0.15] pl-4">
388 <ChatMarkdown
389 text={
390 timelineEntry.message.text ||
391 (timelineEntry.message.streaming
392 ? ""
393 : "(empty response)")
394 }
395 />
396 {timelineEntry.message.streaming && (
397 <div className="pt-1.5">
398 <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">
399 <span className="inline-flex gap-1">
400 <span className="h-1.5 w-1.5 animate-pulse rounded-full bg-sky-100/80" />
401 <span className="h-1.5 w-1.5 animate-pulse rounded-full bg-sky-100/80 [animation-delay:150ms]" />
402 <span className="h-1.5 w-1.5 animate-pulse rounded-full bg-sky-100/80 [animation-delay:300ms]" />
403 </span>
404 <span>Thinking</span>
411 )}
412 {timelineEntry.kind === "work" ? (
413 <div className="flex items-start gap-2 py-0.5 pl-1.5">
414 <span className="mt-[7px] h-1.5 w-1.5 shrink-0 rounded-full bg-white/[0.18]" />
415 <p
416 className={`py-[2px] text-[11px] leading-relaxed ${workToneClass(timelineEntry.entry.tone)}`}
417 >
418 {timelineEntry.entry.detail ? (
419 <>
420 {timelineEntry.entry.label}
421 <span className="ml-1.5 font-mono text-[11px] opacity-60">
422 {timelineEntry.entry.detail}
405423 </span>
406 </div>
424 </>
425 ) : (
426 timelineEntry.entry.label
407427 )}
408 <p className="mt-1.5 text-[10px] text-[#a0a0a0]/30">
409 {formatMessageMeta(
410 timelineEntry.message.createdAt,
411 timelineEntry.message.streaming
412 ? formatElapsed(
413 timelineEntry.message.createdAt,
414 nowIso,
415 )
416 : formatElapsed(
417 timelineEntry.message.createdAt,
418 assistantCompletionByItemId.get(
419 timelineEntry.message.id,
420 ),
421 ),
422 )}
428 </p>
429 </div>
430 ) : timelineEntry.message.role === "user" ? (
431 <div className="flex justify-end">
432 <div className="max-w-[80%] rounded-2xl rounded-br-sm border border-white/[0.08] bg-white/[0.05] px-4 py-3">
433 <pre className="whitespace-pre-wrap break-words font-mono text-sm leading-relaxed text-[#e0e0e0]">
434 {timelineEntry.message.text}
435 </pre>
436 <p className="mt-1.5 text-right text-[10px] text-[#a0a0a0]/30">
437 {formatTimestamp(timelineEntry.message.createdAt)}
423438 </p>
424439 </div>
425 )}
426 </div>
427 ),
428 )}
440 </div>
441 ) : (
442 <div className="px-1 py-0.5">
443 <ChatMarkdown
444 text={
445 timelineEntry.message.text ||
446 (timelineEntry.message.streaming
447 ? ""
448 : "(empty response)")
449 }
450 />
451 {timelineEntry.message.streaming && (
452 <div className="pt-1.5">
453 <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">
454 <span className="inline-flex gap-1">
455 <span className="h-1.5 w-1.5 animate-pulse rounded-full bg-sky-100/80" />
456 <span className="h-1.5 w-1.5 animate-pulse rounded-full bg-sky-100/80 [animation-delay:150ms]" />
457 <span className="h-1.5 w-1.5 animate-pulse rounded-full bg-sky-100/80 [animation-delay:300ms]" />
458 </span>
459 <span>Thinking</span>
460 </span>
461 </div>
462 )}
463 <p className="mt-1.5 text-[10px] text-[#a0a0a0]/30">
464 {formatMessageMeta(
465 timelineEntry.message.createdAt,
466 timelineEntry.message.streaming
467 ? formatElapsed(
468 timelineEntry.message.createdAt,
469 nowIso,
470 )
471 : formatElapsed(
472 timelineEntry.message.createdAt,
473 assistantCompletionByItemId.get(
474 timelineEntry.message.id,
475 ),
476 ),
477 )}
478 </p>
479 </div>
480 )}
481 </Fragment>
482 ))}
429483 {isWorking && (
430 <div className="border-l-2 border-white/[0.05] pl-4 py-1">
484 <div className="flex items-center gap-2 py-0.5 pl-1.5">
485 <span className="h-1.5 w-1.5 shrink-0 rounded-full bg-white/[0.18]" />
431486 <div className="flex items-center pt-1">
432487 <span className="inline-flex items-center gap-[3px]">
433488 <span className="h-1 w-1 rounded-full bg-white/20 animate-pulse" />
apps/renderer/src/components/Sidebar.tsx+33-6
......@@ -14,6 +14,14 @@ function formatRelativeTime(iso: string): string {
1414 return `${Math.floor(hours / 24)}d ago`;
1515}
1616
17function threadStatusLabel(
18 status: "connecting" | "ready" | "running" | "error" | "closed" | undefined,
19): "Working" | "Connecting" | null {
20 if (status === "running") return "Working";
21 if (status === "connecting") return "Connecting";
22 return null;
23}
24
1725export default function Sidebar() {
1826 const { state, dispatch } = useStore();
1927 const api = useMemo(() => readNativeApi(), []);
......@@ -107,9 +115,15 @@ export default function Sidebar() {
107115 {/* Project list */}
108116 <nav className="flex-1 overflow-y-auto px-2">
109117 {state.projects.map((project) => {
110 const threads = state.threads.filter(
111 (t) => t.projectId === project.id,
112 );
118 const threads = state.threads
119 .filter((t) => t.projectId === project.id)
120 .sort((a, b) => {
121 const byDate =
122 new Date(b.createdAt).getTime() -
123 new Date(a.createdAt).getTime();
124 if (byDate !== 0) return byDate;
125 return b.id.localeCompare(a.id);
126 });
113127 return (
114128 <div key={project.id} className="mb-1">
115129 {/* Project header */}
......@@ -136,6 +150,9 @@ export default function Sidebar() {
136150 <div className="ml-2 border-l border-white/[0.06] pl-2">
137151 {threads.map((thread) => {
138152 const isActive = state.activeThreadId === thread.id;
153 const threadStatus = threadStatusLabel(
154 thread.session?.status,
155 );
139156 return (
140157 <button
141158 key={thread.id}
......@@ -152,9 +169,19 @@ export default function Sidebar() {
152169 })
153170 }
154171 >
155 <span className="flex-1 truncate text-xs">
156 {thread.title}
157 </span>
172 <div className="flex min-w-0 flex-1 items-center gap-1.5">
173 {threadStatus && (
174 <span className="inline-flex items-center gap-1 text-[10px] text-[#7ed8ff]/80">
175 <span className="h-1.5 w-1.5 animate-pulse rounded-full bg-sky-300/80" />
176 <span className="hidden xl:inline">
177 {threadStatus}
178 </span>
179 </span>
180 )}
181 <span className="min-w-0 flex-1 truncate text-xs">
182 {thread.title}
183 </span>
184 </div>
158185 <span className="shrink-0 text-[10px] text-[#a0a0a0]/30">
159186 {formatRelativeTime(thread.createdAt)}
160187 </span>
apps/renderer/src/session-logic.test.ts+58
......@@ -5,6 +5,7 @@ import {
55 type WorkLogEntry,
66 applyEventToMessages,
77 deriveTimelineEntries,
8 deriveWorkLogEntries,
89 evolveSession,
910} from "./session-logic";
1011import type { ChatMessage } from "./types";
......@@ -105,6 +106,63 @@ describe("deriveTimelineEntries", () => {
105106 });
106107});
107108
109describe("deriveWorkLogEntries", () => {
110 it("drops preamble/work events from the visible work log", () => {
111 const entries = deriveWorkLogEntries(
112 [
113 makeEvent({
114 id: "evt-1",
115 method: "item/started",
116 turnId: "turn-1",
117 createdAt: "2026-02-08T10:00:00.000Z",
118 payload: { item: { type: "preamble", text: "thinking" } },
119 }),
120 makeEvent({
121 id: "evt-2",
122 method: "item/started",
123 turnId: "turn-1",
124 createdAt: "2026-02-08T10:00:01.000Z",
125 payload: { item: { type: "work", text: "planning" } },
126 }),
127 makeEvent({
128 id: "evt-3",
129 method: "item/started",
130 turnId: "turn-1",
131 createdAt: "2026-02-08T10:00:02.000Z",
132 payload: { item: { type: "tool_call", command: "ls -la" } },
133 }),
134 ],
135 "turn-1",
136 );
137
138 expect(entries).toHaveLength(1);
139 expect(entries[0]?.label).toBe("Tool call");
140 });
141
142 it("logs turn completion with elapsed time", () => {
143 const entries = deriveWorkLogEntries(
144 [
145 makeEvent({
146 id: "evt-start",
147 method: "turn/started",
148 turnId: "turn-1",
149 createdAt: "2026-02-08T10:00:00.000Z",
150 }),
151 makeEvent({
152 id: "evt-complete",
153 method: "turn/completed",
154 turnId: "turn-1",
155 createdAt: "2026-02-08T10:00:10.000Z",
156 payload: { turn: { id: "turn-1", status: "completed" } },
157 }),
158 ],
159 "turn-1",
160 );
161
162 expect(entries[0]?.label).toBe("Turn complete in 10s");
163 });
164});
165
108166describe("evolveSession", () => {
109167 it("updates thread id when thread starts", () => {
110168 const previous = makeSession();
apps/renderer/src/session-logic.ts+72-14
......@@ -96,17 +96,26 @@ function normalizeItemType(raw: string | undefined): string {
9696 return raw.replace(/[_-]/g, " ").replace(/\s+/g, " ").trim().toLowerCase();
9797}
9898
99function itemTypeMeta(type: string): {
100 label: string;
101 tone: WorkLogEntry["tone"];
102} {
99function shouldDropItemType(type: string): boolean {
103100 if (
104101 type.includes("preamble") ||
105102 type.includes("reasoning") ||
106103 type.includes("thought")
107104 ) {
108 return { label: "Preamble", tone: "thinking" };
105 return true;
109106 }
107
108 return type === "work" || type.startsWith("work ");
109}
110
111function shouldDropMethod(method: string): boolean {
112 return /(^|\/)(preamble|work)(\/|$)/i.test(method);
113}
114
115function itemTypeMeta(type: string): {
116 label: string;
117 tone: WorkLogEntry["tone"];
118} {
110119 if (
111120 type.includes("tool") ||
112121 type.includes("command") ||
......@@ -146,6 +155,10 @@ function entryFromItemLifecycle(event: ProviderEvent): WorkLogEntry | null {
146155 const payload = asObject(event.payload);
147156 const item = asObject(payload?.item);
148157 const normalizedType = normalizeItemType(asString(item?.type));
158 if (shouldDropItemType(normalizedType) || shouldDropMethod(event.method)) {
159 return null;
160 }
161
149162 const meta = itemTypeMeta(normalizedType);
150163 const isStarted = event.method === "item/started";
151164 const isCompleted = event.method === "item/completed";
......@@ -166,6 +179,7 @@ function entryFromItemLifecycle(event: ProviderEvent): WorkLogEntry | null {
166179
167180function entryFromRequest(event: ProviderEvent): WorkLogEntry | null {
168181 if (event.kind !== "request") return null;
182 if (shouldDropMethod(event.method)) return null;
169183
170184 if (event.method.includes("commandExecution")) {
171185 return {
......@@ -202,18 +216,57 @@ function entryFromRequest(event: ProviderEvent): WorkLogEntry | null {
202216 };
203217}
204218
205function entryFromNotification(event: ProviderEvent): WorkLogEntry | null {
219function entryFromNotification(
220 event: ProviderEvent,
221 turnStartedAt: number,
222): WorkLogEntry | null {
206223 if (event.kind !== "notification") return null;
224 if (shouldDropMethod(event.method)) return null;
207225 if (event.method === "item/agentMessage/delta") return null;
208 if (event.method === "turn/started" || event.method === "turn/completed") {
226 if (event.method === "turn/started") {
209227 return null;
210228 }
211229 if (event.method === "thread/started") return null;
212230
231 if (event.method === "turn/completed") {
232 const payload = asObject(event.payload);
233 const turn = asObject(payload?.turn);
234 const status = asString(turn?.status);
235 const turnError = asObject(turn?.error);
236 const turnErrorMessage = asString(turnError?.message);
237 const eventAt = Date.parse(event.createdAt);
238 const durationMs =
239 Number.isNaN(turnStartedAt) ||
240 Number.isNaN(eventAt) ||
241 eventAt < turnStartedAt
242 ? undefined
243 : eventAt - turnStartedAt;
244
245 return {
246 id: event.id,
247 createdAt: event.createdAt,
248 label:
249 status === "failed"
250 ? "Turn failed"
251 : durationMs !== undefined
252 ? `Turn complete in ${formatDuration(durationMs)}`
253 : "Turn complete",
254 ...(turnErrorMessage ? { detail: truncateLine(turnErrorMessage) } : {}),
255 tone: status === "failed" ? "error" : "info",
256 };
257 }
258
213259 const lifecycleEntry = entryFromItemLifecycle(event);
214260 if (lifecycleEntry) return lifecycleEntry;
215261
216262 if (event.method.startsWith("item/")) {
263 const payload = asObject(event.payload);
264 const item = asObject(payload?.item);
265 const normalizedType = normalizeItemType(asString(item?.type));
266 if (shouldDropItemType(normalizedType)) {
267 return null;
268 }
269
217270 return {
218271 id: event.id,
219272 createdAt: event.createdAt,
......@@ -227,6 +280,7 @@ function entryFromNotification(event: ProviderEvent): WorkLogEntry | null {
227280
228281function entryFromError(event: ProviderEvent): WorkLogEntry | null {
229282 if (event.kind !== "error") return null;
283 if (shouldDropMethod(event.method)) return null;
230284
231285 return {
232286 id: event.id,
......@@ -237,6 +291,12 @@ function entryFromError(event: ProviderEvent): WorkLogEntry | null {
237291 };
238292}
239293
294function eventTurnId(event: ProviderEvent): string | undefined {
295 const payload = asObject(event.payload);
296 const turn = asObject(payload?.turn);
297 return event.turnId ?? asString(turn?.id);
298}
299
240300export function deriveWorkLogEntries(
241301 events: ProviderEvent[],
242302 turnId: string | undefined,
......@@ -246,10 +306,7 @@ export function deriveWorkLogEntries(
246306 const turnStartedAtIso = turnId
247307 ? ordered.find((event) => {
248308 if (event.method !== "turn/started") return false;
249 const payload = asObject(event.payload);
250 const turn = asObject(payload?.turn);
251 const startedTurnId = event.turnId ?? asString(turn?.id);
252 return startedTurnId === turnId;
309 return eventTurnId(event) === turnId;
253310 })?.createdAt
254311 : undefined;
255312 const turnStartedAt = turnStartedAtIso
......@@ -258,11 +315,12 @@ export function deriveWorkLogEntries(
258315
259316 for (const event of ordered) {
260317 if (turnId) {
261 if (event.turnId && event.turnId !== turnId) {
318 const scopedTurnId = eventTurnId(event);
319 if (scopedTurnId && scopedTurnId !== turnId) {
262320 continue;
263321 }
264322
265 if (!event.turnId && !Number.isNaN(turnStartedAt)) {
323 if (!scopedTurnId && !Number.isNaN(turnStartedAt)) {
266324 const eventAt = Date.parse(event.createdAt);
267325 if (!Number.isNaN(eventAt) && eventAt < turnStartedAt) {
268326 continue;
......@@ -276,7 +334,7 @@ export function deriveWorkLogEntries(
276334 continue;
277335 }
278336
279 const fromNotification = entryFromNotification(event);
337 const fromNotification = entryFromNotification(event, turnStartedAt);
280338 if (fromNotification) {
281339 entries.push(fromNotification);
282340 continue;
apps/renderer/src/store.ts+62-1
......@@ -11,7 +11,12 @@ import {
1111import type { ProviderEvent, ProviderSession } from "@acme/contracts";
1212import { resolveModelSlug } from "./model-logic";
1313import { hydratePersistedState, toPersistedState } from "./persistenceSchema";
14import { applyEventToMessages, evolveSession } from "./session-logic";
14import {
15 applyEventToMessages,
16 asObject,
17 asString,
18 evolveSession,
19} from "./session-logic";
1520import type { Project, Thread } from "./types";
1621
1722// ── Actions ──────────────────────────────────────────────────────────
......@@ -103,6 +108,57 @@ function findThreadBySessionId(
103108 return threads.find((t) => t.session?.sessionId === sessionId);
104109}
105110
111function getEventTurnId(event: ProviderEvent): string | undefined {
112 if (event.turnId) return event.turnId;
113 const payload = asObject(event.payload);
114 const turn = asObject(payload?.turn);
115 return asString(turn?.id);
116}
117
118function durationMs(startIso: string, endIso: string): number | undefined {
119 const start = Date.parse(startIso);
120 const end = Date.parse(endIso);
121 if (Number.isNaN(start) || Number.isNaN(end) || end < start) {
122 return undefined;
123 }
124
125 return end - start;
126}
127
128function updateTurnFields(
129 thread: Thread,
130 event: ProviderEvent,
131): Partial<Thread> {
132 if (event.method === "turn/started") {
133 return {
134 latestTurnId: getEventTurnId(event) ?? thread.latestTurnId,
135 latestTurnStartedAt: event.createdAt,
136 latestTurnCompletedAt: undefined,
137 latestTurnDurationMs: undefined,
138 };
139 }
140
141 if (event.method === "turn/completed") {
142 const completedTurnId = getEventTurnId(event) ?? thread.latestTurnId;
143 const startedAt =
144 completedTurnId && completedTurnId === thread.latestTurnId
145 ? thread.latestTurnStartedAt
146 : undefined;
147 const elapsed =
148 startedAt && startedAt.length > 0
149 ? durationMs(startedAt, event.createdAt)
150 : undefined;
151
152 return {
153 latestTurnId: completedTurnId ?? thread.latestTurnId,
154 latestTurnCompletedAt: event.createdAt,
155 latestTurnDurationMs: elapsed,
156 };
157 }
158
159 return {};
160}
161
106162// ── Reducer ──────────────────────────────────────────────────────────
107163
108164function reducer(state: AppState, action: Action): AppState {
......@@ -164,6 +220,7 @@ function reducer(state: AppState, action: Action): AppState {
164220 events: [event, ...t.events].slice(0, 200),
165221 error:
166222 event.kind === "error" && event.message ? event.message : t.error,
223 ...updateTurnFields(t, event),
167224 })),
168225 };
169226 }
......@@ -176,6 +233,10 @@ function reducer(state: AppState, action: Action): AppState {
176233 session: action.session,
177234 events: [],
178235 error: null,
236 latestTurnId: undefined,
237 latestTurnStartedAt: undefined,
238 latestTurnCompletedAt: undefined,
239 latestTurnDurationMs: undefined,
179240 })),
180241 };
181242
apps/renderer/src/types.ts+4
......@@ -28,4 +28,8 @@ export interface Thread {
2828 events: ProviderEvent[];
2929 error: string | null;
3030 createdAt: string;
31 latestTurnId?: string | undefined;
32 latestTurnStartedAt?: string | undefined;
33 latestTurnCompletedAt?: string | undefined;
34 latestTurnDurationMs?: number | undefined;
3135}