Skip to content

Reader: markdown in the middle of a turn, and one column for the meta row - #90

Merged
lvwerra merged 4 commits into
mainfrom
design/reader-order
Aug 18, 2026
Merged

Reader: markdown in the middle of a turn, and one column for the meta row#90
lvwerra merged 4 commits into
mainfrom
design/reader-order

Conversation

@lvwerra

@lvwerra lvwerra commented Aug 18, 2026

Copy link
Copy Markdown
Member

Carries #88 onto main. It merged into design/reader-order rather than main, because that branch was its base — #88 was deliberately stacked on #85 so the two would not fork while both were editing Exchange.tsx. #85 then merged to main first, so #88's merge landed on a branch that was already upstream, and main ended up without any of it.

Nothing here is new work. Four commits, all previously reviewed or operator-verified on the dev Space:

  • 0e58be8 — mid-turn messages render as markdown, not raw syntax (improv.md iteration two)
  • 633d492 — the empty line at the top of an expanded note: it was the head row, not a margin
  • f1e1bfd — one column for the meta row, and no empty row above a working line
  • 5f379a1 — the Reader: the middle of a turn is markdown too #88 merge commit

Verified as part of the dev5 assembly currently running on agent-manager-dev: web typecheck, all 13 web suites, and both server suites green.

The lesson worth keeping: a stacked PR merges into its stack parent, not into main. When the parent lands first, the child's merge silently goes nowhere useful. Either retarget the child's base to main before merging it, or merge the child into the parent first and the parent last.

Agent Manager and others added 4 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>
Reader: the middle of a turn is markdown too
@lvwerra
lvwerra merged commit eba68d2 into main Aug 18, 2026
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