fix: unify chat process and reply presentation
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
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<HTMLDivElement>(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<HTMLElement>(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<HTMLElement>(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 };
|
||||
}
|
||||
Reference in New Issue
Block a user