keep a turn's work open until you supersede it, and stay pinned to it

tool rows used to close on a five-second timer, so the list shifted under you
while you were reading it and a call you had opened re-collapsed on its own.
open-ness is now derived — a row is open because it belongs to the live turn,
not because it rendered recently — and your own toggle lives above the
virtualiser, which was throwing it away every time a row scrolled out.

the previous turn now folds when you send the next message, and it folds
visibly: the new fold is born holding the rows it replaces, so the height is
unchanged across the swap, then closes over 260ms. reopening an old
conversation still renders collapsed; expanding work is only a service to
someone watching it happen.

autoscroll was failing for three compounding reasons. it was smooth, and a
smooth scroll is by definition away from the bottom for its whole duration,
so new content interrupting it left the view stranded. leaving the bottom was
read as intent regardless of cause, so the interrupted scroll — and any row
measuring past its 150px estimate — silently disarmed pinning until you
scrolled down by hand. and nothing watched for the ResizeObserver correction
that arrives after the estimate, which keeping tool rows open made much worse.
pinning is instant now, intent comes from real gestures, and the list re-pins
when the measured total changes.
This commit is contained in:
2026-08-07 17:29:16 +00:00
parent 90c546c098
commit dc5ad28aa2
9 changed files with 365 additions and 159 deletions
@@ -1,5 +1,5 @@
import type { KeyboardEvent } from 'react';
import { useState, useRef, useEffect } from 'react';
import { useState, useRef, useEffect, useCallback } from 'react';
import { useUserVisibleModels } from 'state/useModels';
import { useChat, type UseChatType } from '../../../hooks/useChat';
import { useAttachments } from '../useAttachments';
@@ -76,6 +76,10 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp
const bottomRef = useRef<HTMLDivElement | null>(null);
const userScrolledRef = useRef(false);
const didInitialScrollRef = useRef(false);
// When the reader last touched the scroller. Scroll *position* cannot tell a deliberate scroll-up from
// the list growing under an animation or a row measuring taller than its estimate, and reading it as
// intent is what used to switch auto-scroll off mid-turn and leave it off.
const gestureAtRef = useRef(0);
// The prompt currently in flight, kept verbatim so a stop can put it back in the composer.
const lastSentRef = useRef('');
@@ -206,31 +210,49 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp
};
}, []);
// Keep the view pinned to the bottom as messages/stream grow — but never yank it down when the user
// has scrolled up (e.g. to read history or to trigger lazy-loading of older messages). The first pass
// is an instant jump (repeated on the next frame so it lands cleanly once the tail window measures);
// later passes animate. Prepending older messages doesn't fire this — the user has scrolled up by then.
useEffect(() => {
/**
* Pin to the bottom, unless the reader has deliberately scrolled away.
*
* Instant, never smoothed. A smooth scroll takes hundreds of milliseconds during which the view is *not*
* at the bottom, and a turn producing output interrupts it long before it arrives — so the chase never
* finished, and every frame of it read as the reader having scrolled up. Instant lands in one event and
* cannot be outrun by the content it is chasing.
*/
const stickToBottom = useCallback(() => {
if (userScrolledRef.current) return;
const toBottom = (behavior: ScrollBehavior) => bottomRef.current?.scrollIntoView({ behavior });
if (!didInitialScrollRef.current) {
bottomRef.current?.scrollIntoView({ behavior: 'auto' });
}, []);
// Two frames on the first pass, so the jump repeats once the tail window has measured itself. Prepending
// older messages doesn't fire this — the reader has scrolled up by then and `stickToBottom` declines.
useEffect(() => {
stickToBottom();
if (didInitialScrollRef.current) return;
didInitialScrollRef.current = true;
toBottom('auto');
requestAnimationFrame(() => toBottom('auto'));
} else {
toBottom('smooth');
}
}, [messages, streamingText]);
requestAnimationFrame(stickToBottom);
}, [messages, streamingText, stickToBottom]);
// Detect user scrolling up; near the top, lazy-load the next older window (reverse infinite scroll).
useEffect(() => {
const viewport = scrollViewportRef.current;
if (!viewport) return;
// Anything that can move a scroller by hand: the wheel, a drag on the bar, a finger, the keyboard.
// Momentum after a flick needs no window of its own — the gesture itself has already carried the view
// off the bottom and set the flag, and only arriving back at the bottom clears it.
const GESTURE_MS = 700;
const markGesture = () => {
gestureAtRef.current = performance.now();
};
const handleScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = viewport;
const atBottom = scrollHeight - scrollTop - clientHeight < 60;
userScrolledRef.current = !atBottom;
// Being at the bottom is unambiguous, so it always re-arms. Leaving the bottom only counts when the
// reader did it: otherwise a row measuring taller than its 150px estimate, or a fold shrinking, reads
// as "they want to stay up here" and auto-scroll never comes back on.
if (atBottom) userScrolledRef.current = false;
else if (performance.now() - gestureAtRef.current < GESTURE_MS) userScrolledRef.current = true;
setShowJumpToBottom(!atBottom);
// Only after the initial bottom-anchor has run, so the mount's scrollTop=0 doesn't trigger a fetch.
if (didInitialScrollRef.current && hasMoreOlderRef.current && !isLoadingOlderRef.current && scrollTop < 200) {
@@ -239,7 +261,17 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp
};
viewport.addEventListener('scroll', handleScroll);
return () => viewport.removeEventListener('scroll', handleScroll);
viewport.addEventListener('wheel', markGesture, { passive: true });
viewport.addEventListener('touchmove', markGesture, { passive: true });
viewport.addEventListener('mousedown', markGesture);
viewport.addEventListener('keydown', markGesture);
return () => {
viewport.removeEventListener('scroll', handleScroll);
viewport.removeEventListener('wheel', markGesture);
viewport.removeEventListener('touchmove', markGesture);
viewport.removeEventListener('mousedown', markGesture);
viewport.removeEventListener('keydown', markGesture);
};
}, []);
// Focus textarea on mount
@@ -299,6 +331,7 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp
availableModels,
showJumpToBottom,
jumpToBottom,
stickToBottom,
commandFeedback,
textareaRef,
scrollViewportRef,
@@ -139,11 +139,11 @@ type MessageBubbleProps = {
message: ChatMessage;
onAnswer?: (text: string) => void;
onRetry?: (prompt: string) => void;
/** Set only for the turn in progress; tool rows use it to open themselves while they run. */
live?: boolean;
/** Set for rows in the turn you are watching; tool rows start expanded and stay that way until it folds. */
defaultOpen?: boolean;
};
export const MessageBubble = ({ message, onAnswer, onRetry, live }: MessageBubbleProps) => {
export const MessageBubble = ({ message, onAnswer, onRetry, defaultOpen }: MessageBubbleProps) => {
switch (message.role) {
case 'user': {
const rawText = typeof message.text === 'string' ? message.text : '';
@@ -214,7 +214,7 @@ export const MessageBubble = ({ message, onAnswer, onRetry, live }: MessageBubbl
if (message.toolName === 'question' && onAnswer) {
return <QuestionActivity message={message} onAnswer={onAnswer} />;
}
return <ToolActivity message={message} live={live} />;
return <ToolActivity message={message} defaultOpen={defaultOpen} />;
case 'result':
return (
@@ -5,6 +5,8 @@ import { measureHeight } from 'helpers/measure-zoomed';
import type { UseEmbeddableChatType } from '../EmbeddableChat/useEmbeddableChat';
import { MessageBubble, StreamingBubble } from './MessageBubble';
import { buildRows } from './turn-rows';
import { useFoldExit } from './fold-exit';
import { ToolOpenProvider } from './tool-open';
import { TurnEnd, TurnFold } from './TurnFold';
const OVERSCAN = 5;
@@ -24,6 +26,7 @@ export const MessageList = ({ manager }: MessageListProps) => {
scrollViewportRef,
bottomRef,
isLoadingOlder,
stickToBottom,
} = manager;
// The live turn stays open; everything before it is folded. See `turn-rows.ts`.
@@ -39,16 +42,42 @@ export const MessageList = ({ manager }: MessageListProps) => {
return next;
});
// Whether a turn has run while this list has been mounted. Tool rows expand themselves so you can watch
// work happen, which is only a service to someone who is watching — reopening yesterday's conversation
// should hand you a readable page, not every output it ever produced. Never reset: sending the next
// message starts generating again, so the flag is already true by the time the new turn has any rows.
const watched = useRef(false);
if (isGenerating) watched.current = true;
// The turn you just superseded, on its way out. It holds the work open for a beat and then shrinks it.
const foldExit = useFoldExit(rows, watched.current);
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => scrollViewportRef.current,
estimateSize: () => 150,
overscan: OVERSCAN,
// Rows must reconcile by identity, not by position. The default extractor keys them by index, so
// prepending older messages hands every row the key of the row above it — React then keeps the mounted
// component and swaps its props, and the measured height cache follows the slot instead of the content.
getItemKey: (index) => rows[index]?.key ?? index,
// Measured height must be in the same unit as the translateY below — layout px, which is what the
// panel zoom leaves alone. A raw getBoundingClientRect is in rendered px and stacks the bubbles.
measureElement: measureHeight,
});
// Re-pin once the rows have actually measured themselves.
//
// A new row is laid out at the 150px estimate, so the scroll that fires when the message arrives lands on
// a bottom that isn't the real one — and the correction arrives later, through a ResizeObserver, on a
// render that no message change accompanies. Nothing was watching for it. An expanded tool call is several
// times the estimate, which is why this got worse the moment tool rows stopped collapsing on a timer:
// every row of a running turn now overshoots, and the view sat a screenful short of the work.
const totalSize = virtualizer.getTotalSize();
useLayoutEffect(() => {
stickToBottom();
}, [totalSize, stickToBottom]);
// 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
@@ -78,6 +107,7 @@ export const MessageList = ({ manager }: MessageListProps) => {
</div>
)}
{rows.length > 0 && (
<ToolOpenProvider>
<div className="relative w-full" style={{ height: virtualizer.getTotalSize() }}>
{virtualizer.getVirtualItems().map((virtualRow) => {
const row = rows[virtualRow.index]!;
@@ -97,16 +127,19 @@ export const MessageList = ({ manager }: MessageListProps) => {
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}
// Deliberately not `isGenerating`: a turn's work stays open after it finishes, and
// closes when you send the next message and the turn folds behind its summary.
defaultOpen={row.live && watched.current}
/>
)}
{row.kind === 'fold' && (
<TurnFold
messages={row.messages}
counts={row.counts}
open={openFolds.has(row.key)}
// While a fold is on its way out it answers to the animation rather than to you;
// your own toggles take back over the moment it has finished closing.
open={foldExit?.key === row.key ? foldExit.open : openFolds.has(row.key)}
childrenOpen={foldExit?.key === row.key}
onToggle={() => toggleFold(row.key)}
/>
)}
@@ -116,6 +149,7 @@ export const MessageList = ({ manager }: MessageListProps) => {
);
})}
</div>
</ToolOpenProvider>
)}
{isGenerating && (
<div className="px-4 py-1.5">
@@ -1,58 +1,33 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useMemo, useState } from 'react';
import { FileText, Terminal, Pencil, Search, Globe, Wrench, ChevronRight, Bot } from 'lucide-react';
import type { ChatMessage } from '../types';
import type { DiffLine } from './line-diff';
import { diffStat, editDiff } from './line-diff';
import { CodeSurface } from './CodeSurface';
import { CopyButton } from './CopyButton';
import { useToolOpen } from './tool-open';
type ToolMessage = Extract<ChatMessage, { role: 'tool' }>;
type ToolActivityProps = {
message: ToolMessage;
/**
* True only for rows in the turn that is happening right now. Read once, at mount: a row that arrives
* while you are watching opens itself, and a row that merely re-mounts later (scrolled back into a
* virtualised list, or reloaded from the transcript) does not. Auto-expansion is a way of watching work
* happen, not a property of the transcript.
* Whether this row starts expanded — true for the turn you are watching happen, false everywhere else.
*
* This used to be a clock: the row stamped its own arrival, held itself open for five seconds and then
* collapsed on a timer. That made a running turn shift under the reader continuously, each row changing
* height at a moment nobody chose, and it made the answer depend on when a virtualised row happened to
* first render rather than on when the call happened. Expansion is now a fact about position in the
* conversation, so a row only changes height when you click it or when you move on to the next turn.
*/
live?: boolean;
defaultOpen?: boolean;
};
/**
* How long a finished call stays open before it collapses. Long enough to read what came back, short
* enough that a burst of fast calls doesn't leave six panels stacked on screen. Each row runs its own
* clock from its own result, so parallel calls stagger out naturally instead of needing to be sequenced.
* Expanded output is a glance, not a read: the cap keeps a 400-line file from taking the viewport, and
* `CodeSurface`'s own "Show more" gives the whole thing in one click when the glance isn't enough.
*/
const AUTO_HOLD_MS = 5000;
/** Auto-expanded output is a glance, not a read: the cap keeps a 400-line file from taking the viewport. */
const AUTO_MAX_LINES = 10;
const MANUAL_MAX_LINES = 20;
/**
* When each call was first seen and when it settled, keyed by tool call id and held outside React.
*
* Two things force it out of the component. Rows are virtualised, so a component unmounting is not the
* call ending and remounting is not a new call — state inside the row would make a scroll look like a
* fresh tool run. And a fast call (`git status`, `wc`) can have its start and its result render together,
* so a row that only opens when it *catches* the unresolved state never opens at all: the very calls
* quick enough to batch are the ones that appeared not to work.
*
* Recording the moment instead of the transition makes both cases the same case — the row asks how long
* ago this call finished, not whether it happened to be watching when it did.
*/
const seen = new Map<string, { at: number; resolved?: number }>();
function trackCall(id: string, pending: boolean): { at: number; resolved?: number } {
let entry = seen.get(id);
if (!entry) {
entry = { at: Date.now() };
seen.set(id, entry);
}
if (!pending && entry.resolved === undefined) entry.resolved = Date.now();
return entry;
}
const MAX_LINES = 10;
const toolIcons: Record<string, typeof FileText> = {
Read: FileText,
@@ -208,38 +183,15 @@ function langForPath(path: unknown): string {
return EXT_LANG[ext] ?? ext;
}
export const ToolActivity = ({ message, live }: ToolActivityProps) => {
export const ToolActivity = ({ message, defaultOpen }: ToolActivityProps) => {
const Icon = toolIcons[message.toolName] ?? Wrench;
const { head, tail } = splitSummary(getToolSummary(message.toolName, message.toolInput));
const pending = message.output === undefined;
const isError = message.isError === true;
// Read once: the hold has to survive the turn ending under it, because the last call of a turn settles
// at roughly the moment `live` goes false and has as much right to its five seconds as the rest.
const canAuto = useRef(live === true);
// Written during render, deliberately: the first render is where the decision is made, so a cache
// populated in an effect would always be one render too late for the row that needs it most.
const call = canAuto.current ? trackCall(message.toolCallId, pending) : null;
// null until you touch the row, and never reset: your click outranks the clock for as long as the row
// exists. A control that undoes itself two seconds later is worse than no control.
const [override, setOverride] = useState<boolean | null>(null);
const [, retick] = useState(0);
const settledFor = call?.resolved === undefined ? 0 : Date.now() - call.resolved;
const autoOpen = call !== null && (pending || settledFor < AUTO_HOLD_MS);
// One timer per row, for whatever is left of its own hold — which is what lets parallel calls stagger:
// three greps fired together collapse in the order they came back, not all at once.
useEffect(() => {
if (!call || pending || call.resolved === undefined) return;
const left = call.resolved + AUTO_HOLD_MS - Date.now();
if (left <= 0) return;
const timer = setTimeout(() => retick((n) => n + 1), left);
return () => clearTimeout(timer);
}, [call, pending]);
const open = override ?? autoOpen;
// Your click is remembered above the row and outranks the default for as long as the conversation is
// open — including across the unmount a virtualised list does whenever this scrolls out of view.
const [open, toggle] = useToolOpen(message.toolCallId, defaultOpen === true);
const children = message.children ?? [];
const hint = pending || isError ? null : outputHint(message.toolName, message.output ?? '');
@@ -249,11 +201,11 @@ export const ToolActivity = ({ message, live }: ToolActivityProps) => {
return (
<div className="my-1">
{/* A failure reads as a failure while collapsed. It behaves like every other row — same clock, same
auto-collapse — and differs only in colour, so a call that went wrong stays findable by scanning
rather than by opening things, and stays nameable in conversation without either of us opening it. */}
{/* A failure reads as a failure while collapsed. It behaves like every other row — same default, same
folding — and differs only in colour, so a call that went wrong stays findable by scanning rather
than by opening things, and stays nameable in conversation without either of us opening it. */}
<button
onClick={() => setOverride(!open)}
onClick={toggle}
className={`flex w-full cursor-pointer items-center gap-2 rounded-md px-3 py-1.5 text-left text-sm transition-colors ${
isError ? 'bg-destructive/5 hover:bg-destructive/10' : 'hover:bg-muted'
}`}
@@ -329,7 +281,7 @@ export const ToolActivity = ({ message, live }: ToolActivityProps) => {
toolInput={message.toolInput}
output={message.output}
isError={isError}
maxLines={override === null ? AUTO_MAX_LINES : MANUAL_MAX_LINES}
maxLines={MAX_LINES}
/>
</div>
)}
@@ -1,13 +1,20 @@
import { useEffect, useState } from 'react';
import { ChevronRight, CheckCircle2 } from 'lucide-react';
import type { ChatMessage } from '../types';
import type { FoldCounts } from './turn-rows';
import { foldLabel } from './turn-rows';
import { foldLabel, keyOf } from './turn-rows';
import { COLLAPSE_MS } from './fold-exit';
import { MessageBubble } from './MessageBubble';
type TurnFoldProps = {
messages: ChatMessage[];
counts: FoldCounts;
open: boolean;
/**
* Expand the tool rows inside. Set only while this fold is closing over work you were watching a moment
* ago, so it shrinks from what was actually on your screen rather than snapping to a collapsed copy of it.
*/
childrenOpen?: boolean;
onToggle: () => void;
};
@@ -18,7 +25,22 @@ type TurnFoldProps = {
* 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) => (
export const TurnFold = ({ messages, counts, open, childrenOpen, onToggle }: TurnFoldProps) => {
// The content has to outlive the close by the length of the animation. A fold that unmounts its work on
// the same frame it closes has nothing left to shrink, so it vanishes instead of collapsing — which is
// exactly the jump this is here to remove.
const [keep, setKeep] = useState(false);
useEffect(() => {
if (open) {
setKeep(true);
return;
}
if (!keep) return;
const timer = setTimeout(() => setKeep(false), COLLAPSE_MS);
return () => clearTimeout(timer);
}, [open, keep]);
return (
<div className="my-1">
<button
type="button"
@@ -36,15 +58,30 @@ export const TurnFold = ({ messages, counts, open, onToggle }: TurnFoldProps) =>
<span className="h-px flex-1 bg-border" />
</button>
{open && (
{/* `0fr` → `1fr` is the one way to animate to a height nobody can know in advance — a turn's work is
whatever it is. The inner `overflow-hidden` is what makes the row clip rather than spill while the
track shrinks, and the virtualiser's own ResizeObserver follows the height down, so the rows below
ride the animation instead of teleporting when it ends. */}
<div
className="grid transition-[grid-template-rows] ease-out"
style={{ gridTemplateRows: open ? '1fr' : '0fr', transitionDuration: `${COLLAPSE_MS}ms` }}
>
<div className="overflow-hidden">
{(open || keep) && (
<div className="mt-1 space-y-1.5 border-l-2 border-border pl-2">
{/* Keyed by identity, not position: folded work is where an opened tool row is most likely to
be re-ordered under you by a prepend, and an index key reattaches it to whatever now sits
there. */}
{messages.map((message, i) => (
<MessageBubble key={i} message={message} />
<MessageBubble key={keyOf(message, i)} message={message} defaultOpen={childrenOpen} />
))}
</div>
)}
</div>
</div>
</div>
);
};
/**
* A turn that ended without a closing message. Written as a seam, never as prose — see `turn-rows.ts`.
@@ -0,0 +1,59 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import type { ChatRow } from './turn-rows';
/**
* A beat between your message landing and the previous turn folding away, so the two read as cause and
* effect rather than as one simultaneous lurch. Short enough that it is a rhythm, not a wait.
*/
const HOLD_MS = 180;
/** Long enough to read as movement, short enough not to be a delay. `TurnFold` animates over the same span. */
export const COLLAPSE_MS = 260;
/** The fold that is closing right now: `open` is what it should be showing this frame. */
export type FoldExit = { key: string; open: boolean };
/**
* Turn the fold into something you can watch happen.
*
* Folding is not a collapse — `buildRows` replaces a turn's work with a single summary row, so the rows you
* were reading are gone on the same frame the summary arrives and there is nothing left to animate. The way
* back is to let the new fold be *born open*, holding exactly the messages it replaced, and then close it:
* the list keeps the same total height across the swap and the movement afterwards is a real height change.
*
* Only the newest fold, and only once. `enabled` is the same "have I watched a turn run here" question the
* tool rows ask — opening an old conversation should render its folds closed, not play twenty animations at
* you. Requiring the fold to be the *last* one keeps a prepend of older messages, which mints fold keys
* above the ones already on screen, from being mistaken for a turn you just finished.
*/
export function useFoldExit(rows: ChatRow[], enabled: boolean): FoldExit | null {
const [exit, setExit] = useState<FoldExit | null>(null);
// null until the first pass, which is how "these folds were already here when I arrived" is told apart
// from "this fold just appeared". Every fold on a freshly loaded transcript is new; none of them is recent.
const knownFolds = useRef<ReadonlySet<string> | null>(null);
useLayoutEffect(() => {
const foldKeys = rows.filter((row) => row.kind === 'fold').map((row) => row.key);
const known = knownFolds.current;
knownFolds.current = new Set(foldKeys);
if (!known || !enabled) return;
const newest = foldKeys[foldKeys.length - 1];
if (!newest || known.has(newest)) return;
setExit({ key: newest, open: true });
}, [rows, enabled]);
useEffect(() => {
if (!exit?.open) return;
const timer = setTimeout(() => setExit((prev) => (prev ? { ...prev, open: false } : null)), HOLD_MS);
return () => clearTimeout(timer);
}, [exit]);
useEffect(() => {
if (!exit || exit.open) return;
const timer = setTimeout(() => setExit(null), COLLAPSE_MS);
return () => clearTimeout(timer);
}, [exit]);
return exit;
}
@@ -0,0 +1,55 @@
import { createContext, useContext, useMemo, useState, type ReactNode } from 'react';
/**
* Which tool rows you have opened or closed by hand, held above the rows themselves.
*
* Rows are virtualised, so a row unmounting is not the call ending and remounting is not a new call. State
* kept inside the row is thrown away the moment it scrolls past the overscan window — which is exactly the
* reasoning that already lifted the turn folds out of `TurnFold` and into `MessageList`. The tool rows were
* left behind, so scrolling away from a call you had expanded, or any re-render that shifted the list,
* silently closed it again.
*
* Only your explicit clicks live here. The default — open while you are watching the turn happen, collapsed
* once you have moved on — is derived per row from the transcript, so there is nothing to seed, invalidate
* or clean up, and a reload renders the same rows as a live session.
*/
type ToolOpenStore = {
isOpen: (id: string, fallback: boolean) => boolean;
/** `current` is what the row is showing now, so a click means "the opposite of that" regardless of why. */
toggle: (id: string, current: boolean) => void;
};
const ToolOpenContext = createContext<ToolOpenStore | null>(null);
export const ToolOpenProvider = ({ children }: { children: ReactNode }) => {
const [overrides, setOverrides] = useState<ReadonlyMap<string, boolean>>(() => new Map());
const store = useMemo<ToolOpenStore>(
() => ({
isOpen: (id, fallback) => overrides.get(id) ?? fallback,
toggle: (id, current) =>
setOverrides((prev) => {
const next = new Map(prev);
next.set(id, !current);
return next;
}),
}),
[overrides],
);
return <ToolOpenContext.Provider value={store}>{children}</ToolOpenContext.Provider>;
};
/**
* A row's open state and its toggle.
*
* Falls back to component-local state when no provider is above it, so a `ToolActivity` rendered outside the
* message list still expands — it just doesn't survive being unmounted, which outside a virtualised list it
* isn't.
*/
export function useToolOpen(id: string, defaultOpen: boolean): [boolean, () => void] {
const store = useContext(ToolOpenContext);
const [local, setLocal] = useState<boolean | null>(null);
const open = store ? store.isOpen(id, defaultOpen) : (local ?? defaultOpen);
return [open, () => (store ? store.toggle(id, open) : setLocal(!open))];
}
@@ -139,6 +139,29 @@ describe('buildRows', () => {
});
});
/** What tool rows key their expansion off. Getting this wrong expands a whole transcript at once. */
describe('buildRows — the live flag', () => {
const live = (messages: ChatMessage[], isGenerating = false) =>
buildRows(messages, isGenerating)
.filter((r) => r.kind === 'message')
.map((r) => (r.kind === 'message' ? r.live : null));
test('the current turn is live, and stays live after it finishes', () => {
expect(live([user('go'), tool('a'), say('answer')], true)).toEqual([true, true, true]);
expect(live([user('go'), tool('a'), say('answer')], false)).toEqual([true, true, true]);
});
test('a superseded 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]);
});
test('a window with no user message is unfolded but not live', () => {
// Nothing is current when there is no boundary to find which turn is. Marking it live would open every
// tool call in a lazily-loaded chunk the moment it scrolled into view.
expect(live([say('note'), tool('a'), say('answer')])).toEqual([false, false, false]);
});
});
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');
@@ -40,7 +40,12 @@ export type FoldCounts = {
};
export type ChatRow =
| { kind: 'message'; key: string; message: ChatMessage }
/**
* `live` marks the turn that is happening now — the one after your most recent message. It is what tool
* rows key their default expansion off, so "expanded" is a fact about where the row sits in the
* conversation rather than about how long ago it rendered.
*/
| { 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. */
| { kind: 'fold'; key: string; messages: ChatMessage[]; counts: FoldCounts }
/**
@@ -52,7 +57,7 @@ export type ChatRow =
| { 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 {
export function keyOf(message: ChatMessage, fallback: number): string {
switch (message.role) {
case 'tool':
return `t:${message.toolCallId || fallback}`;
@@ -77,12 +82,14 @@ type TurnParams = {
fold: boolean;
/** Whether the turn has finished — the live turn has not, while a response is still streaming. */
complete: boolean;
/** Stamped onto every row of this turn. Not simply `!fold` — see `buildRows`. */
live: boolean;
};
function buildTurn({ segment, offset, fold, complete }: TurnParams): ChatRow[] {
function buildTurn({ segment, offset, fold, complete, live }: TurnParams): ChatRow[] {
const rows: ChatRow[] = [];
const push = (message: ChatMessage, i: number) =>
rows.push({ kind: 'message', key: keyOf(message, offset + i), message });
rows.push({ kind: 'message', key: keyOf(message, offset + i), message, live });
// Your message is never folded — it is the question the whole turn is an answer to.
let start = 0;
@@ -161,7 +168,9 @@ export function buildRows(messages: ChatMessage[], isGenerating: boolean): ChatR
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(from, to), offset: from, fold: true, complete: true, live: false }),
);
}
rows.push(
...buildTurn({
@@ -169,6 +178,10 @@ export function buildRows(messages: ChatMessage[], isGenerating: boolean): ChatR
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,
}),
);