fold every turn but the live one

A conversation is turn-based: your message, the work, the answer, repeat. The
moment you send the next message the tool calls and running commentary that
produced the last answer stop being what you are reading and start being what you
are scrolling past. So every turn but the live one collapses to three parts —
what you asked, one summary row, and what I concluded — with the summary naming
what you gave up ("5 tool calls · 2 messages · 1 failed") so you can tell whether
you want it back. Failures are counted on the summary rather than only inside,
because a red row you have to open to find is a red row you never find.

It is a pure derivation over the message list rather than state, which is what
makes a reload render identically to a live session: no wire format, no
persistence, no server change. Dividers and compaction seams split a fold instead
of disappearing into one, because "5 tool calls" hiding a /clear misreports what
happened to the conversation rather than to the work.

A turn that ends cleanly without saying anything gets a marker row. It happens
rarely and is disproportionately confusing — the composer re-enables and nothing
appears, which is indistinguishable from a turn that died. Deliberately a seam and
never prose: words in my voice that I did not write are a lie, and the next time it
happened you would not know which kind of row you were reading.

Fold-open state lives in the list, not the fold, because rows are virtualised and
state inside one would be thrown away when it scrolled past the overscan window.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 05:10:33 +00:00
co-authored by Claude Opus 5
parent 969b2f3762
commit 5bdb2474ea
4 changed files with 452 additions and 29 deletions
@@ -1,24 +1,14 @@
import { useLayoutEffect, useRef } from 'react';
import { useLayoutEffect, useMemo, useRef, useState } from 'react';
import { ArrowDown, Loader2 } from 'lucide-react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { measureHeight } from 'helpers/measure-zoomed';
import type { UseEmbeddableChatType } from '../EmbeddableChat/useEmbeddableChat';
import type { ChatMessage } from '../types';
import { MessageBubble, StreamingBubble } from './MessageBubble';
import { buildRows } from './turn-rows';
import { TurnEnd, TurnFold } from './TurnFold';
const OVERSCAN = 5;
// A cheap identity for the first message, to tell a top-prepend (lazy-loaded older window) apart from a
// bottom-append (new turn). Only messages[0] changing while the list grows means older were prepended.
function firstMessageKey(m: ChatMessage | undefined): string {
if (!m) return '';
if (m.role === 'assistant') return `a:${m.id ?? m.text.slice(0, 40)}`;
if (m.role === 'tool') return `t:${m.toolCallId}`;
if (m.role === 'task') return `k:${m.taskId}`;
if (m.role === 'divider') return `d:${m.sessionId}`;
return `${m.role}:${'text' in m ? m.text.slice(0, 40) : ''}`;
}
type MessageListProps = {
manager: UseEmbeddableChatType;
};
@@ -36,8 +26,21 @@ export const MessageList = ({ manager }: MessageListProps) => {
isLoadingOlder,
} = manager;
// The live turn stays open; everything before it is folded. See `turn-rows.ts`.
const rows = useMemo(() => buildRows(messages, isGenerating), [messages, isGenerating]);
// Which folds you have opened. Lifted out of the fold itself because rows are virtualised: state held
// inside one would be thrown away the moment it scrolled past the overscan window and silently snap shut.
const [openFolds, setOpenFolds] = useState<ReadonlySet<string>>(() => new Set());
const toggleFold = (key: string) =>
setOpenFolds((prev) => {
const next = new Set(prev);
if (!next.delete(key)) next.add(key);
return next;
});
const virtualizer = useVirtualizer({
count: messages.length,
count: rows.length,
getScrollElement: () => scrollViewportRef.current,
estimateSize: () => 150,
overscan: OVERSCAN,
@@ -46,18 +49,20 @@ export const MessageList = ({ manager }: MessageListProps) => {
measureElement: measureHeight,
});
// Keep the viewport anchored when older messages are prepended: the message that used to be first
// (now shifted down by the number prepended) is pinned back to the top, so the content doesn't jump.
const prevLenRef = useRef(messages.length);
// Keep the viewport anchored when older messages are prepended: the row that used to be first (now
// shifted down by however many rows arrived above it) is pinned back to the top, so content doesn't jump.
// Counted in rows rather than messages, because a prepended turn folds down to fewer rows than it has
// messages and scrolling by the message count would overshoot.
const prevLenRef = useRef(rows.length);
const prevFirstKeyRef = useRef<string | null>(null);
useLayoutEffect(() => {
const grew = messages.length - prevLenRef.current;
const firstKey = firstMessageKey(messages[0]);
const grew = rows.length - prevLenRef.current;
const firstKey = rows[0]?.key ?? '';
const prepended = grew > 0 && prevFirstKeyRef.current !== null && firstKey !== prevFirstKeyRef.current;
if (prepended) virtualizer.scrollToIndex(grew, { align: 'start' });
prevLenRef.current = messages.length;
prevLenRef.current = rows.length;
prevFirstKeyRef.current = firstKey;
}, [messages, virtualizer]);
}, [rows, virtualizer]);
return (
<div className="flex-1 min-h-0 relative">
@@ -67,15 +72,15 @@ export const MessageList = ({ manager }: MessageListProps) => {
</div>
)}
<div ref={scrollViewportRef} className="h-full overflow-y-auto">
{messages.length === 0 && !isGenerating && (
{rows.length === 0 && !isGenerating && (
<div className="flex items-center justify-center h-full min-h-[200px] text-muted-foreground text-sm">
Send a message to start
</div>
)}
{messages.length > 0 && (
{rows.length > 0 && (
<div className="relative w-full" style={{ height: virtualizer.getTotalSize() }}>
{virtualizer.getVirtualItems().map((virtualRow) => {
const msg = messages[virtualRow.index]!;
const row = rows[virtualRow.index]!;
return (
<div
key={virtualRow.key}
@@ -87,11 +92,25 @@ export const MessageList = ({ manager }: MessageListProps) => {
}}
>
<div className="py-1.5">
<MessageBubble
message={msg}
onAnswer={(text) => sendPrompt(text)}
onRetry={(prompt) => sendPrompt(prompt)}
/>
{row.kind === 'message' && (
<MessageBubble
message={row.message}
onAnswer={(text) => sendPrompt(text)}
onRetry={(prompt) => sendPrompt(prompt)}
// Every unfolded tool row belongs to the live turn by construction — older turns'
// work only ever appears inside a fold — so generating is the whole condition.
live={isGenerating}
/>
)}
{row.kind === 'fold' && (
<TurnFold
messages={row.messages}
counts={row.counts}
open={openFolds.has(row.key)}
onToggle={() => toggleFold(row.key)}
/>
)}
{row.kind === 'turn-end' && <TurnEnd />}
</div>
</div>
);
@@ -0,0 +1,61 @@
import { ChevronRight, CheckCircle2 } from 'lucide-react';
import type { ChatMessage } from '../types';
import type { FoldCounts } from './turn-rows';
import { foldLabel } from './turn-rows';
import { MessageBubble } from './MessageBubble';
type TurnFoldProps = {
messages: ChatMessage[];
counts: FoldCounts;
open: boolean;
onToggle: () => void;
};
/**
* One turn's work, collapsed to a line.
*
* The failure count is on the summary rather than only inside, because a red row you have to open to find
* is a red row you never find. It is also the shared handle: "the one where the tsgo call went red" has to
* be sayable without either of us expanding anything.
*/
export const TurnFold = ({ messages, counts, open, onToggle }: TurnFoldProps) => (
<div className="my-1">
<button
type="button"
onClick={onToggle}
aria-expanded={open}
className={`flex w-full cursor-pointer items-center gap-2 rounded-md px-3 py-1 text-left text-xs transition-colors ${
counts.failed
? 'text-destructive hover:bg-destructive/10'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
}`}
>
<ChevronRight className={`h-3 w-3 shrink-0 transition-transform ${open ? 'rotate-90' : ''}`} />
<span className="h-px flex-1 bg-border" />
<span className="shrink-0 tabular-nums">{foldLabel(counts)}</span>
<span className="h-px flex-1 bg-border" />
</button>
{open && (
<div className="mt-1 space-y-1.5 border-l-2 border-border pl-2">
{messages.map((message, i) => (
<MessageBubble key={i} message={message} />
))}
</div>
)}
</div>
);
/**
* A turn that ended without a closing message. Written as a seam, never as prose — see `turn-rows.ts`.
*/
export const TurnEnd = () => (
<div className="flex items-center gap-3 py-2 text-muted-foreground select-none">
<div className="h-px flex-1 bg-border" />
<span className="flex items-center gap-1.5 text-[11px] font-medium tracking-wide uppercase">
<CheckCircle2 className="h-3 w-3" />
Turn finished · no closing message
</span>
<div className="h-px flex-1 bg-border" />
</div>
);
@@ -0,0 +1,155 @@
import { describe, expect, test } from 'bun:test';
import type { ChatMessage } from '../types';
import { buildRows, foldLabel } from './turn-rows';
const user = (text: string): ChatMessage => ({ role: 'user', text });
const say = (text: string): ChatMessage => ({ role: 'assistant', id: text, text });
const tool = (id: string, isError?: boolean): ChatMessage => ({
role: 'tool',
toolName: 'Bash',
toolInput: {},
toolCallId: id,
output: 'ok',
...(isError ? { isError: true } : {}),
});
/** The shape under test is the row sequence, not the keys. */
const shape = (messages: ChatMessage[], isGenerating = false) =>
buildRows(messages, isGenerating).map((r) => (r.kind === 'message' ? r.message.role : r.kind));
describe('buildRows', () => {
test('the live turn is untouched — every row stays where it was', () => {
expect(shape([user('go'), say('one'), tool('a'), say('two')], true)).toEqual([
'user',
'assistant',
'tool',
'assistant',
]);
});
test('an older turn folds to question, summary, answer', () => {
const messages = [user('first'), say('thinking'), tool('a'), tool('b'), say('answer'), user('second')];
expect(shape(messages)).toEqual(['user', 'fold', 'assistant', 'user']);
});
test('the fold counts what it hid, failures included', () => {
const messages = [user('first'), say('note'), tool('a'), tool('b', true), say('answer'), user('second')];
const fold = buildRows(messages, false).find((r) => r.kind === 'fold');
expect(fold?.kind === 'fold' && fold.counts).toEqual({ tools: 2, notes: 1, tasks: 0, failed: 1 });
});
test('a fold opens onto exactly the messages it replaced, in order', () => {
const messages = [user('first'), say('note'), tool('a'), say('answer'), user('second')];
const fold = buildRows(messages, false).find((r) => r.kind === 'fold');
expect(fold?.kind === 'fold' && fold.messages).toEqual([say('note'), tool('a')]);
});
test('a divider is never swallowed by a fold — it splits it', () => {
const messages = [
user('first'),
tool('a'),
{ role: 'divider', sessionId: 's1' } as ChatMessage,
tool('b'),
say('answer'),
user('second'),
];
expect(shape(messages)).toEqual(['user', 'fold', 'divider', 'fold', 'assistant', 'user']);
});
test('a compaction seam survives folding too', () => {
const messages = [
user('first'),
tool('a'),
{ role: 'compact', trigger: 'auto', done: true } as ChatMessage,
say('answer'),
user('second'),
];
expect(shape(messages)).toEqual(['user', 'fold', 'compact', 'assistant', 'user']);
});
test('a turn that ends on a tool call is marked finished rather than left hanging', () => {
expect(shape([user('go'), tool('a')], false)).toEqual(['user', 'tool', 'turn-end']);
});
test('a turn still running is not marked finished', () => {
expect(shape([user('go'), tool('a')], true)).toEqual(['user', 'tool']);
});
test('a turn that failed keeps its own row and gets no marker', () => {
expect(shape([user('go'), tool('a'), { role: 'error', text: 'boom' }], false)).toEqual(['user', 'tool', 'error']);
});
test('an interrupted turn gets no marker either', () => {
expect(shape([user('go'), tool('a'), { role: 'interrupted' }], false)).toEqual(['user', 'tool', 'interrupted']);
});
test('a cost row alone is not an answer — the marker still appears, above it', () => {
const messages: ChatMessage[] = [
user('go'),
tool('a'),
{ role: 'result', cost: { inputTokens: 1, outputTokens: 1, totalUSD: 0 } },
];
expect(shape(messages, false)).toEqual(['user', 'tool', 'turn-end', 'result']);
});
test('an empty assistant block does not count as having answered', () => {
expect(shape([user('go'), tool('a'), say(' ')], false)).toEqual(['user', 'tool', 'turn-end', 'assistant']);
});
test('your own message is never folded', () => {
const rows = buildRows([user('first'), tool('a'), say('answer'), user('second')], false);
expect(rows[0]?.kind === 'message' && rows[0].message).toEqual(user('first'));
});
test('a window with no user message at all folds nothing', () => {
expect(shape([say('note'), tool('a'), say('answer')], false)).toEqual(['assistant', 'tool', 'assistant']);
});
test('every older turn folds, not just the previous one', () => {
const messages = [
user('1'),
tool('a'),
say('one'),
user('2'),
tool('b'),
say('two'),
user('3'),
tool('c'),
say('three'),
];
expect(shape(messages, false)).toEqual([
'user',
'fold',
'assistant',
'user',
'fold',
'assistant',
'user',
'tool',
'assistant',
]);
});
test('an empty transcript produces nothing', () => {
expect(buildRows([], false)).toEqual([]);
});
test('a bare question with no work yet produces no marker and no fold', () => {
expect(shape([user('go')], true)).toEqual(['user']);
});
});
describe('foldLabel', () => {
test('reads as a sentence and singularises', () => {
expect(foldLabel({ tools: 1, notes: 1, tasks: 0, failed: 0 })).toBe('1 tool call · 1 message');
expect(foldLabel({ tools: 5, notes: 3, tasks: 0, failed: 0 })).toBe('5 tool calls · 3 messages');
});
test('failures are named on the summary, not left inside', () => {
expect(foldLabel({ tools: 5, notes: 0, tasks: 0, failed: 2 })).toBe('5 tool calls · 2 failed');
});
test('background tasks are counted apart from tool calls', () => {
expect(foldLabel({ tools: 0, notes: 0, tasks: 1, failed: 0 })).toBe('1 background task');
});
});
@@ -0,0 +1,188 @@
import type { ChatMessage } from '../types';
/**
* A conversation is a chess game: your message, my work, my answer, repeat. Only the turn you are in is
* worth seeing in full — the moment you send the next message, the tool calls and the running commentary
* that produced the previous answer stop being the thing you are reading and start being the thing you are
* scrolling past. So every turn but the live one folds to three parts: what you asked, one summary row, and
* what I concluded.
*
* This is a pure derivation over the message list, which is what makes a reload render identically to a
* live session: there is no fold *state* anywhere, on the wire or on disk, only a grouping that both paths
* compute from the same array.
*/
/** Rows that are landmarks about the conversation rather than work done inside a turn. These never fold. */
function isStructural(message: ChatMessage): boolean {
return message.role === 'divider' || message.role === 'compact';
}
/** The work of a turn: everything that belongs behind the summary row. */
function isWork(message: ChatMessage): boolean {
return message.role === 'tool' || message.role === 'task';
}
/** Prose I actually produced. An empty assistant block renders as nothing and must not count as an answer. */
function isAnswer(message: ChatMessage): boolean {
return message.role === 'assistant' && message.text.trim().length > 0;
}
/** Ways a turn can end other than by answering. Each has its own row already and rules out the marker. */
function isFailure(message: ChatMessage): boolean {
return message.role === 'error' || message.role === 'interrupted' || message.role === 'cutoff';
}
export type FoldCounts = {
tools: number;
notes: number;
tasks: number;
failed: number;
};
export type ChatRow =
| { kind: 'message'; key: string; message: ChatMessage }
/** A collapsed run of one turn's work. `messages` is the whole of it, in order, for when it is opened. */
| { kind: 'fold'; key: string; messages: ChatMessage[]; counts: FoldCounts }
/**
* A turn that finished cleanly without saying anything. Rare, and disproportionately confusing: the
* composer re-enables and nothing appears, which is indistinguishable from a turn that died. Deliberately
* synthesised as a seam rather than as prose — words in my voice that I did not write are a lie, and the
* next time it happened you would not know which kind of row you were reading.
*/
| { kind: 'turn-end'; key: string };
/** Stable enough to survive a prepend of older messages, which shifts every index in the list. */
function keyOf(message: ChatMessage, fallback: number): string {
switch (message.role) {
case 'tool':
return `t:${message.toolCallId || fallback}`;
case 'task':
return `k:${message.taskId || fallback}`;
case 'assistant':
return `a:${message.id ?? `${fallback}:${message.text.slice(0, 24)}`}`;
case 'divider':
return `d:${message.sessionId}`;
case 'user':
return `u:${fallback}:${message.text.slice(0, 24)}`;
default:
return `${message.role}:${fallback}`;
}
}
type TurnParams = {
segment: ChatMessage[];
/** Index of `segment[0]` in the full list, so keys stay unique across turns. */
offset: number;
/** False for the live turn: its work stays on screen. */
fold: boolean;
/** Whether the turn has finished — the live turn has not, while a response is still streaming. */
complete: boolean;
};
function buildTurn({ segment, offset, fold, complete }: TurnParams): ChatRow[] {
const rows: ChatRow[] = [];
const push = (message: ChatMessage, i: number) =>
rows.push({ kind: 'message', key: keyOf(message, offset + i), message });
// Your message is never folded — it is the question the whole turn is an answer to.
let start = 0;
if (segment[0]?.role === 'user') {
push(segment[0], 0);
start = 1;
}
// The exposed tail is the trailing run that isn't work: my final prose, and whatever settled the turn.
let tailStart = segment.length;
while (tailStart > start && !isWork(segment[tailStart - 1]!)) tailStart--;
const head = segment.slice(start, tailStart);
const tail = segment.slice(tailStart);
if (!fold) {
head.forEach((message, i) => push(message, start + i));
} else {
// Structural rows split the run rather than disappearing into it: "5 tool calls" hiding a `/clear`
// would misreport what happened to the conversation, not just to the work.
let run: ChatMessage[] = [];
let runAt = start;
const flush = () => {
if (!run.length) return;
const counts: FoldCounts = {
tools: run.filter((m) => m.role === 'tool').length,
notes: run.filter(isAnswer).length,
tasks: run.filter((m) => m.role === 'task').length,
failed: run.filter((m) => m.role === 'tool' && m.isError === true).length,
};
rows.push({ kind: 'fold', key: `f:${keyOf(run[0]!, runAt)}`, messages: run, counts });
run = [];
};
head.forEach((message, i) => {
if (isStructural(message)) {
flush();
push(message, start + i);
runAt = start + i + 1;
return;
}
if (!run.length) runAt = start + i;
run.push(message);
});
flush();
}
// Nothing was said and nothing went wrong — say so, rather than ending on a collapsed tool call.
if (complete && !tail.some(isAnswer) && !tail.some(isFailure) && (head.length > 0 || tail.length > 0)) {
rows.push({ kind: 'turn-end', key: `e:${offset}` });
}
tail.forEach((message, i) => push(message, tailStart + i));
return rows;
}
/**
* Group a flat transcript into display rows. Only the turn after your last message stays open.
*/
export function buildRows(messages: ChatMessage[], isGenerating: boolean): ChatRow[] {
if (!messages.length) return [];
const userAt: number[] = [];
messages.forEach((m, i) => {
if (m.role === 'user') userAt.push(i);
});
// Everything from your most recent message onward is live. A transcript that opens mid-conversation may
// have no user message at all in its window; then nothing has been superseded and nothing folds.
const liveStart = userAt.length ? userAt[userAt.length - 1]! : 0;
const bounds = [0, ...userAt.filter((i) => i > 0 && i < liveStart), liveStart].filter(
(v, i, a) => i === 0 || v !== a[i - 1],
);
const rows: ChatRow[] = [];
for (let i = 0; i < bounds.length - 1; i++) {
const from = bounds[i]!;
const to = bounds[i + 1]!;
rows.push(...buildTurn({ segment: messages.slice(from, to), offset: from, fold: true, complete: true }));
}
rows.push(
...buildTurn({
segment: messages.slice(liveStart),
offset: liveStart,
fold: false,
complete: !isGenerating,
}),
);
return rows;
}
/** "5 tool calls · 2 messages · 1 failed" — what you gave up by folding, so you can tell if you want it back. */
export function foldLabel(counts: FoldCounts): string {
const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`;
const parts: string[] = [];
if (counts.tools) parts.push(plural(counts.tools, 'tool call'));
if (counts.notes) parts.push(plural(counts.notes, 'message'));
if (counts.tasks) parts.push(plural(counts.tasks, 'background task'));
if (!parts.length) parts.push('nothing to show');
if (counts.failed) parts.push(`${counts.failed} failed`);
return parts.join(' · ');
}