diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 6119a79b1..aa0c59c1b 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -61,6 +61,95 @@ 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"; +type ShellRoute = { + view: ShellView; + activeKey: string | null; + settingsSection: SettingsSectionKey; +}; + +const SETTINGS_SECTION_KEYS: SettingsSectionKey[] = [ + "overview", + "appearance", + "models", + "image", + "browser", + "apps", + "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 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: "settings", activeKey, settingsSection }; + } + if (path === "/apps") { + return { view: "apps", activeKey, settingsSection: "apps" }; + } + 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; @@ -433,9 +522,14 @@ function Shell({ 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 [settingsInitialSection, setSettingsInitialSection] = useState("overview"); + 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 [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); @@ -467,6 +561,31 @@ function Shell({ const runningChatIdsRef = useRef>(new Set()); const activeChatIdRef = useRef(null); + 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) @@ -558,6 +677,21 @@ function Shell({ }); }, [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; @@ -653,8 +787,11 @@ function Shell({ try { const scope = workspaceScope ?? activeWorkspaceScope; const chatId = await createChat(scope); - setActiveKey(`websocket:${chatId}`); - setView("chat"); + navigate({ + view: "chat", + activeKey: `websocket:${chatId}`, + settingsSection: "overview", + }); setMobileSidebarOpen(false); if (scope) { setWorkspaceOverrides((current) => ({ @@ -670,15 +807,14 @@ function Shell({ } return null; } - }, [activeWorkspaceScope, createChat, t]); + }, [activeWorkspaceScope, createChat, navigate, t]); const onNewChat = useCallback(() => { - setActiveKey(null); + navigate(defaultShellRoute()); setDraftWorkspaceScope(null); setWorkspaceError(null); - setView("chat"); setMobileSidebarOpen(false); - }, []); + }, [navigate]); const onNewChatInProject = useCallback( (projectPath: string, projectName: string) => { @@ -688,7 +824,7 @@ function Shell({ onNewChat(); return; } - setActiveKey(null); + navigate(defaultShellRoute()); setDraftWorkspaceScope(normalizeWorkspaceScope({ project_path: trimmed, project_name: projectName || projectNameFromPath(trimmed), @@ -696,10 +832,9 @@ function Shell({ restrict_to_workspace: base.access_mode === "restricted", })); setWorkspaceError(null); - setView("chat"); setMobileSidebarOpen(false); }, - [activeWorkspaceScope, onNewChat, workspaces?.default_scope], + [activeWorkspaceScope, navigate, onNewChat, workspaces?.default_scope], ); const onSelectChat = useCallback( @@ -720,11 +855,10 @@ function Shell({ setDraftWorkspaceScope(null); } setWorkspaceError(null); - setActiveKey(key); - setView("chat"); + navigate({ view: "chat", activeKey: key, settingsSection: "overview" }); setMobileSidebarOpen(false); }, - [sessions], + [navigate, sessions], ); const onTogglePin = useCallback( @@ -845,10 +979,14 @@ function Shell({ 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); + navigate({ + view: "chat", + activeKey: next?.key ?? null, + settingsSection: "overview", + }); } }, - [activeKey, sessions, sidebarState.archived_keys, updateSidebarState], + [activeKey, navigate, sessions, sidebarState.archived_keys, updateSidebarState], ); const onToggleArchived = useCallback(() => { @@ -891,27 +1029,40 @@ function Shell({ const onOpenSettings = useCallback((section: SettingsSectionKey = "overview") => { setSessionSearchOpen(false); - setSettingsInitialSection(section); - setView("settings"); + navigate({ view: "settings", activeKey, settingsSection: section }); setMobileSidebarOpen(false); - }, []); + }, [activeKey, navigate]); const onOpenApps = useCallback(() => { setSessionSearchOpen(false); - setSettingsInitialSection("apps"); - setView("apps"); + navigate({ view: "apps", activeKey, settingsSection: "apps" }); setMobileSidebarOpen(false); - }, []); + }, [activeKey, navigate]); + + const onSettingsSectionChange = useCallback( + (section: SettingsSectionKey) => { + navigate({ + view: section === "apps" ? "apps" : "settings", + activeKey, + settingsSection: section, + }); + }, + [activeKey, navigate], + ); const onBackToChat = useCallback(() => { - setView("chat"); setMobileSidebarOpen(false); - setActiveKey((current) => { - if (!current) return null; - if (sessions.some((session) => session.key === current)) return current; + 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", }); - }, [sessions]); + }, [activeKey, navigate, sessions]); const onRestart = useCallback(() => { const chatId = activeSession?.chatId ?? client.defaultChatId; @@ -1003,14 +1154,26 @@ function Shell({ ? (sessions[currentIndex + 1]?.key ?? sessions[currentIndex - 1]?.key ?? null) : activeKey; setPendingDelete(null); - if (deletingActive) setActiveKey(fallbackKey); + if (deletingActive) { + navigate({ + view: "chat", + activeKey: fallbackKey, + settingsSection: "overview", + }, { replace: true }); + } try { await deleteChat(key); } catch (e) { - if (deletingActive) setActiveKey(key); + if (deletingActive) { + navigate({ + view: "chat", + activeKey: key, + settingsSection: "overview", + }, { replace: true }); + } console.error("Failed to delete session", e); } - }, [pendingDelete, deleteChat, activeKey, sessions]); + }, [pendingDelete, deleteChat, activeKey, navigate, sessions]); const headerTitle = activeSession ? sidebarState.title_overrides[activeSession.key] || @@ -1205,6 +1368,7 @@ function Shell({ onModelNameChange={onModelNameChange} onSettingsChange={setSettingsSnapshot} onWorkspaceSettingsChange={refreshWorkspaces} + onSectionChange={onSettingsSectionChange} onLogout={onLogout} onRestart={onRestart} isRestarting={isRestarting} diff --git a/webui/src/components/settings/SettingsView.tsx b/webui/src/components/settings/SettingsView.tsx index 227737ae9..7a12982c2 100644 --- a/webui/src/components/settings/SettingsView.tsx +++ b/webui/src/components/settings/SettingsView.tsx @@ -271,6 +271,7 @@ interface SettingsViewProps { onModelNameChange: (modelName: string | null) => void; onSettingsChange?: (payload: SettingsPayload) => void; onWorkspaceSettingsChange?: () => void | Promise; + onSectionChange?: (section: SettingsSectionKey) => void; onLogout?: () => void; onRestart?: () => void; isRestarting?: boolean; @@ -319,6 +320,7 @@ export function SettingsView({ onModelNameChange, onSettingsChange, onWorkspaceSettingsChange, + onSectionChange, onLogout, onRestart, isRestarting = false, @@ -392,6 +394,14 @@ export function SettingsView({ useEffect(() => { setActiveSection(initialSection); }, [initialSection]); + + const selectSection = useCallback( + (section: SettingsSectionKey) => { + setActiveSection(section); + onSectionChange?.(section); + }, + [onSectionChange], + ); const [webSearchKeyVisible, setWebSearchKeyVisible] = useState(false); const [webSearchKeyEditing, setWebSearchKeyEditing] = useState(false); const [form, setForm] = useState({ @@ -1128,7 +1138,7 @@ export function SettingsView({ onRestart={restartViaSettingsSurface} isRestarting={isRestarting || hostEngineApplying} showBrandLogos={localPrefs.brandLogos} - onSelectSection={setActiveSection} + onSelectSection={selectSection} /> ); case "appearance": @@ -1199,7 +1209,7 @@ export function SettingsView({ saving={imageGenerationSaving} onChangeForm={setImageGenerationForm} onSave={saveImageGenerationSettings} - onOpenProviders={() => setActiveSection("models")} + onOpenProviders={() => selectSection("models")} showBrandLogos={localPrefs.brandLogos} onRestart={restartViaSettingsSurface} isRestarting={isRestarting || hostEngineApplying} @@ -1318,7 +1328,7 @@ export function SettingsView({ {showSidebar ? ( { toggleThemeSpy.mockReset(); attachSpy.mockReset(); runStatusHandlers.clear(); + window.history.replaceState(null, "", "/"); localStorage.removeItem("nanobot-webui.sidebar.completed-runs.v1"); vi.mocked(fetchBootstrap).mockReset().mockResolvedValue({ token: "tok", @@ -628,6 +629,44 @@ describe("App layout", () => { expect(attachSpy).toHaveBeenCalledWith("chat-a"); }); + it("restores the active chat from the URL hash after a page reload", async () => { + mockSessions = [ + { + key: "websocket:chat-a", + channel: "websocket", + chatId: "chat-a", + createdAt: "2026-04-16T10:00:00Z", + updatedAt: "2026-04-16T10:00:00Z", + preview: "Active after reload", + }, + { + key: "websocket:chat-b", + channel: "websocket", + chatId: "chat-b", + createdAt: "2026-04-16T11:00:00Z", + updatedAt: "2026-04-16T11:00:00Z", + preview: "Other chat", + }, + ]; + window.history.replaceState( + null, + "", + `/#/chat/${encodeURIComponent("websocket:chat-a")}`, + ); + + render(); + + await waitFor(() => expect(connectSpy).toHaveBeenCalled()); + await waitFor(() => expect(document.title).toBe("Active after reload ยท nanobot")); + const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" }); + expect( + within(sidebar).getByRole("button", { name: /^Active after reload$/ }), + ).toBeInTheDocument(); + expect(window.location.hash).toBe( + `#/chat/${encodeURIComponent("websocket:chat-a")}`, + ); + }); + it("opens the settings view from the sidebar footer", async () => { mockSessions = [ { @@ -991,6 +1030,51 @@ describe("App layout", () => { expect(screen.getByRole("button", { name: "Save" })).toBeEnabled(); }); + it("restores the settings section from the URL hash after a page reload", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + if (String(input) === "/api/settings") { + return jsonResponse(baseSettingsPayload()); + } + return { ok: false, status: 404, json: async () => ({}) } as Response; + }), + ); + window.history.replaceState(null, "", "/#/settings?section=models"); + + render(); + + await waitFor(() => expect(connectSpy).toHaveBeenCalled()); + expect(await screen.findByRole("heading", { name: "Models" })).toBeInTheDocument(); + expect(window.location.hash).toBe("#/settings?section=models"); + }); + + it("updates the URL hash when switching settings sections", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + if (String(input) === "/api/settings") { + return jsonResponse(baseSettingsPayload()); + } + return { ok: false, status: 404, json: async () => ({}) } as Response; + }), + ); + + render(); + + await waitFor(() => expect(connectSpy).toHaveBeenCalled()); + const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" }); + fireEvent.click(within(sidebar).getByRole("button", { name: "Settings" })); + expect(await screen.findByRole("heading", { name: "Overview" })).toBeInTheDocument(); + expect(window.location.hash).toBe("#/settings"); + + const settingsNav = screen.getByRole("navigation", { name: "Settings sections" }); + fireEvent.click(within(settingsNav).getByRole("button", { name: "Models" })); + + expect(await screen.findByRole("heading", { name: "Models" })).toBeInTheDocument(); + expect(window.location.hash).toBe("#/settings?section=models"); + }); + it("opens Apps from the main sidebar without replacing the sidebar", async () => { vi.stubGlobal( "fetch",