Skip to content

Reader: the middle of a turn is markdown too - #88

Merged
lvwerra merged 3 commits into
design/reader-orderfrom
design/step-markdown
Aug 18, 2026
Merged

Reader: the middle of a turn is markdown too#88
lvwerra merged 3 commits into
design/reader-orderfrom
design/step-markdown

Conversation

@lvwerra

@lvwerra lvwerra commented Aug 18, 2026

Copy link
Copy Markdown
Member

intermediate agent messages in the reader are not rendered as md although they come with md syntax.

Before/after captures →

Stacked on #85 (design/reader-order), which restructures this same file — StepRow and the step rail. Base is that branch, not main, so this diff is only the markdown change; it needs #85 to land first, and GitHub will retarget it to main automatically when that happens.

What was wrong

Only the answer was ever rendered — highlightHtml(renderMarkdown(answer), q) into .markdown.cx-md (Exchange.tsx:181,221). Everything the agent said on the way there is a Step of kind note / think / compact, rendered through <Hi text={shown} q={q} />: plain text plus search highlighting. So a mid-turn message arrived with headings, lists, code spans and links and was printed as syntax.

What changed

proseOf() in exchanges.ts names the kinds that are the agent writing for a readernote, think, compact — and StepRow sends those through the answer's own path. Everything else stays literal.

It renders in the body, not in the head row. That row is a <button>, and markdown carries links and block elements; neither is valid — or clickable — inside one. So the row keeps its one-line preview while shut, hands the text over when open, and the rendered prose sits in .cs-body, the way a tool's input and a shell's output already do.

That narrows the old rule rather than breaking it: "expanding never repeats itself" becomes expanding never shows the same words twice — which printing the syntax above a rendered copy would. I built it the other way first (preview kept above the rendered prose) and the duplication was the reason to change it; both variants are in the captures.

The four things you flagged

1. highlighting Same path as the answer, highlightHtml(renderMarkdown(text), q) — the helper walks text nodes only, so tags survive. Searching “index” marks 2 hits inside the rendered prose, parents strong and p: the bold survived and the mark went inside it.
2. the preview Stays raw, deliberately. ## Plan says the message opens with a heading and ``` says code is coming — Plan says less. Stripping would also mean a second markdown pass over a string that may be cut mid-token, i.e. a second renderer path to get wrong.
3. truncation marked closes a cut fence, table or list itself, and DOMPurify reparses what it emits. Verified on a 27,603-char message that the 20,000-char VIEW_BLOCK_CAP cuts inside a fence: the step renders, its …+7,603 chars not retained line follows, and every later step and answer still renders (steps in the last exchange: 2, answers on screen: 3, no page errors).
4. scope In: note, think, compact. Out: a tool's input (JSON, and ToolCall already formats it), a shell's output and a tool result (terminal bytes — two spaces mean two spaces, and markdown would eat them), an image.

Each kind also keeps the colour it had as plain text, so a rendered aside doesn't present itself as the reply and rendered thinking still looks like thinking. And the now-dead .cs-detail.full rule goes with the in-place expansion it styled.

Verified

Measured on both builds against a fixture whose middle messages carry a heading, an ordered list, code spans, a link, a fenced block and a quote:

