Agent Manager commited on
Commit
a5d1eab
·
1 Parent(s): 57ab5eb

terminal ⇄ conversation: one switch, in the bottom bar, and a better answer rule

Browse files

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

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

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

Also: an exchange with nothing to say — a prompt that got no reply at all —
renders no meta row rather than an empty one.

docs/conversation-view.md CHANGED
@@ -60,7 +60,7 @@ Four depths of one component:
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, RENDER 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.
@@ -150,7 +150,7 @@ Rules that keep this from becoming the ugly viewer in a small box:
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 RENDER)
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
@@ -163,7 +163,7 @@ exchange per click**, never by "load everything":
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 RENDER 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.
@@ -183,26 +183,29 @@ was a workaround for not having the middle; with the middle visible it has nothi
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: TUIRENDER
187
 
188
- The pane header gets a two-state segmented control, next to the title:
189
 
190
  ```
191
- ● claude agent-manager [ TUI | RENDER ] share ✕
192
  ```
193
 
194
- - **Only an agent gets the switch.** A shell has no conversation to render, and the files/trace
195
- panels are not sessions at all same rule the Overview uses to decide what is an agent
196
- (`cli !== 'shell' && !isPassive(cli)`).
197
- - **TUI** — today's terminal, untouched.
198
- - **RENDER** the same session as a conversation: `TraceView` (already extracted upstream,
199
- `TracePane.tsx:TraceView`, takes a `src` loader) over `/api/trace/:id`, at D3.
200
- - The mode is a **view preference**, kept in `localStorage` per session id, not in the store.
201
- - The terminal element stays mounted and connected underneath; RENDER draws over it. Toggling
 
 
 
202
  must not detach tmux — a reattach costs a repaint and can trip the handoff path
203
  (`HANDOFF_CODE`, `TerminalPane.tsx`). **Verify** this before shipping: xterm needs layout to
204
  fit, so "cover, don't unmount" is the low-risk option, and a refit on return is required.
205
- - The RENDER toolbar is a second header row, not a squeeze into the first — on a phone the
206
  first row has no spare width. It carries only what is true of the whole session and said
207
  nowhere else: the model, `13 turns`, the token totals abbreviated (`2.2M↓ 654k↑`), `▲▼`, and
208
  the search box. The harness is the logo in the row above; the raw message count and the cached
@@ -216,7 +219,7 @@ The pane header gets a two-state segmented control, next to the title:
216
  the nearest positioned ancestor and lands a few rows off.
217
  - Vocabulary: a **turn** is one exchange, a **message** is a raw transcript row. Each turn's meta
218
  line says `turn 6/13`, so the bar counts the same things the reader does.
219
- - **The terminal must not keep the keyboard** while RENDER covers it. A mounted xterm with focus
220
  swallows keystrokes into the agent's TTY, invisibly — and several paths grab focus back (the
221
  pane becoming active, the header, the key bar), some of them *after* the mode changes, so the
222
  guard belongs at each call rather than at the switch.
@@ -233,25 +236,25 @@ The pane header gets a two-state segmented control, next to the title:
233
  - **The conversation fills the pane.** A fixed reading column left a gutter of nothing on each
234
  side while the prompt band still spanned the full width, so the two disagreed about where the
235
  conversation began. The pane is the measure: narrow the pane and the conversation narrows.
236
- - **RENDER can be replied to.** Reading a conversation and answering it are the same act — the
237
  card has always known that, and a rendered session that could only be read would send you back
238
- to the TUI to type. It is the card's own composer (`.ov-live`), the same `sendInput`, and the
239
  same optimistic echo: your prompt appears at the bottom with a `working` line until the
240
  transcript catches up. Only a trace with **no agent behind it** — a shared file, an import — is
241
  read-only, which is what `readOnly` is for.
242
  - **Share** moves here from the sidebar. One session, one place.
