From 1173b3e36335f68497b8866bdb13775d72be373e Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Thu, 2 Apr 2026 17:54:18 +0200 Subject: [PATCH] feat(sidebar): rewrite Sidebar as composable compound component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the monolithic Sidebar (560 lines of app-specific logic) with a composable shell exposing Sidebar.Header, Sidebar.Section, Sidebar.Footer, and Sidebar.FooterLink sub-components. Application logic (tree builders, starred items, domain types) is removed — those responsibilities move to the consuming app layer. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/design-system/layout/Sidebar/Sidebar.tsx | 746 ++++++------------- 1 file changed, 212 insertions(+), 534 deletions(-) diff --git a/src/design-system/layout/Sidebar/Sidebar.tsx b/src/design-system/layout/Sidebar/Sidebar.tsx index b67105b..ee38234 100644 --- a/src/design-system/layout/Sidebar/Sidebar.tsx +++ b/src/design-system/layout/Sidebar/Sidebar.tsx @@ -1,561 +1,239 @@ -import { useState, useEffect, useMemo } from 'react' -import { useNavigate, useLocation } from 'react-router-dom' -import { Search, X, ChevronRight, ChevronDown, Settings, FileText } from 'lucide-react' +import { type ReactNode } from 'react' +import { + Search, + X, + ChevronsLeft, + ChevronsRight, + ChevronRight, + ChevronDown, +} from 'lucide-react' import styles from './Sidebar.module.css' -import camelLogoUrl from '../../../assets/camel-logo.svg' -import { SidebarTree, type SidebarTreeNode } from './SidebarTree' -import { useStarred } from './useStarred' -import { StatusDot } from '../../primitives/StatusDot/StatusDot' +import { SidebarContext, useSidebarContext } from './SidebarContext' -// ── Types ──────────────────────────────────────────────────────────────────── +// ── Sub-component props ───────────────────────────────────────────────────── -export interface SidebarApp { - id: string - name: string - health: 'live' | 'stale' | 'dead' - exchangeCount: number - routes: SidebarRoute[] - agents: SidebarAgent[] -} - -export interface SidebarRoute { - id: string - name: string - exchangeCount: number -} - -export interface SidebarAgent { - id: string - name: string - status: 'live' | 'stale' | 'dead' - tps: number -} - -interface SidebarProps { - apps: SidebarApp[] +interface SidebarHeaderProps { + logo: ReactNode + title: string + version?: string + onClick?: () => void className?: string - onNavigate?: (path: string) => void } -// ── Helpers ────────────────────────────────────────────────────────────────── - -function formatCount(n: number): string { - if (n >= 1000) return `${(n / 1000).toFixed(1)}k` - return String(n) -} - -function buildAppTreeNodes(apps: SidebarApp[]): SidebarTreeNode[] { - return apps.map((app) => ({ - id: `app:${app.id}`, - label: app.name, - icon: , - badge: formatCount(app.exchangeCount), - path: `/apps/${app.id}`, - starrable: true, - starKey: app.id, - children: app.routes.map((route) => ({ - id: `route:${app.id}:${route.id}`, - starKey: `${app.id}:${route.id}`, - label: route.name, - icon: , - badge: formatCount(route.exchangeCount), - path: `/apps/${app.id}/${route.id}`, - starrable: true, - })), - })) -} - -function buildRouteTreeNodes(apps: SidebarApp[]): SidebarTreeNode[] { - return apps - .filter((app) => app.routes.length > 0) - .map((app) => ({ - id: `routes:${app.id}`, - label: app.name, - icon: , - badge: `${app.routes.length} routes`, - path: `/routes/${app.id}`, - starrable: true, - starKey: `routes:${app.id}`, - children: app.routes.map((route) => ({ - id: `routestat:${app.id}:${route.id}`, - starKey: `routes:${app.id}:${route.id}`, - label: route.name, - icon: , - badge: formatCount(route.exchangeCount), - path: `/routes/${app.id}/${route.id}`, - starrable: true, - })), - })) -} - -function buildAgentTreeNodes(apps: SidebarApp[]): SidebarTreeNode[] { - return apps - .filter((app) => app.agents.length > 0) - .map((app) => { - const liveCount = app.agents.filter((a) => a.status === 'live').length - return { - id: `agents:${app.id}`, - label: app.name, - icon: , - badge: `${liveCount}/${app.agents.length} live`, - path: `/agents/${app.id}`, - starrable: true, - starKey: `agents:${app.id}`, - children: app.agents.map((agent) => ({ - id: `agent:${app.id}:${agent.id}`, - starKey: `${app.id}:${agent.id}`, - label: agent.name, - badge: `${agent.tps.toFixed(1)}/s`, - path: `/agents/${app.id}/${agent.id}`, - starrable: true, - })), - } - }) -} - -// ── Starred section helpers ────────────────────────────────────────────────── - -interface StarredItem { - starKey: string +interface SidebarSectionProps { + icon: ReactNode label: string - icon?: React.ReactNode - path: string - type: 'application' | 'route' | 'agent' | 'routestat' - parentApp?: string + open: boolean + onToggle: () => void + active?: boolean + children: ReactNode + className?: string } -function collectStarredItems(apps: SidebarApp[], starredIds: Set): StarredItem[] { - const items: StarredItem[] = [] - - for (const app of apps) { - if (starredIds.has(app.id)) { - items.push({ - starKey: app.id, - label: app.name, - icon: , - path: `/apps/${app.id}`, - type: 'application', - }) - } - for (const route of app.routes) { - const key = `${app.id}:${route.id}` - if (starredIds.has(key)) { - items.push({ - starKey: key, - label: route.name, - path: `/apps/${app.id}/${route.id}`, - type: 'route', - parentApp: app.name, - }) - } - } - const agentsAppKey = `agents:${app.id}` - if (starredIds.has(agentsAppKey)) { - items.push({ - starKey: agentsAppKey, - label: app.name, - icon: , - path: `/agents/${app.id}`, - type: 'agent', - }) - } - for (const agent of app.agents) { - const key = `${app.id}:${agent.id}` - if (starredIds.has(key)) { - items.push({ - starKey: key, - label: agent.name, - path: `/agents/${app.id}/${agent.id}`, - type: 'agent', - parentApp: app.name, - }) - } - } - // Routes tree starred items - const routesAppKey = `routes:${app.id}` - if (starredIds.has(routesAppKey)) { - items.push({ - starKey: routesAppKey, - label: app.name, - icon: , - path: `/routes/${app.id}`, - type: 'routestat', - }) - } - for (const route of app.routes) { - const routeKey = `routes:${app.id}:${route.id}` - if (starredIds.has(routeKey)) { - items.push({ - starKey: routeKey, - label: route.name, - path: `/routes/${app.id}/${route.id}`, - type: 'routestat', - parentApp: app.name, - }) - } - } - } - - return items +interface SidebarFooterProps { + children: ReactNode + className?: string } -// ── StarredGroup ───────────────────────────────────────────────────────────── - -function StarredGroup({ - label, - items, - onNavigate, - onRemove, -}: { +interface SidebarFooterLinkProps { + icon: ReactNode label: string - items: StarredItem[] - onNavigate: (path: string) => void - onRemove: (starKey: string) => void -}) { + active?: boolean + onClick?: () => void + className?: string +} + +interface SidebarRootProps { + collapsed?: boolean + onCollapseToggle?: () => void + searchValue?: string + onSearchChange?: (query: string) => void + children: ReactNode + className?: string +} + +// ── Sub-components ────────────────────────────────────────────────────────── + +function SidebarHeader({ logo, title, version, onClick, className }: SidebarHeaderProps) { + const { collapsed } = useSidebarContext() + return ( -
-
{label}
- {items.map((item) => ( -
onNavigate(item.path)} - role="button" - tabIndex={0} - onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onNavigate(item.path) }} - > - {item.icon} -
- {item.label} - {item.parentApp && ( - {item.parentApp} - )} -
- +
{ if (e.key === 'Enter' || e.key === ' ') onClick() } : undefined} + > + {logo} + {!collapsed && ( +
+ {title} + {version && {version}}
- ))} + )}
) } -// ── Sidebar ────────────────────────────────────────────────────────────────── +function SidebarSection({ + icon, + label, + open, + onToggle, + active, + children, + className, +}: SidebarSectionProps) { + const { collapsed, onCollapseToggle } = useSidebarContext() -export function Sidebar({ apps, className, onNavigate }: SidebarProps) { - const [search, setSearch] = useState('') - const [appsCollapsed, _setAppsCollapsed] = useState(() => localStorage.getItem('cameleer:sidebar:apps-collapsed') === 'true') - const [agentsCollapsed, _setAgentsCollapsed] = useState(() => localStorage.getItem('cameleer:sidebar:agents-collapsed') === 'true') - const [routesCollapsed, _setRoutesCollapsed] = useState(() => localStorage.getItem('cameleer:sidebar:routes-collapsed') === 'true') - - const setAppsCollapsed = (updater: (v: boolean) => boolean) => { - _setAppsCollapsed((prev) => { - const next = updater(prev) - localStorage.setItem('cameleer:sidebar:apps-collapsed', String(next)) - return next - }) - } - - const setAgentsCollapsed = (updater: (v: boolean) => boolean) => { - _setAgentsCollapsed((prev) => { - const next = updater(prev) - localStorage.setItem('cameleer:sidebar:agents-collapsed', String(next)) - return next - }) - } - - const setRoutesCollapsed = (updater: (v: boolean) => boolean) => { - _setRoutesCollapsed((prev) => { - const next = updater(prev) - localStorage.setItem('cameleer:sidebar:routes-collapsed', String(next)) - return next - }) - } - const routerNavigate = useNavigate() - const nav = onNavigate ?? routerNavigate - const location = useLocation() - const { starredIds, isStarred, toggleStar } = useStarred() - - // Build tree data - const appNodes = useMemo(() => buildAppTreeNodes(apps), [apps]) - const agentNodes = useMemo(() => buildAgentTreeNodes(apps), [apps]) - const routeNodes = useMemo(() => buildRouteTreeNodes(apps), [apps]) - - // Sidebar reveal from Cmd-K navigation (passed via location state) - const sidebarRevealPath = (location.state as { sidebarReveal?: string } | null)?.sidebarReveal ?? null - - useEffect(() => { - if (!sidebarRevealPath) return - - // Uncollapse Applications section if reveal path matches an apps tree node - const matchesAppTree = appNodes.some((node) => - node.path === sidebarRevealPath || node.children?.some((child) => child.path === sidebarRevealPath), + // In icon-rail (collapsed) mode, render a centered icon with tooltip + if (collapsed) { + return ( +
{ + // Expand sidebar and open the section + onCollapseToggle?.() + if (!open) onToggle() + }} + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + onCollapseToggle?.() + if (!open) onToggle() + } + }} + > + {icon} +
) - if (matchesAppTree && appsCollapsed) { - _setAppsCollapsed(false) - localStorage.setItem('cameleer:sidebar:apps-collapsed', 'false') - } - - // Uncollapse Agents section if reveal path matches an agents tree node - const matchesAgentTree = agentNodes.some((node) => - node.path === sidebarRevealPath || node.children?.some((child) => child.path === sidebarRevealPath), - ) - if (matchesAgentTree && agentsCollapsed) { - _setAgentsCollapsed(false) - localStorage.setItem('cameleer:sidebar:agents-collapsed', 'false') - } - }, [sidebarRevealPath]) // eslint-disable-line react-hooks/exhaustive-deps - - // Build starred items - const starredItems = useMemo( - () => collectStarredItems(apps, starredIds), - [apps, starredIds], - ) - - const starredApps = starredItems.filter((i) => i.type === 'application') - const starredRoutes = starredItems.filter((i) => i.type === 'route') - const starredAgents = starredItems.filter((i) => i.type === 'agent') - const starredRouteStats = starredItems.filter((i) => i.type === 'routestat') - const hasStarred = starredItems.length > 0 - - // When a sidebar reveal path is provided (e.g. via Cmd-K navigation), - // use it for sidebar selection so the correct item is highlighted - const effectiveSelectedPath = sidebarRevealPath ?? location.pathname + } return ( - + {open && children} +
) } +function SidebarFooter({ children, className }: SidebarFooterProps) { + return ( +
+ {children} +
+ ) +} + +function SidebarFooterLink({ icon, label, active, onClick, className }: SidebarFooterLinkProps) { + const { collapsed } = useSidebarContext() + + return ( +
{ if (e.key === 'Enter' || e.key === ' ') onClick() } : undefined} + > + {icon} + {!collapsed && ( +
+
{label}
+
+ )} +
+ ) +} + +// ── Root component ────────────────────────────────────────────────────────── + +function SidebarRoot({ + collapsed = false, + onCollapseToggle, + searchValue, + onSearchChange, + children, + className, +}: SidebarRootProps) { + return ( + + + + ) +} + +// ── Compound export ───────────────────────────────────────────────────────── + +export const Sidebar = Object.assign(SidebarRoot, { + Header: SidebarHeader, + Section: SidebarSection, + Footer: SidebarFooter, + FooterLink: SidebarFooterLink, +})