Agent Manager commited on
Commit
7ef25ac
·
1 Parent(s): a5d1eab

Name the modes for their form: terminal ⇄ reader

Browse files

"Conversation" described the terminal just as well — both modes show the same
session; what differs is the form. Reader mode borrows the browser's idea: the
same content, laid out to be read. The toggle reads `terminal | reader`, the
type is `PaneMode = 'terminal' | 'reader'`, and the overlay is `.pane-reader`.

app-shots.mjs now resets the fleet before it runs — half its checks compare
pane counts, and previous runs left groups and spare shells behind.

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, 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,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 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,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 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,12 +183,12 @@ 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: terminal ⇄ conversation
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
@@ -199,13 +199,15 @@ pane with nothing to render (a shell) simply stays a terminal.
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,7 +221,7 @@ pane with nothing to render (a shell) simply stays a terminal.
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.
@@ -233,10 +235,10 @@ pane with nothing to render (a shell) simply stays a terminal.
233
  tool call is unusable while a task runs; one that never moves makes you chase it.
234
  - **The prompt band sticks to the top** while you read a long turn. What you want overhead deep
235
  in someone's 67-step answer is the question it is answering — not a row of numbers.
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
@@ -250,11 +252,11 @@ pane with nothing to render (a shell) simply stays a terminal.
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
@@ -376,7 +378,7 @@ the block model already supports.
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,7 +423,7 @@ Built (this branch):
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 terminal ⇄ conversation, 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,7 +435,7 @@ Not yet, in the order I would do it:
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.
 
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.
 
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
 
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.
 
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
 
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
 
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.
 
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
 
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
 
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)?
 
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.
 
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.
web/src/App.tsx CHANGED
@@ -79,11 +79,11 @@ export default function App() {
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;
@@ -986,14 +986,14 @@ export default function App() {
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>
 
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;
 
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>
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 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
 
 
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
 
web/src/components/TerminalPane.tsx CHANGED
@@ -202,7 +202,7 @@ export default function TerminalPane({
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>(() => {});
@@ -213,11 +213,11 @@ export default function TerminalPane({
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');
@@ -805,7 +805,7 @@ export default function TerminalPane({
805
  return () => clearTimeout(t);
806
  }, [active]);
807
 
808
- // Under RENDER the terminal is covered but still mounted — and a mounted xterm
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(() => {
@@ -862,11 +862,11 @@ export default function TerminalPane({
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>
872
  )}
 
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>(() => {});
 
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');
 
805
  return () => clearTimeout(t);
806
  }, [active]);
807
 
808
+ // In reader mode the terminal is covered but still mounted — and a mounted xterm
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(() => {
 
862
  </div>
863
  <div className="term-host" ref={frameRef}>
864
  <div className="term-fill" ref={hostRef} />
865
+ {/* Reader mode 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-reader" onMouseDown={(e) => e.stopPropagation()}>
870
  <ConversationView session={session} paused={visible === false} isMobile={isMobile} />
871
  </div>
872
  )}
web/src/conversation.css CHANGED
@@ -187,10 +187,10 @@ mark.cx-hit.on { background: var(--accent); color: var(--panel); }
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; }
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; }
 
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; }
web/src/lib/paneMode.ts CHANGED
@@ -1,17 +1,17 @@
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 {
 
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 {