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