Spaces:
Running
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 +29 -26
- web/src/App.tsx +16 -0
- web/src/components/Overview.tsx +2 -2
- web/src/components/TerminalPane.tsx +13 -26
- web/src/components/conversation/ConversationView.tsx +2 -2
- web/src/components/conversation/Exchange.tsx +8 -4
- web/src/components/conversation/exchanges.ts +23 -12
- web/src/conversation.css +1 -1
- web/src/lib/paneMode.ts +15 -19
- web/src/styles.css +4 -0
- web/test/exchanges.test.mjs +26 -3
|
@@ -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,
|
| 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
|
| 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
|
| 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:
|
| 187 |
|
| 188 |
-
The
|
| 189 |
|
| 190 |
```
|
| 191 |
-
|
| 192 |
```
|
| 193 |
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
- **
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
-
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|
| 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 |
-
- **
|
| 237 |
card has always known that, and a rendered session that could only be read would send you back
|
| 238 |
-
to 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
|
| 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`) |
|
| 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`) |
|
| 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 |
-
|
| 376 |
-
2. **
|
| 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
|
| 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
|
| 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
|
| 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: 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
|
| 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 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 |
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.
|
|
@@ -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>
|
|
@@ -72,9 +72,9 @@ function useConversationTail(id: string, on: boolean, live: boolean) {
|
|
| 72 |
return { turns, missing };
|
| 73 |
}
|
| 74 |
|
| 75 |
-
/** Opening
|
| 76 |
const openRendered = (id: string, onOpen: (sid: string) => void) => {
|
| 77 |
-
writePaneMode(
|
| 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 |
|
|
@@ -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>('
|
| 205 |
-
const focusTerm = () => { if (modeRef.current !== '
|
| 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 |
-
//
|
| 216 |
-
// conversation to
|
| 217 |
-
// this component's business at all.
|
| 218 |
const canRender = session.cli !== 'shell' && !isPassive(session.cli);
|
| 219 |
-
const
|
| 220 |
-
|
| 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 (
|
| 818 |
else if (focused) termRef.current?.focus();
|
| 819 |
-
}, [
|
| 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 |
-
{/*
|
| 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 |
-
{
|
| 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>
|
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
//
|
| 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 |
|
|
@@ -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
|
| 19 |
-
(
|
| 20 |
-
const
|
| 21 |
-
(t
|
|
|
|
|
|
|
| 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 |
)}
|
|
@@ -10,7 +10,8 @@ export interface Exchange {
|
|
| 10 |
at: number;
|
| 11 |
prompt: TraceTurn | null;
|
| 12 |
steps: TraceTurn[];
|
| 13 |
-
|
|
|
|
| 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:
|
| 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 |
-
|
| 69 |
-
//
|
| 70 |
-
// the
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
for (let i = x.steps.length - 1; i >= 0; i--) {
|
| 72 |
-
if (x.steps[i].kind === 'final' && saidSomething(x.steps[i])) {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
}
|
|
@@ -187,7 +187,7 @@ mark.cx-hit.on { background: var(--accent); color: var(--panel); }
|
|
| 187 |
.cs-detail { font-size: 12px; }
|
| 188 |
}
|
| 189 |
|
| 190 |
-
/* ----------
|
| 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; }
|
|
@@ -1,31 +1,27 @@
|
|
| 1 |
-
//
|
| 2 |
// (docs/conversation-view.md §3.3)
|
| 3 |
//
|
| 4 |
-
//
|
| 5 |
-
// it
|
| 6 |
-
//
|
| 7 |
-
//
|
| 8 |
-
|
| 9 |
-
export type PaneMode = 'tui' | 'render';
|
| 10 |
|
| 11 |
-
const KEY = 'am
|
| 12 |
const EVENT = 'am:pane-mode';
|
| 13 |
|
| 14 |
-
export function readPaneMode(
|
| 15 |
-
try { return localStorage.getItem(KEY
|
| 16 |
}
|
| 17 |
|
| 18 |
-
export function writePaneMode(
|
| 19 |
-
try { localStorage.setItem(KEY
|
| 20 |
-
window.dispatchEvent(new CustomEvent(EVENT, { detail:
|
| 21 |
}
|
| 22 |
|
| 23 |
-
/**
|
| 24 |
-
export function onPaneMode(
|
| 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 |
}
|
|
@@ -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; }
|
|
@@ -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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 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.
|
| 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.
|