Files
cameleer-server/docs/superpowers/plans/2026-04-02-composable-sidebar-migration.md
hsiegeln 574f82b731
Some checks failed
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m7s
CI / docker (push) Successful in 37s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Has been cancelled
docs: add historical implementation plans
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 15:45:49 +02:00

22 KiB

Composable Sidebar Migration 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: Migrate the server UI from the old monolithic <Sidebar apps={[...]}/> to the new composable compound Sidebar API from @cameleer/design-system v0.1.23, adding admin accordion behavior and icon-rail collapse.

Architecture: Extract tree-building logic into a local sidebar-utils.ts, rewrite sidebar composition in LayoutShell.tsx using Sidebar.Header/Section/Footer compound components, add admin accordion behavior via route-based section state management, and simplify AdminLayout.tsx by removing its tab navigation (sidebar handles it now).

Tech Stack: React 19, @cameleer/design-system v0.1.23, react-router, lucide-react, CSS Modules


File Map

File Action Responsibility
ui/src/components/sidebar-utils.ts Create Tree-building functions, SidebarApp type, formatCount, admin node builder
ui/src/components/LayoutShell.tsx Modify Rewrite sidebar composition with compound API, add accordion + collapse
ui/src/pages/Admin/AdminLayout.tsx Modify Remove tab navigation (sidebar handles it), keep content wrapper

Task 1: Create sidebar-utils.ts with Tree-Building Functions

Files:

  • Create: ui/src/components/sidebar-utils.ts

  • Step 1: Create the utility file

This file contains the SidebarApp type (moved from DS), tree-building functions, and the admin node builder. These were previously inside the DS's monolithic Sidebar component.

import type { ReactNode } from 'react';
import type { SidebarTreeNode } from '@cameleer/design-system';

// ── Domain types (moved from DS) ──────────────────────────────────────────

export interface SidebarRoute {
  id: string;
  name: string;
  exchangeCount: number;
}

export interface SidebarAgent {
  id: string;
  name: string;
  status: 'live' | 'stale' | 'dead';
  tps: number;
}

export interface SidebarApp {
  id: string;
  name: string;
  health: 'live' | 'stale' | 'dead';
  exchangeCount: number;
  routes: SidebarRoute[];
  agents: SidebarAgent[];
}

// ── Helpers ───────────────────────────────────────────────────────────────

export function formatCount(n: number): string {
  if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
  return String(n);
}

// ── Tree node builders ────────────────────────────────────────────────────

export function buildAppTreeNodes(
  apps: SidebarApp[],
  statusDot: (health: string) => ReactNode,
  chevron: ReactNode,
): SidebarTreeNode[] {
  return apps.map((app) => ({
    id: app.id,
    label: app.name,
    icon: statusDot(app.health),
    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: chevron,
      badge: formatCount(route.exchangeCount),
      path: `/apps/${app.id}/${route.id}`,
    })),
  }));
}

export function buildAgentTreeNodes(
  apps: SidebarApp[],
  statusDot: (health: string) => ReactNode,
): 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: statusDot(app.health),
        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: statusDot(agent.status),
          badge: `${agent.tps.toFixed(1)}/s`,
          path: `/agents/${app.id}/${agent.id}`,
        })),
      };
    });
}

export function buildRouteTreeNodes(
  apps: SidebarApp[],
  statusDot: (health: string) => ReactNode,
  chevron: ReactNode,
): SidebarTreeNode[] {
  return apps
    .filter((app) => app.routes.length > 0)
    .map((app) => ({
      id: `routes:${app.id}`,
      label: app.name,
      icon: statusDot(app.health),
      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: chevron,
        badge: formatCount(route.exchangeCount),
        path: `/routes/${app.id}/${route.id}`,
      })),
    }));
}

export function buildAdminTreeNodes(): SidebarTreeNode[] {
  return [
    { id: 'admin:rbac', label: 'User Management', path: '/admin/rbac' },
    { id: 'admin:audit', label: 'Audit Log', path: '/admin/audit' },
    { id: 'admin:oidc', label: 'OIDC', path: '/admin/oidc' },
    { id: 'admin:appconfig', label: 'App Config', path: '/admin/appconfig' },
    { id: 'admin:database', label: 'Database', path: '/admin/database' },
    { id: 'admin:clickhouse', label: 'ClickHouse', path: '/admin/clickhouse' },
  ];
}

