diff --git a/TODO.md b/TODO.md index ee5fd5846e178667d35f50ab4affbc469eede582..d9b475b1da12854eb04953109d18ab1df3c9a0f5 100644 --- a/TODO.md +++ b/TODO.md @@ -7,6 +7,6 @@ - [ ] Selecting folder should instantly create project - [ ] New projects should go on top - [ ] Projects should be sorted by latest thread update -- [ ] "Response" bar should ONLY appear on work completed -- [ ] "Response" bar should ONLY appear above the FINAL message in a series -- [ ] Reduce noise of tool calls (combine "command run" and "command completed", make tool calls a "box" with limited # of calls shown at a time, etc) +- [x] "Response" bar should ONLY appear on work completed +- [x] "Response" bar should ONLY appear above the FINAL message in a series +- [x] Reduce noise of tool calls (combine "command run" and "command completed", make tool calls a "box" with limited # of calls shown at a time, etc) diff --git a/apps/renderer/src/components/ChatView.tsx b/apps/renderer/src/components/ChatView.tsx index 3463a6bd73fa3cfd2032ad98c8928e103dda0798..f26662e77a7c1d2bd9353bc9d63b5e68cb4e3d98 100644 --- a/apps/renderer/src/components/ChatView.tsx +++ b/apps/renderer/src/components/ChatView.tsx @@ -50,6 +50,7 @@ function editorLabel(editor: (typeof EDITORS)[number]): string { } const LAST_EDITOR_KEY = "codething:last-editor"; +const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { if (tone === "error") return "text-rose-300/50 dark:text-rose-300/50"; @@ -93,7 +94,7 @@ function derivePendingApprovals( events: ProviderEvent[], ): PendingApprovalCard[] { const pending = new Map(); - const ordered = [...events].reverse(); + const ordered = [...events].toReversed(); for (const event of ordered) { if ( @@ -150,6 +151,9 @@ export default function ChatView() { const [respondingRequestIds, setRespondingRequestIds] = useState( [], ); + const [expandedWorkGroups, setExpandedWorkGroups] = useState< + Record + >({}); const [nowTick, setNowTick] = useState(() => Date.now()); const messagesEndRef = useRef(null); const textareaRef = useRef(null); @@ -169,6 +173,14 @@ export default function ChatView() { () => deriveWorkLogEntries(activeThread?.events ?? [], undefined), [activeThread?.events], ); + const latestTurnWorkEntries = useMemo( + () => + deriveWorkLogEntries( + activeThread?.events ?? [], + activeThread?.latestTurnId, + ), + [activeThread?.events, activeThread?.latestTurnId], + ); const pendingApprovals = useMemo( () => derivePendingApprovals(activeThread?.events ?? []), [activeThread?.events], @@ -190,7 +202,9 @@ export default function ChatView() { const completionSummary = useMemo(() => { if (!activeThread?.latestTurnStartedAt) return null; if (!activeThread.latestTurnCompletedAt) return null; - if (workLogEntries.length === 0) return null; + if (!latestTurnWorkEntries.some((entry) => entry.tone === "tool")) { + return null; + } if ( typeof activeThread.latestTurnDurationMs === "number" && @@ -209,28 +223,36 @@ export default function ChatView() { activeThread?.latestTurnStartedAt, activeThread?.latestTurnCompletedAt, activeThread?.latestTurnDurationMs, - workLogEntries.length, + latestTurnWorkEntries, ]); const completionDividerBeforeEntryId = useMemo(() => { if (!activeThread?.latestTurnStartedAt) return null; if (!activeThread.latestTurnCompletedAt) return null; - if (workLogEntries.length === 0) return null; + if (!completionSummary) return null; const turnStartedAt = Date.parse(activeThread.latestTurnStartedAt); + const turnCompletedAt = Date.parse(activeThread.latestTurnCompletedAt); if (Number.isNaN(turnStartedAt)) return null; + if (Number.isNaN(turnCompletedAt)) return null; - const entry = timelineEntries.find((timelineEntry) => { - if (timelineEntry.kind !== "message") return false; - if (timelineEntry.message.role !== "assistant") return false; + let inRangeMatch: string | null = null; + let fallbackMatch: string | null = null; + for (const timelineEntry of timelineEntries) { + if (timelineEntry.kind !== "message") continue; + if (timelineEntry.message.role !== "assistant") continue; const messageAt = Date.parse(timelineEntry.message.createdAt); - return !Number.isNaN(messageAt) && messageAt >= turnStartedAt; - }); - return entry?.id ?? null; + if (Number.isNaN(messageAt) || messageAt < turnStartedAt) continue; + fallbackMatch = timelineEntry.id; + if (messageAt <= turnCompletedAt) { + inRangeMatch = timelineEntry.id; + } + } + return inRangeMatch ?? fallbackMatch; }, [ - activeThread?.latestTurnStartedAt, activeThread?.latestTurnCompletedAt, + activeThread?.latestTurnStartedAt, + completionSummary, timelineEntries, - workLogEntries.length, ]); const runtimeSessionConfig = state.runtimeMode === "full-access" @@ -282,6 +304,10 @@ export default function ChatView() { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); }, [phase, workLogCount]); + useEffect(() => { + setExpandedWorkGroups({}); + }, [activeThread?.id]); + // Auto-resize textarea useEffect(() => { const ta = textareaRef.current; @@ -695,12 +721,123 @@ export default function ChatView() { ) : (
- {timelineEntries.map((timelineEntry, index) => ( - - {timelineEntry.kind === "message" && - timelineEntry.message.role === "assistant" && - (completionDividerBeforeEntryId === timelineEntry.id || - timelineEntries[index - 1]?.kind === "work") && ( + {timelineEntries.map((timelineEntry, index) => { + if ( + timelineEntry.kind === "work" && + timelineEntries[index - 1]?.kind === "work" + ) { + return null; + } + + const showCompletionDivider = + timelineEntry.kind === "message" && + timelineEntry.message.role === "assistant" && + completionDividerBeforeEntryId === timelineEntry.id; + + if (timelineEntry.kind === "work") { + const groupedEntries = [timelineEntry.entry]; + let cursor = index + 1; + while (cursor < timelineEntries.length) { + const nextEntry = timelineEntries[cursor]; + if (!nextEntry || nextEntry.kind !== "work") break; + groupedEntries.push(nextEntry.entry); + cursor += 1; + } + + const groupId = timelineEntry.id; + const isExpanded = expandedWorkGroups[groupId] ?? false; + const hasOverflow = + groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES; + const visibleEntries = + hasOverflow && !isExpanded + ? groupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES) + : groupedEntries; + const hiddenCount = groupedEntries.length - visibleEntries.length; + const onlyToolEntries = groupedEntries.every( + (entry) => entry.tone === "tool", + ); + const groupLabel = onlyToolEntries + ? groupedEntries.length === 1 + ? "Tool call" + : `Tool calls (${groupedEntries.length})` + : groupedEntries.length === 1 + ? "Work event" + : `Work log (${groupedEntries.length})`; + + return ( + +
+
+

+ {groupLabel} +

+ {hasOverflow && ( + + )} +
+
+ {visibleEntries.map((workEntry) => ( +
+ +

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

+
+ ))} +
+
+
+ ); + } + + if (timelineEntry.message.role === "user") { + return ( + +
+
+
+                          {timelineEntry.message.text}
+                        
+

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

+
+
+
+ ); + } + + return ( + + {showCompletionDivider && (
@@ -711,39 +848,6 @@ export default function ChatView() {
)} - {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 - -
- )}

{formatMessageMeta( timelineEntry.message.createdAt, @@ -782,9 +874,9 @@ export default function ChatView() { )}

- )} -
- ))} +
+ ); + })} {isWorking && (
diff --git a/apps/renderer/src/session-logic.test.ts b/apps/renderer/src/session-logic.test.ts index 200c97ca2dcc20d045568da3ffb838730f99806a..d7b832d019d51fd0a906f33f4accdd22babe766e 100644 --- a/apps/renderer/src/session-logic.test.ts +++ b/apps/renderer/src/session-logic.test.ts @@ -134,7 +134,7 @@ describe("deriveWorkLogEntries", () => { expect(entries[0]?.label).toBe("Tool call"); }); - it("logs turn completion with elapsed time", () => { + it("does not surface successful turn completion as a work-log row", () => { const entries = deriveWorkLogEntries( [ makeEvent({ @@ -154,7 +154,38 @@ describe("deriveWorkLogEntries", () => { "turn-1", ); - expect(entries[0]?.label).toBe("Turn complete in 10s"); + expect(entries).toHaveLength(0); + }); + + it("shows failed turn completion in the work log", () => { + const entries = deriveWorkLogEntries( + [ + makeEvent({ + id: "evt-start", + method: "turn/started", + turnId: "turn-1", + createdAt: "2026-02-08T10:00:00.000Z", + }), + makeEvent({ + id: "evt-complete", + method: "turn/completed", + turnId: "turn-1", + createdAt: "2026-02-08T10:00:10.000Z", + payload: { + turn: { + id: "turn-1", + status: "failed", + error: { message: "sandbox denied" }, + }, + }, + }), + ], + "turn-1", + ); + + expect(entries).toHaveLength(1); + expect(entries[0]?.label).toBe("Turn failed"); + expect(entries[0]?.detail).toBe("sandbox denied"); }); it("hides reasoning and agent-message noise from the visible work log", () => { @@ -229,11 +260,45 @@ describe("deriveWorkLogEntries", () => { "turn-1", ); - expect(entries.map((entry) => entry.label)).toEqual([ - "Turn complete in 8.0s", - "Tool call complete", - "Tool call", - ]); + expect(entries.map((entry) => entry.label)).toEqual(["Tool call"]); + }); + + it("coalesces command start/completed lifecycle into one entry", () => { + const entries = deriveWorkLogEntries( + [ + makeEvent({ + id: "evt-command-start", + method: "item/started", + turnId: "turn-1", + createdAt: "2026-02-08T10:00:01.000Z", + payload: { + item: { + id: "item-command", + type: "command_execution", + command: "git status --short", + }, + }, + }), + makeEvent({ + id: "evt-command-complete", + method: "item/completed", + turnId: "turn-1", + createdAt: "2026-02-08T10:00:02.000Z", + payload: { + item: { + id: "item-command", + type: "command_execution", + command: "git status --short", + }, + }, + }), + ], + "turn-1", + ); + + expect(entries).toHaveLength(1); + expect(entries[0]?.label).toBe("Command run"); + expect(entries[0]?.detail).toBe("git status --short"); }); it("preserves full tool-call detail text without data truncation", () => { @@ -257,6 +322,50 @@ describe("deriveWorkLogEntries", () => { expect(entries[0]?.detail).toBe(longCommand); }); + it("hides generic tool-call rows that have no identifying detail", () => { + const entries = deriveWorkLogEntries( + [ + makeEvent({ + id: "evt-tool-start", + method: "item/started", + turnId: "turn-1", + createdAt: "2026-02-08T10:00:05.000Z", + payload: { + item: { id: "item-tool", type: "tool_call" }, + }, + }), + ], + "turn-1", + ); + + expect(entries).toHaveLength(0); + }); + + it("includes collab tool-call rows with tool name detail", () => { + const entries = deriveWorkLogEntries( + [ + makeEvent({ + id: "evt-collab-start", + method: "item/started", + turnId: "turn-1", + createdAt: "2026-02-08T10:00:05.000Z", + payload: { + item: { + id: "item-collab", + type: "collabAgentToolCall", + tool: "spawnAgent", + }, + }, + }), + ], + "turn-1", + ); + + expect(entries).toHaveLength(1); + expect(entries[0]?.label).toBe("Tool call"); + expect(entries[0]?.detail).toBe("spawnAgent"); + }); + it("can derive work-log entries across all turns when not scoped", () => { const entries = deriveWorkLogEntries( [ diff --git a/apps/renderer/src/session-logic.ts b/apps/renderer/src/session-logic.ts index 3329f8b1963c853dd802daa6f14a493e2d6ad35b..ca06978215fa9be92fa347bfd5d57c383f8be95f 100644 --- a/apps/renderer/src/session-logic.ts +++ b/apps/renderer/src/session-logic.ts @@ -135,15 +135,28 @@ function itemTypeMeta(type: string): { return { label: "Work item", tone: "info" }; } +interface ItemLifecycleCandidate { + id: string; + itemId?: string; + createdAt: string; + label: string; + detail?: string; + tone: WorkLogEntry["tone"]; + phase: "started" | "completed"; +} + function extractDetail( payload: Record | undefined, item: Record | undefined, ): string | undefined { const candidates = [ + asString(item?.command), + asString(item?.tool), + asString(item?.name), asString(item?.title), asString(item?.summary), asString(item?.text), - asString(item?.command), + asString(item?.prompt), asString(payload?.message), asString(payload?.prompt), asString(payload?.command), @@ -156,7 +169,9 @@ function extractDetail( return undefined; } -function entryFromItemLifecycle(event: ProviderEvent): WorkLogEntry | null { +function lifecycleCandidateFromItemEvent( + event: ProviderEvent, +): ItemLifecycleCandidate | null { const payload = asObject(event.payload); const item = asObject(payload?.item); const normalizedType = normalizeItemType(asString(item?.type)); @@ -174,14 +189,16 @@ function entryFromItemLifecycle(event: ProviderEvent): WorkLogEntry | null { return null; } - const label = isCompleted ? `${meta.label} complete` : meta.label; const detail = extractDetail(payload, item); + const itemId = event.itemId ?? asString(item?.id); return { id: event.id, + ...(itemId ? { itemId } : {}), createdAt: event.createdAt, - label, + label: meta.label, ...(detail ? { detail } : {}), tone: meta.tone, + phase: isCompleted ? "completed" : "started", }; } @@ -224,10 +241,7 @@ function entryFromRequest(event: ProviderEvent): WorkLogEntry | null { }; } -function entryFromNotification( - event: ProviderEvent, - turnStartedAt: number, -): WorkLogEntry | null { +function entryFromNotification(event: ProviderEvent): WorkLogEntry | null { if (event.kind !== "notification") return null; if (shouldDropMethod(event.method)) return null; if (event.method === "item/agentMessage/delta") return null; @@ -240,34 +254,22 @@ function entryFromNotification( const payload = asObject(event.payload); const turn = asObject(payload?.turn); const status = asString(turn?.status); + if (status !== "failed") { + return null; + } const turnError = asObject(turn?.error); const turnErrorMessage = asString(turnError?.message); const turnErrorDetail = normalizeDetail(turnErrorMessage); - const eventAt = Date.parse(event.createdAt); - const durationMs = - Number.isNaN(turnStartedAt) || - Number.isNaN(eventAt) || - eventAt < turnStartedAt - ? undefined - : eventAt - turnStartedAt; return { id: event.id, createdAt: event.createdAt, - label: - status === "failed" - ? "Turn failed" - : durationMs !== undefined - ? `Turn complete in ${formatDuration(durationMs)}` - : "Turn complete", + label: "Turn failed", ...(turnErrorDetail ? { detail: turnErrorDetail } : {}), - tone: status === "failed" ? "error" : "info", + tone: "error", }; } - const lifecycleEntry = entryFromItemLifecycle(event); - if (lifecycleEntry) return lifecycleEntry; - if (event.method.startsWith("item/")) return null; return null; @@ -307,19 +309,61 @@ export function deriveWorkLogEntries( : undefined; const turnStartedAt = turnStartedAtIso ? Date.parse(turnStartedAtIso) : Number.NaN; + const shouldIncludeEvent = (event: ProviderEvent): boolean => { + if (!turnId) return true; + const scopedTurnId = eventTurnId(event); + if (scopedTurnId && scopedTurnId !== turnId) { + return false; + } + + if (!scopedTurnId && !Number.isNaN(turnStartedAt)) { + const eventAt = Date.parse(event.createdAt); + if (!Number.isNaN(eventAt) && eventAt < turnStartedAt) { + return false; + } + } + + return true; + }; + + const completedLifecycleItemIds = new Set(); + for (const event of ordered) { + if (!shouldIncludeEvent(event)) continue; + const candidate = lifecycleCandidateFromItemEvent(event); + if (candidate?.phase === "completed" && candidate.itemId) { + completedLifecycleItemIds.add(candidate.itemId); + } + } + for (const event of ordered) { - if (turnId) { - const scopedTurnId = eventTurnId(event); - if (scopedTurnId && scopedTurnId !== turnId) { + if (!shouldIncludeEvent(event)) continue; + + const lifecycleCandidate = lifecycleCandidateFromItemEvent(event); + if (lifecycleCandidate) { + if ( + lifecycleCandidate.phase === "started" && + lifecycleCandidate.itemId && + completedLifecycleItemIds.has(lifecycleCandidate.itemId) + ) { continue; } - - if (!scopedTurnId && !Number.isNaN(turnStartedAt)) { - const eventAt = Date.parse(event.createdAt); - if (!Number.isNaN(eventAt) && eventAt < turnStartedAt) { - continue; - } + if ( + lifecycleCandidate.label === "Tool call" && + !lifecycleCandidate.detail + ) { + continue; } + + entries.push({ + id: lifecycleCandidate.id, + createdAt: lifecycleCandidate.createdAt, + label: lifecycleCandidate.label, + ...(lifecycleCandidate.detail + ? { detail: lifecycleCandidate.detail } + : {}), + tone: lifecycleCandidate.tone, + }); + continue; } const fromRequest = entryFromRequest(event); @@ -328,7 +372,7 @@ export function deriveWorkLogEntries( continue; } - const fromNotification = entryFromNotification(event, turnStartedAt); + const fromNotification = entryFromNotification(event); if (fromNotification) { entries.push(fromNotification); continue; diff --git a/apps/renderer/src/store.test.ts b/apps/renderer/src/store.test.ts index 666f11c70fbd127658240a3e6688a703668e065f..9cce45f27652ad5b4e29546b23591dadc349c362 100644 --- a/apps/renderer/src/store.test.ts +++ b/apps/renderer/src/store.test.ts @@ -93,7 +93,7 @@ describe("store reducer thread continuity", () => { expect(next.threads[0]?.codexThreadId).toBe("thr_backfilled"); }); - it("surfaces thread id mismatches without overwriting stored identity", () => { + it("ignores events from a foreign thread within the same session", () => { const state = makeState(makeThread({ codexThreadId: "thr_expected" })); const next = reducer(state, { type: "APPLY_EVENT", @@ -105,7 +105,30 @@ describe("store reducer thread continuity", () => { activeAssistantItemRef: { current: null }, }); - expect(next.threads[0]?.codexThreadId).toBe("thr_expected"); - expect(next.threads[0]?.error).toContain("Thread identity mismatch"); + expect(next).toBe(state); + }); + + it("rebases thread identity on thread/started during connect", () => { + const state = makeState( + makeThread({ + codexThreadId: "thr_old", + session: makeSession({ + status: "connecting", + threadId: "thr_old", + }), + }), + ); + const next = reducer(state, { + type: "APPLY_EVENT", + event: makeEvent({ + method: "thread/started", + threadId: "thr_new", + payload: { thread: { id: "thr_new" } }, + }), + activeAssistantItemRef: { current: null }, + }); + + expect(next.threads[0]?.codexThreadId).toBe("thr_new"); + expect(next.threads[0]?.session?.threadId).toBe("thr_new"); }); }); diff --git a/apps/renderer/src/store.ts b/apps/renderer/src/store.ts index 1363a9a62420ea235904399866d5d5a8b53da6a6..a93c77750c365b0abb78a168e1b4535b47f6085c 100644 --- a/apps/renderer/src/store.ts +++ b/apps/renderer/src/store.ts @@ -132,7 +132,37 @@ function getEventTurnId(event: ProviderEvent): string | undefined { function getEventThreadId(event: ProviderEvent): string | undefined { if (event.threadId) return event.threadId; const payload = asObject(event.payload); - return asString(payload?.threadId) ?? asString(asObject(payload?.thread)?.id); + const payloadThread = asObject(payload?.thread); + const payloadMessage = asObject(payload?.msg); + return ( + asString(payload?.threadId) ?? + asString(payloadThread?.id) ?? + asString(payload?.conversationId) ?? + asString(payload?.thread_id) ?? + asString(payloadMessage?.thread_id) + ); +} + +function shouldIgnoreForeignThreadEvent( + thread: Thread, + event: ProviderEvent, +): boolean { + const eventThreadId = getEventThreadId(event); + if (!eventThreadId) { + return false; + } + + const expectedThreadId = thread.session?.threadId ?? thread.codexThreadId; + if (!expectedThreadId || eventThreadId === expectedThreadId) { + return false; + } + + // During connect, accept a thread/started notification as an identity rebind. + if (event.method === "thread/started" && thread.session?.status === "connecting") { + return false; + } + + return true; } function durationMs(startIso: string, endIso: string): number | undefined { @@ -226,6 +256,7 @@ export function reducer(state: AppState, action: Action): AppState { const { event, activeAssistantItemRef } = action; const target = findThreadBySessionId(state.threads, event.sessionId); if (!target) return state; + if (shouldIgnoreForeignThreadEvent(target, event)) return state; return { ...state, @@ -233,17 +264,14 @@ export function reducer(state: AppState, action: Action): AppState { ...t, ...(() => { const eventThreadId = getEventThreadId(event); - const hasThreadMismatch = - t.codexThreadId !== null && - eventThreadId !== undefined && - eventThreadId !== t.codexThreadId; - const threadMismatchError = hasThreadMismatch - ? `Thread identity mismatch: expected ${t.codexThreadId}, received ${eventThreadId}.` - : null; + const shouldRebindIdentity = + event.method === "thread/started" && + t.session?.status === "connecting"; return { - codexThreadId: t.codexThreadId ?? eventThreadId ?? null, + codexThreadId: shouldRebindIdentity + ? eventThreadId ?? t.codexThreadId + : t.codexThreadId ?? eventThreadId ?? null, error: - threadMismatchError ?? (event.kind === "error" && event.message ? event.message : t.error),