diff --git a/ui-tui/src/__tests__/episodeView.test.tsx b/ui-tui/src/__tests__/episodeView.test.tsx index 4b65e395c..f3f7b932a 100644 --- a/ui-tui/src/__tests__/episodeView.test.tsx +++ b/ui-tui/src/__tests__/episodeView.test.tsx @@ -12,6 +12,7 @@ import type { Episode, EpisodeTool, Msg } from '../types.js' import { $directChat, viewKeyOf } from '../app/directChatStore.js' import { resetFolds, toggleFold } from '../app/foldStore.js' import { EpisodeMessage, EpisodeView, turnFoldScope } from '../components/episodeView.js' +import { MESSAGE_FOLD_KEEP_OPEN_CHARS } from '../config/limits.js' import { foldDirectTurns } from '../domain/directEpisodes.js' import { TOOL_PREVIEW_ROWS } from '../domain/episodeSummary.js' import { TRANSCRIPT_GUTTER_INSET, transcriptGutterWidth } from '../lib/inputMetrics.js' @@ -756,3 +757,92 @@ describe('the reasoning row once the model has stopped', () => { expect(settled({ reasoningMs: 12_000 })).not.toContain('weighing the two shapes') }) }) + +/* Three defaults the transcript's reader asked for: an agent message of two or + more rendered lines keeps the work it introduced open, a delegation stays + visible while the turn is still running, and a trace box does not print a + line of the model's reasoning unasked. */ +describe('what the transcript folds by default', () => { + const LONG = 'x'.repeat(MESSAGE_FOLD_KEEP_OPEN_CHARS) + + it('keeps the work open when the message that introduced it runs to two lines', () => { + const episodes: Episode[] = [ + { index: 0, narration: LONG, reasoning: '', tools: [] }, + { index: 1, narration: '', reasoning: '', tools: [call('a', 'exec', 'ls'), call('b', 'exec', 'pwd')] } + ] + + const f = view(episodes) + + // Both calls on their own rows under the summary, rather than the summary + // alone standing in for them. + expect(f).toContain('ls') + expect(f).toContain('pwd') + }) + + it('still folds the work under a message shorter than that', () => { + const episodes: Episode[] = [ + { index: 0, narration: 'x'.repeat(MESSAGE_FOLD_KEEP_OPEN_CHARS - 1), reasoning: '', tools: [] }, + { index: 1, narration: '', reasoning: '', tools: [call('a', 'exec', 'ls'), call('b', 'exec', 'pwd')] } + ] + + const f = view(episodes) + + expect(f).toContain('ran 2 commands') + }) + + it('lets the reader fold the work under a long message by hand', () => { + /* The message rule picks the untouched default; a reader's close is + `foldStore`'s and outranks it. Driven through `toggleFold` with the same + default the row's own click passes, not through a seeded key. */ + resetFolds() + toggleFold('', 'seg:a', true) + const episodes: Episode[] = [ + { index: 0, narration: LONG, reasoning: '', tools: [] }, + { index: 1, narration: '', reasoning: '', tools: [call('a', 'exec', 'ls'), call('b', 'exec', 'pwd')] } + ] + + const f = view(episodes) + + expect(f).toContain('ran 2 commands') + expect(f).not.toContain('pwd') + resetFolds() + }) + + it('keeps every delegation in a running stretch, not only the latest call', () => { + // `read_file` after the two graphs leaves both dag calls behind the call in + // hand. The folded live view used to render a panel for the latest tool + // only, so the earlier delegations disappeared while the turn ran. + const second = { ...DAG_RUN, runId: 'dag-2' } + const episodes: Episode[] = [ + { + index: 0, + narration: '', + reasoning: '', + tools: [ + call('s1', 'run_subagent_dag', 'first graph', { dag: DAG_RUN }), + call('s2', 'run_subagent_dag', 'second graph', { dag: second }), + call('t1', 'read_file', 'notes.md', { done: false }) + ] + } + ] + + const f = view(episodes, { live: true }) + + expect(f).toContain('dag-1') + expect(f).toContain('dag-2') + }) + + it('does not print a line of reasoning in a trace box unless it is opened', () => { + const THOUGHT = 'the model weighs the two options and picks the safer one' + const episodes: Episode[] = [{ index: 0, narration: '', reasoning: THOUGHT, tools: [] }] + + // Dense is the trace-box rendering. Folded, the row names the thought and + // nothing else; the text is behind the row's own toggle. + const folded = view(episodes, { dense: true }) + expect(folded).toContain('thought') + expect(folded).not.toContain(THOUGHT) + + const open = view(episodes, { dense: true, openKeys: ['rsn:0'] }) + expect(open).toContain(THOUGHT) + }) +}) diff --git a/ui-tui/src/__tests__/virtualHeights.test.ts b/ui-tui/src/__tests__/virtualHeights.test.ts index e5f666afb..ee5b2b3c1 100644 --- a/ui-tui/src/__tests__/virtualHeights.test.ts +++ b/ui-tui/src/__tests__/virtualHeights.test.ts @@ -10,7 +10,7 @@ import { $directChat, viewKeyOf } from '../app/directChatStore.js' import { $folds, callFolds, resetFolds, toggleFold } from '../app/foldStore.js' import { turnFoldScope } from '../components/episodeView.js' import { MessageLine } from '../components/messageLine.js' -import { DAG_TRACE_BOX_ROWS } from '../config/limits.js' +import { DAG_TRACE_BOX_ROWS, MESSAGE_FOLD_KEEP_OPEN_CHARS } from '../config/limits.js' import { layoutDagGraph } from '../lib/dagGraphLayout.js' import { dagNodeKey } from '../lib/dagOpenNodes.js' import { estimatedMsgHeight, messageHeightKey, wrappedLines } from '../lib/virtualHeights.js' @@ -376,3 +376,45 @@ describe('estimatedMsgHeight covers an open tool card', () => { ) }) }) + +// A message of two or more rendered lines keeps its stretch open by default, +// and the reader can still shut it. Both states have to measure the same rows +// the renderer draws, or the virtualizer reserves the wrong height. +describe('estimatedMsgHeight follows the message rule', () => { + const scope = turnFoldScope(viewKeyOf($directChat.get().active), 't1') + const msg = (narration: string): Msg => ({ + episodes: [ + { index: 0, narration, reasoning: '', tools: [] }, + { + index: 1, + narration: '', + reasoning: '', + tools: [ + { done: true, id: 'a', name: 'exec', ok: true, summary: 'ls' }, + { done: true, id: 'b', name: 'exec', ok: true, summary: 'pwd' } + ] + } + ], + foldId: 't1', + kind: 'episodes', + role: 'assistant', + text: '' + }) + const estimate = (m: Msg) => + estimatedMsgHeight(m, 92, { ...BASE, cardFolds: callFolds($folds.get(), scope) }) + + beforeEach(resetFolds) + + it('reserves the open rows, then the folded row once the reader shuts it', async () => { + const m = msg('x'.repeat(MESSAGE_FOLD_KEEP_OPEN_CHARS)) + + const open = estimate(m) + expect(open).toBeGreaterThanOrEqual(await renderedRows(m, 92)) + + toggleFold(scope, 'seg:a', true) + + const closed = estimate(m) + expect(closed).toBeGreaterThanOrEqual(await renderedRows(m, 92)) + expect(closed).toBeLessThan(open) + }) +}) diff --git a/ui-tui/src/components/episodeView.tsx b/ui-tui/src/components/episodeView.tsx index d1a581c55..4788a1a58 100644 --- a/ui-tui/src/components/episodeView.tsx +++ b/ui-tui/src/components/episodeView.tsx @@ -17,6 +17,7 @@ import { cardDefaultOpen, detailBlockShape, failureNote, + keepsWorkOpen, segmentTurn, toolArgument, toolParts, @@ -47,6 +48,10 @@ const INDENT = transcriptGutterWidth('assistant', '') const STEP = 2 const MIN_TAIL = 12 +/* A call whose result is a panel. Its title box is the only row that names it, + so every rule that keeps a delegation visible reads this one. */ +const isPanelTool = (tool: EpisodeTool) => Boolean(tool.dag || tool.spawn) + // The part of a click a fold control needs. Declared structurally rather than // imported: the fork does not export its ClickEvent, and appLayout.tsx already // names its own slice of the same event this way. @@ -533,7 +538,7 @@ const WorkSegment = memo(function WorkSegment({ // open: a call with no argument and no output renders no block, and a blank row // above nothing is a one-line band spending two rows. const callRow = (tool: EpisodeTool, depth: number, onToggle: () => void, opened = false) => { - if (tool.dag || tool.spawn) { + if (isPanelTool(tool)) { return null } @@ -622,14 +627,16 @@ const WorkSegment = memo(function WorkSegment({ ) } - // ── Running: the summary so far, plus only the call in hand ── + // ── Running: the summary so far, the call in hand, and every delegation ── // // Quiet by default and openable all the same. What a reader wants from a call // is most urgent while it runs -- the command a two-minute `exec` is actually // running is a question that cannot wait for it to finish -- so the rows here // carry the same toggles the settled ones do. Only the default differs: the - // stretch stays compressed to the summary and the call in hand until the - // reader opens it (see `detailDefaultOpen`, which does not fire in flight). + // stretch stays compressed to the summary, the call in hand and a panel per + // delegation until the reader opens it (see `detailDefaultOpen`, which does + // not fire in flight). A delegation gets its panel at this depth because the + // panel is the only row that names it. if (inFlight) { const latest = [...tools].reverse().find(tool => !tool.done) ?? tools[tools.length - 1]! const done = totalDurationMs(tools.filter(tool => tool.done)) @@ -693,8 +700,16 @@ const WorkSegment = memo(function WorkSegment({ width={width} /> )} - {dagFor(latest, INDENT + STEP)} - {spawnFor(latest, INDENT + STEP)} + {/* Every delegation in the stretch, not only the one in hand: a + spawn or dag panel is the only place the call is named, so a + later plain call must not fold an earlier delegation out of + sight while the turn runs. */} + {tools.filter(isPanelTool).map(tool => ( + + {dagFor(tool, INDENT + STEP)} + {spawnFor(tool, INDENT + STEP)} + + ))} {cardOpen(latest) ? detailFor(latest, INDENT + STEP, () => toggleCall(latest.id)) : null} )} @@ -774,9 +789,10 @@ export const EpisodeView = memo(function EpisodeView({ closedKeys?: readonly string[] cols?: number compact?: boolean - /** Trace-box rendering: no breathing rows between segments, and a static - * reasoning row carries a tail of its text instead of standing bare. The - * height estimator's own `dense` must agree (see `estimatedMsgHeight`). */ + /** Trace-box rendering: no breathing rows between segments. A folded + * reasoning row stays a bare label here -- the reader opened the box to + * watch the run, not to read the scratch work. The height estimator's own + * `dense` must agree (see `estimatedMsgHeight`). */ dense?: boolean episodes: Episode[] live?: boolean @@ -877,9 +893,13 @@ export const EpisodeView = memo(function EpisodeView({ onToggle={() => toggle(`rsn:${ep.index}`)} running={thinking} t={t} - // In a trace box the row is static, so without the tail it reads - // as a bare label -- the one word "reasoning" says nothing. - tail={(thinking || dense) && !rsnOpen ? reasoning : ''} + // Only the live row shows a tail. A trace box renders a settled + // run, where the reader asked to watch the run, not to read the + // model's scratch work: the row names the thought and how long it + // took, and its text is one click away. Printing a line of it + // there is the reasoning expanded by default, which is exactly + // what this view must not do. + tail={thinking && !rsnOpen ? reasoning : ''} time={durationLabel(reasoningMs, Boolean(thinking))} width={width} /> @@ -909,11 +929,11 @@ export const EpisodeView = memo(function EpisodeView({ ) } - const renderWork = (seg: Extract) => { + const renderWork = (seg: Extract, keepOpen: boolean) => { // The tools whose result is a panel: a summary line cannot say which node // failed or what a delegated run is doing, so a stretch holding one opens // itself, and folding it by hand still leaves the panel. - const hasPanel = seg.tools.some(tool => tool.dag || tool.spawn) + const hasPanel = seg.tools.some(isPanelTool) // A solo stretch has no "expanded" depth of its own: its fold state gates the // detail block directly (WorkSegment's solo branch above), so for one call // this state IS the card's own fold, and the card's predicate is what answers @@ -933,16 +953,23 @@ export const EpisodeView = memo(function EpisodeView({ // unfolds itself as the calls land is the churn the compressed live view // exists to avoid. A reader's own open still stands -- `foldOpen` reads it // first. + // + // `keepOpen` is the message rule: a multi-call stretch introduced by an + // agent message of two or more rendered lines opens on its own, so the rows + // the message led to are readable beside it (see `keepsWorkOpen`). A solo + // call is already one visible row, so the rule leaves its detail block to + // the card's own predicate (`detailDefaultOpen`): a call with content opens + // its detail, one without a block opens nothing. const detailDefaultOpen = seg.tools.length === 1 ? !hasPanel && Boolean(seg.tools[0]!.done) && cardDefaultOpen(seg.tools[0]!) - : hasPanel && !seg.live + : (hasPanel || keepOpen) && !seg.live return ( 0 && !dense ? 1 : 0} > - {seg.kind === 'talk' ? renderTalk(seg.episode) : renderWork(seg)} + {seg.kind === 'talk' ? renderTalk(seg.episode) : renderWork(seg, keepsWorkOpen(segments, i))} ))} diff --git a/ui-tui/src/config/limits.ts b/ui-tui/src/config/limits.ts index 032019925..bdda6a1b2 100644 --- a/ui-tui/src/config/limits.ts +++ b/ui-tui/src/config/limits.ts @@ -26,6 +26,14 @@ export const LONG_MSG = 300 export const MAX_HISTORY = 800 export const THINKING_COT_MAX = 160 +// An agent message this long keeps the work it introduced open rather than +// folding into a one-line summary. A character count, not measured rows: the +// fold decision is made outside a layout pass and the renderer only has the +// string. 100 is about two rows in an ordinary 100-column terminal body +// (~50 double-width glyphs per row), which is the reading measure a message of +// "two lines" is judged by here. +export const MESSAGE_FOLD_KEEP_OPEN_CHARS = 100 + // Rows per wheel event (pre-accel). 1 keeps Ink's DECSTBM fast path live // (each scroll < viewport-1) and produces smooth motion. wheelAccel.ts // ramps this on sustained scrolls. diff --git a/ui-tui/src/domain/episodeSummary.ts b/ui-tui/src/domain/episodeSummary.ts index 7b6df84ab..9777e6280 100644 --- a/ui-tui/src/domain/episodeSummary.ts +++ b/ui-tui/src/domain/episodeSummary.ts @@ -4,6 +4,7 @@ import type { Episode, EpisodeTool } from '../types.js' +import { MESSAGE_FOLD_KEEP_OPEN_CHARS } from '../config/limits.js' import { hasMeaningfulReasoning } from '../lib/reasoning.js' import { claudeRule } from './claudeCodeTools.js' import { codexRule } from './codexTools.js' @@ -609,3 +610,20 @@ export const segmentTurn = (episodes: Episode[], liveIndex?: number): Segment[] return segments } + +/** + * Whether the work segment at `index` follows an agent message long enough + * that the work should not be folded away from it. + * + * The transcript folds "every call between two things the model said" into one + * summary row. That is a compression the reader can undo, except when the + * message introducing the stretch runs to two or more rendered lines: the + * message is the part worth reading, and the calls it led to are what makes it + * legible. Judged by character count rather than measured rows -- see + * `MESSAGE_FOLD_KEEP_OPEN_CHARS`. + */ +export const keepsWorkOpen = (segments: Segment[], index: number): boolean => { + const prev = segments[index - 1] + + return prev?.kind === 'talk' && (prev.episode.narration ?? '').trim().length >= MESSAGE_FOLD_KEEP_OPEN_CHARS +} diff --git a/ui-tui/src/lib/virtualHeights.ts b/ui-tui/src/lib/virtualHeights.ts index fd48cc9da..9ef2d29e1 100644 --- a/ui-tui/src/lib/virtualHeights.ts +++ b/ui-tui/src/lib/virtualHeights.ts @@ -8,7 +8,7 @@ import { stringWidth } from '@hermes/ink' import type { EpisodeTool, Msg } from '../types.js' import { EMPTY_CALL_FOLDS, type CallFolds } from '../app/foldStore.js' -import { cardDefaultOpen, detailBlockShape, foldedPreviewRows, segmentTurn } from '../domain/episodeSummary.js' +import { cardDefaultOpen, detailBlockShape, foldedPreviewRows, keepsWorkOpen, segmentTurn } from '../domain/episodeSummary.js' import { layoutDagGraph } from './dagGraphLayout.js' import { dagNodeToggleKey } from './dagOpenNodes.js' import { dagDetailRoom, dagRowDetail, dagTraceBoxRows, dagTraceRows, hasDagRowDetail } from './dagStatus.js' @@ -255,7 +255,9 @@ export const estimatedMsgHeight = ( // symptom. A stretch of work is no longer one row by construction: a settled // card opens on its own predicate, so its block is measured here the same way // it is drawn, and a talk segment is its reasoning row plus its wrapped prose. - for (const [i, seg] of segmentTurn(msg.episodes ?? []).entries()) { + const segments = segmentTurn(msg.episodes ?? []) + + for (const [i, seg] of segments.entries()) { h += i > 0 && !dense ? 1 : 0 if (seg.kind === 'work') { @@ -301,8 +303,10 @@ export const estimatedMsgHeight = ( h += 1 // Folded, a multi-call stretch is its summary row; the panels inside it - // still draw (WorkSegment's own defaultOpen branch). - if (!segIsOpen(seg.key, hasPanel && !seg.live)) { + // still draw (WorkSegment's own defaultOpen branch). The message rule + // (see `keepsWorkOpen`) opens the stretch by default, and the estimate + // has to agree or the virtualizer reserves the folded height. + if (!segIsOpen(seg.key, (hasPanel || keepsWorkOpen(segments, i)) && !seg.live)) { for (const tool of panelTools) { h += panelRows(tool, Math.max(28, panelWidth - (INDENT + STEP))) } diff --git a/ui-web/src/features/tasks/NodeRecord.test.tsx b/ui-web/src/features/tasks/NodeRecord.test.tsx index 8b265e1d4..6ff65d349 100644 --- a/ui-web/src/features/tasks/NodeRecord.test.tsx +++ b/ui-web/src/features/tasks/NodeRecord.test.tsx @@ -585,12 +585,14 @@ describe('fold memory', () => { it('keeps a fold open across a remount when the node key is the same', () => { const steps: NodeStep[] = [{ kind: 'think', text: 'first, check the dates' }] const first = render() - fireEvent.click(first.container.querySelector('.tkthh') as HTMLElement) + /* A sub-agent's reasoning is folded until asked for; the click is the ask. */ expect(first.container.querySelector('blockquote')).toBeNull() + fireEvent.click(first.container.querySelector('.tkthh') as HTMLElement) + expect(first.container.querySelector('blockquote')).not.toBeNull() first.unmount() const second = render() - expect(second.container.querySelector('blockquote')).toBeNull() + expect(second.container.querySelector('blockquote')).not.toBeNull() }) it('does not carry one node\'s fold onto a different node', () => { @@ -600,7 +602,7 @@ describe('fold memory', () => { a.unmount() const b = render() - expect(b.container.querySelector('blockquote')).not.toBeNull() + expect(b.container.querySelector('blockquote')).toBeNull() }) }) diff --git a/ui-web/src/features/tasks/NodeRecord.tsx b/ui-web/src/features/tasks/NodeRecord.tsx index 73a009ef7..f5019c3b8 100644 --- a/ui-web/src/features/tasks/NodeRecord.tsx +++ b/ui-web/src/features/tasks/NodeRecord.tsx @@ -194,7 +194,10 @@ function ThinkBlock({ text, live, nodeKey, foldKey }: { text: string; live: boolean; nodeKey: string; foldKey: string }): JSX.Element { const touched = useSyncExternalStore(store.subscribe, () => store.foldOf(nodeKey, foldKey)) - const open = touched ?? true + /* A node's reasoning is the model's scratch work, not its report: a reader + opens it when they want to see how the node got there. Same default the + transcript gives a delegated run's thought. */ + const open = touched ?? false return (
{/* A shut body is not built, which is where the weight was: a forty-turn session built 7361 nodes of which 6400 sat in shut fold bodies. On the - conversation's lane the runtime opens none: a turn that just finished - shuts its own (`collapse`) and a replayed one arrives shut, so the only - bodies built are the ones the reader opened. + conversation's lane the runtime opens a turn only when its work holds + a message of two or more rendered lines or a spawn/dag call + (`foldKeepsOpen`); every other finished turn shuts its own. The + protected turns are not free: on a 443-turn session 182 of 287 folds + arrived open, building 77,194 nodes against 57,141 before the rule + (+35.1%). A delegated pane opens every turn's, and is a different size of thing: measured on the two largest instance records on hand, 55 messages in diff --git a/ui-web/src/features/transcript/store.lane.test.ts b/ui-web/src/features/transcript/store.lane.test.ts index 58f4fda3e..1493231a4 100644 --- a/ui-web/src/features/transcript/store.lane.test.ts +++ b/ui-web/src/features/transcript/store.lane.test.ts @@ -9,6 +9,8 @@ import { resetSources, setSources } from '../../state/sources' import { cleanPreview, okOf } from './source' import * as store from './store' +import type { FoldData, Lane, StepData, StepHandle } from './types' + beforeEach(() => { resetSources() @@ -576,3 +578,144 @@ describe('a mid-turn message on the live path', () => { expect(store.turnKept(lane)).toBe(true) }) }) + +/* A finished turn folds its work behind one line. Three kinds of content are + important enough that the fold opens itself instead -- an agent message of + two or more rendered lines, and a spawn or dag call, whose whole point is + that the reader can see it ran. The reader's own toggle still wins: a fold + they shut stays shut across the turns that follow. */ +describe('what keeps a finished turn\'s fold open', () => { + const folds = (lane: Lane): FoldData[] => + lane.segs.filter((s): s is FoldData => s.kind === 'fold') + const turn = (lane: Lane, said: string, call: string): void => { + store.askText(lane, 'Q') + const first: StepHandle = store.newStep(lane) + first.setSay(said) + first.tool(call, {}, call, `c-${call}`).done(true, 'ok', 5) + const last: StepHandle = store.newStep(lane) + last.sayDelta('done') + store.finishTurn(lane, last, [first.seg, last.seg], '5s') + } + + it('opens over a narration of two or more rendered lines', () => { + const lane = store.newLane('main:long', true) + turn(lane, 'x'.repeat(store.FOLD_KEEP_OPEN_CHARS), 'read_file') + + const [fold] = folds(lane) + expect(fold?.open).toBe(true) + const [step] = fold ? fold.steps : [] + expect((step as StepData | undefined)?.say).toHaveLength(store.FOLD_KEEP_OPEN_CHARS) + }) + + it('stays shut over a narration shorter than that', () => { + const lane = store.newLane('main:short', true) + turn(lane, 'x'.repeat(store.FOLD_KEEP_OPEN_CHARS - 1), 'read_file') + + expect(folds(lane)[0]?.open).toBe(false) + }) + + it('opens over a spawn call without asking', () => { + /* `spawn` is display-only here: the fold default is the whole contract. */ + const lane = store.newLane('main:spawn', true) + turn(lane, 'delegating', 'spawn') + + expect(folds(lane)[0]?.open).toBe(true) + }) + + it('opens over a dag call without asking', () => { + const lane = store.newLane('main:dag', true) + turn(lane, 'running the graph', 'run_subagent_dag') + + expect(folds(lane)[0]?.open).toBe(true) + }) + + it('leaves a fold the reader shut shut, even when a later turn would open one', () => { + /* The rule is a default, not a lock: `toggleFold` takes the fold over, and + nothing the runtime does afterwards reopens it. */ + const lane = store.newLane('main:pinned', true) + turn(lane, 'x'.repeat(store.FOLD_KEEP_OPEN_CHARS), 'spawn') + const [first] = folds(lane) + store.toggleFold(lane, first as FoldData) + expect(first?.open).toBe(false) + + turn(lane, 'x'.repeat(store.FOLD_KEEP_OPEN_CHARS), 'read_file') + + const after = folds(lane) + expect(after).toHaveLength(2) + expect(after[0]?.open).toBe(false) + expect(after[1]?.open).toBe(true) + }) +}) + +/* A run of silent steps is merged into one row. The merged row is a fresh + default -- open when it delegated -- unless one of the steps it replaces was + the reader's, whose choice travels with the calls it was made about. */ +describe('a merged run of silent steps', () => { + const ran = (lane: Lane, name: string, id: string): StepHandle => { + const st: StepHandle = store.newStep(lane) + st.tool(name, {}, name, id).done(true, 'ok', 5) + st.seal() + return st + } + const mergedOf = (lane: Lane): StepData | undefined => + lane.segs.find((s): s is StepData => s.kind === 'step' && s.merged) + + it('stays open when the run delegated', () => { + const lane = store.newLane('main:merge-spawn', true) + const a = ran(lane, 'spawn', 'c1') + const b = ran(lane, 'read_file', 'c2') + + store.foldRuns(lane, [a.seg, b.seg]) + + expect(mergedOf(lane)?.wkOpen).toBe(true) + }) + + it('carries the reader\'s close into the merged row', () => { + const lane = store.newLane('main:merge-pin', true) + const a = ran(lane, 'spawn', 'c1') + const b = ran(lane, 'read_file', 'c2') + store.toggleWork(lane, a.seg) + + store.foldRuns(lane, [a.seg, b.seg]) + + expect(mergedOf(lane)?.wkPinned).toBe(true) + expect(mergedOf(lane)?.wkOpen).toBe(false) + }) +}) + +/* The fold rule reads narration, not the answer. `finishTurn` promotes the + last thing the model said into the answer row before `collapse` builds the + fold, so a long answer cannot open the work over it. The answer-only shape + leaves no step in the fold at all; the shape that proves the clearing is an + answer sharing its step with a call, which keeps the step inside the fold. */ +describe('the turn answer is not what the fold rule reads', () => { + const folds = (lane: Lane): FoldData[] => + lane.segs.filter((s): s is FoldData => s.kind === 'fold') + + it('does not open over a long answer alone', () => { + const lane = store.newLane('main:answer', true) + store.askText(lane, 'Q') + const work = store.newStep(lane) + work.tool('read_file', {}, 'read_file', 'c1').done(true, 'ok', 5) + const last = store.newStep(lane) + last.sayDelta('A'.repeat(store.FOLD_KEEP_OPEN_CHARS + 60)) + store.finishTurn(lane, last, [work.seg, last.seg], '5s') + + const [fold] = folds(lane) + expect(fold?.open).toBe(false) + expect(fold?.steps.some((s: StepData) => s.say.length >= store.FOLD_KEEP_OPEN_CHARS)).toBe(false) + }) + + it('does not open when a long answer shares its step with a call', () => { + const lane = store.newLane('main:answer-with-call', true) + store.askText(lane, 'Q') + const st = store.newStep(lane) + st.setSay('B'.repeat(store.FOLD_KEEP_OPEN_CHARS + 60)) + st.tool('read_file', {}, 'read_file', 'c1').done(true, 'ok', 5) + store.finishTurn(lane, st, [st.seg], '5s') + + const [fold] = folds(lane) + expect(fold?.open).toBe(false) + expect(fold?.steps[0]?.say).toBe('') + }) +}) diff --git a/ui-web/src/features/transcript/store.ts b/ui-web/src/features/transcript/store.ts index 471af06e8..6307fcc37 100644 --- a/ui-web/src/features/transcript/store.ts +++ b/ui-web/src/features/transcript/store.ts @@ -66,6 +66,14 @@ const shortPath = (p: string): string => { const MIN_RUN_STEPS = 2 export const DTL_MAX_LINES = 80 +/* An agent message this long keeps its turn's fold open. Importance is read + off the message's length rather than off measured rows -- a rendered-lines + test would need a layout pass per segment, and the caller has only the + string. 140 characters is about two lines at the transcript's reading width + (a ~70-glyph measure at 15px), so a message that fills two lines is on the + open side of the threshold. Tune it here, not at a call site. */ +export const FOLD_KEEP_OPEN_CHARS = 140 + export { actName, argPath, firstErrLine, phraseOf, shortArg, verbIngOf, verbOf } /* The demo replay hands the one string it displays where the live RPC hands @@ -1075,11 +1083,31 @@ function newCallData( return c } -/* The row-fold transitions: the step that just outgrew a single call folds its +/* A call whose information a one-line summary cannot stand in for. A spawn or + a dag row is the only place the delegation is visible -- which sub-agent ran, + and how far its graph got -- so the default-open rules read this predicate + rather than restating it. The reader's own toggle still decides either way: + this answers "what does an untouched step do", never "may the reader shut + it". + + `load_playbook` is classified `dag` by name even when it runs in prompt mode + and starts no run (see `DAG_CALLS` in `newStep`), so such a turn opens here + too: the card it draws still names the playbook. The TUI has no card for a + call with no run and leaves the same turn folded; that is the one place the + two clients disagree about this rule. */ +const callKeepsOpen = (c: CallData): boolean => c.kind === 'spawn' || c.kind === 'dag' + +/* The row-fold transitions: a step that just outgrew a single call folds its rows for the first time, and a failure is the one thing worth opening - unasked -- unless the reader pinned the fold. */ + unasked. Both are defaults -- `wkPinned` is the reader having taken the fold + over, and from then on nothing here moves it. A step holding a spawn or dag + call defaults open instead of folding, so the delegation is readable without + a click; the reader can still shut it. */ function paintWork(lane: Lane, seg: StepData, grewPast1: boolean): void { - if (seg.calls.length > 1 && grewPast1 && !seg.wkPinned) seg.wkOpen = false + if (seg.calls.length > 1 && !seg.wkPinned) { + if (seg.calls.some(callKeepsOpen)) seg.wkOpen = true + else if (grewPast1) seg.wkOpen = false + } if (seg.calls.length > 1 && seg.failed && !seg.wkPinned && !seg.wkOpen) seg.wkOpen = true bump(lane, seg) } @@ -1231,16 +1259,31 @@ export function newStep(lane: Lane): StepHandle { const stepSolid = (s: StepData): boolean => s.calls.length > 0 || (s.thinkShown && s.hasThink) || !!s.say.trim() +/* Whether a turn's work reads as important enough not to fold away. Two things + qualify, and both are display defaults rather than a change to what folding + can do -- the reader may still shut the fold by hand: + * + * - an agent message of two or more rendered lines (`FOLD_KEEP_OPEN_CHARS`); + * - a spawn or dag call, whose whole point is that the reader can see the + * delegation happened. */ +const foldKeepsOpen = (steps: StepData[]): boolean => + steps.some((s) => s.say.trim().length >= FOLD_KEEP_OPEN_CHARS || s.calls.some(callKeepsOpen)) + /* Every fold this lane opened by itself, shut. The reader's own are left * alone: `toggleFold` clears `auto`, so a fold they have touched is no longer * one of these. * - * A running sub-agent under a fold does not hold it open. The run's own status - * lives on the task rows, which is where a reader follows it; the fold is the - * turn's, and the turn is over. */ + * A fold holding a message or a delegation the rule protects stays open. The + * rule is re-read here rather than latched at creation because the work that + * completes it can land after the fold exists. + * + * A run still executing under the fold is not what holds it open -- the run's + * own status lives on the task rows, which is where a reader follows it. It is + * the call's kind that does: a spawn or dag row is the only place the + * delegation is visible, whether or not the run has finished. */ function shutAutoFolds(lane: Lane, except: FoldData | null): void { lane.segs.forEach((s) => { - if (s.kind !== 'fold' || s === except || !s.auto) return + if (s.kind !== 'fold' || s === except || !s.auto || foldKeepsOpen(s.steps)) return s.auto = false s.open = false bump(lane, s) @@ -1254,12 +1297,13 @@ function shutAutoFolds(lane: Lane, except: FoldData | null): void { * the whole time the turn ran; once the answer lands they fold shut behind the * one line, so what stays on screen is the answer and not the trail that led * to it. The trail is one click away, and the row's clock says there is one. - * A sub-agent still running under it does not keep it open: that run is - * followed on the task rows, not here. * - * Replay opens none: a reopened conversation arrives with every turn shut, the - * last one included, the same as a turn watched live ends. That is also what - * the weight asks for -- a forty-turn session built 7361 nodes and 6400 of them + * `foldKeepsOpen` is the exception, and it applies on both paths: a turn whose + * work carries an agent message of two or more rendered lines, or a spawn or + * dag call, arrives with its fold open. That is a default, not a lock -- a + * reader who shuts it keeps it shut (`toggleFold` clears `auto`). What the + * weight still gets is every other turn, and the shut bodies of every fold a + * reader never opened: a forty-turn session built 7361 nodes and 6400 of them * sat in shut fold bodies. */ export function collapse(lane: Lane, time?: string | null, live = false): void { const segs = lane.segs @@ -1293,6 +1337,11 @@ export function collapse(lane: Lane, time?: string | null, live = false): void { fold!.steps.push(s) }) fold.time = time || null + /* Work joining a fold the runtime still owns brings the rule with it: a + long message or a delegation that completes the turn opens the fold + around it. A fold the reader touched has `auto` cleared and is left the + way they left it. */ + if (fold.auto && foldKeepsOpen(fold.steps)) fold.open = true bump(lane, fold) bumpList(lane) return @@ -1304,10 +1353,16 @@ export function collapse(lane: Lane, time?: string | null, live = false): void { instance's conversation is short enough to read at once, which is why `shutAutoFolds` stays on the live path, where a whole session's folds accumulate. Marked `auto` either way, so the reader's own collapse takes it - over from the runtime (`toggleFold`) and survives the next poll. */ + over from the runtime (`toggleFold`) and survives the next poll. + + The main conversation's fold opens on the same rule when its work carries + a long message or a delegation (`foldKeepsOpen`), and stays open across + later turns because `shutAutoFolds` re-reads that rule rather than the one + decision made here. */ const born = !lane.main + const keepOpen = born || foldKeepsOpen(loose) const f: FoldData = { - v: 0, id: nextId(), kind: 'fold', time: time || null, open: born, auto: born, steps: [], + v: 0, id: nextId(), kind: 'fold', time: time || null, open: keepOpen, auto: true, steps: [], } if (live) shutAutoFolds(lane, f) segs.splice(firstAt, 0, f) @@ -1404,12 +1459,20 @@ function replaceStep(lane: Lane, at: StepData, next: StepData | null): boolean { function mergeRun(lane: Lane, group: StepData[]): void { const calls = group.flatMap((s) => s.calls) if (!calls.length || !calls.every((c) => c.done)) return + /* A merged step is a fresh default, unless one of the steps it replaces was + the reader's: then their choice travels with the calls it was made about. + Otherwise the same rule `paintWork` applies holds -- a run holding a + delegation stays open until someone closes it. */ + const pinned = group.find((s) => s.wkPinned) const holder: StepData = { v: 0, id: nextId(), kind: 'step', think: '', thinkLive: false, thinkT0: 0, thinkMs: 0, thinkOpen: false, thinkPinned: false, thinkShown: false, say: '', sayCaret: false, hasSay: false, hasThink: false, hasQA: false, failed: calls.some((c) => !c.ok), - calls, wkOpen: false, wkPinned: false, merged: true, + calls, + wkOpen: pinned ? pinned.wkOpen : calls.some(callKeepsOpen), + wkPinned: Boolean(pinned), + merged: true, } replaceStep(lane, group[0] as StepData, holder) group.slice(1).forEach((s) => replaceStep(lane, s, null)) diff --git a/ui-web/src/features/transcript/types.ts b/ui-web/src/features/transcript/types.ts index abd246287..df52831a9 100644 --- a/ui-web/src/features/transcript/types.ts +++ b/ui-web/src/features/transcript/types.ts @@ -282,9 +282,10 @@ export interface FoldData { kind: 'fold' time: string | null open: boolean - /* Open because the runtime opened it, and so still the runtime's to close. - A fold the reader has touched is theirs from then on: `toggleFold` clears - this, and nothing shuts it afterwards. */ + /* The runtime still owns this fold's open state, so it may open or shut it + as the turn's content changes (see `shutAutoFolds` and `collapse`). A fold + the reader has touched is theirs from then on: `toggleFold` clears this, + and nothing moves it afterwards. */ auto: boolean steps: StepData[] }