Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
130 changes: 47 additions & 83 deletions frontend/src/components/app-sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,28 +1,20 @@
import * as React from "react"
import { Home, Bot, Workflow, Database, Plug, MessageSquare, Zap, Server, ScrollText, Users, BookOpen, Cpu, Link2, Boxes, Terminal, Settings, ChevronRight, Shield } from "lucide-react"
import { NavLink, useLocation } from "react-router-dom"
import { Home, Bot, Workflow, Database, Plug, MessageSquare, Zap, Server, ScrollText, Users, BookOpen, Cpu, Link2, Boxes, Terminal, Settings, Shield } from "lucide-react"
import { useLocation } from "react-router-dom"

import { NavMain } from "@/components/nav-main"
import { NavCollapsibleGroup } from "@/components/nav-collapsible"
import { NavUser } from "@/components/nav-user"
import { AppSidebarHeader } from "@/components/app-sidebar-header"
import { usePermissions } from "@/contexts/PermissionsContext"
import { fetchDocCountQuiet } from "@/services/utilsApi"
import { doctype } from "@/data/doctypes"
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarRail,
useSidebar,
} from "@/components/ui/sidebar"

/**
Expand Down Expand Up @@ -58,14 +50,23 @@ const buildNavItems = [
icon: Workflow,
capability: "flows.use",
},
]

/**
* Knowledge surfaces live in a flat "Know" label group, matching Build and
* Operate: Tables for structured/relational data, Sources for retrieval
* knowledge stores backed by any vector/FTS backend (RAG). Future source
* types (Files, Repos, Drives) nest here as additional items.
*/
const knowNavItems = [
{
title: "Data",
title: "Tables",
url: "/data",
icon: Database,
capability: "agent.view_all",
},
{
title: "Knowledge",
title: "Sources",
url: "/knowledge",
icon: BookOpen,
capability: "agent.use",
Expand Down Expand Up @@ -93,19 +94,11 @@ const operateNavItems = [
},
]

const peopleNavItems = [
{
title: "Users",
url: "/users",
icon: Users,
capability: "users.manage",
},
]

/**
* Settings-adjacent pages are grouped under a single collapsible sidebar
* entry instead of each getting a top-level item, to keep the primary nav
* short. Same capability-gating rules as allNavItems.
* short. User management lives here too — it's an admin task, not a daily
* destination. Same capability-gating rules as allNavItems.
*/
const settingsNavItems = [
{
Expand Down Expand Up @@ -156,12 +149,17 @@ const settingsNavItems = [
icon: Shield,
capability: "roles.manage",
},
{
title: "Users",
url: "/users",
icon: Users,
capability: "users.manage",
},
]

export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const location = useLocation()
const { hasCapability, isLoading } = usePermissions()
const { state: sidebarState, isMobile, setOpen } = useSidebar()
const [agentCount, setAgentCount] = React.useState<number | undefined>(undefined)

React.useEffect(() => {
Expand All @@ -185,35 +183,34 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
)
}

const isPathInItems = (items: { url: string }[]) =>
items.some(
(item) =>
location.pathname === item.url || location.pathname.startsWith(item.url + "/"),
)

// While permissions are loading show only uncapability-gated items so the
// sidebar doesn't flash/jump once capabilities resolve.
const dashboardItems = filterItemsByCapability(dashboardNavItems)
const buildItems = filterItemsByCapability(buildNavItems).map((item) =>
item.title === "Agents" ? { ...item, count: agentCount } : item
)
const knowledgeItems = filterItemsByCapability(knowNavItems)
const operateItems = filterItemsByCapability(operateNavItems)
const peopleItems = filterItemsByCapability(peopleNavItems)
const settingsItems = isLoading
? []
: settingsNavItems.filter((item) => item.capability === null || hasCapability(item.capability))
const isSettingsActive = settingsItems.some((item) => location.pathname.startsWith(item.url))
const [isSettingsOpen, setIsSettingsOpen] = React.useState(isSettingsActive)

React.useEffect(() => {
if (isSettingsActive) {
setIsSettingsOpen(true)
}
}, [isSettingsActive])
// Settings is the only collapsible group; its open state is owned here so
// the active route auto-opens it on navigation.
const settingsActive = isPathInItems(settingsItems)
const [settingsOpen, setSettingsOpen] = React.useState(settingsActive)

const handleSettingsClick = (event: React.MouseEvent<HTMLButtonElement>) => {
// In icon-collapsed desktop mode, open sidebar first so the submenu
// becomes visible immediately instead of appearing unresponsive.
if (!isMobile && sidebarState === "collapsed") {
event.preventDefault()
setOpen(true)
setIsSettingsOpen(true)
React.useEffect(() => {
if (settingsActive) {
setSettingsOpen(true)
}
}
}, [settingsActive])

return (
<Sidebar collapsible="icon" {...props}>
Expand All @@ -223,50 +220,17 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
<SidebarContent>
{dashboardItems.length > 0 && <NavMain items={dashboardItems} />}
{buildItems.length > 0 && <NavMain items={buildItems} label="Build" />}
{knowledgeItems.length > 0 && <NavMain items={knowledgeItems} label="Know" />}
{operateItems.length > 0 && <NavMain items={operateItems} label="Operate" />}
{peopleItems.length > 0 && <NavMain items={peopleItems} label="People" />}
{settingsItems.length > 0 && (
<SidebarGroup>
<SidebarMenu>
<Collapsible
open={isSettingsOpen}
onOpenChange={setIsSettingsOpen}
className="group/settings"
>
<SidebarMenuItem>
<CollapsibleTrigger asChild>
<SidebarMenuButton
tooltip="Settings"
isActive={isSettingsActive}
onClick={handleSettingsClick}
>
<Settings />
<span>Settings</span>
<ChevronRight className="ml-auto transition-transform group-data-[state=open]/settings:rotate-90" />
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{settingsItems.map((item) => {
const isActive = location.pathname.startsWith(item.url)
return (
<SidebarMenuSubItem key={item.title}>
<SidebarMenuSubButton asChild isActive={isActive}>
<NavLink to={item.url}>
<item.icon />
<span>{item.title}</span>
</NavLink>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
)
})}
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
</SidebarMenu>
</SidebarGroup>
)}
{settingsItems.length > 0 && (
<NavCollapsibleGroup
title="Settings"
icon={Settings}
items={settingsItems}
open={settingsOpen}
onOpenChange={setSettingsOpen}
/>
)}
</SidebarContent>
<SidebarFooter className="p-0 mb-1 mt-2 border-t border-sidebar-border">
<NavUser />
Expand Down
131 changes: 131 additions & 0 deletions frontend/src/components/nav-collapsible.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { ChevronRight, type LucideIcon } from "lucide-react"
import { NavLink, useLocation } from "react-router-dom"

import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
SidebarGroup,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
useSidebar,
} from "@/components/ui/sidebar"

export interface NavCollapsibleItem {
title: string
url: string
icon?: LucideIcon
}

/**
* A collapsible sidebar group (two-level nav). Expanded sidebar: an accordion
* section whose open state is owned by the parent so only one group is open
* at a time. Collapsed icon rail: the group becomes a flyout popover so its
* sub-items stay reachable without expanding the rail.
*/
export function NavCollapsibleGroup({
title,
icon: Icon,
items,
open,
onOpenChange,
}: {
title: string
icon: LucideIcon
items: NavCollapsibleItem[]
open: boolean
onOpenChange: (open: boolean) => void
}) {
const location = useLocation()
const { isMobile, setOpenMobile, state } = useSidebar()

const isItemActive = (url: string) =>
location.pathname === url || location.pathname.startsWith(url + "/")
const isActive = items.some((item) => isItemActive(item.url))

const handleNavClick = () => {
if (isMobile) {
setOpenMobile(false)
}
}

if (state === "collapsed") {
return (
<SidebarGroup>
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton tooltip={title} isActive={isActive}>
<Icon />
<span>{title}</span>
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" align="start" sideOffset={16} className="w-48">
<DropdownMenuLabel>{title}</DropdownMenuLabel>
<DropdownMenuSeparator />
{items.map((item) => (
<DropdownMenuItem key={item.title} asChild>
<NavLink
to={item.url}
onClick={handleNavClick}
className={isItemActive(item.url) ? "bg-accent text-accent-foreground font-medium" : ""}
>
{item.title}
</NavLink>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
)
}

return (
<SidebarGroup>
<SidebarMenu>
<Collapsible open={open} onOpenChange={onOpenChange} className="group/collapsible">
<SidebarMenuItem>
<CollapsibleTrigger asChild>
<SidebarMenuButton tooltip={title} isActive={isActive}>
<Icon />
<span>{title}</span>
<ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{items.map((item) => (
<SidebarMenuSubItem key={item.title}>
<SidebarMenuSubButton asChild isActive={isItemActive(item.url)}>
<NavLink to={item.url} onClick={handleNavClick}>
{item.icon && <item.icon />}
<span>{item.title}</span>
</NavLink>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
</SidebarMenu>
</SidebarGroup>
)
}