Thomas Wolf commited on
Commit
d1908de
·
unverified ·
2 Parent(s): 20d56687ef25ac

Merge pull request #37 from huggingface/design/trace-unify

Browse files
docs/conversation-view.md ADDED
@@ -0,0 +1,443 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # One conversation, three depths — unifying the Overview card and the trace viewer
2
+
3
+ Status: **draft implementation** · Branch `design/trace-unify` · Written 2026-08-05
4
+
5
+ The Overview reads well and shows too little. The trace viewer shows everything and reads
6
+ badly. They render the same thing — one agent's conversation — through two components, two
7
+ data paths and two visual languages. This document proposes collapsing that into **one
8
+ renderer used at four depths**, says exactly which affordance moves where, and records how each
9
+ choice was arrived at (§9). §10 tracks what of it is built.
10
+
11
+ Companion docs: `docs/trace-panel-spec.md` (how the reader and the viewer were built, and why
12
+ the digest is not enough), `docs/session-sharing.md` (share/receive).
13
+
14
+ ---
15
+
16
+ ## 1. What is actually wrong
17
+
18
+ **Two renderers.** The Overview card (`web/src/components/Overview.tsx:29`) draws
19
+ prompt → answer from a *digest*. The viewer (`web/src/components/TracePane.tsx:166`, `Row`)
20
+ draws role-badged turns from *trace pages*. Nothing is shared but `renderMarkdown`.
21
+
22
+ **The card cannot show the middle.** Its history control (`Overview.tsx:45,57,122-140`)
23
+ *replaces* the answer with an earlier one — a stepper, not an unfold. And the data it steps
24
+ through, `digest.turnsLog` (`server/src/traces.js:57-81`), holds only assistant **text** turns
25
+ from the **current** request. Tool calls, thinking, and everything before the last prompt are
26
+ not in the payload at all. So "unfold the messages in between" is not a UI tweak: the card
27
+ has to read the trace (§5).
28
+
29
+ **The sidebar carries four trace affordances** that all belong to a session it already lists:
30
+ read-trace and share on every agent row (`Sidebar.tsx:245-247`), share and handover on every
31
+ `trace` row (`Sidebar.tsx:237-238`). Worse, reading a trace *creates a session record*
32
+ (`App.tsx:openTrace`) — a second sidebar row for a session that already has one. On mobile,
33
+ where row actions are always visible (`styles.css:831`), each row is a cluster of five glyphs.
34
+
35
+ **The viewer is dressed as a terminal.** `.trace-body { background: var(--term-bg) }`
36
+ (`styles.css:895`), uppercase role pills, a mono chip per model and per-row token counts. The
37
+ card uses panel background, hairlines, one accent `❯`, 13px text. Same content, two registers —
38
+ and the terminal register is the one nobody likes.
39
+
40
+ **The card is boxed in on a phone.** `.ovw-backdrop { padding: 7vh 20px 20px }` +
41
+ `.ovw-win { max-width: 640px; max-height: 85vh }` (`styles.css`) is a desktop dialog. On a
42
+ 390pt screen it wastes ~15 % of the height and 40pt of width, and being `position: fixed` it
43
+ ignores `--vvh`, so an open keyboard can sit over the reply line.
44
+
45
+ ---
46
+
47
+ ## 2. The idea
48
+
49
+ > An agent's conversation is a list of **exchanges**. An exchange is *your prompt*, *the work*,
50
+ > and *the answer*. Every surface in this app shows exchanges — one or many, shallow or deep.
51
+
52
+ ```
53
+ Exchange = { prompt, steps[], answer }
54
+ ```
55
+
56
+ Four depths of one component:
57
+
58
+ | Depth | Surface | Shows |
59
+ |---|---|---|
60
+ | **D0** brief | Overview tile | prompt (1 line) + state |
61
+ | **D1** card | Overview card, collapsed | prompt, one meta line, answer, reply box |
62
+ | **D2** open | Overview card, unfolded | + the steps between prompt and answer; `↑ show previous turn` walks back one exchange at a time |
63
+ | **D3** full | Session pane, reader mode | every exchange, windowed, each expandable to D2 |
64
+
65
+ The viewer stops being a different thing and becomes **a vertical stack of the card**. That is
66
+ the whole design; the rest is consequences.
67
+
68
+ ---
69
+
70
+ ## 3. Surface by surface
71
+
72
+ ### 3.1 Overview tile — unchanged
73
+
74
+ Still digest-fed, still one line of prompt and a state word. It is the only thing on screen in
75
+ numbers, and it is already right.
76
+
77
+ ### 3.2 Overview card
78
+
79
+ Collapsed, it looks exactly like today: `❯ prompt`, one meta line, the answer as markdown, the
80
+ reply line. **Everything about the turn is on that one line, under the prompt** — nothing above
81
+ it. The line is also the fold control, so it names what it is hiding:
82
+
83
+ ```
84
+ ↑ show previous turn
85
+
86
+ ▒▒ ❯ rebuild the fixture generator so it merges index.json ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
87
+
88
+ ▸ 14 steps · 9 tools · 42s · 18.4k tok
89
+
90
+ Done — `scripts/lab-fixtures.mjs` now merges by name …
91
+
92
+ ❯ reply… ⬆
93
+ ```
94
+
95
+ The composer is the same everywhere: `❯`, a growing textarea, and a square send key. No
96
+ "↵ send · ⇧↵ newline" caption — it appeared the moment you typed, which is the moment you
97
+ already knew.
98
+
99
+ In the viewer the same line carries the turn's identity on its right — one row, two halves:
100
+
101
+ ```
102
+ ▒▒ ❯ rebuild the fixture generator so it merges index.json ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
103
+ ▸ 14 steps · 9 tools · 42s · 18.4k tok turn 6/13 01:32 PM
104
+ ```
105
+
106
+ The prompt is a **band**: a faint accent tint between two hairlines, with the `❯` hanging in
107
+ a 16px gutter to its left. Nothing else in the exchange sits outside that column — the answer
108
+ has no rail, the work has no rail — so scrolling a long conversation, the arrow and the band
109
+ are the only things your eye has to track, and they mean exactly one thing: *you said this*.
110
+
111
+ Unfolded (D2), the steps appear **between** prompt and answer, one line each, chronological:
112
+
113
+ ```
114
+ ▒▒ ❯ rebuild the fixture generator so it merges index.json ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
115
+ ▾ 14 steps · 9 tools · 42s · 18.4k tok
116
+ ▸ thinking weighing a merge against a rewrite…
117
+ ✓ Read scripts/lab-fixtures.mjs
118
+ ✓ Edit scripts/lab-fixtures.mjs · +6 −1
119
+ ▸ I'll also cap the candidate list before statting…
120
+ ✓ Bash node scripts/lab-fixtures.mjs …
121
+ ✗ Bash git -c core.hooksPath=… commit
122
+ Done — `scripts/lab-fixtures.mjs` now merges by name …
123
+ ```
124
+
125
+ Rules that keep this from becoming the ugly viewer in a small box:
126
+
127
+ - **One line per step**, always. Nothing expands by default.
128
+ - **One left column, two meanings.** A tool reports its outcome there (`✓` / `✗`); everything
129
+ else offers a disclosure triangle, greyed when there is nothing more to see. The fold control
130
+ above uses the same column and the same triangle, so it reads as the head of the list.
131
+ - **Expanding never repeats itself.** Text steps (thinking, an aside, a compaction) simply stop
132
+ being truncated — same font, same colour, more of it. Only a tool has genuinely *different*
133
+ material below: its input and what came back.
134
+ - Consecutive calls to the same tool collapse (`✓ Read ×4 App.tsx, api.ts, +2`). The grouping
135
+ logic exists — `ToolGroup` in `TracePane.tsx:86` — and gets reused, not rewritten.
136
+ - **Thinking is one line** with a preview; system/harness turns are not shown at all in the card
137
+ (they are the harness talking to itself).
138
+ - No badges, no per-step timestamps, no per-step tokens.
139
+ - **A failed result is a box with a red border**, not a red bar down one side. The bar reads as
140
+ decoration bolted onto the left edge; the border says *this block is the failure*.
141
+ - **Something is always at the bottom while the agent works.** With no answer yet — or a partial
142
+ one — a `working` line sits below the last thing said and above the reply box.
143
+ - **The card opens on the latest turn**, its prompt at the top of the body and the answer
144
+ reading downward; while the agent is working it pins to the tail instead. Asking for the
145
+ previous turn scrolls to the **top** — you asked for that turn, so the body lands on it rather
146
+ than leaving you where you were. The body is
147
+ the scroll region, sized by the window (`flex: 1; min-height: 0; overflow-y: auto`) — **never**
148
+ by a `vh` number, which is measured against the viewport and leaves a full-screen phone card
149
+ two-thirds empty.
150
+ - **Only the card you opened reads the trace.** Inline in the Overview list a card is a
151
+ summary — one prompt, one clamped answer — and the digest already has that. Reading the trace
152
+ per visible agent, and polling it every three seconds for the working ones, would turn the
153
+ Overview from one `/api/meta` poll into one transcript read per row. The window (and reader mode)
154
+ is where the middle gets fetched.
155
+ - **A card says nothing the surface around it already says.** No turn number (there is one turn),
156
+ no clock (its header carries `·6m`), no model, no harness. In the viewer the same line's right
157
+ half adds `turn 6/13` and the time, and names the model *only* when that turn's model differs
158
+ from the session's.
159
+
160
+ **Going back in history** — the part you were least sure about. The card grows by **one
161
+ exchange per click**, never by "load everything":
162
+
163
+ - A centred `↑ show previous turn` at the top of the body prepends the previous exchange,
164
+ collapsed to prompt + answer (its steps fold behind the same `▸ n steps`). Centred, because it
165
+ belongs to the whole card rather than to the column of text under it.
166
+ - After the second one, `full history ↗` joins it, opening the pane in reader mode at that
167
+ exchange. The card is for "what just happened"; archaeology has a bigger room, and the handoff
168
+ is one click.
169
+ - Cap the card at, say, 5 exchanges regardless — past that the card is the wrong tool and says so.
170
+
171
+ This replaces the `↑ ↓` turn stepper entirely. Stepping through turns *in place of* the answer
172
+ was a workaround for not having the middle; with the middle visible it has nothing left to do.
173
+
174
+ **Mobile.** The card window becomes the screen, with a small margin:
175
+
176
+ ```css
177
+ @media (max-width: 720px) {
178
+ .ovw-backdrop { padding: 6px; height: var(--vvh, 100dvh); align-items: stretch; }
179
+ .ovw-win { max-width: none; width: 100%; max-height: none; height: 100%; border-radius: 12px; }
180
+ }
181
+ ```
182
+
183
+ `--vvh` is already maintained from `window.visualViewport` (`App.tsx:106-114`); the backdrop
184
+ must use it, or an open keyboard covers the reply line it is there to serve.
185
+
186
+ ### 3.3 The session pane: terminal ⇄ reader
187
+
188
+ The bottom bar gets a two-state control, next to the zoom:
189
+
190
+ ```
191
+ [ terminal | reader ] − 100% +
192
+ ```
193
+
194
+ **It is one setting for the whole app, like zoom** — not a per-pane toggle. Reading a fleet means
195
+ reading it the same way, and a per-session switch was a preference nobody wanted to manage. A
196
+ pane with nothing to render (a shell) simply stays a terminal.
197
+
198
+ - **Only an agent has a conversation.** A shell stays a terminal whatever the switch says, and
199
+ the files/trace panels are not sessions at all — same rule the Overview uses to decide what is
200
+ an agent (`cli !== 'shell' && !isPassive(cli)`).
201
+ - **terminal** — today's terminal, untouched.
202
+ - **reader** — the same session, laid out: the exchange renderer over `/api/trace/:id`, at D3.
203
+ The two modes show the *same content*; what differs is the form, which is why the labels name
204
+ the form. "Conversation" would have described the terminal just as well.
205
+ - The mode is a **view preference**, kept in `localStorage` for the app, not in the store.
206
+ - The terminal element stays mounted and connected underneath; the reader draws over it. Toggling
207
+ must not detach tmux — a reattach costs a repaint and can trip the handoff path
208
+ (`HANDOFF_CODE`, `TerminalPane.tsx`). **Verify** this before shipping: xterm needs layout to
209
+ fit, so "cover, don't unmount" is the low-risk option, and a refit on return is required.
210
+ - The reader's toolbar is a second header row, not a squeeze into the first — on a phone the
211
+ first row has no spare width. It carries only what is true of the whole session and said
212
+ nowhere else: the model, `13 turns`, the token totals abbreviated (`2.2M↓ 654k↑`), `▲▼`, and
213
+ the search box. The harness is the logo in the row above; the raw message count and the cached
214
+ tokens are details, so they live in `title` attributes. There is no "expand everything" — each
215
+ turn folds itself, and search opens what it needs to.
216
+ - **Search has to be followable.** Filtering to matching turns is not finding: the term is
217
+ highlighted wherever it lands, a turn whose only match is inside its folded work *unfolds it*
218
+ (and opens the step holding it, body included), the box reports `3/17`, and `▲▼` switch from
219
+ walking turns to walking hits. With no query, `▲▼` put the next turn's prompt at the top of
220
+ the reading area — measured against the scroller's rect, not `offsetTop`, which is relative to
221
+ the nearest positioned ancestor and lands a few rows off.
222
+ - Vocabulary: a **turn** is one exchange, a **message** is a raw transcript row. Each turn's meta
223
+ line says `turn 6/13`, so the bar counts the same things the reader does.
224
+ - **The terminal must not keep the keyboard** while reader mode covers it. A mounted xterm with focus
225
+ swallows keystrokes into the agent's TTY, invisibly — and several paths grab focus back (the
226
+ pane becoming active, the header, the key bar), some of them *after* the mode changes, so the
227
+ guard belongs at each call rather than at the switch.
228
+ - **A failed refresh must not blank the conversation.** The poll's error is a strip above the
229
+ turns, not a replacement for them: this mount answers `EIO` now and then, and going stale for
230
+ three seconds beats losing your place mid-read.
231
+ - **A search survives a refresh.** Only a new query jumps to the first hit; a poll landing keeps
232
+ your position among the matches.
233
+ - **While the agent is working the viewer follows the tail**, and stops the moment you scroll up
234
+ (`< 48px from the bottom` is "still following"). A viewer that yanks you back down on every
235
+ tool call is unusable while a task runs; one that never moves makes you chase it.
236
+ - **The prompt band sticks to the top** while you read a long turn. What you want overhead deep
237
+ in someone's 67-step answer is the question it is answering — not a row of numbers.
238
+ - **The reader fills the pane.** A fixed reading column left a gutter of nothing on each
239
+ side while the prompt band still spanned the full width, so the two disagreed about where the
240
+ conversation began. The pane is the measure: narrow the pane and the conversation narrows.
241
+ - **Reader mode can be replied to.** Reading a conversation and answering it are the same act — the
242
+ card has always known that, and a rendered session that could only be read would send you back
243
+ to the terminal to type. It is the card's own composer (`.ov-live`), the same `sendInput`, and the
244
+ same optimistic echo: your prompt appears at the bottom with a `working` line until the
245
+ transcript catches up. Only a trace with **no agent behind it** — a shared file, an import — is
246
+ read-only, which is what `readOnly` is for.
247
+ - **Share** moves here from the sidebar. One session, one place.
248
+ - **Handover** ("continue from this trace in a new agent") lives in the conversation footer, beside
249
+ the provenance line — the only place where its meaning is obvious.
250
+
251
+ ### 3.4 What leaves the sidebar
252
+
253
+ | Today | Tomorrow |
254
+ |---|---|
255
+ | `Read this session's trace` on every agent row (`Sidebar.tsx:245`) | bottom bar → **reader** |
256
+ | `Share this session` on every agent row (`Sidebar.tsx:246`) | pane header → **share** |
257
+ | A `trace` **session row per read** (`App.tsx:openTrace`) | **gone** — no duplicate rows |
258
+ | Trace row's `Share` (`Sidebar.tsx:237`) | trace pane header (imported traces keep a pane) |
259
+ | Trace row's `Handover` (`Sidebar.tsx:238`) | reader / trace-pane footer |
260
+ | Quick-add **Trace** = open a shared dataset (`Sidebar.tsx:482`) | **stays** |
261
+
262
+ The last row is deliberate: an **imported** trace has no session behind it, so it is a genuine
263
+ object that needs a row of its own. Same for a transcript opened from the Files pane
264
+ (`getFileTracePage`). What disappears is the *local* trace pane — a session's own history is
265
+ now a mode of its own pane, not a second entity.
266
+
267
+ Agent rows keep stop/play and delete. Three glyphs less per row, which is most visible exactly
268
+ where the sidebar is worst: on a phone.
269
+
270
+ ---
271
+
272
+ ## 4. The renderer
273
+
274
+ ### 4.1 Turns → exchanges
275
+
276
+ Pure client-side, no new server concept:
277
+
278
+ ```ts
279
+ // split at each operator prompt; the answer is the turn the server already marked
280
+ splitExchanges(turns: TraceTurn[]): Exchange[]
281
+ new exchange at every turn where role === 'user' && !isHarnessNoise(turn)
282
+ answer = last turn in the exchange with kind === 'final' // markFinalTurns, traces.js:1371
283
+ steps = everything else, in order, minus role === 'system'
284
+ ```
285
+
286
+ `kind: 'final'` is already derived for every harness (`markFinalTurns`), which is why this works
287
+ uniformly and why codex's `task_complete` needs no special case.
288
+
289
+ ### 4.2 Block vocabulary — one line each
290
+
291
+ | Block | Collapsed line | Expanded |
292
+ |---|---|---|
293
+ | `thinking` | `⋯ thought for 12s` + preview | plain text, no markdown |
294
+ | `tool_use` (+`tool_result`) | `⌗ <Tool> <arg summary>` + `✓`/`✗` | args and result, `.tv-pre` style |
295
+ | run of same tool | `⌗ Read ×4 App.tsx, api.ts, +2` | each call in turn |
296
+ | `text` (non-final) | `· <one line>` muted | full markdown |
297
+ | `shell` | `$ <command>` + `✓`/`✗ exit n` | stdout/stderr |
298
+ | `compaction` | `↺ context compacted` | the summary |
299
+ | `image` | thumbnail chip | full image |
300
+ | `system` | *not rendered* in card; behind a toggle in D3 | — |
301
+ | `more` (truncation) | `+412 KB not retained` — **never** silently dropped | — |
302
+
303
+ ### 4.3 Visual grammar — the overview's, everywhere
304
+
305
+ Role is expressed by typography and position, not by a badge.
306
+
307
+ - Background `--panel`, never `--term-bg`. Hairlines at **exchange** boundaries only, not per turn.
308
+ - Prompt: `❯` in accent, 13px/550, `pre-wrap` (`.ov-prompt`).
309
+ - Answer: markdown at 13px, no rail — the tinted prompt band above it is what separates turns
310
+ (`styles.css:966`) — the one thing the viewer does better than the card today. Keep it in both.
311
+ - Steps: 11px mono label + 12px muted detail, single line, ellipsised.
312
+ - Model chip, token counts and timestamp move to the **exchange header**, right-aligned, muted,
313
+ `tabular-nums`. Per-turn usage survives as a `title` on hover.
314
+ - Uppercase role pills (`.tv-badge`), per-row model chips and per-row token counts are removed.
315
+ - D3 gets a **max reading width** (~720px, centered) when the pane is wide. Line length is most
316
+ of why the card reads better than the viewer.
317
+
318
+ Kept as-is: fold behaviour, height-measured windowing, search-says-what-it-searched, prompt
319
+ navigation, image blocks, the "not retained" and "reasoning was encrypted" honesty notes.
320
+
321
+ ---
322
+
323
+ ## 5. Data: which depth reads what
324
+
325
+ | Depth | Source | Cadence |
326
+ |---|---|---|
327
+ | D0 tile, D1 collapsed card | `/api/meta` digest — already polled for the whole Space | 1.5 s while the Overview is open (`App.tsx:200`) |
328
+ | D2 open card | `/api/trace/:id?offset=-40` — the tail | on unfold; every 3 s **only while that agent is running**; stops on collapse |
329
+ | card `↑ earlier` | `/api/trace/:id` around the previous prompt index | on click |
330
+ | D3 pane | `/api/trace/:id` paged, unchanged | on scroll |
331
+
332
+ The digest stays exactly what it is: the cheap, always-on summary. The moment you ask for the
333
+ middle, we read the real trace. No third data path, and no growth in what the 1 Hz Space-wide
334
+ pass retains (`docs/trace-panel-spec.md` §2 forbids that, for good reason).
335
+
336
+ **Two small server changes**, both in `pageOf()` (`server/src/traces.js:1404`):
337
+
338
+ 1. **`prompts: [{ i, ts, text }]`** beside today's `userTurns: number[]`. Clipped to ~200 chars.
339
+ It lets any surface draw the exchange skeleton — including the label on `↑ earlier` — without
340
+ fetching pages it will not render. Same single pass that already builds `userTurns`.
341
+ 2. **Negative offset** = from the end (`offset=-40` → last 40 turns), so a tail read is one
342
+ request instead of "fetch page 0 to learn `total`, then fetch the tail".
343
+
344
+ **The live-parse cost is the one number to watch.** `viewMemo` keys on mtime+size
345
+ (`traces.js:1437`), so every fetch against a *running* session re-parses the whole file — 634 ms
346
+ for a 9.46 MB transcript per the spec's measurements. At 3 s for one unfolded card that is
347
+ ~20 % of a core, bounded to one session (the memo holds exactly one), and it stops when the card
348
+ closes. Acceptable for a foreground action. If it bites, the fix is a **tail parser** that reads
349
+ only the last N lines: results whose call is off-window simply render as standalone rows, which
350
+ the block model already supports.
351
+
352
+ ---
353
+
354
+ ## 6. What this buys
355
+
356
+ - One component to style, so "the overview is more pleasant" becomes true everywhere at once.
357
+ - The card answers "what did it actually do?" without leaving the Overview.
358
+ - A session's history is reachable from the session, not from a second sidebar row.
359
+ - Three glyphs less per sidebar row, and a full-screen card on a phone.
360
+ - Removed code: the `turnsLog` stepper in the card, `openTrace`'s pane-creation path, two
361
+ sidebar buttons, `.tv-badge` and the terminal-styled viewer chrome.
362
+
363
+ ---
364
+
365
+ ## 7. Risks
366
+
367
+ | Risk | Mitigation |
368
+ |---|---|
369
+ | Re-parsing a live transcript every 3 s | one session at a time; running-only; tail parser in reserve |
370
+ | Covering the terminal breaks xterm fit / trips tmux handoff | keep mounted + refit on return; verify against a live session before shipping |
371
+ | The unfolded card becomes the ugly viewer in a small box | one line per step, nothing expanded by default, system turns hidden, hard cap of 5 exchanges |
372
+ | Losing an affordance someone used (share from the sidebar) | every removal has a named new home (§3.4); no capability drops |
373
+ | `trace` sessions already in the store | the pane type stays for imported bundles and Files-pane transcripts; existing local trace panes keep working, we just stop creating new ones |
374
+
375
+ ---
376
+
377
+ ## 8. Open questions (operator)
378
+
379
+ 1. **How far back in the card?** Proposal: one exchange per click, hard cap 5, then hand off to
380
+ the conversation. Alternative: one, then straight to the full conversation.
381
+ 2. **Does the app open in reader mode**, or always start on the terminal?
382
+ 3. **Kill the `↑ ↓` turn stepper?** Proposal: yes — unfolding replaces it.
383
+ 4. **Unfolded card of a running agent**: live-refresh every 3 s, or freeze until it finishes
384
+ (cheaper, less alive)?
385
+ 5. **Harness/system turns in D3**: hidden behind a toggle (proposal), or collapsed rows as today?
386
+ 6. **Does the Overview's list view survive?** With a full-screen card on mobile and tiles on
387
+ desktop, the list view may be redundant.
388
+
389
+ ---
390
+
391
+ ## 9. How this was designed
392
+
393
+ Every choice above was made by looking at it, not by reasoning about it: a local
394
+ harness (not committed — it carries real transcripts, and this repo is public) renders these
395
+ components against **captured conversations** in phone, tablet and desktop frames, in both
396
+ themes, with six scenarios — done, running, just-sent, failed tool, truncated, never-prompted.
397
+ Fixtures are captured by running the app's own reader (`readTraceByPath`), so the harness cannot
398
+ drift from the payload shape.
399
+
400
+ It also **replays a turn block by block** — a tool call lands, its result comes back, thinking
401
+ between them, the answer only at the end — which is how the live rules in §3.2 and §3.3 were
402
+ found. Three of them exist only because the replay made them obvious:
403
+
404
+ - The viewer has to follow the tail while the agent works, and stop the moment you scroll up.
405
+ - Mid-task there is *no answer*: an agent's aside is not a reply, so promoting the last message
406
+ to the answer slot moved it below the tool calls that came after it.
407
+ - The card's `working` line and reply box have to survive the middle of a turn, not just its end.
408
+
409
+ **One lesson worth keeping:** a frame is a container, not a viewport, so `@media (max-width:
410
+ 720px)` never fires inside one — and neither does it fire for a 390px-wide *pane* on a desktop.
411
+ Pane chrome therefore wraps by default rather than at a breakpoint, and the card body is sized by
412
+ its window (`flex: 1; min-height: 0`) rather than by any `vh` number.
413
+
414
+ ## 10. Sequencing
415
+
416
+ Built (this branch):
417
+
418
+ 1. `web/src/components/conversation/` — `exchanges.ts` (turns → exchanges → step lines),
419
+ `Exchange.tsx` (one exchange at any depth), `ToolCall.tsx` (an expanded call as a command, an
420
+ edit, a file — not as JSON), `ConversationView.tsx` (D3), `web/src/conversation.css`.
421
+ 2. The Overview card reads the trace tail and renders exchanges: the work unfolds, history grows
422
+ one turn per click, the `turnsLog` stepper is gone. The digest still drives the card when
423
+ there is no transcript to read.
424
+ 3. `pageOf()` takes a negative offset — "the last N turns" without a round trip to learn `total`
425
+ (`server/test/trace-tail.test.mjs`).
426
+ 4. The session pane gets terminal ⇄ reader, from the bottom bar, app-wide. It draws over the terminal, which stays mounted and
427
+ connected but loses the keyboard; the mode is a per-session view preference in `localStorage`
428
+ (`web/src/lib/paneMode.ts`), and its event reaches a pane that is already open — which is what
429
+ the card's "full history ↗" needs.
430
+ 5. Tests for the one piece of judgement in the renderer — what counts as the answer, and what
431
+ stays in the work (`web/test/exchanges.test.mjs`, `npm test` in `web/`).
432
+
433
+ Not yet, in the order I would do it:
434
+
435
+ 6. `head.prompts[]` (§5) — index, first line and timestamp per prompt, so a surface can draw the
436
+ skeleton and label "show previous turn" before fetching the page that holds it.
437
+ 7. The sidebar loses its trace buttons and `openTrace`; share moves into the pane header (§3.4).
438
+ 8. Windowing by exchange in reader mode: a collapsed turn is 2–3 rows, so the DOM stays small,
439
+ `head.prompts[]` gives the skeleton up front, and only an opened turn needs its page. The
440
+ measured-height machinery in `TraceView` is reused as-is — what changes is what a "row" means.
441
+ Until then it reads the last 400 turns in one request.
442
+ 9. The mobile card sizing block (§3.2) — the lab's `.lab-mfix` mirror of it is not the real
443
+ `@media` rule, and the real one has not landed.
server/package.json CHANGED
@@ -14,7 +14,7 @@
14
  "start": "node src/index.js",