// ── localStorage-backed section collapse ──────────────────────────────────

export function readCollapsed(key: string, defaultValue: boolean): boolean {
  try {
    const raw = localStorage.getItem(key);
    if (raw !== null) return raw === 'true';
  } catch { /* ignore */ }
  return defaultValue;
}

export function writeCollapsed(key: string, value: boolean): void {
  try {
    localStorage.setItem(key, String(value));
  } catch { /* ignore */ }
}
  • Step 2: Verify it compiles

Run: cd C:/Users/Hendrik/Documents/projects/cameleer3-server/ui && npx tsc --project tsconfig.app.json --noEmit Expected: No errors.

  • Step 3: Commit
git add ui/src/components/sidebar-utils.ts
git commit -m "feat(#112): extract sidebar tree builders and types from DS"

Task 2: Rewrite LayoutShell with Composable Sidebar

Files:

  • Modify: ui/src/components/LayoutShell.tsx

This is the main migration task. The LayoutContent function gets a significant rewrite of its sidebar composition while preserving all existing TopBar, CommandPalette, ContentTabs, breadcrumb, and scope logic.

  • Step 1: Update imports

Replace the old DS imports and add new ones. In LayoutShell.tsx, change the first 10 lines to:

import { Outlet, useNavigate, useLocation } from 'react-router';
import { AppShell, Sidebar, SidebarTree, useStarred, TopBar, CommandPalette, CommandPaletteProvider, GlobalFilterProvider, ToastProvider, BreadcrumbProvider, useCommandPalette, useGlobalFilters, StatusDot } from '@cameleer/design-system';
import type { SidebarTreeNode, SearchResult } from '@cameleer/design-system';
import { Box, Cpu, GitBranch, Settings, FileText, ChevronRight } from 'lucide-react';
import { useRouteCatalog } from '../api/queries/catalog';
import { useAgents } from '../api/queries/agents';
import { useSearchExecutions, useAttributeKeys } from '../api/queries/executions';
import { useAuthStore } from '../auth/auth-store';
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
import { ContentTabs } from './ContentTabs';
import { useScope } from '../hooks/useScope';
import { buildAppTreeNodes, buildAgentTreeNodes, buildRouteTreeNodes, buildAdminTreeNodes, readCollapsed, writeCollapsed } from './sidebar-utils';
import type { SidebarApp } from './sidebar-utils';
  • Step 2: Remove the old sidebarApps builder and healthToColor function

Delete the healthToColor function (lines 12-19) and the sidebarApps useMemo block (lines 128-154). These are replaced by tree-building functions that produce SidebarTreeNode[] directly.

  • Step 3: Add sidebar state management inside LayoutContent

