diff --git a/TODO.md b/TODO.md index e4b6e4b62be7fe06a1bffd01a8102e52bad217da..d825f3db9d7af6c48c5af68acf1453a4945ed481 100644 --- a/TODO.md +++ b/TODO.md @@ -1,9 +1,9 @@ ## TODO -- [ ] Fix sort order for threads (newest first) -- [ ] Indicate in thread list which threads are currently working -- [ ] Logs should be per-thread, not a single events.txt -- [ ] Audit the logs to identify which events are (and aren't) worth showing to the user, i.e. drop "preamble" and "work" events -- [ ] 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) -- [ ] Better logging of "time to complete" (I want this more than the timestamps tbh) -- [ ] Remove the left bars on messages to make UI feel "flatter" +- [x] Fix sort order for threads (newest first) +- [x] Indicate in thread list which threads are currently working +- [x] Logs should be per-thread, not a single events.txt +- [x] Audit the logs to identify which events are (and aren't) worth showing to the user, i.e. drop "preamble" and "work" events +- [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) +- [x] Better logging of "time to complete" (I want this more than the timestamps tbh) +- [x] Remove the left bars on messages to make UI feel "flatter" diff --git a/apps/desktop/src/providerManager.test.ts b/apps/desktop/src/providerManager.test.ts index ba44e312fdd93e8f37f0aea08ea0646e5aa42a04..3d3516c62373e303b83dae7d4d35a7725d4bac75 100644 --- a/apps/desktop/src/providerManager.test.ts +++ b/apps/desktop/src/providerManager.test.ts @@ -3,20 +3,43 @@ import { describe, expect, it } from "vitest"; import { ProviderManager } from "./providerManager"; describe("ProviderManager", () => { - it("detaches provider event listener and ends log stream on dispose", () => { + it("detaches provider event listener and ends thread log streams on dispose", () => { const manager = new ProviderManager(); const internals = manager as unknown as { codex: { listenerCount: (event: string) => number }; - logStream: { writableEnded: boolean; destroyed: boolean }; + onCodexEvent: (event: { + id: string; + kind: "notification"; + provider: "codex"; + sessionId: string; + createdAt: string; + method: string; + threadId: string; + }) => void; + threadLogStreams: Map< + string, + { writableEnded: boolean; destroyed: boolean } + >; }; + internals.onCodexEvent({ + id: "evt-1", + kind: "notification", + provider: "codex", + sessionId: "sess-1", + createdAt: new Date().toISOString(), + method: "thread/started", + threadId: "thread-1", + }); + const stream = internals.threadLogStreams.get("thread-1"); + expect(stream).toBeDefined(); + if (!stream) return; expect(internals.codex.listenerCount("event")).toBe(1); manager.dispose(); expect(internals.codex.listenerCount("event")).toBe(0); - expect( - internals.logStream.writableEnded || internals.logStream.destroyed, - ).toBe(true); + expect(stream.writableEnded || stream.destroyed).toBe(true); + expect(internals.threadLogStreams.size).toBe(0); }); it("allows multiple dispose calls", () => { diff --git a/apps/desktop/src/providerManager.ts b/apps/desktop/src/providerManager.ts index 68793311e96e761608b45cdd066fd3bc278d29d8..4d21b2f1f0e19ff1c9c1b3fb2a6ca3aba7097b37 100644 --- a/apps/desktop/src/providerManager.ts +++ b/apps/desktop/src/providerManager.ts @@ -23,14 +23,17 @@ export interface ProviderManagerEvents { export class ProviderManager extends EventEmitter { private readonly codex = new CodexAppServerManager(); - private readonly logStream: fs.WriteStream; + private readonly threadLogsDir: string; + private readonly threadLogStreams = new Map(); + private readonly sessionThreadIds = new Map(); + private readonly pendingEventsBySession = new Map(); private disposed = false; private readonly onCodexEvent = (event: ProviderEvent) => { if (this.disposed) { return; } - this.logStream.write(`${JSON.stringify(event)}\n`); + this.routeEventToThreadLog(event); this.emit("event", event); }; @@ -38,10 +41,8 @@ export class ProviderManager extends EventEmitter { super(); const logsDir = path.resolve(process.cwd(), ".logs"); - fs.mkdirSync(logsDir, { recursive: true }); - this.logStream = fs.createWriteStream(path.join(logsDir, "events.txt"), { - flags: "a", - }); + this.threadLogsDir = path.join(logsDir, "threads"); + fs.mkdirSync(this.threadLogsDir, { recursive: true }); this.codex.on("event", this.onCodexEvent); } @@ -52,7 +53,12 @@ export class ProviderManager extends EventEmitter { throw new Error(`Provider '${input.provider}' is not implemented yet.`); } - return this.codex.startSession(input); + const session = await this.codex.startSession(input); + if (session.threadId) { + this.sessionThreadIds.set(session.sessionId, session.threadId); + this.flushPendingSessionEvents(session.sessionId, session.threadId); + } + return session; } async sendTurn(raw: ProviderSendTurnInput): Promise { @@ -76,6 +82,8 @@ export class ProviderManager extends EventEmitter { stopSession(raw: ProviderStopSessionInput): void { const input = providerStopSessionInputSchema.parse(raw); this.codex.stopSession(input.sessionId); + this.sessionThreadIds.delete(input.sessionId); + this.pendingEventsBySession.delete(input.sessionId); } listSessions(): ProviderSession[] { @@ -93,6 +101,94 @@ export class ProviderManager extends EventEmitter { this.disposed = true; this.codex.off("event", this.onCodexEvent); - this.logStream.end(); + for (const stream of this.threadLogStreams.values()) { + stream.end(); + } + this.threadLogStreams.clear(); + this.sessionThreadIds.clear(); + this.pendingEventsBySession.clear(); + } + + private routeEventToThreadLog(event: ProviderEvent): void { + const threadId = this.resolveThreadId(event); + if (!threadId) { + const pending = this.pendingEventsBySession.get(event.sessionId) ?? []; + pending.push(event); + this.pendingEventsBySession.set(event.sessionId, pending.slice(-100)); + return; + } + + this.flushPendingSessionEvents(event.sessionId, threadId); + this.writeThreadEvent(threadId, event); + } + + private flushPendingSessionEvents(sessionId: string, threadId: string): void { + const pending = this.pendingEventsBySession.get(sessionId); + if (!pending || pending.length === 0) { + return; + } + + for (const event of pending) { + this.writeThreadEvent(threadId, event); + } + this.pendingEventsBySession.delete(sessionId); + } + + private resolveThreadId(event: ProviderEvent): string | undefined { + const fromPayload = this.readThreadIdFromPayload(event.payload); + const threadId = + event.threadId ?? + fromPayload ?? + this.sessionThreadIds.get(event.sessionId); + + if (threadId) { + this.sessionThreadIds.set(event.sessionId, threadId); + } + + return threadId; + } + + private readThreadIdFromPayload(payload: unknown): string | undefined { + if (!payload || typeof payload !== "object") { + return undefined; + } + + const record = payload as Record; + const direct = record.threadId; + if (typeof direct === "string" && direct.length > 0) { + return direct; + } + + const thread = record.thread; + if (!thread || typeof thread !== "object") { + return undefined; + } + + const nested = (thread as Record).id; + if (typeof nested === "string" && nested.length > 0) { + return nested; + } + + return undefined; + } + + private writeThreadEvent(threadId: string, event: ProviderEvent): void { + const stream = this.getOrCreateThreadLogStream(threadId); + stream.write(`${JSON.stringify(event)}\n`); + } + + private getOrCreateThreadLogStream(threadId: string): fs.WriteStream { + const cached = this.threadLogStreams.get(threadId); + if (cached) { + return cached; + } + + const safeThreadId = threadId.replace(/[^a-zA-Z0-9._-]/g, "_"); + const stream = fs.createWriteStream( + path.join(this.threadLogsDir, `${safeThreadId}.events.ndjson`), + { flags: "a" }, + ); + this.threadLogStreams.set(threadId, stream); + return stream; } } diff --git a/apps/renderer/src/components/ChatView.tsx b/apps/renderer/src/components/ChatView.tsx index 641bc58164693b300635b94eef7814838f40e207..dd53ae8510c74ba1cf9b4f001471c11cb30ff369 100644 --- a/apps/renderer/src/components/ChatView.tsx +++ b/apps/renderer/src/components/ChatView.tsx @@ -1,5 +1,6 @@ import { type FormEvent, + Fragment, type KeyboardEvent, useEffect, useMemo, @@ -18,6 +19,7 @@ import { derivePhase, deriveTimelineEntries, deriveWorkLogEntries, + formatDuration, formatElapsed, formatTimestamp, readNativeApi, @@ -67,12 +69,13 @@ export default function ChatView() { ); const phase = derivePhase(activeThread?.session ?? null); const isWorking = phase === "running" || isSending || isConnecting; - const activeTurnId = activeThread?.session?.activeTurnId; + const workLogTurnId = + activeThread?.latestTurnId ?? activeThread?.session?.activeTurnId; const nowIso = new Date(nowTick).toISOString(); const modelOptions = MODEL_OPTIONS; const workLogEntries = useMemo( - () => deriveWorkLogEntries(activeThread?.events ?? [], activeTurnId), - [activeThread?.events, activeTurnId], + () => deriveWorkLogEntries(activeThread?.events ?? [], workLogTurnId), + [activeThread?.events, workLogTurnId], ); const assistantCompletionByItemId = useMemo(() => { const map = new Map(); @@ -85,13 +88,54 @@ export default function ChatView() { return map; }, [activeThread?.events]); const timelineEntries = useMemo( - () => - deriveTimelineEntries( - activeThread?.messages ?? [], - isWorking ? workLogEntries : [], - ), - [activeThread?.messages, isWorking, workLogEntries], + () => deriveTimelineEntries(activeThread?.messages ?? [], workLogEntries), + [activeThread?.messages, workLogEntries], ); + const completionSummary = useMemo(() => { + if (!activeThread?.latestTurnStartedAt) return null; + if (!activeThread.latestTurnCompletedAt) return null; + if (workLogEntries.length === 0) return null; + + if ( + typeof activeThread.latestTurnDurationMs === "number" && + Number.isFinite(activeThread.latestTurnDurationMs) && + activeThread.latestTurnDurationMs >= 0 + ) { + return `Worked for ${formatDuration(activeThread.latestTurnDurationMs)}`; + } + + const elapsed = formatElapsed( + activeThread.latestTurnStartedAt, + activeThread.latestTurnCompletedAt, + ); + return elapsed ? `Worked for ${elapsed}` : null; + }, [ + activeThread?.latestTurnStartedAt, + activeThread?.latestTurnCompletedAt, + activeThread?.latestTurnDurationMs, + workLogEntries.length, + ]); + const completionDividerBeforeEntryId = useMemo(() => { + if (!activeThread?.latestTurnStartedAt) return null; + if (!activeThread.latestTurnCompletedAt) return null; + if (workLogEntries.length === 0) return null; + + const turnStartedAt = Date.parse(activeThread.latestTurnStartedAt); + if (Number.isNaN(turnStartedAt)) return null; + + const entry = timelineEntries.find((timelineEntry) => { + if (timelineEntry.kind !== "message") return false; + if (timelineEntry.message.role !== "assistant") return false; + const messageAt = Date.parse(timelineEntry.message.createdAt); + return !Number.isNaN(messageAt) && messageAt >= turnStartedAt; + }); + return entry?.id ?? null; + }, [ + activeThread?.latestTurnStartedAt, + activeThread?.latestTurnCompletedAt, + timelineEntries, + workLogEntries.length, + ]); // Auto-scroll on new messages const messageCount = activeThread?.messages.length ?? 0; @@ -349,85 +393,96 @@ export default function ChatView() { ) : (
- {timelineEntries.map((timelineEntry) => - timelineEntry.kind === "work" ? ( -
-

- {timelineEntry.entry.detail ? ( - <> - {timelineEntry.entry.label} - - {timelineEntry.entry.detail} + {timelineEntries.map((timelineEntry, index) => ( + + {timelineEntry.kind === "message" && + timelineEntry.message.role === "assistant" && + (completionDividerBeforeEntryId === timelineEntry.id || + timelineEntries[index - 1]?.kind === "work") && ( +

+ + + {completionSummary + ? `Response • ${completionSummary}` + : "Response"} + + +
+ )} + {timelineEntry.kind === "work" ? ( +
+ +

+ {timelineEntry.entry.detail ? ( + <> + {timelineEntry.entry.label} + + {timelineEntry.entry.detail} + + + ) : ( + timelineEntry.entry.label + )} +

+
+ ) : timelineEntry.message.role === "user" ? ( +
+
+
+                        {timelineEntry.message.text}
+                      
+

+ {formatTimestamp(timelineEntry.message.createdAt)} +

+
+
+ ) : ( +
+ + {timelineEntry.message.streaming && ( +
+ + + + + + + Thinking - - ) : ( - timelineEntry.entry.label - )} -

-
- ) : ( -
- {timelineEntry.message.role === "user" ? ( -
-
-
-                          {timelineEntry.message.text}
-                        
-

- {formatTimestamp(timelineEntry.message.createdAt)} -

-
- ) : ( -
- - {timelineEntry.message.streaming && ( -
- - - - - - - Thinking - -
- )} -

- {formatMessageMeta( - timelineEntry.message.createdAt, - timelineEntry.message.streaming - ? formatElapsed( - timelineEntry.message.createdAt, - nowIso, - ) - : formatElapsed( - timelineEntry.message.createdAt, - assistantCompletionByItemId.get( - timelineEntry.message.id, - ), + )} +

+ {formatMessageMeta( + timelineEntry.message.createdAt, + timelineEntry.message.streaming + ? formatElapsed( + timelineEntry.message.createdAt, + nowIso, + ) + : formatElapsed( + timelineEntry.message.createdAt, + assistantCompletionByItemId.get( + timelineEntry.message.id, ), - )} -

-
- )} -
- ), - )} + ), + )} +

+
+ )} + + ))} {isWorking && ( -
+
+
diff --git a/apps/renderer/src/components/Sidebar.tsx b/apps/renderer/src/components/Sidebar.tsx index f476e0fd9e0334912d79a7d9a77a0a601afd9612..6b14530916131734c13fd43f2ee207698cad86b5 100644 --- a/apps/renderer/src/components/Sidebar.tsx +++ b/apps/renderer/src/components/Sidebar.tsx @@ -14,6 +14,14 @@ function formatRelativeTime(iso: string): string { return `${Math.floor(hours / 24)}d ago`; } +function threadStatusLabel( + status: "connecting" | "ready" | "running" | "error" | "closed" | undefined, +): "Working" | "Connecting" | null { + if (status === "running") return "Working"; + if (status === "connecting") return "Connecting"; + return null; +} + export default function Sidebar() { const { state, dispatch } = useStore(); const api = useMemo(() => readNativeApi(), []); @@ -107,9 +115,15 @@ export default function Sidebar() { {/* Project list */}