Files
next-tjwater-frontend/src/features/agent/components/agent-process-disclosure.tsx
T

228 lines
7.4 KiB
TypeScript

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<boolean>();
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<HTMLDetailsElement>(null);
const anchor = useRef<Anchor | null>(null);
const frame = useRef<number | undefined>(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<HTMLElement>("[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<HTMLElement>) {
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 = (
<span data-process-toggle-icon className="inline-grid size-6 shrink-0 place-items-center">
<ChevronDown
aria-hidden
className={cn(
"size-4",
animated && "transition-transform duration-150 motion-reduce:transition-none",
open && phase !== "closing" && "rotate-180"
)}
/>
</span>
);
return (
<>
{!live && toolbar?.(
<Button
type="button"
variant="ghost"
size="sm"
className={cn(AGENT_MESSAGE_ACTION_CLASS_NAME, "gap-2 rounded-lg px-3")}
aria-expanded={open}
aria-controls={contentId}
data-process-toggle
onClick={(event) => {
void toggle(event);
}}
>
{processIcon}
<span data-process-toggle-label>{label}</span>
</Button>
)}
<details
ref={root}
open={open}
hidden={!live && toolbar && !open ? true : undefined}
data-agent-process={kind === "process" && !live ? "" : undefined}
data-agent-live-process={live ? "" : undefined}
data-agent-tool={kind === "tool" ? "" : undefined}
data-disclosure-motion={phase}
className={cn(
"min-w-0 overflow-hidden",
kind === "process" &&
(toolbar
? "mt-2 rounded-xl border border-border bg-card"
: "rounded-xl border border-border bg-card")
)}
>
<summary
onClick={(event) => {
void toggle(event);
}}
aria-controls={contentId}
aria-hidden={toolbar ? true : undefined}
tabIndex={toolbar ? -1 : undefined}
className={cn(
toolbar
? "hidden"
: "flex min-h-10 cursor-pointer list-none gap-2 text-xs font-medium leading-5 text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring [&::-webkit-details-marker]:hidden",
!toolbar &&
(kind === "process" ? "items-center px-3 py-2" : "items-start rounded-md py-1"),
kind === "process" && open && "border-b border-border"
)}
>
{!toolbar ? (
<>
{kind === "process" && !toolbar ? processIcon : null}
<span className="min-w-0 flex-1">{label}</span>
{kind === "tool" ? (
<span data-tool-chevron className="inline-grid size-6 shrink-0 place-items-center">
<ChevronDown
aria-hidden
className={cn(
"size-4 text-muted-foreground",
animated && "transition-transform duration-150 motion-reduce:transition-none",
open && phase !== "closing" ? "rotate-0" : "-rotate-90"
)}
/>
</span>
) : null}
</>
) : null}
</summary>
<motion.div
id={contentId}
initial={false}
animate={controls}
data-agent-disclosure-content
className={kind === "process" ? "space-y-3 p-3" : "pb-2 pl-8"}
>
{children}
</motion.div>
</details>
</>
);
}