feat(webui): refine collapsible sidebar

This commit is contained in:
Xubin Ren 2026-05-22 00:31:55 +08:00
parent 8281cd1946
commit cb7daa77db
4 changed files with 225 additions and 94 deletions

View File

@ -43,6 +43,7 @@ const SIDEBAR_STORAGE_KEY = "nanobot-webui.sidebar";
const COMPLETED_RUNS_STORAGE_KEY = "nanobot-webui.sidebar.completed-runs.v1"; const COMPLETED_RUNS_STORAGE_KEY = "nanobot-webui.sidebar.completed-runs.v1";
const RESTART_STARTED_KEY = "nanobot-webui.restartStartedAt"; const RESTART_STARTED_KEY = "nanobot-webui.restartStartedAt";
const SIDEBAR_WIDTH = 272; const SIDEBAR_WIDTH = 272;
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"; type ShellView = "chat" | "settings";
@ -411,6 +412,10 @@ function Shell({
setDesktopSidebarOpen(false); setDesktopSidebarOpen(false);
}, []); }, []);
const openDesktopSidebar = useCallback(() => {
setDesktopSidebarOpen(true);
}, []);
const closeMobileSidebar = useCallback(() => { const closeMobileSidebar = useCallback(() => {
setMobileSidebarOpen(false); setMobileSidebarOpen(false);
}, []); }, []);
@ -732,17 +737,19 @@ function Shell({
"relative z-20 hidden shrink-0 overflow-hidden lg:block", "relative z-20 hidden shrink-0 overflow-hidden lg:block",
"transition-[width] duration-300 ease-out", "transition-[width] duration-300 ease-out",
)} )}
style={{ width: desktopSidebarOpen ? SIDEBAR_WIDTH : 0 }} style={{
width: desktopSidebarOpen ? SIDEBAR_WIDTH : SIDEBAR_RAIL_WIDTH,
}}
> >
<div <div
className={cn( className="absolute inset-y-0 left-0 h-full w-full overflow-hidden bg-sidebar shadow-inner-right"
"absolute inset-y-0 left-0 h-full overflow-hidden bg-sidebar shadow-inner-right",
"transition-transform duration-300 ease-out",
desktopSidebarOpen ? "translate-x-0" : "-translate-x-full",
)}
style={{ width: SIDEBAR_WIDTH }}
> >
<Sidebar {...sidebarProps} onCollapse={closeDesktopSidebar} /> <Sidebar
{...sidebarProps}
collapsed={!desktopSidebarOpen}
onCollapse={closeDesktopSidebar}
onExpand={openDesktopSidebar}
/>
</div> </div>
</aside> </aside>
) : null} ) : null}
@ -797,7 +804,7 @@ function Shell({
onTurnEnd={onTurnEnd} onTurnEnd={onTurnEnd}
theme={theme} theme={theme}
onToggleTheme={toggle} onToggleTheme={toggle}
hideSidebarToggleOnDesktop={desktopSidebarOpen} hideSidebarToggleOnDesktop
/> />
</div> </div>
{view === "settings" && ( {view === "settings" && (

View File

@ -1,4 +1,4 @@
import { useState } from "react"; import { useState, type ReactNode } from "react";
import { import {
Archive, Archive,
ListFilter, ListFilter,
@ -28,6 +28,7 @@ import type {
SidebarSortMode, SidebarSortMode,
SidebarViewState, SidebarViewState,
} from "@/lib/types"; } from "@/lib/types";
import { cn } from "@/lib/utils";
interface SidebarProps { interface SidebarProps {
sessions: ChatSummary[]; sessions: ChatSummary[];
@ -44,7 +45,9 @@ interface SidebarProps {
onToggleArchived: () => void; onToggleArchived: () => void;
onUpdateView: (view: Partial<SidebarViewState>) => void; onUpdateView: (view: Partial<SidebarViewState>) => void;
onCollapse: () => void; onCollapse: () => void;
onExpand?: () => void;
containActionMenus?: boolean; containActionMenus?: boolean;
collapsed?: boolean;
pinnedKeys?: string[]; pinnedKeys?: string[];
archivedKeys?: string[]; archivedKeys?: string[];
titleOverrides?: Record<string, string>; titleOverrides?: Record<string, string>;
@ -59,6 +62,8 @@ export function Sidebar(props: SidebarProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [menuPortalContainer, setMenuPortalContainer] = const [menuPortalContainer, setMenuPortalContainer] =
useState<HTMLElement | null>(null); useState<HTMLElement | null>(null);
const collapsed = Boolean(props.collapsed);
const toggleLabel = t("thread.header.toggleSidebar");
return ( return (
<nav <nav
@ -66,16 +71,34 @@ export function Sidebar(props: SidebarProps) {
aria-label={t("sidebar.navigation")} aria-label={t("sidebar.navigation")}
className="flex h-full w-full min-w-0 flex-col border-r border-sidebar-border/60 bg-sidebar text-sidebar-foreground" className="flex h-full w-full min-w-0 flex-col border-r border-sidebar-border/60 bg-sidebar text-sidebar-foreground"
> >
<div className="flex items-center justify-between px-3 pb-2.5 pt-3"> <div
<picture className="block min-w-0"> className={cn(
<source srcSet="/brand/nanobot_logo.webp" type="image/webp" /> "flex items-center px-3 pb-2.5 pt-3",
collapsed ? "w-14 justify-start" : "justify-between",
)}
>
<button
type="button"
aria-label={collapsed ? toggleLabel : undefined}
aria-hidden={collapsed ? undefined : true}
title={collapsed ? toggleLabel : undefined}
onClick={collapsed ? props.onExpand : undefined}
tabIndex={collapsed ? 0 : -1}
className={cn(
"flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-xl transition-colors",
collapsed
? "-ml-0.5 hover:bg-sidebar-accent/75"
: "pointer-events-none -ml-0.5",
)}
>
<img <img
src="/brand/nanobot_logo.png" src="/brand/nanobot_icon.png"
alt="nanobot" alt=""
className="h-6 w-auto select-none object-contain opacity-95" className="h-8 w-8 select-none object-contain"
draggable={false} draggable={false}
/> />
</picture> </button>
{!collapsed && (
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@ -85,43 +108,48 @@ export function Sidebar(props: SidebarProps) {
> >
<Menu className="h-3.5 w-3.5" /> <Menu className="h-3.5 w-3.5" />
</Button> </Button>
)}
</div> </div>
<div className="space-y-1.5 px-2 pb-2"> <div
<Button className={cn(
"space-y-1.5 px-2 pb-2",
collapsed && "flex w-14 flex-col items-center px-0",
)}
>
<SidebarActionButton
collapsed={collapsed}
label={t("sidebar.newChat")}
onClick={props.onNewChat} onClick={props.onNewChat}
className="h-8 w-full justify-start gap-2 rounded-full px-3 text-[12.5px] font-medium text-sidebar-foreground/92 hover:bg-sidebar-accent/75 hover:text-sidebar-foreground" icon={<SquarePen className="h-4 w-4" />}
variant="ghost" />
> <SidebarActionButton
<SquarePen className="h-3.5 w-3.5" /> collapsed={collapsed}
{t("sidebar.newChat")} label={t("sidebar.searchAria")}
</Button>
<Button
type="button"
onClick={props.onOpenSearch} onClick={props.onOpenSearch}
className="h-8 w-full justify-start gap-2 rounded-full px-3 text-[12.5px] font-medium text-sidebar-foreground/85 hover:bg-sidebar-accent/75 hover:text-sidebar-foreground" icon={<Search className="h-4 w-4" />}
variant="ghost" />
>
<Search className="h-3.5 w-3.5" aria-hidden />
{t("sidebar.searchAria")}
</Button>
<SidebarViewMenu <SidebarViewMenu
compact={collapsed}
view={props.viewState} view={props.viewState}
onUpdateView={props.onUpdateView} onUpdateView={props.onUpdateView}
/> />
{props.archivedCount ? ( {props.archivedCount ? (
<Button <SidebarActionButton
type="button" collapsed={collapsed}
label={props.showArchived ? t("chat.hideArchived") : t("chat.showArchived")}
onClick={props.onToggleArchived} onClick={props.onToggleArchived}
className="h-8 w-full justify-start gap-2 rounded-full px-3 text-[12.5px] font-medium text-sidebar-foreground/75 hover:bg-sidebar-accent/75 hover:text-sidebar-foreground" icon={<Archive className="h-4 w-4" />}
variant="ghost" />
>
<Archive className="h-3.5 w-3.5" aria-hidden />
{props.showArchived ? t("chat.hideArchived") : t("chat.showArchived")}
</Button>
) : null} ) : null}
</div> </div>
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden"> <div
className={cn(
"flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden transition-opacity duration-200",
collapsed && "pointer-events-none opacity-0",
)}
>
{!collapsed && (
<ChatList <ChatList
sessions={props.sessions} sessions={props.sessions}
activeKey={props.activeKey} activeKey={props.activeKey}
@ -146,28 +174,86 @@ export function Sidebar(props: SidebarProps) {
props.containActionMenus ? menuPortalContainer : undefined props.containActionMenus ? menuPortalContainer : undefined
} }
/> />
)}
</div> </div>
<Separator className="bg-sidebar-border/50" /> <Separator className="bg-sidebar-border/50" />
<div className="flex items-center gap-1 px-2.5 py-2.5 text-xs"> <div
<Button className={cn(
type="button" "flex items-center gap-1 px-2.5 py-2.5 text-xs",
variant="ghost" collapsed && "w-14 flex-col px-0",
onClick={props.onOpenSettings} )}
className="h-8 min-w-0 flex-1 justify-start gap-2 rounded-full px-2.5 text-[12.5px] font-medium text-sidebar-foreground/85 hover:bg-sidebar-accent/75 hover:text-sidebar-foreground"
> >
<Settings className="h-3.5 w-3.5" aria-hidden /> <SidebarActionButton
{t("sidebar.settings")} collapsed={collapsed}
</Button> label={t("sidebar.settings")}
onClick={props.onOpenSettings}
className={collapsed ? undefined : "flex-1"}
icon={<Settings className="h-4 w-4" />}
/>
<ConnectionBadge /> <ConnectionBadge />
</div> </div>
</nav> </nav>
); );
} }
function SidebarActionButton({
collapsed,
label,
icon,
onClick,
className,
}: {
collapsed: boolean;
label: string;
icon: ReactNode;
onClick: () => void;
className?: string;
}) {
return (
<Button
type="button"
variant="ghost"
aria-label={label}
title={collapsed ? label : undefined}
onClick={onClick}
className={cn(
"group h-8 min-w-0 gap-2 overflow-hidden rounded-full font-medium text-sidebar-foreground/85 hover:bg-sidebar-accent/75 hover:text-sidebar-foreground",
"transition-[width,padding,border-radius,color,background-color] duration-300 ease-out",
collapsed
? "w-9 justify-center gap-0 rounded-xl px-0"
: "w-full justify-start gap-2 px-3 text-[12.5px]",
className,
)}
>
<span
className={cn(
"flex shrink-0 items-center justify-center transition-transform duration-300 ease-out",
collapsed ? "translate-x-0" : "translate-x-0",
)}
aria-hidden
>
{icon}
</span>
<span
className={cn(
"min-w-0 overflow-hidden truncate whitespace-nowrap transition-[max-width,opacity,transform] duration-200 ease-out",
collapsed
? "max-w-0 -translate-x-1 opacity-0"
: "max-w-[12rem] translate-x-0 opacity-100",
)}
>
{label}
</span>
</Button>
);
}
function SidebarViewMenu({ function SidebarViewMenu({
compact = false,
view, view,
onUpdateView, onUpdateView,
}: { }: {
compact?: boolean;
view?: SidebarViewState; view?: SidebarViewState;
onUpdateView: (view: Partial<SidebarViewState>) => void; onUpdateView: (view: Partial<SidebarViewState>) => void;
}) { }) {
@ -182,11 +268,28 @@ function SidebarViewMenu({
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
type="button" type="button"
className="h-8 w-full justify-start gap-2 rounded-full px-3 text-[12.5px] font-medium text-sidebar-foreground/75 hover:bg-sidebar-accent/75 hover:text-sidebar-foreground" aria-label={t("sidebar.viewOptions")}
title={compact ? t("sidebar.viewOptions") : undefined}
className={cn(
"h-8 min-w-0 overflow-hidden font-medium text-sidebar-foreground/75 hover:bg-sidebar-accent/75 hover:text-sidebar-foreground",
"transition-[width,padding,border-radius,color,background-color] duration-300 ease-out",
compact
? "w-9 justify-center gap-0 rounded-xl px-0"
: "w-full justify-start gap-2 rounded-full px-3 text-[12.5px]",
)}
variant="ghost" variant="ghost"
> >
<ListFilter className="h-3.5 w-3.5" aria-hidden /> <ListFilter className="h-4 w-4 shrink-0" aria-hidden />
<span
className={cn(
"min-w-0 overflow-hidden truncate whitespace-nowrap transition-[max-width,opacity,transform] duration-200 ease-out",
compact
? "max-w-0 -translate-x-1 opacity-0"
: "max-w-[12rem] translate-x-0 opacity-100",
)}
>
{t("sidebar.viewOptions")} {t("sidebar.viewOptions")}
</span>
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-52"> <DropdownMenuContent align="start" className="w-52">

View File

@ -32,12 +32,17 @@ export function ThreadHeader({
onClick={onToggleSidebar} onClick={onToggleSidebar}
className={cn( className={cn(
"h-7 w-7 rounded-md text-muted-foreground hover:bg-accent/35 hover:text-foreground", "h-7 w-7 rounded-md text-muted-foreground hover:bg-accent/35 hover:text-foreground",
hideSidebarToggleOnDesktop && "lg:pointer-events-none lg:opacity-0", hideSidebarToggleOnDesktop && "lg:hidden",
)} )}
> >
<Menu className="h-3.5 w-3.5" /> <Menu className="h-3.5 w-3.5" />
</Button> </Button>
<ThemeButton theme={theme} onToggleTheme={onToggleTheme} label={t("thread.header.toggleTheme")} /> <ThemeButton
theme={theme}
onToggleTheme={onToggleTheme}
label={t("thread.header.toggleTheme")}
className="ml-auto"
/>
</div> </div>
); );
} }
@ -52,7 +57,7 @@ export function ThreadHeader({
onClick={onToggleSidebar} onClick={onToggleSidebar}
className={cn( className={cn(
"h-7 w-7 rounded-md text-muted-foreground hover:bg-accent/35 hover:text-foreground", "h-7 w-7 rounded-md text-muted-foreground hover:bg-accent/35 hover:text-foreground",
hideSidebarToggleOnDesktop && "lg:pointer-events-none lg:opacity-0", hideSidebarToggleOnDesktop && "lg:hidden",
)} )}
> >
<Menu className="h-3.5 w-3.5" /> <Menu className="h-3.5 w-3.5" />
@ -62,7 +67,12 @@ export function ThreadHeader({
</div> </div>
</div> </div>
<ThemeButton theme={theme} onToggleTheme={onToggleTheme} label={t("thread.header.toggleTheme")} /> <ThemeButton
theme={theme}
onToggleTheme={onToggleTheme}
label={t("thread.header.toggleTheme")}
className="ml-auto shrink-0"
/>
<div aria-hidden className="pointer-events-none absolute inset-x-0 top-full h-4" /> <div aria-hidden className="pointer-events-none absolute inset-x-0 top-full h-4" />
</div> </div>
@ -73,10 +83,12 @@ function ThemeButton({
theme, theme,
onToggleTheme, onToggleTheme,
label, label,
className,
}: { }: {
theme: "light" | "dark"; theme: "light" | "dark";
onToggleTheme: () => void; onToggleTheme: () => void;
label: string; label: string;
className?: string;
}) { }) {
return ( return (
<Button <Button
@ -84,7 +96,10 @@ function ThemeButton({
size="icon" size="icon"
aria-label={label} aria-label={label}
onClick={onToggleTheme} onClick={onToggleTheme}
className="h-8 w-8 rounded-full text-muted-foreground/85 hover:bg-accent/40 hover:text-foreground" className={cn(
"h-8 w-8 rounded-full text-muted-foreground/85 hover:bg-accent/40 hover:text-foreground",
className,
)}
> >
{theme === "dark" ? ( {theme === "dark" ? (
<Sun className="h-4 w-4" /> <Sun className="h-4 w-4" />

View File

@ -1025,10 +1025,16 @@ describe("App layout", () => {
fireEvent.click(screen.getByRole("button", { name: "Collapse sidebar" })); fireEvent.click(screen.getByRole("button", { name: "Collapse sidebar" }));
const desktopAside = container.querySelector("aside.lg\\:block") as HTMLElement; const desktopAside = container.querySelector("aside.lg\\:block") as HTMLElement;
await waitFor(() => expect(desktopAside.style.width).toBe("0px")); await waitFor(() => expect(desktopAside.style.width).toBe("56px"));
expect(screen.queryByRole("button", { name: "Start a new chat" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Start a new chat" })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Toggle sidebar" })); const rail = screen.getByRole("navigation", { name: "Sidebar navigation" });
expect(within(rail).getByRole("button", { name: "New chat" })).toBeInTheDocument();
expect(within(rail).getByRole("button", { name: "Search" })).toBeInTheDocument();
expect(within(rail).getByRole("button", { name: "View" })).toBeInTheDocument();
expect(within(rail).queryByText("Existing chat")).not.toBeInTheDocument();
fireEvent.click(within(rail).getByRole("button", { name: "Toggle sidebar" }));
await waitFor(() => expect(desktopAside.style.width).toBe("272px")); await waitFor(() => expect(desktopAside.style.width).toBe("272px"));
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" }); const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });