import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { BarChart3, BookOpen, ChevronRight, Code2, ImageIcon, LayoutGrid, Lightbulb, MoreHorizontal, Palette, Sparkles, } from "lucide-react"; import { useTranslation } from "react-i18next"; 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, type SendImage, type SendOptions } from "@/hooks/useNanobotStream"; import { useSessionHistory } from "@/hooks/useSessions"; import { listSlashCommands } from "@/lib/api"; import type { ChatSummary, SlashCommand, UIMessage } from "@/lib/types"; import { useClient } from "@/providers/ClientProvider"; interface ThreadShellProps { session: ChatSummary | null; title: string; onToggleSidebar: () => void; onGoHome?: () => void; onNewChat?: () => void; onCreateChat?: () => Promise; onTurnEnd?: () => void; theme?: "light" | "dark"; onToggleTheme?: () => void; 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; } const QUICK_ACTION_KEYS = [ { key: "plan", icon: LayoutGrid, tone: "text-[#f25b8f]" }, { key: "analyze", icon: BarChart3, tone: "text-[#4f9de8]" }, { key: "brainstorm", icon: Lightbulb, tone: "text-[#53c59d]" }, { key: "code", icon: Code2, tone: "text-[#eba45d]" }, { key: "summarize", icon: BookOpen, tone: "text-[#a877e7]" }, { key: "more", icon: MoreHorizontal, tone: "text-muted-foreground/65" }, ] as const; const IMAGE_QUICK_ACTION_KEYS = [ { key: "icon", icon: ImageIcon, tone: "text-[#4f9de8]" }, { key: "sticker", icon: Sparkles, tone: "text-[#f25b8f]" }, { key: "poster", icon: Palette, tone: "text-[#eba45d]" }, { key: "product", icon: LayoutGrid, tone: "text-[#53c59d]" }, { key: "portrait", icon: ImageIcon, tone: "text-[#a877e7]" }, { key: "edit", icon: MoreHorizontal, tone: "text-muted-foreground/65" }, ] as const; interface PendingFirstMessage { content: string; images?: SendImage[]; options?: SendOptions; } export function ThreadShell({ session, title, onToggleSidebar, onCreateChat, onTurnEnd, theme = "light", onToggleTheme = () => {}, hideSidebarToggleOnDesktop = false, }: ThreadShellProps) { const { t } = useTranslation(); const chatId = session?.chatId ?? null; const historyKey = session?.key ?? null; const { messages: historical, loading, hasPendingToolCalls } = useSessionHistory(historyKey); const { modelName, token } = useClient(); const [booting, setBooting] = useState(false); const [slashCommands, setSlashCommands] = useState([]); const [heroImageMode, setHeroImageMode] = useState(false); const pendingFirstRef = useRef(null); const messageCacheRef = useRef>(new Map()); const lastCachedChatIdRef = useRef(null); const initial = useMemo(() => { if (!chatId) return historical; return messageCacheRef.current.get(chatId) ?? historical; }, [chatId, historical]); const { messages, isStreaming, send, stop, setMessages, streamError, dismissStreamError, } = useNanobotStream(chatId, initial, hasPendingToolCalls, onTurnEnd); const showHeroComposer = messages.length === 0 && !loading; 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((prev) => { if (cached && cached.length > 0) return cached; if (historical.length === 0 && prev.length > 0) return prev; return historical; }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [loading, chatId, historical]); useEffect(() => { if (chatId) return; setMessages(historical); }, [chatId, historical, setMessages]); useLayoutEffect(() => { if (!chatId) { lastCachedChatIdRef.current = null; return; } if (loading) return; // Skip the first cache write after a chat switch. During that render, // `messages` can still belong to the previous chat until the stream hook // resets its local state for the new session. if (lastCachedChatIdRef.current !== chatId) { lastCachedChatIdRef.current = chatId; if (messages.length > 0) { messageCacheRef.current.set(chatId, messages); } return; } messageCacheRef.current.set(chatId, messages); }, [chatId, loading, messages]); useEffect(() => { if (!chatId) return; const pending = pendingFirstRef.current; if (!pending) return; pendingFirstRef.current = null; send(pending.content, pending.images, pending.options); setBooting(false); }, [chatId, send]); useEffect(() => { let cancelled = false; (async () => { try { const commands = await listSlashCommands(token); if (!cancelled) setSlashCommands(commands); } catch { if (!cancelled) setSlashCommands([]); } })(); return () => { cancelled = true; }; }, [token]); const handleWelcomeSend = useCallback( async (content: string, images?: SendImage[], options?: SendOptions) => { if (booting) return; setBooting(true); pendingFirstRef.current = { content, images, options }; const newId = await onCreateChat?.(); if (!newId) { pendingFirstRef.current = null; setBooting(false); } }, [booting, onCreateChat], ); const handleQuickAction = useCallback( (prompt: string) => { const options: SendOptions | undefined = heroImageMode ? { imageGeneration: { enabled: true, aspect_ratio: null } } : undefined; if (session) { send(prompt, undefined, options); return; } void handleWelcomeSend(prompt, undefined, options); }, [handleWelcomeSend, heroImageMode, send, session], ); const quickActionItems = heroImageMode ? IMAGE_QUICK_ACTION_KEYS : QUICK_ACTION_KEYS; const quickActionPrefix = heroImageMode ? "thread.empty.imageQuickActions" : "thread.empty.quickActions"; const quickActions = (
{quickActionItems.map(({ key, icon: Icon, tone }) => { const title = t(`${quickActionPrefix}.${key}.title`); const prompt = t(`${quickActionPrefix}.${key}.prompt`); return ( ); })}
); const composer = ( <> {streamError ? ( ) : null} {session ? ( ) : ( )} {showHeroComposer ? quickActions : null} ); const emptyState = loading ? (
{t("thread.loadingConversation")}
) : (

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

); return (
); }