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 { useSessions } from "@/hooks/useSessions"; import { useDeferredTitleRefresh } from "@/hooks/useDeferredTitleRefresh"; import { ThemeProvider, 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, useClient } 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 RESTART_STARTED_KEY = "nanobot-webui.restartStartedAt"; 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]); 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 { client } = useClient(); 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 restartSawDisconnectRef = useRef(false); const [restartToast, setRestartToast] = useState(null); const [isRestarting, setIsRestarting] = useState(false); useEffect(() => { try { window.localStorage.setItem( SIDEBAR_STORAGE_KEY, desktopSidebarOpen ? "1" : "0", ); } catch { // ignore storage errors (private mode, etc.) } }, [desktopSidebarOpen]); 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 onBackToChat = useCallback(() => { setView("chat"); setMobileSidebarOpen(false); setActiveKey((current) => { if (!current) return null; if (sessions.some((session) => session.key === current)) return current; return sessions[0]?.key ?? null; }); }, [sessions]); const onRestart = useCallback(() => { const chatId = activeSession?.chatId ?? client.defaultChatId; if (!chatId) return; restartSawDisconnectRef.current = false; setIsRestarting(true); try { window.localStorage.setItem(RESTART_STARTED_KEY, String(Date.now())); } catch { // ignore storage errors } client.sendMessage(chatId, "/restart"); }, [activeSession?.chatId, client]); useEffect(() => { return client.onRuntimeModelUpdate((modelName) => { onModelNameChange(modelName); }); }, [client, onModelNameChange]); useEffect(() => { return client.onStatus((status) => { let startedAt = 0; try { startedAt = Number(window.localStorage.getItem(RESTART_STARTED_KEY) ?? "0"); } catch { startedAt = 0; } if (!startedAt) return; if (status !== "open") { restartSawDisconnectRef.current = true; return; } const elapsedMs = Date.now() - startedAt; if (!restartSawDisconnectRef.current && elapsedMs < 1500) return; try { window.localStorage.removeItem(RESTART_STARTED_KEY); } catch { // ignore storage errors } setIsRestarting(false); setRestartToast(t("app.restart.completed", { seconds: (elapsedMs / 1000).toFixed(1) })); window.setTimeout(() => setRestartToast(null), 3_500); }); }, [client, t]); const onTurnEnd = useDeferredTitleRefresh(activeSession, 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(() => { if (view === "settings") { document.title = t("app.documentTitle.chat", { title: t("settings.sidebar.title"), }); return; } document.title = activeSession ? t("app.documentTitle.chat", { title: headerTitle }) : t("app.documentTitle.base"); }, [activeSession, headerTitle, i18n.resolvedLanguage, t, view]); const sidebarProps = { sessions, activeKey, loading, onNewChat, onSelect: onSelectChat, onRequestDelete: (key: string, label: string) => setPendingDelete({ key, label }), onOpenSettings, }; const showMainSidebar = view !== "settings"; return (
{/* Desktop sidebar: in normal flow, so the thread area width stays honest. */} {showMainSidebar ? ( ) : null} {showMainSidebar ? ( setMobileSidebarOpen(open)} > ) : null}
{view === "settings" && (
)}
setPendingDelete(null)} onConfirm={onConfirmDelete} /> {restartToast ? (
{restartToast}
) : null}
); }