fix: unify chat process and reply presentation

This commit is contained in:
2026-10-29 17:54:34 +08:00
parent 7621bf0f86
commit 9474788db6
46 changed files with 3160 additions and 500 deletions
@@ -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 };
}