fix(agent-mode): coalesce streamed prose split by an invisible trail part#2648
fix(agent-mode): coalesce streamed prose split by an invisible trail part#2648zeroliu wants to merge 1 commit into
Conversation
…part A streamed agent message renders each `text` part as its own block-level markdown div, so when a top-level-invisible part — a hidden ToolSearch, an empty plan, or a concurrent background sub-agent's event — lands between two prose deltas, one sentence splits into two stacked blocks and shows a spurious mid-sentence line break in the live UI. The saved markdown, built from the flat `displayText` (deltas concatenated with no separator), has no break, so the artifact is render-only and self-heals on reload. Add a shared `isTopLevelInvisible` predicate and apply it across all three consumers of the parts stream: - `foldNodes` skips empty plans and coalesces adjacent text nodes, so the live trail matches the saved `displayText`. - `agentResponseText` derives from the folded trail, so Copy/Insert no longer injects a blank line into a split sentence (a visible tool card / reasoning / non-empty plan between prose still yields a real `\n\n` break). - `splitTrailingText` treats invisible parts as transparent and the collapsed "Worked for X" view renders the final run as one block, so the collapse can't strand half a sentence in the hidden research. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8d92e2d331
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| parts.forEach((p, i) => { | ||
| if (finalIdx.has(i)) final.push(p as TextPart); | ||
| else research.push(p); | ||
| }); |
There was a problem hiding this comment.
Keep invisible parts from creating an empty collapse
When a completed turn is otherwise just prose split by an empty plan or hidden ToolSearch, the backward scan now treats both prose chunks as final, but this branch still pushes the invisible separator into research. AgentTrail then considers any non-text research item as content, enters the “Worked for X” collapse, and the expanded LinearTrail renders nothing because those parts are dropped by buildAgentTrail, leaving an empty accordion above a plain answer. Filter top-level-invisible parts out of research (or base the content check on rendered nodes) so invisible-only research does not trigger collapse.
Useful? React with 👍 / 👎.
| if (prevNode && prevNode.type === "text") { | ||
| out[out.length - 1] = { | ||
| type: "text", | ||
| part: { kind: "text", text: prevNode.part.text + p.text }, |
There was a problem hiding this comment.
Preserve whitespace-only deltas when coalescing prose
When a streamed newline/space-only chunk is isolated by invisible events (for example text("A"), a hidden ToolSearch or sub-agent child, text("\n\n"), another invisible event, then text("B")), the whitespace-only text part is skipped and this coalescing turns the visible text into AB, even though the stored displayText is A\n\nB. Text parts are opened after any non-text event, so invisible events around a whitespace delta are enough to lose the model's intentional separator in the live trail and Copy/Insert output; preserve skipped whitespace in the pending prose run or avoid coalescing across it.
Useful? React with 👍 / 👎.
Problem
In agent mode, a streamed assistant message occasionally shows a line break in the middle of a sentence in the live UI, e.g.:
The model never produced the break: the raw Claude Code session log has the text as one contiguous block, and the saved conversation
.md(built from the flatdisplayText, deltas concatenated with no separator) has no break either. It is a render-only artifact that self-heals on reload.Root cause
The live trail renders each streamed
textpart as its own block-level markdown<div>. When a part that is invisible at the top level lands between two prose deltas, one sentence splits into two stacked blocks. The three invisible triggers:ToolSearch(deferred-tool loader, filtered from the trail),plan(PlanPillrendersnull),Fix
One shared
isTopLevelInvisiblepredicate, applied to all three consumers of the parts stream (src/agentMode/ui/agentTrail.ts):foldNodes) — skip empty-plan nodes and coalesce adjacent text nodes, so the streamed trail is byte-identical to the saveddisplayText.agentResponseText) — derived from the folded trail, so a split sentence no longer gets a\n\nblank line; genuinely separate prose (around a visible tool card / reasoning / non-empty plan) still gets\n\n.splitTrailingText+AgentTrailView) — invisible parts are transparent to the trailing-run boundary, and the final run renders as one coalesced block, so the collapse can't strand half a sentence in the hidden research.Testing
buildAgentTrail,agentResponseText, andsplitTrailingText; visible-separator behavior is preserved.npx tsc --noEmit,npm run lint,npm run formatclean.🤖 Generated with Claude Code