blob: afdb9873e548e44df8c4d8945b6ee3cf822305e6 [file]
import { useEffect, useLayoutEffect, useRef, useState, type RefObject } from 'react';
import type { StoredMessage } from '@maka/core';
import { createArrivalBottomPin, type ArrivalBottomPin } from './arrival-bottom-pin.js';
import { createTurnSizeWarmup } from './turn-size-warmup.js';
export function useChatScroll(input: {
scrollRef: RefObject<HTMLElement | null>;
sessionId?: string;
hasTurns: boolean;
messages: readonly StoredMessage[];
target?: { turnId: string; nonce: number };
behavior?: ScrollBehavior;
/**
* #2052: false while the progressive mount is still filling the transcript.
* The warm-up snapshots the `.maka-turn` NodeList once, so starting it
* against a partial window would leave every not-yet-mounted turn at its
* 250px placeholder size for the life of the session.
*/
warmupReady?: boolean;
}) {
const [highlightedTurnId, setHighlightedTurnId] = useState<string | null>(null);
const arrivalPin = useRef<ArrivalBottomPin | null>(null);
// ChatLayout owns steady-state following. A session change is product
// navigation rather than content growth, so the new transcript must be at its
// latest turn the first time it is painted — and it arrives in pieces (mount
// window, idle fill chunks, content-visibility warm-up), each of which reads
// to Astryx as growth to spring after. Writing `scrollTop` once here is not
// enough: at this point the switched-to transcript is still an empty scroller,
// and every piece that lands afterwards restarts the flight. The pin consumes
// those growth steps instantly instead, until the warm-up below reports the
// geometry settled or the reader takes over; see arrival-bottom-pin.ts.
//
// Passive, not a layout effect: ChatLayout re-renders with the switch and
// hands React a fresh merged callback ref, so its own root ref is detached
// and not yet reattached while a child's layout effects run — the scroller is
// reliably reachable only after the commit, which is the same reason the
// warm-up effect below publishes its marker passively.
useEffect(() => {
const viewport = input.scrollRef.current;
if (!viewport) return;
// Nothing to arrive: keep the plain positioning this effect has always done
// for a transcript that is empty (or still loading its first turn), and let
// the pin install on the commit those turns land in.
if (!input.hasTurns) {
viewport.scrollTop = viewport.scrollHeight;
return;
}
const pin = createArrivalBottomPin({
viewport,
content: viewport.querySelector('.maka-chat-message-list'),
// Published the way the warm-up and the progressive fill publish theirs,
// so a test can wait on the arrival window instead of guessing at timing.
onStateChange: (state) => { viewport.dataset.arrivalPin = state; },
});
arrivalPin.current = pin;
return () => {
pin.dispose();
arrivalPin.current = null;
delete viewport.dataset.arrivalPin;
};
}, [input.sessionId, input.hasTurns, input.scrollRef]);
// Withdraw a previous transcript's terminal marker in the same commit that
// changes the session. ChatLayout owns the DOM ref, so on the first mount its
// root can still be unavailable to this child layout effect; the passive
// warm-up effect below publishes `running` once that parent ref is attached.
useLayoutEffect(() => {
const root = input.scrollRef.current;
if (!root) return;
root.dataset.turnWarmup = 'running';
return () => { delete root.dataset.turnWarmup; };
}, [input.sessionId, input.hasTurns, input.scrollRef]);
// Replace content-visibility placeholders with final-layout remembered
// sizes. ChatLayout's ResizeObserver follows each height change while its
// scroll lock is active.
useEffect(() => {
const root = input.scrollRef.current;
if (!root) return;
root.dataset.turnWarmup = 'running';
if (!input.hasTurns || input.warmupReady === false) return;
let disposed = false;
let cancelWarmup: (() => void) | undefined;
let pollTimer: number | undefined;
let settleTimer: number | undefined;
let settleAttempts = 0;
const warmOnceSettled = () => {
if (disposed) return;
if (root.querySelector('.maka-markdown-pending')) {
pollTimer = window.setTimeout(warmOnceSettled, 100);
return;
}
cancelWarmup = createTurnSizeWarmup({
turns: () => root.querySelectorAll<HTMLElement>('.maka-turn'),
onSettled: () => {
if (disposed) return;
root.dataset.turnWarmup = 'settled';
// Astryx follows each ResizeObserver update while locked. Chromium
// can leave the final content-visibility release a few sub-pixels
// short of the exact maximum; finish only when the user is still
// inside Astryx's own 10px lock threshold, never after they read up.
const finishPinnedWarmup = () => {
const distanceFromBottom = root.scrollHeight - root.scrollTop - root.clientHeight;
if (distanceFromBottom <= 10) {
root.scrollTop = root.scrollHeight;
// Arrival is over: nothing else grows the document on its own, so
// following goes back to Astryx for streaming and new turns.
arrivalPin.current?.release();
return;
}
settleAttempts += 1;
if (settleAttempts < 50) {
settleTimer = window.setTimeout(finishPinnedWarmup, 100);
return;
}
arrivalPin.current?.release();
};
settleTimer = window.setTimeout(finishPinnedWarmup, 100);
},
});
};
const fontsReady: Promise<unknown> =
typeof document !== 'undefined' && document.fonts ? document.fonts.ready : Promise.resolve();
void fontsReady.then(warmOnceSettled);
return () => {
disposed = true;
window.clearTimeout(pollTimer);
window.clearTimeout(settleTimer);
cancelWarmup?.();
};
}, [input.sessionId, input.hasTurns, input.warmupReady, input.scrollRef]);
useEffect(() => {
const target = input.target;
if (!target?.turnId) return;
// Navigating to a turn is the reader choosing a position, so it outranks an
// arrival still in flight. (An upward scroll would release the pin on its
// own a frame later; releasing here keeps the first frame honest too.)
arrivalPin.current?.release();
const frame = window.requestAnimationFrame(() => {
const root = input.scrollRef.current;
if (!root) return;
const element = root.querySelector(`[data-turn-id="${CSS.escape(target.turnId)}"]`);
if (!element || !('scrollIntoView' in element)) return;
const targetElement = element as HTMLElement;
targetElement.setAttribute('tabindex', '-1');
targetElement.scrollIntoView({
behavior: input.behavior ?? 'smooth',
block: 'center',
});
targetElement.focus({ preventScroll: true });
setHighlightedTurnId(target.turnId);
});
const clear = window.setTimeout(() => {
setHighlightedTurnId((current) => (current === target.turnId ? null : current));
}, 2200);
return () => {
window.cancelAnimationFrame(frame);
window.clearTimeout(clear);
};
}, [input.target?.turnId, input.target?.nonce, input.behavior, input.sessionId, input.messages, input.scrollRef]);
return {
highlightedTurnId,
};
}