import { v4 as uuidv4 } from "uuid"; import { ReactNode, useEffect, useRef } from "react"; import { motion } from "framer-motion"; import { cn } from "@/lib/utils"; import { useStreamContext } from "@/providers/Stream"; import { useState, FormEvent } from "react"; import { Button } from "../ui/button"; import { Checkpoint, Message } from "@langchain/langgraph-sdk"; import { AssistantMessage, AssistantMessageLoading } from "./messages/ai"; import { HumanMessage } from "./messages/human"; import { DO_NOT_RENDER_ID_PREFIX, ensureToolCallsHaveResponses, } from "@/lib/ensure-tool-responses"; import { LangGraphLogoSVG } from "../icons/langgraph"; import { TooltipIconButton } from "./tooltip-icon-button"; import { ArrowDown, LoaderCircle, PanelRightOpen, PanelRightClose, SquarePen, } from "lucide-react"; import { useQueryState, parseAsBoolean } from "nuqs"; import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom"; import ThreadHistory from "./history"; import { toast } from "sonner"; import { useMediaQuery } from "@/hooks/useMediaQuery"; import { Label } from "../ui/label"; import { Switch } from "../ui/switch"; function StickyToBottomContent(props: { content: ReactNode; footer?: ReactNode; className?: string; contentClassName?: string; }) { const context = useStickToBottomContext(); return (
{props.content}
{props.footer}
); } function ScrollToBottom(props: { className?: string }) { const { isAtBottom, scrollToBottom } = useStickToBottomContext(); if (isAtBottom) return null; return ( ); } export function Thread() { const [threadId, setThreadId] = useQueryState("threadId"); const [chatHistoryOpen, setChatHistoryOpen] = useQueryState( "chatHistoryOpen", parseAsBoolean.withDefault(false), ); const [hideToolCalls, setHideToolCalls] = useQueryState( "hideToolCalls", parseAsBoolean.withDefault(false), ); const [input, setInput] = useState(""); const [firstTokenReceived, setFirstTokenReceived] = useState(false); const isLargeScreen = useMediaQuery("(min-width: 1024px)"); const stream = useStreamContext(); const messages = stream.messages; const isLoading = stream.isLoading; const lastError = useRef(undefined); useEffect(() => { if (!stream.error) { lastError.current = undefined; return; } try { const message = (stream.error as any).message; if (!message || lastError.current === message) { // Message has already been logged. do not modify ref, return early. return; } // Message is defined, and it has not been logged yet. Save it, and send the error lastError.current = message; toast.error("An error occurred. Please try again.", { description: (

Error: {message}

), richColors: true, closeButton: true, }); } catch { // no-op } }, [stream.error]); // TODO: this should be part of the useStream hook const prevMessageLength = useRef(0); useEffect(() => { if ( messages.length !== prevMessageLength.current && messages?.length && messages[messages.length - 1].type === "ai" ) { setFirstTokenReceived(true); } prevMessageLength.current = messages.length; }, [messages]); const handleSubmit = (e: FormEvent) => { e.preventDefault(); if (!input.trim() || isLoading) return; setFirstTokenReceived(false); const newHumanMessage: Message = { id: uuidv4(), type: "human", content: input, }; const toolMessages = ensureToolCallsHaveResponses(stream.messages); stream.submit( { messages: [...toolMessages, newHumanMessage] }, { streamMode: ["values"], optimisticValues: (prev) => ({ ...prev, messages: [ ...(prev.messages ?? []), ...toolMessages, newHumanMessage, ], }), }, ); setInput(""); }; const handleRegenerate = ( parentCheckpoint: Checkpoint | null | undefined, ) => { // Do this so the loading state is correct prevMessageLength.current = prevMessageLength.current - 1; setFirstTokenReceived(false); stream.submit(undefined, { checkpoint: parentCheckpoint, streamMode: ["values"], }); }; const chatStarted = !!threadId || !!messages.length; return (
{!chatStarted && (
{(!chatHistoryOpen || !isLargeScreen) && ( )}
)} {chatStarted && (
{(!chatHistoryOpen || !isLargeScreen) && ( )}
setThreadId(null)} animate={{ marginLeft: !chatHistoryOpen ? 48 : 0, }} transition={{ type: "spring", stiffness: 300, damping: 30, }} > Agent Chat
setThreadId(null)} >
)} {messages .filter((m) => !m.id?.startsWith(DO_NOT_RENDER_ID_PREFIX)) .map((message, index) => message.type === "human" ? ( ) : ( ), )} {isLoading && !firstTokenReceived && ( )} } footer={
{!chatStarted && (

Agent Chat

)}