feat(web): rich tool-call transcript — tool rows, inline diffs, thinking bursts, live status - #5471
feat(web): rich tool-call transcript — tool rows, inline diffs, thinking bursts, live status#5471CuriosityOS wants to merge 2 commits into
Conversation
…ing bursts, live status Renders provider tool calls as compact Claude Code-style rows in the chat transcript: a Tool(arg) header with a status dot, a one-line result summary, and inline unified diffs for file edits. Adds streamed thinking bursts (grouped reasoning deltas with a "Thought for Xs" completed row), a live work-status line under the working indicator, and context-compaction rows. Server side: ClaudeAdapter forwards a bounded toolUseResult summary (structuredPatch capped at 400 lines) in toolData; ingestion tracks reasoning deltas into thinking-burst activities, forwards toolCallId/data on item events, and surfaces compaction starts; snapshot payload projection now retains tool detail fields under a recursive clamp (4k strings / 100-item arrays / depth 6) so re-hydrated threads render the same rows as the live stream. Web side: session-logic normalizes Codex native items and ACP tool calls into a shared shape (tool name, input, diff hunks, result text), derives a live work status, and folds thinking bursts into the work log; the timeline renders the new rows with expand-on-click detail while keeping existing agent/subagent rendering untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
| return hunk ? capToolDiff(filePath, [hunk]) : null; | ||
| } | ||
|
|
||
| const content = typeof toolInput.content === "string" ? toolInput.content : null; |
There was a problem hiding this comment.
🟡 Medium src/session-logic.ts:1808
extractToolDiff treats every content-based write/write_file/update_file call as a file creation, prefixing every line with +. When such a tool overwrites an existing file, the transcript reports the entire new content as additions and omits all removed lines, producing incorrect add/remove counts. Additionally, an empty content value causes extractToolDiff to return null, dropping the diff for a valid overwrite of an existing file with empty contents. This happens because the content branch at line 1809 unconditionally builds all-+ lines and returns null when the resulting array is empty, with no awareness of prior file state. Consider tracking whether the target file already existed and emitting old content as - lines (or marking the hunk as an overwrite) so edits to existing files render correctly.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/session-logic.ts around line 1808:
`extractToolDiff` treats every `content`-based `write`/`write_file`/`update_file` call as a file creation, prefixing every line with `+`. When such a tool overwrites an existing file, the transcript reports the entire new content as additions and omits all removed lines, producing incorrect add/remove counts. Additionally, an empty `content` value causes `extractToolDiff` to return `null`, dropping the diff for a valid overwrite of an existing file with empty contents. This happens because the `content` branch at line 1809 unconditionally builds all-`+` lines and returns `null` when the resulting array is empty, with no awareness of prior file state. Consider tracking whether the target file already existed and emitting old content as `-` lines (or marking the hunk as an overwrite) so edits to existing files render correctly.
| thinkingBurstByThreadId.delete(threadId); | ||
| const durationMs = Math.max(0, Date.parse(burst.lastEventAt) - Date.parse(burst.startedAt)); |
There was a problem hiding this comment.
🟡 Medium Layers/ProviderRuntimeIngestion.ts:991
closeThinkingBurst deletes the burst from thinkingBurstByThreadId before dispatching the final thinking.progress and thinking.completed activities. If either orchestrationEngine.dispatch call fails, processInputSafely catches and logs the error, but the burst state is already deleted — so a later reasoning event can't retry the close, leaving the persisted thinking.progress activity permanently uncompleted. Consider moving thinkingBurstByThreadId.delete(threadId) to after both dispatches succeed.
| thinkingBurstByThreadId.delete(threadId); | |
| const durationMs = Math.max(0, Date.parse(burst.lastEventAt) - Date.parse(burst.startedAt)); | |
| const durationMs = Math.max(0, Date.parse(burst.lastEventAt) - Date.parse(burst.startedAt)); |
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts around lines 991-992:
`closeThinkingBurst` deletes the burst from `thinkingBurstByThreadId` before dispatching the final `thinking.progress` and `thinking.completed` activities. If either `orchestrationEngine.dispatch` call fails, `processInputSafely` catches and logs the error, but the burst state is already deleted — so a later reasoning event can't retry the close, leaving the persisted `thinking.progress` activity permanently uncompleted. Consider moving `thinkingBurstByThreadId.delete(threadId)` to after both dispatches succeed.
| case "request.opened": | ||
| case "user-input.requested": | ||
| case "turn.completed": | ||
| case "turn.aborted": | ||
| case "session.exited": |
There was a problem hiding this comment.
🟡 Medium Layers/ProviderRuntimeIngestion.ts:271
eventEndsThinkingBurst returns false for runtime.error and terminal session.state.changed events, so an open thinking burst is never closed when the session aborts during reasoning. The burst stays in thinkingBurstByThreadId and the transcript shows a live "Thinking" row indefinitely until some unrelated later event happens to close it. The function should also return true for these terminal/error cases so thinking.completed is emitted and the burst is flushed.
case "request.opened":
case "user-input.requested":
+ case "runtime.error":
+ case "session.state.changed":
case "turn.completed":
case "turn.aborted":
case "session.exited":🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts around lines 271-275:
`eventEndsThinkingBurst` returns `false` for `runtime.error` and terminal `session.state.changed` events, so an open thinking burst is never closed when the session aborts during reasoning. The burst stays in `thinkingBurstByThreadId` and the transcript shows a live "Thinking" row indefinitely until some unrelated later event happens to close it. The function should also return `true` for these terminal/error cases so `thinking.completed` is emitted and the burst is flushed.
| Effect.map((uuid) => CommandId.make(`provider:${event.eventId}:${tag}:${uuid}`)), | ||
| ); | ||
|
|
||
| const thinkingBurstByThreadId = new Map<ThreadId, ThinkingBurstState>(); |
There was a problem hiding this comment.
🟡 Medium Layers/ProviderRuntimeIngestion.ts:939
If the server restarts while a thinking burst is in progress, the persisted activity stream is left with a thinking.progress (or thinking.started) row that never gets a matching thinking.completed row. The burst state lives only in the in-memory thinkingBurstByThreadId map, so after a restart the map is empty and no closing event can be synthesized for the orphaned burst. The client transcript is left showing a permanently live "Thinking" entry, and any later reasoning deltas start a separate burst instead of continuing the original one. Consider persisting enough burst state to reconstruct the open burst on startup, or emitting a terminal row on startup for any thinking.started/thinking.progress activity that has no corresponding thinking.completed.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts around line 939:
If the server restarts while a thinking burst is in progress, the persisted activity stream is left with a `thinking.progress` (or `thinking.started`) row that never gets a matching `thinking.completed` row. The burst state lives only in the in-memory `thinkingBurstByThreadId` map, so after a restart the map is empty and no closing event can be synthesized for the orphaned burst. The client transcript is left showing a permanently live "Thinking" entry, and any later reasoning deltas start a separate burst instead of continuing the original one. Consider persisting enough burst state to reconstruct the open burst on startup, or emitting a terminal row on startup for any `thinking.started`/`thinking.progress` activity that has no corresponding `thinking.completed`.
| * so edits render like Claude's structured patches instead of a full-file | ||
| * remove/add wall. | ||
| */ | ||
| function contextualDiffHunk(oldText: string, newText: string): WorkLogToolDiffHunk | null { |
There was a problem hiding this comment.
🟡 Medium src/session-logic.ts:1979
contextualDiffHunk treats everything between the first and last changed line as a single remove/add block. For an ACP edit touching two distant locations in the same file, every unchanged line between them is emitted as both removed and added, inflating add/remove counts. When the file exceeds MAX_TOOL_DIFF_LINES, capToolDiff can exhaust the cap on these false deletions and drop the real additions or the second edit, producing materially incorrect inline diffs. Consider splitting disjoint changes into separate hunks or computing a real line diff instead of collapsing the middle span into one replacement.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/session-logic.ts around line 1979:
`contextualDiffHunk` treats everything between the first and last changed line as a single remove/add block. For an ACP edit touching two distant locations in the same file, every unchanged line between them is emitted as both removed and added, inflating add/remove counts. When the file exceeds `MAX_TOOL_DIFF_LINES`, `capToolDiff` can exhaust the cap on these false deletions and drop the real additions or the second edit, producing materially incorrect inline diffs. Consider splitting disjoint changes into separate hunks or computing a real line diff instead of collapsing the middle span into one replacement.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 3 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Want fixes drafted automatically? Bugbot Autofix can create code changes for findings. A team admin can enable Autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit ad731ab. Configure here.
ApprovabilityVerdict: Needs human review 7 blocking correctness issues found. This PR introduces a significant new feature (rich tool-call transcript with thinking bursts, inline diffs, live status) with substantial new UI components and server-side state management. Additionally, 7 unresolved review comments identify logic bugs in the implementation. New features of this scope with outstanding issues warrant human review. You can customize Macroscope's approvability policy. Learn more. |
…ttribution and stale-turn handling Server: - Cap thinking-burst text at 32k chars so burst state and each persisted progress/completed payload stay bounded (chars keeps counting past the cap). - Close open thinking bursts on runtime.error; ignore turn.completed/aborted events for a different turn than the open burst's so stale replays don't split reasoning into premature "Thought for Xs" rows. - Delete burst state only after both close dispatches succeed so a failed close can be retried (stable activity ids make retries idempotent). - clampToolDetail: cap object key count (100) alongside string/array/depth. - Retain ACP `locations` in projected payloads — the client reads the primary file-path argument from it. - Cap structuredPatch line length (500 chars) in the forwarded toolUseResult. Web: - ACP contextual diffs now split on unique anchor lines, so two edits at distant locations render as separate hunks instead of one replacement block that double-counts every unchanged line between them. - Multi-file ACP/Codex diffs only include hunks for the first file — other files' hunks no longer render under the wrong filename. - deriveLiveWorkStatus ignores unstamped activities from before the running turn started, so an open tool/thinking row left by an interrupted earlier turn can't resurface as current status. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Addressed the review findings in dc67dd4: Fixed
Each fix has a regression test (stale-turn burst survival, anchor-split hunks, multi-file attribution, stale live-status filtering). Not changed, with rationale
🤖 Generated with Claude Code |
| * Reduce them to contextual hunks so edits render like Claude's structured | ||
| * patches instead of a full-file remove/add wall. | ||
| */ | ||
| function contextualDiffHunks(oldText: string, newText: string): WorkLogToolDiffHunk[] { |
There was a problem hiding this comment.
🟡 Medium src/session-logic.ts:2087
contextualDiffHunks renders no diff for an edit that only adds or removes the file's trailing newline — for example oldText = "const x = 1;\n" and newText = "const x = 1;". splitDiffContent strips the terminal empty element, so both inputs become identical arrays; collectContextualDiffBlocks returns no blocks and the function returns [], dropping the edit from the UI. Consider preserving the trailing-newline difference so such edits still produce a hunk.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/session-logic.ts around line 2087:
`contextualDiffHunks` renders no diff for an edit that only adds or removes the file's trailing newline — for example `oldText = "const x = 1;\n"` and `newText = "const x = 1;"`. `splitDiffContent` strips the terminal empty element, so both inputs become identical arrays; `collectContextualDiffBlocks` returns no blocks and the function returns `[]`, dropping the edit from the UI. Consider preserving the trailing-newline difference so such edits still produce a hunk.
| // the burst-completion activity was lost. | ||
| openThinking = null; | ||
| const entry = toDerivedWorkLogEntry(activity); | ||
| const key = entry.toolCallId ?? entry.collapseKey ?? entry.id; |
There was a problem hiding this comment.
🟡 Medium src/session-logic.ts:942
In deriveLiveWorkStatus, tools that never get a toolCallId stay open after tool.completed: a tool.started event is keyed in openToolsByKey by entry.id (because tool.started rows never receive a collapseKey, and the key fallback is entry.toolCallId ?? entry.collapseKey ?? entry.id), but the matching tool.completed row computes its own entry.id, so openToolsByKey.delete(key) misses and the completed tool is never removed. The live-work indicator then keeps showing a completed tool as "Running ..." instead of dropping it. Consider keying both tool.started and tool.completed by a stable field (e.g. toolCallId first, then a collapseKey derived consistently for both lifecycle stages) so delete hits the same entry.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/session-logic.ts around line 942:
In `deriveLiveWorkStatus`, tools that never get a `toolCallId` stay open after `tool.completed`: a `tool.started` event is keyed in `openToolsByKey` by `entry.id` (because `tool.started` rows never receive a `collapseKey`, and the key fallback is `entry.toolCallId ?? entry.collapseKey ?? entry.id`), but the matching `tool.completed` row computes its own `entry.id`, so `openToolsByKey.delete(key)` misses and the completed tool is never removed. The live-work indicator then keeps showing a completed tool as "Running ..." instead of dropping it. Consider keying both `tool.started` and `tool.completed` by a stable field (e.g. `toolCallId` first, then a `collapseKey` derived consistently for both lifecycle stages) so `delete` hits the same entry.

What
Upgrades the chat transcript's tool-call rendering to compact, information-dense rows (in the style of Claude Code's transcript):
Tool(primary arg)with a status dot (running / done / error) and a⎿ resultone-liner, expandable for full input/output.Edit/Write/MultiEdit, Codex native patches, ACP content diffs) render as inline unified diffs directly in the transcript, with add/remove line counts and theme-aware colors.Thought for Xsentry when the burst ends, expandable to the reasoning text.How
Server
ClaudeAdapterforwards a boundedtoolUseResultsummary intoolData(structuredPatch capped at 400 lines, filePath kept) so the client can render edit diffs without replaying the file.ProviderRuntimeIngestiontracks reasoning deltas into thinking-burst activities (throttled to 250ms, stable progress id per burst), forwardstoolCallId/dataon item events, and emits acontext-compaction.startedactivity fromsession.state.changed.ActivityPayloadProjectionnow retains tool-detail fields (toolName,input,result,toolUseResult,item,content,rawInput) in snapshots under a recursive clamp (4k-char strings, 100-item arrays, depth 6) instead of pruning them to one-line summaries — re-hydrated threads render the same rows the live stream did, with bounded row size. Live events were already unpruned; this only affects snapshot rows.Web
session-logicnormalizes Codex native tool items and ACP tool calls into one shape (tool name, input, diff hunks, result text), merges it into work-log entries, and addsderiveLiveWorkStatus.MessagesTimelinerenders the new rows; work groups keep their existing fold behavior (state tracked as collapsed-set with expanded default). Existing agent/subagent rendering (spawn CTA rows, agents panel, task linkage) is untouched.Scope notes
kindstring, and payload additions are additive.Testing
deriveLiveWorkStatus, timeline fold semantics, live-status row diffing.🤖 Generated with Claude Code
Note
Medium Risk
Touches provider ingestion, activity projection, and large timeline UI paths; behavior is well-tested but regressions could affect transcript accuracy, snapshot size, or live status for all providers.
Overview
Delivers a richer chat transcript: compact
Tool(arg)rows with status dots,⎿result lines, inline file diffs, expandable “Thought for Xs” reasoning, context-compaction shimmer rows, and a phase-aware working indicator (thinking / running tool / writing).Server:
ProviderRuntimeIngestioncollapses reasoningcontent.deltastreams into throttledthinking.started/thinking.progress/thinking.completedactivities (stable progress id, turn-aware close, staleturn.completedguard). Tool lifecycle events now carrytoolCallId, moredataontool.started, andcontext-compaction.startedfrom compacting session state.ClaudeAdapterattaches a boundedtoolUseResult(structuredPatch+filePath) on edit tools.ActivityPayloadProjectionretains and clamps tool-detail fields in snapshots instead of one-line summaries so re-hydrated threads match live rendering.Web:
session-logicnormalizes Claude / Codex / ACP tool payloads intotoolName,toolInput,toolDiff,toolResultText, maps completed thinking to work-log rows, and addsderiveLiveWorkStatus(with optional Claude silent-thinking fallback).MessagesTimelinerenders the new row types and passes live status into the working row; work groups default expanded (trackcollapsedWorkGroupIds).Reviewed by Cursor Bugbot for commit dc67dd4. Bugbot is set up for automated code reviews on this repo. Configure here.
Note
Add rich tool-call transcript with inline diffs, thinking bursts, and live status to chat timeline
thinking.started, throttledthinking.progress, andthinking.completedactivities, and emits acontext-compaction.startedactivity when the provider reports compaction.structuredPatch) in projected activity payloads.deriveLiveWorkStatusto compute a live phase indicator (Thinking / Running / Editing) from activities and streaming state, and enrichesWorkLogEntrywithtoolName,toolInput,toolDiff, andtoolResultText.ThinkingWorkEntryRowfor completed reasoning bursts, aContextCompactionRowdivider, and aLiveThinkingStreamwith auto-scroll in the live working indicator.collapsedWorkGroupIdsreplacesexpandedWorkGroupIdsinderiveMessagesTimelineRows; callers that previously passed expanded IDs must invert their state.Macroscope summarized dc67dd4.