fix(chat): open a session already at its end, and keep it there

A session opened from the sidebar could land above the bottom, or show a
frame sitting lower and then snap up. The viewport was pinned before the
content was final: the recap note renders once the session record arrives
and grew the footer under the pinned viewport, and on large sessions
subagent task cards grow when their child sessions load, moving everything
above the viewport.

- The recap note holds the timeline reveal until the session record is in
  memory, so it is part of the first finished picture.
- The scroll hook holds the reveal until the viewport is pinned; the reveal
  itself runs once the content height has held still for two frames, with
  one exact pin against the final height (bounded at 300ms).
- Sitting on the end of a session that is not producing output is an
  invariant: content growth re-pins from a MutationObserver in the same
  frame the list writes its layout, so no frame paints with the end out of
  view. Output growth keeps gliding through followEnd, which now glides only
  while the session is working.
This commit is contained in:
Bohdan Triapitsyn
2026-08-29 21:17:28 +03:00
parent c2136ce838
commit d8e223bf49
6 changed files with 159 additions and 8 deletions
@@ -4,6 +4,7 @@ import { MessageFreshnessDetector } from '@/lib/messageFreshness';
import { createScrollSpy } from '@/components/chat/lib/scroll/scrollSpy';
import { useViewportStore } from '@/sync/viewport-store';
import { useUIStore } from '@/stores/useUIStore';
import type { TimelineRevealGate } from '@/components/chat/timelineRevealGate';
import {
CHAT_LIST_ANCHOR_OFFSET,
getAnchoredTurnMetrics,
@@ -76,9 +77,20 @@ interface UseChatTimelineScrollOptions {
// Id of the newest user message in the rendered timeline. When a send has
// armed the anchor, the next new id here becomes the anchored row.
lastUserMessageId: string | null;
// True while the session is producing output. Follow corrections glide
// only then. Outside a live stream — entering a session, a tab becoming
// active, rows re-measuring after a switch — the viewport must land on
// the end instantly: an animated catch-up scrolls visibly through the
// conversation and gets cut short by the next measurement.
sessionIsWorking: boolean;
// Reveal gate of the session being opened. Held until the viewport is
// pinned to the end, so the session is never shown scrolled to the top.
revealGate?: TimelineRevealGate | null;
onActiveTurnChange?: (turnId: string | null) => void;
}
export interface UseChatTimelineScrollResult {
scrollRef: React.RefObject<HTMLDivElement | null>;
// The live scroll element, as state, so effects that must re-bind when the
@@ -122,8 +134,12 @@ export const useChatTimelineScroll = ({
sessionMessageCount,
composerOverlayHeight,
lastUserMessageId,
sessionIsWorking,
revealGate = null,
onActiveTurnChange,
}: UseChatTimelineScrollOptions): UseChatTimelineScrollResult => {
const sessionIsWorkingRef = React.useRef(sessionIsWorking);
sessionIsWorkingRef.current = sessionIsWorking;
const scrollRef = React.useRef<HTMLDivElement | null>(null);
const listRef = React.useRef<TimelineListHandle | null>(null);
@@ -633,6 +649,10 @@ export const useChatTimelineScroll = ({
const end = node.scrollHeight - node.clientHeight;
const distance = end - node.scrollTop;
if (distance <= 1) return;
if (!sessionIsWorkingRef.current) {
node.scrollTop = end;
return;
}
if (distance > node.clientHeight) {
node.scrollTop = end - node.clientHeight;
}
@@ -864,6 +884,61 @@ export const useChatTimelineScroll = ({
};
}, [queueSave, realContentOverflowsViewport, scrollNode]);
// ── entry pin ───────────────────────────────────────────────────────────
// An opened session is shown once, already at its end: the reveal gate is
// held until the viewport sits on the end, and the pin is one instant
// write. The list lays its rows out before the first frame, so this
// resolves within a frame; the gate's own cap bounds the wait.
React.useLayoutEffect(() => {
if (!currentSessionKey || !scrollNode) return;
const releaseReveal = revealGate?.hold() ?? null;
let frame: number | null = null;
const settle = () => {
frame = null;
if (!userOwnsScrollRef.current && modeRef.current === 'following-end') {
const end = scrollNode.scrollHeight - scrollNode.clientHeight;
if (end - scrollNode.scrollTop > 1) scrollNode.scrollTop = end;
}
releaseReveal?.();
};
frame = requestAnimationFrame(settle);
return () => {
if (frame !== null) cancelAnimationFrame(frame);
releaseReveal?.();
};
}, [currentSessionKey, revealGate, scrollNode]);
// ── pinned end ──────────────────────────────────────────────────────────
// "At the end" is an invariant, not a one-time scroll: while the reader
// sits on the end of a session that is not producing output, any growth
// of the content (a footer that decides to render, a row re-measured)
// keeps the end in view with one instant write. Output growth belongs to
// followEnd, which glides.
React.useEffect(() => {
if (!scrollNode || typeof MutationObserver === 'undefined') return;
const content = scrollNode.firstElementChild;
if (!content) return;
const pin = () => {
if (sessionIsWorkingRef.current) return;
if (userOwnsScrollRef.current || !isAtEndRef.current || modeRef.current !== 'following-end') return;
const end = scrollNode.scrollHeight - scrollNode.clientHeight;
if (end - scrollNode.scrollTop > 1) scrollNode.scrollTop = end;
};
// A MutationObserver runs as a microtask right after the list writes
// its layout (row positions, container height), before the frame is
// painted, so the pin lands in the same frame as the growth. A
// ResizeObserver would only see the container a rendering step later
// and let one frame paint with the end out of view.
const mutations = new MutationObserver(pin);
mutations.observe(content, { childList: true, subtree: true, attributes: true, attributeFilter: ['style'] });
const resizes = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(pin);
resizes?.observe(content);
return () => {
mutations.disconnect();
resizes?.disconnect();
};
}, [scrollNode]);
// ── session lifecycle ───────────────────────────────────────────────────
const lastSessionKeyRef = React.useRef<string | null>(null);
React.useEffect(() => {
@@ -55,6 +55,8 @@ export interface SessionAssistState {
visibleRecap: string | null;
/** Suggestion text — fresh payload, session idle; caller still gates on input emptiness. */
suggestion: string | null;
/** False until the session record is in memory; the recap cannot be decided before that. */
sessionKnown: boolean;
}
export function useSessionAssistState(sessionId: string, directory?: string): SessionAssistState {
@@ -93,5 +95,6 @@ export function useSessionAssistState(sessionId: string, directory?: string): Se
assist,
visibleRecap: sessionRecapEnabled && assist && assist.recap && quietElapsed ? assist.recap : null,
suggestion: sessionSuggestionEnabled && assist && assist.suggestion ? assist.suggestion : null,
sessionKnown: session !== undefined && session !== null,
};
}