fix(chat): hold bottom on first session open while late async content lands

On the first open of a session, late async data (most visibly a task/subagent
tool whose nested rows are fetched from the child session after entry) grew the
timeline a beat after the one-shot entry pin, stranding the viewport mid-history.
The steady-state idle gate intentionally ignores that growth, so re-pinning could
not recover it. Add a short, gesture-cancellable entry-stick window that forces
the bottom on every growth until content quiesces (or the user scrolls), covering
both the ResizeObserver and the structural notifyContentChange path.
This commit is contained in:
Bohdan Triapitsyn
2026-06-30 03:05:33 +03:00
parent a5e4fb3b21
commit 9e6d0df942
+93 -5
View File
@@ -79,6 +79,17 @@ const AUTO_MATCH_TOLERANCE_PX = 2;
// After streaming stops, keep following the bottom for a short window so the
// final content can settle into place.
const SETTLE_MS = 300;
// Entry-stick window. On the FIRST open of a session, late async data (most
// visibly a task/subagent tool whose nested rows are fetched from the child
// session after entry — see useEnsureSessionMessages in ToolPart.tsx) grows the
// timeline a beat or two AFTER we have already pinned to the bottom, leaving the
// viewport stranded mid-history. The steady-state idle gate deliberately ignores
// that growth (it can't tell entry from a user reading idle history). So instead
// of weakening the gate, we open a short, gesture-cancellable window on entry
// during which we FORCE the bottom on every growth. It ends QUIESCENCE_MS after
// growth stops (capped by MAX_MS), or instantly on any real user scroll gesture.
const ENTRY_STICK_QUIESCENCE_MS = 600;
const ENTRY_STICK_MAX_MS = 8000;
const now = (): number => (typeof performance !== 'undefined' ? performance.now() : Date.now());
@@ -173,6 +184,12 @@ export const useChatAutoFollow = ({
const autoRef = React.useRef<{ top: number; time: number } | null>(null);
const autoTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
// Entry-stick window state (see ENTRY_STICK_* above).
const entryStickRef = React.useRef(false);
const entryStickQuietTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const entryStickCapTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const entryStickLastHeightRef = React.useRef(0);
const saveTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingSaveRef = React.useRef<{ sessionId: string; anchor: number } | null>(null);
// When restoreSnapshot is invoked while ChatViewport is still hydrating
@@ -234,6 +251,48 @@ export const useChatAutoFollow = ({
return Math.abs(el.scrollTop - a.top) < AUTO_MATCH_TOLERANCE_PX;
}, []);
// ── entry-stick window ───────────────────────────────────────────────────
const endEntryStick = React.useCallback(() => {
entryStickRef.current = false;
if (entryStickQuietTimerRef.current) {
clearTimeout(entryStickQuietTimerRef.current);
entryStickQuietTimerRef.current = null;
}
if (entryStickCapTimerRef.current) {
clearTimeout(entryStickCapTimerRef.current);
entryStickCapTimerRef.current = null;
}
}, []);
// (Re)arm the quiescence timer: the window closes this long after the last
// growth. Called once on begin and again on every growth-driven re-pin.
const armEntryStickQuiet = React.useCallback(() => {
if (entryStickQuietTimerRef.current) {
clearTimeout(entryStickQuietTimerRef.current);
}
entryStickQuietTimerRef.current = setTimeout(() => {
entryStickQuietTimerRef.current = null;
endEntryStick();
}, ENTRY_STICK_QUIESCENCE_MS);
}, [endEntryStick]);
const beginEntryStick = React.useCallback(() => {
const el = scrollRef.current;
if (!el) return;
entryStickRef.current = true;
entryStickLastHeightRef.current = el.scrollHeight;
armEntryStickQuiet();
// Reset the absolute cap fresh on every entry (e.g. session switch) so a
// stale cap from a previous open can't cut this window short.
if (entryStickCapTimerRef.current) {
clearTimeout(entryStickCapTimerRef.current);
}
entryStickCapTimerRef.current = setTimeout(() => {
entryStickCapTimerRef.current = null;
endEntryStick();
}, ENTRY_STICK_MAX_MS);
}, [armEntryStickQuiet, endEntryStick]);
// ── overflow / scroll-to-bottom button ──────────────────────────────────
const updateOverflowAndButton = React.useCallback(() => {
const container = scrollRef.current;
@@ -322,8 +381,11 @@ export const useChatAutoFollow = ({
}, [setStateValue, updateOverflowAndButton]);
const releaseFromUserIntent = React.useCallback(() => {
// A genuine user gesture (wheel/touch/key/scrollbar) cancels the entry
// window immediately so we never fight the user's read position.
endEntryStick();
stop();
}, [stop]);
}, [endEntryStick, stop]);
// ── per-session snapshot persistence (kept; restore still goes to bottom) ─
const flushSave = React.useCallback(() => {
@@ -389,9 +451,13 @@ export const useChatAutoFollow = ({
// history measures in, so there is no smooth scroll-from-mid artifact.
setStateValue('following');
scrollToBottom(true);
// Hold the bottom across late async growth (e.g. task/subagent child
// session data landing a beat after entry) until content quiesces or the
// user scrolls.
beginEntryStick();
updateOverflowAndButton();
return false;
}, [scrollToBottom, setStateValue, updateOverflowAndButton]);
}, [beginEntryStick, scrollToBottom, setStateValue, updateOverflowAndButton]);
// ── session change ───────────────────────────────────────────────────────
React.useEffect(() => {
@@ -570,6 +636,18 @@ export const useChatAutoFollow = ({
return;
}
updateOverflowAndButton();
// Entry-stick window: on first session open, FORCE the bottom on
// every growth so late async data (task/subagent child rows, code
// highlight, mermaid) can't strand the viewport mid-history. Force
// overrides any false `released` from the growth itself; only a real
// user gesture clears the window (releaseFromUserIntent).
if (entryStickRef.current && el) {
const grew = el.scrollHeight > entryStickLastHeightRef.current + 1;
entryStickLastHeightRef.current = el.scrollHeight;
scrollToBottom(true);
if (grew) armEntryStickQuiet();
return;
}
// Idle resize = layout churn (virtualizer re-measurement, async
// tool/code rendering), NOT live growth. Never re-pin when idle, or
// tall items re-measuring as the user scrolls cause an endless
@@ -584,7 +662,7 @@ export const useChatAutoFollow = ({
observer.observe(inner);
}
return () => observer.disconnect();
}, [containerEl, isActive, scrollToBottom, setStateValue, updateOverflowAndButton]);
}, [armEntryStickQuiet, containerEl, isActive, scrollToBottom, setStateValue, updateOverflowAndButton]);
React.useEffect(() => {
updateOverflowAndButton();
@@ -593,10 +671,19 @@ export const useChatAutoFollow = ({
const notifyContentChange = React.useCallback((_reason?: ContentChangeReason) => {
void _reason;
updateOverflowAndButton();
// Entry-stick window: late structural growth (notably the task/subagent
// summary landing from the child session — ToolPart emits 'structural'
// here) must keep us pinned and refresh the quiescence timer, even though
// the session is idle.
if (entryStickRef.current) {
scrollToBottom(true);
armEntryStickQuiet();
return;
}
if (stateRef.current === 'following') {
scrollToBottom(false);
}
}, [scrollToBottom, updateOverflowAndButton]);
}, [armEntryStickQuiet, scrollToBottom, updateOverflowAndButton]);
const animationHandlersRef = React.useRef<Map<string, AnimationHandlers>>(new Map());
@@ -635,13 +722,14 @@ export const useChatAutoFollow = ({
clearTimeout(settleTimerRef.current);
settleTimerRef.current = null;
}
endEntryStick();
flushSave();
if (saveTimerRef.current !== null) {
clearTimeout(saveTimerRef.current);
saveTimerRef.current = null;
}
};
}, [flushSave]);
}, [endEntryStick, flushSave]);
React.useEffect(() => {
if (!onActiveTurnChange) return;