Add these state and memo declarations inside LayoutContent, after the existing hooks (after const { scope, setTab } = useScope(); around line 112):

  // ── Sidebar state ──────────────────────────────────────────────────────
  const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
  const [filterQuery, setFilterQuery] = useState('');
  const { starredIds, isStarred, toggleStar } = useStarred();

  // Section collapse states — persisted to localStorage
  const [appsOpen, setAppsOpen] = useState(() => !readCollapsed('cameleer:sidebar:apps-collapsed', false));
  const [agentsOpen, setAgentsOpen] = useState(() => !readCollapsed('cameleer:sidebar:agents-collapsed', false));
  const [routesOpen, setRoutesOpen] = useState(() => !readCollapsed('cameleer:sidebar:routes-collapsed', true));
  const [adminOpen, setAdminOpen] = useState(false);

  const toggleApps = useCallback(() => {
    setAppsOpen((v) => { writeCollapsed('cameleer:sidebar:apps-collapsed', v); return !v; });
  }, []);
  const toggleAgents = useCallback(() => {
    setAgentsOpen((v) => { writeCollapsed('cameleer:sidebar:agents-collapsed', v); return !v; });
  }, []);
  const toggleRoutes = useCallback(() => {
    setRoutesOpen((v) => { writeCollapsed('cameleer:sidebar:routes-collapsed', v); return !v; });
  }, []);
  const toggleAdmin = useCallback(() => setAdminOpen((v) => !v), []);

  // Accordion: entering admin expands admin, collapses operational sections
  const prevOpsState = useRef<{ apps: boolean; agents: boolean; routes: boolean } | null>(null);

  useEffect(() => {
    if (isAdminPage) {
      // Save current operational state, then collapse all, expand admin
      prevOpsState.current = { apps: appsOpen, agents: agentsOpen, routes: routesOpen };
      setAppsOpen(false);
      setAgentsOpen(false);
      setRoutesOpen(false);
      setAdminOpen(true);
    } else if (prevOpsState.current) {
      // Restore operational state, collapse admin
      setAppsOpen(prevOpsState.current.apps);
      setAgentsOpen(prevOpsState.current.agents);
      setRoutesOpen(prevOpsState.current.routes);
      setAdminOpen(false);
      prevOpsState.current = null;
    }
  }, [isAdminPage]); // eslint-disable-line react-hooks/exhaustive-deps

  // Build tree nodes from catalog data
  const statusDot = useCallback((health: string) => <StatusDot status={health as any} />, []);
  const chevronIcon = useMemo(() => <ChevronRight size={12} />, []);

  const appNodes = useMemo(
    () => buildAppTreeNodes(catalog ? [...catalog].sort((a: any, b: any) => a.appId.localeCompare(b.appId)).map((app: any) => ({
      id: app.appId,
      name: app.appId,
      health: app.health as 'live' | 'stale' | 'dead',
      exchangeCount: app.exchangeCount,
      routes: [...(app.routes || [])].sort((a: any, b: any) => a.routeId.localeCompare(b.routeId)).map((r: any) => ({
        id: r.routeId, name: r.routeId, exchangeCount: r.exchangeCount,
      })),
      agents: [...(app.agents || [])].sort((a: any, b: any) => a.name.localeCompare(b.name)).map((a: any) => ({
        id: a.id, name: a.name, status: a.status as 'live' | 'stale' | 'dead', tps: a.tps ?? 0,
      })),
    })) : [], statusDot, chevronIcon),
    [catalog, statusDot, chevronIcon],
  );

  const agentNodes = useMemo(
    () => buildAgentTreeNodes(catalog ? [...catalog].sort((a: any, b: any) => a.appId.localeCompare(b.appId)).map((app: any) => ({
      id: app.appId,
      name: app.appId,
      health: app.health as 'live' | 'stale' | 'dead',
      exchangeCount: app.exchangeCount,
      routes: [],
      agents: [...(app.agents || [])].sort((a: any, b: any) => a.name.localeCompare(b.name)).map((a: any) => ({
        id: a.id, name: a.name, status: a.status as 'live' | 'stale' | 'dead', tps: a.tps ?? 0,
      })),
    })) : [], statusDot),
    [catalog, statusDot],
  );

  const routeNodes = useMemo(
    () => buildRouteTreeNodes(catalog ? [...catalog].sort((a: any, b: any) => a.appId.localeCompare(b.appId)).map((app: any) => ({
      id: app.appId,
      name: app.appId,
      health: app.health as 'live' | 'stale' | 'dead',
      exchangeCount: app.exchangeCount,
      routes: [...(app.routes || [])].sort((a: any, b: any) => a.routeId.localeCompare(b.routeId)).map((r: any) => ({
        id: r.routeId, name: r.routeId, exchangeCount: r.exchangeCount,
      })),
      agents: [],
    })) : [], statusDot, chevronIcon),
    [catalog, statusDot, chevronIcon],
  );

  const adminNodes = useMemo(() => buildAdminTreeNodes(), []);

  // Sidebar reveal from Cmd-K navigation
  const sidebarRevealPath = (location.state as { sidebarReveal?: string } | null)?.sidebarReveal ?? null;

  useEffect(() => {
    if (!sidebarRevealPath) return;
    if (sidebarRevealPath.startsWith('/apps') && !appsOpen) setAppsOpen(true);
    if (sidebarRevealPath.startsWith('/agents') && !agentsOpen) setAgentsOpen(true);
    if (sidebarRevealPath.startsWith('/routes') && !routesOpen) setRoutesOpen(true);
    if (sidebarRevealPath.startsWith('/admin') && !adminOpen) setAdminOpen(true);
  }, [sidebarRevealPath]); // eslint-disable-line react-hooks/exhaustive-deps

  const effectiveSelectedPath = sidebarRevealPath ?? location.pathname;
  • Step 4: Replace the return block's sidebar composition