15
  "dev": "node --watch src/index.js",
16
  "test:ui": "node terminal-ui.test.mjs",
17
- "test": "node test/repin.test.mjs && node test/opencode-resume.test.mjs && node test/terminal-modes.test.mjs && node migration.test.mjs && node resize.test.mjs"
18
  },
19
  "engines": {
20
  "node": ">=20.19"
 
14
  "start": "node src/index.js",
15
  "dev": "node --watch src/index.js",
16
  "test:ui": "node terminal-ui.test.mjs",
17
+ "test": "node test/repin.test.mjs && node test/opencode-resume.test.mjs && node test/terminal-modes.test.mjs && node test/trace-tail.test.mjs && node migration.test.mjs && node resize.test.mjs"
18
  },
19
  "engines": {
20
  "node": ">=20.19"
server/src/traces.js CHANGED
@@ -1430,7 +1430,11 @@ function pageOf(parsed, offset, limit) {
1430
  // before the page holding it has been fetched.
1431
  const userTurns = [];
1432
  for (let i = 0; i < total; i++) if (parsed.messages[i].role === 'user') userTurns.push(i);
1433
- const from = Math.max(0, Math.min(offset | 0, total));
 
 
 
 
1434
  const to = Math.min(total, from + Math.max(1, Math.min(limit | 0 || 200, 500)));
1435
  return {
1436
  harness: parsed.harness, harnessLabel: parsed.harnessLabel, sessionId: parsed.sessionId,
 
1430
  // before the page holding it has been fetched.
1431
  const userTurns = [];
1432
  for (let i = 0; i < total; i++) if (parsed.messages[i].role === 'user') userTurns.push(i);
1433
+ // A negative offset reads from the END what any surface showing the tail of
1434
+ // a conversation wants (the Overview card, RENDER mode) without first making a
1435
+ // round trip just to learn `total`.
1436
+ const off = offset | 0;
1437
+ const from = off < 0 ? Math.max(0, total + off) : Math.max(0, Math.min(off, total));
1438
  const to = Math.min(total, from + Math.max(1, Math.min(limit | 0 || 200, 500)));
1439
  return {
1440
  harness: parsed.harness, harnessLabel: parsed.harnessLabel, sessionId: parsed.sessionId,
server/test/trace-tail.test.mjs ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Reading the TAIL of a trace: a negative offset counts back from the end.
2
+ //
3
+ // The Overview card and RENDER mode both show the end of a conversation. Without
4
+ // this they would have to fetch once just to learn `total`, then fetch again —
5
+ // two round trips per card, on a FUSE-backed transcript. Run with:
6
+ // node test/trace-tail.test.mjs
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import os from 'node:os';
10
+ import assert from 'node:assert/strict';
11
+
12
+ const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'trace-tail-'));
13
+ process.env.DATA_DIR = path.join(TMP, 'data');
14
+ fs.mkdirSync(process.env.DATA_DIR, { recursive: true });
15
+
16
+ const { readTraceByPath } = await import('../src/traces.js');
17
+
18
+ // A minimal Claude transcript: 12 alternating turns, each one identifiable.
19
+ const file = path.join(TMP, 'session.jsonl');
20
+ const lines = [];
21
+ for (let i = 0; i < 12; i++) {
22
+ const user = i % 2 === 0;
23
+ lines.push(JSON.stringify({
24
+ type: user ? 'user' : 'assistant',
25
+ cwd: TMP,
26
+ timestamp: new Date(Date.UTC(2026, 0, 1, 0, i)).toISOString(),
27
+ message: {
28
+ role: user ? 'user' : 'assistant',
29
+ content: [{ type: 'text', text: `turn ${i}` }],
30
+ },
31
+ }));
32
+ }
33
+ fs.writeFileSync(file, `${lines.join('\n')}\n`);
34
+
35
+ const textOf = (t) => t.blocks.filter((b) => b.type === 'text').map((b) => b.text).join('');
36
+
37
+ const all = await readTraceByPath(file, { offset: 0, limit: 200 });
38
+ assert.equal(all.total, 12, 'twelve turns were written');
39
+
40
+ // The tail: the last four turns, in order, without knowing `total` first.
41
+ const tail = await readTraceByPath(file, { offset: -4, limit: 4 });
42
+ assert.equal(tail.offset, 8, 'a negative offset resolves against the end');
43
+ assert.deepEqual(tail.turns.map(textOf), ['turn 8', 'turn 9', 'turn 10', 'turn 11']);
44
+
45
+ // Asking for more tail than exists starts at the beginning rather than wrapping.
46
+ const over = await readTraceByPath(file, { offset: -500, limit: 500 });
47
+ assert.equal(over.offset, 0, 'a too-large tail clamps to the start');
48
+ assert.equal(over.turns.length, 12);
49
+
50
+ // Positive offsets are untouched by the change.
51
+ const mid = await readTraceByPath(file, { offset: 4, limit: 2 });
52
+ assert.equal(mid.offset, 4);
53
+ assert.deepEqual(mid.turns.map(textOf), ['turn 4', 'turn 5']);
54
+
55
+ // `userTurns` indexes the WHOLE conversation, not the page — jumping to a prompt
56
+ // has to work before the page holding it has been fetched.
57
+ assert.deepEqual(tail.userTurns, [0, 2, 4, 6, 8, 10]);
58
+
59
+ fs.rmSync(TMP, { recursive: true, force: true });
60
+ console.log('trace-tail: ok');
web/package.json CHANGED
@@ -13,6 +13,7 @@
13
  "dev": "vite",
14
  "build": "tsc --noEmit && vite build",
15
  "typecheck": "tsc --noEmit",
 
16
  "preview": "vite preview"
17
  },
18
  "dependencies": {
@@ -38,8 +39,8 @@
38
  "@codemirror/view": "^6.43.7",
39
  "@lezer/highlight": "^1.2.3",
40
  "@xterm/addon-clipboard": "^0.1.0",
41
- "@xterm/addon-web-links": "^0.12.0",
42
  "@xterm/addon-fit": "^0.10.0",
 
43
  "@xterm/xterm": "^5.5.0",
44
  "codemirror": "^6.0.2",
45
  "dompurify": "^3.4.11",
@@ -53,6 +54,7 @@
53
  "@types/react": "^18.3.3",
54
  "@types/react-dom": "^18.3.0",
55
  "@vitejs/plugin-react": "^4.3.1",
 
56
  "typescript": "^5.5.3",
57
  "vite": "^5.4.0"
58
  }
 
13
  "dev": "vite",
14
  "build": "tsc --noEmit && vite build",
15
  "typecheck": "tsc --noEmit",
16
+ "test": "node test/exchanges.test.mjs",
17
  "preview": "vite preview"
18
  },
19
  "dependencies": {
 
39
  "@codemirror/view": "^6.43.7",
40
  "@lezer/highlight": "^1.2.3",
41
  "@xterm/addon-clipboard": "^0.1.0",
 
42
  "@xterm/addon-fit": "^0.10.0",
43
+ "@xterm/addon-web-links": "^0.12.0",
44
  "@xterm/xterm": "^5.5.0",
45
  "codemirror": "^6.0.2",
46
  "dompurify": "^3.4.11",
 
54
  "@types/react": "^18.3.3",
55
  "@types/react-dom": "^18.3.0",
56
  "@vitejs/plugin-react": "^4.3.1",
57
+ "playwright": "^1.62.1",
58
  "typescript": "^5.5.3",
59
  "vite": "^5.4.0"
60
  }
web/src/App.tsx CHANGED
@@ -15,6 +15,7 @@ import BackupBanner from './components/BackupBanner';
15
  import Welcome from './components/Welcome';
16
  import * as api from './api';
17
  import type { Cli, GridSpec, MoveTarget, OverviewFilter, Session, Tree } from './types';
 
18
  import { isPassive, isRemote } from './types';
19
  import { GridGlyph, ListGlyph } from './components/icons';
20
 
