import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { DeleteConfirm } from "@/components/DeleteConfirm"; import { RenameChatDialog } from "@/components/RenameChatDialog"; import { Sidebar } from "@/components/Sidebar"; import { SessionSearchDialog } from "@/components/SessionSearchDialog"; import { SettingsView } from "@/components/settings/SettingsView"; import { ThreadShell } from "@/components/thread/ThreadShell"; import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"; import { useSessions } from "@/hooks/useSessions"; import { useDeferredTitleRefresh } from "@/hooks/useDeferredTitleRefresh"; import { useSidebarState } from "@/hooks/useSidebarState"; import { ThemeProvider, useTheme } from "@/hooks/useTheme"; import { cn } from "@/lib/utils"; import { clearSavedSecret, deriveWsUrl, fetchBootstrap, loadSavedSecret, saveSecret, } from "@/lib/bootstrap"; import { deriveTitle } from "@/lib/format"; 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; tokenExpiresAt: number; modelName: string | null; }; const SIDEBAR_STORAGE_KEY = "nanobot-webui.sidebar"; const COMPLETED_RUNS_STORAGE_KEY = "nanobot-webui.sidebar.completed-runs.v1"; const RESTART_STARTED_KEY = "nanobot-webui.restartStartedAt"; const SIDEBAR_WIDTH = 272; const TOKEN_REFRESH_MARGIN_MS = 30_000; const TOKEN_REFRESH_MIN_DELAY_MS = 5_000; type ShellView = "chat" | "settings"; function bootstrapTokenExpiresAt(expiresInSeconds: number): number { return Date.now() + Math.max(0, expiresInSeconds) * 1000; } function tokenRefreshDelayMs(expiresAt: number): number { const remaining = Math.max(0, expiresAt - Date.now()); const margin = Math.min( TOKEN_REFRESH_MARGIN_MS, Math.max(1_000, remaining / 2), ); return Math.max(TOKEN_REFRESH_MIN_DELAY_MS, remaining - margin); } 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; } } function readCompletedRunChatIds(): Set { if (typeof window === "undefined") return new Set(); try { const raw = window.localStorage.getItem(COMPLETED_RUNS_STORAGE_KEY); const parsed = raw ? JSON.parse(raw) : []; if (!Array.isArray(parsed)) return new Set(); return new Set(parsed.filter((item): item is string => typeof item === "string")); } catch { return new Set(); } } function writeCompletedRunChatIds(chatIds: Set): void { try { window.localStorage.setItem( COMPLETED_RUNS_STORAGE_KEY, JSON.stringify(Array.from(chatIds)), ); } catch { // ignore storage errors (private mode, etc.) } } export default function App() { const { t } = useTranslation(); const [state, setState] = useState({ status: "loading" }); const bootstrapSecretRef = useRef(""); 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); let client: NanobotClient; client = new NanobotClient({ url, onReauth: async () => { try { const refreshed = await fetchBootstrap("", bootstrapSecretRef.current); const refreshedUrl = deriveWsUrl(refreshed.ws_path, refreshed.token); const tokenExpiresAt = bootstrapTokenExpiresAt(refreshed.expires_in); setState((current) => current.status === "ready" && current.client === client ? { ...current, token: refreshed.token, tokenExpiresAt, modelName: refreshed.model_name ?? current.modelName, } : current, ); return refreshedUrl; } catch { return null; } }, }); bootstrapSecretRef.current = secret; client.connect(); setState({ status: "ready", client, token: boot.token, tokenExpiresAt: bootstrapTokenExpiresAt(boot.expires_in), 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(() => { if (state.status !== "ready") return; const client = state.client; const timer = window.setTimeout(async () => { try { const boot = await fetchBootstrap("", bootstrapSecretRef.current); const url = deriveWsUrl(boot.ws_path, boot.token); const tokenExpiresAt = bootstrapTokenExpiresAt(boot.expires_in); client.updateUrl(url); setState((current) => current.status === "ready" && current.client === client ? { ...current, token: boot.token, tokenExpiresAt, modelName: boot.model_name ?? current.modelName, } : current, ); } catch (e) { const msg = (e as Error).message; if (msg.includes("HTTP 401") || msg.includes("HTTP 403")) { setState({ status: "auth", failed: true }); } } }, tokenRefreshDelayMs(state.tokenExpiresAt)); return () => window.clearTimeout(timer); }, [state]); 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 { state: sidebarState, update: updateSidebarState } = useSidebarState(sessions, !loading); const [activeKey, setActiveKey] = useState(null); const [view, setView] = useState("chat"); const [desktopSidebarOpen, setDesktopSidebarOpen] = useState(readSidebarOpen); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const [sessionSearchOpen, setSessionSearchOpen] = useState(false); const [pendingDelete, setPendingDelete] = useState<{ key: string; label: string; } | null>(null); const [pendingRename, setPendingRename] = useState<{ key: string; label: string; } | null>(null); const restartSawDisconnectRef = useRef(false); const [restartToast, setRestartToast] = useState(null); const [isRestarting, setIsRestarting] = useState(false); const [runningChatIds, setRunningChatIds] = useState>(() => new Set()); const [completedChatIds, setCompletedChatIds] = useState>(readCompletedRunChatIds); const runningChatIdsRef = useRef>(new Set()); useEffect(() => { try { window.localStorage.setItem( SIDEBAR_STORAGE_KEY, desktopSidebarOpen ? "1" : "0", ); } catch { // ignore storage errors (private mode, etc.) } }, [desktopSidebarOpen]); useEffect(() => { writeCompletedRunChatIds(completedChatIds); }, [completedChatIds]); const activeSession = useMemo(() => { if (!activeKey) return null; return sessions.find((s) => s.key === activeKey) ?? null; }, [sessions, activeKey]); const runningChatIdList = useMemo(() => Array.from(runningChatIds), [runningChatIds]); const completedChatIdList = useMemo(() => Array.from(completedChatIds), [completedChatIds]); useEffect(() => { if (loading) return; const knownChatIds = new Set(sessions.map((session) => session.chatId)); setCompletedChatIds((current) => { const next = new Set( Array.from(current).filter((chatId) => knownChatIds.has(chatId)), ); return next.size === current.size ? current : next; }); }, [loading, sessions]); useEffect(() => { if (loading) return; const activeRunIds = sessions .filter((session) => typeof session.runStartedAt === "number") .map((session) => session.chatId); if (activeRunIds.length === 0) return; for (const chatId of activeRunIds) { client.attach(chatId); } setRunningChatIds((current) => { let changed = false; const next = new Set(current); for (const chatId of activeRunIds) { if (!next.has(chatId)) changed = true; next.add(chatId); } if (!changed) return current; runningChatIdsRef.current = next; return next; }); setCompletedChatIds((current) => { let changed = false; const next = new Set(current); for (const chatId of activeRunIds) { if (next.delete(chatId)) changed = true; } return changed ? next : current; }); }, [client, loading, sessions]); 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) => { const selectedChatId = sessions.find((session) => session.key === key)?.chatId; if (selectedChatId) { setCompletedChatIds((current) => { if (!current.has(selectedChatId)) return current; const next = new Set(current); next.delete(selectedChatId); return next; }); } setActiveKey(key); setView("chat"); setMobileSidebarOpen(false); }, [sessions], ); const onTogglePin = useCallback( (key: string) => { void updateSidebarState((current) => { const pinned = new Set(current.pinned_keys); if (pinned.has(key)) { pinned.delete(key); } else { pinned.add(key); } return { ...current, pinned_keys: Array.from(pinned), }; }); }, [updateSidebarState], ); const onRequestRename = useCallback((key: string, label: string) => { setPendingRename({ key, label }); }, []); const onConfirmRename = useCallback( (title: string) => { if (!pendingRename) return; const key = pendingRename.key; setPendingRename(null); void updateSidebarState((current) => { const titleOverrides = { ...current.title_overrides }; const cleaned = title.trim(); if (cleaned) { titleOverrides[key] = cleaned; } else { delete titleOverrides[key]; } return { ...current, title_overrides: titleOverrides, }; }); }, [pendingRename, updateSidebarState], ); const onToggleArchive = useCallback( (key: string) => { void updateSidebarState((current) => { const archived = new Set(current.archived_keys); const pinned = current.pinned_keys.filter((item) => item !== key); if (archived.has(key)) { archived.delete(key); } else { archived.add(key); } return { ...current, pinned_keys: pinned, archived_keys: Array.from(archived), }; }); if (activeKey === key && !sidebarState.archived_keys.includes(key)) { const archived = new Set([...sidebarState.archived_keys, key]); const next = sessions.find((session) => !archived.has(session.key)); setActiveKey(next?.key ?? null); } }, [activeKey, sessions, sidebarState.archived_keys, updateSidebarState], ); const onToggleArchived = useCallback(() => { void updateSidebarState((current) => ({ ...current, view: { ...current.view, show_archived: !current.view.show_archived, }, })); }, [updateSidebarState]); const onUpdateSidebarView = useCallback( (viewUpdate: Partial) => { void updateSidebarState((current) => ({ ...current, view: { ...current.view, ...viewUpdate, }, })); }, [updateSidebarState], ); const onOpenSessionSearch = useCallback(() => { setMobileSidebarOpen(false); setSessionSearchOpen(true); }, []); const onSelectSearchResult = useCallback( (key: string) => { setSessionSearchOpen(false); onSelectChat(key); }, [onSelectChat], ); const onOpenSettings = useCallback(() => { setSessionSearchOpen(false); 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.onRunStatus((chatId, startedAt) => { if (startedAt != null) { const nextRunning = new Set(runningChatIdsRef.current); nextRunning.add(chatId); runningChatIdsRef.current = nextRunning; setRunningChatIds(nextRunning); setCompletedChatIds((current) => { if (!current.has(chatId)) return current; const next = new Set(current); next.delete(chatId); return next; }); return; } if (!runningChatIdsRef.current.has(chatId)) return; const nextRunning = new Set(runningChatIdsRef.current); nextRunning.delete(chatId); runningChatIdsRef.current = nextRunning; setRunningChatIds(nextRunning); setCompletedChatIds((current) => { const next = new Set(current); next.add(chatId); return next; }); }); }, [client]); 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 ? sidebarState.title_overrides[activeSession.key] || activeSession.title || deriveTitle(activeSession.preview, t("chat.newChat")) : 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 }), onTogglePin, onRequestRename, onToggleArchive, onOpenSettings, onOpenSearch: onOpenSessionSearch, onToggleArchived, onUpdateView: onUpdateSidebarView, pinnedKeys: sidebarState.pinned_keys, archivedKeys: sidebarState.archived_keys, titleOverrides: sidebarState.title_overrides, runningChatIds: runningChatIdList, completedChatIds: completedChatIdList, viewState: sidebarState.view, showArchived: sidebarState.view.show_archived, archivedCount: sidebarState.archived_keys.length, }; const showMainSidebar = view !== "settings"; return (
{/* Desktop sidebar: in normal flow, so the thread area width stays honest. */} {showMainSidebar ? ( ) : null} {showMainSidebar ? ( setMobileSidebarOpen(open)} > {t("sidebar.navigation")} ) : null} {showMainSidebar ? ( ) : null}
{view === "settings" && (
)}
setPendingDelete(null)} onConfirm={onConfirmDelete} /> setPendingRename(null)} onConfirm={onConfirmRename} /> {restartToast ? (
{restartToast}
) : null}
); }