diff --git a/docs/superpowers/plans/2026-04-02-composable-sidebar.md b/docs/superpowers/plans/2026-04-02-composable-sidebar.md new file mode 100644 index 0000000..6c13919 --- /dev/null +++ b/docs/superpowers/plans/2026-04-02-composable-sidebar.md @@ -0,0 +1,1609 @@ +# 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` |