@@ -78,6 +79,11 @@ export default function App() {
78
  // stored — flipping the setting instantly (un)archives.
79
  const [showArchived, setShowArchived] = useState(false);
80
  const [archiveAfter, setArchiveAfter] = useState<'week' | 'month' | 'never'>('month');
 
 
 
 
 
81
  const [zoom, setZoom] = useState<number>(() => {
82
  const z = parseInt(localStorage.getItem('am-zoom') || '100', 10);
83
  return Number.isFinite(z) ? z : 100;
@@ -515,7 +521,21 @@ export default function App() {
515
  setActiveRef(`g:${g.id}`);
516
  } catch (e) { showErr('Couldn’t create the group')(e); }
517
  };
518
- const doMove = (ref: string, to: MoveTarget) => api.move(ref, to).then(refresh).catch(showErr('Couldn’t move that'));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
519
  const renameGroup = (id: string, name: string) => api.renameGroup(id, name).then(refresh).catch(showErr('Couldn’t rename'));
520
  const renameSession = (id: string, name: string) => { if (name.trim()) api.renameSession(id, name.trim()).then(refresh).catch(showErr('Couldn’t rename')); };
521
  const deleteGroup = (id: string) => api.deleteGroup(id).then(() => { if (activeRef === `g:${id}`) setActiveRef(null); refresh(); }).catch(showErr('Couldn’t delete the group'));
@@ -718,6 +738,7 @@ export default function App() {
718
  cli={cliMap[s.cli]}
719
  theme={theme}
720
  zoom={zoom}
 
721
  focused={shown && sessions.length > 1 && s.id === focusedId}
722
  visible={shown && deckVisible}
723
  active={shown && deckVisible && s.id === focusedId}
@@ -965,6 +986,15 @@ export default function App() {
965
  </span>
966
  )}
967
  <span className="spacer" />
 
 
 
 
 
 
 
 
 
968
  <button className="zbtn" title="Zoom out" onClick={() => setZoom((z) => Math.max(50, z - 10))}>−</button>
969
  <button className="zlvl" title="Reset to 100%" onClick={() => setZoom(100)}>{zoom}%</button>
970
  <button className="zbtn" title="Zoom in" onClick={() => setZoom((z) => Math.min(200, z + 10))}>+</button>
 
15
  import Welcome from './components/Welcome';
16
  import * as api from './api';
17
  import type { Cli, GridSpec, MoveTarget, OverviewFilter, Session, Tree } from './types';
18
+ import { onPaneMode, readPaneMode, writePaneMode } from './lib/paneMode';
19
  import { isPassive, isRemote } from './types';
20
  import { GridGlyph, ListGlyph } from './components/icons';
21
 
 
79
  // stored — flipping the setting instantly (un)archives.
80
  const [showArchived, setShowArchived] = useState(false);
81
  const [archiveAfter, setArchiveAfter] = useState<'week' | 'month' | 'never'>('month');
82
+ // How every pane is read — the terminal itself, or reader mode over the same
83
+ // session. App-wide, like zoom, and remembered the same way.
84
+ const [paneMode, setPaneMode] = useState(readPaneMode);
85
+ useEffect(() => onPaneMode(setPaneMode), []);
86
+ const showPaneMode = (m: 'terminal' | 'reader') => { setPaneMode(m); writePaneMode(m); };
87
  const [zoom, setZoom] = useState<number>(() => {
88
  const z = parseInt(localStorage.getItem('am-zoom') || '100', 10);
89
  return Number.isFinite(z) ? z : 100;
 
521
  setActiveRef(`g:${g.id}`);
522
  } catch (e) { showErr('Couldn’t create the group')(e); }
523
  };
524
+ // Merging two agents, or dropping one into a group, changes what the pane you
525
+ // are looking at IS — it is now part of a grid. Follow it there rather than
526
+ // leaving you on a single view of a session that has moved.
527
+ const doMove = (ref: string, to: MoveTarget) => api.move(ref, to)
528
+ .then(async () => {
529
+ const next = await api.getTree().catch(() => null);
530
+ if (!next) return refresh();
531
+ setTree(next);
532
+ const watching = activeRef?.startsWith('s:') ? activeRef.slice(2) : null;
533
+ if (!watching) return undefined;
534
+ const home = next.groups.find((g) => g.sessionIds.includes(watching));
535
+ if (home) setActiveRef(`g:${home.id}`);
536
+ return undefined;
537
+ })
538
+ .catch(showErr('Couldn’t move that'));
539
  const renameGroup = (id: string, name: string) => api.renameGroup(id, name).then(refresh).catch(showErr('Couldn’t rename'));
540
  const renameSession = (id: string, name: string) => { if (name.trim()) api.renameSession(id, name.trim()).then(refresh).catch(showErr('Couldn’t rename')); };
541
  const deleteGroup = (id: string) => api.deleteGroup(id).then(() => { if (activeRef === `g:${id}`) setActiveRef(null); refresh(); }).catch(showErr('Couldn’t delete the group'));
 
738
  cli={cliMap[s.cli]}
739
  theme={theme}
740
  zoom={zoom}
741
+ mode={paneMode}
742
  focused={shown && sessions.length > 1 && s.id === focusedId}
743
  visible={shown && deckVisible}
744
  active={shown && deckVisible && s.id === focusedId}
 
986
  </span>
987
  )}
988
  <span className="spacer" />
989
+ {/* Reader mode sits with zoom because it is the same kind of
990
+ setting: how you are looking at everything, not what any one
991
+ pane is. The content is identical either way — this is form. */}
992
+ <span className="seg modebar">
993
+ <button className={paneMode === 'terminal' ? 'on' : ''} title="The terminal itself"
994
+ onClick={() => showPaneMode('terminal')}>terminal</button>
995
+ <button className={paneMode === 'reader' ? 'on' : ''} title="Reader mode — the same session, laid out"
996
+ onClick={() => showPaneMode('reader')}>reader</button>
997
+ </span>
998
  <button className="zbtn" title="Zoom out" onClick={() => setZoom((z) => Math.max(50, z - 10))}>−</button>
999
  <button className="zlvl" title="Reset to 100%" onClick={() => setZoom(100)}>{zoom}%</button>
1000
  <button className="zbtn" title="Zoom in" onClick={() => setZoom((z) => Math.min(200, z + 10))}>+</button>
web/src/components/Overview.tsx CHANGED
@@ -1,11 +1,15 @@
1
- import { useEffect, useMemo, useRef, useState } from 'react';
2
  import type { CSSProperties, ReactNode } from 'react';
3
  import * as api from '../api';
4
- import type { MetaSession } from '../api';
5
  import type { Cli, OverviewFilter, Session, SessionState, Tree } from '../types';
6
  import { isPassive } from '../types';
7
  import { renderMarkdown } from '../lib/markdown';
8
  import Logo from './Logo';
 
 
 
 
9
 
