import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { DeleteConfirm } from "@/components/DeleteConfirm"; import { Sidebar } from "@/components/Sidebar"; import { SettingsView } from "@/components/settings/SettingsView"; import { ThreadShell } from "@/components/thread/ThreadShell"; import { Sheet, SheetContent } from "@/components/ui/sheet"; import { preloadMarkdownText } from "@/components/MarkdownText"; import { useSessions } from "@/hooks/useSessions"; import { useTheme } from "@/hooks/useTheme"; import { cn } from "@/lib/utils"; import { clearSavedSecret, deriveWsUrl, fetchBootstrap, loadSavedSecret, saveSecret, } from "@/lib/bootstrap"; import { NanobotClient } from "@/lib/nanobot-client"; import { ClientProvider } from "@/providers/ClientProvider"; import type { ChatSummary } from "@/lib/types"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; type BootState = | { status: "loading" } | { status: "error"; message: string } | { status: "auth"; failed?: boolean } | { status: "ready"; client: NanobotClient; token: string; modelName: string | null; }; const SIDEBAR_STORAGE_KEY = "nanobot-webui.sidebar"; const SIDEBAR_WIDTH = 272; type ShellView = "chat" | "settings"; function AuthForm({ failed, onSecret, }: { failed: boolean; onSecret: (secret: string) => void; }) { const { t } = useTranslation(); const [value, setValue] = useState(""); const [submitting, setSubmitting] = useState(false); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); const secret = value.trim(); if (!secret) return; setSubmitting(true); onSecret(secret); }; return (

{t("app.auth.title")}

{t("app.auth.hint")}

{failed && (

{t("app.auth.invalid")}

)} setValue(e.target.value)} disabled={submitting} autoFocus />
); } function readSidebarOpen(): boolean { if (typeof window === "undefined") return true; try { const raw = window.localStorage.getItem(SIDEBAR_STORAGE_KEY); if (raw === null) return true; return raw === "1"; } catch { return true; } } export default function App() { const { t } = useTranslation(); const [state, setState] = useState({ status: "loading" }); const bootstrapWithSecret = useCallback( (secret: string) => { let cancelled = false; (async () => { setState({ status: "loading" }); try { const boot = await fetchBootstrap("", secret); if (cancelled) return; if (secret) saveSecret(secret); const url = deriveWsUrl(boot.ws_path, boot.token); const client = new NanobotClient({ url, onReauth: async () => { try { const refreshed = await fetchBootstrap("", secret); return deriveWsUrl(refreshed.ws_path, refreshed.token); } catch { return null; } }, }); client.connect(); setState({ status: "ready", client, token: boot.token, modelName: boot.model_name ?? null, }); } catch (e) { if (cancelled) return; const msg = (e as Error).message; if (msg.includes("HTTP 401") || msg.includes("HTTP 403")) { setState({ status: "auth", failed: true }); } else { setState({ status: "error", message: msg }); } } })(); return () => { cancelled = true; }; }, [], ); useEffect(() => { const saved = loadSavedSecret(); return bootstrapWithSecret(saved); }, [bootstrapWithSecret]); useEffect(() => { const warm = () => preloadMarkdownText(); const win = globalThis as typeof globalThis & { requestIdleCallback?: ( callback: IdleRequestCallback, options?: IdleRequestOptions, ) => number; cancelIdleCallback?: (handle: number) => void; }; if (typeof win.requestIdleCallback === "function") { const id = win.requestIdleCallback(warm, { timeout: 1500 }); return () => win.cancelIdleCallback?.(id); } const id = globalThis.setTimeout(warm, 250); return () => globalThis.clearTimeout(id); }, []); if (state.status === "loading") { return (
{t("app.loading.connecting")}
); } if (state.status === "auth") { return ( bootstrapWithSecret(s)} /> ); } if (state.status === "error") { return (

{t("app.error.title")}

{state.message}

{t("app.error.gatewayHint")}

); } const handleModelNameChange = (modelName: string | null) => { setState((current) => current.status === "ready" ? { ...current, modelName } : current, ); }; const handleLogout = () => { if (state.status === "ready") { state.client.close(); } clearSavedSecret(); setState({ status: "auth" }); }; return ( ); } function Shell({ onModelNameChange, onLogout }: { onModelNameChange: (modelName: string | null) => void; onLogout: () => void }) { const { t, i18n } = useTranslation(); const { theme, toggle } = useTheme(); const { sessions, loading, refresh, createChat, deleteChat } = useSessions(); const [activeKey, setActiveKey] = useState(null); const [view, setView] = useState("chat"); const [desktopSidebarOpen, setDesktopSidebarOpen] = useState(readSidebarOpen); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const [pendingDelete, setPendingDelete] = useState<{ key: string; label: string; } | null>(null); const lastSessionsLen = useRef(0); useEffect(() => { try { window.localStorage.setItem( SIDEBAR_STORAGE_KEY, desktopSidebarOpen ? "1" : "0", ); } catch { // ignore storage errors (private mode, etc.) } }, [desktopSidebarOpen]); useEffect(() => { if (activeKey) return; if (sessions.length > 0 && lastSessionsLen.current === 0) { setActiveKey(sessions[0].key); } lastSessionsLen.current = sessions.length; }, [sessions, activeKey]); const activeSession = useMemo(() => { if (!activeKey) return null; return sessions.find((s) => s.key === activeKey) ?? null; }, [sessions, activeKey]); const closeDesktopSidebar = useCallback(() => { setDesktopSidebarOpen(false); }, []); const closeMobileSidebar = useCallback(() => { setMobileSidebarOpen(false); }, []); const toggleSidebar = useCallback(() => { const isDesktop = typeof window !== "undefined" && window.matchMedia("(min-width: 1024px)").matches; if (isDesktop) { setDesktopSidebarOpen((v) => !v); } else { setMobileSidebarOpen((v) => !v); } }, []); const onCreateChat = useCallback(async () => { try { const chatId = await createChat(); setActiveKey(`websocket:${chatId}`); setView("chat"); setMobileSidebarOpen(false); return chatId; } catch (e) { console.error("Failed to create chat", e); return null; } }, [createChat]); const onNewChat = useCallback(() => { setActiveKey(null); setView("chat"); setMobileSidebarOpen(false); }, []); const onSelectChat = useCallback( (key: string) => { setActiveKey(key); setView("chat"); setMobileSidebarOpen(false); }, [], ); const onOpenSettings = useCallback(() => { setView("settings"); setMobileSidebarOpen(false); }, []); const onTurnEnd = useCallback(() => { void refresh(); }, [refresh]); const onConfirmDelete = useCallback(async () => { if (!pendingDelete) return; const key = pendingDelete.key; const deletingActive = activeKey === key; const currentIndex = sessions.findIndex((s) => s.key === key); const fallbackKey = deletingActive ? (sessions[currentIndex + 1]?.key ?? sessions[currentIndex - 1]?.key ?? null) : activeKey; setPendingDelete(null); if (deletingActive) setActiveKey(fallbackKey); try { await deleteChat(key); } catch (e) { if (deletingActive) setActiveKey(key); console.error("Failed to delete session", e); } }, [pendingDelete, deleteChat, activeKey, sessions]); const headerTitle = activeSession ? activeSession.title || activeSession.preview || t("chat.fallbackTitle", { id: activeSession.chatId.slice(0, 6) }) : t("app.brand"); useEffect(() => { document.title = activeSession ? t("app.documentTitle.chat", { title: headerTitle }) : t("app.documentTitle.base"); }, [activeSession, headerTitle, i18n.resolvedLanguage, t]); const sidebarProps = { sessions, activeKey, loading, onNewChat, onSelect: onSelectChat, onRequestDelete: (key: string, label: string) => setPendingDelete({ key, label }), }; return (
{/* Desktop sidebar: in normal flow, so the thread area width stays honest. */} setMobileSidebarOpen(open)} >
{view === "settings" ? ( setView("chat")} onModelNameChange={onModelNameChange} onLogout={onLogout} /> ) : ( )}
setPendingDelete(null)} onConfirm={onConfirmDelete} />
); }