import { useLayoutEffect, useRef, useState } from "react"; import { useAnimationControls, useReducedMotion } from "motion/react"; import { useStickToBottomContext } from "use-stick-to-bottom"; import type { AgentChatMessage } from "../types"; import { agentEase } from "../components/agent-motion"; type ReadingAnchor = { element: HTMLElement; text: string; top: number }; const readable = "p, h1, h2, h3, li, summary"; /** A final report is readable before transport cleanup finishes. Handoff once. */ export function useAgentAnswerTransition(message: AgentChatMessage, running: boolean) { const ready = Boolean(message.finalAnswer) || !running; const [committed, setCommitted] = useState(ready); const [phase, setPhase] = useState(ready ? "settled" : "streaming"); const previous = useRef(message); const root = useRef(null); const anchor = useRef<{ scroll: HTMLElement; reading?: ReadingAnchor; top: number; release: () => void } | null>(null); const controls = useAnimationControls(); const reducedMotion = useReducedMotion(); const { scrollRef, stopScroll, state } = useStickToBottomContext(); useLayoutEffect(() => { if (!ready) previous.current = message; }, [message, ready]); useLayoutEffect(() => { if (!ready && committed) { setCommitted(false); setPhase("streaming"); controls.set({ opacity: 1 }); return; } if (!ready || committed) return; let disposed = false; const scroll = scrollRef.current; const element = root.current; if (scroll && element) { anchor.current?.release(); anchor.current = null; const viewport = scroll.getBoundingClientRect(); // Use the same near-bottom state as the conversation's follow button. const following = state.isAtBottom || state.isNearBottom; const reading = !following ? [...scroll.querySelectorAll(readable)].find((node) => { const bounds = node.getBoundingClientRect(); return bounds.height > 0 && bounds.bottom > viewport.top + 12 && bounds.top < viewport.bottom; }) : undefined; stopScroll(); const overflowAnchor = scroll.style.overflowAnchor; scroll.style.overflowAnchor = "none"; const cancel = () => { anchor.current?.release(); anchor.current = null; }; const events = ["wheel", "pointerdown", "touchmove", "keydown"]; for (const event of events) scroll.addEventListener(event, cancel, { passive: true }); anchor.current = { scroll, reading: reading ? { element: reading, text: reading.textContent ?? "", top: reading.getBoundingClientRect().top } : undefined, top: Math.max(viewport.top + 12, element.getBoundingClientRect().top), release: () => { scroll.style.overflowAnchor = overflowAnchor; for (const event of events) scroll.removeEventListener(event, cancel); }, }; } const commit = () => { if (!disposed) { setCommitted(true); setPhase(reducedMotion ? "settled" : "entering"); } }; if (reducedMotion) commit(); else { setPhase("leaving"); void controls.start({ opacity: 0 }, { duration: 0.08, ease: agentEase }).then(commit); } return () => { disposed = true; controls.stop(); }; }, [committed, controls, ready, reducedMotion, scrollRef, state, stopScroll]); useLayoutEffect(() => { if (!committed) return; let disposed = false; let frame: number | undefined; const restore = () => { const current = anchor.current; if (!current || !root.current) return; let target: HTMLElement | null = root.current.querySelector("[data-answer-parts]") ?? root.current; const reading = current.reading; if (reading) { target = reading.element.isConnected && reading.element.textContent === reading.text && reading.element.getBoundingClientRect().height > 0 ? reading.element : [...current.scroll.querySelectorAll(readable)].find((node) => node.textContent === reading.text && node.getBoundingClientRect().height > 0) ?? target; } if (target) current.scroll.scrollTop += target.getBoundingClientRect().top - (reading?.top ?? current.top); }; restore(); frame = requestAnimationFrame(restore); const finish = () => { if (disposed) return; restore(); anchor.current?.release(); anchor.current = null; setPhase("settled"); }; if (reducedMotion) { controls.set({ opacity: 1 }); frame = requestAnimationFrame(() => { restore(); frame = requestAnimationFrame(finish); }); } else void controls.start({ opacity: 1 }, { duration: 0.12, ease: agentEase }).then(finish); return () => { disposed = true; if (frame !== undefined) cancelAnimationFrame(frame); controls.stop(); }; }, [committed, controls, reducedMotion]); useLayoutEffect(() => () => { anchor.current?.release(); anchor.current = null; }, []); return { message: ready && !committed ? previous.current : message, completed: committed, phase, root, controls }; }