# Composable Sidebar Refactor — Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Replace the monolithic `Sidebar` component with a composable compound component (`Sidebar`, `Sidebar.Header`, `Sidebar.Section`, `Sidebar.Footer`, `Sidebar.FooterLink`), export `SidebarTree` and `useStarred` publicly, and migrate the mock app to a single `LayoutShell` using the new API. **Architecture:** The DS `Sidebar` becomes a composable shell providing frame, search input, and collapse toggle via React context. Application-specific logic (tree building, starred grouping, section ordering) moves into `LayoutShell.tsx` in the mock app. React Router layout routes replace per-page `` wrappers. **Tech Stack:** React 18, TypeScript, CSS Modules, React Router v6 (layout routes + ``), Vitest + React Testing Library **Spec:** `docs/superpowers/specs/2026-04-02-composable-sidebar-design.md` --- ## File Structure ### Design System (create/modify) | File | Action | Purpose | |------|--------|---------| | `src/design-system/layout/Sidebar/SidebarContext.ts` | Create | React context for `collapsed` + `onCollapseToggle` | | `src/design-system/layout/Sidebar/Sidebar.tsx` | Rewrite | Compound component shell with sub-components | | `src/design-system/layout/Sidebar/Sidebar.module.css` | Modify | Remove app-specific styles, add section/footer/collapsed/tooltip styles | | `src/design-system/layout/Sidebar/SidebarTree.tsx` | No change | Already data-driven, just newly exported | | `src/design-system/layout/Sidebar/useStarred.ts` | No change | Already standalone, just newly exported | | `src/design-system/layout/Sidebar/Sidebar.test.tsx` | Rewrite | Tests for compound component API | | `src/design-system/layout/index.ts` | Modify | Export `SidebarTree`, `SidebarTreeNode`, `useStarred`; remove `SidebarApp`/`SidebarRoute`/`SidebarAgent` | ### Mock App (create/modify) | File | Action | Purpose | |------|--------|---------| | `src/layout/LayoutShell.tsx` | Create | Single sidebar composition with compound API + `` | | `src/App.tsx` | Modify | Layout route wrapping all pages | | `src/pages/Dashboard/Dashboard.tsx` | Modify | Remove `` wrapper | | `src/pages/Routes/Routes.tsx` | Modify | Remove `` wrapper (two return sites) | | `src/pages/ExchangeDetail/ExchangeDetail.tsx` | Modify | Remove `` wrapper (two return sites) | | `src/pages/AgentHealth/AgentHealth.tsx` | Modify | Remove `` wrapper | | `src/pages/AgentInstance/AgentInstance.tsx` | Modify | Remove `` wrapper (two return sites) | | `src/pages/Admin/Admin.tsx` | Modify | `AdminLayout` drops ``, becomes content-only | | `src/pages/ApiDocs/ApiDocs.tsx` | Modify | Remove `` wrapper | | `src/pages/AppDetail/AppDetail.tsx` | Modify | Remove `` wrapper | | `src/pages/Inventory/sections/LayoutSection.tsx` | Modify | Update Sidebar demo to use compound API | --- ## Task 1: SidebarContext **Files:** - Create: `src/design-system/layout/Sidebar/SidebarContext.ts` - [ ] **Step 1: Create the context file** ```ts // src/design-system/layout/Sidebar/SidebarContext.ts 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) } ``` - [ ] **Step 2: Commit** ```bash git add src/design-system/layout/Sidebar/SidebarContext.ts git commit -m "feat(sidebar): add SidebarContext for collapsed state" ``` --- ## Task 2: Rewrite Sidebar as compound component **Files:** - Rewrite: `src/design-system/layout/Sidebar/Sidebar.tsx` This replaces the entire file. The old monolithic component (~560 lines) becomes ~150 lines of compound component shell. All application-specific code (type definitions, tree builders, starred logic, hardcoded sections) is deleted. - [ ] **Step 1: Replace Sidebar.tsx with compound component** ```tsx // src/design-system/layout/Sidebar/Sidebar.tsx import { type ReactNode } from 'react' import { Search, X, ChevronsLeft, ChevronsRight, ChevronRight, ChevronDown } from 'lucide-react' import styles from './Sidebar.module.css' import { SidebarContext, useSidebarContext } from './SidebarContext' // ── Sidebar (shell) ───────────────────────────────────────────────────────── interface SidebarProps { collapsed?: boolean onCollapseToggle?: () => void searchValue?: string onSearchChange?: (query: string) => void children: ReactNode className?: string } function SidebarRoot({ collapsed = false, onCollapseToggle, searchValue = '', onSearchChange, children, className, }: SidebarProps) { const showSearch = onSearchChange != null && !collapsed return ( ) } // ── Sidebar.Header ────────────────────────────────��───────────────────────── interface SidebarHeaderProps { logo: ReactNode title: string version?: string onClick?: () => void } function SidebarHeader({ logo, title, version, onClick }: SidebarHeaderProps) { const { collapsed } = useSidebarContext() return (
{logo} {!collapsed && (
{title} {version && {version}}
)}
) } // ── Sidebar.Section ───────────────────────────────────────────────────────── interface SidebarSectionProps { label: string icon?: ReactNode collapsed?: boolean onToggle?: () => void active?: boolean children?: ReactNode } function SidebarSection({ label, icon, collapsed: sectionCollapsed = false, onToggle, active = false, children, }: SidebarSectionProps) { const { collapsed: sidebarCollapsed, onCollapseToggle } = useSidebarContext() // Icon-rail mode: render centered icon with tooltip if (sidebarCollapsed) { return (
{ onCollapseToggle?.() onToggle?.() }} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { onCollapseToggle?.() onToggle?.() } }} title={label} > {icon && {icon}}
) } // Expanded mode: accordion section return (
{onToggle && ( )} {icon && {icon}} { if (e.key === 'Enter' || e.key === ' ') onToggle?.() }} > {label}
{!sectionCollapsed && children}
) } // ── Sidebar.Footer ────────────────────────────────────────────────────────── interface SidebarFooterProps { children: ReactNode } function SidebarFooter({ children }: SidebarFooterProps) { return
{children}
} // ── Sidebar.FooterLink ────────────────────────────────────────────────────── interface SidebarFooterLinkProps { icon: ReactNode label: string onClick?: () => void active?: boolean } function SidebarFooterLink({ icon, label, onClick, active = false }: SidebarFooterLinkProps) { const { collapsed } = useSidebarContext() return (
{ if (e.key === 'Enter' || e.key === ' ') onClick?.() }} title={collapsed ? label : undefined} > {icon} {!collapsed && (
{label}
)}
) } // ── Compound export ───────────────────────────────────────────────────────���─ export const Sidebar = Object.assign(SidebarRoot, { Header: SidebarHeader, Section: SidebarSection, Footer: SidebarFooter, FooterLink: SidebarFooterLink, }) ``` - [ ] **Step 2: Verify TypeScript compiles** Run: `npx tsc --noEmit 2>&1 | head -30` This will have errors because consumers still use the old API. That's expected — we fix them in later tasks. The Sidebar file itself should be error-free. - [ ] **Step 3: Commit** ```bash git add src/design-system/layout/Sidebar/Sidebar.tsx git commit -m "feat(sidebar): rewrite as compound component Replaces monolithic Sidebar with composable API: Sidebar, Sidebar.Header, Sidebar.Section, Sidebar.Footer, Sidebar.FooterLink. Application-specific logic removed." ``` --- ## Task 3: Update Sidebar.module.css **Files:** - Modify: `src/design-system/layout/Sidebar/Sidebar.module.css` Remove application-specific styles (starred section, hardcoded section headers). Add collapsed/icon-rail styles, section icon slot, and tooltip support. Keep search, tree, and bottom link styles. - [ ] **Step 1: Replace the CSS file contents** The file keeps: `.sidebar`, `.logo*`, `.brand`, `.version`, `.searchWrap`, `.searchInput`, `.searchIcon`, `.searchClear`, all `.tree*` styles, `.bottom`, `.bottomItem`, `.bottomItemActive`, `.bottomIcon`, `.itemInfo`, `.itemName`, `.noResults`. Remove `.navArea` (no longer used — children render directly in the sidebar flex column). Delete: `.section` (old header), `.starredSection`, `.starredHeader`, `.starredList`, `.starredGroup`, `.starredGroupLabel`, `.starredItem`, `.starredItemInfo`, `.starredItemName`, `.starredItemContext`, `.starredRemove`, `.items`, `.item`, `.item.active`, `.navIcon`, `.routeArrow`. Add the following new styles **after the existing `.navArea` block** (around line 114): ```css /* ── Collapsed sidebar (icon rail) ──────────────────────────���───────────── */ .sidebarCollapsed { width: 48px; } .sidebar { transition: width 200ms ease; } /* Collapse toggle button */ .collapseToggle { position: absolute; top: 12px; right: 8px; z-index: 1; background: none; border: none; padding: 4px; margin: 0; color: var(--sidebar-muted); cursor: pointer; display: flex; align-items: center; justify-content: center; border-radius: var(--radius-sm); transition: color 0.12s, background 0.12s; } .collapseToggle:hover { color: var(--sidebar-text); background: rgba(255, 255, 255, 0.08); } /* ── Section icon (in expanded header and rail mode) ─────────────────��──── */ .sectionIcon { display: flex; align-items: center; justify-content: center; flex-shrink: 0; color: var(--sidebar-muted); width: 16px; } /* ── Icon-rail section item ───────────────────────────���─────────────────── */ .sectionRailItem { display: flex; align-items: center; justify-content: center; padding: 10px 0; cursor: pointer; border-left: 3px solid transparent; transition: background 0.12s; } .sectionRailItem:hover { background: var(--sidebar-hover); } .sectionRailItemActive { border-left-color: var(--amber); } .sectionRailItemActive .sectionIcon { color: var(--amber); } /* ── Active section (expanded mode) ────────────────────────���────────────── */ .treeSectionActive { border-left-color: var(--amber); } ``` Also modify the existing `.sidebar` rule to add `position: relative` and `overflow-y` (children are rendered directly in the flex column; sections scroll naturally): Change line 1-8 from: ```css .sidebar { width: 260px; flex-shrink: 0; background: var(--sidebar-bg); display: flex; flex-direction: column; overflow: hidden; } ``` to: ```css .sidebar { width: 260px; flex-shrink: 0; background: var(--sidebar-bg); display: flex; flex-direction: column; overflow: hidden; overflow-y: auto; position: relative; transition: width 200ms ease; } ``` Update `.bottom` to pin to the bottom of the sidebar: ```css .bottom { border-top: 1px solid rgba(255, 255, 255, 0.06); padding: 6px; flex-shrink: 0; margin-top: auto; } ``` And modify `.logo` to handle collapsed centering — change padding from `padding: 16px 18px;` to: ```css .logo { padding: 16px 18px; display: flex; align-items: center; 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; } ``` Remove the following class blocks entirely (starred section, old flat items, old section header): - `.section` (lines 117-124) - `.items` (lines 127-129) - `.item` through `.routeArrow` (lines 132-174) - `.starredSection` through `.starredRemove:hover` (lines 386-472) - [ ] **Step 2: Commit** ```bash git add src/design-system/layout/Sidebar/Sidebar.module.css git commit -m "style(sidebar): update CSS for compound component + collapsed mode" ``` --- ## Task 4: Update layout barrel exports **Files:** - Modify: `src/design-system/layout/index.ts` - [ ] **Step 1: Replace the layout index** ```ts // src/design-system/layout/index.ts export { AppShell } from './AppShell/AppShell' export { Sidebar } from './Sidebar/Sidebar' export { SidebarTree } from './Sidebar/SidebarTree' export type { SidebarTreeNode } from './Sidebar/SidebarTree' export { useStarred } from './Sidebar/useStarred' export { TopBar } from './TopBar/TopBar' ``` Note: `SidebarApp`, `SidebarRoute`, `SidebarAgent` type exports are intentionally removed — they are application-domain types. - [ ] **Step 2: Commit** ```bash git add src/design-system/layout/index.ts git commit -m "feat(sidebar): export SidebarTree, SidebarTreeNode, useStarred from layout barrel" ``` --- ## Task 5: Rewrite Sidebar tests **Files:** - Rewrite: `src/design-system/layout/Sidebar/Sidebar.test.tsx` - [ ] **Step 1: Replace test file with compound component tests** ```tsx // src/design-system/layout/Sidebar/Sidebar.test.tsx 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 } from './Sidebar' import { ThemeProvider } from '../../providers/ThemeProvider' function renderSidebar(ui: React.ReactElement) { return render( {ui} , ) } describe('Sidebar compound component', () => { it('renders Header with logo, title, and version', () => { renderSidebar( 🐪} title="cameleer" version="v1.0" /> , ) expect(screen.getByTestId('logo')).toBeInTheDocument() expect(screen.getByText('cameleer')).toBeInTheDocument() expect(screen.getByText('v1.0')).toBeInTheDocument() }) it('hides Header title and version when collapsed', () => { renderSidebar( 🐪} title="cameleer" version="v1.0" /> , ) expect(screen.getByTestId('logo')).toBeInTheDocument() expect(screen.queryByText('cameleer')).not.toBeInTheDocument() expect(screen.queryByText('v1.0')).not.toBeInTheDocument() }) it('renders Section with label', () => { renderSidebar(
section content
, ) expect(screen.getByText('Applications')).toBeInTheDocument() expect(screen.getByText('section content')).toBeInTheDocument() }) it('hides Section children when section is collapsed', () => { renderSidebar(
hidden content
, ) expect(screen.getByText('Applications')).toBeInTheDocument() expect(screen.queryByText('hidden content')).not.toBeInTheDocument() }) it('calls onToggle when Section header is clicked', async () => { const onToggle = vi.fn() const user = userEvent.setup() renderSidebar(
content
, ) await user.click(screen.getByText('Agents')) expect(onToggle).toHaveBeenCalledOnce() }) it('renders collapse toggle and calls onCollapseToggle', async () => { const onCollapseToggle = vi.fn() const user = userEvent.setup() renderSidebar( 🐪} title="test" /> , ) const toggle = screen.getByLabelText('Collapse sidebar') await user.click(toggle) expect(onCollapseToggle).toHaveBeenCalledOnce() }) it('renders expand toggle label when collapsed', () => { renderSidebar( {}}> 🐪} title="test" /> , ) expect(screen.getByLabelText('Expand sidebar')).toBeInTheDocument() }) it('renders search input and calls onSearchChange', async () => { const onSearchChange = vi.fn() const user = userEvent.setup() renderSidebar( 🐪} title="test" /> , ) const input = screen.getByPlaceholderText('Filter...') await user.type(input, 'a') expect(onSearchChange).toHaveBeenCalledWith('a') }) it('hides search input when sidebar is collapsed', () => { renderSidebar( {}}> 🐪} title="test" /> , ) expect(screen.queryByPlaceholderText('Filter...')).not.toBeInTheDocument() }) it('hides search when onSearchChange is not provided', () => { renderSidebar( 🐪} title="test" /> , ) expect(screen.queryByPlaceholderText('Filter...')).not.toBeInTheDocument() }) it('renders FooterLinks with icons and labels', () => { renderSidebar( ⚙} label="Admin" /> 📄} label="API Docs" /> , ) expect(screen.getByText('Admin')).toBeInTheDocument() expect(screen.getByText('API Docs')).toBeInTheDocument() expect(screen.getByTestId('admin-icon')).toBeInTheDocument() }) it('hides FooterLink labels when collapsed and sets title', () => { renderSidebar( ⚙} label="Admin" /> , ) expect(screen.queryByText('Admin')).not.toBeInTheDocument() expect(screen.getByTitle('Admin')).toBeInTheDocument() }) it('calls FooterLink onClick', async () => { const onClick = vi.fn() const user = userEvent.setup() renderSidebar( ⚙} label="Admin" onClick={onClick} /> , ) await user.click(screen.getByText('Admin')) expect(onClick).toHaveBeenCalledOnce() }) it('renders Section as icon-rail item when sidebar is collapsed', () => { renderSidebar( 📦} /> , ) expect(screen.getByTestId('apps-icon')).toBeInTheDocument() expect(screen.getByTitle('Applications')).toBeInTheDocument() // Label text should NOT be rendered as visible text in rail mode expect(screen.queryByText('Applications')).not.toBeInTheDocument() }) it('fires both onCollapseToggle and onToggle when icon-rail section is clicked', async () => { const onCollapseToggle = vi.fn() const onToggle = vi.fn() const user = userEvent.setup() renderSidebar( 📦} collapsed onToggle={onToggle} /> , ) await user.click(screen.getByTitle('Applications')) expect(onCollapseToggle).toHaveBeenCalledOnce() expect(onToggle).toHaveBeenCalledOnce() }) it('applies active highlight to FooterLink', () => { renderSidebar( ⚙} label="Admin" active /> , ) const adminEl = screen.getByText('Admin').closest('[role="button"]')! expect(adminEl.className).toContain('bottomItemActive') }) }) ``` - [ ] **Step 2: Run tests to verify they pass** Run: `npx vitest run src/design-system/layout/Sidebar/Sidebar.test.tsx` Expected: All tests pass. If any fail, fix the component or test to match. - [ ] **Step 3: Commit** ```bash git add src/design-system/layout/Sidebar/Sidebar.test.tsx git commit -m "test(sidebar): rewrite tests for compound component API" ``` --- ## Task 6: Create LayoutShell **Files:** - Create: `src/layout/LayoutShell.tsx` This is the central migration piece. All application-specific sidebar logic (tree building, starred section, section collapse persistence, sidebarReveal) moves here from the old `Sidebar.tsx`. - [ ] **Step 1: Create the LayoutShell file** ```tsx // src/layout/LayoutShell.tsx import { useState, useEffect, useMemo } 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:${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 ────────────────────────────────��──────────────────────── interface StarredItem { starKey: string label: string icon?: React.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.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 }) } } 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 } // ── StarredGroup sub-component ────────────────────────────────────────────── function StarredGroup({ label, items, onNavigate, onRemove }: { label: string items: StarredItem[] onNavigate: (path: string) => void onRemove: (starKey: string) => void }) { 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}}
))}
) } // ── LayoutShell ───────────────────────────────────────────────────────────── export function LayoutShell() { const [sidebarCollapsed, setSidebarCollapsed] = useState(false) const [filterQuery, setFilterQuery] = useState('') // Section collapse state with localStorage persistence 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 = (fn: (v: boolean) => boolean) => { _setAppsCollapsed((prev) => { const next = fn(prev) localStorage.setItem('cameleer:sidebar:apps-collapsed', String(next)) return next }) } const setAgentsCollapsed = (fn: (v: boolean) => boolean) => { _setAgentsCollapsed((prev) => { const next = fn(prev) localStorage.setItem('cameleer:sidebar:agents-collapsed', String(next)) return next }) } const setRoutesCollapsed = (fn: (v: boolean) => boolean) => { _setRoutesCollapsed((prev) => { const next = fn(prev) localStorage.setItem('cameleer:sidebar:routes-collapsed', String(next)) return next }) } const navigate = useNavigate() const location = useLocation() const { starredIds, isStarred, toggleStar } = useStarred() // Build tree data 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 useEffect(() => { if (!sidebarRevealPath) return const matchesAppTree = appNodes.some((node) => node.path === sidebarRevealPath || node.children?.some((child) => child.path === sidebarRevealPath), ) if (matchesAppTree && appsCollapsed) { _setAppsCollapsed(false) localStorage.setItem('cameleer:sidebar:apps-collapsed', 'false') } 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 const effectiveSelectedPath = sidebarRevealPath ?? location.pathname // Starred items const starredItems = useMemo(() => collectStarredItems(SIDEBAR_APPS, starredIds), [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 return ( setSidebarCollapsed((v) => !v)} searchValue={filterQuery} onSearchChange={setFilterQuery} > ) } ``` - [ ] **Step 2: Commit** ```bash git add src/layout/LayoutShell.tsx git commit -m "feat: create LayoutShell with compound Sidebar and Outlet" ``` --- ## Task 7: Update App.tsx to use layout routes **Files:** - Modify: `src/App.tsx` - [ ] **Step 1: Replace App.tsx with layout route structure** ```tsx // src/App.tsx import { useMemo, useCallback } from 'react' import { Routes, Route, Navigate, useNavigate } from 'react-router-dom' import { LayoutShell } from './layout/LayoutShell' import { Dashboard } from './pages/Dashboard/Dashboard' import { Routes as RoutesPage } from './pages/Routes/Routes' import { ExchangeDetail } from './pages/ExchangeDetail/ExchangeDetail' import { AgentHealth } from './pages/AgentHealth/AgentHealth' import { AgentInstance } from './pages/AgentInstance/AgentInstance' import { Inventory } from './pages/Inventory/Inventory' import { AuditLog } from './pages/Admin/AuditLog/AuditLog' import { OidcConfig } from './pages/Admin/OidcConfig/OidcConfig' import { UserManagement } from './pages/Admin/UserManagement/UserManagement' import { ApiDocs } from './pages/ApiDocs/ApiDocs' import { CommandPalette } from './design-system/composites/CommandPalette/CommandPalette' import type { SearchResult } from './design-system/composites/CommandPalette/types' import { useCommandPalette } from './design-system/providers/CommandPaletteProvider' import { useGlobalFilters } from './design-system/providers/GlobalFilterProvider' import { buildSearchData } from './mocks/searchData' import { exchanges } from './mocks/exchanges' import { routes } from './mocks/routes' import { agents } from './mocks/agents' import { buildRouteToAppMap } from './mocks/sidebar' const routeToApp = buildRouteToAppMap() /** Compute which sidebar path to reveal for a given search result */ function computeSidebarRevealPath(result: SearchResult): string | undefined { if (!result.path) return undefined if (result.category === 'application') return result.path if (result.category === 'route') return result.path if (result.category === 'agent') return result.path if (result.category === 'exchange') { const exchange = exchanges.find((e) => e.id === result.id) if (exchange) { const appId = routeToApp.get(exchange.route) if (appId) return `/apps/${appId}/${exchange.route}` } } return result.path } export default function App() { const navigate = useNavigate() const { open: paletteOpen, setOpen } = useCommandPalette() const { isInTimeRange, statusFilters } = useGlobalFilters() const filteredSearchData = useMemo(() => { let filteredExchanges = exchanges.filter((e) => isInTimeRange(e.timestamp)) if (statusFilters.size > 0) { filteredExchanges = filteredExchanges.filter((e) => statusFilters.has(e.status)) } return buildSearchData(filteredExchanges, routes, agents) }, [isInTimeRange, statusFilters]) const handleSelect = useCallback( (result: SearchResult) => { if (result.path) { const sidebarReveal = computeSidebarRevealPath(result) navigate(result.path, { state: sidebarReveal ? { sidebarReveal } : undefined }) } setOpen(false) }, [navigate, setOpen], ) return ( <> }> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> setOpen(false)} onOpen={() => setOpen(true)} data={filteredSearchData} onSelect={handleSelect} /> ) } ``` Note: The Inventory route stays **outside** the `LayoutShell` because it has its own layout and renders a standalone Sidebar demo. - [ ] **Step 2: Commit** ```bash git add src/App.tsx git commit -m "refactor: wrap routes in LayoutShell layout route" ``` --- ## Task 8: Strip AppShell+Sidebar wrappers from page components **Files:** - Modify: `src/pages/Dashboard/Dashboard.tsx` - Modify: `src/pages/Routes/Routes.tsx` - Modify: `src/pages/ExchangeDetail/ExchangeDetail.tsx` - Modify: `src/pages/AgentHealth/AgentHealth.tsx` - Modify: `src/pages/AgentInstance/AgentInstance.tsx` - Modify: `src/pages/Admin/Admin.tsx` - Modify: `src/pages/ApiDocs/ApiDocs.tsx` - Modify: `src/pages/AppDetail/AppDetail.tsx` Each page currently wraps its content in `}>...`. Since `LayoutShell` now provides the `AppShell` + sidebar, each page must return **only the content** that was previously inside `` (the TopBar + page body). Pages with `DetailPanel` passed as `detail` prop to `AppShell` need special handling — see below. - [ ] **Step 1: Dashboard.tsx** Remove imports of `AppShell`, `Sidebar`, `SIDEBAR_APPS` (keep `buildRouteToAppMap`). Remove the `` wrapper. The `detail` prop passed to `AppShell` is for `DetailPanel`. Since `AppShell` uses a portal (`
`), the `DetailPanel` component already portals itself. The `detail` prop on `AppShell` is deprecated (comment says so). So we just render `DetailPanel` alongside the other content. Change the return from: ```tsx return ( } detail={selectedExchange ? () : undefined} >
...
) ``` to: ```tsx return ( <>
...
{selectedExchange && ()} ) ``` Remove these import lines: ```tsx import { AppShell } from '../../design-system/layout/AppShell/AppShell' import { Sidebar } from '../../design-system/layout/Sidebar/Sidebar' ``` And remove `SIDEBAR_APPS` from the sidebar import (keep `buildRouteToAppMap`): ```tsx import { buildRouteToAppMap } from '../../mocks/sidebar' ``` - [ ] **Step 2: Routes.tsx** This file has **two return statements** (route detail view and top-level view). Both wrap in ``. Strip both. Remove imports of `AppShell`, `Sidebar`, `SIDEBAR_APPS` (keep `buildRouteToAppMap`). Replace each `}>...` with a fragment `<>...`. - [ ] **Step 3: ExchangeDetail.tsx** Has **two return sites** (not found + normal). Strip both. Remove imports of `AppShell`, `Sidebar`, `SIDEBAR_APPS` (keep `buildRouteToAppMap`). Replace each `}>...` with `<>...`. - [ ] **Step 4: AgentHealth.tsx** One return with ``. Remove imports of `AppShell`, `Sidebar`, `SIDEBAR_APPS`. Replace wrapper with fragment. Render `DetailPanel` alongside content. - [ ] **Step 5: AgentInstance.tsx** **Two return sites** (not found + normal). Remove imports of `AppShell`, `Sidebar`, `SIDEBAR_APPS`. Replace both wrappers with fragments. - [ ] **Step 6: Admin.tsx (AdminLayout)** `AdminLayout` currently wraps in `}>`. Strip it. Remove imports of `AppShell`, `Sidebar`, `SIDEBAR_APPS`. Change `AdminLayout` return from: ```tsx }>
{children}
``` to: ```tsx <>
{children}
``` - [ ] **Step 7: ApiDocs.tsx** Remove imports of `AppShell`, `Sidebar`, `SIDEBAR_APPS`. Return: ```tsx <> ``` - [ ] **Step 8: AppDetail.tsx** Remove imports of `AppShell`, `Sidebar`, `SIDEBAR_APPS`. Return: ```tsx <> ``` - [ ] **Step 9: Verify TypeScript compiles** Run: `npx tsc --noEmit` Expected: Zero errors. All pages now return content fragments, `LayoutShell` provides the `AppShell` wrapper. - [ ] **Step 10: Commit** ```bash git add src/pages/Dashboard/Dashboard.tsx src/pages/Routes/Routes.tsx src/pages/ExchangeDetail/ExchangeDetail.tsx src/pages/AgentHealth/AgentHealth.tsx src/pages/AgentInstance/AgentInstance.tsx src/pages/Admin/Admin.tsx src/pages/ApiDocs/ApiDocs.tsx src/pages/AppDetail/AppDetail.tsx git commit -m "refactor: strip AppShell+Sidebar wrappers from all page components Pages now render content only. LayoutShell provides the AppShell wrapper with compound Sidebar via React Router layout route." ``` --- ## Task 9: Update Inventory LayoutSection **Files:** - Modify: `src/pages/Inventory/sections/LayoutSection.tsx` The Inventory page's Sidebar demo needs to use the compound API. This is a standalone showcase, not wrapped by `LayoutShell`. - [ ] **Step 1: Update LayoutSection to use compound Sidebar API** Replace the imports and data section: Remove: ```tsx import { Sidebar } from '../../../design-system/layout/Sidebar/Sidebar' import type { SidebarApp } from '../../../design-system/layout/Sidebar/Sidebar' ``` Add: ```tsx 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 { StatusDot } from '../../../design-system/primitives/StatusDot/StatusDot' import { Box, Cpu, Settings, FileText, ChevronRight } from 'lucide-react' ``` Replace the `SAMPLE_APPS` data and the Sidebar demo card: ```tsx // ── Sample tree nodes for demo ─────────────────────���──────────────────────── const SAMPLE_APP_NODES: SidebarTreeNode[] = [ { id: 'app:app1', label: 'cameleer-prod', icon: , badge: '14.3k', path: '#', starrable: true, starKey: 'app1', children: [ { id: 'route:app1:r1', label: 'order-ingest', icon: , badge: '5.4k', path: '#', starrable: true, starKey: 'app1:r1' }, { id: 'route:app1:r2', label: 'payment-validate', icon: , badge: '3.1k', path: '#', starrable: true, starKey: 'app1:r2' }, ], }, { id: 'app:app2', label: 'cameleer-staging', icon: , badge: '871', path: '#', starrable: true, starKey: 'app2', children: [ { id: 'route:app2:r3', label: 'notify-customer', icon: , badge: '2.2k', path: '#', starrable: true, starKey: 'app2:r3' }, ], }, { id: 'app:app3', label: 'cameleer-dev', icon: , badge: '42', path: '#', starrable: true, starKey: 'app3', }, ] ``` Then update the Sidebar DemoCard: ```tsx {/* 2. Sidebar */}
🐪} title="cameleer" version="v3.2.1" /> }> false} onToggleStar={() => {}} /> } label="Admin" /> } label="API Docs" />
``` - [ ] **Step 2: Verify build** Run: `npx tsc --noEmit` Expected: Zero errors. - [ ] **Step 3: Commit** ```bash git add src/pages/Inventory/sections/LayoutSection.tsx git commit -m "refactor(inventory): update Sidebar demo to compound API" ``` --- ## Task 10: Run full test suite and build - [ ] **Step 1: Run all tests** Run: `npx vitest run` Expected: All tests pass. The `useStarred.test.ts` tests are unchanged and should still pass. - [ ] **Step 2: Run TypeScript check** Run: `npx tsc --noEmit` Expected: Zero errors. - [ ] **Step 3: Run build** Run: `npm run build` Expected: Clean Vite build with no errors. - [ ] **Step 4: Fix any issues found** If tests fail or build breaks, fix the specific issue. Common problems: - Missing imports after removing old Sidebar types - CSS class references that were removed - SidebarTree props that changed shape - [ ] **Step 5: Commit any fixes** ```bash git add -u git commit -m "fix: resolve test/build issues from sidebar refactor" ``` --- ## Summary of changes | Area | Before | After | |------|--------|-------| | `Sidebar.tsx` | 560 lines, monolithic, hardcoded 3 sections | ~150 lines, compound component shell | | `SidebarTree.tsx` | Internal, not exported | Exported publicly | | `useStarred.ts` | Internal, not exported | Exported publicly | | Layout exports | `Sidebar`, `SidebarApp/Route/Agent` types | `Sidebar`, `SidebarTree`, `SidebarTreeNode`, `useStarred` | | Page components | Each wraps `` (11 files) | Return content only, `LayoutShell` provides shell | | `App.tsx` | Flat route list | Layout route (``) wrapping all pages | | Application logic | In DS package | In `src/layout/LayoutShell.tsx` |