From 7092271fdcb96388d48562a7504c9c3ee366b3b5 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Thu, 2 Apr 2026 17:53:49 +0200 Subject: [PATCH 01/11] feat(sidebar): add SidebarContext for composable sidebar Create context and hook to share collapsed state and toggle callback between compound component sub-components. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/design-system/layout/Sidebar/SidebarContext.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 src/design-system/layout/Sidebar/SidebarContext.ts 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) +} 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 02/11] 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, +}) From 357e4972208e8a326b84f230d172722948240ba7 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Thu, 2 Apr 2026 17:55:20 +0200 Subject: [PATCH 03/11] feat(sidebar): update CSS for composable compound component Add collapsed state styles (sidebarCollapsed, collapseToggle), icon-rail mode (sectionRailItem, sectionIcon), and width transition. Remove old monolithic classes (navArea, section, items, item, navIcon, routeArrow, all starred-section styles). Pin footer with margin-top: auto. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../layout/Sidebar/Sidebar.module.css | 188 +++++------------- 1 file changed, 53 insertions(+), 135 deletions(-) diff --git a/src/design-system/layout/Sidebar/Sidebar.module.css b/src/design-system/layout/Sidebar/Sidebar.module.css index c95aab1..66f12f3 100644 --- a/src/design-system/layout/Sidebar/Sidebar.module.css +++ b/src/design-system/layout/Sidebar/Sidebar.module.css @@ -5,6 +5,36 @@ display: flex; flex-direction: column; overflow: hidden; + position: relative; + transition: width 200ms ease; +} + +.sidebarCollapsed { + width: 48px; +} + +/* Collapse toggle */ +.collapseToggle { + position: absolute; + top: 8px; + right: 4px; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + background: none; + border: none; + color: var(--sidebar-muted); + cursor: pointer; + border-radius: var(--radius-sm); + padding: 0; + z-index: 1; + transition: color 0.12s; +} + +.collapseToggle:hover { + color: var(--sidebar-text); } /* Logo */ @@ -15,6 +45,12 @@ gap: 10px; border-bottom: 1px solid rgba(255, 255, 255, 0.06); flex-shrink: 0; + overflow: hidden; +} + +.sidebarCollapsed .logo { + padding: 16px 0; + justify-content: center; } .logoImg { @@ -106,71 +142,40 @@ background: rgba(255, 255, 255, 0.08); } -/* Scrollable nav area */ -.navArea { - flex: 1; - overflow-y: auto; - min-height: 0; -} - -/* Section headers */ -.section { - padding: 14px 12px 5px; - font-size: 10px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 1.2px; +/* Section icon (collapsed rail) */ +.sectionIcon { + display: flex; + align-items: center; + justify-content: center; + width: 16px; color: var(--sidebar-muted); } -/* Items container */ -.items { - padding: 0 6px; -} - -/* Nav item (flat links like Dashboards) */ -.item { +/* Rail item (collapsed sidebar section) */ +.sectionRailItem { display: flex; align-items: center; - gap: 10px; - padding: 7px 12px; - border-radius: var(--radius-sm); - color: var(--sidebar-text); - font-size: 13px; + justify-content: center; + padding: 10px 0; cursor: pointer; - transition: all 0.12s; border-left: 3px solid transparent; - margin-bottom: 1px; - user-select: none; + transition: background 0.12s; } -.item:hover { +.sectionRailItem:hover { background: var(--sidebar-hover); - color: #e8dfd4; } -.item.active { - background: var(--sidebar-active); - color: var(--amber); +.sectionRailItemActive { border-left-color: var(--amber); } -.navIcon { - font-size: 14px; - width: 18px; - text-align: center; - color: var(--sidebar-muted); - flex-shrink: 0; -} - -.item.active .navIcon { +.sectionRailItemActive .sectionIcon { color: var(--amber); } -.routeArrow { - color: var(--sidebar-muted); - font-size: 10px; - flex-shrink: 0; +.treeSectionActive { + border-left-color: var(--amber); } /* Item sub-elements */ @@ -383,100 +388,13 @@ color: var(--amber); } -/* ── Starred section ─────────────────────────────────────────────────────── */ - -.starredSection { - border-top: 1px solid rgba(255, 255, 255, 0.06); - margin-top: 4px; -} - -.starredHeader { - color: var(--amber); -} - -.starredList { - padding: 0 6px 6px; -} - -.starredGroup { - margin-bottom: 4px; -} - -.starredGroupLabel { - padding: 4px 12px 2px; - font-size: 10px; - color: var(--sidebar-muted); - font-weight: 500; -} - -.starredItem { - display: flex; - align-items: center; - gap: 8px; - padding: 4px 12px; - border-radius: var(--radius-sm); - color: var(--sidebar-text); - font-size: 12px; - cursor: pointer; - transition: background 0.12s; - user-select: none; -} - -.starredItem:hover { - background: var(--sidebar-hover); -} - -.starredItemInfo { - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; -} - -.starredItemName { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - font-weight: 500; -} - -.starredItemContext { - font-size: 10px; - color: var(--sidebar-muted); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -/* Remove button */ -.starredRemove { - background: none; - border: none; - padding: 2px; - margin: 0; - color: var(--sidebar-muted); - cursor: pointer; - opacity: 0; - transition: opacity 0.15s, color 0.15s; - display: flex; - align-items: center; - flex-shrink: 0; -} - -.starredItem:hover .starredRemove { - opacity: 1; -} - -.starredRemove:hover { - color: var(--error); -} - /* ── Bottom links ────────────────────────────────────────────────────────── */ .bottom { border-top: 1px solid rgba(255, 255, 255, 0.06); padding: 6px; flex-shrink: 0; + margin-top: auto; } .bottomItem { From d2c2b921836b9dd8b087ca5bc5b9b90dacea1a76 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Thu, 2 Apr 2026 17:55:29 +0200 Subject: [PATCH 04/11] feat(sidebar): update barrel exports for composable sidebar Export SidebarTree, SidebarTreeNode, and useStarred from the layout barrel. Remove old app-domain type exports (SidebarApp, SidebarRoute, SidebarAgent) that no longer exist in the rewritten Sidebar. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/design-system/layout/index.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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' From c401516b2d71c61cd7c0f23fb71aa70968b5da9f Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Thu, 2 Apr 2026 17:59:25 +0200 Subject: [PATCH 05/11] fix(sidebar): add icon to expanded section, fix icon-rail callbacks, fix active border MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Render icon between chevron and label in expanded SidebarSection - Remove !open guard from icon-rail click — always fire both callbacks - Add border-left: 3px solid transparent to .treeSection so .treeSectionActive border-left-color takes effect - Remove duplicate .treeSectionLabel CSS declaration Co-Authored-By: Claude Opus 4.6 (1M context) --- src/design-system/layout/Sidebar/Sidebar.module.css | 10 +--------- src/design-system/layout/Sidebar/Sidebar.tsx | 5 +++-- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/src/design-system/layout/Sidebar/Sidebar.module.css b/src/design-system/layout/Sidebar/Sidebar.module.css index 66f12f3..12c5bef 100644 --- a/src/design-system/layout/Sidebar/Sidebar.module.css +++ b/src/design-system/layout/Sidebar/Sidebar.module.css @@ -205,15 +205,7 @@ padding: 0 6px 6px; margin-bottom: 2px; border-bottom: 1px solid rgba(255, 255, 255, 0.12); -} - -.treeSectionLabel { - padding: 10px 12px 4px; - font-size: 10px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 1px; - color: var(--sidebar-muted); + border-left: 3px solid transparent; } /* Collapsible section toggle */ diff --git a/src/design-system/layout/Sidebar/Sidebar.tsx b/src/design-system/layout/Sidebar/Sidebar.tsx index ee38234..e9e1089 100644 --- a/src/design-system/layout/Sidebar/Sidebar.tsx +++ b/src/design-system/layout/Sidebar/Sidebar.tsx @@ -97,14 +97,14 @@ function SidebarSection({ onClick={() => { // Expand sidebar and open the section onCollapseToggle?.() - if (!open) onToggle() + onToggle() }} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { onCollapseToggle?.() - if (!open) onToggle() + onToggle() } }} > @@ -124,6 +124,7 @@ function SidebarSection({ > {open ? : } + {icon && {icon}} {label}
{open && children} From 5a91875723722cabbff0a4980ee8407bf2fae9fa Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Thu, 2 Apr 2026 18:01:39 +0200 Subject: [PATCH 06/11] test(sidebar): rewrite Sidebar tests for compound component API Replace legacy monolithic Sidebar test suite with 16 tests covering the new compound component API (Sidebar.Header, Section, Footer, FooterLink) including icon-rail collapsed mode, search input visibility, and active state highlighting. Co-Authored-By: Claude Sonnet 4.6 --- .../layout/Sidebar/Sidebar.test.tsx | 415 ++++++++++++------ 1 file changed, 285 insertions(+), 130 deletions(-) diff --git a/src/design-system/layout/Sidebar/Sidebar.test.tsx b/src/design-system/layout/Sidebar/Sidebar.test.tsx index ecaf6ed..21c8f52 100644 --- a/src/design-system/layout/Sidebar/Sidebar.test.tsx +++ b/src/design-system/layout/Sidebar/Sidebar.test.tsx @@ -1,172 +1,327 @@ -import { describe, it, expect, beforeEach } from 'vitest' +import { describe, it, expect, vi } from 'vitest' import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { MemoryRouter } from 'react-router-dom' -import { Sidebar, type SidebarApp } from './Sidebar' +import { Sidebar } from './Sidebar' import { ThemeProvider } from '../../providers/ThemeProvider' -const TEST_APPS: SidebarApp[] = [ - { - id: 'order-service', - name: 'order-service', - health: 'live', - exchangeCount: 1433, - routes: [ - { id: 'order-intake', name: 'order-intake', exchangeCount: 892 }, - { id: 'order-enrichment', name: 'order-enrichment', exchangeCount: 541 }, - ], - agents: [ - { id: 'prod-1', name: 'prod-1', status: 'live', tps: 14.2 }, - { id: 'prod-2', name: 'prod-2', status: 'live', tps: 11.8 }, - ], - }, - { - id: 'payment-svc', - name: 'payment-svc', - health: 'live', - exchangeCount: 912, - routes: [ - { id: 'payment-process', name: 'payment-process', exchangeCount: 414 }, - ], - agents: [], - }, -] +// ── Helpers ───────────────────────────────────────────────────────────────── -function renderSidebar(props: Partial[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/) }) }) From 36999941c07461477d81e4c96566baff2251d881 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Thu, 2 Apr 2026 18:04:08 +0200 Subject: [PATCH 07/11] feat(layout): create LayoutShell with compound Sidebar composition Move all application-specific sidebar logic (tree builders, starred items, section collapse state, sidebarReveal handling) out of the DS Sidebar into a shared LayoutShell that wraps Outlet for route-level layout sharing. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/layout/LayoutShell.tsx | 451 +++++++++++++++++++++++++++++++++++++ 1 file changed, 451 insertions(+) create mode 100644 src/layout/LayoutShell.tsx 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'} + /> + + + } + > + + + ) +} From 8cd3c3a99df9f4e5f57f44ae0ae3796c33e80336 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Thu, 2 Apr 2026 18:05:49 +0200 Subject: [PATCH 08/11] refactor: wrap routes in LayoutShell layout route Co-Authored-By: Claude Opus 4.6 (1M context) --- src/App.tsx | 35 +++++++++++++++++++---------------- 1 file changed, 19 insertions(+), 16 deletions(-) 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 ( <> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> Date: Thu, 2 Apr 2026 18:09:16 +0200 Subject: [PATCH 09/11] refactor: strip AppShell+Sidebar wrappers from all page components Co-Authored-By: Claude Opus 4.6 (1M context) --- src/pages/Admin/Admin.tsx | 7 +- src/pages/AgentHealth/AgentHealth.tsx | 29 ++- src/pages/AgentInstance/AgentInstance.tsx | 11 +- src/pages/ApiDocs/ApiDocs.tsx | 7 +- src/pages/AppDetail/AppDetail.tsx | 7 +- src/pages/Dashboard/Dashboard.tsx | 200 ++++++++++---------- src/pages/ExchangeDetail/ExchangeDetail.tsx | 20 +- src/pages/Routes/Routes.tsx | 12 +- 8 files changed, 129 insertions(+), 164 deletions(-) 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/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 ( - }> + <> - + ) } From 18bf644040812edfe0746992a0f29e17b78891c7 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Thu, 2 Apr 2026 18:10:09 +0200 Subject: [PATCH 10/11] refactor(inventory): update Sidebar demo to compound API Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Inventory/sections/LayoutSection.tsx | 68 +++++++++++-------- 1 file changed, 40 insertions(+), 28 deletions(-) diff --git a/src/pages/Inventory/sections/LayoutSection.tsx b/src/pages/Inventory/sections/LayoutSection.tsx index 76481ab..a5996a9 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" /> + +
From eeb2713612cd2bd0b9a5bc0d13a99fdcaa142fc0 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Thu, 2 Apr 2026 18:13:03 +0200 Subject: [PATCH 11/11] fix: strip Sidebar wrapper from RouteDetail + fix StatusDot prop in LayoutSection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RouteDetail.tsx was missed in page stripping pass — remove AppShell + Sidebar wrapper, replace with fragment - LayoutSection.tsx used StatusDot status= instead of variant= Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Inventory/sections/LayoutSection.tsx | 6 +++--- src/pages/RouteDetail/RouteDetail.tsx | 20 ++++--------------- 2 files changed, 7 insertions(+), 19 deletions(-) diff --git a/src/pages/Inventory/sections/LayoutSection.tsx b/src/pages/Inventory/sections/LayoutSection.tsx index a5996a9..2a16e60 100644 --- a/src/pages/Inventory/sections/LayoutSection.tsx +++ b/src/pages/Inventory/sections/LayoutSection.tsx @@ -31,7 +31,7 @@ const SAMPLE_APP_NODES: SidebarTreeNode[] = [ { id: 'app1', label: 'cameleer-prod', - icon: , + icon: , badge: '14.3k', path: '/apps/app1', starrable: true, @@ -44,7 +44,7 @@ const SAMPLE_APP_NODES: SidebarTreeNode[] = [ { id: 'app2', label: 'cameleer-staging', - icon: , + icon: , badge: '871', path: '/apps/app2', starrable: true, @@ -56,7 +56,7 @@ const SAMPLE_APP_NODES: SidebarTreeNode[] = [ { id: 'app3', label: 'cameleer-dev', - icon: , + icon: , badge: '42', path: '/apps/app3', starrable: true, 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 */} - + ) }