branch point (#85) this branch
prose steps rendered markdown=false, tags=- markdown=true, tags=h2,p,ol,li,code,strong,a,pre,blockquote
head row when open carries the whole message one-line preview handed over
highlights inside prose 0 2
the cut message's row, 390pt 332px tall 281px — rendering is shorter than printing syntax

web/test/stepMarkdown.test.mjs (new, wired into npm test) pins the two halves node can hold: truncated fences/tables/lists close and an unterminated code span stays literal; and proseOf answers which kinds render. Mutation-checked — letting shell output through the renderer fails it. The highlight-over-markdown path needs a DOM (highlightHtml uses DOMParser), so it is checked in the browser instead, and the number above is from that run rather than from a green tick.

Web suite and typecheck green; server suite green.

Collisions

Trial-merged against #84 (feat/trace-share-and-reader-info) at its current head: one conflict, web/package.json — the recurring test script line. Its branch now also carries statusMark, fileWrapToggle and settingsMobile suites plus a test:render script, so the resolution is to keep both lists and both scripts. ConversationView.tsx and conversation.css do not conflict: #84's work is the bar and the i panel, mine is StepRow and .cs-md.

🤖 Generated with Claude Code

@lvwerra

lvwerra commented Aug 18, 2026

Copy link
Copy Markdown
Member Author

Found it, and it was none of the three candidates. Pushed as 0efb74f; the captures page has a new section with the before/after.

What it wasn't

Measured on the expanded row rather than reasoned about:

candidate measurement verdict
1. first block's margin-top not collapsed <h2> first block, margin: 0px/3px ruled out — .cs-md > :first-child { margin-top: 0 } already handles it
2. renderer emits a leading empty node emptyFirstNode: false; marked on "\n\nAnswer…", " \n\n Answer…" and plain text all produce no leading <p></p> ruled out — markdown drops leading whitespace
3. the source starts with a newline and white-space now preserves it fixture source starts ## Plan, and oneLine() does .replace(/\s+/g,' ').trim(), so the preview was hiding nothing that survives rendering ruled out

Candidate 3 was worth chasing for the reason you gave, so I checked it from the other end too: a note whose text does begin with \n\n renders identically to one that doesn't, and the collapsed preview trims it. There was no hidden leading whitespace.

What it was

The blank line was the head row. A note carries no label, so when its text moved to the body — which this PR did, because a <button> cannot hold links — the row was left with nothing but a triangle. A row whose only content is a 14px triangle still takes a line: 16.3px, plus 2px of body padding. That is the "empty line".

So, to your question about the triangle: not intended, and the same bug wearing the other hat. One cause, two symptoms.

The fix

Open, a note is two columns — the disclosure gutter, and the prose beside it:

.cs.note.open { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: start; }
.cs.note.open > .cs-head { grid-column: 1; width: auto; align-self: stretch; padding-right: 0; }
.cs.note.open > .cs-head .cs-detail { display: none; }
.cs.note.open > .cs-body { grid-column: 2; padding: 0 0 6px 0.45em; }

The triangle now sits beside the first line it belongs to, the way it does collapsed, so toggling doesn't move the text. The gutter it lives in is align-self: stretch, so the whole left edge of the open step is the target that closes it — a bigger target than the empty row it replaces, not a smaller one.

desktop before after
row height 244px 222px
triangle y=2, alone on its line y=2
first block below it y=0 — level with the triangle

think and compact deliberately keep the stacked layout: their row says thinking / context compacted, so it was never an empty line, and putting a long label in a narrow gutter column would push the prose across the pane on a phone.

Pinned

stepMarkdown.test.mjs now holds both halves, because if it is fixed only in CSS the next person needs to know which cause it was:

  • the two ruled-out candidates: three leading-whitespace shapes produce no empty node and start with a real block; oneLine trims the same whitespace;
  • the layout: .cs.note.open is a two-column grid, the emptied detail slot is display: none, the body has no top padding, and .cs.think.open did not gain the grid.

Mutation-checked — dropping the grid rule fails .cs.note.open lays out in two columns. Web suite and typecheck green.

One harness note, since it nearly cost me a wrong diagnosis

My capture script read process.env.PORT for the fixture instance's port. In this Space PORT=7860 is exported into agent shells and points at the live dev instance, so the browser was driving the real app while curl talked to my fixture — the two disagreed and it looked like a rendering difference. Nothing was written (it failed on a row click), but any harness here should hardcode its own port rather than inherit PORT.

Agent Manager and others added 3 commits August 18, 2026 11:32
> intermediate agent messages in the reader are not rendered as md although they
> come with md syntax.

Only the answer was ever rendered — `highlightHtml(renderMarkdown(answer), q)`
into `.markdown.cx-md`. Everything the agent said on the way there went through
`<Hi text={shown} />`, which is plain text plus search highlighting, so a
mid-turn message arrived with `## Plan`, `**index**` and
`[the docs](https://…)` and was shown exactly like that.

The prose kinds now take the same path. `proseOf()` (exchanges.ts) names them —
`note`, `think`, `compact`: the agent writing for a reader — and everything else
stays literal, because a tool's input is JSON, a shell's output and a tool
result are terminal bytes where two spaces mean two spaces, and an image is an
image.

**It renders in the body, not in the head row.** That row is a `<button>` and
markdown carries links and block elements, neither of which is valid or
clickable inside one. So the row keeps its one-line preview while shut, hands
the text over when open, and the rendered prose sits in `.cs-body` the way a
tool's input and a shell's output already do. That replaces "expanding never
repeats itself" with something narrower: expanding never *shows the same words
twice*, which printing syntax above a rendered copy would.

**The collapsed preview keeps its syntax.** `## Plan` says the message opens
with a heading and ``` says code is coming; `Plan` says less. Stripping it would
also mean a second markdown pass over a string that may be cut mid-token.

**A cut message cannot take the panel with it.** These steps carry a `more`
tail, so the text can end mid-fence or mid-table. `marked` closes the block
itself and DOMPurify reparses what it emits; verified on a 27,603-char message
the 20,000-char cap cuts inside a fence — the step renders, its "+7,603 chars
not retained" line follows, and every later step and answer still renders.

Each kind keeps the colour it had as plain text (`.cs.think .cs-md` stays
purple, a note stays quiet), so a rendered aside does not present itself as the
agent's reply. The dead `.cs-detail.full` rule goes with the in-place expansion
it styled.

Search highlighting survives, which was the first thing to check: searching
"index" marks two hits inside the rendered prose, their parents `strong` and
`p` — the bold survived and the mark landed inside it.

`web/test/stepMarkdown.test.mjs` pins the two testable halves: truncated fences,
tables and lists close (an unterminated code span stays literal), and `proseOf`
answers which kinds render. Mutation-checked by letting shell output through the
renderer, which fails it. The highlight-over-markdown path needs a DOM
(`highlightHtml` walks text nodes with DOMParser), so it is checked in the
browser rather than in node.

Branched off design/reader-order (#85), which restructures this file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Operator, on dev: "it seems to add an empty line at the beginning when
expanded." Three causes were plausible and measurement ruled out all three:

- the first rendered block already had `margin-top: 0px` (`.cs-md > :first-child`);
- the renderer emits no empty node — a source starting with a blank line, or
  with blank lines and spaces, yields no leading `<p></p>`, because markdown
  drops leading whitespace;
- and `oneLine()` trims the same whitespace for the collapsed preview, so the
  two views never disagreed about it.

The blank line WAS the head row. A `note` has no label, so when its text moved
to the body the row held nothing but a triangle — and a row whose only content
is a 14px triangle still takes a line: 16.3px, plus 2px of body padding. That is
the same reason the triangle sat alone, so it was one bug wearing two hats, and
neither was intended.

Open, a note is now two columns — the disclosure gutter, and the prose beside it
— so the triangle keeps company with the first line and the gutter is what
closes the step. Desktop row 244px → 222px; the triangle stays at y=2 while the
first block moves from below it to y=0, level with it. `think` and `compact`
keep the stacked layout: their row says `thinking` or `context compacted`, which
is not an empty line.

Pinned in `stepMarkdown.test.mjs`: the two ruled-out candidates (no empty node
for three leading-whitespace shapes, `oneLine` trims), and the layout itself —
`.cs.note.open` is a two-column grid, its emptied detail slot is out of the
flow, the body has no top padding, and `think` did *not* gain the grid. Dropping
the grid rule fails it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… line

Two states the operator hit on dev, both in the exchange meta row.

**A turn with no tool calls indented its summary.** Measured from the ink rather
than the boxes: everything in an exchange starts on one column at x=16 — the
prompt's words, the answer's words, the step rows — and a flat summary started at
x=35, because `.cx-fold.flat` paid `padding-left: 1.75em` to reserve the gutter
for an expand triangle that cannot exist in that state.

§3.2 already answers which way to fix that: the prompt's `❯` is the only thing
outside the text column. So the marks hang instead — the fold's `▸` and the
working line's spinner both — and nothing reserves space inside the column. The
cell is one number, `--cx-mark` on `.cx-meta`, read by the negative margin and by
the glyph's width so they cannot drift apart. Flat and expandable summaries now
start on the same column as the prose: 35 → 16, with the mark at 0..16 in the
gutter. `working` moves 31 → 16 by the same rule.

**A turn with nothing yet had an empty row above its working line.** No steps, no
duration, no tokens means `stepSummary` is empty, so the meta row rendered with a
blank left half and the working line sat on the row below — dropped rather than
placed. There is no meta row in that state now: the turn's facts ride the working
line, one row instead of one and a half.

The working line does not move for this, which was the constraint that mattered:
it is the last row at the text column in both states. Checked live rather than
inferred — appending a tool call to the transcript under an open reader and
letting the poll pick it up gives `ink 16 → 16`, `last row true → true`, and the
facts moving up into the newly-rendered meta row.

The DOM assertions for that state all passed while the row rendered
`workingturn 3/301:02 PM`: `.spacer { flex: 1 }` was scoped to `.cx-meta`, and
`.cx-running` cannot take a `gap` (its spinner is a ::before flex item, and a gap
would push `working` off the column). Both the spacer and the facts' own margins
are pinned now, because only a screenshot caught it.

Pinned in `pendingExchange.test.mjs`: an empty turn summarises to nothing and its
facts render inside `.cx-running` with no meta row; the same turn with a duration
puts them back on the meta row and not on both; a finished turn with no tools
gets a flat fold; and in CSS, a flat fold has no left padding, the fold hangs by
`--cx-mark`, the spinner hangs by `--mark-w`, and the working row spaces its
facts. Mutation-checked: restoring the 1.75em gutter fails one, forcing
`factsOnRunningRow` false fails another.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@lvwerra
lvwerra force-pushed the design/step-markdown branch from 0efb74f to f1e1bfd Compare August 18, 2026 11:44
@lvwerra
lvwerra force-pushed the design/reader-order branch from fedb89a to 2486300 Compare August 18, 2026 11:45
@lvwerra

lvwerra commented Aug 18, 2026

Copy link
Copy Markdown
Member Author

Both fixed, on this branch (f1e1bfd). Rebased on current main first — the only conflict was web/package.json's test-script line, resolved as main's list plus my suite, and I moved design/reader-order (#85's branch, also mine) onto the same base so the stack stays coherent: #85 still reports exactly 6 files, +202/-8. New sections on the captures page.

The indent: a gutter reserved for a control that cannot exist

Measured from the ink, not the boxes — a flat fold's box starts where an expandable one's does, which is why this looks fine in a DOM dump:

prompt text mark summary text answer text working
turn with tools, before 16 16 33 16
turn with no tools, before 16 35 16
turn with nothing yet, before 16 31
all three, after 16 0 (in the gutter) 15–16 16 16

Which of the two fixes the design wants: §3.2 already answers it — "the prompt's is the ONLY thing outside the text column… everything else lines up in one column". A mark that sits inside the column pushes its own row's text sideways, so the gutter should not be reserved by anything: the fold's and the working line's spinner both hang, and .cx-fold.flat pays nothing. The cell is one number — --cx-mark on .cx-meta — read by the negative margin and by the glyph's width, so the hang and the glyph cannot drift apart. (The 1px spread between 15 and 16 is sub-pixel rounding of the em arithmetic, not a second alignment.)

The working line: it was the empty row above it

stepSummary is empty for a turn with no steps, no duration and no tokens, so the meta row rendered with a blank left half and the working line landed on the row below — dropped rather than placed. There is no meta row in that state now: the turn's facts ride the working line, one row instead of one and a half.

The constraint you set — the line must not jump when the first step arrives — is met, and I checked it in motion rather than inferring it. Appending a tool call to the transcript under an open reader and letting the poll pick it up:

empty turn, working    children=cx-prompt→cx-running   workingIsLast=true  ink=16  facts on: working line
first step arrived     children=cx-prompt→cx-meta→cx-running  workingIsLast=true  ink=16  facts on: meta row

The working line keeps its place — last row, text column — and what moves is the facts, up one line into the meta row that now exists. That is the smallest thing that can move, and it happens once.

One thing worth flagging, because tests missed it

Every DOM assertion for the new state passed while the row actually rendered:

workingturn 3/301:02 PM

.spacer { flex: 1 } was scoped to .cx-meta, so inside .cx-running the spacer had no width; and .cx-running cannot take a gap, because its spinner is a ::before flex item and a gap would push working back off the column. Only the screenshot caught it. Both the shared spacer and the facts' own margins are pinned now.

Pinned

In pendingExchange.test.mjs, covering both ordinary states you named:

  • an empty turn summarises to ''; its facts render inside .cx-running; no .cx-meta exists; the working line is still last;
  • the same turn with a duration puts the facts back on the meta row and not on both;
  • a finished turn with zero tool calls gets class="cx-fold flat" and shows 13s;
  • and in CSS: a flat fold has no padding-left, the fold hangs by --cx-mark, the spinner hangs by --mark-w, and the working row spaces its facts.

Mutation-checked — restoring the 1.75em gutter fails the first CSS check, and forcing factsOnRunningRow to false fails the empty-state render check. Web suite (132 checks) and server suite green.

Collisions

Trial-merged #84 at its current head: clean now, both files. The web/package.json conflict I reported last round is gone — main's script line already carries the suites that were only on #84's branch then, and my rebase picked that up. ConversationView.tsx and conversation.css still do not overlap: #84 is the bar and the i panel, mine is StepRow, the meta row and .cs-md.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant