import { useCallback, useEffect, useLayoutEffect, useRef, type RefObject } from 'react'; import { decideScroll, type ScrollDirection } from '../utils/scrollDecision.ts'; /** * Decide the restore target on a chat switch or load-resolve. * * Inputs: * - nextChatId: chat we are ENTERING (or null if no chat selected) * - isLoaded: is the next chat's content rendered now? * - savedScrollTop: previously-saved scrollTop for nextChatId (or undefined) * * Output: { restore: 'saved' | 'bottom' | null } * - restore: 'saved' = write scrollTop = the saved value; 'bottom' = scrollHeight; * null = do nothing (still loading / deselected). * * NOTE: there is deliberately no "save the leaving chat's scrollTop" step here. A layout effect * runs AFTER React has already swapped the container's content to the NEW chat, so a post-swap * read captures the NEW content's (possibly clamped) scrollTop, not the leaving chat's position — * saving then restores the returning chat to the TOP. Instead the scroll listener saves the live * scrollTop continuously (see below), so the map always holds each chat's last REAL position. * * This is a pure function so it can be unit-tested without React. */ export interface RestoreDecision { restore: 'saved' | 'bottom' | null; } export function decideRestoreTarget( nextChatId: string | null, isLoaded: boolean, savedScrollTop: number | undefined, ): RestoreDecision { const restore: 'saved' | 'bottom' | null = nextChatId !== null && isLoaded ? (savedScrollTop !== undefined ? 'saved' : 'bottom') : null; return { restore }; } /** * Per-chat scroll-position memory + auto-scroll heuristic. * * - On chat switch (and once content for the new chat has actually rendered): * saves the leaving chat's scrollTop, restores the entering chat's saved * scrollTop, or jumps to bottom on first visit. All synchronously, before * paint, via useLayoutEffect — no visible "jump" or smooth-scroll animation. * - The hook depends on BOTH activeChatId AND isLoaded so that a cold-open * (spinner first, then data) correctly waits to restore until the messages * list is mounted with non-zero scrollHeight. * - On message append: `onMessageAppended(direction)` snapshots the geometry * BEFORE the new message is committed, then defers the scroll-to-bottom (if * any) to the next frame so the new message is already in the DOM. * - Pinned-to-bottom: media (``/`