Files
cameleer-server/docs/superpowers/plans/2026-04-02-composable-sidebar-migration.md
hsiegeln cb3ebfea7c
Some checks failed
CI / cleanup-branch (push) Has been skipped
CI / build (push) Failing after 18s
CI / docker (push) Has been skipped
CI / deploy (push) Has been skipped
CI / deploy-feature (push) Has been skipped
chore: rename cameleer3 to cameleer
Rename Java packages from com.cameleer3 to com.cameleer, module
directories from cameleer3-* to cameleer-*, and all references
throughout workflows, Dockerfiles, docs, migrations, and pom.xml.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 15:28:42 +02:00

573 lines
22 KiB
Markdown

# 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.
```typescript
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/cameleer-server/ui && npx tsc --project tsconfig.app.json --noEmit`
Expected: No errors.
- [ ] **Step 3: Commit**
```bash
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:
```typescript
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):
```typescript
// ── 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:
```typescript
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/cameleer-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**
```bash
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`:
```typescript
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/cameleer-server/ui && npx tsc --project tsconfig.app.json --noEmit`
Expected: No errors.
- [ ] **Step 3: Commit**
```bash
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**
```bash
git add -A
git commit -m "fix(#112): sidebar migration adjustments from visual review"
```