diff --git a/docs/conversation-view.md b/docs/conversation-view.md index 9fb87c10..644eedd2 100644 --- a/docs/conversation-view.md +++ b/docs/conversation-view.md @@ -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 ` {open && (
- {/* 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 ? ( +
+ ) : null} {!!full && !!more &&
…{moreLabel(more)}
} {s.kind === 'tools' && s.blocks.map((b, i) => ( b.type === 'tool_use' ? ( @@ -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 = ( + <> + + {model && {model}} + {/* 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. */} + turn {padTurn(n ?? 0, total)}{total ? `/${total}` : ''} + {x.startTs ? {fmtClock(x.startTs)} : null} + + ); + const factsOnRunningRow = !summary && !!running && n != null; return (
{prompt ?
: 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)) && (
{steps.length > 0 ? (
)} {isOpen && stepsBefore.length > 0 && ( @@ -256,6 +290,7 @@ export function ExchangeView({ {running && (
working{latest && · {latest}} + {factsOnRunningRow && facts}
)}
diff --git a/web/src/components/conversation/exchanges.ts b/web/src/components/conversation/exchanges.ts index 891ffb1b..a6b8b959 100644 --- a/web/src/components/conversation/exchanges.ts +++ b/web/src/components/conversation/exchanges.ts @@ -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) => { diff --git a/web/src/conversation.css b/web/src/conversation.css index 6c4b5e69..7aa8a7c0 100644 --- a/web/src/conversation.css +++ b/web/src/conversation.css @@ -25,8 +25,16 @@ .cx-meta { display: flex; align-items: baseline; gap: 8px; min-width: 0; font-size: 0.85em; color: var(--muted); + /* the cell the fold's ▸ hangs in, so the summary starts on the text column — + one number, read by .cx-fold's negative margin and by the glyph's width */ + --cx-mark: calc(1em + 5px); } -.cx-meta .spacer { flex: 1; min-width: 4px; } +/* the same spacer serves both rows that carry the turn's facts: the meta row, + and the working line when the turn has no meta row of its own yet */ +.cx-meta .spacer, .cx-running .spacer { flex: 1; min-width: 4px; } +/* .cx-running has no `gap` on purpose — the spinner is a ::before flex item and + a gap would push `working` off the text column — so the facts space themselves */ +.cx-running .cx-model, .cx-running .cx-n, .cx-running .cx-time { flex: none; margin-left: 8px; } .cx-n, .cx-time, .cx-model { flex: none; font-variant-numeric: tabular-nums; } .cx-n { color: color-mix(in srgb, var(--accent) 65%, var(--muted)); } .cx-model { opacity: 0.8; } @@ -49,14 +57,24 @@ /* the fold control names what it hides — never a bare caret */ .cx-fold { - display: inline-flex; align-items: baseline; gap: 6px; min-width: 0; + display: inline-flex; align-items: baseline; gap: 5px; min-width: 0; background: none; border: none; padding: 0; margin: 0; + /* the ▸ hangs in the gutter (see below), so the summary starts on the column */ + margin-left: calc(-1 * var(--cx-mark)); font: inherit; color: var(--muted); cursor: pointer; text-align: left; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -/* the inset a folded row's ▸ takes up, so a flat summary starts at the same - column — in em, because the triangle is a glyph and grows with the text */ -.cx-fold.flat { cursor: default; padding-left: 1.75em; } +/* The triangle hangs in the gutter, so the SUMMARY starts on the text column — + the same column as the prompt's words, the answer's words and the step rows' + text. §3.2's rule is that the prompt's ❯ is the only thing outside that + column, and a mark that pushes its own row's text sideways breaks it: a turn + with no tool calls has no triangle, so its summary used to start 19px right of + everything else (the operator: "they are a bit to the right because of the + whitespace where the uncollapse arrow would be"). Reserving the gutter for a + control that cannot exist in that state was the bug; now nothing reserves it, + because the mark is outside the column in both states. */ +.cx-fold .cs-tri { width: 1em; } +.cx-fold.flat { cursor: default; margin-left: 0; } .cx-fold:hover { color: var(--text); } .cx-fold.on { color: var(--text); } /* the rail's triangle, borrowed into the smaller meta row: it steps down from @@ -92,12 +110,50 @@ min-width: 0; flex: 1; font-size: 0.96em; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -/* expanded in place: same font, same colour, just no longer cut */ -.cs-detail.full { white-space: pre-wrap; overflow: visible; text-overflow: clip; overflow-wrap: break-word; } +/* No `.full` variant any more: a text step used to expand IN the head row, and + now the row hands its text to the body to be rendered (see .cs-md below), so + the detail is always the one truncated line. */ .cs.note .cs-detail { color: var(--text); opacity: 0.78; } .cs-more { font-size: 0.77em; color: var(--muted); padding: 2px 0 0; } /* left inset = the rail column above it, so a body hangs under its own row */ .cs-body { padding: 2px 0 6px 1em; } +/* 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 the head row had + nothing left in it — and a row whose only content is a 14px triangle still + takes a line, which is the "empty line at the beginning" it read as. Nothing + was wrong with the markdown: no empty node, no uncollapsed margin, no leading + newline in the source (markdown drops those anyway). The blank line WAS the + row. So the triangle now sits beside the first line it belongs to, and the + gutter it lives in is what you press to close the step again. + `think` and `compact` keep the stacked layout: their row says `thinking` or + `context compacted`, so it is not an empty line. */ +.cs.note.open { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: start; } +.cs.note.open > .cs-head { grid-column: 1; width: auto; align-self: stretch; padding-right: 0; } +.cs.note.open > .cs-head .cs-detail { display: none; } +.cs.note.open > .cs-body { grid-column: 2; padding: 0 0 6px 0.45em; } + +/* Prose inside a step — an aside, thinking, a compaction — is the same markdown + as an answer, one register quieter. A step is a row in a rail, so its headings + step down to something that reads as emphasis rather than as a title of the + page, and the margins are tight enough that a three-line aside does not + present itself as a reply. Same resets as .cx-md: the shared .markdown card + brings a border, a background and 14px, none of which belong in a rail. */ +.markdown.cs-md { flex: none; border: none; background: none; padding: 0; font-size: 0.96em; line-height: 1.5; } +.cs-md > :first-child { margin-top: 0; } +.cs-md > :last-child { margin-bottom: 0; } +.cs-md p, .cs-md ul, .cs-md ol, .cs-md blockquote, .cs-md pre, .cs-md table { margin: 0 0 6px; } +.cs-md ul, .cs-md ol { padding-left: 1.5em; } +.cs-md h1, .cs-md h2, .cs-md h3, .cs-md h4 { margin: 7px 0 3px; line-height: 1.3; } +.cs-md h1 { font-size: 1.15em; } +.cs-md h2 { font-size: 1.08em; } +.cs-md h3, .cs-md h4 { font-size: 1em; } +.cs-md pre { font-size: 0.9em; } +/* and each kind keeps the colour it had as plain text, or a rendered aside would + read as the agent's reply and rendered thinking would stop looking like + thinking (the same two colours .cs-detail and .cs-pre.think already use) */ +.cs.think .cs-md { color: color-mix(in srgb, #8b73c4 70%, var(--text)); } +.cs.note .cs-md { color: var(--text); opacity: 0.85; } +.cs.compact .cs-md { color: var(--muted); } .cs-pre { white-space: pre-wrap; word-break: break-word; margin: 3px 0; padding: 6px 8px; background: var(--panel-2); border-radius: var(--r-sm); @@ -130,7 +186,10 @@ .cx-note { font-size: 0.81em; color: var(--muted); padding-top: 4px; } .cx-running { + /* the spinner is a mark, so it hangs in the gutter with the other marks and + `working` starts on the text column rather than a cell's width right of it */ display: flex; align-items: baseline; min-width: 0; font-size: 0.96em; color: var(--muted); + margin-left: calc(-1 * var(--mark-w)); } .cx-running-at { margin-left: 7px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; opacity: 0.85; } .cx-running::before { diff --git a/web/test/pendingExchange.test.mjs b/web/test/pendingExchange.test.mjs index b7d9ba91..290fc1f7 100644 --- a/web/test/pendingExchange.test.mjs +++ b/web/test/pendingExchange.test.mjs @@ -48,6 +48,15 @@ await build({ external: ['react', 'react-dom', 'react/jsx-runtime'], plugins: [stubMarkdown], }); const { PendingExchange, ExchangeView } = await import(pathToFileURL(out).href); +// stepSummary decides whether a turn has a left half at all, so the two live in +// the same check: no summary → the facts ride the working line. +const exOut = path.join(outDir, 'exchanges-meta.mjs'); +await build({ + entryPoints: [path.join(HERE, '../src/components/conversation/exchanges.ts')], + outfile: exOut, format: 'esm', bundle: false, logLevel: 'error', +}); +const { stepSummary, stepsOf: stepsOfEx } = await import(pathToFileURL(exOut).href); +const stepSummaryOf = (x) => stepSummary(x, stepsOfEx(x.steps)); let failed = 0; const check = (what, fn) => { @@ -146,6 +155,73 @@ check('and the padded cell is exactly as wide as the widest one', () => { check('a session that never reaches ten pads nothing', () => assert.equal(turnCell(3, 9), 'turn 3/9')); +console.log('\nand the meta row lines up in the two states that have no work'); +// Both reported from dev. A turn with nothing yet — no steps, no duration, no +// tokens — used to render an empty meta row above the working line, which read +// as the widget sitting "weirdly low"; and a finished turn with no tool calls +// used to indent its summary into the gutter where the expand triangle would be, +// for a triangle that cannot exist in that state. +const emptyTurn = { + key: 'x9', at: 9, startTs: 1_700_000_000_000, endTs: 1_700_000_000_000, tokens: 0, toolCalls: 0, + prompt: { role: 'user', ts: 1_700_000_000_000, blocks: [{ type: 'text', text: 'now look at the packer' }] }, + steps: [], answer: [], +}; +const quietTurn = { + ...emptyTurn, key: 'x8', at: 8, endTs: 1_700_000_013_000, tokens: 188, + prompt: { role: 'user', ts: 1_700_000_000_000, blocks: [{ type: 'text', text: "that's nice" }] }, + answer: [{ role: 'assistant', ts: 1_700_000_013_000, kind: 'final', blocks: [{ type: 'text', text: 'Quiet turn, then.' }] }], +}; +check('a turn with nothing yet has no summary to show', () => { + assert.equal(stepSummaryOf(emptyTurn), '', 'an empty turn should summarise to nothing'); +}); +check('so while it works, the facts ride the working line and there is no empty row', () => { + const html = render(ExchangeView, { x: emptyTurn, n: 9, total: 9, running: true }); + assert.ok(!html.includes('class="cx-meta'), 'no meta row should be rendered'); + const run = html.slice(html.indexOf('cx-running')); + assert.match(run, /class="cx-n"/, 'the turn number should be on the working line'); + assert.match(run, /class="cx-time"/, 'and the clock with it'); +}); +check('the working line is still the last thing in the turn', () => { + const html = render(ExchangeView, { x: emptyTurn, n: 9, total: 9, running: true }); + assert.ok(html.lastIndexOf('cx-running') > html.lastIndexOf('cx-prompt')); +}); +check('once there is a summary the facts are back on the meta row', () => { + const html = render(ExchangeView, { x: { ...emptyTurn, endTs: emptyTurn.startTs + 31_000, tokens: 4300 }, n: 9, total: 9, running: true }); + const meta = html.slice(html.indexOf('cx-meta'), html.indexOf('cx-running')); + assert.match(meta, /class="cx-n"/, 'the facts belong to the meta row as soon as it exists'); + const run = html.slice(html.indexOf('cx-running')); + assert.ok(!run.includes('class="cx-n"'), 'and not on the working line as well'); +}); +check('a finished turn with no tool calls says so with a flat fold', () => { + const html = render(ExchangeView, { x: quietTurn, n: 8, total: 9 }); + assert.match(html, /class="cx-fold flat"/, 'no steps means nothing to unfold'); + assert.match(html, /13s/); +}); + +console.log('\nand nothing reserves the gutter for a control that cannot exist'); +{ + const css = fs.readFileSync(path.join(HERE, '../src/conversation.css'), 'utf8'); + const rule = (sel) => { const i = css.indexOf(sel); return i < 0 ? null : css.slice(i, css.indexOf('}', i) + 1); }; + check('a flat fold has no left padding at all', () => { + const r = rule('.cx-fold.flat {'); + assert.ok(r, 'no .cx-fold.flat rule'); + assert.doesNotMatch(r, /padding-left/, `a flat summary must start on the text column: ${r}`); + }); + check('and an expandable one hangs its triangle by exactly the mark cell', () => { + assert.match(rule('.cx-fold {') || '', /margin-left:\s*calc\(-1 \* var\(--cx-mark\)\)/); + assert.match(rule('.cx-meta {') || '', /--cx-mark:/); + }); + check('the facts on the working line are pushed right, like on the meta row', () => { + // the DOM assertions above pass whether or not this exists; without it the + // row renders "workingturn 3/301:02 PM", which only a screenshot shows + assert.match(rule('.cx-meta .spacer, .cx-running .spacer {') || '', /flex:\s*1/); + assert.match(rule('.cx-running .cx-model, .cx-running .cx-n, .cx-running .cx-time {') || '', /margin-left/); + }); + check('the working line hangs its spinner the same way', () => { + assert.match(rule('.cx-running {') || '', /margin-left:\s*calc\(-1 \* var\(--mark-w\)\)/); + }); +} + console.log('\nand the CSS pair the nesting exists for is still a pair'); const css = fs.readFileSync(path.join(HERE, '../src/conversation.css'), 'utf8'); check('.cx pays out the gutter in em', () => assert.match(css, /\.cx\s*\{[^}]*padding:[^;]*1\.23em/)); diff --git a/web/test/stepMarkdown.test.mjs b/web/test/stepMarkdown.test.mjs new file mode 100644 index 00000000..387c9199 --- /dev/null +++ b/web/test/stepMarkdown.test.mjs @@ -0,0 +1,136 @@ +// Intermediate messages render as markdown — and a truncated one cannot take the +// panel down with it. +// +// Only the ANSWER used to be rendered (`highlightHtml(renderMarkdown(answer))`), +// so an agent's mid-turn message — which is written with headings, lists, code +// spans and links like everything else it writes — was shown as raw syntax. The +// prose kinds (`note`, `think`, `compact`) now take the same path. +// +// Two things are worth pinning rather than eyeballing: +// 1. these messages carry a `more` tail, so `full` can end mid-fence or +// mid-table, and a renderer that emits an unclosed
 would swallow
+//      everything after it in the panel;
+//   2. the rendered prose must sit in the step's BODY, not in its head row —
+//      that row is a