import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { AskUserPrompt } from "@/components/thread/AskUserPrompt"; import { ThreadComposer } from "@/components/thread/ThreadComposer"; import { ThreadHeader } from "@/components/thread/ThreadHeader"; import { StreamErrorNotice } from "@/components/thread/StreamErrorNotice"; import { ThreadViewport } from "@/components/thread/ThreadViewport"; import { useNanobotStream } from "@/hooks/useNanobotStream"; import { useSessionHistory } from "@/hooks/useSessions"; import type { ChatSummary, UIMessage } from "@/lib/types"; import { useClient } from "@/providers/ClientProvider"; interface ThreadShellProps { session: ChatSummary | null; title: string; onToggleSidebar: () => void; onGoHome: () => void; onNewChat: () => Promise; hideSidebarToggleOnDesktop?: boolean; } function toModelBadgeLabel(modelName: string | null): string | null { if (!modelName) return null; const trimmed = modelName.trim(); if (!trimmed) return null; const leaf = trimmed.split("/").pop() ?? trimmed; return leaf || trimmed; } export function ThreadShell({ session, title, onToggleSidebar, onGoHome, onNewChat, hideSidebarToggleOnDesktop = false, }: ThreadShellProps) { const { t } = useTranslation(); const chatId = session?.chatId ?? null; const historyKey = session?.key ?? null; const { messages: historical, loading } = useSessionHistory(historyKey); const { client, modelName } = useClient(); const [booting, setBooting] = useState(false); const pendingFirstRef = useRef(null); const messageCacheRef = useRef>(new Map()); const initial = useMemo(() => { if (!chatId) return historical; return messageCacheRef.current.get(chatId) ?? historical; }, [chatId, historical]); const { messages, isStreaming, send, setMessages, streamError, dismissStreamError, } = useNanobotStream(chatId, initial); const showHeroComposer = messages.length === 0 && !loading; const pendingAsk = useMemo(() => { for (let index = messages.length - 1; index >= 0; index -= 1) { const message = messages[index]; if (message.kind === "trace") continue; if (message.role === "user") return null; if (message.role === "assistant" && message.buttons?.some((row) => row.length > 0)) { return { question: message.content, buttons: message.buttons, }; } if (message.role === "assistant") return null; } return null; }, [messages]); useEffect(() => { if (!chatId || loading) return; const cached = messageCacheRef.current.get(chatId); // When the user switches away and back, keep the local in-memory thread // state (including not-yet-persisted messages) instead of replacing it with // whatever the history endpoint currently knows about. setMessages(cached && cached.length > 0 ? cached : historical); // eslint-disable-next-line react-hooks/exhaustive-deps }, [loading, chatId, historical]); useEffect(() => { if (chatId) return; setMessages(historical); }, [chatId, historical, setMessages]); useEffect(() => { if (!chatId) return; messageCacheRef.current.set(chatId, messages); }, [chatId, messages]); useEffect(() => { if (!chatId) return; const pending = pendingFirstRef.current; if (!pending) return; pendingFirstRef.current = null; client.sendMessage(chatId, pending); setMessages((prev) => [ ...prev, { id: crypto.randomUUID(), role: "user", content: pending, createdAt: Date.now(), }, ]); setBooting(false); }, [chatId, client, setMessages]); const handleWelcomeSend = useCallback( async (content: string) => { if (booting) return; setBooting(true); pendingFirstRef.current = content; const newId = await onNewChat(); if (!newId) { pendingFirstRef.current = null; setBooting(false); } }, [booting, onNewChat], ); const emptyState = loading ? (
{t("thread.loadingConversation")}
) : (
nanobot

{t("thread.empty.description")}

); return (
{streamError ? ( ) : null} {pendingAsk ? ( ) : null} {session ? ( ) : ( )} } />
); }