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:
+271
-5
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user