10
  const fmtAgo = (ts: number) => {
11
  if (!ts) return '';
@@ -22,11 +26,71 @@ const eligible = (s: Session) => s.cli !== 'shell' && !isPassive(s.cli);
22
  const bucket = (state: SessionState): OverviewFilter =>
23
  state === 'working' ? 'working' : state === 'waiting' ? 'waiting' : 'quiet';
24
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  const Caret = () => (
26
  <svg className="ov-caret" viewBox="0 0 10 10" aria-hidden="true"><path d="M1.8 3.2h6.4L5 7.4z" fill="currentColor" /></svg>
27
  );
28
 
29
- function Card({ s, color, pending, isMobile, onOpen, onClose }: {
 
 
 
 
 
 
 
 
30
  s: MetaSession;
31
  color?: string;
32
  pending?: boolean; // digest still loading — show a shimmer instead of "no prompt yet"
@@ -42,8 +106,15 @@ function Card({ s, color, pending, isMobile, onOpen, onClose }: {
42
  // send succeeds — the digest round-trip (CLI writes transcript → rebuild →
43
  // poll) can take seconds, and a frozen card reads as "did that get lost?".
44
  const [sent, setSent] = useState<{ text: string; at: number } | null>(null);
45
- const [histIdx, setHistIdx] = useState(0); // 0 = live view, n = n-th exchange back
 
 
46
  const inputRef = useRef<HTMLTextAreaElement>(null);
 
 
 
 
 
47
 
48
  // After you send (or when the transcript shows a prompt newer than the last
49
  // answer), the old answer is stale — a spinner takes its place.
@@ -58,6 +129,15 @@ function Card({ s, color, pending, isMobile, onOpen, onClose }: {
58
  const idx = Math.min(histIdx, hist.length);
59
  const entry = idx > 0 ? hist[idx - 1] : null;
60
 
 
 
 
 
 
 
 
 
 
61
  const send = async () => {
62
  const text = draft.trim();
63
  if (!text || sending) return;
@@ -76,6 +156,22 @@ function Card({ s, color, pending, isMobile, onOpen, onClose }: {
76
  setSending(false);
77
  };
78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  const ago = fmtAgo(Math.max(d?.lastAssistantTs || 0, d?.lastPromptTs || 0) || Date.parse(s.createdAt) || 0);
80
  const promptText = sent ? sent.text : d?.lastPromptText || '';
81
  const answerText = entry ? entry.answer : d?.lastAssistantText || '';
@@ -99,7 +195,7 @@ function Card({ s, color, pending, isMobile, onOpen, onClose }: {
99
  const answerHtml = showAnswer ? renderMarkdown(answerMd || answerText) : '';
100
 
101
  return (
102
- <div className="ov-card">
103
  <div className="ov-id" onClick={() => onOpen(s.id)} title="Open pane">
104
  <span className={`status ${s.state}`} />
105
  <Logo cli={s.cli} size={12} tint={color} />
@@ -111,40 +207,81 @@ function Card({ s, color, pending, isMobile, onOpen, onClose }: {
111
  </div>
112
 
113
  {/* the card body is the ONLY scroll region (window mode); input stays put */}
114
- <div className="ov-body">
115
- {promptText ? (
116
- <div className="ov-prompt">{sent ? sent.text : (d?.lastPromptRaw || promptText)}</div>
117
- ) : pending ? (
118
- <div className="ov-prompt-skel"><span className="skel" style={{ width: '70%' }} /></div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119
  ) : (
120
- <div className="ov-prompt ov-prompt-none">no prompt yet</div>
121
- )}
122
- {(metaBits.length > 0 || hist.length > 0) && (
123
- <div className="ov-meta mono">
124
- <span className="ov-meta-bits">{metaBits.join(' · ')}</span>
125
- <span className="spacer" />
126
- {hist.length > 0 && (
127
- <span className="ov-nav">
128
- {idx > 0 && <span className="ov-nav-pos">turn {totalTurns - idx}/{totalTurns}</span>}
129
- <button
130
- className="ov-nav-btn" title="Earlier turn" disabled={idx >= hist.length}
131
- onClick={() => setHistIdx(Math.min(idx + 1, hist.length))}
132
- >↑</button>
133
- <button
134
- className="ov-nav-btn" title="Later turn" disabled={idx === 0}
135
- onClick={() => setHistIdx(Math.max(idx - 1, 0))}
136
- >↓</button>
137
- </span>
138
  )}
139
- </div>
140
- )}
141
-
142
- {answerHtml && (
143
- <div className="ov-answer-wrap">
144
- <div className="markdown ov-md" dangerouslySetInnerHTML={{ __html: answerHtml }} />
145
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
  )}
147
- {showLiveProgress && <div className="ov-busy mono">running</div>}
148
  </div>
149
 
150
  <div className="ov-live">
@@ -170,8 +307,7 @@ function Card({ s, color, pending, isMobile, onOpen, onClose }: {
170
  if (e.key === 'Escape') { setDraft(''); inputRef.current?.blur(); }
171
  }}
172
  />
173
- {draft.trim() && <button className="ov-send" title="Send" onClick={send} disabled={sending}></button>}
174
- {draft.trim() && !isMobile && <span className="ov-hint">↵ send · ⇧↵ newline</span>}
175
  </div>
176
  {failed && <div className="ov-note">failed to reach the agent</div>}
177
  </div>
 
1
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
2
  import type { CSSProperties, ReactNode } from 'react';
3
  import * as api from '../api';
4
+ import type { MetaSession, TraceTurn } from '../api';
5
  import type { Cli, OverviewFilter, Session, SessionState, Tree } from '../types';
6
  import { isPassive } from '../types';
7
  import { renderMarkdown } from '../lib/markdown';
8
  import Logo from './Logo';
9
+ import { SendGlyph } from './icons';
10
+ import ExchangeView from './conversation/Exchange';
11
+ import { writePaneMode } from '../lib/paneMode';
12
+ import { splitExchanges } from './conversation/exchanges';
13
 
14
  const fmtAgo = (ts: number) => {
15
  if (!ts) return '';
 
26
  const bucket = (state: SessionState): OverviewFilter =>
27
  state === 'working' ? 'working' : state === 'waiting' ? 'waiting' : 'quiet';
28
 
29
+ /** How much of the conversation the card reads. Cheap: one page, from the end. */
30
+ const CARD_TAIL = 120;
31
+ const CARD_TURNS = 5; // past this the card is the wrong tool, and says so
32
+ const POLL_MS = 3_000;
33
+ const MISSING_MS = 30_000; // a session with no trace: check back, but rarely
34
+
35
+ /**
36
+ * The tail of a session's conversation (docs/conversation-view.md §5).
37
+ *
38
+ * The digest cannot answer "what happened in the middle": `turnsLog` holds only
39
+ * assistant TEXT from the current request — no tool calls, no thinking, nothing
40
+ * before the last prompt. So the card reads the trace itself, and falls back to
41
+ * the digest when there is no transcript yet (a session that never started, an
42
+ * unsupported harness).
43
+ *
44
+ * `on` is false for the card inline in the list: a summary does not need the
45
+ * middle, and one trace read per visible agent — every three seconds for the
46
+ * working ones — is a lot to spend on something nobody asked to see.
47
+ */
48
+ function useConversationTail(id: string, on: boolean, live: boolean) {
49
+ const [turns, setTurns] = useState<TraceTurn[] | null>(null);
50
+ // No transcript for this session: back off hard rather than 404 on a loop.
51
+ const [missing, setMissing] = useState(false);
52
+ const load = useCallback(async () => {
53
+ try {
54
+ const page = await api.getTracePage(id, -CARD_TAIL, CARD_TAIL);
55
+ setTurns(page.turns);
56
+ setMissing(false);
57
+ } catch {
58
+ setMissing(true);
59
+ }
60
+ }, [id]);
61
+ useEffect(() => {
62
+ setTurns(null);
63
+ setMissing(false);
64
+ if (on) load();
65
+ }, [load, on]);
66
+ // While the agent works the transcript is still being written.
67
+ useEffect(() => {
68
+ if (!on || !live) return undefined;
69
+ const h = window.setInterval(load, missing ? MISSING_MS : POLL_MS);
70
+ return () => window.clearInterval(h);
71
+ }, [on, live, missing, load]);
72
+ return { turns, missing };
73
+ }
74
+
75
+ /** Opening this session's pane in reader mode rather than on the TTY. */
76
+ const openRendered = (id: string, onOpen: (sid: string) => void) => {
77
+ writePaneMode('reader'); // app-wide, and it reaches open panes too
78
+ onOpen(id);
79
+ };
80
+
81
  const Caret = () => (
82
  <svg className="ov-caret" viewBox="0 0 10 10" aria-hidden="true"><path d="M1.8 3.2h6.4L5 7.4z" fill="currentColor" /></svg>
83
  );
84
 
85
+ /**
86
+ * One exchange plus a reply box (docs/conversation-view.md §3.2).
87
+ *
88
+ * Collapsed it reads as it always did — your prompt, the answer, the reply line.
89
+ * What is new is the middle: `▸ 14 steps · 9 tools` unfolds the work between the
90
+ * two, and history grows one turn at a time instead of a stepper that replaced
91
+ * the answer with an older one.
92
+ */
93
+ export function Card({ s, color, pending, isMobile, onOpen, onClose }: {
94
  s: MetaSession;
95
  color?: string;
96
  pending?: boolean; // digest still loading — show a shimmer instead of "no prompt yet"
 
106
  // send succeeds — the digest round-trip (CLI writes transcript → rebuild →
107
  // poll) can take seconds, and a frozen card reads as "did that get lost?".
108
  const [sent, setSent] = useState<{ text: string; at: number } | null>(null);
109
+ const [histIdx, setHistIdx] = useState(0); // digest fallback only: n-th answer back
110
+ const [back, setBack] = useState(0); // how many earlier turns are shown
111
+ const [openWork, setOpenWork] = useState(false);
112
  const inputRef = useRef<HTMLTextAreaElement>(null);
113
+ const bodyRef = useRef<HTMLDivElement>(null);
114
+ const latestRef = useRef<HTMLDivElement>(null);
115
+ // The window is the only place the card can grow; inline in the list it stays
116
+ // a summary, so the answer is clamped and history stays behind the pane.
117
+ const windowed = !!onClose;
118
 
119
  // After you send (or when the transcript shows a prompt newer than the last
120
  // answer), the old answer is stale — a spinner takes its place.
 
129
  const idx = Math.min(histIdx, hist.length);
130
  const entry = idx > 0 ? hist[idx - 1] : null;
131
 
132
+ const { turns } = useConversationTail(s.id, windowed, running || awaiting);
133
+ const exchanges = useMemo(() => (turns ? splitExchanges(turns) : []), [turns]);
134
+ const cap = windowed ? CARD_TURNS : 1;
135
+ const shownX = exchanges.slice(Math.max(0, exchanges.length - 1 - back));
136
+ const latestX = shownX[shownX.length - 1];
137
+ const earlierX = shownX.slice(0, -1);
138
+ const remainingX = exchanges.length - shownX.length;
139
+ const atCap = back + 1 >= cap;
140
+
141
  const send = async () => {
142
  const text = draft.trim();
143
  if (!text || sending) return;
 
156
  setSending(false);
157
  };
158
 
159
+ // Where the body sits after a change:
160
+ // · asked for the previous turn → the top, at the turn you asked for
161
+ // · working → the tail, where the newest line is
162
+ // · otherwise → the latest turn's prompt, reading downward from there
163
+ const wasBack = useRef(back);
164
+ useLayoutEffect(() => {
165
+ const el = bodyRef.current;
166
+ if (!el || !latestX) return;
167
+ const prepended = back > wasBack.current;
168
+ wasBack.current = back;
169
+ if (prepended) { el.scrollTop = 0; return; }
170
+ if (running) { el.scrollTop = el.scrollHeight; return; }
171
+ const tgt = latestRef.current;
172
+ if (tgt) el.scrollTop += tgt.getBoundingClientRect().top - el.getBoundingClientRect().top;
173
+ }, [back, sent, running, latestX]);
174
+
175
  const ago = fmtAgo(Math.max(d?.lastAssistantTs || 0, d?.lastPromptTs || 0) || Date.parse(s.createdAt) || 0);
176
  const promptText = sent ? sent.text : d?.lastPromptText || '';
177
  const answerText = entry ? entry.answer : d?.lastAssistantText || '';
 
195
  const answerHtml = showAnswer ? renderMarkdown(answerMd || answerText) : '';
196
 
197
  return (
198
+ <div className={`ov-card${windowed ? '' : ' ov-compact'}`}>
199
  <div className="ov-id" onClick={() => onOpen(s.id)} title="Open pane">
200
  <span className={`status ${s.state}`} />
201
  <Logo cli={s.cli} size={12} tint={color} />
 
207
  </div>
208
 
209
  {/* the card body is the ONLY scroll region (window mode); input stays put */}
210
+ <div className="ov-body" ref={bodyRef}>
211
+ {latestX ? (
212
+ <>
213
+ {/* History grows upward, one turn per click — never a transcript dump. */}
214
+ {windowed && (remainingX > 0 || back > 0) && (
215
+ <div className="cx-earlier mono">
216
+ {remainingX > 0 && !atCap && (
217
+ <button className="cx-earlier-btn" onClick={() => setBack((b) => b + 1)}>
218
+ ↑ show previous turn
219
+ </button>
220
+ )}
221
+ {(atCap || back > 0) && (
222
+ <button className="cx-earlier-btn" onClick={() => openRendered(s.id, onOpen)}>
223
+ full history ↗
224
+ </button>
225
+ )}
226
+ {atCap && <span className="cx-earlier-note">card holds {cap} turns</span>}
227
+ </div>
228
+ )}
229
+ {earlierX.map((x) => <ExchangeView key={x.key} x={x} dim />)}
230
+ <div ref={latestRef}>
231
+ <ExchangeView
232
+ x={latestX}
233
+ open={openWork}
234
+ onToggle={() => setOpenWork((o) => !o)}
235
+ running={running && !justSent}
236
+ />
237
+ </div>
238
+ {/* Optimistic echo: the digest round-trip can take seconds, and a
239
+ frozen card reads as "did that get lost?". */}
240
+ {justSent && sent && (
241
+ <>
242
+ <div className="cx-prompt">{sent.text}</div>
243
+ <div className="cx-running mono">working</div>
244
+ </>
245
+ )}
246
+ </>
247
  ) : (
248
+ /* No transcript to read yet (never started, or a harness with no
249
+ trace): the digest still knows the last prompt and answer. */
250
+ <>
251
+ {promptText ? (
252
+ <div className="ov-prompt">{sent ? sent.text : (d?.lastPromptRaw || promptText)}</div>
253
+ ) : pending ? (
254
+ <div className="ov-prompt-skel"><span className="skel" style={{ width: '70%' }} /></div>
255
+ ) : (
256
+ <div className="ov-prompt ov-prompt-none">no prompt yet</div>
 
 
 
 
 
 
 
 
 
257
  )}
258
+ {(metaBits.length > 0 || hist.length > 0) && (
259
+ <div className="ov-meta mono">
260
+ <span className="ov-meta-bits">{metaBits.join(' · ')}</span>
261
+ <span className="spacer" />
262
+ {hist.length > 0 && (
263
+ <span className="ov-nav">
264
+ {idx > 0 && <span className="ov-nav-pos">turn {totalTurns - idx}/{totalTurns}</span>}
265
+ <button
266
+ className="ov-nav-btn" title="Earlier turn" disabled={idx >= hist.length}
267
+ onClick={() => setHistIdx(Math.min(idx + 1, hist.length))}
268
+ >↑</button>
269
+ <button
270
+ className="ov-nav-btn" title="Later turn" disabled={idx === 0}
271
+ onClick={() => setHistIdx(Math.max(idx - 1, 0))}
272
+ >↓</button>
273
+ </span>
274
+ )}
275
+ </div>
276
+ )}
277
+ {answerHtml && (
278
+ <div className="ov-answer-wrap">
279
+ <div className="markdown ov-md" dangerouslySetInnerHTML={{ __html: answerHtml }} />
280
+ </div>
281
+ )}
282
+ {showLiveProgress && <div className="ov-busy mono">running</div>}
283
+ </>
284
  )}
 
285
  </div>
286
 
287
  <div className="ov-live">
 
307
  if (e.key === 'Escape') { setDraft(''); inputRef.current?.blur(); }
308
  }}
309
  />
310
+ {draft.trim() && <button className="ov-send" title="Send" onClick={send} disabled={sending}><SendGlyph /></button>}
 
311
  </div>
312
  {failed && <div className="ov-note">failed to reach the agent</div>}
313
  </div>
web/src/components/TerminalPane.tsx CHANGED
@@ -7,6 +7,9 @@ import '@xterm/xterm/css/xterm.css';
7
  import type { Cli, Session } from '../types';
8
  import { STATE_LABEL } from '../types';
9
  import Logo from './Logo';
 
 
 
10
  import { CloseGlyph, RefreshGlyph } from './icons';
11
 
12
  const THEMES: Record<'light' | 'dark', ITheme> = {
@@ -177,7 +180,7 @@ if (typeof window !== 'undefined') {
177
  }
178
 
179
  export default function TerminalPane({
180
- session, cli, theme, focused, visible, active, zoom = 100, dragId, isMobile, onDragActive, onFocus, onRename, onClose,
181
  }: {
182
  session: Session;
183
  cli?: Cli;
@@ -186,6 +189,7 @@ export default function TerminalPane({
186
  visible?: boolean;
187
  active?: boolean;
188
  zoom?: number;
 
189
  dragId?: string; // set when the pane can be rearranged (group view)
190
  isMobile?: boolean; // show the on-screen control-key bar
191
  onDragActive?: (dragging: boolean) => void;
@@ -194,6 +198,11 @@ export default function TerminalPane({
194
  onClose: () => void;
195
  }) {
196
  const hostRef = useRef<HTMLDivElement>(null);
 
 
 
 
 
197
  const frameRef = useRef<HTMLDivElement>(null);
198
  const termRef = useRef<Terminal | null>(null);
199
  const resyncRef = useRef<() => void>(() => {});
@@ -203,6 +212,12 @@ export default function TerminalPane({
203
  const controllerRef = useRef(false);
204
  const previousZoomRef = useRef(zoom);
205
  const [preview] = useState<TerminalPreview | null>(() => loadTerminalPreview(session.id));
 
 
 
 
 
 
206
  // Send a raw byte string to the PTY (for the mobile key-bar: arrows, Esc…).
207
  const sendKeyRef = useRef<(d: string) => void>(() => {});
208
  const [conn, setConn] = useState<ConnState>('connecting');
@@ -228,7 +243,7 @@ export default function TerminalPane({
228
  if (!text) return;
229
  setPasteOpen(false);
230
  termRef.current?.paste(text);
231
- termRef.current?.focus();
232
  };
233
 
234
  // Phones have no Ctrl+V, so the key-bar needs an explicit paste. Two paths,
@@ -779,11 +794,19 @@ export default function TerminalPane({
779
  const t = setTimeout(() => {
780
  claimRef.current();
781
  resyncRef.current();
782
- termRef.current?.focus();
783
  }, 0);
784
  return () => clearTimeout(t);
785
  }, [active]);
786
 
 
 
 
 
 
 
 
 
787
  // Focused panes tint toward THEIR agent's brand color, not the app accent.
788
  const tint = cli?.color;
789
  const pathLabel = workspaceLabel(session.path);
@@ -802,7 +825,7 @@ export default function TerminalPane({
802
  draggable={!!dragId}
803
  onDragStart={dragId ? (e) => { e.dataTransfer.setData('text/plain', dragId); e.dataTransfer.effectAllowed = 'move'; onDragActive?.(true); } : undefined}
804
  onDragEnd={dragId ? () => onDragActive?.(false) : undefined}
805
- onMouseDown={(e) => { if (!dragId) e.preventDefault(); onFocus?.(); termRef.current?.focus(); }}
806
  >
807
  <div className="ph-left">
808
  <Logo cli={session.cli} size={16} tint={tint} />
@@ -821,11 +844,21 @@ export default function TerminalPane({
821
  )}
822
  <div className="ph-right">
823
  <span className="ph-path" title={pathLabel}>{pathLabel}</span>
 
 
824
  <button className="mini-btn ph-close" title="Close" onClick={(e) => { e.stopPropagation(); onClose(); }}><CloseGlyph /></button>
825
  </div>
826
  </div>
827
  <div className="term-host" ref={frameRef}>
828
  <div className="term-fill" ref={hostRef} />
 
 
 
 
 
 
 
 
829
  </div>
830
  {isMobile && conn === 'connected' && (
831
  // Control keys the phone keyboard lacks — needed for TUI menus (model
@@ -840,7 +873,7 @@ export default function TerminalPane({
840
  <button
841
  key={label}
842
  className="tk-btn"
843
- onPointerDown={(e) => { e.preventDefault(); e.stopPropagation(); sendKeyRef.current(seq); termRef.current?.focus(); }}
844
  >{label}</button>
845
  ))}
846
  {/* Unlike the key buttons this must NOT preventDefault: the clipboard
@@ -875,14 +908,14 @@ export default function TerminalPane({
875
  // Typed text (or a paste some browsers deliver as plain input) still
876
  // needs a way out; Enter sends, Shift+Enter keeps the newline.
877
  onKeyDown={(e) => {
878
- if (e.key === 'Escape') { setPasteOpen(false); termRef.current?.focus(); }
879
  if (e.key === 'Enter' && !e.shiftKey) {
880
  e.preventDefault();
881
  commitPaste((e.target as HTMLTextAreaElement).value);
882
  }
883
  }}
884
  />
885
- <button className="tp-x" onClick={() => { setPasteOpen(false); termRef.current?.focus(); }}>cancel</button>
886
  </div>
887
  )}
888
  {booting && preview && conn !== 'exited' && (
 
7
  import type { Cli, Session } from '../types';
8
  import { STATE_LABEL } from '../types';
9
  import Logo from './Logo';
10
+ import ConversationView from './conversation/ConversationView';
11
+ import { isPassive } from '../types';
12
+ import type { PaneMode } from '../lib/paneMode';
13
  import { CloseGlyph, RefreshGlyph } from './icons';
14
 
15
  const THEMES: Record<'light' | 'dark', ITheme> = {
 
180
  }
181
 
182
  export default function TerminalPane({
183
+ session, cli, theme, focused, visible, active, zoom = 100, mode = 'terminal', dragId, isMobile, onDragActive, onFocus, onRename, onClose,
184
  }: {
185
  session: Session;
186
  cli?: Cli;
 
189
  visible?: boolean;
190
  active?: boolean;
191
  zoom?: number;
192
+ mode?: PaneMode; // app-wide reading mode, from the bottom bar
193
  dragId?: string; // set when the pane can be rearranged (group view)
194
  isMobile?: boolean; // show the on-screen control-key bar
195
  onDragActive?: (dragging: boolean) => void;
 
198
  onClose: () => void;
199
  }) {
200
  const hostRef = useRef<HTMLDivElement>(null);
201
+ // Focus, unless the conversation is covering the terminal. Several paths grab
202
+ // it — becoming active, the header, the key bar — and some fire after the mode
203
+ // changes, so the guard lives with the call rather than with the switch.
204
+ const modeRef = useRef<PaneMode>('terminal');
205
+ const focusTerm = () => { if (modeRef.current !== 'reader') termRef.current?.focus(); };
206
  const frameRef = useRef<HTMLDivElement>(null);
207
  const termRef = useRef<Terminal | null>(null);
208
  const resyncRef = useRef<() => void>(() => {});
 
212
  const controllerRef = useRef(false);
213
  const previousZoomRef = useRef(zoom);
214
  const [preview] = useState<TerminalPreview | null>(() => loadTerminalPreview(session.id));
215
+ // The mode is app-wide (the bottom bar owns it, like zoom), but only an agent
216
+ // has a conversation to read: a shell is a shell, and files/trace panels are
217
+ // not this component's business at all.
218
+ const canRender = session.cli !== 'shell' && !isPassive(session.cli);
219
+ const reading = mode === 'reader' && canRender;
220
+ modeRef.current = reading ? 'reader' : 'terminal';
221
  // Send a raw byte string to the PTY (for the mobile key-bar: arrows, Esc…).
222
  const sendKeyRef = useRef<(d: string) => void>(() => {});
223
  const [conn, setConn] = useState<ConnState>('connecting');
 
243
  if (!text) return;
244
  setPasteOpen(false);
245
  termRef.current?.paste(text);
246
+ focusTerm();
247
  };
248
 
249
  // Phones have no Ctrl+V, so the key-bar needs an explicit paste. Two paths,
 
794
  const t = setTimeout(() => {
795
  claimRef.current();
796
  resyncRef.current();
797
+ focusTerm();
798
  }, 0);
799
  return () => clearTimeout(t);
800
  }, [active]);
801
 
802
+ // In reader mode the terminal is covered but still mounted — and a mounted xterm
803
+ // with focus swallows every keystroke into the agent's TTY, invisibly. Hand
804
+ // focus back when the terminal is on top again.
805
+ useEffect(() => {
806
+ if (reading) termRef.current?.blur();
807
+ else if (focused) termRef.current?.focus();
808
+ }, [reading, focused]);
809
+
810
  // Focused panes tint toward THEIR agent's brand color, not the app accent.
811
  const tint = cli?.color;
812
  const pathLabel = workspaceLabel(session.path);
 
825
  draggable={!!dragId}
826
  onDragStart={dragId ? (e) => { e.dataTransfer.setData('text/plain', dragId); e.dataTransfer.effectAllowed = 'move'; onDragActive?.(true); } : undefined}
827
  onDragEnd={dragId ? () => onDragActive?.(false) : undefined}
828
+ onMouseDown={(e) => { if (!dragId) e.preventDefault(); onFocus?.(); focusTerm(); }}
829
  >
830
  <div className="ph-left">
831
  <Logo cli={session.cli} size={16} tint={tint} />
 
844
  )}
845
  <div className="ph-right">
846
  <span className="ph-path" title={pathLabel}>{pathLabel}</span>
847
+ {/* The trace stops being a separate thing you open: it is this
848
+ session, read instead of watched. */}
849
  <button className="mini-btn ph-close" title="Close" onClick={(e) => { e.stopPropagation(); onClose(); }}><CloseGlyph /></button>
850
  </div>
851
  </div>
852
  <div className="term-host" ref={frameRef}>
853
  <div className="term-fill" ref={hostRef} />
854
+ {/* Reader mode draws OVER the terminal rather than replacing it: xterm needs
855
+ layout to fit, and detaching tmux costs a repaint and can trip the
856
+ handoff path. The terminal stays mounted and connected underneath. */}
857
+ {reading && (
858
+ <div className="pane-reader" onMouseDown={(e) => e.stopPropagation()}>
859
+ <ConversationView session={session} paused={visible === false} isMobile={isMobile} />
860
+ </div>
861
+ )}
862
  </div>
863
  {isMobile && conn === 'connected' && (
864
  // Control keys the phone keyboard lacks — needed for TUI menus (model
 
873
  <button
874
  key={label}
875
  className="tk-btn"
876
+ onPointerDown={(e) => { e.preventDefault(); e.stopPropagation(); sendKeyRef.current(seq); focusTerm(); }}
877
  >{label}</button>
878
  ))}
879
  {/* Unlike the key buttons this must NOT preventDefault: the clipboard
 
908
  // Typed text (or a paste some browsers deliver as plain input) still
909
  // needs a way out; Enter sends, Shift+Enter keeps the newline.
910
  onKeyDown={(e) => {
911
+ if (e.key === 'Escape') { setPasteOpen(false); focusTerm(); }
912
  if (e.key === 'Enter' && !e.shiftKey) {
913
  e.preventDefault();
914
  commitPaste((e.target as HTMLTextAreaElement).value);
915
  }
916
  }}
917
  />
918
+ <button className="tp-x" onClick={() => { setPasteOpen(false); focusTerm(); }}>cancel</button>
919
  </div>
920
  )}
921
  {booting && preview && conn !== 'exited' && (
web/src/components/conversation/ConversationView.tsx ADDED
@@ -0,0 +1,278 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Conversation mode: a session's trace, as a stack of exchanges.
2
+ // (docs/conversation-view.md §3.3)
3
+ //
4
+ // The pane header above this belongs to the session; everything here is the
5
+ // reader's own: a line of session facts, search, turn navigation, and the same
6
+ // ExchangeView the Overview card shows one of.
7
+ //
8
+ // Draft scope: this reads the tail of the trace in one request and renders every
9
+ // turn in it. Windowing by exchange (§10.7) is the next step — a collapsed turn
10
+ // is 2–3 rows, so the DOM stays small, but the measured-height machinery in
11
+ // TraceView is what makes it survive a 5,000-turn session.
12
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
13
+ import * as api from '../../api';
14
+ import type { TracePage, TraceTurn } from '../../api';
15
+ import type { Session } from '../../types';
16
+ import { fmtTok, splitExchanges } from './exchanges';
17
+ import ExchangeView from './Exchange';
18
+ import { SendGlyph } from '../icons';
19
+
20
+ /** How much of the tail RENDER mode reads. The server caps a page at 500. */
21
+ export const RENDER_TAIL = 400;
22
+ const POLL_MS = 3_000;
23
+
24
+ const fmtNum = (n: number) => n.toLocaleString();
25
+ const fmtUsage = (u?: { in: number; out: number } | null) =>
26
+ (u ? `${fmtTok(u.in)}↓ ${fmtTok(u.out)}↑` : '');
27
+
28
+ export default function ConversationView({ session, paused, isMobile, readOnly, onHandover }: {
29
+ session: Session;
30
+ /** The pane is off-screen: stop asking the server for a trace nobody sees. */
31
+ paused?: boolean;
32
+ isMobile?: boolean;
33
+ /** A trace with no agent behind it — a shared file, an import. Read-only. */
34
+ readOnly?: boolean;
35
+ onHandover?: () => void;
36
+ }) {
37
+ const [page, setPage] = useState<TracePage | null>(null);
38
+ const [error, setError] = useState<string>('');
39
+ const [query, setQuery] = useState('');
40
+ const [hits, setHits] = useState(0);
41
+ const [hit, setHit] = useState(0);
42
+ const scroller = useRef<HTMLDivElement | null>(null);
43
+ const rows = useRef(new Map<number, HTMLElement>());
44
+ // Follow the work while it arrives — but only while the reader is already at
45
+ // the bottom. Scrolling up to read something is a decision, and yanking the
46
+ // view back down on the next tool call would undo it.
47
+ const stick = useRef(true);
48
+ // Reading a conversation and answering it are the same act — the card has
49
+ // always known that. Only a trace with no agent behind it is read-only.
50
+ const [draft, setDraft] = useState('');
51
+ const [sending, setSending] = useState(false);
52
+ const [failed, setFailed] = useState(false);
53
+ const [sent, setSent] = useState<{ text: string; at: number } | null>(null);
54
+ const inputRef = useRef<HTMLTextAreaElement>(null);
55
+
56
+ const live = session.state === 'working' && !paused;
57
+
58
+ const load = useCallback(async () => {
59
+ try {
60
+ // A negative offset reads from the end (server/src/traces.js pageOf).
61
+ const p = await api.getTracePage(session.id, -RENDER_TAIL, RENDER_TAIL);
62
+ setPage(p);
63
+ setError('');
64
+ } catch (e) {
65
+ // A failed REFRESH must not throw away the conversation on screen: this
66
+ // mount answers EIO now and then, and blanking mid-read is worse than
67
+ // going stale for three seconds.
68
+ setError(e instanceof Error ? e.message : 'could not read this trace');
69
+ }
70
+ }, [session.id]);
71
+
72
+ useEffect(() => { setPage(null); setError(''); load(); }, [load]);
73
+ // While the agent works, the trace is still being written.
74
+ useEffect(() => {
75
+ if (!live) return undefined;
76
+ const h = window.setInterval(load, POLL_MS);
77
+ return () => window.clearInterval(h);
78
+ }, [live, load]);
79
+ // Coming back into view, catch up at once rather than waiting for a tick.
80
+ useEffect(() => { if (!paused && page) load(); }, [paused]); // eslint-disable-line react-hooks/exhaustive-deps
81
+
82
+ const turns: TraceTurn[] = useMemo(() => page?.turns || [], [page]);
83
+ const exchanges = useMemo(() => splitExchanges(turns), [turns]);
84
+ const last = exchanges[exchanges.length - 1];
85
+
86
+ // The optimistic echo stands until the transcript catches up: a CLI writes it,
87
+ // the reader picks it up, the poll lands — seconds, during which a card that
88
+ // showed nothing would read as "did that get lost?".
89
+ const promptOf = (x?: typeof last) =>
90
+ (x?.prompt?.blocks || []).filter((b) => b.type === 'text').map((b) => ('text' in b ? b.text : '')).join('').trim();
91
+ if (sent && promptOf(last) === sent.text) setSent(null);
92
+
93
+ const send = async () => {
94
+ const text = draft.trim();
95
+ if (!text || sending) return;
96
+ setSending(true); setFailed(false);
97
+ try {
98
+ await api.sendInput(session.id, text);
99
+ setDraft(''); setSent({ text, at: Date.now() });
100
+ if (inputRef.current) { inputRef.current.style.height = 'auto'; inputRef.current.blur(); }
101
+ stick.current = true;
102
+ load();
103
+ } catch { setFailed(true); window.setTimeout(() => setFailed(false), 4000); }
104
+ setSending(false);
105
+ };
106
+ const q = query.trim().toLowerCase();
107
+ const shown = useMemo(() => {
108
+ if (!q) return exchanges;
109
+ return exchanges.filter((x) =>
110
+ [x.prompt, ...x.answer, ...x.steps].some((t) =>
111
+ t && t.blocks.some((b) => JSON.stringify(b).toLowerCase().includes(q))));
112
+ }, [exchanges, q]);
113
+
114
+ // Highlighting happens inside ExchangeView; finding the marks is this
115
+ // component's job, so it counts them and walks them in document order.
116
+ const marks = () => [...(scroller.current?.querySelectorAll('mark.cx-hit') || [])] as HTMLElement[];
117
+ const goMark = (found: HTMLElement[], i: number) => {
118
+ found.forEach((m) => m.classList.remove('on'));
119
+ const el = found[i];
120
+ if (!el) return;
121
+ el.classList.add('on');
122
+ el.scrollIntoView({ block: 'center', behavior: 'smooth' });
123
+ };
124
+ // Recount after every render that can change the marks — but a poll landing
125
+ // must not throw you back to the first hit while you are walking them, so only
126
+ // a NEW query jumps.
127
+ const lastQuery = useRef(q);
128
+ useEffect(() => {
129
+ const found = marks();
130
+ const fresh = lastQuery.current !== q;
131
+ lastQuery.current = q;
132
+ setHits(found.length);
133
+ if (!found.length) { setHit(0); return; }
134
+ const i = fresh ? 0 : Math.min(hit, found.length - 1);
135
+ setHit(i);
136
+ if (fresh) goMark(found, i);
137
+ else found[i]?.classList.add('on');
138
+ // eslint-disable-next-line react-hooks/exhaustive-deps
139
+ }, [q, shown]);
140
+
141
+ const stepHit = (dir: -1 | 1) => {
142
+ const found = marks();
143
+ if (!found.length) return;
144
+ const i = (hit + dir + found.length) % found.length;
145
+ setHit(i);
146
+ goMark(found, i);
147
+ };
148
+
149
+ /**
150
+ * Put the next turn's top at the top of the reading area. offsetTop is wrong
151
+ * here — it is measured against the nearest positioned ancestor, not the
152
+ * scroller — so this measures both rects and moves by the difference.
153
+ */
154
+ const goTurn = (dir: -1 | 1) => {
155
+ const el = scroller.current;
156
+ if (!el) return;
157
+ const base = el.getBoundingClientRect().top - el.scrollTop;
158
+ const tops = [...rows.current.entries()]
159
+ .sort((a, b) => a[0] - b[0])
160
+ .map(([, node]) => node.getBoundingClientRect().top - base);
161
+ const cur = el.scrollTop;
162
+ const next = dir < 0
163
+ ? tops.filter((t) => t < cur - 8).pop()
164
+ : tops.find((t) => t > cur + 8);
165
+ if (next != null) el.scrollTo({ top: Math.max(0, next - 4), behavior: 'smooth' });
166
+ };
167
+ const nav = (dir: -1 | 1) => (q && hits ? stepHit(dir) : goTurn(dir));
168
+
169
+ useLayoutEffect(() => {
170
+ const el = scroller.current;
171
+ if (el && live && stick.current) el.scrollTop = el.scrollHeight;
172
+ }, [turns, live]);
173
+
174
+ if (!page) return <div className="cxv-empty mono">{error || 'reading the trace…'}</div>;
175
+
176
+ return (
177
+ <div className="cxv">
178
+ {/* The reader's own controls, on their own row: on a phone the pane
179
+ header above has no spare width. */}
180
+ <div className="cxv-bar mono">
181
+ {page.model && <span className="cxv-chip">{page.model}</span>}
182
+ <span className="cxv-count" title={`${fmtNum(page.total)} messages`}>
183
+ {fmtNum(exchanges.length)} turn{exchanges.length === 1 ? '' : 's'}
184
+ </span>
185
+ {page.usage && (
186
+ <span className="cxv-tok" title={page.usage.cacheRead ? `${fmtNum(page.usage.cacheRead)} cached` : undefined}>
187
+ {fmtUsage(page.usage)}
188
+ </span>
189
+ )}
190
+ <span className="spacer" />
191
+ <span className="cxv-nav">
192
+ <button className="cxv-mini" onClick={() => nav(-1)} title={q && hits ? 'Previous match' : 'Previous turn'}>▲</button>
193
+ <button className="cxv-mini" onClick={() => nav(1)} title={q && hits ? 'Next match' : 'Next turn'}>▼</button>
194
+ </span>
195
+ <input className="cxv-search" placeholder="Search…" value={query}
196
+ onChange={(e) => setQuery(e.target.value)} />
197
+ {q && <span className="cxv-hits">{hits ? `${hit + 1}/${hits}` : '0'}</span>}
198
+ </div>
199
+
200
+ <div className="cxv-body" ref={scroller}
201
+ onScroll={(e) => {
202
+ const el = e.currentTarget;
203
+ stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < 48;
204
+ }}>
205
+ <div className="cxv-col">
206
+ {error && <div className="cxv-msg bad mono">{error} · showing the last read</div>}
207
+ {(page.truncated || page.offset > 0) && (
208
+ <div className="cxv-msg mono">
209
+ {page.offset > 0 ? `${fmtNum(page.offset)} earlier messages are not shown` : 'earlier turns are not shown'}
210
+ </div>
211
+ )}
212
+ {page.note && <div className="cxv-msg mono">{page.note}</div>}
213
+ {q && (
214
+ <div className="cxv-msg mono">
215
+ {shown.length} of {exchanges.length} turns match “{query}”
216
+ {hits ? ` · ${hits} highlighted, ▲▼ walks them` : ''}
217
+ </div>
218
+ )}
219
+ {shown.map((x, i) => (
220
+ <div key={x.key} ref={(el) => { if (el) rows.current.set(i, el); else rows.current.delete(i); }}>
221
+ <ExchangeView
222
+ x={x}
223
+ n={exchanges.indexOf(x) + 1}
224
+ total={exchanges.length}
225
+ q={q || undefined}
226
+ baseModel={page.model || undefined}
227
+ running={live && x === exchanges[exchanges.length - 1]}
228
+ />
229
+ </div>
230
+ ))}
231
+ {sent && (
232
+ <>
233
+ <div className="cx-prompt">{sent.text}</div>
234
+ <div className="cx-running mono">working</div>
235
+ </>
236
+ )}
237
+ {!exchanges.length && !sent && <div className="cxv-msg mono">nothing in this trace yet</div>}
238
+ </div>
239
+ </div>
240
+
241
+ {!readOnly && (
242
+ <div className="ov-live cxv-live">
243
+ <span className="ov-p mono">❯</span>
244
+ <textarea
245
+ ref={inputRef}
246
+ rows={1}
247
+ value={draft}
248
+ disabled={sending}
249
+ placeholder={sending ? 'sending…' : 'reply…'}
250
+ autoComplete="off" autoCorrect="off" autoCapitalize="off" spellCheck={false}
251
+ onChange={(e) => { setDraft(e.target.value); e.currentTarget.style.height = 'auto'; e.currentTarget.style.height = `${e.currentTarget.scrollHeight}px`; }}
252
+ onKeyDown={(e) => {
253
+ // Desktop: Enter sends, Shift+Enter newlines. Mobile keyboards
254
+ // cannot do Shift+Enter, so there the button sends.
255
+ if (e.key === 'Enter' && !e.shiftKey && !isMobile) { e.preventDefault(); send(); }
256
+ if (e.key === 'Escape') { setDraft(''); inputRef.current?.blur(); }
257
+ }}
258
+ />
259
+ {draft.trim() && <button className="ov-send" title="Send" onClick={send} disabled={sending}><SendGlyph /></button>}
260
+ </div>
261
+ )}
262
+ {failed && <div className="ov-note cxv-note">failed to reach the agent</div>}
263
+
264
+ <div className="cxv-foot mono">
265
+ <span className="cxv-path" title={page.cwd || undefined}>
266
+ {page.cwd}
267
+ {page.firstTs ? ` · ${new Date(page.firstTs).toLocaleDateString()}` : ''}
268
+ </span>
269
+ <span className="spacer" />
270
+ {onHandover && (
271
+ <button className="cxv-mini" onClick={onHandover} title="Start a new agent from this conversation">
272
+ continue in a new agent ↗
273
+ </button>
274
+ )}
275
+ </div>
276
+ </div>
277
+ );
278
+ }
web/src/components/conversation/Exchange.tsx ADDED
@@ -0,0 +1,236 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // One exchange, at whatever depth the surface needs. (docs/conversation-view.md §2)
2
+ //
3
+ // The Overview card is one of these plus a reply box; RENDER mode is a stack of
4
+ // them. Nothing here knows which — that is the point, and it is why the two
5
+ // surfaces cannot drift apart again.
6
+ //
7
+ // Left edge: the prompt's ❯ is the ONLY thing outside the text column. No rail
8
+ // on the answer, no rail on the work — scrolling, you find turns by that arrow
9
+ // and the tinted prompt bar, and everything else lines up in one column.
10
+ import { useMemo, useState } from 'react';
11
+ import type { ReactNode } from 'react';
12
+ import type { TraceTurn } from '../../api';
13
+ import { renderMarkdown } from '../../lib/markdown';
14
+ import type { Exchange, Step } from './exchanges';
15
+ import { fmtClock, fmtDur, fmtTok, oneLine, stepSummary, stepText, stepsOf } from './exchanges';
16
+ import ToolCall from './ToolCall';
17
+
18
+ const blocksOf = (t: TraceTurn | TraceTurn[] | null) =>
19
+ (Array.isArray(t) ? t : [t]).flatMap((x) => x?.blocks || []);
20
+ const textOf = (t: TraceTurn | TraceTurn[] | null) =>
21
+ blocksOf(t).filter((b) => b.type === 'text').map((b) => ('text' in b ? b.text : '')).join('\n\n').trim();
22
+ const moreOf = (t: TraceTurn | TraceTurn[] | null) =>
23
+ blocksOf(t).reduce((n, b) => n + (('more' in b && b.more) || 0), 0);
24
+
25
+ const moreLabel = (more?: number) =>
26
+ (more ? `+${more > 1024 ? `${Math.round(more / 1024)} KB` : `${more} chars`} not retained` : '');
27
+
28
+ /** Search hits in plain text. The answer's HTML gets the same treatment below. */
29
+ function Hi({ text, q }: { text: string; q?: string }) {
30
+ if (!q) return <>{text}</>;
31
+ const out: ReactNode[] = [];
32
+ const hay = text.toLowerCase();
33
+ let i = 0;
34
+ for (let j = hay.indexOf(q); j >= 0; j = hay.indexOf(q, i)) {
35
+ if (j > i) out.push(text.slice(i, j));
36
+ out.push(<mark className="cx-hit" key={j}>{text.slice(j, j + q.length)}</mark>);
37
+ i = j + q.length;
38
+ }
39
+ if (!out.length) return <>{text}</>;
40
+ out.push(text.slice(i));
41
+ return <>{out}</>;
42
+ }
43
+
44
+ /** Same, inside rendered markdown: text nodes only, so the tags survive untouched. */
45
+ function highlightHtml(html: string, q?: string): string {
46
+ if (!q) return html;
47
+ const doc = new DOMParser().parseFromString(html, 'text/html');
48
+ const walk = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT);
49
+ const nodes: Text[] = [];
50
+ while (walk.nextNode()) nodes.push(walk.currentNode as Text);
51
+ for (const n of nodes) {
52
+ if (!n.data.toLowerCase().includes(q)) continue;
53
+ const frag = doc.createDocumentFragment();
54
+ let rest = n.data;
55
+ for (let j = rest.toLowerCase().indexOf(q); j >= 0; j = rest.toLowerCase().indexOf(q)) {
56
+ if (j) frag.appendChild(doc.createTextNode(rest.slice(0, j)));
57
+ const m = doc.createElement('mark');
58
+ m.className = 'cx-hit';
59
+ m.textContent = rest.slice(j, j + q.length);
60
+ frag.appendChild(m);
61
+ rest = rest.slice(j + q.length);
62
+ }
63
+ if (rest) frag.appendChild(doc.createTextNode(rest));
64
+ n.parentNode?.replaceChild(frag, n);
65
+ }
66
+ return doc.body.innerHTML;
67
+ }
68
+
69
+ /**
70
+ * One line of work. The left column carries the row's status and nothing else:
71
+ * a tool's ✓/✗, or a disclosure triangle — greyed when there is nothing more.
72
+ *
73
+ * Text steps (thinking, an aside, a compaction) expand *in place*: the preview
74
+ * keeps its font and simply stops being truncated, so nothing is said twice.
75
+ * Only a tool has something genuinely different below — its input and result.
76
+ */
77
+ function StepRow({ s, q }: { s: Step; q?: string }) {
78
+ const [selfOpen, setSelfOpen] = useState(false);
79
+ // A search you cannot follow is a tease: a step holding the term opens itself,
80
+ // body and all, so the hit is on screen and the ▲▼ nav can reach it.
81
+ const hit = !!q && stepText(s).toLowerCase().includes(q);
82
+ const open = selfOpen || hit;
83
+ let label = '';
84
+ let preview = '';
85
+ let full = ''; // set for the kinds that expand in place
86
+ if (s.kind === 'tools') {
87
+ label = s.count > 1 ? `${s.name} ×${s.count}` : s.name;
88
+ preview = s.details.join(', ');
89
+ } else if (s.kind === 'think') {
90
+ label = 'thinking';
91
+ preview = oneLine(s.text, 120);
92
+ full = s.text;
93
+ } else if (s.kind === 'note') {
94
+ preview = oneLine(s.text, 140);
95
+ full = s.text;
96
+ } else if (s.kind === 'shell') {
97
+ label = 'shell';
98
+ preview = oneLine(s.command, 100);
99
+ } else if (s.kind === 'compact') {
100
+ label = 'context compacted';
101
+ preview = oneLine(s.text, 120);
102
+ full = s.text;
103
+ } else {
104
+ label = 'image';
105
+ }
106
+
107
+ const more = 'more' in s ? s.more || 0 : 0;
108
+ const can = s.kind === 'tools' ? s.blocks.length > 0
109
+ : s.kind === 'shell' ? !!s.out.trim()
110
+ : s.kind === 'image' ? true
111
+ : full.trim().length > preview.length || more > 0;
112
+ const shown = open && full ? full : preview;
113
+
114
+ return (
115
+ <div className={`cs${open ? ' open' : ''} ${s.kind}`}>
116
+ <button className="cs-head" disabled={!can} onClick={() => setSelfOpen((o) => !o)}>
117
+ {s.kind === 'tools'
118
+ ? <span className={`cs-mark ${s.failed ? 'bad' : 'ok'}`}>{s.failed ? '✗' : '✓'}</span>
119
+ : <span className={`cs-tri${can ? '' : ' off'}`}>{open ? '▾' : '▸'}</span>}
120
+ {label && <span className="cs-label mono">{label}</span>}
121
+ <span className={`cs-detail${open && full ? ' full' : ''}`}><Hi text={shown} q={q} /></span>
122
+ </button>
123
+ {open && (
124
+ <div className="cs-body">
125
+ {/* the text already expanded above; only its cut tail is left to say */}
126
+ {!!full && !!more && <div className="cs-more mono">…{moreLabel(more)}</div>}
127
+ {s.kind === 'tools' && s.blocks.map((b, i) => (
128
+ b.type === 'tool_use' ? (
129
+ <div key={i} className="cs-call">
130
+ <ToolCall name={b.name} text={b.text} />
131
+ {!!b.more && <div className="cs-more mono">…{moreLabel(b.more)}</div>}
132
+ </div>
133
+ ) : b.type === 'tool_result' ? (
134
+ <pre key={i} className={`cs-pre out${b.failed ? ' bad' : ''}`}><Hi text={b.text.trim()} q={q} />{b.more ? `\n…${moreLabel(b.more)}` : ''}</pre>
135
+ ) : null
136
+ ))}
137
+ {s.kind === 'shell' && <pre className="cs-pre out"><Hi text={s.out} q={q} /></pre>}
138
+ {s.kind === 'image' && <img className="cs-img" src={s.src} alt="" />}
139
+ </div>
140
+ )}
141
+ </div>
142
+ );
143
+ }
144
+
145
+ /** The last step, as one line of status: "Bash cd /home/…", "Now the scan itself:". */
146
+ function nowDoing(s?: Step): string {
147
+ if (!s) return '';
148
+ if (s.kind === 'tools') return oneLine(`${s.name} ${s.details[s.details.length - 1] || ''}`, 70);
149
+ if (s.kind === 'shell') return oneLine(s.command, 70);
150
+ if (s.kind === 'image') return 'image';
151
+ if (s.kind === 'think') return oneLine(s.text, 70);
152
+ if (s.kind === 'compact') return 'context compacted';
153
+ return oneLine(s.text, 70);
154
+ }
155
+
156
+ export function ExchangeView({
157
+ x, n, total, open, onToggle, running, dim, q, baseModel,
158
+ }: {
159
+ x: Exchange;
160
+ n?: number; // 1-based position — the viewer numbers turns, a card does not
161
+ total?: number;
162
+ open?: boolean; // controlled fold of the work
163
+ onToggle?: () => void;
164
+ running?: boolean; // no answer yet, and one is coming
165
+ dim?: boolean; // an earlier exchange, prepended above the current one
166
+ q?: string; // lowercased search term, highlighted where it lands
167
+ baseModel?: string; // the session's model; a turn only names its own if it differs
168
+ }) {
169
+ const [selfOpen, setSelfOpen] = useState(false);
170
+ const toggle = onToggle ?? (() => setSelfOpen((o) => !o));
171
+
172
+ const steps = useMemo(() => stepsOf(x.steps), [x.steps]);
173
+ // A turn whose match is buried in the work unfolds it, or the search would
174
+ // report a hit with nothing on screen to look at.
175
+ const hitInWork = useMemo(
176
+ () => (q ? steps.some((s) => stepText(s).toLowerCase().includes(q)) : false), [steps, q]);
177
+ const isOpen = open ?? (selfOpen || hitInWork);
178
+ const prompt = textOf(x.prompt);
179
+ const answer = textOf(x.answer);
180
+ const answerHtml = useMemo(
181
+ () => (answer ? highlightHtml(renderMarkdown(answer), q) : ''), [answer, q]);
182
+ const answerMore = moreOf(x.answer);
183
+ const summary = stepSummary(x, steps);
184
+ const latest = running ? nowDoing(steps[steps.length - 1]) : '';
185
+ // Naming the model on every turn is noise when it never changes; when it DOES
186
+ // change mid-session that is worth a word, so say it only then.
187
+ const model = x.model && x.model !== baseModel ? x.model : '';
188
+
189
+ return (
190
+ <section className={`cx${dim ? ' dim' : ''}`}>
191
+ {prompt ? <div className="cx-prompt"><Hi text={prompt} q={q} /></div> : null}
192
+
193
+ {/* Everything about the turn on ONE line, under the prompt: what the work
194
+ was on the left, which turn it is on the right. Nothing above. */}
195
+ {(summary || n != null) && (
196
+ <div className="cx-meta mono">
197
+ {steps.length > 0 ? (
198
+ <button className={`cx-fold${isOpen ? ' on' : ''}`} onClick={toggle} title={isOpen ? 'Hide the work' : 'Show the work'}>
199
+ <span className="cs-tri">{isOpen ? '▾' : '▸'}</span>
200
+ {summary}
201
+ </button>
202
+ ) : <span className="cx-fold flat">{summary}</span>}
203
+ {/* Which turn, when, and on what — the viewer's business. A card shows one
204
+ turn, dated in its own header, so it says none of this. */}
205
+ {n != null && (
206
+ <>
207
+ <span className="spacer" />
208
+ {model && <span className="cx-model">{model}</span>}
209
+ <span className="cx-n">turn {n}{total ? `/${total}` : ''}</span>
210
+ {x.startTs ? <span className="cx-time">{fmtClock(x.startTs)}</span> : null}
211
+ </>
212
+ )}
213
+ </div>
214
+ )}
215
+ {isOpen && steps.length > 0 && (
216
+ <div className="cx-steps">{steps.map((s, i) => <StepRow key={i} s={s} q={q} />)}</div>
217
+ )}
218
+
219
+ {answerHtml ? (
220
+ <div className="cx-answer">
221
+ <div className="markdown cx-md" dangerouslySetInnerHTML={{ __html: answerHtml }} />
222
+ {!!answerMore && <div className="cx-note mono">…{moreLabel(answerMore)}</div>}
223
+ </div>
224
+ ) : null}
225
+ {/* Mid-task there is no answer — an agent's aside is not a reply — so the
226
+ running line carries the latest thing that happened instead. */}
227
+ {running && (
228
+ <div className="cx-running mono">
229
+ working{latest && <span className="cx-running-at">· {latest}</span>}
230
+ </div>
231
+ )}
232
+ </section>
233
+ );
234
+ }
235
+
236
+ export default ExchangeView;
web/src/components/conversation/ToolCall.tsx ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // An expanded tool call, rendered as what it is rather than as its wire format.
2
+ // (docs/conversation-view.md §4.3)
3
+ //
4
+ // The JSON a harness sends is an argument object, not a thing to read: a Bash
5
+ // call is a command, an Edit is a before and an after, a Read is a file and a
6
+ // range. Anything unrecognised still renders — as fields, and as JSON only where
7
+ // the value really is structured.
8
+ import type { ReactNode } from 'react';
9
+
10
+ const isScalar = (v: unknown) =>
11
+ v === null || typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean';
12
+
13
+ const Block = ({ kind, children }: { kind?: string; children: ReactNode }) => (
14
+ <pre className={`cs-pre${kind ? ` ${kind}` : ''}`}>{children}</pre>
15
+ );
16
+
17
+ const Caption = ({ children }: { children: ReactNode }) => (
18
+ <div className="ct-cap mono">{children}</div>
19
+ );
20
+
21
+ /** "lines 170–210" reads; `{offset: 170, limit: 40}` does not. */
22
+ function range(o: Record<string, unknown>): string {
23
+ const off = typeof o.offset === 'number' ? o.offset : null;
24
+ const lim = typeof o.limit === 'number' ? o.limit : null;
25
+ if (off == null && lim == null) return '';
26
+ if (off != null && lim != null) return `lines ${off}–${off + lim}`;
27
+ return off != null ? `from line ${off}` : `first ${lim} lines`;
28
+ }
29
+
30
+ const SHOWN_ELSEWHERE = new Set([
31
+ 'command', 'description', 'file_path', 'path', 'notebook_path', 'content',
32
+ 'old_string', 'new_string', 'offset', 'limit', 'replace_all',
33
+ ]);
34
+
35
+ /** Whatever is left, as fields — scalars inline, structure as JSON. */
36
+ function Fields({ o, skip }: { o: Record<string, unknown>; skip?: Set<string> }) {
37
+ const rows = Object.entries(o).filter(([k, v]) =>
38
+ !(skip?.has(k)) && v !== undefined && v !== '' && !(Array.isArray(v) && !v.length));
39
+ if (!rows.length) return null;
40
+ return (
41
+ <dl className="ct-fields mono">
42
+ {rows.map(([k, v]) => (
43
+ <div key={k} className="ct-row">
44
+ <dt>{k}</dt>
45
+ <dd>
46
+ {isScalar(v)
47
+ ? (typeof v === 'string' && v.includes('\n')
48
+ ? <Block>{v}</Block>
49
+ : String(v))
50
+ : <Block>{JSON.stringify(v, null, 2)}</Block>}
51
+ </dd>
52
+ </div>
53
+ ))}
54
+ </dl>
55
+ );
56
+ }
57
+
58
+ export default function ToolCall({ name, text }: { name: string; text: string }) {
59
+ let parsed: unknown;
60
+ try { parsed = JSON.parse(text); } catch { return <Block>{text}</Block>; }
61
+ if (!parsed || typeof parsed !== 'object') return <Block>{String(parsed)}</Block>;
62
+ const o = parsed as Record<string, unknown>;
63
+
64
+ const file = [o.file_path, o.path, o.notebook_path].find((v) => typeof v === 'string') as string | undefined;
65
+ const note = typeof o.description === 'string' ? o.description : '';
66
+
67
+ // A shell call is a command.
68
+ if (typeof o.command === 'string') {
69
+ return (
70
+ <>
71
+ {note && <Caption>{note}</Caption>}
72
+ <Block kind="ct-cmd">{o.command}</Block>
73
+ <Fields o={o} skip={SHOWN_ELSEWHERE} />
74
+ </>
75
+ );
76
+ }
77
+
78
+ // An edit is a before and an after.
79
+ if (typeof o.old_string === 'string' && typeof o.new_string === 'string') {
80
+ return (
81
+ <>
82
+ {file && <Caption>{file}{o.replace_all ? ' · every occurrence' : ''}</Caption>}
83
+ <Block kind="ct-was">{o.old_string}</Block>
84
+ <Block kind="ct-now">{o.new_string}</Block>
85
+ <Fields o={o} skip={SHOWN_ELSEWHERE} />
86
+ </>
87
+ );
88
+ }
89
+
90
+ // A write is a file and its contents.
91
+ if (file && typeof o.content === 'string') {
92
+ return (
93
+ <>
94
+ <Caption>{file}</Caption>
95
+ <Block>{o.content}</Block>
96
+ <Fields o={o} skip={SHOWN_ELSEWHERE} />
97
+ </>
98
+ );
99
+ }
100
+
101
+ // A read is a file and a range.
102
+ if (file) {
103
+ const r = range(o);
104
+ return (
105
+ <>
106
+ <Caption>{file}{r ? ` · ${r}` : ''}</Caption>
107
+ {note && <div className="ct-note">{note}</div>}
108
+ <Fields o={o} skip={SHOWN_ELSEWHERE} />
109
+ </>
110
+ );
111
+ }
112
+
113
+ // Everything else — a search, a fetch, an agent — is its fields.
114
+ return (
115
+ <>
116
+ {note && <Caption>{note}</Caption>}
117
+ <Fields o={o} skip={new Set(['description'])} />
118
+ </>
119
+ );
120
+ }
web/src/components/conversation/exchanges.ts ADDED
@@ -0,0 +1,227 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Turns → exchanges → step lines. (docs/conversation-view.md §4)
2
+ //
3
+ // One prompt, the work, the answer. Every surface in the app is some depth of
4
+ // this, so the grouping lives here and not in a component.
5
+ import type { TraceBlock, TraceTurn } from '../../api';
6
+
7
+ export interface Exchange {
8
+ key: string;
9
+ /** Index into the turn array — what a "jump to this exchange" needs. */
10
+ at: number;
11
+ prompt: TraceTurn | null;
12
+ steps: TraceTurn[];
13
+ /** Everything the agent said after its last action — usually one turn. */
14
+ answer: TraceTurn[];
15
+ startTs: number;
16
+ endTs: number;
17
+ tokens: number;
18
+ toolCalls: number;
19
+ model?: string;
20
+ }
21
+
22
+ // Mirrors the digest's rule in server/src/traces.js: a "user" line that opens
23
+ // with a tag or an interrupt marker is the harness talking, not the operator.
24
+ export const isOperatorPrompt = (t: TraceTurn) => {
25
+ if (t.role !== 'user') return false;
26
+ const text = t.blocks.filter((b) => b.type === 'text').map((b) => ('text' in b ? b.text : '')).join('').trim();
27
+ if (!text) return t.blocks.some((b) => b.type === 'image');
28
+ return !text.startsWith('<') && !text.startsWith('[Request interrupted');
29
+ };
30
+
31
+ const usageOf = (t: TraceTurn) => (t.usage ? (t.usage.in || 0) + (t.usage.out || 0) : 0);
32
+
33
+ export function splitExchanges(turns: TraceTurn[]): Exchange[] {
34
+ const out: Exchange[] = [];
35
+ let cur: Exchange | null = null;
36
+ const open = (at: number, prompt: TraceTurn | null) => {
37
+ cur = {
38
+ key: `x${at}`, at, prompt, steps: [], answer: [],
39
+ startTs: prompt?.ts || 0, endTs: prompt?.ts || 0, tokens: 0, toolCalls: 0,
40
+ };
41
+ out.push(cur);
42
+ };
43
+ // Everything lands in `steps` in the order it happened; the answer is chosen
44
+ // afterwards and lifted out. Promoting as we go used to reorder the middle:
45
+ // a superseded answer was appended wherever the NEXT one arrived, so an
46
+ // intermediate message jumped below the tool calls that came after it.
47
+ turns.forEach((t, i) => {
48
+ if (isOperatorPrompt(t)) { open(i, t); return; }
49
+ if (!cur) open(i, null);
50
+ const x = cur!;
51
+ if (t.ts) { if (!x.startTs) x.startTs = t.ts; x.endTs = Math.max(x.endTs, t.ts); }
52
+ x.tokens += usageOf(t);
53
+ x.toolCalls += t.blocks.filter((b) => b.type === 'tool_use').length;
54
+ if (t.model && !x.model) x.model = t.model;
55
+ if (t.role !== 'system') x.steps.push(t);
56
+ });
57
+
58
+ const saidSomething = (t: TraceTurn) =>
59
+ t.role === 'assistant' && t.blocks.some((b) => b.type === 'text' && b.text.trim());
60
+ // …and stopped there. A message followed by more tool calls is the agent
61
+ // thinking out loud mid-task, not its reply — which is most of what you see
62
+ // while one is still working.
63
+ const endedOnIt = (t: TraceTurn) => {
64
+ const said = t.blocks.filter((b) => b.type !== 'text' || b.text.trim());
65
+ return said.length > 0 && said[said.length - 1].type === 'text';
66
+ };
67
+ const spoke = (t: TraceTurn) => saidSomething(t) && endedOnIt(t);
68
+
69
+ for (const x of out) {
70
+ // The answer is the trailing RUN of messages: everything the agent said
71
+ // after its last action. Taking only the last one buried real answers —
72
+ // a harness marks the last assistant text of a request as `final`, and
73
+ // that is sometimes a throwaway ("No response requested.") written in
74
+ // reply to a notification, with the actual answer in the turn above it.
75
+ let from = x.steps.length;
76
+ while (from > 0 && spoke(x.steps[from - 1])) from--;
77
+ if (from < x.steps.length) {
78
+ x.answer = x.steps.slice(from);
79
+ x.steps.length = from;
80
+ continue;
81
+ }
82
+ // Nothing at the end: an agent that answered and then went back to work
83
+ // (a resumed task) still answered. Its last `final` stands, where it is.
84
+ for (let i = x.steps.length - 1; i >= 0; i--) {
85
+ if (x.steps[i].kind === 'final' && saidSomething(x.steps[i])) {
86
+ x.answer = [x.steps[i]];
87
+ x.steps.splice(i, 1);
88
+ break;
89
+ }
90
+ }
91
+ }
92
+ return out;
93
+ }
94
+
95
+ // ---------- step lines ----------
96
+
97
+ export type Step =
98
+ | { kind: 'think'; text: string; more?: number }
99
+ | { kind: 'tools'; name: string; count: number; details: string[]; failed: boolean; blocks: TraceBlock[] }
100
+ | { kind: 'note'; text: string; more?: number }
101
+ | { kind: 'shell'; command: string; out: string; failed: boolean }
102
+ | { kind: 'image'; src: string }
103
+ | { kind: 'compact'; text: string };
104
+
105
+ // The one field of a tool call worth a line: what it acted on.
106
+ const ARG_KEYS = ['file_path', 'path', 'notebook_path', 'command', 'pattern', 'glob', 'url', 'query', 'prompt', 'description', 'subagent_type'];
107
+ const shortPath = (p: string) => {
108
+ const parts = p.split('/').filter(Boolean);
109
+ return parts.length > 2 ? `…/${parts.slice(-2).join('/')}` : p;
110
+ };
111
+
112
+ export function argSummary(text: string): string {
113
+ let v: unknown = null;
114
+ try { v = JSON.parse(text); } catch {
115
+ // A block the reader had to cut mid-JSON still names what it acted on.
116
+ for (const k of ARG_KEYS) {
117
+ const m = new RegExp(`"${k}"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)`).exec(text);
118
+ if (m && m[1].trim()) return oneLine(m[1].replace(/\\n/g, ' ').replace(/\\"/g, '"'), 70);
119
+ }
120
+ return oneLine(text.replace(/^[{\s"]+/, ''), 70);
121
+ }
122
+ if (!v || typeof v !== 'object') return oneLine(String(v), 70);
123
+ const o = v as Record<string, unknown>;
124
+ for (const k of ARG_KEYS) {
125
+ const raw = o[k];
126
+ if (typeof raw !== 'string' || !raw.trim()) continue;
127
+ return oneLine(k.includes('path') ? shortPath(raw) : raw, 70);
128
+ }
129
+ return oneLine(Object.keys(o).join(', '), 70);
130
+ }
131
+
132
+ /** Everything in a step a search could reasonably land on, including bodies. */
133
+ export function stepText(s: Step): string {
134
+ if (s.kind === 'tools') return [s.name, ...s.details, ...s.blocks.map((b) => ('text' in b ? b.text : ''))].join('\n');
135
+ if (s.kind === 'shell') return `${s.command}\n${s.out}`;
136
+ if (s.kind === 'image') return '';
137
+ return s.text;
138
+ }
139
+
140
+ export const oneLine = (s: string, n = 90) => {
141
+ const t = (s || '').replace(/\s+/g, ' ').trim();
142
+ return t.length > n ? `${t.slice(0, n)}…` : t;
143
+ };
144
+
145
+ export function stepsOf(turns: TraceTurn[]): Step[] {
146
+ const out: Step[] = [];
147
+ const pushTool = (name: string, detail: string, blocks: TraceBlock[], failed: boolean) => {
148
+ const last = out[out.length - 1];
149
+ // Consecutive calls to the SAME tool read as one line: "Read ×4".
150
+ if (last && last.kind === 'tools' && last.name === name) {
151
+ last.count++;
152
+ if (detail && last.details.length < 4) last.details.push(detail);
153
+ last.blocks.push(...blocks);
154
+ last.failed = last.failed || failed;
155
+ return;
156
+ }
157
+ out.push({ kind: 'tools', name, count: 1, details: detail ? [detail] : [], failed, blocks });
158
+ };
159
+
160
+ for (const t of turns) {
161
+ const bs = t.blocks;
162
+ for (let i = 0; i < bs.length; i++) {
163
+ const b = bs[i];
164
+ if (b.type === 'tool_use') {
165
+ // Sweep up this call's results (the server files them next to the call).
166
+ const group: TraceBlock[] = [b];
167
+ let failed = false;
168
+ let j = i + 1;
169
+ while (j < bs.length && bs[j].type === 'tool_result') {
170
+ const r = bs[j] as Extract<TraceBlock, { type: 'tool_result' }>;
171
+ group.push(r);
172
+ failed = failed || !!r.failed;
173
+ j++;
174
+ }
175
+ i = j - 1;
176
+ pushTool(b.name, argSummary(b.text), group, failed);
177
+ } else if (b.type === 'tool_result') {
178
+ pushTool('result', oneLine(b.text, 60), [b], !!b.failed);
179
+ } else if (b.type === 'thinking') {
180
+ out.push({ kind: 'think', text: b.text, more: b.more });
181
+ } else if (b.type === 'shell') {
182
+ out.push({ kind: 'shell', command: b.command, out: `${b.stdout || ''}${b.stderr || ''}`, failed: !!b.exitCode });
183
+ } else if (b.type === 'compaction') {
184
+ out.push({ kind: 'compact', text: b.text });
185
+ } else if (b.type === 'image') {
186
+ out.push({ kind: 'image', src: b.src });
187
+ } else if (b.type === 'text' && b.text.trim()) {
188
+ out.push({ kind: 'note', text: b.text, more: b.more });
189
+ }
190
+ }
191
+ }
192
+ return out;
193
+ }
194
+
195
+ // ---------- formatting ----------
196
+
197
+ /** 954 · 21.0k · 654k · 2.2M · 1.4B — one decimal only where it says something. */
198
+ export const fmtTok = (n = 0) => {
199
+ const [v, unit] = n >= 1e9 ? [n / 1e9, 'B'] : n >= 1e6 ? [n / 1e6, 'M'] : n >= 1e3 ? [n / 1e3, 'k'] : [n, ''];
200
+ if (!unit) return String(Math.round(v));
201
+ return `${v >= 100 ? Math.round(v) : v.toFixed(1)}${unit}`;
202
+ };
203
+
204
+ export const fmtDur = (ms: number) => {
205
+ if (!ms || ms < 0) return '';
206
+ const s = Math.round(ms / 1000);
207
+ if (s < 60) return `${s}s`;
208
+ const m = Math.floor(s / 60);
209
+ return m < 60 ? `${m}m ${s % 60}s` : `${Math.floor(m / 60)}h ${m % 60}m`;
210
+ };
211
+
212
+ export const fmtClock = (ms?: number) =>
213
+ ms ? new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '';
214
+
215
+ /**
216
+ * "14 steps · 9 tools · 42s · 21.0k tok" — the whole turn in one line. There is
217
+ * no header above the prompt any more, so this carries the numbers everywhere.
218
+ */
219
+ export function stepSummary(x: Exchange, steps: Step[]): string {
220
+ const bits: string[] = [];
221
+ if (steps.length) bits.push(`${steps.length} step${steps.length === 1 ? '' : 's'}`);
222
+ if (x.toolCalls) bits.push(`${x.toolCalls} tool${x.toolCalls === 1 ? '' : 's'}`);
223
+ const d = fmtDur(x.endTs - x.startTs);
224
+ if (d) bits.push(d);
225
+ if (x.tokens) bits.push(`${fmtTok(x.tokens)} tok`);
226
+ return bits.join(' · ');
227
+ }
web/src/components/icons.tsx CHANGED
@@ -285,3 +285,13 @@ export const HandoverGlyph = ({ className }: { className?: string }) => (
285
  <path d="M8.9 5.8 12.1 9l-3.2 3.2" />
286
  </G>
287
  );
 
 
 
 
 
 
 
 
 
 
 
285
  <path d="M8.9 5.8 12.1 9l-3.2 3.2" />
286
  </G>
287
  );
288
+
289
+ // Send: an upward arrow with a little more weight than the rest of the set —
290
+ // it sits alone on a filled button, where 1.2px reads as thin.
291
+ export const SendGlyph = ({ className }: { className?: string }) => (
292
+ <svg className={className} viewBox="0 0 16 16" fill="none" stroke="currentColor"
293
+ strokeWidth="1.9" strokeLinejoin="round" strokeLinecap="round" aria-hidden="true">
294
+ <path d="M8 12.8V3.6" />
295
+ <path d="M4.1 7.4 8 3.4l3.9 4" />
296
+ </svg>
297
+ );
web/src/conversation.css ADDED
@@ -0,0 +1,226 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* The conversation grammar (docs/conversation-view.md §4.3), shared by the
2
+ Overview card and the pane's RENDER mode. App tokens only — --panel, --border,
3
+ --accent, --muted, --font-mono. Nothing terminal-flavoured: no --term-bg, no
4
+ uppercase role pills, no per-row chips. */
5
+
6
+ /* ---------- one exchange ---------- */
7
+ /* padding-left is the gutter the prompt's ❯ hangs into. Everything else —
8
+ answer, work, fold control — starts at the content edge, so the arrow is the
9
+ only mark outside the column and turns are findable while scrolling. */
10
+ .cx { display: flex; flex-direction: column; gap: 7px; min-width: 0; padding: 2px 0 2px 16px; }
11
+ .cx.dim { opacity: 0.72; }
12
+ .cx.dim .cx-answer { -webkit-line-clamp: 6; }
13
+
14
+ /* one meta row, under the prompt: the work on the left, which turn on the right.
15
+ Nothing sits above the prompt any more. */
16
+ .cx-meta {
17
+ display: flex; align-items: baseline; gap: 8px; min-width: 0;
18
+ font-size: 11px; color: var(--muted);
19
+ }
20
+ .cx-meta .spacer { flex: 1; min-width: 4px; }
21
+ .cx-n, .cx-time, .cx-model { flex: none; font-variant-numeric: tabular-nums; }
22
+ .cx-n { color: color-mix(in srgb, var(--accent) 65%, var(--muted)); }
23
+ .cx-model { opacity: 0.8; }
24
+
25
+ /* the prompt: a lightly tinted band between hairlines, with the chevron hanging
26
+ in the gutter — what your eye lands on when scrolling a long conversation */
27
+ .cx-prompt {
28
+ position: relative; margin-left: -16px; padding: 6px 10px 7px 16px;
29
+ background: color-mix(in srgb, var(--accent) 7%, transparent);
30
+ border-top: 1px solid var(--border); border-bottom: 1px solid var(--border);
31
+ font-size: 13px; font-weight: 550; color: var(--text);
32
+ white-space: pre-wrap; overflow-wrap: break-word;
33
+ }
34
+ .cx-prompt::before {
35
+ content: '❯'; position: absolute; left: 3px; top: 6px;
36
+ font-family: var(--font-mono); color: var(--accent); font-weight: 700;
37
+ }
38
+
39
+ /* the fold control names what it hides — never a bare caret */
40
+ .cx-fold {
41
+ display: inline-flex; align-items: baseline; gap: 6px; min-width: 0;
42
+ background: none; border: none; padding: 0; margin: 0;
43
+ font: inherit; color: var(--muted); cursor: pointer; text-align: left;
44
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
45
+ }
46
+ .cx-fold.flat { cursor: default; padding-left: 19px; }
47
+ .cx-fold:hover { color: var(--text); }
48
+ .cx-fold.on { color: var(--text); }
49
+ .cx-fold .cs-tri { color: var(--muted); }
50
+ .cx-fold.on .cs-tri { color: var(--accent); }
51
+
52
+ /* ---------- the work: a rail of one-line steps ---------- */
53
+ .cx-steps { display: flex; flex-direction: column; padding: 1px 0; }
54
+ .cs { min-width: 0; }
55
+ .cs-head {
56
+ display: flex; align-items: baseline; gap: 6px; width: 100%; min-width: 0;
57
+ background: none; border: none; padding: 2px 4px 2px 0; margin: 0;
58
+ font: inherit; font-size: 12px; color: var(--text); text-align: left; cursor: pointer;
59
+ border-radius: var(--r-sm);
60
+ }
61
+ .cs-head:hover:not(:disabled) { background: color-mix(in srgb, var(--border) 45%, transparent); }
62
+ .cs-head:disabled { cursor: default; }
63
+ /* one column, two meanings: a tool reports its outcome, everything else offers
64
+ to open. Greyed triangle = nothing more to see. */
65
+ .cs-tri, .cs-mark {
66
+ flex: none; width: 13px; font-size: 10px; line-height: 1.6;
67
+ font-family: var(--font-mono); color: var(--muted);
68
+ }
69
+ .cs-tri.off { color: color-mix(in srgb, var(--border-strong) 65%, transparent); }
70
+ .cs-mark { font-size: 11px; }
71
+ .cs-mark.ok { color: color-mix(in srgb, var(--ok, #4b9e6a) 85%, var(--muted)); }
72
+ .cs-mark.bad { color: var(--danger, #c05353); }
73
+ .cs-label { flex: none; font-size: 11px; font-weight: 600; color: var(--muted); }
74
+ .cs.tools .cs-label { color: color-mix(in srgb, var(--accent) 70%, var(--muted)); }
75
+ .cs.think .cs-label { color: #8b73c4; }
76
+ .cs-detail {
77
+ min-width: 0; flex: 1; font-size: 11.5px; color: var(--muted);
78
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
79
+ }
80
+ /* expanded in place: same font, same colour, just no longer cut */
81
+ .cs-detail.full { white-space: pre-wrap; overflow: visible; text-overflow: clip; overflow-wrap: break-word; }
82
+ .cs.note .cs-detail { color: var(--text); opacity: 0.78; }
83
+ .cs-more { font-size: 10px; color: var(--muted); padding: 2px 0 0; }
84
+ .cs-body { padding: 2px 0 6px 13px; }
85
+ .cs-pre {
86
+ white-space: pre-wrap; word-break: break-word; margin: 3px 0; padding: 6px 8px;
87
+ background: var(--panel-2); border-radius: var(--r-sm);
88
+ font-family: var(--font-mono); font-size: 11px; line-height: 1.5;
89
+ max-height: 20rem; overflow: auto;
90
+ }
91
+ .cs-pre.out { color: var(--muted); }
92
+ .cs-pre.out.bad {
93
+ border: 1px solid color-mix(in srgb, var(--danger, #c05353) 55%, transparent);
94
+ background: color-mix(in srgb, var(--danger, #c05353) 7%, var(--panel-2));
95
+ }
96
+ .cs-pre.think { color: color-mix(in srgb, #8b73c4 70%, var(--text)); }
97
+ .cs-img { max-width: 100%; max-height: 18rem; border-radius: var(--r-sm); }
98
+
99
+ /* ---------- the answer: the one thing written for the reader ---------- */
100
+ .cx-answer { min-width: 0; }
101
+ .markdown.cx-md { border: none; background: none; padding: 0; font-size: 12.5px; line-height: 1.55; }
102
+ .cx-md > :first-child { margin-top: 0; }
103
+ .cx-md > :last-child { margin-bottom: 0; }
104
+ .cx-md pre { font-size: 11px; }
105
+ .cx-note { font-size: 10.5px; color: var(--muted); padding-top: 4px; }
106
+
107
+ .cx-running {
108
+ display: flex; align-items: baseline; min-width: 0; font-size: 12.5px; color: var(--muted);
109
+ }
110
+ .cx-running-at { margin-left: 7px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; opacity: 0.85; }
111
+ .cx-running::before {
112
+ content: '⠋'; display: inline-block; width: 1.2em; color: var(--accent);
113
+ animation: ov-spin 0.9s steps(1) infinite;
114
+ }
115
+
116
+ /* ---------- card: history grows upward, one exchange at a time ---------- */
117
+ .cx-earlier { display: flex; align-items: center; justify-content: center; gap: 12px; font-size: 11px; }
118
+ .cx-earlier-btn {
119
+ background: none; border: none; padding: 0; font: inherit; font-size: 10.5px;
120
+ color: var(--accent); cursor: pointer;
121
+ }
122
+ .cx-earlier-btn:hover { text-decoration: underline; }
123
+ .cx-earlier-n { color: var(--muted); }
124
+ .cx-earlier-note { color: var(--muted); }
125
+
126
+ /* ---------- search hits ---------- */
127
+ mark.cx-hit {
128
+ background: color-mix(in srgb, var(--accent) 28%, transparent);
129
+ color: inherit; border-radius: 2px; padding: 0 1px;
130
+ }
131
+ mark.cx-hit.on { background: var(--accent); color: var(--panel); }
132
+
133
+ /* ---------- RENDER mode: the same thing, stacked ---------- */
134
+ .cxv { display: flex; flex-direction: column; min-height: 0; }
135
+
136
+ .cxv-bar {
137
+ /* wraps at ANY width, not behind a media query: a pane is a container, and
138
+ a 390px pane on a desktop never triggers a viewport media query */
139
+ display: flex; align-items: center; gap: 7px 8px; flex: none; flex-wrap: wrap;
140
+ padding: 5px 9px; background: var(--panel-2); border-bottom: 1px solid var(--border);
141
+ font-size: 10.5px; color: var(--muted);
142
+ }
143
+ .cxv-bar .spacer { flex: 1; }
144
+ .cxv-chip { padding: 0 5px; border: 1px solid var(--border); border-radius: var(--r-sm); white-space: nowrap; }
145
+ .cxv-count, .cxv-tok { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-variant-numeric: tabular-nums; }
146
+ .cxv-tok { flex: 0 1 auto; }
147
+ .cxv-mini {
148
+ flex: none; white-space: nowrap; background: none; border: 1px solid transparent; border-radius: var(--r-sm);
149
+ padding: 1px 5px; font: inherit; font-size: 10.5px; color: var(--muted); cursor: pointer;
150
+ }
151
+ .cxv-mini:hover { color: var(--text); border-color: var(--border); }
152
+ .cxv-mini.on { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); }
153
+ .cxv-nav { display: inline-flex; gap: 2px; }
154
+ .cxv-hits { flex: none; font-variant-numeric: tabular-nums; color: var(--muted); }
155
+ .cxv-search {
156
+ flex: 1 1 7rem; width: auto; min-width: 4.5rem; max-width: 22rem;
157
+ padding: 2px 6px; font: inherit; font-size: 11px;
158
+ background: var(--panel); color: var(--text); border: 1px solid var(--border); border-radius: var(--r-sm);
159
+ }
160
+
161
+ /* Panel background, and a column that FILLS the pane. A fixed reading width
162
+ left a gutter of nothing on each side while the prompt band — which is what
163
+ you scan for — still spanned the full width, so the two disagreed about where
164
+ the conversation began. The pane is the measure: make it narrow and the
165
+ conversation is narrow. */
166
+ .cxv-body { flex: 1; min-height: 0; overflow-y: auto; background: var(--panel); padding: 4px 14px 30px; }
167
+ .cxv-col { display: flex; flex-direction: column; min-width: 0; }
168
+ .cxv-msg { font-size: 11px; color: var(--muted); padding: 6px 0; }
169
+ .cxv-foot {
170
+ display: flex; align-items: center; gap: 8px; flex: none;
171
+ padding: 5px 10px; border-top: 1px solid var(--border); background: var(--panel);
172
+ color: var(--muted); font-size: 10.5px; overflow: hidden;
173
+ }
174
+ .cxv-foot .cxv-path { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
175
+ .cxv-foot .spacer { flex: 1; min-width: 4px; }
176
+
177
+ /* the prompt band sticks: deep inside a long turn, the thing you want overhead
178
+ is what you asked for — not a row of numbers */
179
+ .cxv-col .cx-prompt {
180
+ position: sticky; top: 0; z-index: 2;
181
+ background: color-mix(in srgb, var(--accent) 7%, var(--panel));
182
+ }
183
+
184
+ /* ---------- phone ---------- */
185
+ @media (max-width: 720px) {
186
+ .cxv-body { padding: 4px 8px 24px; }
187
+ .cs-detail { font-size: 12px; }
188
+ }
189
+
190
+ /* ---------- reader mode inside a session pane ---------- */
191
+ /* Drawn over the live terminal, which stays mounted underneath. */
192
+ .pane-reader { position: absolute; inset: 0; z-index: 3; display: flex; background: var(--panel); }
193
+ .pane-reader > .cxv { flex: 1; min-width: 0; }
194
+ .cxv-empty { margin: auto; padding: 24px; font-size: 11.5px; color: var(--muted); text-align: center; }
195
+ .ph-modes { flex: none; }
196
+ .ph-modes button { padding: 1px 7px; font-size: 9.5px; letter-spacing: 0.06em; }
197
+
198
+ /* The overlay needs a positioned host; .term-host is otherwise plain. */
199
+ .slot .term-host { position: relative; }
200
+
201
+ /* ---------- the card inline in the Overview list ---------- */
202
+ /* There it is a summary, not a room: one turn, the answer clamped. The window
203
+ (and RENDER mode) is where a conversation gets to be long. */
204
+ .ov-card.ov-compact .cx-answer .markdown {
205
+ display: -webkit-box; -webkit-line-clamp: 6; -webkit-box-orient: vertical; overflow: hidden;
206
+ }
207
+
208
+ /* ---------- an expanded tool call ---------- */
209
+ .cs-call { display: flex; flex-direction: column; }
210
+ .ct-cap { font-size: 10.5px; color: var(--muted); padding: 3px 0 1px; overflow-wrap: anywhere; }
211
+ .ct-note { font-size: 11px; color: var(--muted); padding: 1px 0 2px; }
212
+ .cs-pre.ct-cmd { color: var(--text); }
213
+ .cs-pre.ct-cmd::before { content: '$ '; color: var(--accent); }
214
+ /* a before and an after — tinted, not barred */
215
+ .cs-pre.ct-was { background: color-mix(in srgb, var(--danger, #c05353) 8%, var(--panel-2)); }
216
+ .cs-pre.ct-now { background: color-mix(in srgb, var(--ok, #4b9e6a) 9%, var(--panel-2)); }
217
+ .ct-fields { margin: 3px 0 0; font-size: 11px; }
218
+ .ct-row { display: grid; grid-template-columns: minmax(4.5rem, auto) 1fr; gap: 8px; padding: 1px 0; align-items: baseline; }
219
+ .ct-row dt { color: var(--muted); }
220
+ .ct-row dd { margin: 0; min-width: 0; color: var(--text); overflow-wrap: anywhere; }
221
+ .ct-row dd .cs-pre { margin: 2px 0; }
222
+
223
+ /* The reply line under a rendered conversation — the card's own composer, so
224
+ answering an agent looks the same wherever you are reading it. */
225
+ .cxv-live { flex: none; padding: 6px 12px; border-top: 1px solid var(--border); background: var(--panel); }
226
+ .cxv-note { flex: none; padding: 0 12px 6px; }
web/src/lib/paneMode.ts ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // How every session pane is being read: the terminal itself, or reader mode.
2
+ // (docs/conversation-view.md §3.3)
3
+ //
4
+ // One setting for the whole app, like zoom — not per session. Reading a fleet
5
+ // means reading it the same way; flipping panes one at a time was a preference
6
+ // nobody wanted to manage. Kept in localStorage so a reload does not undo it,
7
+ // and announced so an already-mounted pane hears about it.
8
+ export type PaneMode = 'terminal' | 'reader';
9
+
10
+ const KEY = 'am-pane-mode';
11
+ const EVENT = 'am:pane-mode';
12
+
13
+ export function readPaneMode(): PaneMode {
14
+ try { return localStorage.getItem(KEY) === 'reader' ? 'reader' : 'terminal'; } catch { return 'terminal'; }
15
+ }
16
+
17
+ export function writePaneMode(mode: PaneMode): void {
18
+ try { localStorage.setItem(KEY, mode); } catch { /* private mode: this session only */ }
19
+ window.dispatchEvent(new CustomEvent(EVENT, { detail: mode }));
20
+ }
21
+
22
+ /** Someone else changed it — the Overview card asking for the full history. */
23
+ export function onPaneMode(apply: (m: PaneMode) => void): () => void {
24
+ const h = (e: Event) => apply((e as CustomEvent<PaneMode>).detail);
25
+ window.addEventListener(EVENT, h);
26
+ return () => window.removeEventListener(EVENT, h);
27
+ }
web/src/main.tsx CHANGED
@@ -3,6 +3,7 @@ import type { ReactNode } from 'react';
3
  import { createRoot } from 'react-dom/client';
4
  import App from './App';
5
  import './styles.css';
 
6
 
7
  // Last-resort guard: React unmounts the WHOLE tree on an uncaught render
8
  // error, which reads as a blank white page. Show a reload card instead.
 
3
  import { createRoot } from 'react-dom/client';
4
  import App from './App';
5
  import './styles.css';
6
+ import './conversation.css';
7
 
8
  // Last-resort guard: React unmounts the WHOLE tree on an uncaught render
9
  // error, which reads as a blank white page. Show a reload card instead.
web/src/styles.css CHANGED
@@ -344,7 +344,10 @@ body {
344
  .ov-live textarea::placeholder { color: var(--muted); opacity: 0.7; }
345
  .ov-hint { font-size: 10.5px; color: var(--muted); flex: none; }
346
  /* send button — essential on mobile (no Shift+Enter), handy on desktop */
347
- .ov-send { flex: none; width: 26px; height: 26px; border: none; border-radius: 999px; background: var(--accent); color: var(--accent-fg); font-size: 14px; line-height: 1; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; }
 
 
 
348
  .ov-send:disabled { opacity: 0.5; cursor: default; }
349
  .ov-note { font-size: 11px; color: var(--danger); }
350
 
@@ -1067,6 +1070,10 @@ a.btn-ghost { text-decoration: none; }
1067
  /* mobile stage top bar (back + agent chips) — rendered only on mobile */
1068
  .mbar { display: flex; align-items: center; gap: 8px; padding: 0 0 8px; flex: none; }
1069
  .mback { flex: none; font-size: 19px; padding-bottom: 3px; }
 
 
 
 
1070
  .mtitle { font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1071
  .mchips { display: flex; gap: 6px; overflow-x: auto; flex: 1; padding: 2px; }
1072
  .mchip { display: inline-flex; align-items: center; gap: 7px; padding: 6px 10px; background: var(--panel); border: 1px solid var(--border); border-radius: var(--r-md); cursor: pointer; flex: none; }
 
344
  .ov-live textarea::placeholder { color: var(--muted); opacity: 0.7; }
345
  .ov-hint { font-size: 10.5px; color: var(--muted); flex: none; }
346
  /* send button — essential on mobile (no Shift+Enter), handy on desktop */
347
+ /* A square key, not a bubble: it sits at the end of a line of type, and the
348
+ rounded pill read as a chat app rather than as a prompt. */
349
+ .ov-send { flex: none; width: 26px; height: 26px; padding: 0; border: none; border-radius: 7px; background: var(--accent); color: var(--accent-fg); cursor: pointer; display: inline-flex; align-items: center; justify-content: center; }
350
+ .ov-send svg { width: 15px; height: 15px; }
351
  .ov-send:disabled { opacity: 0.5; cursor: default; }
352
  .ov-note { font-size: 11px; color: var(--danger); }
353
 
 
1070
  /* mobile stage top bar (back + agent chips) — rendered only on mobile */
1071
  .mbar { display: flex; align-items: center; gap: 8px; padding: 0 0 8px; flex: none; }
1072
  .mback { flex: none; font-size: 19px; padding-bottom: 3px; }
1073
+ /* Reading mode, in the zoom bar: the same weight as the controls beside it. */
1074
+ .modebar { flex: none; margin-right: 6px; }
1075
+ .modebar button { padding: 2px 8px; font-size: 10.5px; letter-spacing: 0.02em; }
1076
+
1077
  .mtitle { font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1078
  .mchips { display: flex; gap: 6px; overflow-x: auto; flex: 1; padding: 2px; }
1079
  .mchip { display: inline-flex; align-items: center; gap: 7px; padding: 6px 10px; background: var(--panel); border: 1px solid var(--border); border-radius: var(--r-md); cursor: pointer; flex: none; }
web/test/exchanges.test.mjs ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // What counts as the ANSWER, and what stays in the work.
2
+ //
3
+ // This is the one piece of judgement in the conversation renderer, and it has
4
+ // already been wrong twice: a superseded answer was re-inserted wherever the
5
+ // next one arrived (so an intermediate message rendered below the tool calls
6
+ // that came after it), and mid-task the last message was promoted to the answer
7
+ // slot (so an agent's aside read as its reply, in the wrong place).
8
+ //
9
+ // No test runner: esbuild is already here for vite, so the module is transpiled
10
+ // and imported directly. Run with: node test/exchanges.test.mjs
11
+ import assert from 'node:assert/strict';
12
+ import fs from 'node:fs';
13
+ import os from 'node:os';
14
+ import path from 'node:path';
15
+ import { fileURLToPath, pathToFileURL } from 'node:url';
16
+ import { build } from 'esbuild';
17
+
18
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
19
+ const out = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'exch-')), 'exchanges.mjs');
20
+ await build({
21
+ entryPoints: [path.join(HERE, '../src/components/conversation/exchanges.ts')],
22
+ outfile: out, format: 'esm', bundle: false, logLevel: 'error',
23
+ });
24
+ const { splitExchanges, stepsOf, stepSummary, fmtTok } = await import(pathToFileURL(out).href);
25
+
26
+ let ts = 1_700_000_000_000;
27
+ const at = () => (ts += 30_000);
28
+ const text = (t) => ({ type: 'text', text: t });
29
+ const call = (name, arg) => ({ type: 'tool_use', name, text: JSON.stringify(arg) });
30
+ const result = (t, failed) => ({ type: 'tool_result', text: t, ...(failed ? { failed: true } : {}) });
31
+ const user = (t) => ({ role: 'user', ts: at(), blocks: [text(t)] });
32
+ const agent = (blocks, kind) => ({ role: 'assistant', ts: at(), ...(kind ? { kind } : {}), blocks });
33
+ // a prompt is one turn; an answer is the RUN of turns said after the last action
34
+ const said = (t) => {
35
+ const turns = Array.isArray(t) ? t : [t].filter(Boolean);
36
+ if (!turns.length) return null;
37
+ return turns.map((x) => x.blocks.filter((b) => b.type === 'text').map((b) => b.text).join('')).join('\n\n');
38
+ };
39
+ const kinds = (steps) => steps.map((s) => (s.kind === 'tools' ? `${s.name}×${s.count}` : s.kind));
40
+
41
+ // ---------------------------------------------------------------- mid-task
42
+ // The agent has spoken twice and is still working: neither message is a reply,
43
+ // and both keep their place among the tool calls.
44
+ {
45
+ const [x] = splitExchanges([
46
+ user('why is the scan slow?'),
47
+ agent([text('Let me look at what runs on an interval.'), call('Grep', { pattern: 'setInterval' }), result('runner.js:184')]),
48
+ agent([text('There it is — a sync stat sweep.'), call('Read', { file_path: 'runner.js' }), result('statSync(f)')]),
49
+ agent([call('Edit', { file_path: 'runner.js' }), result('Applied 1 edit')]),
50
+ ]);
51
+ assert.deepEqual(x.answer, [], 'work in flight is not an answer');
52
+ assert.deepEqual(kinds(stepsOf(x.steps)),
53
+ ['note', 'Grep×1', 'note', 'Read×1', 'Edit×1'],
54
+ 'messages stay above the calls they introduced');
55
+ assert.equal(x.toolCalls, 3);
56
+ }
57
+
58
+ // ---------------------------------------------------------------- finished
59
+ // The last turn ended on words: that is the reply, and it leaves the work.
60
+ {
61
+ const [x] = splitExchanges([
62
+ user('why is the scan slow?'),
63
+ agent([text('Looking.'), call('Read', { file_path: 'runner.js' }), result('…')]),
64
+ agent([text('Found it: the tick stats synchronously.')]),
65
+ ]);
66
+ assert.equal(said(x.answer), 'Found it: the tick stats synchronously.');
67
+ assert.deepEqual(kinds(stepsOf(x.steps)), ['note', 'Read×1'], 'the answer is not also a step');
68
+ }
69
+
70
+ // A turn that says something and then calls a tool has not answered yet.
71
+ {
72
+ const [x] = splitExchanges([
73
+ user('go'),
74
+ agent([text('One more check.'), call('Bash', { command: 'npm test' }), result('ok')]),
75
+ ]);
76
+ assert.deepEqual(x.answer, [], 'ending on a tool call is not ending on words');
77
+ }
78
+
79
+ // ------------------------------------------------------- a superseded final
80
+ // Two finals (a resumed task): the later one answers, the earlier one stays
81
+ // where it happened rather than being appended after the work that followed it.
82
+ {
83
+ const [x] = splitExchanges([
84
+ user('ship it'),
85
+ agent([text('Pushed.')], 'final'),
86
+ agent([call('Bash', { command: 'gh pr create' }), result('#37')]),
87
+ agent([text('PR is up: #37.')], 'final'),
88
+ ]);
89
+ assert.equal(said(x.answer), 'PR is up: #37.');
90
+ assert.deepEqual(kinds(stepsOf(x.steps)), ['note', 'Bash×1'], 'the earlier final keeps its position');
91
+ }
92
+
93
+ // ------------------------------------------------- a throwaway after the answer
94
+ // Seen live: the harness marks the last assistant text of a request `final`,
95
+ // and that was "No response requested." — written in reply to a notification,
96
+ // with the real answer in the turn above. Taking only the last `final` buried
97
+ // the answer in the work and showed the boilerplate as the reply.
98
+ {
99
+ const [x] = splitExchanges([
100
+ user('any news on hugging face?'),
101
+ agent([text("I'll search the web for this."), call('WebSearch', { query: 'hugging face' }), result('…')]),
102
+ agent([text("Here's the latest on the incident: …")]),
103
+ agent([text('No response requested.')], 'final'),
104
+ ]);
105
+ assert.equal(said(x.answer),
106
+ "Here's the latest on the incident: …\n\nNo response requested.",
107
+ 'everything said after the last action is the answer');
108
+ assert.deepEqual(kinds(stepsOf(x.steps)), ['note', 'WebSearch×1'], 'and none of it is left in the work');
109
+ }
110
+
111
+ // --------------------------------------------------------------- splitting
112
+ // One exchange per operator prompt; harness-authored "user" lines are not
113
+ // prompts, and neither is an interrupt marker.
114
+ {
115
+ const xs = splitExchanges([
116
+ user('first'),
117
+ agent([text('a')], 'final'),
118
+ { role: 'user', ts: at(), blocks: [text('<system-reminder>budget</system-reminder>')] },
119
+ { role: 'user', ts: at(), blocks: [text('[Request interrupted by user]')] },
120
+ user('second'),
121
+ agent([text('b')], 'final'),
122
+ ]);
123
+ assert.equal(xs.length, 2, 'two prompts, two turns');
124
+ assert.deepEqual(xs.map((x) => said(x.prompt)), ['first', 'second']);
125
+ assert.equal(xs[0].steps.length, 2, 'the harness lines belong to the first turn, as work');
126
+ }
127
+
128
+ // Turns before any prompt (a tail that starts mid-conversation) still render.
129
+ {
130
+ const xs = splitExchanges([agent([text('…continuing')], 'final')]);
131
+ assert.equal(xs.length, 1);
132
+ assert.equal(xs[0].prompt, null);
133
+ assert.equal(said(xs[0].answer), '…continuing');
134
+ }
135
+
136
+ // ------------------------------------------------------------- step lines
137
+ // Consecutive calls to the SAME tool collapse; a different tool breaks the run,
138
+ // and a failed result marks the group.
139
+ {
140
+ const [x] = splitExchanges([
141
+ user('read them'),
142
+ agent([call('Read', { file_path: '/a/b/one.ts' }), result('…'), call('Read', { file_path: '/a/b/two.ts' }), result('…')]),
143
+ agent([call('Bash', { command: 'false' }), result('exit 1', true)]),
144
+ agent([call('Read', { file_path: '/a/b/three.ts' }), result('…')]),
145
+ agent([text('done')], 'final'),
146
+ ]);
147
+ const steps = stepsOf(x.steps);
148
+ assert.deepEqual(kinds(steps), ['Read×2', 'Bash×1', 'Read×1']);
149
+ assert.equal(steps[0].details[0], '…/b/one.ts', 'a path keeps its last two parts');
150
+ assert.equal(steps[1].failed, true, 'a failed result marks its group');
151
+ assert.equal(steps[2].failed, false);
152
+ assert.equal(stepSummary(x, steps).startsWith('3 steps · 4 tools'), true, stepSummary(x, steps));
153
+ }
154
+
155
+ // A tool call the reader had to cut mid-JSON still names what it acted on.
156
+ {
157
+ const [x] = splitExchanges([
158
+ user('edit it'),
159
+ agent([{ type: 'tool_use', name: 'Edit', text: '{"file_path": "server/src/runner.js", "old_str' }]),
160
+ ]);
161
+ assert.equal(stepsOf(x.steps)[0].details[0], 'server/src/runner.js');
162
+ }
163
+
164
+ // ------------------------------------------------------------- formatting
165
+ assert.equal(fmtTok(954), '954');
166
+ assert.equal(fmtTok(21_000), '21.0k');
167
+ assert.equal(fmtTok(654_321), '654k', 'no decimal where it says nothing');
168
+ assert.equal(fmtTok(2_200_000), '2.2M');
169
+ assert.equal(fmtTok(1_400_000_000), '1.4B');
170
+
171
+ fs.rmSync(path.dirname(out), { recursive: true, force: true });
172
+ console.log('exchanges: ok');