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[]
}