feat(#112): migrate to composable sidebar with accordion and collapse

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-02 18:29:25 +02:00
parent bc913eef6e
commit fe49eb5aba

View File

@@ -1,22 +1,40 @@
import { Outlet, useNavigate, useLocation } from 'react-router';
import { AppShell, Sidebar, TopBar, CommandPalette, CommandPaletteProvider, GlobalFilterProvider, ToastProvider, BreadcrumbProvider, useCommandPalette, useGlobalFilters } from '@cameleer/design-system';
import type { SidebarApp, SearchResult } from '@cameleer/design-system';
import {
AppShell,
Sidebar,
SidebarTree,
StatusDot,
TopBar,
CommandPalette,
CommandPaletteProvider,
GlobalFilterProvider,
ToastProvider,
BreadcrumbProvider,
useCommandPalette,
useGlobalFilters,
useStarred,
} from '@cameleer/design-system';
import type { SearchResult, SidebarTreeNode } 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 { useState, useMemo, useCallback, useEffect, useRef, createElement } from 'react';
import { ContentTabs } from './ContentTabs';
import { useScope } from '../hooks/useScope';
import {
buildAppTreeNodes,
buildAgentTreeNodes,
buildAdminTreeNodes,
readCollapsed,
writeCollapsed,
} from './sidebar-utils';
import type { SidebarApp } from './sidebar-utils';
function healthToColor(health: string): string {
switch (health) {
case 'live': return 'success';
case 'stale': return 'warning';
case 'dead': return 'error';
default: return 'auto';
}
}
/* ------------------------------------------------------------------ */
/* Search data builder (unchanged) */
/* ------------------------------------------------------------------ */
function buildSearchData(
catalog: any[] | undefined,
@@ -32,7 +50,7 @@ function buildSearchData(
id: app.appId,
category: 'application',
title: app.appId,
badges: [{ label: (app.health || 'unknown').toUpperCase(), color: healthToColor(app.health) }],
badges: [{ label: (app.health || 'unknown').toUpperCase(), color: healthToSearchColor(app.health) }],
meta: `${(app.routes || []).length} routes · ${(app.agents || []).length} agents (${liveAgents} live) · ${(app.exchangeCount ?? 0).toLocaleString()} exchanges`,
path: `/exchanges/${app.appId}`,
});
@@ -55,7 +73,7 @@ function buildSearchData(
id: agent.instanceId,
category: 'agent',
title: agent.displayName,
badges: [{ label: (agent.status || 'unknown').toUpperCase(), color: healthToColor((agent.status || '').toLowerCase()) }],
badges: [{ label: (agent.status || 'unknown').toUpperCase(), color: healthToSearchColor((agent.status || '').toLowerCase()) }],
meta: `${agent.applicationId} · ${agent.version || ''}${agent.tps != null ? ` · ${agent.tps.toFixed(1)} msg/s` : ''}`,
path: `/runtime/${agent.applicationId}/${agent.instanceId}`,
});
@@ -76,6 +94,15 @@ function buildSearchData(
return results;
}
function healthToSearchColor(health: string): string {
switch (health) {
case 'live': return 'success';
case 'stale': return 'warning';
case 'dead': return 'error';
default: return 'auto';
}
}
function formatDuration(ms: number): string {
if (ms >= 60_000) return `${(ms / 1000).toFixed(0)}s`;
if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s`;
@@ -100,6 +127,31 @@ function useDebouncedValue<T>(value: T, delayMs: number): T {
return debounced;
}
/* ------------------------------------------------------------------ */
/* Icon factories for tree builders */
/* ------------------------------------------------------------------ */
function makeStatusDot(health: string) {
return createElement(StatusDot, { variant: health as any });
}
function makeChevron() {
return createElement(ChevronRight, { size: 14 });
}
/* ------------------------------------------------------------------ */
/* Section open-state keys */
/* ------------------------------------------------------------------ */
const SK_APPS = 'sidebar:section:apps';
const SK_AGENTS = 'sidebar:section:agents';
const SK_ADMIN = 'sidebar:section:admin';
const SK_COLLAPSED = 'sidebar:collapsed';
/* ------------------------------------------------------------------ */
/* Main layout content */
/* ------------------------------------------------------------------ */
function LayoutContent() {
const navigate = useNavigate();
const location = useLocation();
@@ -111,20 +163,86 @@ function LayoutContent() {
const { open: paletteOpen, setOpen: setPaletteOpen } = useCommandPalette();
const { scope, setTab } = useScope();
// Exchange full-text search via command palette (scoped to current sidebar selection)
const [paletteQuery, setPaletteQuery] = useState('');
const debouncedQuery = useDebouncedValue(paletteQuery, 300);
const { data: exchangeResults } = useSearchExecutions(
{
text: debouncedQuery || undefined,
applicationId: scope.appId || undefined,
routeId: scope.routeId || undefined,
offset: 0,
limit: 10,
},
false,
);
// --- Starred items ------------------------------------------------
const { isStarred, toggleStar } = useStarred();
// --- Sidebar collapse ---------------------------------------------
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => readCollapsed(SK_COLLAPSED, false));
const handleCollapseToggle = useCallback(() => {
setSidebarCollapsed((prev) => {
writeCollapsed(SK_COLLAPSED, !prev);
return !prev;
});
}, []);
// --- Sidebar filter -----------------------------------------------
const [filterQuery, setFilterQuery] = useState('');
// --- Section open states ------------------------------------------
const [appsOpen, setAppsOpen] = useState(() => readCollapsed(SK_APPS, true));
const [agentsOpen, setAgentsOpen] = useState(() => readCollapsed(SK_AGENTS, false));
const [adminOpen, setAdminOpen] = useState(() => readCollapsed(SK_ADMIN, false));
// Ref to remember operational section states when switching to admin
const opsStateRef = useRef({ apps: appsOpen, agents: agentsOpen });
const isAdminPage = location.pathname.startsWith('/admin');
// Accordion effect: when entering admin, collapse operational sections; when leaving, restore
const prevAdminRef = useRef(isAdminPage);
useEffect(() => {
if (isAdminPage && !prevAdminRef.current) {
// Entering admin — save operational states and collapse them
opsStateRef.current = { apps: appsOpen, agents: agentsOpen };
setAppsOpen(false);
setAgentsOpen(false);
setAdminOpen(true);
writeCollapsed(SK_APPS, false);
writeCollapsed(SK_AGENTS, false);
writeCollapsed(SK_ADMIN, true);
} else if (!isAdminPage && prevAdminRef.current) {
// Leaving admin — restore operational states
setAppsOpen(opsStateRef.current.apps);
setAgentsOpen(opsStateRef.current.agents);
setAdminOpen(false);
writeCollapsed(SK_APPS, opsStateRef.current.apps);
writeCollapsed(SK_AGENTS, opsStateRef.current.agents);
writeCollapsed(SK_ADMIN, false);
}
prevAdminRef.current = isAdminPage;
}, [isAdminPage]); // eslint-disable-line react-hooks/exhaustive-deps
const toggleApps = useCallback(() => {
if (isAdminPage) {
// Clicking operational section while in admin navigates away
navigate('/exchanges');
return;
}
setAppsOpen((prev) => {
writeCollapsed(SK_APPS, !prev);
return !prev;
});
}, [isAdminPage, navigate]);
const toggleAgents = useCallback(() => {
if (isAdminPage) {
navigate('/exchanges');
return;
}
setAgentsOpen((prev) => {
writeCollapsed(SK_AGENTS, !prev);
return !prev;
});
}, [isAdminPage, navigate]);
const toggleAdmin = useCallback(() => {
setAdminOpen((prev) => {
writeCollapsed(SK_ADMIN, !prev);
return !prev;
});
}, []);
// --- Build SidebarApp[] from catalog ------------------------------
const sidebarApps: SidebarApp[] = useMemo(() => {
if (!catalog) return [];
const cmp = (a: string, b: string) => a.localeCompare(b);
@@ -153,13 +271,45 @@ function LayoutContent() {
}));
}, [catalog]);
// --- Tree nodes ---------------------------------------------------
const appTreeNodes: SidebarTreeNode[] = useMemo(
() => buildAppTreeNodes(sidebarApps, makeStatusDot, makeChevron),
[sidebarApps],
);
const agentTreeNodes: SidebarTreeNode[] = useMemo(
() => buildAgentTreeNodes(sidebarApps, makeStatusDot),
[sidebarApps],
);
const adminTreeNodes: SidebarTreeNode[] = useMemo(
() => buildAdminTreeNodes(),
[],
);
// --- Reveal path for SidebarTree auto-expand ----------------------
const sidebarRevealPath = (location.state as any)?.sidebarReveal ?? null;
// --- Exchange full-text search via command palette -----------------
const [paletteQuery, setPaletteQuery] = useState('');
const debouncedQuery = useDebouncedValue(paletteQuery, 300);
const { data: exchangeResults } = useSearchExecutions(
{
text: debouncedQuery || undefined,
applicationId: scope.appId || undefined,
routeId: scope.routeId || undefined,
offset: 0,
limit: 10,
},
false,
);
const catalogData = useMemo(
() => buildSearchData(catalog, agents as any[], attributeKeys),
[catalog, agents, attributeKeys],
);
// Stable reference for catalog data — only changes when catalog/agents actually change,
// not on every poll cycle (prevents cmd-k scroll reset)
// Stable reference for catalog data
const catalogRef = useRef(catalogData);
if (catalogData !== catalogRef.current && JSON.stringify(catalogData) !== JSON.stringify(catalogRef.current)) {
catalogRef.current = catalogData;
@@ -201,7 +351,7 @@ function LayoutContent() {
return [...catalogRef.current, ...exchangeItems, ...attributeItems];
}, [catalogRef.current, exchangeResults, debouncedQuery]);
const isAdminPage = location.pathname.startsWith('/admin');
// --- Breadcrumb ---------------------------------------------------
const breadcrumb = useMemo(() => {
if (isAdminPage) {
const LABELS: Record<string, string> = {
@@ -219,7 +369,6 @@ function LayoutContent() {
...(i < parts.length - 1 ? { href: '/' + parts.slice(0, i + 1).join('/') } : {}),
}));
}
// Scope trail as breadcrumb items
const items: { label: string; href?: string }[] = [
{ label: 'All Applications', href: `/${scope.tab}` },
];
@@ -229,13 +378,13 @@ function LayoutContent() {
if (scope.routeId) {
items.push({ label: scope.routeId });
}
// Last item has no href (current location)
if (items.length > 0 && !scope.routeId && !scope.appId) {
delete items[items.length - 1].href;
}
return items;
}, [location.pathname, isAdminPage, scope.tab, scope.appId, scope.routeId]);
// --- Callbacks ----------------------------------------------------
const handleLogout = useCallback(() => {
logout();
navigate('/login');
@@ -245,7 +394,6 @@ function LayoutContent() {
if (result.path) {
const state: Record<string, unknown> = { sidebarReveal: result.path };
// For exchange/attribute results, pass selectedExchange in state
if (result.category === 'exchange' || result.category === 'attribute') {
const parts = result.path.split('/').filter(Boolean);
if (parts.length === 4 && parts[0] === 'exchanges') {
@@ -270,11 +418,10 @@ function LayoutContent() {
}, [navigate, scope.appId, scope.routeId]);
// Translate Sidebar's internal paths to our URL structure.
// Pass sidebarReveal state so the DS Sidebar can highlight the clicked entry.
const handleSidebarNavigate = useCallback((path: string) => {
const state = { sidebarReveal: path };
// /apps/:appId and /apps/:appId/:routeId current tab
// /apps/:appId and /apps/:appId/:routeId -> current tab
const appMatch = path.match(/^\/apps\/([^/]+)(?:\/(.+))?$/);
if (appMatch) {
const [, sAppId, sRouteId] = appMatch;
@@ -282,7 +429,7 @@ function LayoutContent() {
return;
}
// /agents/:appId/:instanceId runtime tab
// /agents/:appId/:instanceId -> runtime tab
const agentMatch = path.match(/^\/agents\/([^/]+)(?:\/(.+))?$/);
if (agentMatch) {
const [, sAppId, sInstanceId] = agentMatch;
@@ -293,12 +440,111 @@ function LayoutContent() {
navigate(path, { state });
}, [navigate, scope.tab]);
return (
<AppShell
sidebar={
<Sidebar apps={sidebarApps} onNavigate={handleSidebarNavigate} />
}
// --- Render -------------------------------------------------------
const sidebarElement = (
<Sidebar
collapsed={sidebarCollapsed}
onCollapseToggle={handleCollapseToggle}
searchValue={filterQuery}
onSearchChange={setFilterQuery}
>
<Sidebar.Header
logo={createElement(Box, { size: 20 })}
title="Cameleer"
/>
{/* When on admin pages, show Admin section first (expanded) */}
{isAdminPage && (
<Sidebar.Section
icon={createElement(Settings, { size: 16 })}
label="Admin"
open={adminOpen}
onToggle={toggleAdmin}
active
>
<SidebarTree
nodes={adminTreeNodes}
selectedPath={location.pathname}
isStarred={isStarred}
onToggleStar={toggleStar}
filterQuery={filterQuery}
persistKey="admin"
autoRevealPath={sidebarRevealPath}
onNavigate={handleSidebarNavigate}
/>
</Sidebar.Section>
)}
<Sidebar.Section
icon={createElement(Box, { size: 16 })}
label="Applications"
open={appsOpen}
onToggle={toggleApps}
>
<SidebarTree
nodes={appTreeNodes}
selectedPath={sidebarRevealPath ?? location.pathname}
isStarred={isStarred}
onToggleStar={toggleStar}
filterQuery={filterQuery}
persistKey="apps"
autoRevealPath={sidebarRevealPath}
onNavigate={handleSidebarNavigate}
/>
</Sidebar.Section>
<Sidebar.Section
icon={createElement(Cpu, { size: 16 })}
label="Agents"
open={agentsOpen}
onToggle={toggleAgents}
>
<SidebarTree
nodes={agentTreeNodes}
selectedPath={sidebarRevealPath ?? location.pathname}
isStarred={isStarred}
onToggleStar={toggleStar}
filterQuery={filterQuery}
persistKey="agents"
autoRevealPath={sidebarRevealPath}
onNavigate={handleSidebarNavigate}
/>
</Sidebar.Section>
{/* When NOT on admin pages, show Admin section at bottom */}
{!isAdminPage && (
<Sidebar.Section
icon={createElement(Settings, { size: 16 })}
label="Admin"
open={adminOpen}
onToggle={toggleAdmin}
>
<SidebarTree
nodes={adminTreeNodes}
selectedPath={location.pathname}
isStarred={isStarred}
onToggleStar={toggleStar}
filterQuery={filterQuery}
persistKey="admin"
autoRevealPath={sidebarRevealPath}
onNavigate={handleSidebarNavigate}
/>
</Sidebar.Section>
)}
<Sidebar.Footer>
<Sidebar.FooterLink
icon={createElement(FileText, { size: 16 })}
label="API Docs"
active={location.pathname === '/api-docs'}
onClick={() => handleSidebarNavigate('/api-docs')}
/>
</Sidebar.Footer>
</Sidebar>
);
return (
<AppShell sidebar={sidebarElement}>
<TopBar
breadcrumb={breadcrumb}
user={username ? { name: username } : undefined}