243
- - **Handover** ("continue from this trace in a new agent") lives in the RENDER footer, beside
244
  the provenance line — the only place where its meaning is obvious.
245
 
246
  ### 3.4 What leaves the sidebar
247
 
248
  | Today | Tomorrow |
249
  |---|---|
250
- | `Read this session's trace` on every agent row (`Sidebar.tsx:245`) | pane header → **RENDER** |
251
  | `Share this session` on every agent row (`Sidebar.tsx:246`) | pane header → **share** |
252
  | A `trace` **session row per read** (`App.tsx:openTrace`) | **gone** — no duplicate rows |
253
  | Trace row's `Share` (`Sidebar.tsx:237`) | trace pane header (imported traces keep a pane) |
254
- | Trace row's `Handover` (`Sidebar.tsx:238`) | RENDER / trace-pane footer |
255
  | Quick-add **Trace** = open a shared dataset (`Sidebar.tsx:482`) | **stays** |
256
 
257
  The last row is deliberate: an **imported** trace has no session behind it, so it is a genuine
@@ -372,8 +375,8 @@ the block model already supports.
372
  ## 8. Open questions (operator)
373
 
374
  1. **How far back in the card?** Proposal: one exchange per click, hard cap 5, then hand off to
375
- RENDER. Alternative: one, then straight to RENDER.
376
- 2. **Is RENDER sticky per session**, or does a pane always open in TUI?
377
  3. **Kill the `↑ ↓` turn stepper?** Proposal: yes — unfolding replaces it.
378
  4. **Unfolded card of a running agent**: live-refresh every 3 s, or freeze until it finishes
379
  (cheaper, less alive)?
@@ -418,7 +421,7 @@ Built (this branch):
418
  there is no transcript to read.
419
  3. `pageOf()` takes a negative offset — "the last N turns" without a round trip to learn `total`
420
  (`server/test/trace-tail.test.mjs`).
421
- 4. The session pane gets TUIRENDER. RENDER draws over the terminal, which stays mounted and
422
  connected but loses the keyboard; the mode is a per-session view preference in `localStorage`
423
  (`web/src/lib/paneMode.ts`), and its event reaches a pane that is already open — which is what
424
  the card's "full history ↗" needs.
@@ -430,9 +433,9 @@ Not yet, in the order I would do it:
430
  6. `head.prompts[]` (§5) — index, first line and timestamp per prompt, so a surface can draw the
431
  skeleton and label "show previous turn" before fetching the page that holds it.
432
  7. The sidebar loses its trace buttons and `openTrace`; share moves into the pane header (§3.4).
433
- 8. Windowing by exchange in RENDER mode: a collapsed turn is 2–3 rows, so the DOM stays small,
434
  `head.prompts[]` gives the skeleton up front, and only an opened turn needs its page. The
435
  measured-height machinery in `TraceView` is reused as-is — what changes is what a "row" means.
436
- Until then RENDER reads the last 400 turns in one request.
437
  9. The mobile card sizing block (§3.2) — the lab's `.lab-mfix` mirror of it is not the real
438
  `@media` rule, and the real one has not landed.
 
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, conversation 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.
 
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 conversation 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
 
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 conversation 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.
 
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: terminalconversation
187
 
188
+ The bottom bar gets a two-state control, next to the zoom:
189
 
