mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-06-13 14:23:58 +00:00
fix webui refresh location routing
This commit is contained in:
parent
b2cabb2bd8
commit
1886d22352
@ -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}
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user