diff --git a/src/App.tsx b/src/App.tsx index 6a04477..93853b4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -19,7 +19,8 @@ import { buildSearchData } from './mocks/searchData' import { exchanges } from './mocks/exchanges' import { routes } from './mocks/routes' import { agents } from './mocks/agents' -import { SIDEBAR_APPS, buildRouteToAppMap } from './mocks/sidebar' +import { buildRouteToAppMap } from './mocks/sidebar' +import { LayoutShell } from './layout/LayoutShell' const routeToApp = buildRouteToAppMap() @@ -78,21 +79,23 @@ export default function App() { return ( <> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> [0]> = {}) { - return render( +const LogoIcon = () => + +function Wrapper({ children }: { children: React.ReactNode }) { + return ( - - - - , + {children} + ) } -describe('Sidebar', () => { - beforeEach(() => { - localStorage.clear() - sessionStorage.clear() +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe('Sidebar compound component', () => { + // 1. renders Header with logo, title, version + it('renders Header with logo, title, and version', () => { + render( + + + } title="MyApp" version="v1.2.3" /> + + , + ) + expect(screen.getByTestId('logo-icon')).toBeInTheDocument() + expect(screen.getByText('MyApp')).toBeInTheDocument() + expect(screen.getByText('v1.2.3')).toBeInTheDocument() }) - it('renders the logo and brand name', () => { - renderSidebar() - expect(screen.getByText('cameleer')).toBeInTheDocument() - expect(screen.getByText('v3.2.1')).toBeInTheDocument() + // 2. hides Header title and version when collapsed + it('hides Header title and version when sidebar is collapsed', () => { + render( + + + } title="MyApp" version="v1.2.3" /> + + , + ) + expect(screen.queryByText('MyApp')).not.toBeInTheDocument() + expect(screen.queryByText('v1.2.3')).not.toBeInTheDocument() + // Logo should still be visible + expect(screen.getByTestId('logo-icon')).toBeInTheDocument() }) - it('renders the search input', () => { - renderSidebar() - expect(screen.getByPlaceholderText('Filter...')).toBeInTheDocument() + // 3. renders Section with label and children + it('renders Section with label and children when open', () => { + render( + + + icon} + label="Settings" + open + onToggle={vi.fn()} + > +
Section Child
+
+
+
, + ) + expect(screen.getByText('Settings')).toBeInTheDocument() + expect(screen.getByText('Section Child')).toBeInTheDocument() }) - it('renders Navigation section header', () => { - renderSidebar() - expect(screen.getByText('Navigation')).toBeInTheDocument() + // 4. hides Section children when section collapsed (open=false) + it('hides Section children when section is not open', () => { + render( + + + icon} + label="Settings" + open={false} + onToggle={vi.fn()} + > +
Section Child
+
+
+
, + ) + expect(screen.getByText('Settings')).toBeInTheDocument() + expect(screen.queryByText('Section Child')).not.toBeInTheDocument() }) - it('renders Applications tree section', () => { - renderSidebar() - expect(screen.getByText('Applications')).toBeInTheDocument() + // 5. calls onToggle when Section header clicked + it('calls onToggle when Section chevron button is clicked', async () => { + const user = userEvent.setup() + const onToggle = vi.fn() + + render( + + + icon} + label="Settings" + open + onToggle={onToggle} + > +
child
+
+
+
, + ) + + const btn = screen.getByRole('button', { name: /collapse settings/i }) + await user.click(btn) + expect(onToggle).toHaveBeenCalledTimes(1) }) - it('renders Agents tree section', () => { - renderSidebar() - expect(screen.getByText('Agents')).toBeInTheDocument() + // 6. renders collapse toggle and calls onCollapseToggle + it('renders collapse toggle button and calls onCollapseToggle when clicked', async () => { + const user = userEvent.setup() + const onCollapseToggle = vi.fn() + + render( + + + } title="App" /> + + , + ) + + const toggleBtn = screen.getByRole('button', { name: /collapse sidebar/i }) + await user.click(toggleBtn) + expect(onCollapseToggle).toHaveBeenCalledTimes(1) }) - it('renders Routes nav link', () => { - renderSidebar() - expect(screen.getByText('Routes')).toBeInTheDocument() + // 7. renders expand toggle label when collapsed + it('renders expand toggle when sidebar is collapsed', () => { + render( + + + } title="App" /> + + , + ) + expect(screen.getByRole('button', { name: /expand sidebar/i })).toBeInTheDocument() }) - it('renders bottom links', () => { - renderSidebar() + // 8. renders search input and calls onSearchChange + it('renders search input and calls onSearchChange on input', async () => { + const user = userEvent.setup() + const onSearchChange = vi.fn() + + render( + + + } title="App" /> + + , + ) + + const input = screen.getByPlaceholderText('Filter...') + expect(input).toBeInTheDocument() + + await user.type(input, 'hello') + expect(onSearchChange).toHaveBeenCalled() + // Each keystroke fires once + expect(onSearchChange.mock.calls[0][0]).toBe('h') + }) + + // 9. hides search when collapsed + it('hides search input when sidebar is collapsed', () => { + render( + + + } title="App" /> + + , + ) + expect(screen.queryByPlaceholderText('Filter...')).not.toBeInTheDocument() + }) + + // 10. hides search when onSearchChange not provided + it('hides search input when onSearchChange is not provided', () => { + render( + + + } title="App" /> + + , + ) + expect(screen.queryByPlaceholderText('Filter...')).not.toBeInTheDocument() + }) + + // 11. renders FooterLinks with icons and labels + it('renders FooterLinks with icon and label', () => { + render( + + + + ic} + label="Admin" + /> + + + , + ) + expect(screen.getByTestId('footer-icon')).toBeInTheDocument() expect(screen.getByText('Admin')).toBeInTheDocument() - expect(screen.getByText('API Docs')).toBeInTheDocument() }) - it('renders app names in the Applications tree', () => { - renderSidebar() - // order-service appears in Applications, Routes, and Agents trees - expect(screen.getAllByText('order-service').length).toBeGreaterThanOrEqual(1) - expect(screen.getAllByText('payment-svc').length).toBeGreaterThanOrEqual(1) + // 12. hides FooterLink labels when collapsed and sets title tooltip + it('hides FooterLink label when collapsed and exposes title tooltip', () => { + render( + + + + ic} + label="Admin" + /> + + + , + ) + expect(screen.queryByText('Admin')).not.toBeInTheDocument() + + // The clickable element should carry a title attribute for tooltip + // (accessible name comes from icon content when label is hidden) + const item = screen.getByTitle('Admin') + expect(item).toHaveAttribute('title', 'Admin') }) - it('renders exchange count badges', () => { - renderSidebar() - expect(screen.getByText('1.4k')).toBeInTheDocument() - }) - - it('renders agent live count badge in Agents tree', () => { - renderSidebar() - expect(screen.getByText('2/2 live')).toBeInTheDocument() - }) - - it('does not show starred section when nothing is starred', () => { - renderSidebar() - expect(screen.queryByText('★ Starred')).not.toBeInTheDocument() - }) - - it('shows starred section after starring an item', async () => { + // 13. calls FooterLink onClick + it('calls FooterLink onClick when clicked', async () => { const user = userEvent.setup() - renderSidebar() + const onClick = vi.fn() - // Find the first app row (order-service in Applications tree) and hover to reveal star - const appRows = screen.getAllByText('order-service') - const appRow = appRows[0].closest('[role="treeitem"]')! - await user.hover(appRow) + render( + + + + ic} label="Admin" onClick={onClick} /> + + + , + ) - // Click the star button - const starBtn = appRow.querySelector('button[aria-label="Add to starred"]')! - await user.click(starBtn) - - expect(screen.getByText('★ Starred')).toBeInTheDocument() + await user.click(screen.getByText('Admin')) + expect(onClick).toHaveBeenCalledTimes(1) }) - it('filters tree items by search', async () => { + // 14. renders Section as icon-rail item when sidebar collapsed + it('renders Section as icon-rail item when sidebar is collapsed', () => { + render( + + + ic} + label="Settings" + open={false} + onToggle={vi.fn()} + > +
child
+
+
+
, + ) + + // Label text should not be visible (only as tooltip via title attr) + expect(screen.queryByText('Settings')).not.toBeInTheDocument() + + // Rail item carries title attribute for tooltip + // (accessible name comes from icon content when label is hidden) + const railItem = screen.getByTitle('Settings') + expect(railItem).toHaveAttribute('title', 'Settings') + + // Icon should still render + expect(screen.getByTestId('section-icon')).toBeInTheDocument() + + // Section children should not be rendered + expect(screen.queryByText('child')).not.toBeInTheDocument() + }) + + // 15. fires both onCollapseToggle and onToggle when icon-rail section clicked + it('fires both onCollapseToggle and onToggle when icon-rail section is clicked', async () => { const user = userEvent.setup() - renderSidebar() + const onCollapseToggle = vi.fn() + const onToggle = vi.fn() - const searchInput = screen.getByPlaceholderText('Filter...') - await user.type(searchInput, 'payment') + render( + + + ic} + label="Settings" + open={false} + onToggle={onToggle} + > +
child
+
+
+
, + ) - // payment-svc should still be visible (may appear in multiple trees) - expect(screen.getAllByText('payment-svc').length).toBeGreaterThanOrEqual(1) + const railItem = screen.getByTitle('Settings') + await user.click(railItem) + + expect(onCollapseToggle).toHaveBeenCalledTimes(1) + expect(onToggle).toHaveBeenCalledTimes(1) }) - it('expands tree to show children when chevron is clicked', async () => { - const user = userEvent.setup() - renderSidebar() + // 16. applies active highlight to FooterLink + it('applies active highlight class to FooterLink when active', () => { + render( + + + + ic} label="Admin" active /> + + + , + ) - // Find the expand button for order-service in Applications tree - const expandBtns = screen.getAllByLabelText('Expand') - await user.click(expandBtns[0]) - - // Routes should now be visible - expect(screen.getByText('order-intake')).toBeInTheDocument() - expect(screen.getByText('order-enrichment')).toBeInTheDocument() - }) - - it('collapses expanded tree when chevron is clicked again', async () => { - const user = userEvent.setup() - renderSidebar() - - const expandBtns = screen.getAllByLabelText('Expand') - await user.click(expandBtns[0]) - expect(screen.getByText('order-intake')).toBeInTheDocument() - - const collapseBtn = screen.getByLabelText('Collapse') - await user.click(collapseBtn) - expect(screen.queryByText('order-intake')).not.toBeInTheDocument() - }) - - it('does not render apps with no agents in the Agents tree', () => { - renderSidebar() - // payment-svc has no agents, so it shouldn't appear under the Agents section header - // But it still appears under Applications. Let's check the agent tree specifically. - const agentBadges = screen.queryAllByText(/\/.*live/) - // Only order-service should have an agent badge - expect(agentBadges).toHaveLength(1) - expect(agentBadges[0].textContent).toBe('2/2 live') + const item = screen.getByText('Admin').closest('[role="button"]')! + expect(item.className).toMatch(/bottomItemActive/) }) }) diff --git a/src/design-system/layout/Sidebar/Sidebar.tsx b/src/design-system/layout/Sidebar/Sidebar.tsx index b67105b..e9e1089 100644 --- a/src/design-system/layout/Sidebar/Sidebar.tsx +++ b/src/design-system/layout/Sidebar/Sidebar.tsx @@ -1,561 +1,240 @@ -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?.() + onToggle() + }} + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + onCollapseToggle?.() + 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, +}) diff --git a/src/design-system/layout/Sidebar/SidebarContext.ts b/src/design-system/layout/Sidebar/SidebarContext.ts new file mode 100644 index 0000000..ad09432 --- /dev/null +++ b/src/design-system/layout/Sidebar/SidebarContext.ts @@ -0,0 +1,14 @@ +import { createContext, useContext } from 'react' + +export interface SidebarContextValue { + collapsed: boolean + onCollapseToggle?: () => void +} + +export const SidebarContext = createContext({ + collapsed: false, +}) + +export function useSidebarContext(): SidebarContextValue { + return useContext(SidebarContext) +} diff --git a/src/design-system/layout/index.ts b/src/design-system/layout/index.ts index edf5cd5..b8a9ad0 100644 --- a/src/design-system/layout/index.ts +++ b/src/design-system/layout/index.ts @@ -1,4 +1,6 @@ export { AppShell } from './AppShell/AppShell' export { Sidebar } from './Sidebar/Sidebar' -export type { SidebarApp, SidebarRoute, SidebarAgent } from './Sidebar/Sidebar' +export { SidebarTree } from './Sidebar/SidebarTree' +export type { SidebarTreeNode } from './Sidebar/SidebarTree' +export { useStarred } from './Sidebar/useStarred' export { TopBar } from './TopBar/TopBar' diff --git a/src/layout/LayoutShell.tsx b/src/layout/LayoutShell.tsx new file mode 100644 index 0000000..812a970 --- /dev/null +++ b/src/layout/LayoutShell.tsx @@ -0,0 +1,451 @@ +import { useState, useEffect, useMemo, type ReactNode } from 'react' +import { Outlet, useLocation, useNavigate } from 'react-router-dom' +import { Box, Cpu, GitBranch, Settings, FileText, ChevronRight, X } from 'lucide-react' +import { AppShell } from '../design-system/layout/AppShell/AppShell' +import { Sidebar } from '../design-system/layout/Sidebar/Sidebar' +import { SidebarTree } from '../design-system/layout/Sidebar/SidebarTree' +import type { SidebarTreeNode } from '../design-system/layout/Sidebar/SidebarTree' +import { useStarred } from '../design-system/layout/Sidebar/useStarred' +import { StatusDot } from '../design-system/primitives/StatusDot/StatusDot' +import { SIDEBAR_APPS } from '../mocks/sidebar' +import type { SidebarApp } from '../mocks/sidebar' +import camelLogoUrl from '../assets/camel-logo.svg' + +// ── Helpers ───────────────────────────────────────────────────────────────── + +function formatCount(n: number): string { + if (n >= 1000) return `${(n / 1000).toFixed(1)}k` + return String(n) +} + +// ── Tree node builders ────────────────────────────────────────────────────── + +function buildAppTreeNodes(apps: SidebarApp[]): SidebarTreeNode[] { + return apps.map((app) => ({ + id: app.id, + label: app.name, + icon: , + badge: formatCount(app.exchangeCount), + path: `/apps/${app.id}`, + starrable: true, + starKey: `app:${app.id}`, + children: app.routes.map((route) => ({ + id: `${app.id}/${route.id}`, + label: route.name, + icon: , + badge: formatCount(route.exchangeCount), + path: `/apps/${app.id}/${route.id}`, + })), + })) +} + +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} route${app.routes.length !== 1 ? 's' : ''}`, + path: `/routes/${app.id}`, + starrable: true, + starKey: `routestat:${app.id}`, + children: app.routes.map((route) => ({ + id: `routes:${app.id}/${route.id}`, + label: route.name, + icon: , + badge: formatCount(route.exchangeCount), + path: `/routes/${app.id}/${route.id}`, + })), + })) +} + +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: `agent:${app.id}`, + children: app.agents.map((agent) => ({ + id: `agents:${app.id}/${agent.id}`, + label: agent.name, + icon: , + badge: `${agent.tps} tps`, + path: `/agents/${app.id}/${agent.id}`, + })), + } + }) +} + +// ── Starred items ─────────────────────────────────────────────────────────── + +interface StarredItem { + starKey: string + label: string + icon?: ReactNode + path: string + type: 'application' | 'route' | 'agent' | 'routestat' + parentApp?: string +} + +function collectStarredItems( + apps: SidebarApp[], + starredIds: Set, +): StarredItem[] { + const items: StarredItem[] = [] + + for (const app of apps) { + if (starredIds.has(`app:${app.id}`)) { + items.push({ + starKey: `app:${app.id}`, + label: app.name, + icon: , + path: `/apps/${app.id}`, + type: 'application', + }) + } + + for (const route of app.routes) { + if (starredIds.has(`route:${app.id}/${route.id}`)) { + items.push({ + starKey: `route:${app.id}/${route.id}`, + label: route.name, + icon: , + path: `/apps/${app.id}/${route.id}`, + type: 'route', + parentApp: app.name, + }) + } + } + + if (starredIds.has(`routestat:${app.id}`)) { + items.push({ + starKey: `routestat:${app.id}`, + label: app.name, + icon: , + path: `/routes/${app.id}`, + type: 'routestat', + }) + } + + if (starredIds.has(`agent:${app.id}`)) { + items.push({ + starKey: `agent:${app.id}`, + label: app.name, + icon: , + path: `/agents/${app.id}`, + type: 'agent', + }) + } + } + + return items +} + +// ── Starred group component ───────────────────────────────────────────────── + +interface StarredGroupProps { + label: string + items: StarredItem[] + onRemove: (starKey: string) => void + onNavigate: (path: string) => void +} + +function StarredGroup({ label, items, onRemove, onNavigate }: StarredGroupProps) { + if (items.length === 0) return null + + 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.icon} + + )} + + {item.label} + {item.parentApp && ( + + {item.parentApp} + + )} + + +
+ ))} +
+ ) +} + +// ── localStorage-backed section collapse ──────────────────────────────────── + +function usePersistedCollapse(key: string, defaultValue: boolean): [boolean, () => void] { + const [value, setValue] = useState(() => { + try { + const raw = localStorage.getItem(key) + if (raw !== null) return raw === 'true' + } catch { /* ignore */ } + return defaultValue + }) + + const toggle = () => { + setValue((prev) => { + const next = !prev + try { + localStorage.setItem(key, String(next)) + } catch { /* ignore */ } + return next + }) + } + + return [value, toggle] +} + +// ── LayoutShell ───────────────────────────────────────────────────────────── + +export function LayoutShell() { + const navigate = useNavigate() + const location = useLocation() + const { starredIds, isStarred, toggleStar } = useStarred() + + const [sidebarCollapsed, setSidebarCollapsed] = useState(false) + const [filterQuery, setFilterQuery] = useState('') + + // Section collapse state — persisted to localStorage + const [appsCollapsed, toggleAppsCollapsed] = usePersistedCollapse('cameleer:sidebar:apps-collapsed', false) + const [agentsCollapsed, toggleAgentsCollapsed] = usePersistedCollapse('cameleer:sidebar:agents-collapsed', false) + const [routesCollapsed, toggleRoutesCollapsed] = usePersistedCollapse('cameleer:sidebar:routes-collapsed', false) + + // Tree data — static, so empty deps + const appNodes = useMemo(() => buildAppTreeNodes(SIDEBAR_APPS), []) + const agentNodes = useMemo(() => buildAgentTreeNodes(SIDEBAR_APPS), []) + const routeNodes = useMemo(() => buildRouteTreeNodes(SIDEBAR_APPS), []) + + // Sidebar reveal from Cmd-K navigation + const sidebarRevealPath = (location.state as { sidebarReveal?: string } | null)?.sidebarReveal ?? null + + // Auto-uncollapse matching sections when sidebarRevealPath changes + useEffect(() => { + if (!sidebarRevealPath) return + + if (sidebarRevealPath.startsWith('/apps') && appsCollapsed) { + toggleAppsCollapsed() + } + if (sidebarRevealPath.startsWith('/agents') && agentsCollapsed) { + toggleAgentsCollapsed() + } + if (sidebarRevealPath.startsWith('/routes') && routesCollapsed) { + toggleRoutesCollapsed() + } + }, [sidebarRevealPath]) // eslint-disable-line react-hooks/exhaustive-deps + + const effectiveSelectedPath = sidebarRevealPath ?? location.pathname + + // Starred items — collected and grouped + const allStarred = useMemo( + () => collectStarredItems(SIDEBAR_APPS, starredIds), + [starredIds], + ) + + const starredApps = allStarred.filter((s) => s.type === 'application') + const starredRoutes = allStarred.filter((s) => s.type === 'route') + const starredAgents = allStarred.filter((s) => s.type === 'agent') + const starredRouteStats = allStarred.filter((s) => s.type === 'routestat') + const hasStarred = allStarred.length > 0 + + const camelLogo = ( + + ) + + return ( + setSidebarCollapsed((c) => !c)} + searchValue={filterQuery} + onSearchChange={setFilterQuery} + > + navigate('/apps')} + /> + + } + open={!appsCollapsed} + onToggle={toggleAppsCollapsed} + active={location.pathname.startsWith('/apps')} + > + + + + } + open={!agentsCollapsed} + onToggle={toggleAgentsCollapsed} + active={location.pathname.startsWith('/agents')} + > + + + + } + open={!routesCollapsed} + onToggle={toggleRoutesCollapsed} + active={location.pathname.startsWith('/routes')} + > + + + + {hasStarred && ( + } + open={true} + onToggle={() => {}} + active={false} + > + + + + + + )} + + + } + label="Admin" + onClick={() => navigate('/admin')} + active={location.pathname.startsWith('/admin')} + /> + } + label="API Docs" + onClick={() => navigate('/api-docs')} + active={location.pathname === '/api-docs'} + /> + + + } + > + + + ) +} diff --git a/src/pages/Admin/Admin.tsx b/src/pages/Admin/Admin.tsx index b048a43..99c6414 100644 --- a/src/pages/Admin/Admin.tsx +++ b/src/pages/Admin/Admin.tsx @@ -1,9 +1,6 @@ import { useNavigate, useLocation } from 'react-router-dom' -import { AppShell } from '../../design-system/layout/AppShell/AppShell' -import { Sidebar } from '../../design-system/layout/Sidebar/Sidebar' import { TopBar } from '../../design-system/layout/TopBar/TopBar' import { Tabs } from '../../design-system/composites/Tabs/Tabs' -import { SIDEBAR_APPS } from '../../mocks/sidebar' import styles from './Admin.module.css' import type { ReactNode } from 'react' @@ -23,7 +20,7 @@ export function AdminLayout({ title, children }: AdminLayoutProps) { const location = useLocation() return ( - }> + <> {children}
- + ) } diff --git a/src/pages/AgentHealth/AgentHealth.tsx b/src/pages/AgentHealth/AgentHealth.tsx index 1e0bf81..abcfc02 100644 --- a/src/pages/AgentHealth/AgentHealth.tsx +++ b/src/pages/AgentHealth/AgentHealth.tsx @@ -4,8 +4,6 @@ import { ChevronRight } from 'lucide-react' import styles from './AgentHealth.module.css' // Layout -import { AppShell } from '../../design-system/layout/AppShell/AppShell' -import { Sidebar } from '../../design-system/layout/Sidebar/Sidebar' import { TopBar } from '../../design-system/layout/TopBar/TopBar' // Composites @@ -28,7 +26,6 @@ import { useGlobalFilters } from '../../design-system/providers/GlobalFilterProv // Mock data import { agents, type AgentHealth as AgentHealthData } from '../../mocks/agents' -import { SIDEBAR_APPS } from '../../mocks/sidebar' import { agentEvents } from '../../mocks/agentEvents' // ── URL scope parsing ──────────────────────────────────────────────────────── @@ -317,19 +314,7 @@ export function AgentHealth() { const isFullWidth = scope.level !== 'all' return ( - } - detail={ - selectedInstance ? ( - setPanelOpen(false)} - title={selectedInstance.name} - tabs={detailTabs} - /> - ) : undefined - } - > + <> )} - + + {/* Detail panel (portals itself) */} + {selectedInstance && ( + setPanelOpen(false)} + title={selectedInstance.name} + tabs={detailTabs} + /> + )} + ) } diff --git a/src/pages/AgentInstance/AgentInstance.tsx b/src/pages/AgentInstance/AgentInstance.tsx index 002557e..2633fcd 100644 --- a/src/pages/AgentInstance/AgentInstance.tsx +++ b/src/pages/AgentInstance/AgentInstance.tsx @@ -4,8 +4,6 @@ import { ChevronRight } from 'lucide-react' import styles from './AgentInstance.module.css' // Layout -import { AppShell } from '../../design-system/layout/AppShell/AppShell' -import { Sidebar } from '../../design-system/layout/Sidebar/Sidebar' import { TopBar } from '../../design-system/layout/TopBar/TopBar' // Composites @@ -28,7 +26,6 @@ import { useGlobalFilters } from '../../design-system/providers/GlobalFilterProv // Data import { agents } from '../../mocks/agents' -import { SIDEBAR_APPS } from '../../mocks/sidebar' import { agentEvents } from '../../mocks/agentEvents' import { useState } from 'react' @@ -127,12 +124,12 @@ export function AgentInstance() { if (!agent) { return ( - }> + <>
Agent instance not found.
-
+ ) } @@ -153,7 +150,7 @@ export function AgentInstance() { const statusColor = agent.status === 'live' ? 'success' : agent.status === 'stale' ? 'warning' : 'error' return ( - }> + <> - + ) } diff --git a/src/pages/ApiDocs/ApiDocs.tsx b/src/pages/ApiDocs/ApiDocs.tsx index 9cbd739..71b54f0 100644 --- a/src/pages/ApiDocs/ApiDocs.tsx +++ b/src/pages/ApiDocs/ApiDocs.tsx @@ -1,12 +1,9 @@ -import { AppShell } from '../../design-system/layout/AppShell/AppShell' -import { Sidebar } from '../../design-system/layout/Sidebar/Sidebar' import { TopBar } from '../../design-system/layout/TopBar/TopBar' import { EmptyState } from '../../design-system/primitives/EmptyState/EmptyState' -import { SIDEBAR_APPS } from '../../mocks/sidebar' export function ApiDocs() { return ( - }> + <> - + ) } diff --git a/src/pages/AppDetail/AppDetail.tsx b/src/pages/AppDetail/AppDetail.tsx index 223394b..ebc6946 100644 --- a/src/pages/AppDetail/AppDetail.tsx +++ b/src/pages/AppDetail/AppDetail.tsx @@ -1,15 +1,12 @@ import { useParams } from 'react-router-dom' -import { AppShell } from '../../design-system/layout/AppShell/AppShell' -import { Sidebar } from '../../design-system/layout/Sidebar/Sidebar' import { TopBar } from '../../design-system/layout/TopBar/TopBar' import { EmptyState } from '../../design-system/primitives/EmptyState/EmptyState' -import { SIDEBAR_APPS } from '../../mocks/sidebar' export function AppDetail() { const { id } = useParams<{ id: string }>() return ( - }> + <> - + ) } diff --git a/src/pages/Dashboard/Dashboard.tsx b/src/pages/Dashboard/Dashboard.tsx index ed63d96..4e45016 100644 --- a/src/pages/Dashboard/Dashboard.tsx +++ b/src/pages/Dashboard/Dashboard.tsx @@ -4,8 +4,6 @@ import { TrendingUp, TrendingDown, ArrowRight, ExternalLink, AlertTriangle } fro import styles from './Dashboard.module.css' // Layout -import { AppShell } from '../../design-system/layout/AppShell/AppShell' -import { Sidebar } from '../../design-system/layout/Sidebar/Sidebar' import { TopBar } from '../../design-system/layout/TopBar/TopBar' // Composites @@ -287,106 +285,7 @@ export function Dashboard() { const totalErrors = processorErrors.length + (hasExchangeError && processorErrors.length === 0 ? 1 : 0) return ( - - } - detail={ - selectedExchange ? ( - setPanelOpen(false)} - title={`${selectedExchange.orderId} — ${selectedExchange.route}`} - > - {/* Link to full detail page */} -
- -
- - {/* Overview */} -
-
Overview
-
-
- Status - - - {statusLabel(selectedExchange.status)} - -
-
- Duration - {formatDuration(selectedExchange.durationMs)} -
-
- Route - {selectedExchange.route} -
-
- Customer - {selectedExchange.customer} -
-
- Agent - {selectedExchange.agent} -
-
- Correlation - {selectedExchange.correlationId} -
-
- Timestamp - {selectedExchange.timestamp.toISOString()} -
-
-
- - {/* Errors */} - {totalErrors > 0 && ( -
-
- Errors - {totalErrors > 1 && ( - - )} -
-
-
- {selectedExchange.errorClass ?? processorErrors[0]?.name} -
-
- {selectedExchange.errorMessage ?? `Failed at processor: ${processorErrors[0]?.name}`} -
-
-
- )} - - {/* Route Flow */} -
-
Route Flow
- -
- - {/* Processor Timeline */} -
-
- Processor Timeline - {formatDuration(selectedExchange.durationMs)} -
- -
-
- ) : undefined - } - > + <> {/* Top bar */} -
+ + {/* Detail panel (portals itself) */} + {selectedExchange && ( + setPanelOpen(false)} + title={`${selectedExchange.orderId} — ${selectedExchange.route}`} + > + {/* Link to full detail page */} +
+ +
+ + {/* Overview */} +
+
Overview
+
+
+ Status + + + {statusLabel(selectedExchange.status)} + +
+
+ Duration + {formatDuration(selectedExchange.durationMs)} +
+
+ Route + {selectedExchange.route} +
+
+ Customer + {selectedExchange.customer} +
+
+ Agent + {selectedExchange.agent} +
+
+ Correlation + {selectedExchange.correlationId} +
+
+ Timestamp + {selectedExchange.timestamp.toISOString()} +
+
+
+ + {/* Errors */} + {totalErrors > 0 && ( +
+
+ Errors + {totalErrors > 1 && ( + + )} +
+
+
+ {selectedExchange.errorClass ?? processorErrors[0]?.name} +
+
+ {selectedExchange.errorMessage ?? `Failed at processor: ${processorErrors[0]?.name}`} +
+
+
+ )} + + {/* Route Flow */} +
+
Route Flow
+ +
+ + {/* Processor Timeline */} +
+
+ Processor Timeline + {formatDuration(selectedExchange.durationMs)} +
+ +
+
+ )} + ) } diff --git a/src/pages/ExchangeDetail/ExchangeDetail.tsx b/src/pages/ExchangeDetail/ExchangeDetail.tsx index 7f687e2..48be210 100644 --- a/src/pages/ExchangeDetail/ExchangeDetail.tsx +++ b/src/pages/ExchangeDetail/ExchangeDetail.tsx @@ -3,8 +3,6 @@ import { useParams, useNavigate } from 'react-router-dom' import styles from './ExchangeDetail.module.css' // Layout -import { AppShell } from '../../design-system/layout/AppShell/AppShell' -import { Sidebar } from '../../design-system/layout/Sidebar/Sidebar' import { TopBar } from '../../design-system/layout/TopBar/TopBar' // Composites @@ -22,7 +20,7 @@ import { InfoCallout } from '../../design-system/primitives/InfoCallout/InfoCall // Mock data import { exchanges } from '../../mocks/exchanges' -import { SIDEBAR_APPS, buildRouteToAppMap } from '../../mocks/sidebar' +import { buildRouteToAppMap } from '../../mocks/sidebar' const ROUTE_TO_APP = buildRouteToAppMap() @@ -196,11 +194,7 @@ export function ExchangeDetail() { // Not found state if (!exchange) { return ( - - } - > + <> Exchange "{id}" not found in mock data. - + ) } @@ -229,11 +223,7 @@ export function ExchangeDetail() { const isSelectedFailed = selectedProc?.status === 'fail' return ( - - } - > + <> {/* Top bar */} - + ) } diff --git a/src/pages/Inventory/sections/LayoutSection.tsx b/src/pages/Inventory/sections/LayoutSection.tsx index 76481ab..2a16e60 100644 --- a/src/pages/Inventory/sections/LayoutSection.tsx +++ b/src/pages/Inventory/sections/LayoutSection.tsx @@ -1,6 +1,9 @@ import styles from './LayoutSection.module.css' import { Sidebar } from '../../../design-system/layout/Sidebar/Sidebar' -import type { SidebarApp } from '../../../design-system/layout/Sidebar/Sidebar' +import { SidebarTree } from '../../../design-system/layout/Sidebar/SidebarTree' +import type { SidebarTreeNode } from '../../../design-system/layout/Sidebar/SidebarTree' +import { StatusDot } from '../../../design-system/primitives/StatusDot/StatusDot' +import { Box, Settings, FileText, ChevronRight } from 'lucide-react' import { TopBar } from '../../../design-system/layout/TopBar/TopBar' // ── DemoCard helper ────────────────────────────────────────────────────────── @@ -22,42 +25,42 @@ function DemoCard({ id, title, description, children }: DemoCardProps) { ) } -// ── Sample data (hierarchical) ─────────────────────────────────────────────── +// ── Sample tree nodes ──────────────────────────────────────────────────────── -const SAMPLE_APPS: SidebarApp[] = [ +const SAMPLE_APP_NODES: SidebarTreeNode[] = [ { id: 'app1', - name: 'cameleer-prod', - health: 'live' as const, - exchangeCount: 14320, - routes: [ - { id: 'r1', name: 'order-ingest', exchangeCount: 5421 }, - { id: 'r2', name: 'payment-validate', exchangeCount: 3102 }, - ], - agents: [ - { id: 'ag1', name: 'agent-prod-1', status: 'live' as const, tps: 42 }, - { id: 'ag2', name: 'agent-prod-2', status: 'live' as const, tps: 38 }, + label: 'cameleer-prod', + icon: , + badge: '14.3k', + path: '/apps/app1', + starrable: true, + starKey: 'app:app1', + children: [ + { id: 'app1/r1', label: 'order-ingest', icon: , badge: '5,421', path: '/apps/app1/r1' }, + { id: 'app1/r2', label: 'payment-validate', icon: , badge: '3,102', path: '/apps/app1/r2' }, ], }, { id: 'app2', - name: 'cameleer-staging', - health: 'stale' as const, - exchangeCount: 871, - routes: [ - { id: 'r3', name: 'notify-customer', exchangeCount: 2201 }, - ], - agents: [ - { id: 'ag3', name: 'agent-staging-1', status: 'stale' as const, tps: 5 }, + label: 'cameleer-staging', + icon: , + badge: '871', + path: '/apps/app2', + starrable: true, + starKey: 'app:app2', + children: [ + { id: 'app2/r3', label: 'notify-customer', icon: , badge: '2,201', path: '/apps/app2/r3' }, ], }, { id: 'app3', - name: 'cameleer-dev', - health: 'dead' as const, - exchangeCount: 42, - routes: [], - agents: [], + label: 'cameleer-dev', + icon: , + badge: '42', + path: '/apps/app3', + starrable: true, + starKey: 'app:app3', }, ] @@ -99,10 +102,19 @@ export function LayoutSection() {
- + + 🐪} title="cameleer" version="v3.2.1" /> + } open={true} onToggle={() => {}} active={false}> + false} onToggleStar={() => {}} /> + + + } label="Admin" /> + } label="API Docs" /> + +
diff --git a/src/pages/RouteDetail/RouteDetail.tsx b/src/pages/RouteDetail/RouteDetail.tsx index 7c11239..e29a37f 100644 --- a/src/pages/RouteDetail/RouteDetail.tsx +++ b/src/pages/RouteDetail/RouteDetail.tsx @@ -4,8 +4,6 @@ import { AlertTriangle } from 'lucide-react' import styles from './RouteDetail.module.css' // Layout -import { AppShell } from '../../design-system/layout/AppShell/AppShell' -import { Sidebar } from '../../design-system/layout/Sidebar/Sidebar' import { TopBar } from '../../design-system/layout/TopBar/TopBar' // Composites @@ -22,7 +20,6 @@ import { InfoCallout } from '../../design-system/primitives/InfoCallout/InfoCall // Mock data import { routes } from '../../mocks/routes' import { exchanges, type Exchange } from '../../mocks/exchanges' -import { SIDEBAR_APPS } from '../../mocks/sidebar' // ─── Helpers ────────────────────────────────────────────────────────────────── function formatDuration(ms: number): string { @@ -199,11 +196,7 @@ export function RouteDetail() { // Not found state if (!route) { return ( - - } - > + <>
Route "{id}" not found in mock data.
-
+ ) } const statusVariant = routeStatusVariant(route.status) return ( - - } - > + <> {/* Top bar */} - + ) } diff --git a/src/pages/Routes/Routes.tsx b/src/pages/Routes/Routes.tsx index b49165b..9a31588 100644 --- a/src/pages/Routes/Routes.tsx +++ b/src/pages/Routes/Routes.tsx @@ -3,8 +3,6 @@ import { useNavigate, useParams } from 'react-router-dom' import styles from './Routes.module.css' // Layout -import { AppShell } from '../../design-system/layout/AppShell/AppShell' -import { Sidebar } from '../../design-system/layout/Sidebar/Sidebar' import { TopBar } from '../../design-system/layout/TopBar/TopBar' // Composites @@ -33,7 +31,7 @@ import { type RouteMetricRow, } from '../../mocks/metrics' import { routes } from '../../mocks/routes' -import { SIDEBAR_APPS, buildRouteToAppMap } from '../../mocks/sidebar' +import { buildRouteToAppMap } from '../../mocks/sidebar' const ROUTE_TO_APP = buildRouteToAppMap() @@ -410,7 +408,7 @@ export function Routes() { // ── Route detail view ─────────────────────────────────────────────────────── if (routeId && appId && routeDef) { return ( - }> + <> - + ) } // ── Top level / Application level view ────────────────────────────────────── return ( - }> + <> - + ) }