Files
nanobot/webui/src/components/Sidebar.tsx
T

263 lines
8.5 KiB
TypeScript
Raw Normal View History

2026-05-22 00:31:55 +08:00
import { useState, type ReactNode } from "react";
2026-05-06 14:15:36 +00:00
import {
Archive,
Menu,
2026-05-06 14:15:36 +00:00
Search,
Settings,
2026-05-06 14:15:36 +00:00
SquarePen,
2026-05-25 20:07:02 +08:00
Blocks,
2026-05-06 14:15:36 +00:00
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { ChatList } from "@/components/ChatList";
import { ConnectionBadge } from "@/components/ConnectionBadge";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import type {
ChatSummary,
SidebarViewState,
} from "@/lib/types";
2026-05-22 00:31:55 +08:00
import { cn } from "@/lib/utils";
interface SidebarProps {
sessions: ChatSummary[];
activeKey: string | null;
loading: boolean;
onNewChat: () => void;
onSelect: (key: string) => void;
onRequestDelete: (key: string, label: string) => void;
onTogglePin: (key: string) => void;
onRequestRename: (key: string, label: string) => void;
onToggleArchive: (key: string) => void;
onToggleGroup: (groupId: string) => void;
onRequestRenameProject: (projectKey: string, label: string) => void;
onNewChatInProject: (projectPath: string, projectName: string) => void;
onOpenSettings: () => void;
2026-05-25 20:07:02 +08:00
onOpenApps: () => void;
onOpenSearch: () => void;
2026-05-25 20:07:02 +08:00
activeUtility?: "apps" | null;
onToggleArchived: () => void;
onCollapse: () => void;
2026-05-22 00:31:55 +08:00
onExpand?: () => void;
containActionMenus?: boolean;
2026-05-22 00:31:55 +08:00
collapsed?: boolean;
pinnedKeys?: string[];
archivedKeys?: string[];
titleOverrides?: Record<string, string>;
projectNameOverrides?: Record<string, string>;
collapsedGroups?: Record<string, boolean>;
runningChatIds?: string[];
completedChatIds?: string[];
viewState?: SidebarViewState;
showArchived?: boolean;
archivedCount?: number;
defaultWorkspacePath?: string | null;
hostChromeInset?: boolean;
}
export function Sidebar(props: SidebarProps) {
const { t } = useTranslation();
const [menuPortalContainer, setMenuPortalContainer] =
useState<HTMLElement | null>(null);
2026-05-22 00:31:55 +08:00
const collapsed = Boolean(props.collapsed);
const toggleLabel = t("thread.header.toggleSidebar");
2026-05-06 14:15:36 +00:00
return (
2026-05-06 14:15:36 +00:00
<nav
ref={props.containActionMenus ? setMenuPortalContainer : undefined}
2026-05-06 14:15:36 +00:00
aria-label={t("sidebar.navigation")}
className={cn(
"flex h-full w-full min-w-0 flex-col bg-sidebar text-sidebar-foreground",
!props.hostChromeInset && "border-r border-sidebar-border/60",
)}
2026-05-06 14:15:36 +00:00
>
2026-05-22 00:31:55 +08:00
<div
className={cn(
"flex items-center px-3 pb-2.5",
props.hostChromeInset ? "pt-[2.85rem]" : "pt-3",
2026-05-22 00:31:55 +08:00
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
2026-05-22 00:31:55 +08:00
src="/brand/nanobot_icon.png"
alt=""
className="h-8 w-8 select-none object-contain"
draggable={false}
/>
2026-05-22 00:31:55 +08:00
</button>
{!collapsed && !props.hostChromeInset && (
2026-05-22 00:31:55 +08:00
<Button
variant="ghost"
size="icon"
aria-label={t("sidebar.collapse")}
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>
)}
</div>
2026-05-06 14:15:36 +00:00
2026-05-22 00:31:55 +08:00
<div
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}
2026-05-22 00:31:55 +08:00
icon={<SquarePen className="h-4 w-4" />}
/>
<SidebarActionButton
collapsed={collapsed}
label={t("sidebar.searchAria")}
onClick={props.onOpenSearch}
2026-05-22 00:31:55 +08:00
icon={<Search className="h-4 w-4" />}
/>
2026-05-25 20:07:02 +08:00
<SidebarActionButton
collapsed={collapsed}
label={t("sidebar.apps")}
onClick={props.onOpenApps}
active={props.activeUtility === "apps"}
icon={<Blocks className="h-4 w-4" />}
/>
{props.archivedCount ? (
2026-05-22 00:31:55 +08:00
<SidebarActionButton
collapsed={collapsed}
label={props.showArchived ? t("chat.hideArchived") : t("chat.showArchived")}
onClick={props.onToggleArchived}
2026-05-22 00:31:55 +08:00
icon={<Archive className="h-4 w-4" />}
/>
) : null}
</div>
2026-05-22 00:31:55 +08:00
<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
sessions={props.sessions}
activeKey={props.activeKey}
loading={props.loading}
emptyLabel={t("chat.noSessions")}
onSelect={props.onSelect}
onRequestDelete={props.onRequestDelete}
onTogglePin={props.onTogglePin}
onRequestRename={props.onRequestRename}
onToggleArchive={props.onToggleArchive}
onToggleGroup={props.onToggleGroup}
onRequestRenameProject={props.onRequestRenameProject}
onNewChatInProject={props.onNewChatInProject}
2026-05-22 00:31:55 +08:00
pinnedKeys={props.pinnedKeys}
archivedKeys={props.archivedKeys}
titleOverrides={props.titleOverrides}
projectNameOverrides={props.projectNameOverrides}
collapsedGroups={props.collapsedGroups}
2026-05-22 00:31:55 +08:00
runningChatIds={props.runningChatIds}
completedChatIds={props.completedChatIds}
density={props.viewState?.density}
showPreviews={props.viewState?.show_previews}
showTimestamps={props.viewState?.show_timestamps}
sort={props.viewState?.sort}
showArchived={props.showArchived}
defaultWorkspacePath={props.defaultWorkspacePath}
2026-05-22 00:31:55 +08:00
actionMenuPortalContainer={
props.containActionMenus ? menuPortalContainer : undefined
}
/>
)}
</div>
<Separator className="bg-sidebar-border/50" />
2026-05-22 00:31:55 +08:00
<div
className={cn(
"flex items-center gap-1 px-2.5 py-2.5 text-xs",
collapsed && "w-14 flex-col px-0",
)}
>
<SidebarActionButton
collapsed={collapsed}
label={t("sidebar.settings")}
onClick={props.onOpenSettings}
2026-05-22 00:31:55 +08:00
className={collapsed ? undefined : "flex-1"}
icon={<Settings className="h-4 w-4" />}
/>
<ConnectionBadge />
</div>
2026-05-06 14:15:36 +00:00
</nav>
);
}
2026-05-22 00:31:55 +08:00
function SidebarActionButton({
collapsed,
label,
icon,
onClick,
2026-05-25 20:07:02 +08:00
active = false,
2026-05-22 00:31:55 +08:00
className,
}: {
collapsed: boolean;
label: string;
icon: ReactNode;
onClick: () => void;
2026-05-25 20:07:02 +08:00
active?: boolean;
2026-05-22 00:31:55 +08:00
className?: string;
}) {
return (
<Button
type="button"
variant="ghost"
aria-label={label}
2026-05-25 20:07:02 +08:00
aria-current={active ? "page" : undefined}
2026-05-22 00:31:55 +08:00
title={collapsed ? label : undefined}
2026-05-25 20:07:02 +08:00
onClick={() => onClick()}
2026-05-22 00:31:55 +08:00
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]",
2026-05-25 20:07:02 +08:00
active && "bg-sidebar-accent text-sidebar-foreground shadow-[inset_0_0_0_1px_hsl(var(--sidebar-border)/0.55)]",
2026-05-22 00:31:55 +08:00
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>
);
}