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 @@...@@ -1,9 +1,9 @@
1## TODO1## TODO
22
3- [ ] Fix sort order for threads (newest first)3- [x] Fix sort order for threads (newest first)
4- [ ] Indicate in thread list which threads are currently working4- [x] Indicate in thread list which threads are currently working
5- [ ] Logs should be per-thread, not a single events.txt5- [x] 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" events6- [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- [ ] 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)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- [ ] Better logging of "time to complete" (I want this more than the timestamps tbh)8- [x] 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"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";...@@ -3,20 +3,43 @@ import { describe, expect, it } from "vitest";
3import { ProviderManager } from "./providerManager";3import { ProviderManager } from "./providerManager";
44
5describe("ProviderManager", () => {5describe("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", () => {
7 const manager = new ProviderManager();7 const manager = new ProviderManager();
8 const internals = manager as unknown as {8 const internals = manager as unknown as {
9 codex: { listenerCount: (event: string) => number };9 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 >;
11 };23 };
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
13 expect(internals.codex.listenerCount("event")).toBe(1);37 expect(internals.codex.listenerCount("event")).toBe(1);
14 manager.dispose();38 manager.dispose();
1539
16 expect(internals.codex.listenerCount("event")).toBe(0);40 expect(internals.codex.listenerCount("event")).toBe(0);
17 expect(41 expect(stream.writableEnded || stream.destroyed).toBe(true);
18 internals.logStream.writableEnded || internals.logStream.destroyed,42 expect(internals.threadLogStreams.size).toBe(0);
19 ).toBe(true);
20 });43 });
2144
22 it("allows multiple dispose calls", () => {45 it("allows multiple dispose calls", () => {
apps/desktop/src/providerManager.ts+104-8
...@@ -23,14 +23,17 @@ export interface ProviderManagerEvents {...@@ -23,14 +23,17 @@ export interface ProviderManagerEvents {
2323
24export class ProviderManager extends EventEmitter<ProviderManagerEvents> {24export class ProviderManager extends EventEmitter<ProviderManagerEvents> {
25 private readonly codex = new CodexAppServerManager();25 private readonly codex = new CodexAppServerManager();
26 private readonly logStream: fs.WriteStream;26 private readonly 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[]>();
27 private disposed = false;30 private disposed = false;
28 private readonly onCodexEvent = (event: ProviderEvent) => {31 private readonly onCodexEvent = (event: ProviderEvent) => {
29 if (this.disposed) {32 if (this.disposed) {
30 return;33 return;
31 }34 }
3235
33 this.logStream.write(`${JSON.stringify(event)}\n`);36 this.routeEventToThreadLog(event);
34 this.emit("event", event);37 this.emit("event", event);
35 };38 };
3639
...@@ -38,10 +41,8 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> {...@@ -38,10 +41,8 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> {
38 super();41 super();
3942
40 const logsDir = path.resolve(process.cwd(), ".logs");43 const logsDir = path.resolve(process.cwd(), ".logs");
41 fs.mkdirSync(logsDir, { recursive: true });44 this.threadLogsDir = path.join(logsDir, "threads");
42 this.logStream = fs.createWriteStream(path.join(logsDir, "events.txt"), {45 fs.mkdirSync(this.threadLogsDir, { recursive: true });
43 flags: "a",
44 });
4546
46 this.codex.on("event", this.onCodexEvent);47 this.codex.on("event", this.onCodexEvent);
47 }48 }
...@@ -52,7 +53,12 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> {...@@ -52,7 +53,12 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> {
52 throw new Error(`Provider '${input.provider}' is not implemented yet.`);53 throw new Error(`Provider '${input.provider}' is not implemented yet.`);
53 }54 }
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;
56 }62 }
5763
58 async sendTurn(raw: ProviderSendTurnInput): Promise<ProviderTurnStartResult> {64 async sendTurn(raw: ProviderSendTurnInput): Promise<ProviderTurnStartResult> {
...@@ -76,6 +82,8 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> {...@@ -76,6 +82,8 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> {
76 stopSession(raw: ProviderStopSessionInput): void {82 stopSession(raw: ProviderStopSessionInput): void {
77 const input = providerStopSessionInputSchema.parse(raw);83 const input = providerStopSessionInputSchema.parse(raw);
78 this.codex.stopSession(input.sessionId);84 this.codex.stopSession(input.sessionId);
85 this.sessionThreadIds.delete(input.sessionId);
86 this.pendingEventsBySession.delete(input.sessionId);
79 }87 }
8088
81 listSessions(): ProviderSession[] {89 listSessions(): ProviderSession[] {
...@@ -93,6 +101,94 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> {...@@ -93,6 +101,94 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> {
93101
94 this.disposed = true;102 this.disposed = true;
95 this.codex.off("event", this.onCodexEvent);103 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;
97 }193 }
98}194}
apps/renderer/src/components/ChatView.tsx+137-82
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import {1import {
2 type FormEvent,2 type FormEvent,
3 Fragment,
3 type KeyboardEvent,4 type KeyboardEvent,
4 useEffect,5 useEffect,
5 useMemo,6 useMemo,
...@@ -18,6 +19,7 @@ import {...@@ -18,6 +19,7 @@ import {
18 derivePhase,19 derivePhase,
19 deriveTimelineEntries,20 deriveTimelineEntries,
20 deriveWorkLogEntries,21 deriveWorkLogEntries,
22 formatDuration,
21 formatElapsed,23 formatElapsed,
22 formatTimestamp,24 formatTimestamp,
23 readNativeApi,25 readNativeApi,
...@@ -67,12 +69,13 @@ export default function ChatView() {...@@ -67,12 +69,13 @@ export default function ChatView() {
67 );69 );
68 const phase = derivePhase(activeThread?.session ?? null);70 const phase = derivePhase(activeThread?.session ?? null);
69 const isWorking = phase === "running" || isSending || isConnecting;71 const isWorking = phase === "running" || isSending || isConnecting;
70 const activeTurnId = activeThread?.session?.activeTurnId;72 const workLogTurnId =
73 activeThread?.latestTurnId ?? activeThread?.session?.activeTurnId;
71 const nowIso = new Date(nowTick).toISOString();74 const nowIso = new Date(nowTick).toISOString();
72 const modelOptions = MODEL_OPTIONS;75 const modelOptions = MODEL_OPTIONS;
73 const workLogEntries = useMemo(76 const workLogEntries = useMemo(
74 () => deriveWorkLogEntries(activeThread?.events ?? [], activeTurnId),77 () => deriveWorkLogEntries(activeThread?.events ?? [], workLogTurnId),
75 [activeThread?.events, activeTurnId],78 [activeThread?.events, workLogTurnId],
76 );79 );
77 const assistantCompletionByItemId = useMemo(() => {80 const assistantCompletionByItemId = useMemo(() => {
78 const map = new Map<string, string>();81 const map = new Map<string, string>();
...@@ -85,13 +88,54 @@ export default function ChatView() {...@@ -85,13 +88,54 @@ export default function ChatView() {
85 return map;88 return map;
86 }, [activeThread?.events]);89 }, [activeThread?.events]);
87 const timelineEntries = useMemo(90 const timelineEntries = useMemo(
88 () =>91 () => deriveTimelineEntries(activeThread?.messages ?? [], workLogEntries),
89 deriveTimelineEntries(92 [activeThread?.messages, workLogEntries],
90 activeThread?.messages ?? [],
91 isWorking ? workLogEntries : [],
92 ),
93 [activeThread?.messages, isWorking, workLogEntries],
94 );93 );
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
96 // Auto-scroll on new messages140 // Auto-scroll on new messages
97 const messageCount = activeThread?.messages.length ?? 0;141 const messageCount = activeThread?.messages.length ?? 0;
...@@ -349,85 +393,96 @@ export default function ChatView() {...@@ -349,85 +393,96 @@ export default function ChatView() {
349 </div>393 </div>
350 ) : (394 ) : (
351 <div className="mx-auto max-w-3xl space-y-4">395 <div className="mx-auto max-w-3xl space-y-4">
352 {timelineEntries.map((timelineEntry) =>396 {timelineEntries.map((timelineEntry, index) => (
353 timelineEntry.kind === "work" ? (397 <Fragment key={timelineEntry.id}>
354 <div398 {timelineEntry.kind === "message" &&
355 key={timelineEntry.id}399 timelineEntry.message.role === "assistant" &&
356 className="border-l-2 border-white/[0.05] pl-4 py-1"400 (completionDividerBeforeEntryId === timelineEntry.id ||
357 >401 timelineEntries[index - 1]?.kind === "work") && (
358 <p402 <div className="my-3 flex items-center gap-3">
359 className={`py-[2px] text-[12px] leading-relaxed ${workToneClass(timelineEntry.entry.tone)}`}403 <span className="h-px flex-1 bg-white/[0.1]" />
360 >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">
361 {timelineEntry.entry.detail ? (405 {completionSummary
362 <>406 ? `Response • ${completionSummary}`
363 {timelineEntry.entry.label}407 : "Response"}
364 <span className="ml-1.5 font-mono text-[11px] opacity-60">408 </span>
365 {timelineEntry.entry.detail}409 <span className="h-px flex-1 bg-white/[0.1]" />
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>
385 </div>410 </div>
386 ) : (411 )}
387 <div className="border-l-2 border-white/[0.15] pl-4">412 {timelineEntry.kind === "work" ? (
388 <ChatMarkdown413 <div className="flex items-start gap-2 py-0.5 pl-1.5">
389 text={414 <span className="mt-[7px] h-1.5 w-1.5 shrink-0 rounded-full bg-white/[0.18]" />
390 timelineEntry.message.text ||415 <p
391 (timelineEntry.message.streaming416 className={`py-[2px] text-[11px] leading-relaxed ${workToneClass(timelineEntry.entry.tone)}`}
392 ? ""417 >
393 : "(empty response)")418 {timelineEntry.entry.detail ? (
394 }419 <>
395 />420 {timelineEntry.entry.label}
396 {timelineEntry.message.streaming && (421 <span className="ml-1.5 font-mono text-[11px] opacity-60">
397 <div className="pt-1.5">422 {timelineEntry.entry.detail}
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>
405 </span>423 </span>
406 </div>424 </>
425 ) : (
426 timelineEntry.entry.label
407 )}427 )}
408 <p className="mt-1.5 text-[10px] text-[#a0a0a0]/30">428 </p>
409 {formatMessageMeta(429 </div>
410 timelineEntry.message.createdAt,430 ) : timelineEntry.message.role === "user" ? (
411 timelineEntry.message.streaming431 <div className="flex justify-end">
412 ? formatElapsed(432 <div className="max-w-[80%] rounded-2xl rounded-br-sm border border-white/[0.08] bg-white/[0.05] px-4 py-3">
413 timelineEntry.message.createdAt,433 <pre className="whitespace-pre-wrap break-words font-mono text-sm leading-relaxed text-[#e0e0e0]">
414 nowIso,434 {timelineEntry.message.text}
415 )435 </pre>
416 : formatElapsed(436 <p className="mt-1.5 text-right text-[10px] text-[#a0a0a0]/30">
417 timelineEntry.message.createdAt,437 {formatTimestamp(timelineEntry.message.createdAt)}
418 assistantCompletionByItemId.get(
419 timelineEntry.message.id,
420 ),
421 ),
422 )}
423 </p>438 </p>
424 </div>439 </div>
425 )}440 </div>
426 </div>441 ) : (
427 ),442 <div className="px-1 py-0.5">
428 )}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 ))}
429 {isWorking && (483 {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]" />
431 <div className="flex items-center pt-1">486 <div className="flex items-center pt-1">
432 <span className="inline-flex items-center gap-[3px]">487 <span className="inline-flex items-center gap-[3px]">
433 <span className="h-1 w-1 rounded-full bg-white/20 animate-pulse" />488 <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 {...@@ -14,6 +14,14 @@ function formatRelativeTime(iso: string): string {
14 return `${Math.floor(hours / 24)}d ago`;14 return `${Math.floor(hours / 24)}d ago`;
15}15}
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
17export default function Sidebar() {25export default function Sidebar() {
18 const { state, dispatch } = useStore();26 const { state, dispatch } = useStore();
19 const api = useMemo(() => readNativeApi(), []);27 const api = useMemo(() => readNativeApi(), []);
...@@ -107,9 +115,15 @@ export default function Sidebar() {...@@ -107,9 +115,15 @@ export default function Sidebar() {
107 {/* Project list */}115 {/* Project list */}
108 <nav className="flex-1 overflow-y-auto px-2">116 <nav className="flex-1 overflow-y-auto px-2">
109 {state.projects.map((project) => {117 {state.projects.map((project) => {
110 const threads = state.threads.filter(118 const threads = state.threads
111 (t) => t.projectId === project.id,119 .filter((t) => t.projectId === project.id)
112 );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 });
113 return (127 return (
114 <div key={project.id} className="mb-1">128 <div key={project.id} className="mb-1">
115 {/* Project header */}129 {/* Project header */}
...@@ -136,6 +150,9 @@ export default function Sidebar() {...@@ -136,6 +150,9 @@ export default function Sidebar() {
136 <div className="ml-2 border-l border-white/[0.06] pl-2">150 <div className="ml-2 border-l border-white/[0.06] pl-2">
137 {threads.map((thread) => {151 {threads.map((thread) => {
138 const isActive = state.activeThreadId === thread.id;152 const isActive = state.activeThreadId === thread.id;
153 const threadStatus = threadStatusLabel(
154 thread.session?.status,
155 );
139 return (156 return (
140 <button157 <button
141 key={thread.id}158 key={thread.id}
...@@ -152,9 +169,19 @@ export default function Sidebar() {...@@ -152,9 +169,19 @@ export default function Sidebar() {
152 })169 })
153 }170 }
154 >171 >
155 <span className="flex-1 truncate text-xs">172 <div className="flex min-w-0 flex-1 items-center gap-1.5">
156 {thread.title}173 {threadStatus && (
157 </span>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>
158 <span className="shrink-0 text-[10px] text-[#a0a0a0]/30">185 <span className="shrink-0 text-[10px] text-[#a0a0a0]/30">
159 {formatRelativeTime(thread.createdAt)}186 {formatRelativeTime(thread.createdAt)}
160 </span>187 </span>
apps/renderer/src/session-logic.test.ts+58
...@@ -5,6 +5,7 @@ import {...@@ -5,6 +5,7 @@ import {
5 type WorkLogEntry,5 type WorkLogEntry,
6 applyEventToMessages,6 applyEventToMessages,
7 deriveTimelineEntries,7 deriveTimelineEntries,
8 deriveWorkLogEntries,
8 evolveSession,9 evolveSession,
9} from "./session-logic";10} from "./session-logic";
10import type { ChatMessage } from "./types";11import type { ChatMessage } from "./types";
...@@ -105,6 +106,63 @@ describe("deriveTimelineEntries", () => {...@@ -105,6 +106,63 @@ describe("deriveTimelineEntries", () => {
105 });106 });
106});107});
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
108describe("evolveSession", () => {166describe("evolveSession", () => {
109 it("updates thread id when thread starts", () => {167 it("updates thread id when thread starts", () => {
110 const previous = makeSession();168 const previous = makeSession();
apps/renderer/src/session-logic.ts+72-14
...@@ -96,17 +96,26 @@ function normalizeItemType(raw: string | undefined): string {...@@ -96,17 +96,26 @@ function normalizeItemType(raw: string | undefined): string {
96 return raw.replace(/[_-]/g, " ").replace(/\s+/g, " ").trim().toLowerCase();96 return raw.replace(/[_-]/g, " ").replace(/\s+/g, " ").trim().toLowerCase();
97}97}
9898
99function itemTypeMeta(type: string): {99function shouldDropItemType(type: string): boolean {
100 label: string;
101 tone: WorkLogEntry["tone"];
102} {
103 if (100 if (
104 type.includes("preamble") ||101 type.includes("preamble") ||
105 type.includes("reasoning") ||102 type.includes("reasoning") ||
106 type.includes("thought")103 type.includes("thought")
107 ) {104 ) {
108 return { label: "Preamble", tone: "thinking" };105 return true;
109 }106 }
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} {
110 if (119 if (
111 type.includes("tool") ||120 type.includes("tool") ||
112 type.includes("command") ||121 type.includes("command") ||
...@@ -146,6 +155,10 @@ function entryFromItemLifecycle(event: ProviderEvent): WorkLogEntry | null {...@@ -146,6 +155,10 @@ function entryFromItemLifecycle(event: ProviderEvent): WorkLogEntry | null {
146 const payload = asObject(event.payload);155 const payload = asObject(event.payload);
147 const item = asObject(payload?.item);156 const item = asObject(payload?.item);
148 const normalizedType = normalizeItemType(asString(item?.type));157 const normalizedType = normalizeItemType(asString(item?.type));
158 if (shouldDropItemType(normalizedType) || shouldDropMethod(event.method)) {
159 return null;
160 }
161
149 const meta = itemTypeMeta(normalizedType);162 const meta = itemTypeMeta(normalizedType);
150 const isStarted = event.method === "item/started";163 const isStarted = event.method === "item/started";
151 const isCompleted = event.method === "item/completed";164 const isCompleted = event.method === "item/completed";
...@@ -166,6 +179,7 @@ function entryFromItemLifecycle(event: ProviderEvent): WorkLogEntry | null {...@@ -166,6 +179,7 @@ function entryFromItemLifecycle(event: ProviderEvent): WorkLogEntry | null {
166179
167function entryFromRequest(event: ProviderEvent): WorkLogEntry | null {180function entryFromRequest(event: ProviderEvent): WorkLogEntry | null {
168 if (event.kind !== "request") return null;181 if (event.kind !== "request") return null;
182 if (shouldDropMethod(event.method)) return null;
169183
170 if (event.method.includes("commandExecution")) {184 if (event.method.includes("commandExecution")) {
171 return {185 return {
...@@ -202,18 +216,57 @@ function entryFromRequest(event: ProviderEvent): WorkLogEntry | null {...@@ -202,18 +216,57 @@ function entryFromRequest(event: ProviderEvent): WorkLogEntry | null {
202 };216 };
203}217}
204218
205function entryFromNotification(event: ProviderEvent): WorkLogEntry | null {219function entryFromNotification(
220 event: ProviderEvent,
221 turnStartedAt: number,
222): WorkLogEntry | null {
206 if (event.kind !== "notification") return null;223 if (event.kind !== "notification") return null;
224 if (shouldDropMethod(event.method)) return null;
207 if (event.method === "item/agentMessage/delta") return null;225 if (event.method === "item/agentMessage/delta") return null;
208 if (event.method === "turn/started" || event.method === "turn/completed") {226 if (event.method === "turn/started") {
209 return null;227 return null;
210 }228 }
211 if (event.method === "thread/started") return null;229 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
213 const lifecycleEntry = entryFromItemLifecycle(event);259 const lifecycleEntry = entryFromItemLifecycle(event);
214 if (lifecycleEntry) return lifecycleEntry;260 if (lifecycleEntry) return lifecycleEntry;
215261
216 if (event.method.startsWith("item/")) {262 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
217 return {270 return {
218 id: event.id,271 id: event.id,
219 createdAt: event.createdAt,272 createdAt: event.createdAt,
...@@ -227,6 +280,7 @@ function entryFromNotification(event: ProviderEvent): WorkLogEntry | null {...@@ -227,6 +280,7 @@ function entryFromNotification(event: ProviderEvent): WorkLogEntry | null {
227280
228function entryFromError(event: ProviderEvent): WorkLogEntry | null {281function entryFromError(event: ProviderEvent): WorkLogEntry | null {
229 if (event.kind !== "error") return null;282 if (event.kind !== "error") return null;
283 if (shouldDropMethod(event.method)) return null;
230284
231 return {285 return {
232 id: event.id,286 id: event.id,
...@@ -237,6 +291,12 @@ function entryFromError(event: ProviderEvent): WorkLogEntry | null {...@@ -237,6 +291,12 @@ function entryFromError(event: ProviderEvent): WorkLogEntry | null {
237 };291 };
238}292}
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
240export function deriveWorkLogEntries(300export function deriveWorkLogEntries(
241 events: ProviderEvent[],301 events: ProviderEvent[],
242 turnId: string | undefined,302 turnId: string | undefined,
...@@ -246,10 +306,7 @@ export function deriveWorkLogEntries(...@@ -246,10 +306,7 @@ export function deriveWorkLogEntries(
246 const turnStartedAtIso = turnId306 const turnStartedAtIso = turnId
247 ? ordered.find((event) => {307 ? ordered.find((event) => {
248 if (event.method !== "turn/started") return false;308 if (event.method !== "turn/started") return false;
249 const payload = asObject(event.payload);309 return eventTurnId(event) === turnId;
250 const turn = asObject(payload?.turn);
251 const startedTurnId = event.turnId ?? asString(turn?.id);
252 return startedTurnId === turnId;
253 })?.createdAt310 })?.createdAt
254 : undefined;311 : undefined;
255 const turnStartedAt = turnStartedAtIso312 const turnStartedAt = turnStartedAtIso
...@@ -258,11 +315,12 @@ export function deriveWorkLogEntries(...@@ -258,11 +315,12 @@ export function deriveWorkLogEntries(
258315
259 for (const event of ordered) {316 for (const event of ordered) {
260 if (turnId) {317 if (turnId) {
261 if (event.turnId && event.turnId !== turnId) {318 const scopedTurnId = eventTurnId(event);
319 if (scopedTurnId && scopedTurnId !== turnId) {
262 continue;320 continue;
263 }321 }
264322
265 if (!event.turnId && !Number.isNaN(turnStartedAt)) {323 if (!scopedTurnId && !Number.isNaN(turnStartedAt)) {
266 const eventAt = Date.parse(event.createdAt);324 const eventAt = Date.parse(event.createdAt);
267 if (!Number.isNaN(eventAt) && eventAt < turnStartedAt) {325 if (!Number.isNaN(eventAt) && eventAt < turnStartedAt) {
268 continue;326 continue;
...@@ -276,7 +334,7 @@ export function deriveWorkLogEntries(...@@ -276,7 +334,7 @@ export function deriveWorkLogEntries(
276 continue;334 continue;
277 }335 }
278336
279 const fromNotification = entryFromNotification(event);337 const fromNotification = entryFromNotification(event, turnStartedAt);
280 if (fromNotification) {338 if (fromNotification) {
281 entries.push(fromNotification);339 entries.push(fromNotification);
282 continue;340 continue;
apps/renderer/src/store.ts+62-1
...@@ -11,7 +11,12 @@ import {...@@ -11,7 +11,12 @@ import {
11import type { ProviderEvent, ProviderSession } from "@acme/contracts";11import type { ProviderEvent, ProviderSession } from "@acme/contracts";
12import { resolveModelSlug } from "./model-logic";12import { resolveModelSlug } from "./model-logic";
13import { hydratePersistedState, toPersistedState } from "./persistenceSchema";13import { hydratePersistedState, toPersistedState } from "./persistenceSchema";
14import { applyEventToMessages, evolveSession } from "./session-logic";14import {
15 applyEventToMessages,
16 asObject,
17 asString,
18 evolveSession,
19} from "./session-logic";
15import type { Project, Thread } from "./types";20import type { Project, Thread } from "./types";
1621
17// ── Actions ──────────────────────────────────────────────────────────22// ── Actions ──────────────────────────────────────────────────────────
...@@ -103,6 +108,57 @@ function findThreadBySessionId(...@@ -103,6 +108,57 @@ function findThreadBySessionId(
103 return threads.find((t) => t.session?.sessionId === sessionId);108 return threads.find((t) => t.session?.sessionId === sessionId);
104}109}
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
106// ── Reducer ──────────────────────────────────────────────────────────162// ── Reducer ──────────────────────────────────────────────────────────
107163
108function reducer(state: AppState, action: Action): AppState {164function reducer(state: AppState, action: Action): AppState {
...@@ -164,6 +220,7 @@ function reducer(state: AppState, action: Action): AppState {...@@ -164,6 +220,7 @@ function reducer(state: AppState, action: Action): AppState {
164 events: [event, ...t.events].slice(0, 200),220 events: [event, ...t.events].slice(0, 200),
165 error:221 error:
166 event.kind === "error" && event.message ? event.message : t.error,222 event.kind === "error" && event.message ? event.message : t.error,
223 ...updateTurnFields(t, event),
167 })),224 })),
168 };225 };
169 }226 }
...@@ -176,6 +233,10 @@ function reducer(state: AppState, action: Action): AppState {...@@ -176,6 +233,10 @@ function reducer(state: AppState, action: Action): AppState {
176 session: action.session,233 session: action.session,
177 events: [],234 events: [],
178 error: null,235 error: null,
236 latestTurnId: undefined,
237 latestTurnStartedAt: undefined,
238 latestTurnCompletedAt: undefined,
239 latestTurnDurationMs: undefined,
179 })),240 })),
180 };241 };
181242
apps/renderer/src/types.ts+4
...@@ -28,4 +28,8 @@ export interface Thread {...@@ -28,4 +28,8 @@ export interface Thread {
28 events: ProviderEvent[];28 events: ProviderEvent[];
29 error: string | null;29 error: string | null;
30 createdAt: string;30 createdAt: string;
31 latestTurnId?: string | undefined;
32 latestTurnStartedAt?: string | undefined;
33 latestTurnCompletedAt?: string | undefined;
34 latestTurnDurationMs?: number | undefined;
31}35}