agent transcript: every cut row opens to its whole text; expanding and collapsing keep your place (carry #28324)

Merged
TE
6d ago
+929-19

This carries #28324 (its owner lane is gone), with its commits and authorship kept, merged with the current feat-cmux-next and with the open review BLOCK fixed. #28324's SHIP was only at the older abd00d3d; this head needs a fresh independent review.

What the carry changed

  • Merge with feat-cmux-next. One conflict, in App.tsx's scroll handler: it now does both, trimming the kept fold room on each scroll event (this PR) and clearing unprocessed upward input as the event acknowledges the offset (base, #26718's follow-up).
  • BLOCK at 5e59e449 fixed (foldAnchor.ts): "Collapsing long output can leave a blank viewport. Expand a long tool output near the transcript's end, scroll down to 'Show less,' then collapse it. This anchors the row's now-offscreen top, while the new foldRoom preserves the old scroll offset." Now:
    • foldHoldTarget(toggle, scroller): a control whose row top is above the view (Show less under a long output, scrolled down to) holds the control itself, so the body shrinks up into it and it stays where it was clicked. A row whose top is in view still holds by its top, as before.
    • trimmedFoldRoom: once no hold is in flight, the kept room goes when the rows end above the view's top, so the view is never only room.
    • Tests: foldAnchor.test.ts adds three cases (2 fail on 5e59e449, pass here). New transcript gallery states cut-line-closed, cut-line-open and show-less-keeps-place; their plays fail when the opened label is still cut or the clicked Show less ends out of view, and test/agent-pane-show-less-keeps-place.test.ts runs both plays in Chromium.

Gallery stills

Chromium, Linux CI (cmux-ci test run 41160918121), device scale 2, synthetic data, gallery entry agent-pane.transcript. "Base" is feat-cmux-next at 7c0adaf1 and "before fix" is this carry's merge commit c7447b10 (#28324's code before the BLOCK fix). Both render the head's gallery file, since the states are new. Each state's play is its check: cut-line-open fails on base ("the opened line's label is still cut"), and show-less-keeps-place fails before the fix ("closing the output left the view on blank room"). All six head states pass.

StateBeforeAfter (head d39e1caa)
Cut line opened, light (base)base: opened line still cuthead: opened line wraps whole
Cut line opened, dark (base)base darkhead dark
Cut line closed, light / dark (head)closed lightclosed dark
Show less at the end, light (before fix)before fix: blank viewhead: Show less in view
Show less at the end, dark (before fix)before fix dark: blank viewhead dark
Show less at the end, light / dark (base, no kept room)base lightbase dark

Viewed after image: head-cut-line-open-light.png shows the Grep line opened and wrapped to its whole pattern (through trimmedFoldRoom) above its output, where base cuts it at "holdFo…". head-show-less-dark.png shows the shell block with "Show all 160 lines" and the rows above it after Show less, where the before-fix still (fixbefore-show-less-dark.png) is an empty transcript above the composer, the BLOCK's blank viewport.

#28324 closes once this lands.


Original description (#28324)

Leo's words:

why the f*** did you guys ff***ing ellipsis EVERYTHING WITH NO WAY TO EXPAND WHAT WAS SAID TO SEE

AND IT'S SO F***ING AWKWARD TO SEE THEM THERE AT THE BOTTOM SINCE WHEN I EXPAND THEM IT JUMPS ME DOWN AND WHEN I COLLAPSE IT JUMPS ME UP

The other sessions' message lines (the case he pointed at) already open to the whole message since #27312. This PR fixes the rest of the transcript rows that cut text with no way to see all of it, and makes expanding and collapsing keep your place everywhere, including at the bottom of the transcript.

Audit

Every row that truncates was checked for a way to see the full text. These were missing one and are fixed here:

RowBeforeAfter
Tool call that is not a shell (Grep pattern, URL, path)Label cut at the row's edge. Opening showed only the output, and a call with no output could not be opened at all.Every call opens. Once open, its label wraps to show all of it.
Dispatch reportText cut at 2,000 characters with "…"Shows the whole report. The panel already scrolls.
Agent message line, openedThe other chat's name stayed capped at 40% of the rowThe full name shows once the line is open
Sub-session line, openedIts state (a lane's error, what it waits on) was cut, and the panel does not repeat itThe full state shows once the line is open

Rows that already reach their full text were left alone: shell rows (the Shell block shows the whole command), command rows, thoughts, sent messages, prompts (Show more), sub-session replies (Show more), and the failure cause (opens with its output).

Keeping your place

  • Collapsing near the end. Closing a body at the end of the transcript used to let the browser clamp the scroll offset, which pulled every row (and the line you clicked) down by the closed height. Now a click on a disclosure keeps the content at least as tall as the view's end (foldRoomFor in foldAnchor.ts). The leftover room shrinks as you scroll up and is dropped once the rows reach it, on a send, on Jump to latest, and when you switch chats (trimmedFoldRoom).
  • Every toggle is held. FOLD_TOGGLE used to cover only button.is-toggle, button.cv-subagents and summary. It now covers every aria-expanded control in a row except menu triggers. That adds Show more, Show all lines, Raw, "+N more", the sent-message line (a div role=button) and the words of an agent message line. A control that sits under its body holds the top of its row (foldHoldTarget), so the body grows and shrinks downward and nothing above it moves.

Stop and cleanup

Nothing new animates. The fold animation is unchanged (fold.ts). The existing hold still ends foldMs + FOLD_SETTLE_MS after the click. The new kept room is plain React state with no timer and no observer. It is dropped by trimFoldRoom, which runs on each scroll event and placement pass and does O(1) work: one offset read and one compare. Send, Jump to latest and a session switch drop it directly. Nothing runs per streamed token.

Tests

  • conversation/foldAnchor.test.ts: which clicks are held (one case per toggle kind, plus a menu trigger and plain text that are not), where the hold lands (the line itself vs. the row's top), and the keep and release rules for the collapse room.
  • conversation/TruncatedRowsReachable.test.tsx: one test per fixed row kind. A non-shell tool call with and without output opens and carries its whole label; a shell line keeps its one line; a dispatch report keeps text over 2,000 characters.
  • test/agent-pane-fold-keeps-place.test.ts (Chromium, gallery agent-pane.incoming-message-disclosure): opening a line keeps it within 1 px of where it was. Closing it at the transcript's end, in a setup where the closed height is larger than the room below the view (the old clamp case), also keeps it within 1 px. Scrolling up afterwards releases the room.

Media

Gallery stills are in "Gallery stills" above.

Risk

Medium. The collapse room changes the scroller's height only after a click on a disclosure, and only while nothing below fills the view. Following the latest row, a send and Jump to latest all drop it first. The wider FOLD_TOGGLE makes more clicks hold the view for about 300 ms. A wheel or key during that time releases the hold as before.

Performance

metricbeforeafterdeltaharness
work per streamed token in the transcript path0 new0 new0code read: the new work runs only on clicks, scroll events and placement passes, O(1) each

Overlaps

pr-overlap lists #27580, #27850, #28291, #28128 and #28018 (all from this area's lanes). They touch the same CSS files and dispatchCalls.ts in other rules. This PR only adds rules for opened rows and drops the dispatch cap. It does not subsume #26591 (expanding a tool group to show every item), #26696 (full tool output) or #26718 / #27365 (scroll anchoring while older rows load).

Agent session: agent session

🤖 Generated with Claude Code