// Author: Simon-Pierre Boucher // Contact: contact@spboucher.ai // Project: chat.spboucher.ai "use client"; import { useEffect, useRef, useState } from "react"; import { Markdown } from "./Markdown"; import { siblingInfo, type ApiMessage, type ApiModel } from "./types"; interface StreamingView { assistantMessageId: string; generationId: string; content: string; reasoning: string; error: string | null; } interface MessageListProps { thread: ApiMessage[]; allMessages: ApiMessage[]; pendingUser: ApiMessage | null; streaming: StreamingView | null; models: ApiModel[]; currentModelName: string | null; onSelectBranch: (parentKey: string, childId: string) => void; onRegenerate: (assistantMessageId: string, withModelId?: string) => void; } export function MessageList({ thread, allMessages, pendingUser, streaming, models, currentModelName, onSelectBranch, onRegenerate, }: MessageListProps) { const scrollRef = useRef(null); const [autoFollow, setAutoFollow] = useState(true); // Streaming text auto-follows the bottom; scrolling up pauses it. useEffect(() => { const el = scrollRef.current; if (!el) return; const onScroll = () => { const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 80; setAutoFollow(nearBottom); }; el.addEventListener("scroll", onScroll, { passive: true }); return () => el.removeEventListener("scroll", onScroll); }, []); useEffect(() => { const el = scrollRef.current; if (el && autoFollow) el.scrollTop = el.scrollHeight; }, [thread, pendingUser, streaming?.content, streaming?.reasoning, autoFollow]); const jumpToLatest = () => { const el = scrollRef.current; if (el) el.scrollTop = el.scrollHeight; setAutoFollow(true); }; const isEmpty = thread.length === 0 && !pendingUser && !streaming; return (
{isEmpty ? (
▮▯▮

Pick a model and ask something

{currentModelName ? `${currentModelName} is loaded and ready.` : "Loading the model catalog…"}

) : (
{thread.map((m) => ( ))} {pendingUser &&
{pendingUser.content}
} {streaming && !thread.some((m) => m.id === streaming.assistantMessageId) && ( )}
)} {!autoFollow && (streaming || thread.length > 0) && ( )}
); } function StreamingMessage({ streaming, modelName }: { streaming: StreamingView; modelName: string | null }) { return (
{modelName ?? "model"} streaming
{streaming.reasoning && (
reasoning
{streaming.reasoning}
)}
{streaming.error &&
{streaming.error}
}
); } function MessageItem({ message, allMessages, streaming, models, onSelectBranch, onRegenerate, }: { message: ApiMessage; allMessages: ApiMessage[]; streaming: StreamingView | null; models: ApiModel[]; onSelectBranch: (parentKey: string, childId: string) => void; onRegenerate: (assistantMessageId: string, withModelId?: string) => void; }) { const [copied, setCopied] = useState(false); const isStreamingThis = streaming?.assistantMessageId === message.id; const content = isStreamingThis ? streaming.content : message.content; const reasoning = isStreamingThis ? streaming.reasoning : message.reasoning; const { index, count, siblings } = siblingInfo(allMessages, message); const parentKey = message.parent_id ?? "root"; const modelGone = message.model_id !== null && !models.some((m) => m.id === message.model_id && m.available); const copy = async () => { try { await navigator.clipboard.writeText(content); setCopied(true); setTimeout(() => setCopied(false), 1500); } catch { /* clipboard unavailable */ } }; if (message.role === "user") { return ( <>
{content}
{count > 1 && ( onSelectBranch(parentKey, siblings[index - 1].id)} onNext={() => onSelectBranch(parentKey, siblings[index + 1].id)} /> )} ); } return (
{isStreamingThis && } {message.model_name ?? message.model_id ?? "model"} {modelGone && !isStreamingThis && · Unavailable} ·{" "} {new Date(message.created_at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", })} {message.status === "cancelled" && · stopped}
{reasoning && (
reasoning
{reasoning}
)}
{(message.status === "failed" || (isStreamingThis && streaming?.error)) && (
{isStreamingThis ? streaming?.error : message.error_message ?? "This generation failed."}
)} {!isStreamingThis && (
{count > 1 && ( onSelectBranch(parentKey, siblings[index - 1].id)} onNext={() => onSelectBranch(parentKey, siblings[index + 1].id)} /> )}
)}
); } function BranchNav({ index, count, onPrev, onNext, }: { index: number; count: number; onPrev: () => void; onNext: () => void; }) { return (
{index + 1}/{count}
); }