History

lots of small progress

Theo Browne committed 9 months ago

Commit 1bd823c1 parent 153a1309 files · +505 −123Browse files

Unified · Split

9 files
  1. TODO.md +7 −7
  2. apps/desktop/src/providerManager.test.ts +28 −5
  3. apps/desktop/src/providerManager.ts +104 −8
  4. apps/renderer/src/components/ChatView.tsx +137 −82
  5. apps/renderer/src/components/Sidebar.tsx +33 −6
  6. apps/renderer/src/session-logic.test.ts +58 −0
  7. apps/renderer/src/session-logic.ts +72 −14
  8. apps/renderer/src/store.ts +62 −1
  9. apps/renderer/src/types.ts +4 −0
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"; · 2 unmodified lines
33 import { ProviderManager } from "./providerManager";
44
55 describe("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 { · 22 unmodified lines
2323
2424 export 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> { · 1 unmodified line
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> { · 4 unmodified lines
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> { · 17 unmodified lines
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> { · 11 unmodified lines
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 @@
11 import {
22 type FormEvent,
3+ Fragment,
34 type KeyboardEvent,
45 useEffect,
56 useMemo,
@@ -18,6 +19,7 @@ import { · 12 unmodified lines
1819 derivePhase,
1920 deriveTimelineEntries,
2021 deriveWorkLogEntries,
22+ formatDuration,
2123 formatElapsed,
2224 formatTimestamp,
2325 readNativeApi,
@@ -67,12 +69,13 @@ export default function ChatView() { · 43 unmodified lines
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() { · 6 unmodified lines
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() { · 251 unmodified lines
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 { · 13 unmodified lines
1414 return `${Math.floor(hours / 24)}d ago`;
1515 }
1616
17+function 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+
1725 export default function Sidebar() {
1826 const { state, dispatch } = useStore();
1927 const api = useMemo(() => readNativeApi(), []);
@@ -107,9 +115,15 @@ export default function Sidebar() { · 87 unmodified lines
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() { · 20 unmodified lines
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() { · 10 unmodified lines
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−0
@@ -5,6 +5,7 @@ import { · 4 unmodified lines
55 type WorkLogEntry,
66 applyEventToMessages,
77 deriveTimelineEntries,
8+ deriveWorkLogEntries,
89 evolveSession,
910 } from "./session-logic";
1011 import type { ChatMessage } from "./types";
@@ -105,6 +106,63 @@ describe("deriveTimelineEntries", () => { · 94 unmodified lines
105106 });
106107 });
107108
109+describe("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+
108166 describe("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 { · 95 unmodified lines
9696 return raw.replace(/[_-]/g, " ").replace(/\s+/g, " ").trim().toLowerCase();
9797 }
9898
99−function itemTypeMeta(type: string): {
100− label: string;
101− tone: WorkLogEntry["tone"];
102−} {
99+function 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+
111+function shouldDropMethod(method: string): boolean {
112+ return /(^|\/)(preamble|work)(\/|$)/i.test(method);
113+}
114+
115+function 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 { · 33 unmodified lines
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 { · 14 unmodified lines
166179
167180 function 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 { · 30 unmodified lines
202216 };
203217 }
204218
205−function entryFromNotification(event: ProviderEvent): WorkLogEntry | null {
219+function 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 { · 7 unmodified lines
227280
228281 function 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 { · 4 unmodified lines
237291 };
238292 }
239293
294+function 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+
240300 export function deriveWorkLogEntries(
241301 events: ProviderEvent[],
242302 turnId: string | undefined,
@@ -246,10 +306,7 @@ export function deriveWorkLogEntries( · 3 unmodified lines
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( · 2 unmodified lines
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( · 7 unmodified lines
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 { · 10 unmodified lines
1111 import type { ProviderEvent, ProviderSession } from "@acme/contracts";
1212 import { resolveModelSlug } from "./model-logic";
1313 import { hydratePersistedState, toPersistedState } from "./persistenceSchema";
14−import { applyEventToMessages, evolveSession } from "./session-logic";
14+import {
15+ applyEventToMessages,
16+ asObject,
17+ asString,
18+ evolveSession,
19+} from "./session-logic";
1520 import type { Project, Thread } from "./types";
1621
1722 // ── Actions ──────────────────────────────────────────────────────────
@@ -103,6 +108,57 @@ function findThreadBySessionId( · 85 unmodified lines
103108 return threads.find((t) => t.session?.sessionId === sessionId);
104109 }
105110
111+function 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+
118+function 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+
128+function 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
108164 function reducer(state: AppState, action: Action): AppState {
@@ -164,6 +220,7 @@ function reducer(state: AppState, action: Action): AppState { · 55 unmodified lines
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 { · 6 unmodified lines
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−0
@@ -28,4 +28,8 @@ export interface Thread { · 27 unmodified lines
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 }