From 8eda09d2fa56f0204e6962450ad3cadd27f707a5 Mon Sep 17 00:00:00 2001 From: Dizhan Xue Date: Tue, 27 Oct 2026 09:50:11 +0000 Subject: [PATCH 1/5] feat(*): leave important chat content unfolded by default A finished turn's fold is a one-line compression, and it was hiding the content a reader is most likely to need. Three defaults change, in both chat surfaces, and all of them remain manual folds. ui-web: - a turn's fold opens when its work holds an agent message of 140 characters or more (FOLD_KEEP_OPEN_CHARS), or a spawn/dag call; - a step that delegated keeps its call rows out of the work summary; - a task node's reasoning starts folded, like a delegated transcript's. ui-tui: - the work under a message of 100 characters or more (MESSAGE_FOLD_KEEP_OPEN_CHARS) stays open; - every delegation panel in a running stretch stays visible, not only the call in hand; - a trace box no longer prints a line of reasoning unasked. The reader's own toggle still wins: a fold they touch is theirs, and the runtime does not reopen it. The TUI height estimator is updated with the new default so the virtualised rows keep their reserved height. --- ui-tui/src/__tests__/episodeView.test.tsx | 72 +++++++++++++++++++ ui-tui/src/components/episodeView.tsx | 60 +++++++++++----- ui-tui/src/config/limits.ts | 8 +++ ui-tui/src/domain/episodeSummary.ts | 18 +++++ ui-tui/src/lib/virtualHeights.ts | 12 ++-- ui-web/src/features/tasks/NodeRecord.test.tsx | 8 ++- ui-web/src/features/tasks/NodeRecord.tsx | 5 +- ui-web/src/features/tasks/TasksPage.test.tsx | 9 +-- .../transcript/TranscriptPage.test.tsx | 69 +++++++++++++++--- .../features/transcript/TranscriptPage.tsx | 19 +++-- .../features/transcript/store.lane.test.ts | 70 ++++++++++++++++++ ui-web/src/features/transcript/store.ts | 63 +++++++++++++--- ui-web/src/features/transcript/types.ts | 7 +- 13 files changed, 362 insertions(+), 58 deletions(-) diff --git a/ui-tui/src/__tests__/episodeView.test.tsx b/ui-tui/src/__tests__/episodeView.test.tsx index 4b65e395c..1ef07a32b 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,74 @@ 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('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/components/episodeView.tsx b/ui-tui/src/components/episodeView.tsx index d1a581c55..60741eb8b 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,22 @@ 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 has nothing to reveal there + // and leaves its detail block folded. 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 and a + replayed one arrives shut, so the bodies built are the reader's and + those few. 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..4ecc8d9b1 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,71 @@ 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) + }) +}) diff --git a/ui-web/src/features/transcript/store.ts b/ui-web/src/features/transcript/store.ts index 471af06e8..9ce2b5f7f 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 @@ -1231,16 +1239,37 @@ export function newStep(lane: Lane): StepHandle { const stepSolid = (s: StepData): boolean => s.calls.length > 0 || (s.thinkShown && s.hasThink) || !!s.say.trim() +/* 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 fold rules here and the step's own work + list both read this predicate rather than restating it. */ +export const callKeepsOpen = (c: CallData): boolean => c.kind === 'spawn' || c.kind === 'dag' + +/* 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 +1283,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 +1323,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 +1339,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) 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[] } From a6889737e2cb947ba34a851c5544cfcf832b535d Mon Sep 17 00:00:00 2001 From: Dizhan Xue Date: Tue, 27 Oct 2026 12:31:27 +0000 Subject: [PATCH 2/5] fix(ui-web): let the reader fold a delegating step by hand `keepCalls` made a multi-call step's body visible regardless of `wkOpen`, so the summary row stayed clickable while no click could hide anything and its open marker could disagree with the body. That turned the default into a lock, which is not what the fold rule is for. The default now lives in `paintWork`, gated on the existing `wkPinned` flag -- the same split `FoldData.auto` uses for a turn's fold: the runtime picks the untouched state, and a reader's toggle takes it over for good. A step that delegated still opens by default. A merged silent run inherits a pinned member's choice and otherwise follows the same rule. `StepView` no longer layers a second rule: the summary's open marker and the body both read `seg.wkOpen`. Tests: the reader can shut a delegating step, it stays shut when another call lands, and it opens again on the next click; the merged-run default and its pinned hand-down are pinned directly. The TUI needed no behavior change (its folds resolve through `foldOpen`, where an explicit close always wins); it gains a reader-close test and a height-estimator test proving the estimate and the renderer agree in both states. Existing tests that clicked the summary only to reach the rows now use the `openWork` helper, which clicks a shut summary and leaves an open one -- their subject was never the fold. --- ui-tui/src/__tests__/episodeView.test.tsx | 18 ++++++ ui-tui/src/__tests__/virtualHeights.test.ts | 44 +++++++++++++- .../transcript/TranscriptPage.test.tsx | 57 +++++++++++++++---- .../features/transcript/TranscriptPage.tsx | 15 ++--- .../features/transcript/store.lane.test.ts | 36 ++++++++++++ ui-web/src/features/transcript/store.ts | 36 ++++++++---- 6 files changed, 175 insertions(+), 31 deletions(-) diff --git a/ui-tui/src/__tests__/episodeView.test.tsx b/ui-tui/src/__tests__/episodeView.test.tsx index 1ef07a32b..f3f7b932a 100644 --- a/ui-tui/src/__tests__/episodeView.test.tsx +++ b/ui-tui/src/__tests__/episodeView.test.tsx @@ -790,6 +790,24 @@ describe('what the transcript folds by default', () => { 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 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-web/src/features/transcript/TranscriptPage.test.tsx b/ui-web/src/features/transcript/TranscriptPage.test.tsx index e004b04f2..a9dbb6b37 100644 --- a/ui-web/src/features/transcript/TranscriptPage.test.tsx +++ b/ui-web/src/features/transcript/TranscriptPage.test.tsx @@ -883,7 +883,7 @@ describe('transcript island, history', () => { expect(fold?.textContent).toContain(long) }) - it('does not put a delegating step\'s call rows behind its summary', () => { + it('shows a delegating step\'s call rows by default, without locking them open', () => { const t0 = Date.now() - 60000 act(() => { mount.history([ @@ -907,6 +907,43 @@ describe('transcript island, history', () => { expect($$('.wkin .wrow .vb')).toHaveLength(2) }) + it('lets the reader shut a delegating step, and keeps it shut', () => { + /* `keepCalls` used to make the body visible regardless of `wkOpen`, so the + summary stayed clickable while the click could never hide anything. The + default and the reader's decision are two different things: the first is + `paintWork`'s, the second is `wkPinned`'s. */ + let st!: ReturnType + act(() => { + mount.ask('fan it out') + st = mount.step() + st.tool('spawn', { task: 'look' }, null, 'c1') + st.tool('read_file', { path: '/tmp/a' }, null, 'c2').done(true, 'x', 5) + }) + const sum = (): HTMLElement => $('.wrow.sum') as HTMLElement + const body = (): HTMLElement => $('.wkin') as HTMLElement + + /* Untouched: open, and the summary's own marker agrees with the body. */ + expect(sum().classList.contains('open')).toBe(true) + expect(body().hidden).toBe(false) + + /* The reader shuts it. Style and body are asserted together, because a + summary still reading "open" over a shut body is the same defect one + layer up. */ + act(() => sum().click()) + expect(body().hidden).toBe(true) + expect(sum().classList.contains('open')).toBe(false) + + /* A call landing afterwards is a default, not an instruction to reopen. */ + act(() => { st.tool('read_file', { path: '/tmp/b' }, null, 'c3').done(true, 'y', 5) }) + expect(body().hidden).toBe(true) + expect(sum().classList.contains('open')).toBe(false) + + /* And the reader can open it again, style and body still agreeing. */ + act(() => sum().click()) + expect(body().hidden).toBe(false) + expect(sum().classList.contains('open')).toBe(true) + }) + it('keeps a redirection whole: an ACP shell title stays one row, one command', () => { act(() => { mount.history([ @@ -3108,7 +3145,7 @@ describe('transcript island, the delegation verbs', () => { st.tool('run', { cmd: 'ls' }).done(true, 'ok', 5) st.seal() }) - act(() => { ($('.wk > .wrow.sum') as HTMLElement).click() }) + openWork() const row = $$('.wkin .wrow')[0] as HTMLElement act(() => { row.click() }) return row.nextElementSibling as HTMLElement @@ -3167,7 +3204,7 @@ describe('transcript island, the delegation verbs', () => { st.tool('run', { cmd: 'ls' }).done(true, 'ok', 5) st.seal() }) - act(() => { ($('.wk > .wrow.sum') as HTMLElement).click() }) + openWork() const row = $$('.wkin .wrow')[0] as HTMLElement act(() => { row.click() }) await act(async () => { await Promise.resolve() }) @@ -3213,7 +3250,7 @@ describe('transcript island, tool episodes', () => { it('opens a settled row into its detail card and closes it again', () => { twoCalls() - act(() => { ($('.wk > .wrow.sum') as HTMLElement).click() }) + openWork() const row = $$('.wkin .wrow')[0] as HTMLElement expect(row.classList.contains('tog')).toBe(true) /* Shut is not built at all, so the row stands with no card behind it. */ @@ -3242,7 +3279,7 @@ describe('transcript island, tool episodes', () => { st.tool('raven_config', { action: 'describe' }).done(true, '{}', 5) st.seal() }) - act(() => { ($('.wk > .wrow.sum') as HTMLElement).click() }) + openWork() const row = $$('.wkin .wrow')[0] as HTMLElement act(() => { row.click() }) const dtl = row.nextElementSibling as HTMLElement @@ -3267,7 +3304,7 @@ describe('transcript island, tool episodes', () => { st.tool('run', { cmd: 'ls' }).done(true, 'ok', 5) st.seal() }) - act(() => { ($('.wk > .wrow.sum') as HTMLElement).click() }) + openWork() const row = $$('.wkin .wrow')[0] as HTMLElement act(() => { row.click() }) return (row.nextElementSibling as HTMLElement).querySelector('.bd pre .pth')! @@ -4356,7 +4393,7 @@ describe('transcript island, delegated calls', () => { { run_id: 'r-1', tool_call_id: 'call-1', node: 'alpha', status: 'failed' }) st.seal() }) - act(() => { ($('.wk > .wrow.sum') as HTMLElement).click() }) + openWork() const cards = $$('.wkin .wrow').map((row) => row.nextElementSibling as HTMLElement) const calls = store._dagCallsForTests() @@ -4394,7 +4431,7 @@ describe('transcript island, delegated calls', () => { st.tool('run_subagent_dag', { nodes: [{ id: 'beta' }, { id: 'beta2' }] }, null, 'call-2') st.seal() }) - act(() => { ($('.wk > .wrow.sum') as HTMLElement).click() }) + openWork() const cards = $$('.wkin .wrow').map((row) => row.nextElementSibling as HTMLElement) expect(cards.map((c) => dagField(c, 'gui.dag.run_id'))).toEqual(['r-1', 'r-2']) @@ -4882,7 +4919,7 @@ describe('transcript island, an imported conversation', () => { st.tool('run', { cmd: 'ls' }).done(true, 'ok', 5) st.seal() }) - act(() => { ($('.wk > .wrow.sum') as HTMLElement).click() }) + openWork() const row = $$('.wkin .wrow')[0] as HTMLElement act(() => { row.click() }) return row.nextElementSibling as HTMLElement @@ -4940,7 +4977,7 @@ describe('transcript island, an imported conversation', () => { st.tool('run', { cmd: 'ls' }).done(true, 'ok', 5) st.seal() }) - act(() => { ($('.wk > .wrow.sum') as HTMLElement).click() }) + openWork() act(() => { ($$('.wkin .wrow')[0] as HTMLElement).click() }) await act(async () => { await Promise.resolve() }) expect(asked).toEqual([]) diff --git a/ui-web/src/features/transcript/TranscriptPage.tsx b/ui-web/src/features/transcript/TranscriptPage.tsx index 2f78a50d0..6a6f798b6 100644 --- a/ui-web/src/features/transcript/TranscriptPage.tsx +++ b/ui-web/src/features/transcript/TranscriptPage.tsx @@ -954,11 +954,6 @@ const StepView = memo(function StepView({ lane, seg }: { lane: Lane; seg: StepDa const one = seg.calls.length > 0 && seg.calls.every((c) => c.name === (seg.calls[0] as CallData).name) const live = seg.calls.some((c) => !c.done) const failedN = seg.calls.filter((c) => c.done && !c.ok).length - /* A step that delegated keeps its rows on screen. The summary row is a - one-line stand-in for calls a reader does not need to tell apart; a spawn - or a dag call is not one of those -- which sub-agent ran, and how far its - graph got, is the point -- so the list is not folded over it. */ - const keepCalls = seg.calls.some(store.callKeepsOpen) const showSum = seg.calls.length > 1 /* The rows behind a closed summary are not mounted, so the summary carries the clock for them. Timed from the call in flight rather than the step's @@ -977,11 +972,11 @@ const StepView = memo(function StepView({ lane, seg }: { lane: Lane; seg: StepDa : seg.thinkMs >= 1000 ? t('gui.transcript.think_done', { label: thinkWord, t: store.durText(seg.thinkMs) }) : thinkWord - const wkinHidden = seg.calls.length === 0 - ? true - : seg.calls.length === 1 || keepCalls - ? false - : !seg.wkOpen + /* One state, one writer: the summary's `open` class and this body read the + same `seg.wkOpen`, so the row cannot say "open" over a body that is shut. + `wkOpen` is the default `paintWork` sets (open for a delegating step) or + the reader's own toggle (`toggleWork`), never a second rule layered here. */ + const wkinHidden = seg.calls.length === 0 ? true : seg.calls.length === 1 ? false : !seg.wkOpen if (stepBlank(seg)) return null return (
diff --git a/ui-web/src/features/transcript/store.lane.test.ts b/ui-web/src/features/transcript/store.lane.test.ts index 4ecc8d9b1..175a37a37 100644 --- a/ui-web/src/features/transcript/store.lane.test.ts +++ b/ui-web/src/features/transcript/store.lane.test.ts @@ -646,3 +646,39 @@ describe('what keeps a finished turn\'s fold open', () => { 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) + }) +}) diff --git a/ui-web/src/features/transcript/store.ts b/ui-web/src/features/transcript/store.ts index 9ce2b5f7f..0d6d528e2 100644 --- a/ui-web/src/features/transcript/store.ts +++ b/ui-web/src/features/transcript/store.ts @@ -1083,11 +1083,25 @@ 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". */ +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) } @@ -1239,12 +1253,6 @@ export function newStep(lane: Lane): StepHandle { const stepSolid = (s: StepData): boolean => s.calls.length > 0 || (s.thinkShown && s.hasThink) || !!s.say.trim() -/* 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 fold rules here and the step's own work - list both read this predicate rather than restating it. */ -export const callKeepsOpen = (c: CallData): boolean => c.kind === 'spawn' || c.kind === 'dag' - /* 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: @@ -1445,12 +1453,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)) From ed8e0c178469a57227916c37903ac748681abb9b Mon Sep 17 00:00:00 2001 From: Dizhan Xue Date: Wed, 28 Oct 2026 07:48:38 +0000 Subject: [PATCH 3/5] docs(ui-web): describe the delegated fold as it now behaves The describe block still said a fold shuts over a sub-agent that is still working and that a spawn in flight buys the fold nothing. The assertions below it say the opposite: a spawn or dag call keeps the fold open by default, at the turn's end and on replay, and it is the call kind rather than the running status that does it. The reader's own toggle still wins. --- .../src/features/transcript/TranscriptPage.test.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/ui-web/src/features/transcript/TranscriptPage.test.tsx b/ui-web/src/features/transcript/TranscriptPage.test.tsx index a9dbb6b37..2fc2af0c4 100644 --- a/ui-web/src/features/transcript/TranscriptPage.test.tsx +++ b/ui-web/src/features/transcript/TranscriptPage.test.tsx @@ -1584,12 +1584,14 @@ describe('a thought box while the model is still thinking', () => { }) }) -/** A fold shuts over a sub-agent that is still working, like any other. +/** A fold holding a delegated run opens, so the delegation stays in sight. * - * The run goes on for minutes after the turn that dispatched it has answered; - * it is followed on the task rows, not by holding the turn's fold open. What - * is asserted here is that a spawn in flight buys the fold nothing: not at the - * turn's end, and not when the next turn shuts the folds before it. + * The run itself goes on for minutes after the turn that dispatched it has + * answered, and its status is followed on the task rows -- a run still working + * is not what holds the fold open. The call kind is: a spawn or dag row inside + * the fold is the only place the delegation is named, so that turn's fold + * arrives open at its end and on replay, and a later turn does not shut it. + * It stays a default -- the reader's own toggle takes it over. */ describe('a fold holding a delegated run', () => { const RUNNING = { From ba6f22a49b544dd640ed97b4198edef891dab428 Mon Sep 17 00:00:00 2001 From: Dizhan Xue Date: Fri, 30 Oct 2026 12:02:32 +0000 Subject: [PATCH 4/5] docs(*): correct the fold-default comments to measured behavior The fold-open weight comment called the protected turns "those few"; on the 443-turn session measured for this PR 182 of 287 folds arrive open and build 35.1% more nodes than the baseline. The TUI's solo-call comment said the detail block stays folded, but the solo branch defers to `cardDefaultOpen`, which opens it whenever the call has content. The web `callKeepsOpen` comment now records that a prompt-mode `load_playbook` is classified `dag` by name and that the TUI has no card for a call with no run. --- ui-tui/src/components/episodeView.tsx | 5 +++-- ui-web/src/features/transcript/TranscriptPage.tsx | 7 ++++--- ui-web/src/features/transcript/store.ts | 8 +++++++- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/ui-tui/src/components/episodeView.tsx b/ui-tui/src/components/episodeView.tsx index 60741eb8b..4788a1a58 100644 --- a/ui-tui/src/components/episodeView.tsx +++ b/ui-tui/src/components/episodeView.tsx @@ -957,8 +957,9 @@ export const EpisodeView = memo(function EpisodeView({ // `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 has nothing to reveal there - // and leaves its detail block folded. + // 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]!) diff --git a/ui-web/src/features/transcript/TranscriptPage.tsx b/ui-web/src/features/transcript/TranscriptPage.tsx index 6a6f798b6..94b367720 100644 --- a/ui-web/src/features/transcript/TranscriptPage.tsx +++ b/ui-web/src/features/transcript/TranscriptPage.tsx @@ -1602,9 +1602,10 @@ const FoldView = memo(function FoldView({ lane, seg }: { lane: Lane; seg: FoldDa session built 7361 nodes of which 6400 sat in shut fold bodies. On the 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 and a - replayed one arrives shut, so the bodies built are the reader's and - those few. + (`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.ts b/ui-web/src/features/transcript/store.ts index 0d6d528e2..6307fcc37 100644 --- a/ui-web/src/features/transcript/store.ts +++ b/ui-web/src/features/transcript/store.ts @@ -1088,7 +1088,13 @@ function newCallData( 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". */ + 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 From b0edce2a0bf9b9a92970a38d5a8652834a9be080 Mon Sep 17 00:00:00 2001 From: Dizhan Xue Date: Fri, 30 Oct 2026 12:02:32 +0000 Subject: [PATCH 5/5] test(ui-web): pin the turn answer out of the fold default A long final answer must not open the turn's fold: `finishTurn` promotes it into the answer row and clears the step's prose before `collapse` reads the steps. Two shapes pin that -- an answer-only turn, and an answer sharing its step with a call, where only the cleared `say` keeps the step out of the rule. A mutant that leaves the answer in the step kills the second test; one that collapses before the promotion kills both. --- .../features/transcript/store.lane.test.ts | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/ui-web/src/features/transcript/store.lane.test.ts b/ui-web/src/features/transcript/store.lane.test.ts index 175a37a37..1493231a4 100644 --- a/ui-web/src/features/transcript/store.lane.test.ts +++ b/ui-web/src/features/transcript/store.lane.test.ts @@ -682,3 +682,40 @@ describe('a merged run of silent steps', () => { 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('') + }) +})