"use client"; import { createContext, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { useChat } from "@ai-sdk/react"; import { DefaultChatTransport, isToolUIPart, lastAssistantMessageIsCompleteWithToolCalls } from "ai"; import { useStickToBottomContext } from "use-stick-to-bottom"; import { Conversation, ConversationContent, ConversationEmptyState, ConversationScrollButton, } from "@/components/ai-elements/conversation"; import { Message, MessageContent, MessageResponse } from "@/components/ai-elements/message"; import { Reasoning, ReasoningContent, ReasoningTrigger } from "@/components/ai-elements/reasoning"; import { Shimmer } from "@/components/ai-elements/shimmer"; import { Separator } from "@/components/ui/separator"; import { AnsweredCard } from "@/components/answered-card"; import { artifactRenderers } from "@/components/artifacts"; import { Composer } from "@/components/composer"; import { QuestionDock } from "@/components/question-dock"; import { SidebarToggle } from "@/components/sidebar"; import type { TeacherUIMessage } from "@/src/agent/ui-message"; import type { ArtifactPartProps } from "@/src/core/contracts"; import type { QuestionAnswerData } from "@/src/core/question-core"; import { findPendingToolCall } from "@/src/core/pending"; /** One width for everything: transcript, cards, and the bottom blob share this column. */ const COLUMN = "mx-auto w-full max-w-3xl px-4"; /** * Server-sync channel for artifact renderers (job cards): when a background * job flips its tool part to output-available server-side, the card polls * /api/jobs, then calls syncFromServer() — fresh messages replace local state * and the sendAutomaticallyWhen continuation resumes the teacher. * (contracts.ts "THE ASYNC PROTOCOL" §4.) */ export const ChatActionsContext = createContext<{ chatId: string; syncFromServer: () => Promise; } | null>(null); /** useLayoutEffect that is silent during SSR (client components still render on the server in Next). */ const useIsoLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect; /** * The teaching session. All chat state lives in useChat; the server persists * the canonical transcript after every turn. * * The bottom blob is ONE persistent shell whose contents swap (chat composer ↔ * question dock ↔ collapsed bar). The shell is sticky INSIDE the scroller — * never overlapping the transcript at rest, never covering the scrollbar — * and DockOverlay animates its height on variant changes. */ export function Chat({ chatId, title, initialMessages, initialPrompt, }: { chatId: string; title: string; initialMessages: TeacherUIMessage[]; initialPrompt?: string; }) { const { messages, sendMessage, addToolOutput, setMessages, status, error, stop } = useChat({ id: chatId, messages: initialMessages, transport: new DefaultChatTransport({ api: "/api/chat", body: { chatId } }), // After the learner answers (addToolOutput), automatically send the // continuation request so the teacher reacts to the answer. sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls, }); // New chat with a topic: send the first message exactly once. const sentInitial = useRef(false); useEffect(() => { if (!sentInitial.current && initialPrompt && initialMessages.length === 0) { sentInitial.current = true; void sendMessage({ text: initialPrompt }); } }, [initialPrompt, initialMessages.length, sendMessage]); const pending = findPendingToolCall(messages); const busy = status === "submitted" || status === "streaming"; // A tool call still streaming its input: the teacher is writing a question. // Shown as a transcript shimmer, exactly like the thinking indicator. const questionStreaming = !pending && messages.at(-1)?.role === "assistant" && (messages.at(-1)?.parts.some((p) => isToolUIPart(p) && p.state === "input-streaming") ?? false); const [text, setText] = useState(""); const canSend = text.trim().length > 0 && !pending; const syncFromServer = useCallback(async () => { const res = await fetch(`/api/chats/${chatId}`); if (!res.ok) return; const record = (await res.json()) as { messages?: TeacherUIMessage[] }; if (Array.isArray(record.messages)) setMessages(record.messages); }, [chatId, setMessages]); const onSend = () => { const t = text.trim(); if (!t || busy || pending) return; setText(""); void sendMessage({ text: t }); }; return (
{title}
{messages.length === 0 && !busy && ( )} {messages.map((m, mi) => ( {m.role === "user" ? ( // User text renders plain: the model's LaTeX pipeline is not // applied to learner-authored text ($ stays $, no surprises).
{m.parts.filter((p) => p.type === "text").map((p) => p.text).join("\n")}
) : ( m.parts.map((part, pi) => { const isLastPart = mi === messages.length - 1 && pi === m.parts.length - 1; switch (part.type) { case "text": return {part.text}; case "reasoning": return ( {part.text} ); case "step-start": return pi > 0 ? : null; case "tool-quiz": case "tool-ask_user_question": { // Pending/streaming questions live in the dock, not the // transcript; answered ones render as graded records. if (part.state === "output-available") { return ; } return null; } default: { // Artifact extension point (contracts.ts): job // parts and future artifacts render via the // registry — add renderers there, not here. if (part.type.startsWith("tool-")) { const Renderer = artifactRenderers[part.type.slice("tool-".length)]; if (Renderer) return ; } return null; } } }) )}
))} {status === "submitted" && Thinking…} {questionStreaming && Writing a question…} {error && (
Something went wrong: {error.message}
)}
stop()} canSend={canSend} /> } onSubmitAnswer={(output) => { if (!pending) return; void addToolOutput({ tool: pending.tool as "quiz" | "ask_user_question", toolCallId: pending.toolCallId, output, }); }} />
); } /** * The floating blob: ONE persistent shell, sticky at the bottom of the * scroller (in flow — the transcript scrolls fully clear of it, and it never * covers the scrollbar). Contents swap between the chat composer and the * question dock; shell height morphs between variants (WAAPI, quick). * * Collapse: scroll up and the dock folds into a slim bar; click it (or scroll * back to the bottom) to re-expand. The question form stays MOUNTED while * collapsed (hidden, not unmounted) so a half-made selection survives. */ function DockOverlay({ pending, composer, onSubmitAnswer, }: { pending: ReturnType; composer: React.ReactNode; onSubmitAnswer: (output: QuestionAnswerData) => void; }) { const { isAtBottom, scrollToBottom, scrollRef } = useStickToBottomContext(); const [expanded, setExpanded] = useState(true); const pendingId = pending?.toolCallId ?? null; const initialPendingRef = useRef(pendingId); // New question MID-SESSION: open expanded if the learner is following at // the bottom (scroll to reveal it); reading history → arrive as the bar. // On session mount the dock simply starts expanded at the bottom (the // library's instant initial scroll positions us) — no scroll, no morph. useEffect(() => { if (!pendingId || pendingId === initialPendingRef.current) return; setExpanded(isAtBottom); if (isAtBottom) void scrollToBottom(); // eslint-disable-next-line react-hooks/exhaustive-deps -- fire only when a NEW question lands }, [pendingId]); // ── Collapse state machine with hysteresis ── // Collapsing frees ~Δpx of flow height. If it happened while the learner is // within Δ of the bottom, the browser clamps scrollTop to the new max — // landing them AT the bottom, which re-expands the dock, which re-collapses… // the oscillation. So: collapse only once scrolled a full dock-height past // the bottom (clamp-proof), expand only within 16px of the bottom, and leave // a wide dead band between the two thresholds. Heights measured live. const expandedRef = useRef(expanded); expandedRef.current = expanded; const pendingRef = useRef(!!pending); pendingRef.current = !!pending; const expandedHRef = useRef(0); const collapsedHRef = useRef(42); // Explicit expand (bar click): the smooth scroll to the bottom takes // hundreds of ms, during which the position is still legitimately inside // the collapse zone — the machine would instantly re-collapse the dock // (open → snap shut → reopen at the bottom, the flicker). Suppress the // collapse branch until the reveal scroll settles or is interrupted. const expandIntentRef = useRef(false); const intentTimerRef = useRef>(undefined); const expandAndReveal = () => { expandIntentRef.current = true; setExpanded(true); clearTimeout(intentTimerRef.current); intentTimerRef.current = setTimeout(() => { expandIntentRef.current = false; }, 2000); void Promise.resolve(scrollToBottom()).finally(() => { clearTimeout(intentTimerRef.current); expandIntentRef.current = false; }); }; useEffect(() => { const el = scrollRef.current; if (!el) return; const onScroll = () => { if (!pendingRef.current) return; const d = el.scrollHeight - el.clientHeight - el.scrollTop; const delta = Math.max(expandedHRef.current - collapsedHRef.current, 240); if (expandedRef.current) { if (d > delta + 64 && !expandIntentRef.current) setExpanded(false); } else if (d < 16) { setExpanded(true); void scrollToBottom(); } }; el.addEventListener("scroll", onScroll, { passive: true }); return () => el.removeEventListener("scroll", onScroll); }, [scrollRef, scrollToBottom]); const recordHeight = (h: number) => { if (!pendingRef.current) return; if (expandedRef.current) expandedHRef.current = h; else collapsedHRef.current = h; }; const mode = pending ? `question:${pending.toolCallId}` : "chat"; // The opening variant of a session appears instantly — the fade/rise is // reserved for mid-session transitions. const initialModeRef = useRef(mode); // Height morph: animate the shell from its previous height to its new one // whenever the variant (chat ↔ question ↔ collapsed bar) changes. Typing // growth keeps the same stateKey and is NOT animated. const shellRef = useRef(null); const stateKey = `${mode}:${pending ? expanded : "-"}`; const prevRef = useRef<{ key: string; height: number } | null>(null); useIsoLayoutEffect(() => { const el = shellRef.current; if (!el) return; const p = prevRef.current; if (p && p.key === stateKey) { // Same variant: keep the settled height fresh, but never measure while // a morph is in flight — getBoundingClientRect would read the ANIMATED // value and corrupt the start height of the next morph. if (el.getAnimations().length === 0) { const h = el.getBoundingClientRect().height; p.height = h; recordHeight(h); } return; } el.getAnimations().forEach((a) => a.cancel()); // flush to natural height before measuring const h = el.getBoundingClientRect().height; recordHeight(h); if (p && Math.abs(p.height - h) > 1) { el.animate([{ height: `${p.height}px` }, { height: `${h}px` }], { duration: 180, easing: "cubic-bezier(0.2, 0.8, 0.2, 1)", }); } prevRef.current = { key: stateKey, height: h }; }); return (
{pending ? ( ) : ( composer )}
); }