import { ArrowDown } from "@phosphor-icons/react"; import { useEffect, useRef, useState, type ReactNode, } from "react"; import { cn } from "../../lib/cn"; import { IconButton } from "../actions"; import { AIInput, AIMessageList, type AIInputProps, type AIInputSubmitPayload, type AIMessage, type AIMessageListProps, } from "../inputs"; export type AiPanelProps = { className?: string; composerClassName?: string; emptyState?: ReactNode; inputProps?: Omit< AIInputProps, "loading" | "onChange" | "onSubmit" | "value" >; loading?: boolean; messages: AIMessage[]; messageListProps?: Omit; onChange: (value: string) => void; onSubmit: (payload: AIInputSubmitPayload) => void; transcriptClassName?: string; value: string; }; /** A controlled AI conversation surface sized and aligned for side panels. */ export function AiPanel({ className, composerClassName, emptyState, inputProps, loading = false, messages, messageListProps, onChange, onSubmit, transcriptClassName, value, }: AiPanelProps) { const transcriptRef = useRef(null); const followLatestRef = useRef(true); const programmaticScrollRef = useRef(false); const scrollReleaseRef = useRef(null); const [showScrollToBottom, setShowScrollToBottom] = useState(false); const latestMessage = messages[messages.length - 1]; const latestTimelineKey = [ latestMessage?.id, latestMessage?.content, latestMessage?.status, latestMessage?.parts?.length, latestMessage?.parts?.[latestMessage.parts.length - 1]?.id, latestMessage?.toolCalls?.length, latestMessage?.thoughts?.length, ].join(":"); function syncScrollState() { const transcript = transcriptRef.current; if (!transcript) return; const distanceFromBottom = transcript.scrollHeight - transcript.scrollTop - transcript.clientHeight; setShowScrollToBottom(distanceFromBottom > 72); if (!programmaticScrollRef.current) { followLatestRef.current = distanceFromBottom <= 72; } } function scrollToBottom(behavior: ScrollBehavior = "smooth") { const transcript = transcriptRef.current; if (!transcript) return; followLatestRef.current = true; programmaticScrollRef.current = true; transcript.scrollTo({ behavior, top: transcript.scrollHeight, }); if (scrollReleaseRef.current !== null) { window.clearTimeout(scrollReleaseRef.current); } scrollReleaseRef.current = window.setTimeout(() => { programmaticScrollRef.current = false; scrollReleaseRef.current = null; syncScrollState(); }, behavior === "smooth" ? 280 : 0); } useEffect(() => { const handle = window.requestAnimationFrame(() => { if (followLatestRef.current) scrollToBottom("smooth"); syncScrollState(); }); return () => window.cancelAnimationFrame(handle); }, [latestTimelineKey, loading, messages.length]); useEffect( () => () => { if (scrollReleaseRef.current !== null) { window.clearTimeout(scrollReleaseRef.current); } }, [], ); return (
{ if (event.deltaY < 0) { programmaticScrollRef.current = false; followLatestRef.current = false; } }} onScroll={syncScrollState} ref={transcriptRef} >
{showScrollToBottom ? ( } label="Scroll to latest message" onClick={() => scrollToBottom()} /> ) : null}
); }