One conversation renderer for the Overview card and the session pane - #37
Conversation
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.
3b645ab to
c9fabe1
Compare
…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.
Review roundI 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
Also: lab-only CSS moved out of the app stylesheet, and 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): |
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.
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.turnsLogholds 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 PMon 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 turngrows 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. TheturnsLogstepper is gone.Session pane — a
terminal | readerswitch 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 samesendInput, the same optimistic echo.Server —
pageOf()takes a negative offset, so "the last N turns" is one request instead of one to learntotaland 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
finallooked 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 thatfinal. 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 testinserver/(incl.trace-tail.test.mjs) and inweb/(exchanges.test.mjs— the answer rule, tool grouping, prompt splitting, a call cut mid-JSON),tsc --noEmit, production build.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 (openTraceand 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@mediablock.docs/conversation-view.mdcarries the reasoning, the four depths, where each sidebar affordance goes, and what's left.🤖 Generated with Claude Code