fix webui refresh location routing

This commit is contained in:
chengyongru 2026-06-02 11:29:18 +08:00 committed by Xubin Ren
parent b2cabb2bd8
commit 1886d22352
3 changed files with 292 additions and 34 deletions

View File

@ -61,6 +61,95 @@ const SIDEBAR_RAIL_WIDTH = 56;
const TOKEN_REFRESH_MARGIN_MS = 30_000; const TOKEN_REFRESH_MARGIN_MS = 30_000;
const TOKEN_REFRESH_MIN_DELAY_MS = 5_000; const TOKEN_REFRESH_MIN_DELAY_MS = 5_000;
type ShellView = "chat" | "settings" | "apps"; 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 { function bootstrapTokenExpiresAt(expiresInSeconds: number): number {
return Date.now() + Math.max(0, expiresInSeconds) * 1000; return Date.now() + Math.max(0, expiresInSeconds) * 1000;
@ -433,9 +522,14 @@ function Shell({
const { sessions, loading, refresh, createChat, deleteChat } = useSessions(); const { sessions, loading, refresh, createChat, deleteChat } = useSessions();
const { state: sidebarState, update: updateSidebarState } = const { state: sidebarState, update: updateSidebarState } =
useSidebarState(sessions, !loading); useSidebarState(sessions, !loading);
const [activeKey, setActiveKey] = useState<string | null>(null); const initialRouteRef = useRef<ShellRoute | null>(null);
const [view, setView] = useState<ShellView>("chat"); if (!initialRouteRef.current) initialRouteRef.current = readShellRoute();
const [settingsInitialSection, setSettingsInitialSection] = useState<SettingsSectionKey>("overview"); const [activeKey, setActiveKey] = useState<string | null>(
initialRouteRef.current.activeKey,
);
const [view, setView] = useState<ShellView>(initialRouteRef.current.view);
const [settingsInitialSection, setSettingsInitialSection] =
useState<SettingsSectionKey>(initialRouteRef.current.settingsSection);
const [hostSidebarOpen, setHostSidebarOpen] = const [hostSidebarOpen, setHostSidebarOpen] =
useState<boolean>(readSidebarOpen); useState<boolean>(readSidebarOpen);
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
@ -467,6 +561,31 @@ function Shell({
const runningChatIdsRef = useRef<Set<string>>(new Set()); const runningChatIdsRef = useRef<Set<string>>(new Set());
const activeChatIdRef = useRef<string | null>(null); const activeChatIdRef = useRef<string | null>(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(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
fetchSettings(token) fetchSettings(token)
@ -558,6 +677,21 @@ function Shell({
}); });
}, [loading, sessions]); }, [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(() => { useEffect(() => {
return client.onSessionUpdate((_chatId, _scope, workspaceScope) => { return client.onSessionUpdate((_chatId, _scope, workspaceScope) => {
if (!workspaceScope) return; if (!workspaceScope) return;
@ -653,8 +787,11 @@ function Shell({
try { try {
const scope = workspaceScope ?? activeWorkspaceScope; const scope = workspaceScope ?? activeWorkspaceScope;
const chatId = await createChat(scope); const chatId = await createChat(scope);
setActiveKey(`websocket:${chatId}`); navigate({
setView("chat"); view: "chat",
activeKey: `websocket:${chatId}`,
settingsSection: "overview",
});
setMobileSidebarOpen(false); setMobileSidebarOpen(false);
if (scope) { if (scope) {
setWorkspaceOverrides((current) => ({ setWorkspaceOverrides((current) => ({
@ -670,15 +807,14 @@ function Shell({
} }
return null; return null;
} }
}, [activeWorkspaceScope, createChat, t]); }, [activeWorkspaceScope, createChat, navigate, t]);
const onNewChat = useCallback(() => { const onNewChat = useCallback(() => {
setActiveKey(null); navigate(defaultShellRoute());
setDraftWorkspaceScope(null); setDraftWorkspaceScope(null);
setWorkspaceError(null); setWorkspaceError(null);
setView("chat");
setMobileSidebarOpen(false); setMobileSidebarOpen(false);
}, []); }, [navigate]);
const onNewChatInProject = useCallback( const onNewChatInProject = useCallback(
(projectPath: string, projectName: string) => { (projectPath: string, projectName: string) => {
@ -688,7 +824,7 @@ function Shell({
onNewChat(); onNewChat();
return; return;
} }
setActiveKey(null); navigate(defaultShellRoute());
setDraftWorkspaceScope(normalizeWorkspaceScope({ setDraftWorkspaceScope(normalizeWorkspaceScope({
project_path: trimmed, project_path: trimmed,
project_name: projectName || projectNameFromPath(trimmed), project_name: projectName || projectNameFromPath(trimmed),
@ -696,10 +832,9 @@ function Shell({
restrict_to_workspace: base.access_mode === "restricted", restrict_to_workspace: base.access_mode === "restricted",
})); }));
setWorkspaceError(null); setWorkspaceError(null);
setView("chat");
setMobileSidebarOpen(false); setMobileSidebarOpen(false);
}, },
[activeWorkspaceScope, onNewChat, workspaces?.default_scope], [activeWorkspaceScope, navigate, onNewChat, workspaces?.default_scope],
); );
const onSelectChat = useCallback( const onSelectChat = useCallback(
@ -720,11 +855,10 @@ function Shell({
setDraftWorkspaceScope(null); setDraftWorkspaceScope(null);
} }
setWorkspaceError(null); setWorkspaceError(null);
setActiveKey(key); navigate({ view: "chat", activeKey: key, settingsSection: "overview" });
setView("chat");
setMobileSidebarOpen(false); setMobileSidebarOpen(false);
}, },
[sessions], [navigate, sessions],
); );
const onTogglePin = useCallback( const onTogglePin = useCallback(
@ -845,10 +979,14 @@ function Shell({
if (activeKey === key && !sidebarState.archived_keys.includes(key)) { if (activeKey === key && !sidebarState.archived_keys.includes(key)) {
const archived = new Set([...sidebarState.archived_keys, key]); const archived = new Set([...sidebarState.archived_keys, key]);
const next = sessions.find((session) => !archived.has(session.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(() => { const onToggleArchived = useCallback(() => {
@ -891,27 +1029,40 @@ function Shell({
const onOpenSettings = useCallback((section: SettingsSectionKey = "overview") => { const onOpenSettings = useCallback((section: SettingsSectionKey = "overview") => {
setSessionSearchOpen(false); setSessionSearchOpen(false);
setSettingsInitialSection(section); navigate({ view: "settings", activeKey, settingsSection: section });
setView("settings");
setMobileSidebarOpen(false); setMobileSidebarOpen(false);
}, []); }, [activeKey, navigate]);
const onOpenApps = useCallback(() => { const onOpenApps = useCallback(() => {
setSessionSearchOpen(false); setSessionSearchOpen(false);
setSettingsInitialSection("apps"); navigate({ view: "apps", activeKey, settingsSection: "apps" });
setView("apps");
setMobileSidebarOpen(false); setMobileSidebarOpen(false);
}, []); }, [activeKey, navigate]);
const onSettingsSectionChange = useCallback(
(section: SettingsSectionKey) => {
navigate({
view: section === "apps" ? "apps" : "settings",
activeKey,
settingsSection: section,
});
},
[activeKey, navigate],
);
const onBackToChat = useCallback(() => { const onBackToChat = useCallback(() => {
setView("chat");
setMobileSidebarOpen(false); setMobileSidebarOpen(false);
setActiveKey((current) => { const nextKey = (() => {
if (!current) return null; if (!activeKey) return null;
if (sessions.some((session) => session.key === current)) return current; if (sessions.some((session) => session.key === activeKey)) return activeKey;
return sessions[0]?.key ?? null; return sessions[0]?.key ?? null;
})();
navigate({
view: "chat",
activeKey: nextKey,
settingsSection: "overview",
}); });
}, [sessions]); }, [activeKey, navigate, sessions]);
const onRestart = useCallback(() => { const onRestart = useCallback(() => {
const chatId = activeSession?.chatId ?? client.defaultChatId; const chatId = activeSession?.chatId ?? client.defaultChatId;
@ -1003,14 +1154,26 @@ function Shell({
? (sessions[currentIndex + 1]?.key ?? sessions[currentIndex - 1]?.key ?? null) ? (sessions[currentIndex + 1]?.key ?? sessions[currentIndex - 1]?.key ?? null)
: activeKey; : activeKey;
setPendingDelete(null); setPendingDelete(null);
if (deletingActive) setActiveKey(fallbackKey); if (deletingActive) {
navigate({
view: "chat",
activeKey: fallbackKey,
settingsSection: "overview",
}, { replace: true });
}
try { try {
await deleteChat(key); await deleteChat(key);
} catch (e) { } catch (e) {
if (deletingActive) setActiveKey(key); if (deletingActive) {
navigate({
view: "chat",
activeKey: key,
settingsSection: "overview",
}, { replace: true });
}
console.error("Failed to delete session", e); console.error("Failed to delete session", e);
} }
}, [pendingDelete, deleteChat, activeKey, sessions]); }, [pendingDelete, deleteChat, activeKey, navigate, sessions]);
const headerTitle = activeSession const headerTitle = activeSession
? sidebarState.title_overrides[activeSession.key] || ? sidebarState.title_overrides[activeSession.key] ||
@ -1205,6 +1368,7 @@ function Shell({
onModelNameChange={onModelNameChange} onModelNameChange={onModelNameChange}
onSettingsChange={setSettingsSnapshot} onSettingsChange={setSettingsSnapshot}
onWorkspaceSettingsChange={refreshWorkspaces} onWorkspaceSettingsChange={refreshWorkspaces}
onSectionChange={onSettingsSectionChange}
onLogout={onLogout} onLogout={onLogout}
onRestart={onRestart} onRestart={onRestart}
isRestarting={isRestarting} isRestarting={isRestarting}

View File

@ -271,6 +271,7 @@ interface SettingsViewProps {
onModelNameChange: (modelName: string | null) => void; onModelNameChange: (modelName: string | null) => void;
onSettingsChange?: (payload: SettingsPayload) => void; onSettingsChange?: (payload: SettingsPayload) => void;
onWorkspaceSettingsChange?: () => void | Promise<void>; onWorkspaceSettingsChange?: () => void | Promise<void>;
onSectionChange?: (section: SettingsSectionKey) => void;
onLogout?: () => void; onLogout?: () => void;
onRestart?: () => void; onRestart?: () => void;
isRestarting?: boolean; isRestarting?: boolean;
@ -319,6 +320,7 @@ export function SettingsView({
onModelNameChange, onModelNameChange,
onSettingsChange, onSettingsChange,
onWorkspaceSettingsChange, onWorkspaceSettingsChange,
onSectionChange,
onLogout, onLogout,
onRestart, onRestart,
isRestarting = false, isRestarting = false,
@ -392,6 +394,14 @@ export function SettingsView({
useEffect(() => { useEffect(() => {
setActiveSection(initialSection); setActiveSection(initialSection);
}, [initialSection]); }, [initialSection]);
const selectSection = useCallback(
(section: SettingsSectionKey) => {
setActiveSection(section);
onSectionChange?.(section);
},
[onSectionChange],
);
const [webSearchKeyVisible, setWebSearchKeyVisible] = useState(false); const [webSearchKeyVisible, setWebSearchKeyVisible] = useState(false);
const [webSearchKeyEditing, setWebSearchKeyEditing] = useState(false); const [webSearchKeyEditing, setWebSearchKeyEditing] = useState(false);
const [form, setForm] = useState<AgentSettingsDraft>({ const [form, setForm] = useState<AgentSettingsDraft>({
@ -1128,7 +1138,7 @@ export function SettingsView({
onRestart={restartViaSettingsSurface} onRestart={restartViaSettingsSurface}
isRestarting={isRestarting || hostEngineApplying} isRestarting={isRestarting || hostEngineApplying}
showBrandLogos={localPrefs.brandLogos} showBrandLogos={localPrefs.brandLogos}
onSelectSection={setActiveSection} onSelectSection={selectSection}
/> />
); );
case "appearance": case "appearance":
@ -1199,7 +1209,7 @@ export function SettingsView({
saving={imageGenerationSaving} saving={imageGenerationSaving}
onChangeForm={setImageGenerationForm} onChangeForm={setImageGenerationForm}
onSave={saveImageGenerationSettings} onSave={saveImageGenerationSettings}
onOpenProviders={() => setActiveSection("models")} onOpenProviders={() => selectSection("models")}
showBrandLogos={localPrefs.brandLogos} showBrandLogos={localPrefs.brandLogos}
onRestart={restartViaSettingsSurface} onRestart={restartViaSettingsSurface}
isRestarting={isRestarting || hostEngineApplying} isRestarting={isRestarting || hostEngineApplying}
@ -1318,7 +1328,7 @@ export function SettingsView({
{showSidebar ? ( {showSidebar ? (
<SettingsSidebar <SettingsSidebar
activeSection={activeSection} activeSection={activeSection}
onSelectSection={setActiveSection} onSelectSection={selectSection}
onBackToChat={onBackToChat} onBackToChat={onBackToChat}
onLogout={onLogout} onLogout={onLogout}
hostChromeInset={hostChromeInset} hostChromeInset={hostChromeInset}

View File

@ -199,6 +199,7 @@ describe("App layout", () => {
toggleThemeSpy.mockReset(); toggleThemeSpy.mockReset();
attachSpy.mockReset(); attachSpy.mockReset();
runStatusHandlers.clear(); runStatusHandlers.clear();
window.history.replaceState(null, "", "/");
localStorage.removeItem("nanobot-webui.sidebar.completed-runs.v1"); localStorage.removeItem("nanobot-webui.sidebar.completed-runs.v1");
vi.mocked(fetchBootstrap).mockReset().mockResolvedValue({ vi.mocked(fetchBootstrap).mockReset().mockResolvedValue({
token: "tok", token: "tok",
@ -628,6 +629,44 @@ describe("App layout", () => {
expect(attachSpy).toHaveBeenCalledWith("chat-a"); 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(<App />);
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 () => { it("opens the settings view from the sidebar footer", async () => {
mockSessions = [ mockSessions = [
{ {
@ -991,6 +1030,51 @@ describe("App layout", () => {
expect(screen.getByRole("button", { name: "Save" })).toBeEnabled(); 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(<App />);
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(<App />);
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 () => { it("opens Apps from the main sidebar without replacing the sidebar", async () => {
vi.stubGlobal( vi.stubGlobal(
"fetch", "fetch",