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:
@@ -79,6 +79,17 @@ const AUTO_MATCH_TOLERANCE_PX = 2;
|
|||||||
// After streaming stops, keep following the bottom for a short window so the
|
// After streaming stops, keep following the bottom for a short window so the
|
||||||
// final content can settle into place.
|
// final content can settle into place.
|
||||||
const SETTLE_MS = 300;
|
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());
|
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 autoRef = React.useRef<{ top: number; time: number } | null>(null);
|
||||||
const autoTimerRef = React.useRef<ReturnType<typeof setTimeout> | 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 saveTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const pendingSaveRef = React.useRef<{ sessionId: string; anchor: number } | null>(null);
|
const pendingSaveRef = React.useRef<{ sessionId: string; anchor: number } | null>(null);
|
||||||
// When restoreSnapshot is invoked while ChatViewport is still hydrating
|
// 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;
|
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 ──────────────────────────────────
|
// ── overflow / scroll-to-bottom button ──────────────────────────────────
|
||||||
const updateOverflowAndButton = React.useCallback(() => {
|
const updateOverflowAndButton = React.useCallback(() => {
|
||||||
const container = scrollRef.current;
|
const container = scrollRef.current;
|
||||||
@@ -322,8 +381,11 @@ export const useChatAutoFollow = ({
|
|||||||
}, [setStateValue, updateOverflowAndButton]);
|
}, [setStateValue, updateOverflowAndButton]);
|
||||||
|
|
||||||
const releaseFromUserIntent = React.useCallback(() => {
|
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();
|
||||||
}, [stop]);
|
}, [endEntryStick, stop]);
|
||||||
|
|
||||||
// ── per-session snapshot persistence (kept; restore still goes to bottom) ─
|
// ── per-session snapshot persistence (kept; restore still goes to bottom) ─
|
||||||
const flushSave = React.useCallback(() => {
|
const flushSave = React.useCallback(() => {
|
||||||
@@ -389,9 +451,13 @@ export const useChatAutoFollow = ({
|
|||||||
// history measures in, so there is no smooth scroll-from-mid artifact.
|
// history measures in, so there is no smooth scroll-from-mid artifact.
|
||||||
setStateValue('following');
|
setStateValue('following');
|
||||||
scrollToBottom(true);
|
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();
|
updateOverflowAndButton();
|
||||||
return false;
|
return false;
|
||||||
}, [scrollToBottom, setStateValue, updateOverflowAndButton]);
|
}, [beginEntryStick, scrollToBottom, setStateValue, updateOverflowAndButton]);
|
||||||
|
|
||||||
// ── session change ───────────────────────────────────────────────────────
|
// ── session change ───────────────────────────────────────────────────────
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -570,6 +636,18 @@ export const useChatAutoFollow = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
updateOverflowAndButton();
|
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
|
// Idle resize = layout churn (virtualizer re-measurement, async
|
||||||
// tool/code rendering), NOT live growth. Never re-pin when idle, or
|
// tool/code rendering), NOT live growth. Never re-pin when idle, or
|
||||||
// tall items re-measuring as the user scrolls cause an endless
|
// tall items re-measuring as the user scrolls cause an endless
|
||||||
@@ -584,7 +662,7 @@ export const useChatAutoFollow = ({
|
|||||||
observer.observe(inner);
|
observer.observe(inner);
|
||||||
}
|
}
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [containerEl, isActive, scrollToBottom, setStateValue, updateOverflowAndButton]);
|
}, [armEntryStickQuiet, containerEl, isActive, scrollToBottom, setStateValue, updateOverflowAndButton]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
updateOverflowAndButton();
|
updateOverflowAndButton();
|
||||||
@@ -593,10 +671,19 @@ export const useChatAutoFollow = ({
|
|||||||
const notifyContentChange = React.useCallback((_reason?: ContentChangeReason) => {
|
const notifyContentChange = React.useCallback((_reason?: ContentChangeReason) => {
|
||||||
void _reason;
|
void _reason;
|
||||||
updateOverflowAndButton();
|
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') {
|
if (stateRef.current === 'following') {
|
||||||
scrollToBottom(false);
|
scrollToBottom(false);
|
||||||
}
|
}
|
||||||
}, [scrollToBottom, updateOverflowAndButton]);
|
}, [armEntryStickQuiet, scrollToBottom, updateOverflowAndButton]);
|
||||||
|
|
||||||
const animationHandlersRef = React.useRef<Map<string, AnimationHandlers>>(new Map());
|
const animationHandlersRef = React.useRef<Map<string, AnimationHandlers>>(new Map());
|
||||||
|
|
||||||
@@ -635,13 +722,14 @@ export const useChatAutoFollow = ({
|
|||||||
clearTimeout(settleTimerRef.current);
|
clearTimeout(settleTimerRef.current);
|
||||||
settleTimerRef.current = null;
|
settleTimerRef.current = null;
|
||||||
}
|
}
|
||||||
|
endEntryStick();
|
||||||
flushSave();
|
flushSave();
|
||||||
if (saveTimerRef.current !== null) {
|
if (saveTimerRef.current !== null) {
|
||||||
clearTimeout(saveTimerRef.current);
|
clearTimeout(saveTimerRef.current);
|
||||||
saveTimerRef.current = null;
|
saveTimerRef.current = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [flushSave]);
|
}, [endEntryStick, flushSave]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!onActiveTurnChange) return;
|
if (!onActiveTurnChange) return;
|
||||||
|
|||||||
Reference in New Issue
Block a user