Skip to content

One conversation renderer for the Overview card and the session pane - #37

Merged
thomwolf merged 6 commits into
mainfrom
design/trace-unify
Aug 7, 2026
Merged

One conversation renderer for the Overview card and the session pane#37
thomwolf merged 6 commits into
mainfrom
design/trace-unify

Conversation

@lvwerra

@lvwerra lvwerra commented Aug 5, 2026

Copy link
Copy Markdown
Member

One renderer for what used to be two: the Overview card and a session's trace, both built on the exchange — your prompt, the work, the answer.

Running on am-dev-3 if you want to click it before reading the diff.

Why

The card read well and showed too little; the trace viewer showed everything and read badly. Same content, two components, two data paths, two visual languages. The card couldn't show the middle at all: digest.turnsLog holds only assistant text from the current request — no tool calls, no thinking, nothing before the last prompt.

What's here

web/src/components/conversation/exchanges.ts (turns → exchanges → one line per step), Exchange.tsx (one exchange at any depth), ToolCall.tsx (an expanded call rendered as a command / an edit / a file, not as JSON), ConversationView.tsx, web/src/conversation.css.

The grammar: the prompt is a tinted band with its hanging in a 16px gutter, and it's the only thing outside the text column — no rail on the answer, none on the work. One meta line under it carries the work on the left (▸ 14 steps · 9 tools · 42s · 18.4k tok, and it is the fold control) and, where a surface shows more than one turn, turn 6/13 01:32 PM on the right. Step rows use one left column for two meanings: a tool's /, or a disclosure triangle greyed when there's nothing to open. Text steps expand in place, so nothing is said twice.

Overview card — reads the tail of the trace instead of the digest, so the work unfolds between prompt and answer and ↑ show previous turn grows history a turn at a time. Opens on the latest turn; pins to the tail only while the agent works. Inline in the list it stays digest-driven — one transcript read per visible agent is not what the Overview is for. The turnsLog stepper is gone.

Session pane — a terminal | reader switch in the bottom bar, next to zoom. It's app-wide, like zoom: both modes show the same session, and what differs is the form, which is why the labels name the form. Reader mode draws over the terminal, which stays mounted and connected but loses the keyboard; a shell stays a terminal whatever the switch says. You can reply from it — the card's composer, the same sendInput, the same optimistic echo.

ServerpageOf() takes a negative offset, so "the last N turns" is one request instead of one to learn total and another to read.

The subtle bit

What counts as the answer: the trailing run of messages — everything the agent said after its last action. Taking the last final looked right until a real session disagreed: asked for news, the agent searched, answered, and then replied "No response requested." to a notification — and the harness marked that final. The card showed the boilerplate and buried the news in the work. A message followed by more tool calls is thinking out loud, not a reply; mid-task there is no answer at all, and the running line carries the current step instead.

Verification

  • npm test in server/ (incl. trace-tail.test.mjs) and in web/ (exchanges.test.mjs — the answer rule, tool grouping, prompt splitting, a call cut mid-JSON), tsc --noEmit, production build.
  • A local driving harness (web/app-shots.mjs, excluded from the repo like the design lab — see §9 of the doc) exercises a running instance and checks eleven behaviours: the list view makes zero trace reads, the window makes one, full history ↗ flips an open pane, focus stays out of the covered terminal, reader mode has a reply line and the echo lands, a shell keeps its terminal, a conversation survives a failed refresh, and merging two agents switches to their group layout.

Not in this PR (§10 of the doc)

head.prompts[]; the sidebar cleanup (openTrace and the per-row trace/share buttons); windowing reader mode by exchange — it reads the last 400 turns in one page today and says how many messages it's leaving out; the mobile card-sizing @media block.

docs/conversation-view.md carries the reasoning, the four depths, where each sidebar affordance goes, and what's left.

🤖 Generated with Claude Code

The Overview card read well and showed too little; the trace viewer showed
everything and read badly. They rendered the same thing — one agent's
conversation — through two components, two data paths and two visual languages.
This gives them one renderer, built around the exchange: your prompt, the work,
the answer.

web/src/components/conversation/
  exchanges.ts       turns → exchanges → one line per step
  Exchange.tsx       one exchange, at whatever depth the surface needs
  ToolCall.tsx       an expanded call as a command / an edit / a file, not JSON
  ConversationView   RENDER mode: a stack of exchanges, search, turn nav
  ../conversation.css

