mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-05-22 09:32:33 +00:00
feat(webui): refine collapsible sidebar
This commit is contained in:
parent
8281cd1946
commit
cb7daa77db
@ -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" && (
|
||||||
|
|||||||
@ -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,108 +71,189 @@ 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>
|
||||||
<Button
|
{!collapsed && (
|
||||||
variant="ghost"
|
<Button
|
||||||
size="icon"
|
variant="ghost"
|
||||||
aria-label={t("sidebar.collapse")}
|
size="icon"
|
||||||
onClick={props.onCollapse}
|
aria-label={t("sidebar.collapse")}
|
||||||
className="h-7 w-7 rounded-lg text-muted-foreground/85 hover:bg-sidebar-accent/75 hover:text-sidebar-foreground"
|
onClick={props.onCollapse}
|
||||||
>
|
className="h-7 w-7 rounded-lg text-muted-foreground/85 hover:bg-sidebar-accent/75 hover:text-sidebar-foreground"
|
||||||
<Menu className="h-3.5 w-3.5" />
|
>
|
||||||
</Button>
|
<Menu className="h-3.5 w-3.5" />
|
||||||
|
</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
|
||||||
<ChatList
|
className={cn(
|
||||||
sessions={props.sessions}
|
"flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden transition-opacity duration-200",
|
||||||
activeKey={props.activeKey}
|
collapsed && "pointer-events-none opacity-0",
|
||||||
loading={props.loading}
|
)}
|
||||||
emptyLabel={t("chat.noSessions")}
|
>
|
||||||
onSelect={props.onSelect}
|
{!collapsed && (
|
||||||
onRequestDelete={props.onRequestDelete}
|
<ChatList
|
||||||
onTogglePin={props.onTogglePin}
|
sessions={props.sessions}
|
||||||
onRequestRename={props.onRequestRename}
|
activeKey={props.activeKey}
|
||||||
onToggleArchive={props.onToggleArchive}
|
loading={props.loading}
|
||||||
pinnedKeys={props.pinnedKeys}
|
emptyLabel={t("chat.noSessions")}
|
||||||
archivedKeys={props.archivedKeys}
|
onSelect={props.onSelect}
|
||||||
titleOverrides={props.titleOverrides}
|
onRequestDelete={props.onRequestDelete}
|
||||||
runningChatIds={props.runningChatIds}
|
onTogglePin={props.onTogglePin}
|
||||||
completedChatIds={props.completedChatIds}
|
onRequestRename={props.onRequestRename}
|
||||||
density={props.viewState?.density}
|
onToggleArchive={props.onToggleArchive}
|
||||||
showPreviews={props.viewState?.show_previews}
|
pinnedKeys={props.pinnedKeys}
|
||||||
showTimestamps={props.viewState?.show_timestamps}
|
archivedKeys={props.archivedKeys}
|
||||||
sort={props.viewState?.sort}
|
titleOverrides={props.titleOverrides}
|
||||||
showArchived={props.showArchived}
|
runningChatIds={props.runningChatIds}
|
||||||
actionMenuPortalContainer={
|
completedChatIds={props.completedChatIds}
|
||||||
props.containActionMenus ? menuPortalContainer : undefined
|
density={props.viewState?.density}
|
||||||
}
|
showPreviews={props.viewState?.show_previews}
|
||||||
/>
|
showTimestamps={props.viewState?.show_timestamps}
|
||||||
|
sort={props.viewState?.sort}
|
||||||
|
showArchived={props.showArchived}
|
||||||
|
actionMenuPortalContainer={
|
||||||
|
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",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SidebarActionButton
|
||||||
|
collapsed={collapsed}
|
||||||
|
label={t("sidebar.settings")}
|
||||||
onClick={props.onOpenSettings}
|
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"
|
className={collapsed ? undefined : "flex-1"}
|
||||||
>
|
icon={<Settings className="h-4 w-4" />}
|
||||||
<Settings className="h-3.5 w-3.5" aria-hidden />
|
/>
|
||||||
{t("sidebar.settings")}
|
|
||||||
</Button>
|
|
||||||
<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 />
|
||||||
{t("sidebar.viewOptions")}
|
<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")}
|
||||||
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="start" className="w-52">
|
<DropdownMenuContent align="start" className="w-52">
|
||||||
|
|||||||
@ -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" />
|
||||||
|
|||||||
@ -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" });
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user