2026-05-19 22:42:38 +08:00
|
|
|
import { useState } from "react";
|
2026-05-06 14:15:36 +00:00
|
|
|
import {
|
2026-05-19 22:42:38 +08:00
|
|
|
Archive,
|
|
|
|
|
ListFilter,
|
2026-05-08 13:28:34 +00:00
|
|
|
Menu,
|
2026-05-06 14:15:36 +00:00
|
|
|
Search,
|
2026-05-08 15:31:52 +00:00
|
|
|
Settings,
|
2026-05-06 14:15:36 +00:00
|
|
|
SquarePen,
|
|
|
|
|
} from "lucide-react";
|
2026-04-19 06:39:06 +00:00
|
|
|
import { useTranslation } from "react-i18next";
|
2026-04-18 18:51:53 +00:00
|
|
|
|
|
|
|
|
import { ChatList } from "@/components/ChatList";
|
|
|
|
|
import { ConnectionBadge } from "@/components/ConnectionBadge";
|
|
|
|
|
import { Button } from "@/components/ui/button";
|
2026-05-19 22:42:38 +08:00
|
|
|
import {
|
|
|
|
|
DropdownMenu,
|
|
|
|
|
DropdownMenuCheckboxItem,
|
|
|
|
|
DropdownMenuContent,
|
|
|
|
|
DropdownMenuLabel,
|
|
|
|
|
DropdownMenuRadioGroup,
|
|
|
|
|
DropdownMenuRadioItem,
|
|
|
|
|
DropdownMenuSeparator,
|
|
|
|
|
DropdownMenuTrigger,
|
|
|
|
|
} from "@/components/ui/dropdown-menu";
|
2026-04-18 18:51:53 +00:00
|
|
|
import { Separator } from "@/components/ui/separator";
|
2026-05-19 22:42:38 +08:00
|
|
|
import type {
|
|
|
|
|
ChatSummary,
|
|
|
|
|
SidebarSortMode,
|
|
|
|
|
SidebarViewState,
|
|
|
|
|
} from "@/lib/types";
|
2026-04-18 18:51:53 +00:00
|
|
|
|
|
|
|
|
interface SidebarProps {
|
|
|
|
|
sessions: ChatSummary[];
|
|
|
|
|
activeKey: string | null;
|
|
|
|
|
loading: boolean;
|
|
|
|
|
onNewChat: () => void;
|
|
|
|
|
onSelect: (key: string) => void;
|
|
|
|
|
onRequestDelete: (key: string, label: string) => void;
|
2026-05-19 22:42:38 +08:00
|
|
|
onTogglePin: (key: string) => void;
|
|
|
|
|
onRequestRename: (key: string, label: string) => void;
|
|
|
|
|
onToggleArchive: (key: string) => void;
|
2026-05-08 15:31:52 +00:00
|
|
|
onOpenSettings: () => void;
|
2026-05-19 22:42:38 +08:00
|
|
|
onOpenSearch: () => void;
|
|
|
|
|
onToggleArchived: () => void;
|
|
|
|
|
onUpdateView: (view: Partial<SidebarViewState>) => void;
|
2026-04-18 18:51:53 +00:00
|
|
|
onCollapse: () => void;
|
2026-05-19 22:42:38 +08:00
|
|
|
containActionMenus?: boolean;
|
|
|
|
|
pinnedKeys?: string[];
|
|
|
|
|
archivedKeys?: string[];
|
|
|
|
|
titleOverrides?: Record<string, string>;
|
|
|
|
|
runningChatIds?: string[];
|
|
|
|
|
completedChatIds?: string[];
|
|
|
|
|
viewState?: SidebarViewState;
|
|
|
|
|
showArchived?: boolean;
|
|
|
|
|
archivedCount?: number;
|
2026-04-18 18:51:53 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function Sidebar(props: SidebarProps) {
|
2026-04-19 06:39:06 +00:00
|
|
|
const { t } = useTranslation();
|
2026-05-19 22:42:38 +08:00
|
|
|
const [menuPortalContainer, setMenuPortalContainer] =
|
|
|
|
|
useState<HTMLElement | null>(null);
|
2026-05-06 14:15:36 +00:00
|
|
|
|
2026-04-18 18:51:53 +00:00
|
|
|
return (
|
2026-05-06 14:15:36 +00:00
|
|
|
<nav
|
2026-05-19 22:42:38 +08:00
|
|
|
ref={props.containActionMenus ? setMenuPortalContainer : undefined}
|
2026-05-06 14:15:36 +00:00
|
|
|
aria-label={t("sidebar.navigation")}
|
2026-05-16 01:14:11 +08:00
|
|
|
className="flex h-full w-full min-w-0 flex-col border-r border-sidebar-border/60 bg-sidebar text-sidebar-foreground"
|
2026-05-06 14:15:36 +00:00
|
|
|
>
|
|
|
|
|
<div className="flex items-center justify-between px-3 pb-2.5 pt-3">
|
2026-04-25 18:05:06 +00:00
|
|
|
<picture className="block min-w-0">
|
|
|
|
|
<source srcSet="/brand/nanobot_logo.webp" type="image/webp" />
|
|
|
|
|
<img
|
|
|
|
|
src="/brand/nanobot_logo.png"
|
|
|
|
|
alt="nanobot"
|
2026-05-06 14:15:36 +00:00
|
|
|
className="h-6 w-auto select-none object-contain opacity-95"
|
2026-04-25 18:05:06 +00:00
|
|
|
draggable={false}
|
|
|
|
|
/>
|
|
|
|
|
</picture>
|
2026-05-06 14:15:36 +00: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"
|
|
|
|
|
>
|
2026-05-08 13:28:34 +00:00
|
|
|
<Menu className="h-3.5 w-3.5" />
|
2026-05-06 14:15:36 +00:00
|
|
|
</Button>
|
2026-04-18 18:51:53 +00:00
|
|
|
</div>
|
2026-05-06 14:15:36 +00:00
|
|
|
|
|
|
|
|
<div className="space-y-1.5 px-2 pb-2">
|
2026-04-18 18:51:53 +00:00
|
|
|
<Button
|
|
|
|
|
onClick={props.onNewChat}
|
2026-05-06 14:15:36 +00:00
|
|
|
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"
|
2026-04-25 18:05:06 +00:00
|
|
|
variant="ghost"
|
2026-04-18 18:51:53 +00:00
|
|
|
>
|
2026-04-25 18:05:06 +00:00
|
|
|
<SquarePen className="h-3.5 w-3.5" />
|
2026-04-19 06:39:06 +00:00
|
|
|
{t("sidebar.newChat")}
|
2026-04-18 18:51:53 +00:00
|
|
|
</Button>
|
2026-05-19 22:42:38 +08:00
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
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"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
>
|
|
|
|
|
<Search className="h-3.5 w-3.5" aria-hidden />
|
|
|
|
|
{t("sidebar.searchAria")}
|
|
|
|
|
</Button>
|
|
|
|
|
<SidebarViewMenu
|
|
|
|
|
view={props.viewState}
|
|
|
|
|
onUpdateView={props.onUpdateView}
|
|
|
|
|
/>
|
|
|
|
|
{props.archivedCount ? (
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
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"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
>
|
|
|
|
|
<Archive className="h-3.5 w-3.5" aria-hidden />
|
|
|
|
|
{props.showArchived ? t("chat.hideArchived") : t("chat.showArchived")}
|
|
|
|
|
</Button>
|
|
|
|
|
) : null}
|
2026-04-18 18:51:53 +00:00
|
|
|
</div>
|
2026-05-16 01:14:11 +08:00
|
|
|
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
2026-04-18 18:51:53 +00:00
|
|
|
<ChatList
|
2026-05-19 22:42:38 +08:00
|
|
|
sessions={props.sessions}
|
2026-04-18 18:51:53 +00:00
|
|
|
activeKey={props.activeKey}
|
|
|
|
|
loading={props.loading}
|
2026-05-19 22:42:38 +08:00
|
|
|
emptyLabel={t("chat.noSessions")}
|
2026-04-18 18:51:53 +00:00
|
|
|
onSelect={props.onSelect}
|
|
|
|
|
onRequestDelete={props.onRequestDelete}
|
2026-05-19 22:42:38 +08:00
|
|
|
onTogglePin={props.onTogglePin}
|
|
|
|
|
onRequestRename={props.onRequestRename}
|
|
|
|
|
onToggleArchive={props.onToggleArchive}
|
|
|
|
|
pinnedKeys={props.pinnedKeys}
|
|
|
|
|
archivedKeys={props.archivedKeys}
|
|
|
|
|
titleOverrides={props.titleOverrides}
|
|
|
|
|
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}
|
|
|
|
|
actionMenuPortalContainer={
|
|
|
|
|
props.containActionMenus ? menuPortalContainer : undefined
|
|
|
|
|
}
|
2026-04-18 18:51:53 +00:00
|
|
|
/>
|
|
|
|
|
</div>
|
2026-04-25 18:05:06 +00:00
|
|
|
<Separator className="bg-sidebar-border/50" />
|
2026-05-17 23:52:50 +08:00
|
|
|
<div className="flex items-center gap-1 px-2.5 py-2.5 text-xs">
|
2026-05-08 15:31:52 +00:00
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
onClick={props.onOpenSettings}
|
2026-05-17 23:52:50 +08:00
|
|
|
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"
|
2026-05-08 15:31:52 +00:00
|
|
|
>
|
|
|
|
|
<Settings className="h-3.5 w-3.5" aria-hidden />
|
|
|
|
|
{t("sidebar.settings")}
|
|
|
|
|
</Button>
|
2026-04-18 18:51:53 +00:00
|
|
|
<ConnectionBadge />
|
|
|
|
|
</div>
|
2026-05-06 14:15:36 +00:00
|
|
|
</nav>
|
2026-04-18 18:51:53 +00:00
|
|
|
);
|
|
|
|
|
}
|
2026-05-19 22:42:38 +08:00
|
|
|
|
|
|
|
|
function SidebarViewMenu({
|
|
|
|
|
view,
|
|
|
|
|
onUpdateView,
|
|
|
|
|
}: {
|
|
|
|
|
view?: SidebarViewState;
|
|
|
|
|
onUpdateView: (view: Partial<SidebarViewState>) => void;
|
|
|
|
|
}) {
|
|
|
|
|
const { t } = useTranslation();
|
|
|
|
|
const sort = view?.sort ?? "updated_desc";
|
|
|
|
|
const setSort = (value: string) => {
|
|
|
|
|
if (isSidebarSortMode(value)) onUpdateView({ sort: value });
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<DropdownMenu modal={false}>
|
|
|
|
|
<DropdownMenuTrigger asChild>
|
|
|
|
|
<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"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
>
|
|
|
|
|
<ListFilter className="h-3.5 w-3.5" aria-hidden />
|
|
|
|
|
{t("sidebar.viewOptions")}
|
|
|
|
|
</Button>
|
|
|
|
|
</DropdownMenuTrigger>
|
|
|
|
|
<DropdownMenuContent align="start" className="w-52">
|
|
|
|
|
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
|
|
|
|
{t("sidebar.viewOptions")}
|
|
|
|
|
</DropdownMenuLabel>
|
|
|
|
|
<DropdownMenuCheckboxItem
|
|
|
|
|
checked={view?.density === "compact"}
|
|
|
|
|
onCheckedChange={(checked) =>
|
|
|
|
|
onUpdateView({ density: checked ? "compact" : "comfortable" })
|
|
|
|
|
}
|
|
|
|
|
onSelect={(event) => event.preventDefault()}
|
|
|
|
|
>
|
|
|
|
|
{t("sidebar.compactList")}
|
|
|
|
|
</DropdownMenuCheckboxItem>
|
|
|
|
|
<DropdownMenuCheckboxItem
|
|
|
|
|
checked={Boolean(view?.show_previews)}
|
|
|
|
|
onCheckedChange={(checked) =>
|
|
|
|
|
onUpdateView({ show_previews: Boolean(checked) })
|
|
|
|
|
}
|
|
|
|
|
onSelect={(event) => event.preventDefault()}
|
|
|
|
|
>
|
|
|
|
|
{t("sidebar.showPreviews")}
|
|
|
|
|
</DropdownMenuCheckboxItem>
|
|
|
|
|
<DropdownMenuCheckboxItem
|
|
|
|
|
checked={Boolean(view?.show_timestamps)}
|
|
|
|
|
onCheckedChange={(checked) =>
|
|
|
|
|
onUpdateView({ show_timestamps: Boolean(checked) })
|
|
|
|
|
}
|
|
|
|
|
onSelect={(event) => event.preventDefault()}
|
|
|
|
|
>
|
|
|
|
|
{t("sidebar.showTimestamps")}
|
|
|
|
|
</DropdownMenuCheckboxItem>
|
|
|
|
|
<DropdownMenuSeparator />
|
|
|
|
|
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
|
|
|
|
{t("sidebar.sortLabel")}
|
|
|
|
|
</DropdownMenuLabel>
|
|
|
|
|
<DropdownMenuRadioGroup value={sort} onValueChange={setSort}>
|
|
|
|
|
<DropdownMenuRadioItem value="updated_desc">
|
|
|
|
|
{t("sidebar.sortUpdated")}
|
|
|
|
|
</DropdownMenuRadioItem>
|
|
|
|
|
<DropdownMenuRadioItem value="created_desc">
|
|
|
|
|
{t("sidebar.sortCreated")}
|
|
|
|
|
</DropdownMenuRadioItem>
|
|
|
|
|
<DropdownMenuRadioItem value="title_asc">
|
|
|
|
|
{t("sidebar.sortTitle")}
|
|
|
|
|
</DropdownMenuRadioItem>
|
|
|
|
|
</DropdownMenuRadioGroup>
|
|
|
|
|
</DropdownMenuContent>
|
|
|
|
|
</DropdownMenu>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isSidebarSortMode(value: string): value is SidebarSortMode {
|
|
|
|
|
return value === "updated_desc" || value === "created_desc" || value === "title_asc";
|
|
|
|
|
}
|