190
  ```
191
+ [ terminal | conversation ] 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
+ - **conversation** — the same session, read: the exchange renderer over `/api/trace/:id`, at D3.
203
+ - The mode is a **view preference**, kept in `localStorage` for the app, not in the store.
204
+ - The terminal element stays mounted and connected underneath; the conversation draws over it. Toggling
205
  must not detach tmux — a reattach costs a repaint and can trip the handoff path
206
  (`HANDOFF_CODE`, `TerminalPane.tsx`). **Verify** this before shipping: xterm needs layout to
207
  fit, so "cover, don't unmount" is the low-risk option, and a refit on return is required.
208
+ - The conversation's toolbar is a second header row, not a squeeze into the first — on a phone the
209
  first row has no spare width. It carries only what is true of the whole session and said
210
  nowhere else: the model, `13 turns`, the token totals abbreviated (`2.2M↓ 654k↑`), `▲▼`, and
211
  the search box. The harness is the logo in the row above; the raw message count and the cached
 
219
  the nearest positioned ancestor and lands a few rows off.
220
  - Vocabulary: a **turn** is one exchange, a **message** is a raw transcript row. Each turn's meta
221
  line says `turn 6/13`, so the bar counts the same things the reader does.
222
+ - **The terminal must not keep the keyboard** while the conversation covers it. A mounted xterm with focus
223
  swallows keystrokes into the agent's TTY, invisibly — and several paths grab focus back (the
224
  pane becoming active, the header, the key bar), some of them *after* the mode changes, so the
225
  guard belongs at each call rather than at the switch.
 
236
  - **The conversation fills the pane.** A fixed reading column left a gutter of nothing on each
237
  side while the prompt band still spanned the full width, so the two disagreed about where the
238
  conversation began. The pane is the measure: narrow the pane and the conversation narrows.
239
+ - **The conversation can be replied to.** Reading a conversation and answering it are the same act — the
240
  card has always known that, and a rendered session that could only be read would send you back
241
+ to the terminal to type. It is the card's own composer (`.ov-live`), the same `sendInput`, and the
242
  same optimistic echo: your prompt appears at the bottom with a `working` line until the
243
  transcript catches up. Only a trace with **no agent behind it** — a shared file, an import — is
244
  read-only, which is what `readOnly` is for.
245
  - **Share** moves here from the sidebar. One session, one place.
246
+ - **Handover** ("continue from this trace in a new agent") lives in the conversation footer, beside
247
  the provenance line — the only place where its meaning is obvious.
248
 
249
  ### 3.4 What leaves the sidebar
250
 
251
  | Today | Tomorrow |
252
  |---|---|
253
+ | `Read this session's trace` on every agent row (`Sidebar.tsx:245`) | bottom bar → **conversation** |
254
  | `Share this session` on every agent row (`Sidebar.tsx:246`) | pane header → **share** |
255
  | A `trace` **session row per read** (`App.tsx:openTrace`) | **gone** — no duplicate rows |
256
  | Trace row's `Share` (`Sidebar.tsx:237`) | trace pane header (imported traces keep a pane) |
257
+ | Trace row's `Handover` (`Sidebar.tsx:238`) | conversation / trace-pane footer |
258
  | Quick-add **Trace** = open a shared dataset (`Sidebar.tsx:482`) | **stays** |
259
 
260
  The last row is deliberate: an **imported** trace has no session behind it, so it is a genuine
 
375
  ## 8. Open questions (operator)
376
 
377
  1. **How far back in the card?** Proposal: one exchange per click, hard cap 5, then hand off to
378
+ the conversation. Alternative: one, then straight to the full conversation.
379
+ 2. **Does the app open in conversation mode**, or always start on the terminal?
380
  3. **Kill the `↑ ↓` turn stepper?** Proposal: yes — unfolding replaces it.
381
  4. **Unfolded card of a running agent**: live-refresh every 3 s, or freeze until it finishes
382
  (cheaper, less alive)?
 
421
  there is no transcript to read.
422
  3. `pageOf()` takes a negative offset — "the last N turns" without a round trip to learn `total`
423
  (`server/test/trace-tail.test.mjs`).
424
+ 4. The session pane gets terminalconversation, from the bottom bar, app-wide. It draws over the terminal, which stays mounted and
425
  connected but loses the keyboard; the mode is a per-session view preference in `localStorage`
426
  (`web/src/lib/paneMode.ts`), and its event reaches a pane that is already open — which is what
427
  the card's "full history ↗" needs.
 
