Unified · Split
| @@ -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" | |
| @@ -3,20 +3,43 @@ import { describe, expect, it } from "vitest"; · 2 unmodified lines | ||
| 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", () => { |
| @@ -23,14 +23,17 @@ export interface ProviderManagerEvents { · 22 unmodified lines | ||
| 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> { · 1 unmodified line | ||
| 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> { · 4 unmodified lines | ||
| 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> { · 17 unmodified lines | ||
| 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> { · 11 unmodified lines | ||
| 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 | } |
| @@ -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 { · 12 unmodified lines | ||
| 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() { · 43 unmodified lines | ||
| 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() { · 6 unmodified lines | ||
| 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() { · 251 unmodified lines | ||
| 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" /> |
| @@ -14,6 +14,14 @@ function formatRelativeTime(iso: string): string { · 13 unmodified lines | ||
| 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() { · 87 unmodified lines | ||
| 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() { · 20 unmodified lines | ||
| 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() { · 10 unmodified lines | ||
| 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> |
| @@ -5,6 +5,7 @@ import { · 4 unmodified lines | ||
| 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", () => { · 94 unmodified lines | ||
| 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(); |
| @@ -96,17 +96,26 @@ function normalizeItemType(raw: string | undefined): string { · 95 unmodified lines | ||
| 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 { · 33 unmodified lines | ||
| 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 { · 14 unmodified lines | ||
| 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 { · 30 unmodified lines | ||
| 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 { · 7 unmodified lines | ||
| 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 { · 4 unmodified lines | ||
| 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( · 3 unmodified lines | ||
| 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( · 2 unmodified lines | ||
| 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( · 7 unmodified lines | ||
| 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; |
| @@ -11,7 +11,12 @@ import { · 10 unmodified lines | ||
| 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( · 85 unmodified lines | ||
| 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 { · 55 unmodified lines | ||
| 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 { · 6 unmodified lines | ||
| 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 | |
| @@ -28,4 +28,8 @@ export interface Thread { · 27 unmodified lines | ||
| 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 | } |