The grammar: the prompt is a tinted band with its ❯ hanging in a 16px gutter,
and it is the only thing outside the text column — no rail on the answer, none
on the work. One meta line under it carries the work on the left ("14 steps · 9
tools · 42s · 18.4k tok", and it is the fold control) and, where a surface shows
more than one turn, "turn 6/13 01:32 PM" on the right. Step rows use one left
column for two meanings: a tool's ✓/✗, or a disclosure triangle greyed when
there is nothing to open. Text steps expand in place — same font, no longer
truncated — so nothing is said twice.

Overview card (§3.2). Reads the tail of the trace rather than the digest, which
only ever held assistant TEXT from the current request: no tool calls, no
thinking, nothing before the last prompt. The work now unfolds between prompt
and answer, "show previous turn" grows history one turn at a time, and the card
opens on the latest turn — pinned to the tail only while the agent works. The
digest still drives the card when there is no transcript to read (never started,
a harness without one). The turnsLog stepper is gone: stepping through answers
in place of the answer was a workaround for not having the middle.

Session pane (§3.3). A TUI ⇄ RENDER switch in the header. RENDER draws OVER the
terminal, which stays mounted and connected — xterm needs layout to fit, and
detaching tmux costs a repaint and can trip the handoff path. The mode is a
per-session view preference in localStorage, not session state.

Server: pageOf() takes a negative offset, so "the last N turns" is one request
instead of one to learn `total` and another to read. Covered by
server/test/trace-tail.test.mjs and wired into `npm test`.

What the answer IS, is the subtle part: the last `final` turn, or — when a
harness marks none — the trailing turn only if it ended on words. An assistant
message followed by more tool calls is thinking out loud mid-task, not a reply.
Promoting it moved it below the calls that came after it, which is exactly what
you watch happen while an agent works.

Not yet, and listed in §10: head.prompts[], the sidebar cleanup, windowing
RENDER by exchange (it reads the last 400 turns in one page today), and the
mobile card sizing block.
@lvwerra
lvwerra force-pushed the design/trace-unify branch from 3b645ab to c9fabe1 Compare August 5, 2026 22:47
@lvwerra lvwerra changed the title Design: one conversation renderer for the card and the trace viewer (+ a design lab) Draft: one conversation renderer for the Overview card and the session pane Aug 5, 2026
…ixes

Self-review of the branch turned up ten things. Seven of them are here; the
other three (§10) are follow-ups that need decisions, not fixes.

**The Overview was about to read one transcript per visible agent.** The card
fetched the last 120 turns on mount and polled every three seconds while the
agent worked — fine for the one card you opened, a fleet-sized cost for the
list view, which mounts a card per row. Inline, a card is a summary the digest
already answers; only the windowed card reads the trace now.

**A failed refresh blanked the conversation.** ConversationView returned the
error INSTEAD of the page, so a single EIO on the bucket mount — which happens
— wiped what you were reading. The error is a strip above the turns now, and
the last good read stays on screen.

**A poll threw you back to the first search hit.** The recount effect keyed on
an array whose identity changed every refresh; now only a new query jumps, and
a landing poll keeps your position among the matches.

**The covered terminal kept the keyboard.** Blurring on the mode change was not
enough: the pane becoming active refocuses on a timer, and the header and key
bar refocus on click, so keys typed while reading went into the agent's TTY
invisibly. Focus now goes through one guarded helper.

**"full history ↗" did nothing to an open pane.** The card wrote the mode to
localStorage and the pane only read it on mount. Both sides now share
web/src/lib/paneMode.ts, whose write also announces itself — no more duplicated
'am:pane-mode:' literal, and an already-open pane switches.

**A session with no transcript 404'd every three seconds.** It backs off to 30s.

**RENDER polled while its pane was off-screen.** TerminalPane passes `visible`
down; a paused view stops polling and catches up when it returns.

Plus: lab-only CSS (.cx-card, the lab viewer's own header) moved out of the
app's stylesheet into the lab's, and `web/test/exchanges.test.mjs` — the answer
rule has been wrong twice and had no coverage. It covers mid-task vs finished
turns, a superseded final, prompt splitting, tool grouping and a call cut
mid-JSON. `npm test` in web/ runs it; esbuild (already there for vite) does the
transpile, so no test runner is added.

Verified against a running instance: the list view makes zero trace reads, the
window makes one, "full history ↗" flips an open pane to RENDER, focus stays out
of the terminal, and a conversation survives a failed refresh.
@lvwerra

lvwerra commented Aug 6, 2026

Copy link
Copy Markdown
Member Author

Review round

I reviewed my own diff before asking anyone else to. Ten findings; seven are fixed in 85a08f0, three are follow-ups in §10 of the doc.

Fixed

The Overview was about to read one transcript per visible agent The card fetched 120 turns on mount and polled every 3s while working. Fine for the card you opened; a fleet-sized cost for the list view, which mounts a card per row. Inline, a card is a summary the digest already answers — only the windowed card reads the trace.
A failed refresh blanked the conversation ConversationView returned the error instead of the page, so one EIO on the bucket mount wiped what you were reading. It's a strip above the turns now; the last good read stays.
A poll threw you back to the first search hit The recount effect keyed on an array whose identity changed every refresh. Only a new query jumps now.
The covered terminal kept the keyboard Blurring on the mode change wasn't enough — the pane becoming active refocuses on a timer, and the header and key bar refocus on click. Keys typed while reading went into the agent's TTY invisibly. Focus goes through one guarded helper.
"full history ↗" did nothing to an already-open pane The card wrote localStorage; the pane only read it on mount. Both sides now use web/src/lib/paneMode.ts, whose write announces itself. Kills the duplicated am:pane-mode: literal too.
A session with no transcript 404'd every 3s Backs off to 30s.
RENDER polled while its pane was off-screen TerminalPane passes visible down; a paused view stops and catches up on return.

Also: lab-only CSS moved out of the app stylesheet, and web/test/exchanges.test.mjs — the answer rule (what counts as the reply, what stays in the work) has been wrong twice and had no coverage. It covers mid-task vs finished turns, a superseded final, prompt splitting, tool grouping, and a call cut mid-JSON. npm test in web/; esbuild (already present for vite) does the transpile, so no test runner is added.

Verified against a running instance, not just built: list view makes 0 trace reads, opening the card makes 1, "full history ↗" flips an open pane to RENDER, focus stays out of the terminal under the overlay, and a conversation survives a failed refresh.

Left as follow-ups (§10): head.prompts[], the sidebar cleanup, and windowing RENDER by exchange — it reads the last 400 turns in one page today and says so in the header.

Agent Manager added 4 commits August 6, 2026 21:07
Two things the deployed build got wrong.

**A shell had a RENDER switch.** There is no conversation behind `bash` — the
same rule the Overview uses to decide what is an agent now gates the switch
(`cli !== 'shell' && !isPassive(cli)`), and a non-agent pane is pinned to TUI
even if something wrote a mode preference for it.

**A rendered session could be read but not answered**, which sent you back to
the TUI to type — the one thing RENDER is meant to spare you. It now carries
the card's own composer: the same `.ov-live` markup, the same `sendInput`, the
same optimistic echo (your prompt at the bottom with a `working` line until the
transcript catches up), Enter to send on a desktop and the button on a phone.

Read-only is now a property of the CONTENT, not of the surface: `readOnly` is
for a trace with no agent behind it — a shared file, an import — where there is
nobody to answer.

Verified in a running instance: the shell pane offers no RENDER, the agent pane
does, and a reply sent from RENDER reaches the session and shows immediately.
…merge

**The composer.** The "↵ send · ⇧↵ newline" caption appeared the moment you
started typing — the moment you already knew — so it is gone. The send key is
square with a small radius rather than a pill (it sits at the end of a line of
type, not in a chat bubble), and its arrow is drawn at 1.9px instead of set as
a text glyph, which was thin at 26px.

**RENDER fills the pane.** The 720px reading column left a gutter of nothing on
each side while the prompt band — the thing you scan for — still spanned the
full width, so the two disagreed about where the conversation began. The pane is
the measure now: narrow the pane and the conversation narrows with it.

**Merging two agents lands you in their group.** `doMove` refreshed the tree and
left `activeRef` pointing at `s:<id>`, which is still a valid ref after the move
— so nothing in the reconcile effect corrected it and you stayed on a single
view of a session that had just become half of a grid. If the session you are
watching ends up in a group, the view follows it there. Moving a session you are
NOT looking at still leaves your view alone.

app-shots.mjs gains the regression check: 1 visible pane before the drop, 2
after. It dispatches the drag events across separate ticks, because React has to
commit `dragRef` between them — firing them in one go leaves the handlers
reading a stale closure and nothing happens at all.
…answer rule

**The switch moved and became app-wide.** It sits with zoom, because it is the
same kind of setting — how you are reading everything, not what one pane is.
Per-pane toggling was a preference nobody wanted to manage. A pane with nothing
to render (a shell) stays a terminal whatever the switch says.

**"RENDER" is gone as a name.** It described the implementation, not what you
get. The two states are `terminal` and `conversation`: one is the TTY, the other
is what was said in it. Renamed through the code, the CSS and the doc.

**The answer is the trailing RUN of messages, not the last `final`.** Seen in a
real session: asked for news, the agent searched, answered, and then a
notification arrived that it replied to with "No response requested." — which
the harness marked `final`. The card showed the boilerplate as the answer and
buried the news in the work. Everything the agent said after its last action is
the answer now; a `final` followed by more tool calls still stands, where it is.
`Exchange.answer` is a `TraceTurn[]`. Covered by the case in the test file.

Also: an exchange with nothing to say — a prompt that got no reply at all —
renders no meta row rather than an empty one.
"Conversation" described the terminal just as well — both modes show the same
session; what differs is the form. Reader mode borrows the browser's idea: the
same content, laid out to be read. The toggle reads `terminal | reader`, the
type is `PaneMode = 'terminal' | 'reader'`, and the overlay is `.pane-reader`.

app-shots.mjs now resets the fleet before it runs — half its checks compare
pane counts, and previous runs left groups and spare shells behind.
@lvwerra
lvwerra marked this pull request as ready for review August 6, 2026 22:18
@lvwerra lvwerra changed the title Draft: one conversation renderer for the Overview card and the session pane One conversation renderer for the Overview card and the session pane Aug 6, 2026
@thomwolf
thomwolf merged commit d1908de into main Aug 7, 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.

2 participants