mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-06-15 23:34:00 +00:00
* feat(desktop): add native host scaffold * feat(webui): track turns and usage in gateway * feat(webui): polish desktop chat experience * feat(apps): add ArcGIS and Joplin logos * feat(desktop): polish shell and shared surfaces * fix(webui): avoid preview chips for glob references * test: align CI expectations for token fallback * feat(webui): preview prompt rail entries * feat(webui): add prompt navigator drawer * style(webui): refine prompt navigator placement * style(webui): align prompt navigator with header actions * style(webui): simplify prompt navigator header * refactor(webui): clean thread resource refresh * feat(desktop): add native reply notifications * fix(webui): preserve desktop restart and replay state * fix(desktop): harden gateway proxy startup * fix(web): fall back when readability is unavailable * fix(desktop): hide window instead of closing on macos * fix(webui): unify desktop header actions * fix(webui): simplify prompt history rows * fix(desktop): log notification delivery failures * chore(desktop): clean source package artifacts * fix(cron): support one-time relative reminders * fix(webui): reveal scroll button in place * Revert "fix(cron): support one-time relative reminders" This reverts commit 4c4661da120a3c7283e0768412bae48604e7390b. * refactor(webui): extract token usage heatmap * docs(desktop): clarify contributor guides --------- Co-authored-by: chengyongru <2755839590@qq.com>
299 lines
9.7 KiB
TypeScript
299 lines
9.7 KiB
TypeScript
import { useState, type ReactNode } from "react";
|
|
import {
|
|
Archive,
|
|
Brain,
|
|
Menu,
|
|
Search,
|
|
Settings,
|
|
SquarePen,
|
|
Blocks,
|
|
} 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";
|
|
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;
|
|
onOpenApps: () => void;
|
|
onOpenSkills: () => void;
|
|
onOpenSearch: () => void;
|
|
activeUtility?: "apps" | "skills" | null;
|
|
onToggleArchived: () => void;
|
|
onCollapse: () => void;
|
|
onExpand?: () => void;
|
|
containActionMenus?: boolean;
|
|
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;
|
|
}
|
|
|
|
type NavigatorWithUserAgentData = Navigator & {
|
|
userAgentData?: { platform?: string };
|
|
};
|
|
|
|
function isApplePlatform(): boolean {
|
|
if (typeof navigator === "undefined") return false;
|
|
const platform = navigator.platform || "";
|
|
const userAgentPlatform =
|
|
(navigator as NavigatorWithUserAgentData).userAgentData?.platform || "";
|
|
return /mac|iphone|ipad|ipod/i.test(`${platform} ${userAgentPlatform}`);
|
|
}
|
|
|
|
function newChatShortcutLabel(): string {
|
|
return isApplePlatform() ? "⌘⇧O" : "Ctrl+Shift+O";
|
|
}
|
|
|
|
export function Sidebar(props: SidebarProps) {
|
|
const { t } = useTranslation();
|
|
const [menuPortalContainer, setMenuPortalContainer] =
|
|
useState<HTMLElement | null>(null);
|
|
const collapsed = Boolean(props.collapsed);
|
|
const toggleLabel = t("thread.header.toggleSidebar");
|
|
const newChatShortcut = newChatShortcutLabel();
|
|
|
|
return (
|
|
<nav
|
|
ref={props.containActionMenus ? setMenuPortalContainer : undefined}
|
|
aria-label={t("sidebar.navigation")}
|
|
className={cn(
|
|
"flex h-full w-full min-w-0 flex-col text-sidebar-foreground",
|
|
props.hostChromeInset ? "bg-transparent" : "bg-sidebar",
|
|
!props.hostChromeInset && "border-r border-sidebar-border/60",
|
|
)}
|
|
>
|
|
<div
|
|
className={cn(
|
|
"flex items-center px-3 pb-2.5",
|
|
props.hostChromeInset ? "pt-[2.85rem]" : "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
|
|
src="/brand/nanobot_icon.png"
|
|
alt=""
|
|
className="h-8 w-8 select-none object-contain"
|
|
draggable={false}
|
|
/>
|
|
</button>
|
|
{!collapsed && !props.hostChromeInset && (
|
|
<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>
|
|
|
|
<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}
|
|
icon={<SquarePen className="h-4 w-4" />}
|
|
shortcut={newChatShortcut}
|
|
ariaKeyShortcuts="Meta+Shift+O Control+Shift+O"
|
|
/>
|
|
<SidebarActionButton
|
|
collapsed={collapsed}
|
|
label={t("sidebar.searchAria")}
|
|
onClick={props.onOpenSearch}
|
|
icon={<Search className="h-4 w-4" />}
|
|
/>
|
|
<SidebarActionButton
|
|
collapsed={collapsed}
|
|
label={t("sidebar.apps")}
|
|
onClick={props.onOpenApps}
|
|
active={props.activeUtility === "apps"}
|
|
icon={<Blocks className="h-4 w-4" />}
|
|
/>
|
|
<SidebarActionButton
|
|
collapsed={collapsed}
|
|
label={t("sidebar.skills.title")}
|
|
onClick={props.onOpenSkills}
|
|
active={props.activeUtility === "skills"}
|
|
icon={<Brain className="h-4 w-4" />}
|
|
/>
|
|
{props.archivedCount ? (
|
|
<SidebarActionButton
|
|
collapsed={collapsed}
|
|
label={props.showArchived ? t("chat.hideArchived") : t("chat.showArchived")}
|
|
onClick={props.onToggleArchived}
|
|
icon={<Archive className="h-4 w-4" />}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
<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}
|
|
pinnedKeys={props.pinnedKeys}
|
|
archivedKeys={props.archivedKeys}
|
|
titleOverrides={props.titleOverrides}
|
|
projectNameOverrides={props.projectNameOverrides}
|
|
collapsedGroups={props.collapsedGroups}
|
|
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}
|
|
actionMenuPortalContainer={
|
|
props.containActionMenus ? menuPortalContainer : undefined
|
|
}
|
|
/>
|
|
)}
|
|
</div>
|
|
<Separator className="bg-sidebar-border/50" />
|
|
<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}
|
|
className={collapsed ? undefined : "flex-1"}
|
|
icon={<Settings className="h-4 w-4" />}
|
|
/>
|
|
<ConnectionBadge />
|
|
</div>
|
|
</nav>
|
|
);
|
|
}
|
|
|
|
function SidebarActionButton({
|
|
collapsed,
|
|
label,
|
|
icon,
|
|
onClick,
|
|
active = false,
|
|
className,
|
|
shortcut,
|
|
ariaKeyShortcuts,
|
|
}: {
|
|
collapsed: boolean;
|
|
label: string;
|
|
icon: ReactNode;
|
|
onClick: () => void;
|
|
active?: boolean;
|
|
className?: string;
|
|
shortcut?: string;
|
|
ariaKeyShortcuts?: string;
|
|
}) {
|
|
const title = shortcut ? `${label} (${shortcut})` : collapsed ? label : undefined;
|
|
|
|
return (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
aria-label={label}
|
|
aria-current={active ? "page" : undefined}
|
|
aria-keyshortcuts={ariaKeyShortcuts}
|
|
title={title}
|
|
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]",
|
|
active && "bg-sidebar-accent text-sidebar-foreground shadow-[inset_0_0_0_1px_hsl(var(--sidebar-border)/0.55)]",
|
|
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>
|
|
);
|
|
}
|