diff --git a/frontend/app/[locale]/layout.client.tsx b/frontend/app/[locale]/layout.client.tsx index d79611afbe..11ff17afb2 100644 --- a/frontend/app/[locale]/layout.client.tsx +++ b/frontend/app/[locale]/layout.client.tsx @@ -1,12 +1,12 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + "use client"; -import { type CSSProperties, type ReactNode } from "react"; +import { useState, type CSSProperties, type ReactNode } from "react"; import { usePathname } from "next/navigation"; import { Layout, Spin } from "antd"; -import { TopNavbar } from "@/components/navigation/TopNavbar"; import { SideNavigation } from "@/components/navigation/SideNavigation"; -import { FooterLayout } from "@/components/navigation/FooterLayout"; -import { FOOTER_CONFIG, SIDER_CONFIG } from "@/const/layoutConstants"; +import { SIDER_CONFIG } from "@/const/layoutConstants"; import { AuthDialogs } from "@/components/auth/AuthDialogs"; import { useAuthorizationContext } from "@/components/providers/AuthorizationProvider"; import { useDeployment } from "@/components/providers/deploymentProvider"; @@ -14,22 +14,19 @@ import { getEffectiveRoutePath } from "@/lib/auth"; import { QuotaWarningMonitor } from "@/components/quota/QuotaWarningMonitor"; import { MemoryEmbeddingMonitor } from "@/components/memory/MemoryEmbeddingMonitor"; -const { Sider, Content, Footer } = Layout; +const { Sider, Content } = Layout; export function ClientLayout({ children }: { children: ReactNode }) { const pathname = usePathname(); const { isAuthorized } = useAuthorizationContext(); const { isSpeedMode } = useDeployment(); + const [sidebarPinned, setSidebarPinned] = useState(false); + const sidebarWidth = sidebarPinned ? 240 : SIDER_CONFIG.WIDTH; const effectivePath = getEffectiveRoutePath(pathname); - const isNewChatPage = effectivePath?.startsWith("/newchat") ?? false; const isHomePage = effectivePath === "/"; const isOAuthCompletePage = effectivePath === "/oauth/complete"; const isSharePage = effectivePath.startsWith("/share/"); - const isAgentConfigPage = - effectivePath === "/agents" || effectivePath.startsWith("/agents/"); - - const footerReservedHeight = parseInt(FOOTER_CONFIG.RESERVED_HEIGHT); const layoutStyle: CSSProperties = { height: "100vh", @@ -48,21 +45,12 @@ export function ClientLayout({ children }: { children: ReactNode }) { top: 0, bottom: 0, left: 0, - width: SIDER_CONFIG.WIDTH, + width: sidebarWidth, backgroundColor: "#f0f0f0", - overflow: "hidden", + overflow: "visible", zIndex: 30, }; - const footerStyle: CSSProperties = { - textAlign: "center", - height: footerReservedHeight, - lineHeight: footerReservedHeight, - padding: 0, - flexShrink: 0, - backgroundColor: "#fff", - }; - const contentStyle: CSSProperties = { flex: 1, minHeight: 0, @@ -96,22 +84,21 @@ export function ClientLayout({ children }: { children: ReactNode }) { - + - {!isAgentConfigPage && !isNewChatPage && } - {isHomePage || isOAuthCompletePage || isSharePage || isAuthorized ? ( children @@ -121,12 +108,6 @@ export function ClientLayout({ children }: { children: ReactNode }) { )} - - {!isAgentConfigPage && !isNewChatPage && ( - - )} {!isSpeedMode && } diff --git a/frontend/components/auth/avatarDropdown.tsx b/frontend/components/auth/avatarDropdown.tsx index 9e03a073e2..7d854a0fbb 100644 --- a/frontend/components/auth/avatarDropdown.tsx +++ b/frontend/components/auth/avatarDropdown.tsx @@ -1,33 +1,76 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + "use client"; -import React, { useState } from "react"; +import { useState } from "react"; import { useTranslation } from "react-i18next"; import { Dropdown, Avatar, Spin, Button, Tag, ConfigProvider } from "antd"; import { - UserRound, + Activity, + BookMarked, + Check, + ChevronRight, + Copyright, + ExternalLink, + Globe, LogOut, LogIn, + UserRound, UserRoundPlus, - UserCircle, } from "lucide-react"; +import { GithubOutlined } from "@ant-design/icons"; import type { ItemType } from "antd/es/menu/interface"; import Link from "next/link"; import { useAuthenticationContext } from "@/components/providers/AuthenticationProvider"; import { useAuthorizationContext } from "@/components/providers/AuthorizationProvider"; -import { useConfirmModal } from "@/hooks/useConfirmModal"; -import { getRoleColor } from "@/lib/auth"; +import { useDeployment } from "@/components/providers/deploymentProvider"; import { USER_ROLES } from "@/const/auth"; +import { APP_VERSION, languageOptions } from "@/const/constants"; +import { useAccountMonitoring } from "@/hooks/useAccountMonitoring"; +import { useConfirmModal } from "@/hooks/useConfirmModal"; +import { useLanguageSwitch } from "@/lib/language"; +import { useGlobalConfigStore } from "@/stores/global"; +import { NavigationRailLabel } from "@/components/navigation/NavigationRailLabel"; interface AvatarDropdownProps { rail?: boolean; + expanded?: boolean; + onOpenChange?: (open: boolean) => void; } -export function AvatarDropdown({ rail = false }: AvatarDropdownProps) { +const MENU_POPUP_CLASS = + "[&_.ant-dropdown-menu]:!flex [&_.ant-dropdown-menu]:!flex-col [&_.ant-dropdown-menu]:!gap-1 " + + "[&_.ant-dropdown-menu]:!p-1 [&_.ant-dropdown-menu]:!rounded-[12px] [&_.ant-dropdown-menu]:!bg-white " + + "[&_.ant-dropdown-menu]:!shadow-[0_8px_24px_rgba(0,0,0,0.16)] " + + "[&_.ant-dropdown-menu]:!max-h-[calc(100dvh-16px)] [&_.ant-dropdown-menu]:!overflow-y-auto " + + "[&_.ant-dropdown-menu-item:not(.account-menu-identity)]:!h-10 [&_.ant-dropdown-menu-item]:!rounded-lg [&_.ant-dropdown-menu-item]:!p-2 " + + "[&_.ant-dropdown-menu-submenu-title]:!h-10 [&_.ant-dropdown-menu-submenu-title]:!rounded-lg [&_.ant-dropdown-menu-submenu-title]:!p-2 " + + "[&_.ant-dropdown-menu-title-content]:!min-w-0 [&_.ant-dropdown-menu-title-content]:!text-base " + + "[&_.ant-dropdown-menu-title-content]:!leading-6 [&_.ant-dropdown-menu-title-content]:!font-normal " + + "[&_.ant-dropdown-menu-item-icon]:!me-2 [&_.ant-dropdown-menu-item-icon]:!text-[20px] " + + "[&_.ant-dropdown-menu-item-divider]:!my-0 [&_.ant-dropdown-menu-item-divider]:!bg-[#f0f0f0] " + + "[&_.ant-dropdown-menu-item:not(.account-menu-readonly):hover]:!bg-[rgba(25,25,25,0.08)] " + + "[&_.ant-dropdown-menu-submenu-title:hover]:!bg-[rgba(25,25,25,0.08)] " + + "[&_.ant-dropdown-menu]:!text-[#191919] [&_.ant-dropdown-menu]:!tracking-[0px]"; + +export function AvatarDropdown({ + rail = false, + expanded = false, + onOpenChange, +}: AvatarDropdownProps) { const { user } = useAuthorizationContext(); const { isLoading, logout, openLoginModal, openRegisterModal } = useAuthenticationContext(); + const { appVersion } = useDeployment(); + const { config } = useGlobalConfigStore(); + const monitoringUrl = useAccountMonitoring(); + const { currentLanguage, handleLanguageChange } = useLanguageSwitch(); const [dropdownOpen, setDropdownOpen] = useState(false); + const changeOpen = (open: boolean) => { + setDropdownOpen(open); + onOpenChange?.(open); + }; const { t } = useTranslation("common"); const { confirm } = useConfirmModal(); @@ -41,8 +84,6 @@ export function AvatarDropdown({ rail = false }: AvatarDropdownProps) { return t("auth.admin"); case USER_ROLES.DEV: return t("auth.dev"); - case USER_ROLES.USER: - return t("auth.user"); case USER_ROLES.ASSET_OWNER: return t("auth.assetOwner"); default: @@ -50,7 +91,6 @@ export function AvatarDropdown({ rail = false }: AvatarDropdownProps) { } }; - // Show loading while authentication is in progress if (isLoading) { return rail ? (
@@ -60,154 +100,248 @@ export function AvatarDropdown({ rail = false }: AvatarDropdownProps) { ); } - if (!user) { - const items: ItemType[] = [ - { - key: "not-logged-in", - label: ( -
-
- {t("auth.notLoggedIn")} + + const identity = user?.username?.trim() || user?.email?.trim() || "-"; + const profileLocale = currentLanguage.split("-")[0] === "en" ? "en" : "zh"; + const accountItems: ItemType[] = user + ? [ + { + key: "user-info", + label: ( +
+
+ {identity} +
+ + {getRoleDisplayName(user.role)} +
-
- ), - className: "cursor-default hover:bg-transparent", - style: { - backgroundColor: "transparent", - cursor: "default", + ), + className: + "account-menu-identity account-menu-readonly !h-[68px] !cursor-default hover:!bg-transparent", }, - }, - { - type: "divider", - }, - { - key: "login", - icon: , - label: t("auth.login"), - onClick: () => { - setDropdownOpen(false); - openLoginModal(); + { type: "divider" }, + { + key: "profile", + icon: , + label: ( + + {t("profile.title")} + + ), }, - }, - { - key: "register", - icon: , - label: t("auth.register"), - onClick: () => { - setDropdownOpen(false); - openRegisterModal(); + ] + : [ + { + key: "not-logged-in", + label: t("auth.notLoggedIn"), + className: + "account-menu-readonly !cursor-default hover:!bg-transparent", }, - }, - ]; - - return ( - document.body}> - ( -
{menu}
- )} - getPopupContainer={() => document.body} - > - + ) : ( + + ) ) : ( - } - /> + )}
diff --git a/frontend/components/navigation/NavigationRailLabel.tsx b/frontend/components/navigation/NavigationRailLabel.tsx new file mode 100644 index 0000000000..8b04abff11 --- /dev/null +++ b/frontend/components/navigation/NavigationRailLabel.tsx @@ -0,0 +1,27 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import { Typography } from "antd"; + +interface NavigationRailLabelProps { + expanded: boolean; + label: string; + variant?: "label" | "title"; +} + +export function NavigationRailLabel({ + expanded, + label, + variant = "label", +}: NavigationRailLabelProps) { + if (!expanded) return null; + return ( + + {label} + + ); +} diff --git a/frontend/components/navigation/NotificationBell.tsx b/frontend/components/navigation/NotificationBell.tsx index 86117d465b..d7751fd470 100644 --- a/frontend/components/navigation/NotificationBell.tsx +++ b/frontend/components/navigation/NotificationBell.tsx @@ -1,3 +1,5 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + "use client"; import { useState } from "react"; @@ -13,12 +15,15 @@ import { } from "@/lib/notificationNavigation"; import { formatDateTime } from "@/lib/utils"; import type { NotificationItem } from "@/types/notification"; +import { NavigationRailLabel } from "@/components/navigation/NavigationRailLabel"; interface NotificationBellProps { unreadCount: number; items: NotificationItem[]; isLoading: boolean; rail?: boolean; + expanded?: boolean; + onOpenChange?: (open: boolean) => void; onMarkRead?: (receiverId: number) => Promise; onMarkAllRead?: () => Promise; isMarkingAllRead?: boolean; @@ -29,6 +34,8 @@ export function NotificationBell({ items, isLoading, rail = false, + expanded = false, + onOpenChange, onMarkRead, onMarkAllRead, isMarkingAllRead = false, @@ -38,6 +45,10 @@ export function NotificationBell({ const params = useParams<{ locale: string }>(); const locale = params.locale || "en"; const [dropdownOpen, setDropdownOpen] = useState(false); + const changeOpen = (open: boolean) => { + setDropdownOpen(open); + onOpenChange?.(open); + }; const tooltipTitle = unreadCount > 0 @@ -54,7 +65,7 @@ export function NotificationBell({ return; } - setDropdownOpen(false); + changeOpen(false); if (onMarkRead) { await onMarkRead(item.receiver_id); } @@ -144,27 +155,33 @@ export function NotificationBell({ ) : ( document.body} popupRender={() => panel} > diff --git a/frontend/components/navigation/SideNavigation.tsx b/frontend/components/navigation/SideNavigation.tsx index 9131b8f977..f4d94b8724 100644 --- a/frontend/components/navigation/SideNavigation.tsx +++ b/frontend/components/navigation/SideNavigation.tsx @@ -1,9 +1,18 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + "use client"; -import { useEffect, useMemo, useState, type ComponentType } from "react"; +import { + useEffect, + useMemo, + useRef, + useState, + type ComponentType, +} from "react"; import { useTranslation } from "react-i18next"; import { useParams, usePathname, useRouter } from "next/navigation"; import Link from "next/link"; +import { Button, Tooltip } from "antd"; import { Bell, BookOpen, @@ -17,6 +26,8 @@ import { Home, LineChart, Puzzle, + PanelLeftClose, + PanelLeftOpen, Settings, Zap, } from "lucide-react"; @@ -26,6 +37,8 @@ import { useAuthorizationContext } from "@/components/providers/AuthorizationPro import { useAuthenticationContext } from "@/components/providers/AuthenticationProvider"; import { useDeployment } from "@/components/providers/deploymentProvider"; import { NotificationBell } from "@/components/navigation/NotificationBell"; +import { NavigationRailLabel } from "@/components/navigation/NavigationRailLabel"; +import { useGlobalConfigStore } from "@/stores/global"; import { AUTH_EVENTS } from "@/const/auth"; import { useMarkAllNotificationsRead, @@ -164,12 +177,9 @@ const ROUTE_CONFIG: RouteConfig[] = [ ]; const RAIL_ITEM_CLASS = - "flex h-12 w-12 shrink-0 flex-col items-center justify-center rounded-[4px] border-0 bg-transparent p-2 text-[#191919]"; -const MENU_ITEM_CLASS = `${RAIL_ITEM_CLASS} !h-auto min-h-12 !px-0 !py-1`; + "flex h-11 w-full shrink-0 cursor-pointer items-center overflow-hidden rounded-lg border-0 bg-transparent p-0 pr-2 text-left text-[#191919] hover:bg-[rgba(25,25,25,0.08)]"; const RAIL_ICON_CLASS = "h-5 w-5 shrink-0"; -const RAIL_LABEL_CLASS = - "w-11 max-w-11 break-words text-center text-[12px] leading-[20px] ![letter-spacing:0px] text-[#191919]"; -const SELECTED_ITEM_CLASS = "!bg-[rgba(25,25,25,0.05)] !rounded-[12px]"; +const SELECTED_ITEM_CLASS = "!bg-[rgba(25,25,25,0.08)]"; function isRouteAccessible( route: RouteConfig, @@ -183,7 +193,11 @@ function isRouteAccessible( return accessibleRoutes.includes(route.path); } -export function SideNavigation() { +interface SideNavigationProps { + onPinnedChange?: (pinned: boolean) => void; +} + +export function SideNavigation({ onPinnedChange }: SideNavigationProps = {}) { const { t } = useTranslation("common"); const { accessibleRoutes } = useAuthorizationContext(); const { isAuthenticated, openAuthPromptModal } = useAuthenticationContext(); @@ -192,6 +206,48 @@ export function SideNavigation() { const pathname = usePathname(); const params = useParams<{ locale: string }>(); const locale = params.locale === "en" ? "en" : "zh"; + const { config } = useGlobalConfigStore(); + const [expanded, setExpanded] = useState(false); + const [pinned, setPinned] = useState(false); + const suppressExpansion = useRef(false); + const hovered = useRef(false); + const popups = useRef({ account: false, notification: false }); + const closeTimer = useRef | null>(null); + const expand = () => { + if (closeTimer.current) clearTimeout(closeTimer.current); + if (suppressExpansion.current) return; + setExpanded(true); + }; + const collapseLater = () => { + if (closeTimer.current) clearTimeout(closeTimer.current); + if (pinned || popups.current.account || popups.current.notification) return; + closeTimer.current = setTimeout(() => setExpanded(false), 160); + }; + const handlePopupChange = ( + key: "account" | "notification", + open: boolean + ) => { + popups.current[key] = open; + if (open) { + suppressExpansion.current = false; + expand(); + } else if (!hovered.current) collapseLater(); + }; + const togglePinned = () => { + if (closeTimer.current) clearTimeout(closeTimer.current); + const nextPinned = !pinned; + suppressExpansion.current = !nextPinned; + setPinned(nextPinned); + setExpanded(nextPinned); + onPinnedChange?.(nextPinned); + }; + useEffect( + () => () => { + if (closeTimer.current) clearTimeout(closeTimer.current); + onPinnedChange?.(false); + }, + [onPinnedChange] + ); const [pendingNavigationPath, setPendingNavigationPath] = useState< string | null @@ -267,6 +323,8 @@ export function SideNavigation() { const notificationAction = isSpeedMode ? null : isAuthenticated ? ( handlePopupChange("notification", open)} unreadCount={unreadCount} items={items} isLoading={isNotificationsLoading} @@ -289,26 +347,89 @@ export function SideNavigation() { } }} > - + + + + ); return ( -
- - logo { + hovered.current = true; + expand(); + }} + onMouseLeave={() => { + hovered.current = false; + suppressExpansion.current = false; + collapseLater(); + }} + onFocusCapture={expand} + onBlurCapture={(event) => { + if ( + !event.currentTarget.contains(event.relatedTarget) && + !hovered.current + ) { + suppressExpansion.current = false; + collapseLater(); + } + }} + > + {(!expanded || pinned) && ( +