Replace the return statement in LayoutContent (starting from return ( to the closing );). The key change is replacing <Sidebar apps={sidebarApps} onNavigate={handleSidebarNavigate} /> with the compound Sidebar:

  return (
    <AppShell
      sidebar={
        <Sidebar
          collapsed={sidebarCollapsed}
          onCollapseToggle={() => setSidebarCollapsed((v) => !v)}
          searchValue={filterQuery}
          onSearchChange={setFilterQuery}
        >
          <Sidebar.Header
            logo={<img src="/favicon.svg" alt="" style={{ width: 28, height: 24 }} />}
            title="cameleer"
            version="v3.2.1"
            onClick={() => handleSidebarNavigate('/apps')}
          />

          {isAdminPage && (
            <Sidebar.Section
              label="Admin"
              icon={<Settings size={14} />}
              open={adminOpen}
              onToggle={toggleAdmin}
              active={location.pathname.startsWith('/admin')}
            >
              <SidebarTree
                nodes={adminNodes}
                selectedPath={effectiveSelectedPath}
                filterQuery={filterQuery}
                onNavigate={handleSidebarNavigate}
              />
            </Sidebar.Section>
          )}

          <Sidebar.Section
            label="Applications"
            icon={<Box size={14} />}
            open={appsOpen}
            onToggle={() => { toggleApps(); if (isAdminPage) handleSidebarNavigate('/apps'); }}
            active={effectiveSelectedPath.startsWith('/apps') || effectiveSelectedPath.startsWith('/exchanges') || effectiveSelectedPath.startsWith('/dashboard')}
          >
            <SidebarTree
              nodes={appNodes}
              selectedPath={effectiveSelectedPath}
              isStarred={isStarred}
              onToggleStar={toggleStar}
              filterQuery={filterQuery}
              persistKey="cameleer:expanded:apps"
              autoRevealPath={sidebarRevealPath}
              onNavigate={handleSidebarNavigate}
            />
          </Sidebar.Section>

          <Sidebar.Section
            label="Agents"
            icon={<Cpu size={14} />}
            open={agentsOpen}
            onToggle={() => { toggleAgents(); if (isAdminPage) handleSidebarNavigate('/agents'); }}
            active={effectiveSelectedPath.startsWith('/agents') || effectiveSelectedPath.startsWith('/runtime')}
          >
            <SidebarTree
              nodes={agentNodes}
              selectedPath={effectiveSelectedPath}
              isStarred={isStarred}
              onToggleStar={toggleStar}
              filterQuery={filterQuery}
              persistKey="cameleer:expanded:agents"
              autoRevealPath={sidebarRevealPath}
              onNavigate={handleSidebarNavigate}
            />
          </Sidebar.Section>

          <Sidebar.Section
            label="Routes"
            icon={<GitBranch size={14} />}
            open={routesOpen}
            onToggle={() => { toggleRoutes(); if (isAdminPage) handleSidebarNavigate('/routes'); }}
            active={effectiveSelectedPath.startsWith('/routes')}
          >
            <SidebarTree
              nodes={routeNodes}
              selectedPath={effectiveSelectedPath}
              isStarred={isStarred}
              onToggleStar={toggleStar}
              filterQuery={filterQuery}
              persistKey="cameleer:expanded:routes"
              autoRevealPath={sidebarRevealPath}
              onNavigate={handleSidebarNavigate}
            />
          </Sidebar.Section>

          {!isAdminPage && (
            <Sidebar.Section
              label="Admin"
              icon={<Settings size={14} />}
              open={adminOpen}
              onToggle={() => { toggleAdmin(); handleSidebarNavigate('/admin'); }}
              active={false}
            >
              <SidebarTree
                nodes={adminNodes}
                selectedPath={effectiveSelectedPath}
                filterQuery={filterQuery}
                onNavigate={handleSidebarNavigate}
              />
            </Sidebar.Section>
          )}

          <Sidebar.Footer>
            <Sidebar.FooterLink
              icon={<FileText size={14} />}
              label="API Docs"
              onClick={() => handleSidebarNavigate('/api-docs')}
              active={location.pathname === '/api-docs'}
            />
          </Sidebar.Footer>
        </Sidebar>
      }
    >
      <TopBar
        breadcrumb={breadcrumb}
        user={username ? { name: username } : undefined}
        onLogout={handleLogout}
      />
      <CommandPalette
        open={paletteOpen}
        onClose={() => setPaletteOpen(false)}
        onOpen={() => setPaletteOpen(true)}
        onSelect={handlePaletteSelect}
        onSubmit={handlePaletteSubmit}
        onQueryChange={setPaletteQuery}
        data={searchData}
      />

      {!isAdminPage && (
        <ContentTabs active={scope.tab} onChange={setTab} scope={scope} />
      )}

      <main style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', minHeight: 0, padding: isAdminPage ? '1.5rem' : 0 }}>
        <Outlet />
      </main>
    </AppShell>
  );