433
  6. `head.prompts[]` (§5) — index, first line and timestamp per prompt, so a surface can draw the
434
  skeleton and label "show previous turn" before fetching the page that holds it.
435
  7. The sidebar loses its trace buttons and `openTrace`; share moves into the pane header (§3.4).
436
+ 8. Windowing by exchange in conversation mode: a collapsed turn is 2–3 rows, so the DOM stays small,
437
  `head.prompts[]` gives the skeleton up front, and only an opened turn needs its page. The
438
  measured-height machinery in `TraceView` is reused as-is — what changes is what a "row" means.
439
+ Until then it reads the last 400 turns in one request.
440
  9. The mobile card sizing block (§3.2) — the lab's `.lab-mfix` mirror of it is not the real
441
  `@media` rule, and the real one has not landed.
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;
@@ -732,6 +738,7 @@ export default function App() {
732
  cli={cliMap[s.cli]}
733
  theme={theme}
734
  zoom={zoom}
 
735
  focused={shown && sessions.length > 1 && s.id === focusedId}
736
  visible={shown && deckVisible}
737
  active={shown && deckVisible && s.id === focusedId}
@@ -979,6 +986,15 @@ export default function App() {
979
  </span>
980
  )}
981
  <span className="spacer" />
 
 
 
 
 
 
 
 
 
982
  <button className="zbtn" title="Zoom out" onClick={() => setZoom((z) => Math.max(50, z - 10))}>−</button>
983
  <button className="zlvl" title="Reset to 100%" onClick={() => setZoom(100)}>{zoom}%</button>
984
  <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 the conversation in it.
83
+ // App-wide, like zoom, and remembered the same way.
84
+ const [paneMode, setPaneMode] = useState(readPaneMode);
85
+ useEffect(() => onPaneMode(setPaneMode), []);
86
+ const showPaneMode = (m: 'terminal' | 'conversation') => { 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;
 
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
+ {/* Reading mode sits with zoom because it is the same kind of
990
+ setting: how you are looking at everything, not what a
991
+ particular pane is. */}
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 === 'conversation' ? 'on' : ''} title="The conversation in it, rendered"
996
+ onClick={() => showPaneMode('conversation')}>conversation</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
@@ -72,9 +72,9 @@ function useConversationTail(id: string, on: boolean, live: boolean) {
72
  return { turns, missing };
73
  }
74
 
75
- /** Opening the pane on this session, in RENDER mode (§3.3 keeps it per session). */
76
  const openRendered = (id: string, onOpen: (sid: string) => void) => {
77
- writePaneMode(id, 'render'); // reaches a pane that is already open, too
78
  onOpen(id);
79
  };
80
 
 
72
  return { turns, missing };
73
  }
74
 
75
+ /** Opening this session's pane, reading the conversation rather than the TTY. */
76
  const openRendered = (id: string, onOpen: (sid: string) => void) => {
77
+ writePaneMode('conversation'); // app-wide, and it reaches open panes too
78
  onOpen(id);
79
  };
80
 
web/src/components/TerminalPane.tsx CHANGED
@@ -8,7 +8,6 @@ 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 { onPaneMode, readPaneMode, writePaneMode } from '../lib/paneMode';
12
  import { isPassive } from '../types';
13
  import type { PaneMode } from '../lib/paneMode';
14
  import { CloseGlyph, RefreshGlyph } from './icons';
@@ -181,7 +180,7 @@ if (typeof window !== 'undefined') {
181
  }
182
 
