import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode, } from "react"; import { Moon, PanelLeft, Sun } from "lucide-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, type SettingsSectionKey } 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 { useSkills } from "@/hooks/useSkills"; import { ThemeProvider, useTheme } from "@/hooks/useTheme"; import { cn } from "@/lib/utils"; import { clearSavedSecret, deriveWsUrl, fetchBootstrap, loadSavedSecret, saveSecret, } from "@/lib/bootstrap"; import { displayTitle } from "@/lib/chat-groups"; import { deriveTitle } from "@/lib/format"; import { NanobotClient } from "@/lib/nanobot-client"; import { ClientProvider, useClient } from "@/providers/ClientProvider"; import type { ChatSummary, RuntimeSurface, SettingsPayload, WorkspaceScopePayload, WorkspacesPayload, } from "@/lib/types"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { fetchSettings, fetchWorkspaces } from "@/lib/api"; import { createRuntimeHost, getHostApi, toRuntimeSurface, } from "@/lib/runtime"; import { projectNameFromPath } from "@/lib/workspace"; type BootState = | { status: "loading" } | { status: "error"; message: string } | { status: "auth"; failed?: boolean } | { status: "ready"; client: NanobotClient; token: string; tokenExpiresAt: number; modelName: string | null; runtimeSurface: RuntimeSurface; }; 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 SIDEBAR_RAIL_WIDTH = 56; const TOKEN_REFRESH_MARGIN_MS = 30_000; const TOKEN_REFRESH_MIN_DELAY_MS = 5_000; type ShellView = "chat" | "settings" | "apps" | "skills"; type ShellRoute = { view: ShellView; activeKey: string | null; settingsSection: SettingsSectionKey; }; const SETTINGS_SECTION_KEYS: SettingsSectionKey[] = [ "overview", "appearance", "models", "image", "voice", "browser", "apps", "skills", "runtime", "advanced", ]; function isSettingsSectionKey(value: string | null): value is SettingsSectionKey { return SETTINGS_SECTION_KEYS.includes(value as SettingsSectionKey); } function defaultShellRoute(): ShellRoute { return { view: "chat", activeKey: null, settingsSection: "overview" }; } function shellViewForSettingsSection(section: SettingsSectionKey): ShellView { if (section === "apps" || section === "skills") return section; return "settings"; } function readShellRoute(): ShellRoute { if (typeof window === "undefined") return defaultShellRoute(); const hash = window.location.hash.startsWith("#") ? window.location.hash.slice(1) : window.location.hash; if (!hash || hash === "/" || hash === "/new") return defaultShellRoute(); const [path, query = ""] = hash.split("?", 2); const params = new URLSearchParams(query); const rawSettingsSection = params.get("section"); const settingsSection = isSettingsSectionKey(rawSettingsSection) ? rawSettingsSection : "overview"; const activeKey = params.get("chat")?.trim() || null; if (path === "/settings") { return { view: shellViewForSettingsSection(settingsSection), activeKey, settingsSection, }; } if (path === "/apps") { return { view: "apps", activeKey, settingsSection: "apps" }; } if (path === "/skills") { return { view: "skills", activeKey, settingsSection: "skills" }; } if (path.startsWith("/chat/")) { const encoded = path.slice("/chat/".length); try { const key = decodeURIComponent(encoded).trim(); return key ? { view: "chat", activeKey: key, settingsSection: "overview" } : defaultShellRoute(); } catch { return defaultShellRoute(); } } return defaultShellRoute(); } function shellRouteHash(route: ShellRoute): string { if (route.view === "chat") { return route.activeKey ? `#/chat/${encodeURIComponent(route.activeKey)}` : "#/new"; } const params = new URLSearchParams(); if (route.activeKey) params.set("chat", route.activeKey); if (route.view === "settings" && route.settingsSection !== "overview") { params.set("section", route.settingsSection); } const query = params.toString(); return `#/${route.view}${query ? `?${query}` : ""}`; } function writeShellRoute(route: ShellRoute, replace = false): void { if (typeof window === "undefined") return; const nextHash = shellRouteHash(route); if (window.location.hash === nextHash) return; if (replace) { window.history.replaceState( null, "", `${window.location.pathname}${window.location.search}${nextHash}`, ); return; } window.location.hash = nextHash; } 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.) } } function normalizeWorkspaceScope(scope: WorkspaceScopePayload): WorkspaceScopePayload { const accessMode = scope.access_mode === "restricted" ? "restricted" : "full"; return { ...scope, project_name: scope.project_name ?? projectNameFromPath(scope.project_path), access_mode: accessMode, restrict_to_workspace: accessMode === "restricted", }; } function HostChrome({ onToggleSidebar, onSidebarPreviewEnter, onSidebarPreviewLeave, sidebarOpen = true, rightAction, }: { onToggleSidebar?: () => void; onSidebarPreviewEnter?: () => void; onSidebarPreviewLeave?: () => void; sidebarOpen?: boolean; rightAction?: ReactNode; }) { const { t } = useTranslation(); return (
{onToggleSidebar ? ( ) : null} {rightAction ? (
{rightAction}
) : null}
); } export default function App() { const { t } = useTranslation(); const [state, setState] = useState({ status: "loading" }); const bootstrapSecretRef = useRef(""); const refreshReadyClient = useCallback( async (client: NanobotClient, fallbackSurface: RuntimeSurface) => { const boot = await fetchBootstrap("", bootstrapSecretRef.current); const url = deriveWsUrl(boot.ws_path, boot.token, boot.ws_url); const runtimeSurface = boot.runtime_surface ? toRuntimeSurface(boot.runtime_surface) : fallbackSurface; const runtimeHost = createRuntimeHost(runtimeSurface, boot.runtime_capabilities); const tokenExpiresAt = bootstrapTokenExpiresAt(boot.expires_in); if (runtimeHost.socketFactory) { client.updateUrl(url, runtimeHost.socketFactory); } else { client.updateUrl(url); } setState((current) => current.status === "ready" && current.client === client ? { ...current, token: boot.token, tokenExpiresAt, modelName: boot.model_name ?? current.modelName, runtimeSurface, } : current, ); return { token: boot.token, url }; }, [], ); 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, boot.ws_url); const runtimeSurface = toRuntimeSurface(boot.runtime_surface); const runtimeHost = createRuntimeHost(runtimeSurface, boot.runtime_capabilities); const client = new NanobotClient({ url, socketFactory: runtimeHost.socketFactory, onReauth: async () => { try { const refreshed = await refreshReadyClient(client, runtimeSurface); return refreshed.url; } 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, runtimeSurface, }); } 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; }; }, [refreshReadyClient], ); useEffect(() => { if (state.status !== "ready") return; const client = state.client; const timer = window.setTimeout(async () => { try { await refreshReadyClient(client, state.runtimeSurface); } 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); }, [refreshReadyClient, 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" }); }; const handleNativeEngineRestart = async (): Promise => { const hostApi = getHostApi(); if (!hostApi?.restartEngine) { throw new Error("native engine restart is unavailable"); } await hostApi.restartEngine(); const refreshed = await refreshReadyClient(state.client, state.runtimeSurface); return refreshed.token; }; return ( ); } function Shell({ runtimeSurface, onModelNameChange, onLogout, onNativeEngineRestart, }: { runtimeSurface: RuntimeSurface; onModelNameChange: (modelName: string | null) => void; onLogout: () => void; onNativeEngineRestart: () => Promise; }) { const { t, i18n } = useTranslation(); const { client, token } = useClient(); const { theme, toggle } = useTheme(); const { sessions, loading, refresh, createChat, forkChat, deleteChat } = useSessions(); const { state: sidebarState, update: updateSidebarState } = useSidebarState(sessions, !loading); const initialRouteRef = useRef(null); if (!initialRouteRef.current) initialRouteRef.current = readShellRoute(); const [activeKey, setActiveKey] = useState( initialRouteRef.current.activeKey, ); const [view, setView] = useState(initialRouteRef.current.view); const [settingsInitialSection, setSettingsInitialSection] = useState(initialRouteRef.current.settingsSection); const [hostSidebarOpen, setHostSidebarOpen] = useState(readSidebarOpen); const [hostSidebarPreviewOpen, setHostSidebarPreviewOpen] = useState(false); 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 [pendingProjectRename, setPendingProjectRename] = 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 [workspaces, setWorkspaces] = useState(null); const skills = useSkills(token); const [settingsSnapshot, setSettingsSnapshot] = useState(null); const [workspaceError, setWorkspaceError] = useState(null); const [draftWorkspaceScope, setDraftWorkspaceScope] = useState(null); const [workspaceOverrides, setWorkspaceOverrides] = useState>({}); const runningChatIdsRef = useRef>(new Set()); const activeChatIdRef = useRef(null); const hostSidebarPreviewCloseTimerRef = useRef(null); const effectiveRuntimeSurface = settingsSnapshot?.surface ?? settingsSnapshot?.runtime_surface ?? runtimeSurface; const showHostChrome = effectiveRuntimeSurface === "native"; const showMainSidebar = view !== "settings"; const navigate = useCallback( (route: ShellRoute, options?: { replace?: boolean }) => { setActiveKey(route.activeKey); setView(route.view); setSettingsInitialSection(route.settingsSection); writeShellRoute(route, options?.replace); }, [], ); useEffect(() => { const applyRoute = () => { const route = readShellRoute(); setActiveKey(route.activeKey); setView(route.view); setSettingsInitialSection(route.settingsSection); setWorkspaceError(null); if (route.view === "chat" && !route.activeKey) { setDraftWorkspaceScope(null); } }; window.addEventListener("hashchange", applyRoute); return () => window.removeEventListener("hashchange", applyRoute); }, []); useEffect(() => { let cancelled = false; fetchSettings(token) .then((payload) => { if (!cancelled) setSettingsSnapshot(payload); }) .catch(() => { if (!cancelled) setSettingsSnapshot(null); }); return () => { cancelled = true; }; }, [token]); useEffect(() => { try { window.localStorage.setItem( SIDEBAR_STORAGE_KEY, hostSidebarOpen ? "1" : "0", ); } catch { // ignore storage errors (private mode, etc.) } }, [hostSidebarOpen]); 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]); const activeChatId = activeSession?.chatId ?? null; useEffect(() => { activeChatIdRef.current = activeChatId; if (!activeChatId) return; setCompletedChatIds((current) => { if (!current.has(activeChatId)) return current; const next = new Set(current); next.delete(activeChatId); return next; }); }, [activeChatId]); const activeWorkspaceScope = useMemo(() => { if (activeChatId && workspaceOverrides[activeChatId]) { return workspaceOverrides[activeChatId]; } if (activeSession?.workspaceScope) { return activeSession.workspaceScope; } return draftWorkspaceScope ?? workspaces?.default_scope ?? null; }, [ activeChatId, activeSession?.workspaceScope, draftWorkspaceScope, workspaceOverrides, workspaces?.default_scope, ]); const activeChatRunning = activeChatId ? runningChatIds.has(activeChatId) : false; const refreshWorkspaces = useCallback(async () => { try { const payload = await fetchWorkspaces(token); setWorkspaces(payload); } catch { setWorkspaces(null); } }, [token]); useEffect(() => { void refreshWorkspaces(); }, [refreshWorkspaces]); 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; }); setWorkspaceOverrides((current) => { const entries = Object.entries(current).filter(([chatId]) => knownChatIds.has(chatId)); return entries.length === Object.keys(current).length ? current : Object.fromEntries(entries); }); }, [loading, sessions]); useEffect(() => { if (loading || !activeKey) return; if (sessions.some((session) => session.key === activeKey)) return; const currentRoute = readShellRoute(); navigate( currentRoute.view === "chat" ? defaultShellRoute() : { ...currentRoute, activeKey: null, }, { replace: true }, ); }, [activeKey, loading, navigate, sessions]); useEffect(() => { return client.onSessionUpdate((_chatId, _scope, workspaceScope) => { if (!workspaceScope) return; const next = normalizeWorkspaceScope(workspaceScope); setWorkspaceOverrides((current) => ({ ...current, [_chatId]: next, })); setDraftWorkspaceScope(next); setWorkspaceError(null); void refreshWorkspaces(); }); }, [client, refreshWorkspaces]); useEffect(() => { return client.onError((error) => { if (error.kind !== "workspace_scope_rejected") return; setWorkspaceError(t("errors.workspaceScopeRejected.body")); void refreshWorkspaces(); }); }, [client, refreshWorkspaces, t]); 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 clearHostSidebarPreviewCloseTimer = useCallback(() => { if (hostSidebarPreviewCloseTimerRef.current === null) return; window.clearTimeout(hostSidebarPreviewCloseTimerRef.current); hostSidebarPreviewCloseTimerRef.current = null; }, []); const closeHostSidebarPreview = useCallback(() => { clearHostSidebarPreviewCloseTimer(); setHostSidebarPreviewOpen(false); }, [clearHostSidebarPreviewCloseTimer]); const openHostSidebarPreview = useCallback(() => { if (!showHostChrome || !showMainSidebar || hostSidebarOpen) return; clearHostSidebarPreviewCloseTimer(); setHostSidebarPreviewOpen(true); }, [ clearHostSidebarPreviewCloseTimer, hostSidebarOpen, showHostChrome, showMainSidebar, ]); const scheduleHostSidebarPreviewClose = useCallback(() => { clearHostSidebarPreviewCloseTimer(); if (!showHostChrome || !showMainSidebar || hostSidebarOpen) { setHostSidebarPreviewOpen(false); return; } hostSidebarPreviewCloseTimerRef.current = window.setTimeout(() => { setHostSidebarPreviewOpen(false); hostSidebarPreviewCloseTimerRef.current = null; }, 160); }, [ clearHostSidebarPreviewCloseTimer, hostSidebarOpen, showHostChrome, showMainSidebar, ]); useEffect(() => { return () => clearHostSidebarPreviewCloseTimer(); }, [clearHostSidebarPreviewCloseTimer]); useEffect(() => { if (!showHostChrome || !showMainSidebar || hostSidebarOpen) { closeHostSidebarPreview(); } }, [ closeHostSidebarPreview, hostSidebarOpen, showHostChrome, showMainSidebar, ]); const closeHostSidebar = useCallback(() => { closeHostSidebarPreview(); setHostSidebarOpen(false); }, [closeHostSidebarPreview]); const openHostSidebar = useCallback(() => { closeHostSidebarPreview(); setHostSidebarOpen(true); }, [closeHostSidebarPreview]); const toggleHostSidebar = useCallback(() => { closeHostSidebarPreview(); setHostSidebarOpen((v) => !v); }, [closeHostSidebarPreview]); const closeMobileSidebar = useCallback(() => { setMobileSidebarOpen(false); }, []); const toggleSidebar = useCallback(() => { const isNativeHost = typeof window !== "undefined" && window.matchMedia("(min-width: 1024px)").matches; if (isNativeHost) { closeHostSidebarPreview(); setHostSidebarOpen((v) => !v); } else { setMobileSidebarOpen((v) => !v); } }, [closeHostSidebarPreview]); const applyWorkspaceScope = useCallback( (scope: WorkspaceScopePayload) => { const next = normalizeWorkspaceScope(scope); setWorkspaceError(null); if (activeChatId) { if (!activeChatRunning) { client.setWorkspaceScope(activeChatId, next); } return; } setDraftWorkspaceScope(next); }, [activeChatId, activeChatRunning, client], ); const onCreateChat = useCallback(async (workspaceScope?: WorkspaceScopePayload | null) => { try { const scope = workspaceScope ?? activeWorkspaceScope; const chatId = await createChat(scope); navigate({ view: "chat", activeKey: `websocket:${chatId}`, settingsSection: "overview", }); setMobileSidebarOpen(false); if (scope) { setWorkspaceOverrides((current) => ({ ...current, [chatId]: normalizeWorkspaceScope(scope), })); } return chatId; } catch (e) { console.error("Failed to create chat", e); if (e instanceof Error && e.message.startsWith("workspace_scope_rejected:")) { setWorkspaceError(t("errors.workspaceScopeRejected.body")); } return null; } }, [activeWorkspaceScope, createChat, navigate, t]); const onForkChat = useCallback(async ( sourceChatId: string, beforeUserIndex: number, ) => { try { const sourceSession = sessions.find((session) => session.chatId === sourceChatId); const sourceTitle = sourceSession ? displayTitle(sourceSession, sidebarState.title_overrides, t("chat.newChat")) : t("chat.newChat"); const chatId = await forkChat( sourceChatId, beforeUserIndex, t("chat.forkTitle", { title: sourceTitle }), ); navigate({ view: "chat", activeKey: `websocket:${chatId}`, settingsSection: "overview", }); setMobileSidebarOpen(false); return chatId; } catch (e) { console.error("Failed to fork chat", e); return null; } }, [forkChat, navigate, sessions, sidebarState.title_overrides, t]); const onNewChat = useCallback(() => { navigate(defaultShellRoute()); setDraftWorkspaceScope(null); setWorkspaceError(null); setSessionSearchOpen(false); setMobileSidebarOpen(false); }, [navigate]); const onNewChatInProject = useCallback( (projectPath: string, projectName: string) => { const base = workspaces?.default_scope ?? activeWorkspaceScope; const trimmed = projectPath.trim(); if (!base || !trimmed) { onNewChat(); return; } navigate(defaultShellRoute()); setDraftWorkspaceScope(normalizeWorkspaceScope({ project_path: trimmed, project_name: projectName || projectNameFromPath(trimmed), access_mode: base.access_mode, restrict_to_workspace: base.access_mode === "restricted", })); setWorkspaceError(null); setMobileSidebarOpen(false); }, [activeWorkspaceScope, navigate, onNewChat, workspaces?.default_scope], ); const onSelectChat = useCallback( (key: string) => { const selected = sessions.find((session) => session.key === key); const selectedChatId = selected?.chatId; if (selectedChatId) { setCompletedChatIds((current) => { if (!current.has(selectedChatId)) return current; const next = new Set(current); next.delete(selectedChatId); return next; }); } if (selected?.workspaceScope) { setDraftWorkspaceScope(normalizeWorkspaceScope(selected.workspaceScope)); } else { setDraftWorkspaceScope(null); } setWorkspaceError(null); navigate({ view: "chat", activeKey: key, settingsSection: "overview" }); setMobileSidebarOpen(false); }, [navigate, 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 onToggleGroup = useCallback( (groupId: string) => { void updateSidebarState((current) => { const collapsedGroups = { ...current.collapsed_groups }; if (groupId === "workspace:chats" || groupId === "date:all") { if (collapsedGroups[groupId] === false) { delete collapsedGroups[groupId]; } else { collapsedGroups[groupId] = false; } return { ...current, collapsed_groups: collapsedGroups, }; } if (collapsedGroups[groupId]) { delete collapsedGroups[groupId]; } else { collapsedGroups[groupId] = true; } return { ...current, collapsed_groups: collapsedGroups, }; }); }, [updateSidebarState], ); const onRequestRenameProject = useCallback((key: string, label: string) => { setPendingProjectRename({ key, label }); }, []); const onConfirmProjectRename = useCallback( (title: string) => { if (!pendingProjectRename) return; const key = pendingProjectRename.key; setPendingProjectRename(null); void updateSidebarState((current) => { const projectNameOverrides = { ...current.project_name_overrides }; const cleaned = title.trim(); if (cleaned) { projectNameOverrides[key] = cleaned; } else { delete projectNameOverrides[key]; } return { ...current, project_name_overrides: projectNameOverrides, }; }); }, [pendingProjectRename, 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)); navigate({ view: "chat", activeKey: next?.key ?? null, settingsSection: "overview", }); } }, [activeKey, navigate, sessions, sidebarState.archived_keys, updateSidebarState], ); const onToggleArchived = useCallback(() => { void updateSidebarState((current) => ({ ...current, view: { ...current.view, show_archived: !current.view.show_archived, }, })); }, [updateSidebarState]); const onOpenSessionSearch = useCallback(() => { setMobileSidebarOpen(false); setSessionSearchOpen(true); }, []); useEffect(() => { const handleKeyDown = (event: globalThis.KeyboardEvent) => { if (event.defaultPrevented) return; const commandShiftO = (event.metaKey || event.ctrlKey) && event.shiftKey && !event.altKey; if (commandShiftO && event.key.toLowerCase() === "o") { event.preventDefault(); onNewChat(); return; } const plainCommandK = (event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey; if (!plainCommandK) return; if (event.key.toLowerCase() !== "k") return; event.preventDefault(); onOpenSessionSearch(); }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [onNewChat, onOpenSessionSearch]); const onSelectSearchResult = useCallback( (key: string) => { setSessionSearchOpen(false); onSelectChat(key); }, [onSelectChat], ); const onOpenSettings = useCallback((section: SettingsSectionKey = "overview") => { setSessionSearchOpen(false); navigate({ view: "settings", activeKey, settingsSection: section }); setMobileSidebarOpen(false); }, [activeKey, navigate]); const onOpenModelSettings = useCallback(() => { onOpenSettings("models"); }, [onOpenSettings]); const onOpenApps = useCallback(() => { setSessionSearchOpen(false); navigate({ view: "apps", activeKey, settingsSection: "apps" }); setMobileSidebarOpen(false); }, [activeKey, navigate]); const onOpenSkills = useCallback(() => { setSessionSearchOpen(false); navigate({ view: "skills", activeKey, settingsSection: "skills" }); setMobileSidebarOpen(false); }, [activeKey, navigate]); const onSettingsSectionChange = useCallback( (section: SettingsSectionKey) => { navigate({ view: shellViewForSettingsSection(section), activeKey, settingsSection: section, }); }, [activeKey, navigate], ); const onBackToChat = useCallback(() => { setMobileSidebarOpen(false); const nextKey = (() => { if (!activeKey) return null; if (sessions.some((session) => session.key === activeKey)) return activeKey; return sessions[0]?.key ?? null; })(); navigate({ view: "chat", activeKey: nextKey, settingsSection: "overview", }); }, [activeKey, navigate, 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); if (activeChatIdRef.current === chatId) { next.delete(chatId); } else { next.add(chatId); } return next; }); }); }, [client]); useEffect(() => { return client.onStatus((status) => { const startedAt = (() => { try { return Number(window.localStorage.getItem(RESTART_STARTED_KEY) ?? "0"); } catch { return 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) { navigate({ view: "chat", activeKey: fallbackKey, settingsSection: "overview", }, { replace: true }); } try { await deleteChat(key); } catch (e) { if (deletingActive) { navigate({ view: "chat", activeKey: key, settingsSection: "overview", }, { replace: true }); } console.error("Failed to delete session", e); } }, [pendingDelete, deleteChat, activeKey, navigate, 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; } if (view === "apps") { document.title = t("app.documentTitle.chat", { title: t("settings.nav.apps", { defaultValue: "Apps" }), }); return; } if (view === "skills") { document.title = t("app.documentTitle.chat", { title: t("settings.nav.skills", { defaultValue: "Skills" }), }); 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, onToggleGroup, onRequestRenameProject, onNewChatInProject, onOpenSettings, onOpenApps, onOpenSkills, onOpenSearch: onOpenSessionSearch, activeUtility: view === "apps" || view === "skills" ? view : null, onToggleArchived, pinnedKeys: sidebarState.pinned_keys, archivedKeys: sidebarState.archived_keys, titleOverrides: sidebarState.title_overrides, projectNameOverrides: sidebarState.project_name_overrides, collapsedGroups: sidebarState.collapsed_groups, runningChatIds: runningChatIdList, completedChatIds: completedChatIdList, viewState: sidebarState.view, showArchived: sidebarState.view.show_archived, archivedCount: sidebarState.archived_keys.length, defaultWorkspacePath: workspaces?.default_scope.project_path ?? null, }; const hostSidebarCollapsed = showHostChrome && !hostSidebarOpen; const showHostSidebarPreview = showMainSidebar && hostSidebarCollapsed && hostSidebarPreviewOpen; const hostSidebarFlowWidth = showHostChrome ? (hostSidebarOpen ? SIDEBAR_WIDTH : 0) : (hostSidebarOpen ? SIDEBAR_WIDTH : SIDEBAR_RAIL_WIDTH); const renderHostSidebarFlowContent = !showHostChrome || hostSidebarOpen; useEffect(() => { document.documentElement.classList.toggle("native-host", showHostChrome); return () => { document.documentElement.classList.remove("native-host"); }; }, [showHostChrome]); return (
{showHostChrome ? ( {theme === "dark" ? ( ) : ( )} ) } /> ) : null}
{/* Host sidebar: in normal flow, so the thread area width stays honest. */} {showMainSidebar ? ( ) : null} {showHostSidebarPreview ? ( ) : null} {showMainSidebar ? ( setMobileSidebarOpen(open)} > {t("sidebar.navigation")} ) : null}
{view !== "chat" && (
)}
setPendingDelete(null)} onConfirm={onConfirmDelete} /> setPendingRename(null)} onConfirm={onConfirmRename} /> setPendingProjectRename(null)} onConfirm={onConfirmProjectRename} /> {restartToast ? (
{restartToast}
) : null}
); }