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>
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/cameleer-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
sidebarAppsbuilder andhealthToColorfunction
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/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
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/cameleer-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:
- Sidebar shows all 4 sections: Applications, Agents, Routes, Admin
- Applications and Agents expanded by default, Routes and Admin collapsed
- Sidebar search filters tree items
- Clicking an app navigates to the exchanges page for that app
- TopBar, ContentTabs, CommandPalette all work normally
- Star/unstar items work
- Step 2: Verify sidebar collapse
Click the << toggle in sidebar header:
- Sidebar collapses to ~48px icon rail
- Section icons visible (Box, Cpu, GitBranch, Settings)
- Footer link icon visible (FileText for API Docs)
- 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):
- Admin section appears at top of sidebar, expanded, showing 6 sub-pages
- Applications, Agents, Routes sections are collapsed to single-line headers
- Admin sub-page items show active highlighting for current page
- No admin tabs visible in content area (just content with padding)
- 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:
- Navigates to
/exchanges(or last operational tab) - Admin section collapses
- 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"