| 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 | 1 | ## TODO |
| 2 | 2 | |
| 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"; |
| 3 | 3 | import { ProviderManager } from "./providerManager"; |
| 4 | 4 | |
| 5 | 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 | 7 | const manager = new ProviderManager(); |
| 8 | 8 | const internals = manager as unknown as { |
| 9 | 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 | 37 | expect(internals.codex.listenerCount("event")).toBe(1); |
| 14 | 38 | manager.dispose(); |
| 15 | 39 | |
| 16 | 40 | 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); | |
| 20 | 43 | }); |
| 21 | 44 | |
| 22 | 45 | it("allows multiple dispose calls", () => { |
apps/desktop/src/providerManager.ts+104-8| ... | ... | @@ -23,14 +23,17 @@ export interface ProviderManagerEvents { |
| 23 | 23 | |
| 24 | 24 | export class ProviderManager extends EventEmitter<ProviderManagerEvents> { |
| 25 | 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 | 30 | private disposed = false; |
| 28 | 31 | private readonly onCodexEvent = (event: ProviderEvent) => { |
| 29 | 32 | if (this.disposed) { |
| 30 | 33 | return; |
| 31 | 34 | } |
| 32 | 35 | |
| 33 | this.logStream.write(`${JSON.stringify(event)}\n`); | |
| 36 | this.routeEventToThreadLog(event); | |
| 34 | 37 | this.emit("event", event); |
| 35 | 38 | }; |
| 36 | 39 | |
| ... | ... | @@ -38,10 +41,8 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> { |
| 38 | 41 | super(); |
| 39 | 42 | |
| 40 | 43 | 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 }); | |
| 45 | 46 | |
| 46 | 47 | this.codex.on("event", this.onCodexEvent); |
| 47 | 48 | } |
| ... | ... | @@ -52,7 +53,12 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> { |
| 52 | 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 | 64 | async sendTurn(raw: ProviderSendTurnInput): Promise<ProviderTurnStartResult> { |
| ... | ... | @@ -76,6 +82,8 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> { |
| 76 | 82 | stopSession(raw: ProviderStopSessionInput): void { |
| 77 | 83 | const input = providerStopSessionInputSchema.parse(raw); |
| 78 | 84 | this.codex.stopSession(input.sessionId); |
| 85 | this.sessionThreadIds.delete(input.sessionId); | |
| 86 | this.pendingEventsBySession.delete(input.sessionId); | |
| 79 | 87 | } |
| 80 | 88 | |
| 81 | 89 | listSessions(): ProviderSession[] { |
| ... | ... | @@ -93,6 +101,94 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> { |
| 93 | 101 | |
| 94 | 102 | this.disposed = true; |
| 95 | 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 | 1 | import { |
| 2 | 2 | type FormEvent, |
| 3 | Fragment, | |
| 3 | 4 | type KeyboardEvent, |
| 4 | 5 | useEffect, |
| 5 | 6 | useMemo, |
| ... | ... | @@ -18,6 +19,7 @@ import { |
| 18 | 19 | derivePhase, |
| 19 | 20 | deriveTimelineEntries, |
| 20 | 21 | deriveWorkLogEntries, |
| 22 | formatDuration, | |
| 21 | 23 | formatElapsed, |
| 22 | 24 | formatTimestamp, |
| 23 | 25 | readNativeApi, |
| ... | ... | @@ -67,12 +69,13 @@ export default function ChatView() { |
| 67 | 69 | ); |
| 68 | 70 | const phase = derivePhase(activeThread?.session ?? null); |
| 69 | 71 | const isWorking = phase === "running" || isSending || isConnecting; |
| 70 | const activeTurnId = activeThread?.session?.activeTurnId; | |
| 72 | const workLogTurnId = | |
| 73 | activeThread?.latestTurnId ?? activeThread?.session?.activeTurnId; | |
| 71 | 74 | const nowIso = new Date(nowTick).toISOString(); |
| 72 | 75 | const modelOptions = MODEL_OPTIONS; |
| 73 | 76 | const workLogEntries = useMemo( |
| 74 | () => deriveWorkLogEntries(activeThread?.events ?? [], activeTurnId), | |
| 75 | [activeThread?.events, activeTurnId], | |
| 77 | () => deriveWorkLogEntries(activeThread?.events ?? [], workLogTurnId), | |
| 78 | [activeThread?.events, workLogTurnId], | |
| 76 | 79 | ); |
| 77 | 80 | const assistantCompletionByItemId = useMemo(() => { |
| 78 | 81 | const map = new Map<string, string>(); |
| ... | ... | @@ -85,13 +88,54 @@ export default function ChatView() { |
| 85 | 88 | return map; |
| 86 | 89 | }, [activeThread?.events]); |
| 87 | 90 | 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], | |
| 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 | 140 | // Auto-scroll on new messages |
| 97 | 141 | const messageCount = activeThread?.messages.length ?? 0; |
| ... | ... | @@ -349,85 +393,96 @@ export default function ChatView() { |
| 349 | 393 | </div> |
| 350 | 394 | ) : ( |
| 351 | 395 | <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]" /> | |
| 385 | 410 | </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} | |
| 405 | 423 | </span> |
| 406 | </div> | |
| 424 | </> | |
| 425 | ) : ( | |
| 426 | timelineEntry.entry.label | |
| 407 | 427 | )} |
| 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)} | |
| 423 | 438 | </p> |
| 424 | 439 | </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 | ))} | |
| 429 | 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 | 486 | <div className="flex items-center pt-1"> |
| 432 | 487 | <span className="inline-flex items-center gap-[3px]"> |
| 433 | 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 | 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 | 25 | export default function Sidebar() { |
| 18 | 26 | const { state, dispatch } = useStore(); |
| 19 | 27 | const api = useMemo(() => readNativeApi(), []); |
| ... | ... | @@ -107,9 +115,15 @@ export default function Sidebar() { |
| 107 | 115 | {/* Project list */} |
| 108 | 116 | <nav className="flex-1 overflow-y-auto px-2"> |
| 109 | 117 | {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 | }); | |
| 113 | 127 | return ( |
| 114 | 128 | <div key={project.id} className="mb-1"> |
| 115 | 129 | {/* Project header */} |
| ... | ... | @@ -136,6 +150,9 @@ export default function Sidebar() { |
| 136 | 150 | <div className="ml-2 border-l border-white/[0.06] pl-2"> |
| 137 | 151 | {threads.map((thread) => { |
| 138 | 152 | const isActive = state.activeThreadId === thread.id; |
| 153 | const threadStatus = threadStatusLabel( | |
| 154 | thread.session?.status, | |
| 155 | ); | |
| 139 | 156 | return ( |
| 140 | 157 | <button |
| 141 | 158 | key={thread.id} |
| ... | ... | @@ -152,9 +169,19 @@ export default function Sidebar() { |
| 152 | 169 | }) |
| 153 | 170 | } |
| 154 | 171 | > |
| 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> | |
| 158 | 185 | <span className="shrink-0 text-[10px] text-[#a0a0a0]/30"> |
| 159 | 186 | {formatRelativeTime(thread.createdAt)} |
| 160 | 187 | </span> |
apps/renderer/src/session-logic.test.ts+58| ... | ... | @@ -5,6 +5,7 @@ import { |
| 5 | 5 | type WorkLogEntry, |
| 6 | 6 | applyEventToMessages, |
| 7 | 7 | deriveTimelineEntries, |
| 8 | deriveWorkLogEntries, | |
| 8 | 9 | evolveSession, |
| 9 | 10 | } from "./session-logic"; |
| 10 | 11 | import type { ChatMessage } from "./types"; |
| ... | ... | @@ -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 | 166 | describe("evolveSession", () => { |
| 109 | 167 | it("updates thread id when thread starts", () => { |
| 110 | 168 | const previous = makeSession(); |
apps/renderer/src/session-logic.ts+72-14| ... | ... | @@ -96,17 +96,26 @@ function normalizeItemType(raw: string | undefined): string { |
| 96 | 96 | return raw.replace(/[_-]/g, " ").replace(/\s+/g, " ").trim().toLowerCase(); |
| 97 | 97 | } |
| 98 | 98 | |
| 99 | function itemTypeMeta(type: string): { | |
| 100 | label: string; | |
| 101 | tone: WorkLogEntry["tone"]; | |
| 102 | } { | |
| 99 | function shouldDropItemType(type: string): boolean { | |
| 103 | 100 | if ( |
| 104 | 101 | type.includes("preamble") || |
| 105 | 102 | type.includes("reasoning") || |
| 106 | 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 | 119 | if ( |
| 111 | 120 | type.includes("tool") || |
| 112 | 121 | type.includes("command") || |
| ... | ... | @@ -146,6 +155,10 @@ function entryFromItemLifecycle(event: ProviderEvent): WorkLogEntry | null { |
| 146 | 155 | const payload = asObject(event.payload); |
| 147 | 156 | const item = asObject(payload?.item); |
| 148 | 157 | const normalizedType = normalizeItemType(asString(item?.type)); |
| 158 | if (shouldDropItemType(normalizedType) || shouldDropMethod(event.method)) { | |
| 159 | return null; | |
| 160 | } | |
| 161 | ||
| 149 | 162 | const meta = itemTypeMeta(normalizedType); |
| 150 | 163 | const isStarted = event.method === "item/started"; |
| 151 | 164 | const isCompleted = event.method === "item/completed"; |
| ... | ... | @@ -166,6 +179,7 @@ function entryFromItemLifecycle(event: ProviderEvent): WorkLogEntry | null { |
| 166 | 179 | |
| 167 | 180 | function entryFromRequest(event: ProviderEvent): WorkLogEntry | null { |
| 168 | 181 | if (event.kind !== "request") return null; |
| 182 | if (shouldDropMethod(event.method)) return null; | |
| 169 | 183 | |
| 170 | 184 | if (event.method.includes("commandExecution")) { |
| 171 | 185 | return { |
| ... | ... | @@ -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 | 223 | if (event.kind !== "notification") return null; |
| 224 | if (shouldDropMethod(event.method)) return null; | |
| 207 | 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 | 227 | return null; |
| 210 | 228 | } |
| 211 | 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 | 259 | const lifecycleEntry = entryFromItemLifecycle(event); |
| 214 | 260 | if (lifecycleEntry) return lifecycleEntry; |
| 215 | 261 | |
| 216 | 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 | 270 | return { |
| 218 | 271 | id: event.id, |
| 219 | 272 | createdAt: event.createdAt, |
| ... | ... | @@ -227,6 +280,7 @@ function entryFromNotification(event: ProviderEvent): WorkLogEntry | null { |
| 227 | 280 | |
| 228 | 281 | function entryFromError(event: ProviderEvent): WorkLogEntry | null { |
| 229 | 282 | if (event.kind !== "error") return null; |
| 283 | if (shouldDropMethod(event.method)) return null; | |
| 230 | 284 | |
| 231 | 285 | return { |
| 232 | 286 | id: event.id, |
| ... | ... | @@ -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 | 300 | export function deriveWorkLogEntries( |
| 241 | 301 | events: ProviderEvent[], |
| 242 | 302 | turnId: string | undefined, |
| ... | ... | @@ -246,10 +306,7 @@ export function deriveWorkLogEntries( |
| 246 | 306 | const turnStartedAtIso = turnId |
| 247 | 307 | ? ordered.find((event) => { |
| 248 | 308 | 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; | |
| 253 | 310 | })?.createdAt |
| 254 | 311 | : undefined; |
| 255 | 312 | const turnStartedAt = turnStartedAtIso |
| ... | ... | @@ -258,11 +315,12 @@ export function deriveWorkLogEntries( |
| 258 | 315 | |
| 259 | 316 | for (const event of ordered) { |
| 260 | 317 | if (turnId) { |
| 261 | if (event.turnId && event.turnId !== turnId) { | |
| 318 | const scopedTurnId = eventTurnId(event); | |
| 319 | if (scopedTurnId && scopedTurnId !== turnId) { | |
| 262 | 320 | continue; |
| 263 | 321 | } |
| 264 | 322 | |
| 265 | if (!event.turnId && !Number.isNaN(turnStartedAt)) { | |
| 323 | if (!scopedTurnId && !Number.isNaN(turnStartedAt)) { | |
| 266 | 324 | const eventAt = Date.parse(event.createdAt); |
| 267 | 325 | if (!Number.isNaN(eventAt) && eventAt < turnStartedAt) { |
| 268 | 326 | continue; |
| ... | ... | @@ -276,7 +334,7 @@ export function deriveWorkLogEntries( |
| 276 | 334 | continue; |
| 277 | 335 | } |
| 278 | 336 | |
| 279 | const fromNotification = entryFromNotification(event); | |
| 337 | const fromNotification = entryFromNotification(event, turnStartedAt); | |
| 280 | 338 | if (fromNotification) { |
| 281 | 339 | entries.push(fromNotification); |
| 282 | 340 | continue; |
apps/renderer/src/store.ts+62-1| ... | ... | @@ -11,7 +11,12 @@ import { |
| 11 | 11 | import type { ProviderEvent, ProviderSession } from "@acme/contracts"; |
| 12 | 12 | import { resolveModelSlug } from "./model-logic"; |
| 13 | 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 | 20 | import type { Project, Thread } from "./types"; |
| 16 | 21 | |
| 17 | 22 | // ── Actions ────────────────────────────────────────────────────────── |
| ... | ... | @@ -103,6 +108,57 @@ function findThreadBySessionId( |
| 103 | 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 | 162 | // ── Reducer ────────────────────────────────────────────────────────── |
| 107 | 163 | |
| 108 | 164 | function reducer(state: AppState, action: Action): AppState { |
| ... | ... | @@ -164,6 +220,7 @@ function reducer(state: AppState, action: Action): AppState { |
| 164 | 220 | events: [event, ...t.events].slice(0, 200), |
| 165 | 221 | error: |
| 166 | 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 | 233 | session: action.session, |
| 177 | 234 | events: [], |
| 178 | 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 | 28 | events: ProviderEvent[]; |
| 29 | 29 | error: string | null; |
| 30 | 30 | createdAt: string; |
| 31 | latestTurnId?: string | undefined; | |
| 32 | latestTurnStartedAt?: string | undefined; | |
| 33 | latestTurnCompletedAt?: string | undefined; | |
| 34 | latestTurnDurationMs?: number | undefined; | |
| 31 | 35 | } |