import { useEffect, useId, useRef, useState, type MouseEvent, type ReactNode } from "react"; import { flushSync } from "react-dom"; import { ChevronDown } from "lucide-react"; import { motion, useAnimationControls, useReducedMotion } from "motion/react"; import { useStickToBottomContext } from "use-stick-to-bottom"; import { Button } from "@/shared/ui/button"; import { AGENT_MESSAGE_ACTION_CLASS_NAME } from "./agent-chat-message-chrome"; import { cn } from "@/shared/ui/cn"; import { agentEase } from "./agent-motion"; type Anchor = { scroll: HTMLElement; element: HTMLElement; top: number; release: () => void; }; /** A reader action stops bottom-following before it changes the message's layout. */ export function AgentProcessDisclosure({ label, children, kind = "process", toolbar, live = false }: { label: ReactNode; children: ReactNode; kind?: "process" | "tool"; toolbar?: (trigger: ReactNode) => ReactNode; live?: boolean; }) { const { scrollRef, stopScroll } = useStickToBottomContext(); const [userOpen, setOpen] = useState(); const open = live || (userOpen ?? false); const [animated, setAnimated] = useState(false); const [phase, setPhase] = useState<"idle" | "opening" | "closing">("idle"); const requestedOpen = useRef(false); const sequence = useRef(0); const root = useRef(null); const anchor = useRef(null); const frame = useRef(undefined); const controls = useAnimationControls(); const reducedMotion = useReducedMotion(); const contentId = useId(); function releaseAnchor() { if (frame.current !== undefined) cancelAnimationFrame(frame.current); frame.current = undefined; anchor.current?.release(); anchor.current = null; } function captureAnchor() { releaseAnchor(); const scroll = scrollRef.current; const details = root.current; if (!scroll || !details) return; stopScroll(); const element = details.closest("[data-agent-message-id]") ?? details; const previous = scroll.style.overflowAnchor; scroll.style.overflowAnchor = "none"; const cancel = () => releaseAnchor(); for (const event of ["wheel", "pointerdown", "touchmove", "keydown"]) scroll.addEventListener(event, cancel, { passive: true }); anchor.current = { scroll, element, top: element.getBoundingClientRect().top, release: () => { scroll.style.overflowAnchor = previous; for (const event of ["wheel", "pointerdown", "touchmove", "keydown"]) scroll.removeEventListener(event, cancel); } }; } function restoreAnchor() { const value = anchor.current; if (!value || !value.element.isConnected) return; stopScroll(); const delta = value.element.getBoundingClientRect().top - value.top; if (Math.abs(delta) > 0.5) value.scroll.scrollTop += delta; } async function toggle(event: MouseEvent) { event.preventDefault(); const current = ++sequence.current; const next = !(phase === "idle" ? open : requestedOpen.current); requestedOpen.current = next; controls.stop(); captureAnchor(); const immediate = Boolean(reducedMotion) || event.detail === 0; setAnimated(!immediate); if (next) { controls.set(immediate ? { opacity: 1, y: 0 } : { opacity: 0, y: -4 }); flushSync(() => { setPhase(immediate ? "idle" : "opening"); setOpen(true); }); restoreAnchor(); } else { setPhase(immediate ? "idle" : "closing"); } if (!immediate) await controls.start(next ? { opacity: 1, y: 0 } : { opacity: 0, y: -4 }, { duration: 0.16, ease: agentEase }); if (sequence.current !== current) return; if (!next) { flushSync(() => setOpen(false)); controls.set({ opacity: 1, y: 0 }); } restoreAnchor(); setPhase("idle"); frame.current = requestAnimationFrame(() => { restoreAnchor(); releaseAnchor(); }); } useEffect( () => () => { sequence.current += 1; controls.stop(); if (frame.current !== undefined) cancelAnimationFrame(frame.current); anchor.current?.release(); anchor.current = null; }, [controls] ); const processIcon = ( ); return ( <> {!live && toolbar?.( )} ); }