fold a turn because it is history, not because a newer one exists

Sending a message used to collapse the turn above it, live, while you were still reading it. Watching
your own conversation fold up under you as you typed the next message is worse than the scrolling it
saved.

Folding now keys off `historicalCount` — how many messages at the front of the list came from the server
rather than from this sitting. Nothing collapses while you are watching, however many turns you send;
reload, and all of it has become history and folds at once, which is where the grouping actually earns
its place.

A count rather than a set of ids because everything historical is contiguous and at the front: the
preload seeds it, paging older messages prepends to it, live turns append past it, and a resume replaces
the list with a transcript that is history in its entirety.

Two consequences worth stating rather than discovering.

The last turn is no longer exempt. It used to be excluded from folding for being the live one by
definition; now it is an ordinary turn, so a reloaded conversation folds its final turn too — except
while it is still generating, since hiding work as it arrives is the exact thing being undone.

And folding is no longer a pure derivation over the message list, so a reload does NOT render identically
to a live session. That property was deliberate and is deliberately given up; it is the feature. The
state it costs is one number in useChat, never on the wire and never on disk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 02:19:32 +01:00
co-authored by Claude Opus 5
parent 8d94d08d19
commit 77cd703d72
5 changed files with 152 additions and 62 deletions
@@ -51,6 +51,7 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp
const { const {
messages, messages,
historicalCount,
streamingText, streamingText,
isConnected, isConnected,
isGenerating, isGenerating,
@@ -464,6 +465,7 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp
return { return {
messages, messages,
historicalCount,
streamingText, streamingText,
isConnected, isConnected,
isGenerating, isGenerating,
@@ -18,6 +18,7 @@ type MessageListProps = {
export const MessageList = ({ manager }: MessageListProps) => { export const MessageList = ({ manager }: MessageListProps) => {
const { const {
messages, messages,
historicalCount,
streamingText, streamingText,
isGenerating, isGenerating,
showJumpToBottom, showJumpToBottom,
@@ -29,8 +30,11 @@ export const MessageList = ({ manager }: MessageListProps) => {
stickToBottom, stickToBottom,
} = manager; } = manager;
// The live turn stays open; everything before it is folded. See `turn-rows.ts`. // Turns from this sitting stay open; anything loaded from the server folds. See `turn-rows.ts`.
const rows = useMemo(() => buildRows(messages, isGenerating), [messages, isGenerating]); const rows = useMemo(
() => buildRows(messages, isGenerating, historicalCount),
[messages, isGenerating, historicalCount],
);
// Which folds you have opened. Lifted out of the fold itself because rows are virtualised: state held // 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. // inside one would be thrown away the moment it scrolled past the overscan window and silently snap shut.
@@ -13,9 +13,14 @@ const tool = (id: string, isError?: boolean): ChatMessage => ({
...(isError ? { isError: true } : {}), ...(isError ? { isError: true } : {}),
}); });
/** The shape under test is the row sequence, not the keys. */ /**
const shape = (messages: ChatMessage[], isGenerating = false) => * The shape under test is the row sequence, not the keys.
buildRows(messages, isGenerating).map((r) => (r.kind === 'message' ? r.message.role : r.kind)); *
* `historicalCount` defaults to the whole list — a reload, where everything came from the server. That is
* the case most of these describe; the live case sets it to 0 explicitly.
*/
const shape = (messages: ChatMessage[], isGenerating = false, historicalCount = messages.length) =>
buildRows(messages, isGenerating, historicalCount).map((r) => (r.kind === 'message' ? r.message.role : r.kind));
describe('buildRows', () => { describe('buildRows', () => {
test('the live turn is untouched — every row stays where it was', () => { test('the live turn is untouched — every row stays where it was', () => {
@@ -34,13 +39,13 @@ describe('buildRows', () => {
test('the fold counts what it hid, failures included', () => { 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 messages = [user('first'), say('note'), tool('a'), tool('b', true), say('answer'), user('second')];
const fold = buildRows(messages, false).find((r) => r.kind === 'fold'); const fold = buildRows(messages, false, messages.length).find((r) => r.kind === 'fold');
expect(fold?.kind === 'fold' && fold.counts).toEqual({ tools: 2, notes: 1, tasks: 0, failed: 1 }); 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', () => { 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 messages = [user('first'), say('note'), tool('a'), say('answer'), user('second')];
const fold = buildRows(messages, false).find((r) => r.kind === 'fold'); const fold = buildRows(messages, false, messages.length).find((r) => r.kind === 'fold');
expect(fold?.kind === 'fold' && fold.messages).toEqual([say('note'), tool('a')]); expect(fold?.kind === 'fold' && fold.messages).toEqual([say('note'), tool('a')]);
}); });
@@ -67,8 +72,10 @@ describe('buildRows', () => {
expect(shape(messages)).toEqual(['user', 'fold', 'compact', 'assistant', 'user']); expect(shape(messages)).toEqual(['user', 'fold', 'compact', 'assistant', 'user']);
}); });
// The marker tests describe a turn from this sitting, so they pass 0: whether a finished turn also
// folds is a separate question, pinned in its own tests below.
test('a turn that ends on a tool call is marked finished rather than left hanging', () => { 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']); expect(shape([user('go'), tool('a')], false, 0)).toEqual(['user', 'tool', 'turn-end']);
}); });
test('a turn still running is not marked finished', () => { test('a turn still running is not marked finished', () => {
@@ -76,11 +83,15 @@ describe('buildRows', () => {
}); });
test('a turn that failed keeps its own row and gets no marker', () => { 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']); expect(shape([user('go'), tool('a'), { role: 'error', text: 'boom' }], false, 0)).toEqual([
'user',
'tool',
'error',
]);
}); });
test('an interrupted turn gets no marker either', () => { test('an interrupted turn gets no marker either', () => {
expect(shape([user('go'), tool('a'), { role: 'interrupted' }], false)).toEqual(['user', 'tool', 'interrupted']); expect(shape([user('go'), tool('a'), { role: 'interrupted' }], false, 0)).toEqual(['user', 'tool', 'interrupted']);
}); });
test('a cost row alone is not an answer — the marker still appears, above it', () => { test('a cost row alone is not an answer — the marker still appears, above it', () => {
@@ -89,20 +100,62 @@ describe('buildRows', () => {
tool('a'), tool('a'),
{ role: 'result', cost: { inputTokens: 1, outputTokens: 1, totalUSD: 0 } }, { role: 'result', cost: { inputTokens: 1, outputTokens: 1, totalUSD: 0 } },
]; ];
expect(shape(messages, false)).toEqual(['user', 'tool', 'turn-end', 'result']); expect(shape(messages, false, 0)).toEqual(['user', 'tool', 'turn-end', 'result']);
}); });
test('an empty assistant block does not count as having answered', () => { 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']); expect(shape([user('go'), tool('a'), say(' ')], false, 0)).toEqual(['user', 'tool', 'turn-end', 'assistant']);
}); });
test('your own message is never folded', () => { test('your own message is never folded', () => {
const rows = buildRows([user('first'), tool('a'), say('answer'), user('second')], false); const rows = buildRows([user('first'), tool('a'), say('answer'), user('second')], false, 4);
expect(rows[0]?.kind === 'message' && rows[0].message).toEqual(user('first')); expect(rows[0]?.kind === 'message' && rows[0].message).toEqual(user('first'));
}); });
test('a window with no user message at all folds nothing', () => { test('a window with no user message still folds when it came from the server', () => {
expect(shape([say('note'), tool('a'), say('answer')], false)).toEqual(['assistant', 'tool', 'assistant']); // It used to stay open because there was no turn boundary to fold against. Under the history rule
// there is nothing to work out: it was loaded, so it is history.
expect(shape([say('note'), tool('a'), say('answer')], false)).toEqual(['fold', 'assistant']);
});
test('nothing folds while you are watching, however many turns you send', () => {
// The behaviour this whole rule exists for. Three complete turns, none of them loaded from the
// server: under the old rule the first two collapsed the instant the next message was sent.
const messages = [
user('1'),
tool('a'),
say('one'),
user('2'),
tool('b'),
say('two'),
user('3'),
tool('c'),
say('three'),
];
expect(shape(messages, false, 0)).toEqual([
'user',
'tool',
'assistant',
'user',
'tool',
'assistant',
'user',
'tool',
'assistant',
]);
});
test('the boundary is where the server stopped and this sitting began', () => {
// First turn loaded (6 messages), second typed here. The loaded one folds; the new one does not.
const messages = [user('1'), tool('a'), say('one'), user('2'), tool('b'), say('two')];
expect(shape(messages, false, 3)).toEqual(['user', 'fold', 'assistant', 'user', 'tool', 'assistant']);
});
test('a turn still generating is never folded, even reloaded onto mid-turn', () => {
// Reload while a turn is in flight: every message is history, but hiding the work as it arrives is
// the exact thing being undone. The finished turn above it folds as usual.
const messages = [user('1'), tool('a'), say('one'), user('2'), tool('b')];
expect(shape(messages, true, messages.length)).toEqual(['user', 'fold', 'assistant', 'user', 'tool']);
}); });
test('every older turn folds, not just the previous one', () => { test('every older turn folds, not just the previous one', () => {
@@ -117,6 +170,7 @@ describe('buildRows', () => {
tool('c'), tool('c'),
say('three'), say('three'),
]; ];
// Including the last one, which used to be exempt for being the live turn. Reloaded, nothing is live.
expect(shape(messages, false)).toEqual([ expect(shape(messages, false)).toEqual([
'user', 'user',
'fold', 'fold',
@@ -125,7 +179,7 @@ describe('buildRows', () => {
'fold', 'fold',
'assistant', 'assistant',
'user', 'user',
'tool', 'fold',
'assistant', 'assistant',
]); ]);
}); });
@@ -141,24 +195,27 @@ describe('buildRows', () => {
/** What tool rows key their expansion off. Getting this wrong expands a whole transcript at once. */ /** What tool rows key their expansion off. Getting this wrong expands a whole transcript at once. */
describe('buildRows — the live flag', () => { describe('buildRows — the live flag', () => {
const live = (messages: ChatMessage[], isGenerating = false) => const live = (messages: ChatMessage[], isGenerating = false, historicalCount = messages.length) =>
buildRows(messages, isGenerating) buildRows(messages, isGenerating, historicalCount)
.filter((r) => r.kind === 'message') .filter((r) => r.kind === 'message')
.map((r) => (r.kind === 'message' ? r.live : null)); .map((r) => (r.kind === 'message' ? r.live : null));
test('the current turn is live, and stays live after it finishes', () => { test('a turn from this sitting is live, generating or finished', () => {
expect(live([user('go'), tool('a'), say('answer')], true)).toEqual([true, true, true]); // Finishing is not what ends a turn's expansion any more — being reloaded is.
expect(live([user('go'), tool('a'), say('answer')], false)).toEqual([true, true, true]); expect(live([user('go'), tool('a'), say('answer')], true, 0)).toEqual([true, true, true]);
expect(live([user('go'), tool('a'), say('answer')], false, 0)).toEqual([true, true, true]);
}); });
test('a superseded turn is not live — only your question and my answer survive as rows', () => { test('a folded turn is not live — only your question and my answer survive as rows', () => {
expect(live([user('first'), tool('a'), say('answer'), user('second')])).toEqual([false, false, true]); expect(live([user('first'), tool('a'), say('answer'), user('second')])).toEqual([false, false, false]);
}); });
test('a window with no user message is unfolded but not live', () => { test('unfolded and live are now one claim', () => {
// Nothing is current when there is no boundary to find which turn is. Marking it live would open every // They came apart only because the old rule force-unfolded a window it could find no turn boundary in,
// tool call in a lazily-loaded chunk the moment it scrolled into view. // and opening every tool call in a lazily-loaded chunk would have been wrong. That window folds now,
expect(live([say('note'), tool('a'), say('answer')])).toEqual([false, false, false]); // so there is no case left where a row is on screen unfolded but not current.
expect(live([say('note'), tool('a'), say('answer')])).toEqual([false]);
expect(live([say('note'), tool('a'), say('answer')], false, 0)).toEqual([true, true, true]);
}); });
}); });
@@ -1,15 +1,19 @@
import type { ChatMessage } from '../types'; 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 * A finished conversation is worth reading as question, summary, answer — the tool calls and running
* worth seeing in full — the moment you send the next message, the tool calls and the running commentary * commentary that produced an answer are the thing you scroll past. So a turn folds to those three parts.
* 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 * But it folds because it is HISTORY, not because a newer turn exists. That distinction is the whole
* live session: there is no fold *state* anywhere, on the wire or on disk, only a grouping that both paths * design: this used to collapse the previous turn the instant you sent the next message, and watching
* compute from the same array. * your own conversation fold up under you as you typed was worse than the scrolling it saved. Everything
* that happened while you were watching stays open for as long as the tab is; reload, and all of it has
* become history and folds at once.
*
* The cost is a property worth naming, because it was deliberate before and is deliberately given up now:
* folding is no longer a pure derivation over the message list, so a reload does NOT render identically to
* a live session. That is the feature. The state it depends on is a single count of how many messages came
* from the server — it lives in `useChat`, never on the wire and never on disk.
*/ */
/** Rows that are landmarks about the conversation rather than work done inside a turn. These never fold. */ /** Rows that are landmarks about the conversation rather than work done inside a turn. These never fold. */
@@ -41,9 +45,8 @@ export type FoldCounts = {
export type ChatRow = export type ChatRow =
/** /**
* `live` marks the turn that is happening now — the one after your most recent message. It is what tool * `live` marks a turn from this sitting rather than one loaded from the server. It is what tool rows key
* rows key their default expansion off, so "expanded" is a fact about where the row sits in the * their default expansion off, so "expanded" means "you were here when this happened".
* conversation rather than about how long ago it rendered.
*/ */
| { kind: 'message'; key: string; message: ChatMessage; live: boolean } | { kind: 'message'; key: string; message: ChatMessage; live: boolean }
/** A collapsed run of one turn's work. `messages` is the whole of it, in order, for when it is opened. */ /** A collapsed run of one turn's work. `messages` is the whole of it, in order, for when it is opened. */
@@ -78,11 +81,11 @@ type TurnParams = {
segment: ChatMessage[]; segment: ChatMessage[];
/** Index of `segment[0]` in the full list, so keys stay unique across turns. */ /** Index of `segment[0]` in the full list, so keys stay unique across turns. */
offset: number; offset: number;
/** False for the live turn: its work stays on screen. */ /** False for a turn from this sitting: its work stays on screen. */
fold: boolean; fold: boolean;
/** Whether the turn has finished — the live turn has not, while a response is still streaming. */ /** Whether the turn has finished — the live turn has not, while a response is still streaming. */
complete: boolean; complete: boolean;
/** Stamped onto every row of this turn. Not simply `!fold` — see `buildRows`. */ /** Stamped onto every row of this turn. Now simply `!fold` — see `buildRows`. */
live: boolean; live: boolean;
}; };
@@ -146,9 +149,14 @@ function buildTurn({ segment, offset, fold, complete, live }: TurnParams): ChatR
} }
/** /**
* Group a flat transcript into display rows. Only the turn after your last message stays open. * `historicalCount` is how many messages at the front of the list came from the server rather than from
* this sitting. It is what decides folding now: a turn folds because it is history, not because a newer
* turn exists. Everything that happened while you were watching stays open, however many turns you send
* after it; reload, and all of it is history and folds together.
*
* Omitting it folds nothing, which is the right default for a caller that cannot tell the two apart.
*/ */
export function buildRows(messages: ChatMessage[], isGenerating: boolean): ChatRow[] { export function buildRows(messages: ChatMessage[], isGenerating: boolean, historicalCount = 0): ChatRow[] {
if (!messages.length) return []; if (!messages.length) return [];
const userAt: number[] = []; const userAt: number[] = [];
@@ -156,34 +164,33 @@ export function buildRows(messages: ChatMessage[], isGenerating: boolean): ChatR
if (m.role === 'user') userAt.push(i); if (m.role === 'user') userAt.push(i);
}); });
// Everything from your most recent message onward is live. A transcript that opens mid-conversation may // Every user message is a turn boundary — including the last one, which used to be excluded because the
// have no user message at all in its window; then nothing has been superseded and nothing folds. // turn after it was by definition the live one. Now the final segment is an ordinary turn that folds or
const liveStart = userAt.length ? userAt[userAt.length - 1]! : 0; // not on the same rule as the rest.
const bounds = [0, ...userAt.filter((i) => i > 0), messages.length].filter((v, i, a) => i === 0 || v !== a[i - 1]);
const bounds = [0, ...userAt.filter((i) => i > 0 && i < liveStart), liveStart].filter(
(v, i, a) => i === 0 || v !== a[i - 1],
);
const rows: ChatRow[] = []; const rows: ChatRow[] = [];
for (let i = 0; i < bounds.length - 1; i++) { for (let i = 0; i < bounds.length - 1; i++) {
const from = bounds[i]!; const from = bounds[i]!;
const to = bounds[i + 1]!; const to = bounds[i + 1]!;
const isLast = to === messages.length;
// Fold only a turn whose every message is history. A turn still generating is never folded even when
// its opening messages came from the server — that is a reload onto a live turn, and hiding the work
// as it arrives is exactly the thing being undone here.
const generatingNow = isLast && isGenerating;
const fold = to <= historicalCount && !generatingNow;
rows.push( rows.push(
...buildTurn({ segment: messages.slice(from, to), offset: from, fold: true, complete: true, live: false }), ...buildTurn({
segment: messages.slice(from, to),
offset: from,
fold,
complete: !generatingNow,
// Unfolded and live are one claim now: if a turn's work is on screen, its tool rows are open. The
// two came apart only because the old rule force-unfolded a window it could find no boundary in.
live: !fold,
}),
); );
} }
rows.push(
...buildTurn({
segment: messages.slice(liveStart),
offset: liveStart,
fold: false,
complete: !isGenerating,
// Unfolded is not the same claim as live. With no user message in the window there is no turn boundary
// to find, so the whole window stays unfolded — and calling all of it live would expand every tool call
// in it at once. Nothing is current when we cannot tell which turn is.
live: userAt.length > 0,
}),
);
return rows; return rows;
} }
@@ -61,6 +61,19 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
onTurnComplete, onTurnComplete,
} = options ?? {}; } = options ?? {};
const [messages, setMessages] = useState<ChatMessage[]>(preloadedMessages ?? []); const [messages, setMessages] = useState<ChatMessage[]>(preloadedMessages ?? []);
/**
* How many messages at the FRONT of the list came from the server rather than from this sitting.
*
* Folding used to be a pure derivation over the array, which is what made a reload render identically to
* a live session. That property is deliberately given up here: a turn now folds because it is *history*,
* not because a newer turn exists. Watching your own conversation collapse under you as you typed the
* next message was the thing worth losing it for.
*
* A count rather than a set of ids because everything historical is contiguous and at the front: the
* preload seeds it, paging older messages prepends to it, and everything live is appended past it. The
* one case that replaces rather than appends is a resume, where the whole transcript is history.
*/
const [historicalCount, setHistoricalCount] = useState(preloadedMessages?.length ?? 0);
// Reverse pagination: absolute index of the oldest loaded message (0 = we have the whole transcript). // Reverse pagination: absolute index of the oldest loaded message (0 = we have the whole transcript).
const [oldestOffset, setOldestOffset] = useState(paginate?.initialOffset ?? 0); const [oldestOffset, setOldestOffset] = useState(paginate?.initialOffset ?? 0);
const [isLoadingOlder, setIsLoadingOlder] = useState(false); const [isLoadingOlder, setIsLoadingOlder] = useState(false);
@@ -135,6 +148,9 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
); );
if (res.messages.length) { if (res.messages.length) {
setMessages((prev) => [...res.messages, ...prev]); setMessages((prev) => [...res.messages, ...prev]);
// Paged-in messages are older than everything on screen, so they join the historical block at the
// front and the boundary between history and this sitting moves with them.
setHistoricalCount((count) => count + res.messages.length);
setOldestOffset(res.offset); setOldestOffset(res.offset);
} else { } else {
setOldestOffset(0); setOldestOffset(0);
@@ -366,6 +382,9 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
setSessionId(msg.sessionId); setSessionId(msg.sessionId);
const transcript = rebuildTranscript(msg.messages); const transcript = rebuildTranscript(msg.messages);
setMessages(transcript); setMessages(transcript);
// A resume replaces the list wholesale with what the server has. All of it is history — including a
// turn this tab watched arrive before it disconnected, which is now something being read back.
setHistoricalCount(transcript.length);
if (transcript.length > 0) setHasStarted(true); if (transcript.length > 0) setHasStarted(true);
setIsGenerating(msg.isGenerating); setIsGenerating(msg.isGenerating);
if (msg.streamingText) { if (msg.streamingText) {
@@ -595,6 +614,7 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
return { return {
messages, messages,
historicalCount,
streamingText, streamingText,
isConnected, isConnected,
isGenerating, isGenerating,