183
  export default function TerminalPane({
184
- session, cli, theme, focused, visible, active, zoom = 100, dragId, isMobile, onDragActive, onFocus, onRename, onClose,
185
  }: {
186
  session: Session;
187
  cli?: Cli;
@@ -190,6 +189,7 @@ export default function TerminalPane({
190
  visible?: boolean;
191
  active?: boolean;
192
  zoom?: number;
 
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;
@@ -201,8 +201,8 @@ export default function TerminalPane({
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>('tui');
205
- const focusTerm = () => { if (modeRef.current !== 'render') termRef.current?.focus(); };
206
  const frameRef = useRef<HTMLDivElement>(null);
207
  const termRef = useRef<Terminal | null>(null);
208
  const resyncRef = useRef<() => void>(() => {});
@@ -212,17 +212,12 @@ export default function TerminalPane({
212
  const controllerRef = useRef(false);
213
  const previousZoomRef = useRef(zoom);
214
  const [preview] = useState<TerminalPreview | null>(() => loadTerminalPreview(session.id));
215
- // TUI RENDER (docs/conversation-view.md §3.3). Only an agent has a
216
- // conversation to render: a shell is a shell, and files/trace panels are not
217
- // this component's business at all.
218
  const canRender = session.cli !== 'shell' && !isPassive(session.cli);
219
- const [mode, setMode] = useState<PaneMode>(() => (canRender ? readPaneMode(session.id) : 'tui'));
220
- useEffect(() => { setMode(canRender ? readPaneMode(session.id) : 'tui'); }, [session.id, canRender]);
221
- // Someone else can ask for RENDER on a pane that is already open — the card's
222
- // "full history ↗" does exactly that, and a localStorage write alone is silent.
223
- useEffect(() => (canRender ? onPaneMode(session.id, setMode) : undefined), [session.id, canRender]);
224
- const showMode = (m: PaneMode) => { setMode(m); writePaneMode(session.id, m); };
225
- modeRef.current = mode;
226
  // Send a raw byte string to the PTY (for the mobile key-bar: arrows, Esc…).
227
  const sendKeyRef = useRef<(d: string) => void>(() => {});
228
  const [conn, setConn] = useState<ConnState>('connecting');
@@ -814,9 +809,9 @@ export default function TerminalPane({
814
  // with focus swallows every keystroke into the agent's TTY, invisibly. Hand
815
  // focus back when the terminal is on top again.
816
  useEffect(() => {
817
- if (mode === 'render') termRef.current?.blur();
818
  else if (focused) termRef.current?.focus();
819
- }, [mode, focused]);
820
 
821
  // Focused panes tint toward THEIR agent's brand color, not the app accent.
822
  const tint = cli?.color;
@@ -862,23 +857,15 @@ export default function TerminalPane({
862
  <span className="ph-path" title={pathLabel}>{pathLabel}</span>
863
  {/* The trace stops being a separate thing you open: it is this
864
  session, read instead of watched. */}
865
- {canRender && (
866
- <span className="seg ph-modes" onMouseDown={(e) => e.stopPropagation()}>
867
- <button className={mode === 'tui' ? 'on' : ''} title="The terminal itself"
868
- onClick={(e) => { e.stopPropagation(); showMode('tui'); }}>TUI</button>
869
- <button className={mode === 'render' ? 'on' : ''} title="The conversation, rendered"
870
- onClick={(e) => { e.stopPropagation(); showMode('render'); }}>RENDER</button>
871
- </span>
872
- )}
873
  <button className="mini-btn ph-close" title="Close" onClick={(e) => { e.stopPropagation(); onClose(); }}><CloseGlyph /></button>
874
  </div>
875
  </div>
876
  <div className="term-host" ref={frameRef}>
877
  <div className="term-fill" ref={hostRef} />
878
- {/* RENDER draws OVER the terminal rather than replacing it: xterm needs
879
  layout to fit, and detaching tmux costs a repaint and can trip the
880
  handoff path. The terminal stays mounted and connected underneath. */}
881
- {mode === 'render' && (
882
  <div className="pane-render" onMouseDown={(e) => e.stopPropagation()}>
883
  <ConversationView session={session} paused={visible === false} isMobile={isMobile} />
884
  </div>
 
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';
 
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;
 
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 !== 'conversation') 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 show: 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 === 'conversation' && canRender;
220
+ modeRef.current = reading ? 'conversation' : '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');
 
809
  // with focus swallows every keystroke into the agent's TTY, invisibly. Hand
810
  // focus back when the terminal is on top again.
811
  useEffect(() => {
812
+ if (reading) termRef.current?.blur();
813
  else if (focused) termRef.current?.focus();
814
+ }, [reading, focused]);
815
 
816
  // Focused panes tint toward THEIR agent's brand color, not the app accent.
817
  const tint = cli?.color;
 
857
  <span className="ph-path" title={pathLabel}>{pathLabel}</span>
858
  {/* The trace stops being a separate thing you open: it is this
859
  session, read instead of watched. */}
 
 
 
 
 
 
 
 
860
  <button className="mini-btn ph-close" title="Close" onClick={(e) => { e.stopPropagation(); onClose(); }}><CloseGlyph /></button>
861
  </div>
862
  </div>
863
  <div className="term-host" ref={frameRef}>
864
  <div className="term-fill" ref={hostRef} />
865
+ {/* The conversation draws OVER the terminal rather than replacing it: xterm needs
866
  layout to fit, and detaching tmux costs a repaint and can trip the
867
  handoff path. The terminal stays mounted and connected underneath. */}
868
+ {reading && (
869
  <div className="pane-render" onMouseDown={(e) => e.stopPropagation()}>
870
  <ConversationView session={session} paused={visible === false} isMobile={isMobile} />
871
  </div>
web/src/components/conversation/ConversationView.tsx CHANGED
@@ -1,4 +1,4 @@
1
- // RENDER mode: a session's conversation, 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
@@ -107,7 +107,7 @@ export default function ConversationView({ session, paused, isMobile, readOnly,
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
 
 
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
 
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
 
web/src/components/conversation/Exchange.tsx CHANGED
@@ -15,10 +15,12 @@ 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 textOf = (t: TraceTurn | null) =>
19
- (t?.blocks || []).filter((b) => b.type === 'text').map((b) => ('text' in b ? b.text : '')).join('\n\n').trim();
20
- const moreOf = (t: TraceTurn | null) =>
21
- (t?.blocks || []).reduce((n, b) => n + (('more' in b && b.more) || 0), 0);
 
 
22
 
23
  const moreLabel = (more?: number) =>
24
  (more ? `+${more > 1024 ? `${Math.round(more / 1024)} KB` : `${more} chars`} not retained` : '');
@@ -190,6 +192,7 @@ export function ExchangeView({
190
 
191
  {/* Everything about the turn on ONE line, under the prompt: what the work
192
  was on the left, which turn it is on the right. Nothing above. */}
 
193
  <div className="cx-meta mono">
194
  {steps.length > 0 ? (
195
  <button className={`cx-fold${isOpen ? ' on' : ''}`} onClick={toggle} title={isOpen ? 'Hide the work' : 'Show the work'}>
@@ -208,6 +211,7 @@ export function ExchangeView({
208
  </>
209
  )}
210
  </div>
 
211
  {isOpen && steps.length > 0 && (
212
  <div className="cx-steps">{steps.map((s, i) => <StepRow key={i} s={s} q={q} />)}</div>
213
  )}
 
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` : '');
 
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'}>
 
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
  )}
web/src/components/conversation/exchanges.ts CHANGED
@@ -10,7 +10,8 @@ export interface Exchange {
10
  at: number;
11
  prompt: TraceTurn | null;
12
  steps: TraceTurn[];
13
- answer: TraceTurn | null;
 
14
  startTs: number;
15
  endTs: number;
16
  tokens: number;
@@ -34,7 +35,7 @@ export function splitExchanges(turns: TraceTurn[]): Exchange[] {
34
  let cur: Exchange | null = null;
35
  const open = (at: number, prompt: TraceTurn | null) => {
36
  cur = {
37
- key: `x${at}`, at, prompt, steps: [], answer: null,
38
  startTs: prompt?.ts || 0, endTs: prompt?.ts || 0, tokens: 0, toolCalls: 0,
39
  };
40
  out.push(cur);
@@ -63,20 +64,30 @@ export function splitExchanges(turns: TraceTurn[]): Exchange[] {
63
  const said = t.blocks.filter((b) => b.type !== 'text' || b.text.trim());
64
  return said.length > 0 && said[said.length - 1].type === 'text';
65
  };
 
66
 
67
  for (const x of out) {
68
- let at = -1;
69
- // A later `final` supersedes an earlier one (a resumed task can have two);
70
- // the earlier one stays in the work, where it happened.
 
 
 
 
 
 
 
 
 
 
 
71
  for (let i = x.steps.length - 1; i >= 0; i--) {
72
- if (x.steps[i].kind === 'final' && saidSomething(x.steps[i])) { at = i; break; }
 
 
 
 
73
  }
74
- // No `final` anywhere (some harnesses, some truncations): the trailing turn
75
- // is the answer if it ended on words. Showing none would be a lie; showing
76
- // an earlier one would be a different lie.
77
- const last = x.steps.length - 1;
78
- if (at < 0 && last >= 0 && saidSomething(x.steps[last]) && endedOnIt(x.steps[last])) at = last;
79
- if (at >= 0) { x.answer = x.steps[at]; x.steps.splice(at, 1); }
80
  }
81
  return out;
82
  }
 
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;
 
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);
 
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
  }
web/src/conversation.css CHANGED
@@ -187,7 +187,7 @@ mark.cx-hit.on { background: var(--accent); color: var(--panel); }
187
  .cs-detail { font-size: 12px; }
188
  }
189
 
190
- /* ---------- RENDER mode inside a session pane ---------- */
191
  /* Drawn over the live terminal, which stays mounted underneath. */
192
  .pane-render { position: absolute; inset: 0; z-index: 3; display: flex; background: var(--panel); }
193
  .pane-render > .cxv { flex: 1; min-width: 0; }
 
187
  .cs-detail { font-size: 12px; }
188
  }
189
 
190
+ /* ---------- conversation mode inside a session pane ---------- */
191
  /* Drawn over the live terminal, which stays mounted underneath. */
192
  .pane-render { position: absolute; inset: 0; z-index: 3; display: flex; background: var(--panel); }
193
  .pane-render > .cxv { flex: 1; min-width: 0; }
web/src/lib/paneMode.ts CHANGED
@@ -1,31 +1,27 @@
1
- // Which way a session's pane is being read: the terminal, or the conversation.
2
  // (docs/conversation-view.md §3.3)
3
  //
4
- // A view preference, per session, kept in localStorage rather than the store
5
- // it says nothing about the session itself, and it should survive a reload
6
- // without a round trip. The event exists because a pane that is ALREADY open
7
- // cannot see a write from somewhere else: the Overview card's "full history ↗"
8
- // sets the mode and then asks App to focus that pane.
9
- export type PaneMode = 'tui' | 'render';
10
 
11
- const KEY = 'am:pane-mode:';
12
  const EVENT = 'am:pane-mode';
13
 
14
- export function readPaneMode(id: string): PaneMode {
15
- try { return localStorage.getItem(KEY + id) === 'render' ? 'render' : 'tui'; } catch { return 'tui'; }
16
  }
17
 
18
- export function writePaneMode(id: string, mode: PaneMode): void {
19
- try { localStorage.setItem(KEY + id, mode); } catch { /* private mode: this session only */ }
20
- window.dispatchEvent(new CustomEvent(EVENT, { detail: { id, mode } }));
21
  }
22
 
23
- /** Called when someone else sets the mode for this session. */
24
- export function onPaneMode(id: string, apply: (m: PaneMode) => void): () => void {
25
- const h = (e: Event) => {
26
- const d = (e as CustomEvent<{ id: string; mode: PaneMode }>).detail;
27
- if (d && d.id === id) apply(d.mode);
28
- };
29
  window.addEventListener(EVENT, h);
30
  return () => window.removeEventListener(EVENT, h);
31
  }
 
1
+ // How every session pane is being read: the terminal, or the conversation.
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' | 'conversation';
 
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) === 'conversation' ? 'conversation' : '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/styles.css CHANGED
@@ -1062,6 +1062,10 @@ a.btn-ghost { text-decoration: none; }
1062
  /* mobile stage top bar (back + agent chips) — rendered only on mobile */
1063
  .mbar { display: flex; align-items: center; gap: 8px; padding: 0 0 8px; flex: none; }
1064
  .mback { flex: none; font-size: 19px; padding-bottom: 3px; }
 
 
 
 
1065
  .mtitle { font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1066
  .mchips { display: flex; gap: 6px; overflow-x: auto; flex: 1; padding: 2px; }
1067
  .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; }
 
1062
  /* mobile stage top bar (back + agent chips) — rendered only on mobile */
1063
  .mbar { display: flex; align-items: center; gap: 8px; padding: 0 0 8px; flex: none; }
1064
  .mback { flex: none; font-size: 19px; padding-bottom: 3px; }
1065
+ /* Reading mode, in the zoom bar: the same weight as the controls beside it. */
1066
+ .modebar { flex: none; margin-right: 6px; }
1067
+ .modebar button { padding: 2px 8px; font-size: 10.5px; letter-spacing: 0.02em; }
1068
+
1069
  .mtitle { font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1070
  .mchips { display: flex; gap: 6px; overflow-x: auto; flex: 1; padding: 2px; }
1071
  .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 CHANGED
@@ -30,7 +30,12 @@ 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
- const said = (turn) => (turn ? turn.blocks.filter((b) => b.type === 'text').map((b) => b.text).join('') : null);
 
 
 
 
 
34
  const kinds = (steps) => steps.map((s) => (s.kind === 'tools' ? `${s.name}×${s.count}` : s.kind));
35
 
36
  // ---------------------------------------------------------------- mid-task
@@ -43,7 +48,7 @@ const kinds = (steps) => steps.map((s) => (s.kind === 'tools' ? `${s.name}×${s.
43
  agent([text('There it is — a sync stat sweep.'), call('Read', { file_path: 'runner.js' }), result('statSync(f)')]),
44
  agent([call('Edit', { file_path: 'runner.js' }), result('Applied 1 edit')]),
45
  ]);
46
- assert.equal(x.answer, null, 'work in flight is not an answer');
47
  assert.deepEqual(kinds(stepsOf(x.steps)),
48
  ['note', 'Grep×1', 'note', 'Read×1', 'Edit×1'],
49
  'messages stay above the calls they introduced');
@@ -68,7 +73,7 @@ const kinds = (steps) => steps.map((s) => (s.kind === 'tools' ? `${s.name}×${s.
68
  user('go'),
69
  agent([text('One more check.'), call('Bash', { command: 'npm test' }), result('ok')]),
70
  ]);
71
- assert.equal(x.answer, null, 'ending on a tool call is not ending on words');
72
  }
73
 
74
  // ------------------------------------------------------- a superseded final
@@ -85,6 +90,24 @@ const kinds = (steps) => steps.map((s) => (s.kind === 'tools' ? `${s.name}×${s.
85
  assert.deepEqual(kinds(stepsOf(x.steps)), ['note', 'Bash×1'], 'the earlier final keeps its position');
86
  }
87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88
  // --------------------------------------------------------------- splitting
89
  // One exchange per operator prompt; harness-authored "user" lines are not
90
  // prompts, and neither is an interrupt marker.
 
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
 
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');
 
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
 
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.