Note: The Admin section renders in two positions — at the top when isAdminPage (accordion mode), at the bottom when not (collapsed section). Only one renders at a time via the conditional.

  • Step 5: Verify it compiles

Run: cd C:/Users/Hendrik/Documents/projects/cameleer3-server/ui && npx tsc --project tsconfig.app.json --noEmit Expected: No errors. If StatusDot is not exported from the DS, check the exact export name with grep -r "StatusDot" ui/node_modules/@cameleer/design-system/dist/index.es.d.ts.

  • Step 6: Commit
git add ui/src/components/LayoutShell.tsx
git commit -m "feat(#112): migrate to composable sidebar with accordion and collapse"

Task 3: Simplify AdminLayout

Files:

  • Modify: ui/src/pages/Admin/AdminLayout.tsx

The sidebar now handles admin sub-page navigation, so AdminLayout no longer needs its own <Tabs>.

  • Step 1: Rewrite AdminLayout

Replace the full contents of AdminLayout.tsx:

import { Outlet } from 'react-router';

export default function AdminLayout() {
  return (
    <div style={{ padding: '20px 24px 40px' }}>
      <Outlet />
    </div>
  );
}
  • Step 2: Verify it compiles

Run: cd C:/Users/Hendrik/Documents/projects/cameleer3-server/ui && npx tsc --project tsconfig.app.json --noEmit Expected: No errors.

  • Step 3: Commit
git add ui/src/pages/Admin/AdminLayout.tsx
git commit -m "feat(#112): remove admin tabs, sidebar handles navigation"

Task 4: Visual Verification

  • Step 1: Verify operational mode

Open http://localhost:5173/exchanges and verify:

  1. Sidebar shows all 4 sections: Applications, Agents, Routes, Admin
  2. Applications and Agents expanded by default, Routes and Admin collapsed
  3. Sidebar search filters tree items
  4. Clicking an app navigates to the exchanges page for that app
  5. TopBar, ContentTabs, CommandPalette all work normally
  6. Star/unstar items work
  • Step 2: Verify sidebar collapse

Click the << toggle in sidebar header:

  1. Sidebar collapses to ~48px icon rail
  2. Section icons visible (Box, Cpu, GitBranch, Settings)
  3. Footer link icon visible (FileText for API Docs)
  4. Click any section icon — sidebar expands and that section opens
  • Step 3: Verify admin accordion

Navigate to /admin/rbac (click Admin section in sidebar or navigate directly):

  1. Admin section appears at top of sidebar, expanded, showing 6 sub-pages
  2. Applications, Agents, Routes sections are collapsed to single-line headers
  3. Admin sub-page items show active highlighting for current page
  4. No admin tabs visible in content area (just content with padding)
  5. Clicking between admin sub-pages (e.g., Audit Log, OIDC) works via sidebar
  • Step 4: Verify leaving admin

From an admin page, click "Applications" section header:

  1. Navigates to /exchanges (or last operational tab)
  2. Admin section collapses
  3. Operational sections restore their previous open/closed states
  • Step 5: Final commit if any fixes were needed
git add -A
git commit -m "fix(#112): sidebar migration adjustments from visual review"