"use client"; import { useEffect, useRef, useState } from "react"; import { useChat } from "@ai-sdk/react"; import { DefaultChatTransport, isToolUIPart, lastAssistantMessageIsCompleteWithToolCalls } from "ai"; 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 { 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 { 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"; /** * 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 (never remounts — this is what a * future chat→question morph animation will animate). Its contents swap: * - no pending question → the chat Composer * - question streaming in → "Preparing a question…" shimmer * - pending quiz/ask → the QuestionDock (question + options + the * unified note/answer field + Submit answer) * A pending question is always the transcript tail (the lesson can't continue * before it's answered), so docking it at the bottom loses nothing; on submit * it lands in the transcript as an AnsweredCard. */ export function Chat({ chatId, title, initialMessages, initialPrompt, }: { chatId: string; title: string; initialMessages: TeacherUIMessage[]; initialPrompt?: string; }) { const { messages, sendMessage, addToolOutput, 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 (question being generated): the // blob shows a shimmer instead of the composer. 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 onSend = () => { const t = text.trim(); if (!t || busy || pending) return; setText(""); void sendMessage({ text: t }); }; return (
{title}
{/* Transcript scrolls full-height; the blob floats on top of it. */}
{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: return null; } }) )}
))} {status === "submitted" && Thinking…} {error && (
Something went wrong: {error.message}
)}
{/* The floating blob. ONE persistent shell — contents swap by mode. Content fades out behind it. */}
{pending ? ( void addToolOutput({ tool: pending.tool as "quiz" | "ask_user_question", toolCallId: pending.toolCallId, output, }) } /> ) : questionStreaming ? (
Preparing a question…
) : ( stop()} canSend={canSend} /> )}
); }