Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 40 additions & 3 deletions docs/conversation-view.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,9 +128,33 @@ Rules that keep this from becoming the ugly viewer in a small box:
- **One left column, two meanings.** A tool reports its outcome there (`✓` / `✗`); everything
else offers a disclosure triangle, greyed when there is nothing more to see. The fold control
above uses the same column and the same triangle, so it reads as the head of the list.
- **Expanding never repeats itself.** Text steps (thinking, an aside, a compaction) simply stop
being truncated — same font, same colour, more of it. Only a tool has genuinely *different*
material below: its input and what came back.
- **Expanding never repeats itself.** A text step's one-line preview is the head row's whole
content while it is shut; opened, that row hands the text over and the step's body carries it
**rendered as markdown**. An agent writes an aside the way it writes an answer — headings, lists,
code spans, links — and for a long time only the answer was rendered, so the middle of a turn
showed its syntax raw (`## Plan`, `[the docs](https://…)`). The prose kinds — `note`, `think`,
`compact`, named by `proseOf()` — take the same path the answer does,
`highlightHtml(renderMarkdown(text), q)`, so a search term is still marked inside the rendered
blocks. Three consequences worth stating:
- **It renders in the body, not in the head row.** That row is a `<button>`; markdown carries
links and block elements, and neither is valid — or clickable — inside one.
- **An open `note` is two columns**, the disclosure gutter and the prose beside it. A note has
no label, so once its text moved to the body its head row held nothing but a triangle — and a
row whose only content is a triangle still takes a line, which read as *"it adds an empty line
at the beginning when expanded"*. Nothing was wrong with the rendering: no empty node, no
uncollapsed margin, and a leading newline in the source is dropped by markdown anyway (all
three are pinned in `stepMarkdown.test.mjs`). The blank line **was** the row. `think` and
`compact` keep the stacked layout, because their row says `thinking` or `context compacted`
and is therefore not an empty line.
- **The collapsed preview keeps its syntax.** `## Plan` says the message opens with a heading
and ` ``` ` says a code block is coming, which is more than `Plan` tells you, and stripping
it would mean a second markdown pass over a string that may be cut mid-token.
- **A cut message cannot take the panel with it.** These steps carry a `more` tail, so the text
can end mid-fence or mid-table; `marked` closes both itself and DOMPurify reparses what it
emits, so no unclosed block escapes to swallow what follows. Pinned in
`web/test/stepMarkdown.test.mjs`.
What must stay literal, stays literal: a tool's input is JSON, a shell's output and a tool result
are terminal bytes where two spaces mean two spaces, and an image is an image.
- Consecutive calls to the same tool collapse (`✓ Read ×4 App.tsx, api.ts, +2`). The grouping
logic exists — `ToolGroup` in `TracePane.tsx:86` — and gets reused, not rewritten.
- **Thinking is one line** with a preview; system/harness turns are not shown at all in the card
Expand Down Expand Up @@ -262,6 +286,19 @@ pane with nothing to render (a shell) simply stays a terminal.
two runs of steps with the answer between them. Grouping runs over each side separately, so two
`Read`s either side of the reply stay two rows rather than collapsing into `Read ×2` and erasing
the sequence.
- **One column, and only marks outside it.** The prompt's `❯`, a fold's `▸` and the working line's
spinner all hang in the gutter; the summary, the answer, the step rows and the word `working` all
start on the same text column. A mark that sits *inside* the column pushes its own row's text
sideways, which is what made a turn with no tool calls indent its `13s · 188 tok` by 19px — the
row reserved the gutter for a triangle that cannot exist in that state (`.cx-fold.flat` used to
pay `padding-left: 1.75em`). The cell is one number, `--cx-mark` on `.cx-meta`, read by both the
hang and the glyph's width so they cannot drift apart.
- **A turn with nothing yet has no meta row.** No steps, no duration, no tokens means no left half,
and rendering the row anyway left an empty line above the working line — which read as the widget
sitting low, dropped rather than placed. In that state the turn's facts ride on the working line
itself, so there is one row instead of one and a half. The working line does not move for this:
it is the last row at the text column either way, before and after the first step arrives
(checked live, not inferred — the facts move up into the new meta row, the line stays put).
- **One working line, and it is the last thing in the reader.** It carries what the agent is doing
*now* (`working · Bash …`), so it belongs at the end of the rail it continues — putting it above
the steps would report the present before the past, which is the same disorder as the bullet
Expand Down
2 changes: 1 addition & 1 deletion web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"dev": "vite",
"build": "tsc --noEmit && vite build",
"typecheck": "tsc --noEmit",
"test": "node test/statusMark.test.mjs && node test/mobileBack.test.mjs && node test/pendingExchange.test.mjs && node test/composerAlign.test.mjs && node test/fileWrapToggle.test.mjs && node test/exchanges.test.mjs && node test/sessionTitle.test.mjs && node test/overviewSort.test.mjs && node test/drafts.test.mjs && node test/settingsMobile.test.mjs && node test/traceWindows.test.mjs && node test/sidebar-dnd.test.mjs",
"test": "node test/stepMarkdown.test.mjs && node test/statusMark.test.mjs && node test/mobileBack.test.mjs && node test/pendingExchange.test.mjs && node test/composerAlign.test.mjs && node test/fileWrapToggle.test.mjs && node test/exchanges.test.mjs && node test/sessionTitle.test.mjs && node test/overviewSort.test.mjs && node test/drafts.test.mjs && node test/settingsMobile.test.mjs && node test/traceWindows.test.mjs && node test/sidebar-dnd.test.mjs",
"test:render": "node test/statusMark.render.test.mjs",
"preview": "vite preview"
},
Expand Down
73 changes: 54 additions & 19 deletions web/src/components/conversation/Exchange.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import type { ReactNode } from 'react';
import type { TraceTurn } from '../../api';
import { renderMarkdown } from '../../lib/markdown';
import type { Exchange, Step } from './exchanges';
import { fmtClock, fmtDur, fmtTok, oneLine, stepSummary, stepText, stepsOf } from './exchanges';
import { fmtClock, fmtDur, fmtTok, oneLine, proseOf, stepSummary, stepText, stepsOf } from './exchanges';
import ToolCall from './ToolCall';

const blocksOf = (t: TraceTurn | TraceTurn[] | null) =>
Expand Down Expand Up @@ -113,7 +113,23 @@ function StepRow({ s, q }: { s: Step; q?: string }) {
: s.kind === 'shell' ? !!s.out.trim()
: s.kind === 'image' ? true
: full.trim().length > preview.length || more > 0;
const shown = open && full ? full : preview;
// Prose kinds — an aside, thinking out loud, a compaction — are markdown, and
// an agent writes them like markdown: headings, lists, code spans, links. Only
// the ANSWER used to be rendered, so the middle of a turn showed its syntax
// raw. It renders through the same path the answer takes, highlight and all.
//
// Not in the head row: that row is a <button>, and markdown carries links and
// block elements, neither of which is valid — or clickable — inside one. So the
// row keeps its one-line preview and the rendered prose goes in the body, the
// way every other expanded step already works (a tool's input, a shell's
// output). `shown` therefore stays the preview at every state.
const prose = proseOf(s);
const proseHtml = useMemo(
() => (open && prose.trim() ? highlightHtml(renderMarkdown(prose), q) : ''), [open, prose, q]);
// Open, the head row stops carrying the text: the rendered prose is below it,
// and the one-line preview above that would be the same words twice — once as
// syntax. Collapsed it is the row's whole content, so it stays.
const shown = proseHtml ? '' : preview;

return (
<div className={`cs${open ? ' open' : ''} ${s.kind}`}>
Expand All @@ -122,11 +138,17 @@ function StepRow({ s, q }: { s: Step; q?: string }) {
? <span className={`cs-mark ${s.failed ? 'bad' : 'ok'}`}>{s.failed ? '✗' : '✓'}</span>
: <span className={`cs-tri${can ? '' : ' off'}`}>{open ? '▾' : '▸'}</span>}
{label && <span className="cs-label mono">{label}</span>}
<span className={`cs-detail${open && full ? ' full' : ''}`}><Hi text={shown} q={q} /></span>
{shown ? <span className="cs-detail"><Hi text={shown} q={q} /></span> : <span className="cs-detail" />}
</button>
{open && (
<div className="cs-body">
{/* the text already expanded above; only its cut tail is left to say */}
{/* Rendered, not raw. A truncated message can end mid-fence or
mid-table; marked closes both itself and DOMPurify reparses what it
emits, so a cut tail cannot leave an open block that swallows the
rest of the panel — pinned in test/stepMarkdown.test.mjs. */}
{proseHtml ? (
<div className="markdown cs-md" dangerouslySetInnerHTML={{ __html: proseHtml }} />
) : null}
{!!full && !!more && <div className="cs-more mono">…{moreLabel(more)}</div>}
{s.kind === 'tools' && s.blocks.map((b, i) => (
b.type === 'tool_use' ? (
Expand Down Expand Up @@ -203,14 +225,38 @@ export function ExchangeView({
// Naming the model on every turn is noise when it never changes; when it DOES
// change mid-session that is worth a word, so say it only then.
const model = x.model && x.model !== baseModel ? x.model : '';
// Which turn, when, and on what — the viewer's business. A card shows one
// turn, dated in its own header, so it says none of this. It is one fragment
// because two rows can carry it: the meta row normally, and the working line
// when the turn has nothing else to say yet.
const facts = (
<>
<span className="spacer" />
{model && <span className="cx-model">{model}</span>}
{/* Padded to the width of the total, in FIGURE spaces: the row is mono and
tabular, so one figure space is exactly one digit, and "turn 9/10"
lines up under "turn 10/10" instead of the whole right cluster stepping
left the moment a session passes nine turns. A plain space would
collapse in HTML. */}
<span className="cx-n">turn {padTurn(n ?? 0, total)}{total ? `/${total}` : ''}</span>
{x.startTs ? <span className="cx-time">{fmtClock(x.startTs)}</span> : null}
</>
);
const factsOnRunningRow = !summary && !!running && n != null;

return (
<section className={`cx${dim ? ' dim' : ''}`}>
{prompt ? <div className="cx-prompt"><Hi text={prompt} q={q} /></div> : null}

{/* Everything about the turn on ONE line, under the prompt: what the work
was on the left, which turn it is on the right. Nothing above. */}
{(summary || n != null) && (
was on the left, which turn it is on the right. Nothing above.
A turn that has not done anything yet has no left half — no steps, no
duration, no tokens — and rendering the row anyway left an empty line
above the `working` line, which is what read as the widget sitting
"weirdly low". In that state the facts ride on the working line itself,
so there is one row instead of one and a half. The working line does not
move: it is the last row either way, at the text column. */}
{(summary || (n != null && !factsOnRunningRow)) && (
<div className="cx-meta mono">
{steps.length > 0 ? (
<button className={`cx-fold${isOpen ? ' on' : ''}`} onClick={toggle} title={isOpen ? 'Hide the work' : 'Show the work'}>
Expand All @@ -220,19 +266,7 @@ export function ExchangeView({
) : <span className="cx-fold flat">{summary}</span>}
{/* Which turn, when, and on what — the viewer's business. A card shows one
turn, dated in its own header, so it says none of this. */}
{n != null && (
<>
<span className="spacer" />
{model && <span className="cx-model">{model}</span>}
{/* Padded to the width of the total, in FIGURE spaces: the row is
mono and tabular, so one figure space is exactly one digit, and
"turn 9/10" lines up under "turn 10/10" instead of the whole
right cluster stepping left the moment a session passes nine
turns. A plain space would collapse in HTML. */}
<span className="cx-n">turn {padTurn(n, total)}{total ? `/${total}` : ''}</span>
{x.startTs ? <span className="cx-time">{fmtClock(x.startTs)}</span> : null}
</>
)}
{n != null && facts}
</div>
)}
{isOpen && stepsBefore.length > 0 && (
Expand All @@ -256,6 +290,7 @@ export function ExchangeView({
{running && (
<div className="cx-running mono">
working{latest && <span className="cx-running-at">· {latest}</span>}
{factsOnRunningRow && facts}
</div>
)}
</section>
Expand Down
12 changes: 12 additions & 0 deletions web/src/components/conversation/exchanges.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,18 @@ export type Step =
| { kind: 'image'; src: string }
| { kind: 'compact'; text: string };

/**
* The prose a step carries, or '' when it carries none.
*
* These three kinds are the agent writing for a reader — an aside, thinking out
* loud, a compaction summary — and it writes them in markdown, so the reader
* renders them. The others are not prose and must stay literal: a tool's input
* is JSON, a shell's output and a tool result are terminal bytes where two
* spaces mean two spaces, and an image is an image.
*/
export const proseOf = (s: Step): string =>
(s.kind === 'think' || s.kind === 'note' || s.kind === 'compact' ? s.text : '');

// The one field of a tool call worth a line: what it acted on.
const ARG_KEYS = ['file_path', 'path', 'notebook_path', 'command', 'pattern', 'glob', 'url', 'query', 'prompt', 'description', 'subagent_type'];
const shortPath = (p: string) => {
Expand Down
Loading