import { ArrowDown } from "@phosphor-icons/react"; import { useEffect, useRef, useState, type ReactNode } from "react"; import { cn } from "../../lib/cn"; import { AIInput, type AIInputQuestionAnswer, type AIInputProps, type AIInputQuestion, type AIInputQueueItem, type AIInputSubmitPayload, } from "./ai-input"; import { AIMessageList, type AIMessage, type AIMessageListProps } from "./ai-message-list"; export type AIChatResponse = { content: string; question?: AIInputQuestion; } | string; export type AIChatInterfaceProps = { className?: string; emptyState?: ReactNode; initialMessages?: AIMessage[]; inputProps?: Omit; messageListProps?: Omit; onFeedbackSubmit?: AIMessageListProps["onFeedbackSubmit"]; onQuestionAnswer?: (answer: AIInputQuestionAnswer, question: AIInputQuestion) => void; onSubmit?: (payload: AIInputSubmitPayload) => void | Promise; onQueueChange?: (queue: AIInputQueueItem[]) => void; simulateResponse?: boolean; }; const defaultMessages: AIMessage[] = [ { id: "uhuru-welcome", role: "assistant", title: "Uhuru assistant", content: "Welcome. Ask a question, use **Markdown**, attach a file, or mention `@workspace` and `#orders` for context.", status: "complete", }, ]; export function AIChatInterface({ className, emptyState, initialMessages = defaultMessages, inputProps, messageListProps, onFeedbackSubmit, onQuestionAnswer, onSubmit, onQueueChange, simulateResponse = true, }: AIChatInterfaceProps) { const [messages, setMessages] = useState(initialMessages); const [draft, setDraft] = useState(""); const [loading, setLoading] = useState(false); const [queue, setQueue] = useState([]); const [question, setQuestion] = useState(); const transcriptRef = useRef(null); const { className: messageListClassName, ...restMessageListProps } = messageListProps ?? {}; const hasConversation = messages.length > 0 || queue.length > 0 || loading || Boolean(question); function syncScrollState() { const transcript = transcriptRef.current; if (!transcript) return; const distanceFromBottom = transcript.scrollHeight - transcript.scrollTop - transcript.clientHeight; // Keep the scroll-to-bottom affordance visible only when the transcript is meaningfully above the end. setShowScrollToBottom(distanceFromBottom > 96); } const [showScrollToBottom, setShowScrollToBottom] = useState(false); function scrollToBottom(behavior: ScrollBehavior = "smooth") { const transcript = transcriptRef.current; if (!transcript) return; transcript.scrollTo({ behavior, top: transcript.scrollHeight }); } function updateQueue(next: AIInputQueueItem[]) { setQueue(next); onQueueChange?.(next); } function appendAssistant(response: AIChatResponse) { const nextResponse = typeof response === "string" ? { content: response } : response; setMessages((current) => [...current, { content: nextResponse.content, id: `assistant-${Date.now()}`, role: "assistant", status: "complete" }]); setQuestion(nextResponse.question); } function submit(payload: AIInputSubmitPayload) { const userMessage: AIMessage = { content: payload.prompt || `Attached ${payload.files.length} file${payload.files.length === 1 ? "" : "s"}.`, createdAt: Date.now(), id: `user-${Date.now()}`, role: "user", status: "complete", }; setMessages((current) => [...current, userMessage]); setDraft(""); setLoading(true); if (onSubmit) { Promise.resolve(onSubmit(payload)).then((response) => { if (response) appendAssistant(response); setLoading(false); }).catch((error: unknown) => { setMessages((current) => [...current, { content: error instanceof Error ? error.message : "The assistant could not complete this request.", id: `error-${Date.now()}`, role: "assistant", status: "error" }]); setLoading(false); }); return; } if (!simulateResponse) { setLoading(false); return; } window.setTimeout(() => { appendAssistant("I received your request and kept the prompt context attached. Connect `onSubmit` to your model stream when you are ready for production responses."); setLoading(false); }, 650); } function queuePrompt(payload: AIInputSubmitPayload, status: AIInputQueueItem["status"] = "queued") { updateQueue([...queue, { id: `queue-${Date.now()}`, label: payload.prompt, model: payload.model, prompt: payload.prompt, status }]); } useEffect(() => { const handle = window.requestAnimationFrame(() => { scrollToBottom("auto"); syncScrollState(); }); return () => window.cancelAnimationFrame(handle); }, [loading, messages.length, queue.length, question]); return (
{hasConversation ? ( ) : (
)}
{showScrollToBottom ? ( ) : null} queuePrompt(payload, "steering")} onQueueItemEdit={(item) => { updateQueue(queue.filter((entry) => entry.id !== item.id)); setDraft(item.prompt); }} onQueueItemRemove={(item) => updateQueue(queue.filter((entry) => entry.id !== item.id)) } onQueueItemSelect={(item) => setDraft(item.prompt)} onQueueSubmit={(payload) => queuePrompt(payload)} onQuestionAnswer={(answer, currentQuestion) => { setQuestion(undefined); onQuestionAnswer?.(answer, currentQuestion); }} onQuestionDismiss={() => setQuestion(undefined)} onSubmit={submit} queue={queue} queueEnabled question={question} value={draft} />
); }