feat(webui): upgrade settings and sidebar controls (#3906)

* feat(settings): expand settings api payload

* feat(webui): build app-style settings center

* feat(webui): add centered chat search dialog

* fix(webui): shorten chat search label

* fix(webui): center dialog entrance animation

* fix(webui): simplify chat search results

* fix(webui): tighten mobile settings navigation

* feat(webui): persist sidebar state

* feat(webui): add sidebar organization controls

* refactor(webui): organize backend helpers

* refactor(webui): remove utils compatibility shims

* refactor(session): move shared webui helpers out of webui package

* feat(webui): add image generation settings

* style(webui): refine settings overview layout

* fix(webui): localize settings zh-CN copy

* style(webui): add settings status indicators

* feat(webui): show sidebar run indicators

* fix(webui): persist sidebar run indicators

* fix(webui): highlight settings pending status

* fix(webui): align settings test with provider update

* fix(utils): preserve legacy webui helper imports
This commit is contained in:
Xubin Ren
2026-05-19 22:42:38 +08:00
committed by GitHub
parent 30fc05c746
commit 57d5276da1
45 changed files with 6306 additions and 1012 deletions
+271 -5
View File
@@ -1,13 +1,16 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { DeleteConfirm } from "@/components/DeleteConfirm";
import { RenameChatDialog } from "@/components/RenameChatDialog";
import { Sidebar } from "@/components/Sidebar";
import { SessionSearchDialog } from "@/components/SessionSearchDialog";
import { SettingsView } from "@/components/settings/SettingsView";
import { ThreadShell } from "@/components/thread/ThreadShell";
import { Sheet, SheetContent } from "@/components/ui/sheet";
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
import { useSessions } from "@/hooks/useSessions";
import { useDeferredTitleRefresh } from "@/hooks/useDeferredTitleRefresh";
import { useSidebarState } from "@/hooks/useSidebarState";
import { ThemeProvider, useTheme } from "@/hooks/useTheme";
import { cn } from "@/lib/utils";
import {
@@ -37,6 +40,7 @@ type BootState =
};
const SIDEBAR_STORAGE_KEY = "nanobot-webui.sidebar";
const COMPLETED_RUNS_STORAGE_KEY = "nanobot-webui.sidebar.completed-runs.v1";
const RESTART_STARTED_KEY = "nanobot-webui.restartStartedAt";
const SIDEBAR_WIDTH = 272;
const TOKEN_REFRESH_MARGIN_MS = 30_000;
@@ -121,6 +125,29 @@ function readSidebarOpen(): boolean {
}
}
function readCompletedRunChatIds(): Set<string> {
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<string>): void {
try {
window.localStorage.setItem(
COMPLETED_RUNS_STORAGE_KEY,
JSON.stringify(Array.from(chatIds)),
);
} catch {
// ignore storage errors (private mode, etc.)
}
}
export default function App() {
const { t } = useTranslation();
const [state, setState] = useState<BootState>({ status: "loading" });
@@ -293,18 +320,28 @@ function Shell({
const { client } = useClient();
const { theme, toggle } = useTheme();
const { sessions, loading, refresh, createChat, deleteChat } = useSessions();
const { state: sidebarState, update: updateSidebarState } =
useSidebarState(sessions, !loading);
const [activeKey, setActiveKey] = useState<string | null>(null);
const [view, setView] = useState<ShellView>("chat");
const [desktopSidebarOpen, setDesktopSidebarOpen] =
useState<boolean>(readSidebarOpen);
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const [sessionSearchOpen, setSessionSearchOpen] = useState(false);
const [pendingDelete, setPendingDelete] = useState<{
key: string;
label: string;
} | null>(null);
const [pendingRename, setPendingRename] = useState<{
key: string;
label: string;
} | null>(null);
const restartSawDisconnectRef = useRef(false);
const [restartToast, setRestartToast] = useState<string | null>(null);
const [isRestarting, setIsRestarting] = useState(false);
const [runningChatIds, setRunningChatIds] = useState<Set<string>>(() => new Set());
const [completedChatIds, setCompletedChatIds] = useState<Set<string>>(readCompletedRunChatIds);
const runningChatIdsRef = useRef<Set<string>>(new Set());
useEffect(() => {
try {
@@ -317,12 +354,58 @@ function Shell({
}
}, [desktopSidebarOpen]);
useEffect(() => {
writeCompletedRunChatIds(completedChatIds);
}, [completedChatIds]);
const activeSession = useMemo<ChatSummary | null>(() => {
if (!activeKey) return null;
return sessions.find((s) => s.key === activeKey) ?? null;
}, [sessions, activeKey]);
const runningChatIdList = useMemo(() => Array.from(runningChatIds), [runningChatIds]);
const completedChatIdList = useMemo(() => Array.from(completedChatIds), [completedChatIds]);
useEffect(() => {
if (loading) return;
const knownChatIds = new Set(sessions.map((session) => session.chatId));
setCompletedChatIds((current) => {
const next = new Set(
Array.from(current).filter((chatId) => knownChatIds.has(chatId)),
);
return next.size === current.size ? current : next;
});
}, [loading, sessions]);
useEffect(() => {
if (loading) return;
const activeRunIds = sessions
.filter((session) => typeof session.runStartedAt === "number")
.map((session) => session.chatId);
if (activeRunIds.length === 0) return;
for (const chatId of activeRunIds) {
client.attach(chatId);
}
setRunningChatIds((current) => {
let changed = false;
const next = new Set(current);
for (const chatId of activeRunIds) {
if (!next.has(chatId)) changed = true;
next.add(chatId);
}
if (!changed) return current;
runningChatIdsRef.current = next;
return next;
});
setCompletedChatIds((current) => {
let changed = false;
const next = new Set(current);
for (const chatId of activeRunIds) {
if (next.delete(chatId)) changed = true;
}
return changed ? next : current;
});
}, [client, loading, sessions]);
const closeDesktopSidebar = useCallback(() => {
setDesktopSidebarOpen(false);
@@ -364,14 +447,129 @@ function Shell({
const onSelectChat = useCallback(
(key: string) => {
const selectedChatId = sessions.find((session) => session.key === key)?.chatId;
if (selectedChatId) {
setCompletedChatIds((current) => {
if (!current.has(selectedChatId)) return current;
const next = new Set(current);
next.delete(selectedChatId);
return next;
});
}
setActiveKey(key);
setView("chat");
setMobileSidebarOpen(false);
},
[],
[sessions],
);
const onTogglePin = useCallback(
(key: string) => {
void updateSidebarState((current) => {
const pinned = new Set(current.pinned_keys);
if (pinned.has(key)) {
pinned.delete(key);
} else {
pinned.add(key);
}
return {
...current,
pinned_keys: Array.from(pinned),
};
});
},
[updateSidebarState],
);
const onRequestRename = useCallback((key: string, label: string) => {
setPendingRename({ key, label });
}, []);
const onConfirmRename = useCallback(
(title: string) => {
if (!pendingRename) return;
const key = pendingRename.key;
setPendingRename(null);
void updateSidebarState((current) => {
const titleOverrides = { ...current.title_overrides };
const cleaned = title.trim();
if (cleaned) {
titleOverrides[key] = cleaned;
} else {
delete titleOverrides[key];
}
return {
...current,
title_overrides: titleOverrides,
};
});
},
[pendingRename, updateSidebarState],
);
const onToggleArchive = useCallback(
(key: string) => {
void updateSidebarState((current) => {
const archived = new Set(current.archived_keys);
const pinned = current.pinned_keys.filter((item) => item !== key);
if (archived.has(key)) {
archived.delete(key);
} else {
archived.add(key);
}
return {
...current,
pinned_keys: pinned,
archived_keys: Array.from(archived),
};
});
if (activeKey === key && !sidebarState.archived_keys.includes(key)) {
const archived = new Set([...sidebarState.archived_keys, key]);
const next = sessions.find((session) => !archived.has(session.key));
setActiveKey(next?.key ?? null);
}
},
[activeKey, sessions, sidebarState.archived_keys, updateSidebarState],
);
const onToggleArchived = useCallback(() => {
void updateSidebarState((current) => ({
...current,
view: {
...current.view,
show_archived: !current.view.show_archived,
},
}));
}, [updateSidebarState]);
const onUpdateSidebarView = useCallback(
(viewUpdate: Partial<typeof sidebarState.view>) => {
void updateSidebarState((current) => ({
...current,
view: {
...current.view,
...viewUpdate,
},
}));
},
[updateSidebarState],
);
const onOpenSessionSearch = useCallback(() => {
setMobileSidebarOpen(false);
setSessionSearchOpen(true);
}, []);
const onSelectSearchResult = useCallback(
(key: string) => {
setSessionSearchOpen(false);
onSelectChat(key);
},
[onSelectChat],
);
const onOpenSettings = useCallback(() => {
setSessionSearchOpen(false);
setView("settings");
setMobileSidebarOpen(false);
}, []);
@@ -405,6 +603,35 @@ function Shell({
});
}, [client, onModelNameChange]);
useEffect(() => {
return client.onRunStatus((chatId, startedAt) => {
if (startedAt != null) {
const nextRunning = new Set(runningChatIdsRef.current);
nextRunning.add(chatId);
runningChatIdsRef.current = nextRunning;
setRunningChatIds(nextRunning);
setCompletedChatIds((current) => {
if (!current.has(chatId)) return current;
const next = new Set(current);
next.delete(chatId);
return next;
});
return;
}
if (!runningChatIdsRef.current.has(chatId)) return;
const nextRunning = new Set(runningChatIdsRef.current);
nextRunning.delete(chatId);
runningChatIdsRef.current = nextRunning;
setRunningChatIds(nextRunning);
setCompletedChatIds((current) => {
const next = new Set(current);
next.add(chatId);
return next;
});
});
}, [client]);
useEffect(() => {
return client.onStatus((status) => {
let startedAt = 0;
@@ -452,7 +679,8 @@ function Shell({
}, [pendingDelete, deleteChat, activeKey, sessions]);
const headerTitle = activeSession
? activeSession.title ||
? sidebarState.title_overrides[activeSession.key] ||
activeSession.title ||
deriveTitle(activeSession.preview, t("chat.newChat"))
: t("app.brand");
@@ -476,7 +704,21 @@ function Shell({
onSelect: onSelectChat,
onRequestDelete: (key: string, label: string) =>
setPendingDelete({ key, label }),
onTogglePin,
onRequestRename,
onToggleArchive,
onOpenSettings,
onOpenSearch: onOpenSessionSearch,
onToggleArchived,
onUpdateView: onUpdateSidebarView,
pinnedKeys: sidebarState.pinned_keys,
archivedKeys: sidebarState.archived_keys,
titleOverrides: sidebarState.title_overrides,
runningChatIds: runningChatIdList,
completedChatIds: completedChatIdList,
viewState: sidebarState.view,
showArchived: sidebarState.view.show_archived,
archivedCount: sidebarState.archived_keys.length,
};
const showMainSidebar = view !== "settings";
@@ -513,14 +755,32 @@ function Shell({
<SheetContent
side="left"
showCloseButton={false}
aria-describedby={undefined}
className="p-0 lg:hidden"
style={{ width: SIDEBAR_WIDTH, maxWidth: SIDEBAR_WIDTH }}
>
<Sidebar {...sidebarProps} onCollapse={closeMobileSidebar} />
<SheetTitle className="sr-only">{t("sidebar.navigation")}</SheetTitle>
<Sidebar
{...sidebarProps}
onCollapse={closeMobileSidebar}
containActionMenus
/>
</SheetContent>
</Sheet>
) : null}
{showMainSidebar ? (
<SessionSearchDialog
open={sessionSearchOpen}
onOpenChange={setSessionSearchOpen}
sessions={sessions}
activeKey={activeKey}
loading={loading}
titleOverrides={sidebarState.title_overrides}
onSelect={onSelectSearchResult}
/>
) : null}
<main className="relative flex h-full min-w-0 flex-1 flex-col">
<div
className={cn(
@@ -561,6 +821,12 @@ function Shell({
onCancel={() => setPendingDelete(null)}
onConfirm={onConfirmDelete}
/>
<RenameChatDialog
open={!!pendingRename}
title={pendingRename?.label ?? ""}
onCancel={() => setPendingRename(null)}
onConfirm={onConfirmRename}
/>
{restartToast ? (
<div
role="status"