| author | |
| committer | |
| log | 1bd823cd209fc67ef0bf6f31185e267fbdf8099a |
| tree | 11146c586fae852e9d1b528ae688de77724e2671 |
| parent | 153a130a09e9d2d71b9b1ff0c477c07b7bd81c9a |
| signature | Commit is signed but in an unrecognized format. |
9 files changed, 505 insertions(+), 123 deletions(-)
TODO.md+7-7| ... | @@ -1,9 +1,9 @@ | ... | @@ -1,9 +1,9 @@ |
| 1 | ## TODO | 1 | ## TODO |
| 2 | 2 | ||
| 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 working | 4 | - [x] Indicate in thread list which threads are currently working |
| 5 | - [ ] Logs should be per-thread, not a single events.txt | 5 | - [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" events | 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 | - [ ] 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"; |
| 3 | import { ProviderManager } from "./providerManager"; | 3 | import { ProviderManager } from "./providerManager"; |
| 4 | 4 | ||
| 5 | describe("ProviderManager", () => { | 5 | 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", () => { |
| 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; | ||
| 12 | 36 | ||
| 13 | expect(internals.codex.listenerCount("event")).toBe(1); | 37 | expect(internals.codex.listenerCount("event")).toBe(1); |
| 14 | manager.dispose(); | 38 | manager.dispose(); |
| 15 | 39 | ||
| 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 | }); |
| 21 | 44 | ||
| 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 { |
| 23 | 23 | ||
| 24 | export class ProviderManager extends EventEmitter<ProviderManagerEvents> { | 24 | export 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 | } |
| 32 | 35 | ||
| 33 | this.logStream.write(`${JSON.stringify(event)}\n`); | 36 | this.routeEventToThreadLog(event); |
| 34 | this.emit("event", event); | 37 | this.emit("event", event); |
| 35 | }; | 38 | }; |
| 36 | 39 | ||
| ... | @@ -38,10 +41,8 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> { | ... | @@ -38,10 +41,8 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> { |
| 38 | super(); | 41 | super(); |
| 39 | 42 | ||
| 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 | }); | ||
| 45 | 46 | ||
| 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 | } |
| 54 | 55 | ||
| 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 | } |
| 57 | 63 | ||
| 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 | } |
| 80 | 88 | ||
| 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> { |
| 93 | 101 | ||
| 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 @@ |
| 1 | import { | 1 | import { |
| 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 | ]); | ||
| 95 | 139 | ||
| 96 | // Auto-scroll on new messages | 140 | // 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 | <div | 398 | {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 | <p | 402 | <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 | <ChatMarkdown | 413 | <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.streaming | 416 | 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.streaming | 431 | <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 | } |
| 16 | 16 | ||
| 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 | |||
| 17 | export default function Sidebar() { | 25 | export 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 | <button | 157 | <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"; |
| 10 | import type { ChatMessage } from "./types"; | 11 | import type { ChatMessage } from "./types"; |
| ... | @@ -105,6 +106,63 @@ describe("deriveTimelineEntries", () => { | ... | @@ -105,6 +106,63 @@ describe("deriveTimelineEntries", () => { |
| 105 | }); | 106 | }); |
| 106 | }); | 107 | }); |
| 107 | 108 | ||
| 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 | |||
| 108 | describe("evolveSession", () => { | 166 | describe("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 | } |
| 98 | 98 | ||
| 99 | function itemTypeMeta(type: string): { | 99 | function 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 | |||
| 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 | } { | ||
| 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 { |
| 166 | 179 | ||
| 167 | function entryFromRequest(event: ProviderEvent): WorkLogEntry | null { | 180 | function 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; | ||
| 169 | 183 | ||
| 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 | } |
| 204 | 218 | ||
| 205 | function entryFromNotification(event: ProviderEvent): WorkLogEntry | null { | 219 | function 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; |
| 212 | 230 | ||
| 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; |
| 215 | 261 | ||
| 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 { |
| 227 | 280 | ||
| 228 | function entryFromError(event: ProviderEvent): WorkLogEntry | null { | 281 | function 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; | ||
| 230 | 284 | ||
| 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 | } |
| 239 | 293 | ||
| 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 | |||
| 240 | export function deriveWorkLogEntries( | 300 | export 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 = turnId | 306 | 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 | })?.createdAt | 310 | })?.createdAt |
| 254 | : undefined; | 311 | : undefined; |
| 255 | const turnStartedAt = turnStartedAtIso | 312 | const turnStartedAt = turnStartedAtIso |
| ... | @@ -258,11 +315,12 @@ export function deriveWorkLogEntries( | ... | @@ -258,11 +315,12 @@ export function deriveWorkLogEntries( |
| 258 | 315 | ||
| 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 | } |
| 264 | 322 | ||
| 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 | } |
| 278 | 336 | ||
| 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 { |
| 11 | import type { ProviderEvent, ProviderSession } from "@acme/contracts"; | 11 | import type { ProviderEvent, ProviderSession } from "@acme/contracts"; |
| 12 | import { resolveModelSlug } from "./model-logic"; | 12 | import { resolveModelSlug } from "./model-logic"; |
| 13 | import { hydratePersistedState, toPersistedState } from "./persistenceSchema"; | 13 | 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"; | ||
| 15 | import type { Project, Thread } from "./types"; | 20 | import type { Project, Thread } from "./types"; |
| 16 | 21 | ||
| 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 | } |
| 105 | 110 | ||
| 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 | |||
| 106 | // ── Reducer ────────────────────────────────────────────────────────── | 162 | // ── Reducer ────────────────────────────────────────────────────────── |
| 107 | 163 | ||
| 108 | function reducer(state: AppState, action: Action): AppState { | 164 | function 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 | }; |
| 181 | 242 |
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 | } |