# Navigation Redesign 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:** Redesign the navigation from page-based routing to a scope-based model with three content tabs (Exchanges, Dashboard, Runtime) and a scoping sidebar. **Architecture:** The sidebar becomes a scope filter (app -> route), not a navigator. Three content-level tabs (using SegmentedTabs from design system) switch the view. The URL structure changes from `/apps/:appId` to `/:tab/:appId`. The Exchanges tab transitions between full-width table (no route scope) and 3-column layout (route-scoped: exchange list | exchange header + diagram + details). **Tech Stack:** React 18, React Router v7, TypeScript, @cameleer/design-system v0.1.18, CSS Modules, TanStack Query **Spec:** `docs/superpowers/specs/2026-03-28-navigation-redesign-design.md` --- ## File Structure **New files (create):** | File | Responsibility | |------|---------------| | `ui/src/hooks/useScope.ts` | Parse tab/appId/routeId/exchangeId from URL, provide navigation helpers | | `ui/src/components/ScopeTrail.tsx` | Clickable scope trail (replaces breadcrumbs): All > app > route | | `ui/src/components/ScopeTrail.module.css` | Scope trail styling | | `ui/src/components/ContentTabs.tsx` | Tab bar (Exchanges \| Dashboard \| Runtime) using SegmentedTabs | | `ui/src/components/ContentTabs.module.css` | Tab bar positioning/spacing | | `ui/src/pages/Exchanges/ExchangesPage.tsx` | Orchestrates full-width table vs 3-column layout | | `ui/src/pages/Exchanges/ExchangesPage.module.css` | 3-column grid, exchange list, right panel | | `ui/src/pages/Exchanges/ExchangeList.tsx` | Compact exchange list for left column of 3-column view | | `ui/src/pages/Exchanges/ExchangeHeader.tsx` | Exchange summary + correlation chain for right panel top | | `ui/src/pages/RuntimeTab/RuntimePage.tsx` | Thin wrapper: renders AgentHealth or AgentInstance | | `ui/src/pages/DashboardTab/DashboardPage.tsx` | Thin wrapper: renders RoutesMetrics or RouteDetail | **Modified files:** | File | Changes | |------|---------| | `ui/src/router.tsx` | New URL structure with 3 tab paths + scope params | | `ui/src/components/LayoutShell.tsx` | Add ContentTabs + ScopeTrail, intercept sidebar navigation, remove agents from sidebar data | **Unchanged files (reused as-is):** | File | Used by | |------|---------| | `ui/src/pages/Dashboard/Dashboard.tsx` | ExchangesPage (full-width mode) | | `ui/src/pages/AgentHealth/AgentHealth.tsx` | RuntimePage | | `ui/src/pages/AgentInstance/AgentInstance.tsx` | RuntimePage | | `ui/src/pages/Routes/RoutesMetrics.tsx` | DashboardPage | | `ui/src/pages/Routes/RouteDetail.tsx` | DashboardPage | | `ui/src/components/ExecutionDiagram/ExecutionDiagram.tsx` | ExchangesPage (3-column mode) | | `ui/src/components/ProcessDiagram/ProcessDiagram.tsx` | ExchangesPage (topology-only when no exchange selected) | | `ui/src/pages/Admin/AdminLayout.tsx` | Unchanged | --- ## Design System Notes **Sidebar navigation interception:** The current `Sidebar` component has hardcoded `` paths (`/apps/:appId`, `/agents/:appId/:instanceId`). Since `SidebarProps` has no `onNavigate` callback, we intercept clicks via event delegation on a wrapper `
`, preventing default Link navigation and re-routing to the current tab's URL. This is a pragmatic workaround. A proper `onNavigate` prop should be added to the design system in a future update. **Agents removed from sidebar:** Pass `agents: []` in `SidebarApp` data. The Sidebar component renders nothing for empty agent arrays. **SegmentedTabs:** Available from `@cameleer/design-system`. Interface: `SegmentedTabs({ tabs: TabItem[], active: string, onChange: (value: string) => void })` where `TabItem = { label: ReactNode; count?: number; value: string }`. --- ## Task 1: Create useScope hook **Files:** - Create: `ui/src/hooks/useScope.ts` - [ ] **Step 1: Create the hook** ```typescript // ui/src/hooks/useScope.ts import { useParams, useNavigate, useLocation } from 'react-router'; import { useCallback } from 'react'; export type TabKey = 'exchanges' | 'dashboard' | 'runtime'; const VALID_TABS = new Set(['exchanges', 'dashboard', 'runtime']); export interface Scope { tab: TabKey; appId?: string; routeId?: string; exchangeId?: string; } export function useScope() { const params = useParams<{ tab?: string; appId?: string; routeId?: string; exchangeId?: string }>(); const navigate = useNavigate(); const location = useLocation(); // Derive tab from first URL segment — fallback to 'exchanges' const rawTab = location.pathname.split('/').filter(Boolean)[0] ?? 'exchanges'; const tab: TabKey = VALID_TABS.has(rawTab as TabKey) ? (rawTab as TabKey) : 'exchanges'; const scope: Scope = { tab, appId: params.appId, routeId: params.routeId, exchangeId: params.exchangeId, }; const setTab = useCallback((newTab: TabKey) => { // Preserve scope when switching tabs (except exchangeId which is tab-specific) const parts = ['', newTab]; if (scope.appId) parts.push(scope.appId); if (scope.routeId) parts.push(scope.routeId); navigate(parts.join('/')); }, [navigate, scope.appId, scope.routeId]); const setApp = useCallback((appId: string | undefined) => { if (!appId) { navigate(`/${tab}`); } else { navigate(`/${tab}/${appId}`); } }, [navigate, tab]); const setRoute = useCallback((appId: string, routeId: string | undefined) => { if (!routeId) { navigate(`/${tab}/${appId}`); } else { navigate(`/${tab}/${appId}/${routeId}`); } }, [navigate, tab]); const setExchange = useCallback((appId: string, routeId: string, exchangeId: string | undefined) => { if (!exchangeId) { navigate(`/${tab}/${appId}/${routeId}`); } else { navigate(`/${tab}/${appId}/${routeId}/${exchangeId}`); } }, [navigate, tab]); const clearScope = useCallback(() => { navigate(`/${tab}`); }, [navigate, tab]); return { scope, setTab, setApp, setRoute, setExchange, clearScope }; } ``` - [ ] **Step 2: Verify build** Run: `cd ui && npx tsc --noEmit` Expected: No type errors - [ ] **Step 3: Commit** ```bash git add ui/src/hooks/useScope.ts git commit -m "feat(ui): add useScope hook for tab+scope URL management" ``` --- ## Task 2: Create ScopeTrail component **Files:** - Create: `ui/src/components/ScopeTrail.tsx` - Create: `ui/src/components/ScopeTrail.module.css` - [ ] **Step 1: Create the CSS module** ```css /* ui/src/components/ScopeTrail.module.css */ .trail { display: flex; align-items: center; gap: 0; font-size: 0.8125rem; color: var(--text-muted); min-height: 1.5rem; } .segment { display: inline-flex; align-items: center; } .link { color: var(--text-secondary); text-decoration: none; cursor: pointer; background: none; border: none; padding: 0; font: inherit; font-size: 0.8125rem; } .link:hover { color: var(--amber); text-decoration: underline; } .separator { margin: 0 0.375rem; color: var(--text-muted); user-select: none; } .current { color: var(--text-primary); font-weight: 500; } ``` - [ ] **Step 2: Create the component** ```typescript // ui/src/components/ScopeTrail.tsx import type { Scope, TabKey } from '../hooks/useScope'; import styles from './ScopeTrail.module.css'; interface ScopeTrailProps { scope: Scope; onNavigate: (path: string) => void; } export function ScopeTrail({ scope, onNavigate }: ScopeTrailProps) { const segments: { label: string; path: string }[] = [ { label: 'All Applications', path: `/${scope.tab}` }, ]; if (scope.appId) { segments.push({ label: scope.appId, path: `/${scope.tab}/${scope.appId}` }); } if (scope.routeId) { segments.push({ label: scope.routeId, path: `/${scope.tab}/${scope.appId}/${scope.routeId}` }); } return ( ); } ``` - [ ] **Step 3: Verify build** Run: `cd ui && npx tsc --noEmit` - [ ] **Step 4: Commit** ```bash git add ui/src/components/ScopeTrail.tsx ui/src/components/ScopeTrail.module.css git commit -m "feat(ui): add ScopeTrail component for scope-based breadcrumbs" ``` --- ## Task 3: Create ContentTabs component **Files:** - Create: `ui/src/components/ContentTabs.tsx` - Create: `ui/src/components/ContentTabs.module.css` - [ ] **Step 1: Create the CSS module** ```css /* ui/src/components/ContentTabs.module.css */ .wrapper { padding: 0 1.5rem; padding-top: 0.75rem; padding-bottom: 0; } ``` - [ ] **Step 2: Create the component** ```typescript // ui/src/components/ContentTabs.tsx import { SegmentedTabs } from '@cameleer/design-system'; import type { TabKey } from '../hooks/useScope'; import styles from './ContentTabs.module.css'; const TABS = [ { label: 'Exchanges', value: 'exchanges' as const }, { label: 'Dashboard', value: 'dashboard' as const }, { label: 'Runtime', value: 'runtime' as const }, ]; interface ContentTabsProps { active: TabKey; onChange: (tab: TabKey) => void; } export function ContentTabs({ active, onChange }: ContentTabsProps) { return (
onChange(v as TabKey)} />
); } ``` - [ ] **Step 3: Verify build** Run: `cd ui && npx tsc --noEmit` - [ ] **Step 4: Commit** ```bash git add ui/src/components/ContentTabs.tsx ui/src/components/ContentTabs.module.css git commit -m "feat(ui): add ContentTabs component (Exchanges | Dashboard | Runtime)" ``` --- ## Task 4: Create ExchangeList component Compact exchange list for the left column of the 3-column Exchanges layout. **Files:** - Create: `ui/src/pages/Exchanges/ExchangeList.tsx` - Create: `ui/src/pages/Exchanges/ExchangeList.module.css` - [ ] **Step 1: Create the CSS module** ```css /* ui/src/pages/Exchanges/ExchangeList.module.css */ .list { display: flex; flex-direction: column; overflow-y: auto; height: 100%; border-right: 1px solid var(--border); background: var(--surface); } .item { display: flex; align-items: center; gap: 0.5rem; padding: 0.625rem 0.75rem; cursor: pointer; border-bottom: 1px solid var(--border-light); font-size: 0.8125rem; transition: background 0.1s; } .item:hover { background: var(--surface-hover); } .itemSelected { background: var(--surface-active); border-left: 3px solid var(--amber); padding-left: calc(0.75rem - 3px); } .dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; } .dotOk { background: var(--success); } .dotErr { background: var(--error); } .dotRun { background: var(--running); } .meta { flex: 1; min-width: 0; } .exchangeId { font-family: var(--font-mono); font-size: 0.6875rem; color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .duration { font-family: var(--font-mono); font-size: 0.75rem; color: var(--text-secondary); flex-shrink: 0; } .timestamp { font-size: 0.6875rem; color: var(--text-muted); flex-shrink: 0; } .empty { padding: 2rem; text-align: center; color: var(--text-muted); font-size: 0.8125rem; } ``` - [ ] **Step 2: Create the component** ```typescript // ui/src/pages/Exchanges/ExchangeList.tsx import type { ExecutionSummary } from '../../api/types'; import styles from './ExchangeList.module.css'; interface ExchangeListProps { exchanges: ExecutionSummary[]; selectedId?: string; onSelect: (exchange: ExecutionSummary) => void; } function formatDuration(ms: number): string { if (ms >= 60_000) return `${(ms / 1000).toFixed(0)}s`; if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s`; return `${ms}ms`; } function formatTime(iso: string): string { const d = new Date(iso); const h = String(d.getHours()).padStart(2, '0'); const m = String(d.getMinutes()).padStart(2, '0'); const s = String(d.getSeconds()).padStart(2, '0'); return `${h}:${m}:${s}`; } function dotClass(status: string): string { switch (status) { case 'COMPLETED': return styles.dotOk; case 'FAILED': return styles.dotErr; case 'RUNNING': return styles.dotRun; default: return styles.dotOk; } } export function ExchangeList({ exchanges, selectedId, onSelect }: ExchangeListProps) { if (exchanges.length === 0) { return
No exchanges found
; } return (
{exchanges.map((ex) => (
onSelect(ex)} >
{ex.executionId.slice(0, 12)}
{formatDuration(ex.durationMs)} {formatTime(ex.startTime)}
))}
); } ``` - [ ] **Step 3: Verify build** Run: `cd ui && npx tsc --noEmit` - [ ] **Step 4: Commit** ```bash git add ui/src/pages/Exchanges/ExchangeList.tsx ui/src/pages/Exchanges/ExchangeList.module.css git commit -m "feat(ui): add ExchangeList compact component for 3-column layout" ``` --- ## Task 5: Create ExchangeHeader component Compact exchange summary + correlation chain for the top of the right panel. **Files:** - Create: `ui/src/pages/Exchanges/ExchangeHeader.tsx` - [ ] **Step 1: Create the component** This component extracts the exchange header pattern from `ExchangeDetail.tsx` (lines ~1-50 of the header card section). ```typescript // ui/src/pages/Exchanges/ExchangeHeader.tsx import { StatusDot, MonoText, Badge } from '@cameleer/design-system'; import { useCorrelationChain } from '../../api/queries/correlation'; import type { ExecutionDetail } from '../../components/ExecutionDiagram/types'; interface ExchangeHeaderProps { detail: ExecutionDetail; onExchangeClick?: (executionId: string) => void; } function statusVariant(s: string): 'success' | 'error' | 'running' | 'warning' { switch (s) { case 'COMPLETED': return 'success'; case 'FAILED': return 'error'; case 'RUNNING': return 'running'; default: return 'warning'; } } function formatDuration(ms: number): string { if (ms >= 60_000) return `${(ms / 1000).toFixed(0)}s`; if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s`; return `${ms}ms`; } export function ExchangeHeader({ detail, onExchangeClick }: ExchangeHeaderProps) { const { data: chain } = useCorrelationChain(detail.correlationId ?? null); const correlatedExchanges = (chain ?? []).filter((e: any) => chain && chain.length > 1); return (
{detail.exchangeId || detail.executionId} {detail.routeId} {formatDuration(detail.durationMs)}
{/* Correlation chain */} {correlatedExchanges.length > 1 && (
Correlated: {correlatedExchanges.map((e: any) => ( ))}
)}
); } ``` **Note:** This uses inline styles intentionally since it's a compact, one-off header. If it grows, extract to a CSS module. - [ ] **Step 2: Check that `useCorrelationChain` exists** Run: `cd ui && grep -r "useCorrelationChain" src/api/queries/` Expected: Found in `correlation.ts`. If not found, check `executions.ts` for correlation query. - [ ] **Step 3: Verify build** Run: `cd ui && npx tsc --noEmit` - [ ] **Step 4: Commit** ```bash git add ui/src/pages/Exchanges/ExchangeHeader.tsx git commit -m "feat(ui): add ExchangeHeader component with correlation chain" ``` --- ## Task 6: Create ExchangesPage Orchestrates full-width table (no route scope) vs 3-column layout (route-scoped). **Files:** - Create: `ui/src/pages/Exchanges/ExchangesPage.tsx` - Create: `ui/src/pages/Exchanges/ExchangesPage.module.css` - [ ] **Step 1: Create the CSS module** ```css /* ui/src/pages/Exchanges/ExchangesPage.module.css */ .threeColumn { display: grid; grid-template-columns: 280px 1fr; height: 100%; overflow: hidden; } .rightPanel { display: flex; flex-direction: column; overflow: hidden; height: 100%; } .emptyRight { display: flex; align-items: center; justify-content: center; height: 100%; color: var(--text-muted); font-size: 0.875rem; } ``` - [ ] **Step 2: Create the page component** ```typescript // ui/src/pages/Exchanges/ExchangesPage.tsx import { useState, useMemo, useCallback } from 'react'; import { useParams } from 'react-router'; import { useGlobalFilters } from '@cameleer/design-system'; import { useSearchExecutions } from '../../api/queries/executions'; import { useDiagramByRoute } from '../../api/queries/diagrams'; import { useRouteCatalog } from '../../api/queries/catalog'; import type { ExecutionSummary } from '../../api/types'; import { ExchangeList } from './ExchangeList'; import { ExchangeHeader } from './ExchangeHeader'; import { ExecutionDiagram } from '../../components/ExecutionDiagram/ExecutionDiagram'; import { ProcessDiagram } from '../../components/ProcessDiagram'; import { useExecutionDetail } from '../../api/queries/executions'; import styles from './ExchangesPage.module.css'; // Lazy-import the full-width Dashboard for the no-route-scope view. // This avoids duplicating the KPI strip + DataTable + detail panel logic. import Dashboard from '../Dashboard/Dashboard'; export default function ExchangesPage() { const { appId, routeId, exchangeId } = useParams<{ appId?: string; routeId?: string; exchangeId?: string; }>(); // If no route is scoped, render the existing full-width Dashboard table. // Dashboard already reads appId/routeId from useParams(). if (!routeId) { return ; } // Route is scoped: render 3-column layout return ( ); } // ─── 3-column view when route is scoped ───────────────────────────────────── interface RouteExchangeViewProps { appId: string; routeId: string; initialExchangeId?: string; } function RouteExchangeView({ appId, routeId, initialExchangeId }: RouteExchangeViewProps) { const [selectedExchangeId, setSelectedExchangeId] = useState(initialExchangeId); const { timeRange } = useGlobalFilters(); const timeFrom = timeRange.start.toISOString(); const timeTo = timeRange.end.toISOString(); // Fetch exchanges for this route const { data: searchResult } = useSearchExecutions( { timeFrom, timeTo, routeId, application: appId, sortField: 'startTime', sortDir: 'desc', offset: 0, limit: 50 }, true, ); const exchanges: ExecutionSummary[] = searchResult?.data || []; // Fetch execution detail for selected exchange const { data: detail } = useExecutionDetail(selectedExchangeId ?? null); // Fetch diagram for topology-only view (when no exchange selected) const diagramQuery = useDiagramByRoute(appId, routeId); // Known route IDs for drill-down resolution const { data: catalog } = useRouteCatalog(timeFrom, timeTo); const knownRouteIds = useMemo(() => { const ids = new Set(); if (catalog) { for (const app of catalog) { for (const r of app.routes || []) { ids.add(r.routeId); } } } return ids; }, [catalog]); const handleExchangeSelect = useCallback((ex: ExecutionSummary) => { setSelectedExchangeId(ex.executionId); }, []); return (
{/* Left column: exchange list */} {/* Right column: exchange header + diagram + detail */}
{selectedExchangeId && detail ? ( <> ) : ( /* No exchange selected: show topology-only diagram */ diagramQuery.data ? ( ) : (
Select an exchange to view execution details
) )}
); } ``` - [ ] **Step 3: Verify build** Run: `cd ui && npx tsc --noEmit` Fix any type errors. Common issues: - `useDiagramByRoute` might need different params — check `ui/src/api/queries/diagrams.ts` - `ExecutionSummary` import path — check `ui/src/api/types.ts` - [ ] **Step 4: Commit** ```bash git add ui/src/pages/Exchanges/ git commit -m "feat(ui): add ExchangesPage with full-width and 3-column modes" ``` --- ## Task 7: Create RuntimePage and DashboardPage wrappers Thin wrappers that render the existing page components based on URL params. **Files:** - Create: `ui/src/pages/RuntimeTab/RuntimePage.tsx` - Create: `ui/src/pages/DashboardTab/DashboardPage.tsx` - [ ] **Step 1: Create RuntimePage** ```typescript // ui/src/pages/RuntimeTab/RuntimePage.tsx import { useParams } from 'react-router'; import { lazy, Suspense } from 'react'; import { Spinner } from '@cameleer/design-system'; const AgentHealth = lazy(() => import('../AgentHealth/AgentHealth')); const AgentInstance = lazy(() => import('../AgentInstance/AgentInstance')); const Fallback =
; export default function RuntimePage() { const { instanceId } = useParams<{ appId?: string; instanceId?: string }>(); // If instanceId is present, show agent instance detail; otherwise show agent health overview if (instanceId) { return ; } return ; } ``` - [ ] **Step 2: Create DashboardPage** ```typescript // ui/src/pages/DashboardTab/DashboardPage.tsx import { useParams } from 'react-router'; import { lazy, Suspense } from 'react'; import { Spinner } from '@cameleer/design-system'; const RoutesMetrics = lazy(() => import('../Routes/RoutesMetrics')); const RouteDetail = lazy(() => import('../Routes/RouteDetail')); const Fallback =
; export default function DashboardPage() { const { routeId } = useParams<{ appId?: string; routeId?: string }>(); // If routeId is present, show route detail; otherwise show routes metrics overview if (routeId) { return ; } return ; } ``` - [ ] **Step 3: Verify build** Run: `cd ui && npx tsc --noEmit` - [ ] **Step 4: Commit** ```bash git add ui/src/pages/RuntimeTab/ ui/src/pages/DashboardTab/ git commit -m "feat(ui): add RuntimePage and DashboardPage tab wrappers" ``` --- ## Task 8: Update router with new URL structure Replace the old page-based routes with the new tab-based structure. **Files:** - Modify: `ui/src/router.tsx` - [ ] **Step 1: Rewrite router.tsx** Replace the content of `ui/src/router.tsx` with: ```typescript // ui/src/router.tsx import { createBrowserRouter, Navigate, useParams } from 'react-router'; import { ProtectedRoute } from './auth/ProtectedRoute'; import { LoginPage } from './auth/LoginPage'; import { OidcCallback } from './auth/OidcCallback'; import { LayoutShell } from './components/LayoutShell'; import { lazy, Suspense } from 'react'; import { Spinner } from '@cameleer/design-system'; const ExchangesPage = lazy(() => import('./pages/Exchanges/ExchangesPage')); const DashboardPage = lazy(() => import('./pages/DashboardTab/DashboardPage')); const RuntimePage = lazy(() => import('./pages/RuntimeTab/RuntimePage')); const AdminLayout = lazy(() => import('./pages/Admin/AdminLayout')); const RbacPage = lazy(() => import('./pages/Admin/RbacPage')); const AuditLogPage = lazy(() => import('./pages/Admin/AuditLogPage')); const OidcConfigPage = lazy(() => import('./pages/Admin/OidcConfigPage')); const DatabaseAdminPage = lazy(() => import('./pages/Admin/DatabaseAdminPage')); const OpenSearchAdminPage = lazy(() => import('./pages/Admin/OpenSearchAdminPage')); const AppConfigPage = lazy(() => import('./pages/Admin/AppConfigPage')); const SwaggerPage = lazy(() => import('./pages/Swagger/SwaggerPage')); function SuspenseWrapper({ children }: { children: React.ReactNode }) { return (
}> {children} ); } export const router = createBrowserRouter([ { path: '/login', element: }, { path: '/oidc/callback', element: }, { element: , children: [ { element: , children: [ // Default redirect { index: true, element: }, // Exchanges tab { path: 'exchanges', element: }, { path: 'exchanges/:appId', element: }, { path: 'exchanges/:appId/:routeId', element: }, { path: 'exchanges/:appId/:routeId/:exchangeId', element: }, // Dashboard tab { path: 'dashboard', element: }, { path: 'dashboard/:appId', element: }, { path: 'dashboard/:appId/:routeId', element: }, // Runtime tab { path: 'runtime', element: }, { path: 'runtime/:appId', element: }, { path: 'runtime/:appId/:instanceId', element: }, // Legacy redirects (sidebar uses /apps/... and /agents/... paths) { path: 'apps', element: }, { path: 'apps/:appId', element: }, { path: 'apps/:appId/:routeId', element: }, { path: 'agents', element: }, { path: 'agents/:appId', element: }, { path: 'agents/:appId/:instanceId', element: }, // Old exchange detail redirect { path: 'exchanges-old/:id', element: }, // Admin (unchanged) { path: 'admin', element: , children: [ { index: true, element: }, { path: 'rbac', element: }, { path: 'audit', element: }, { path: 'oidc', element: }, { path: 'appconfig', element: }, { path: 'database', element: }, { path: 'opensearch', element: }, ], }, { path: 'api-docs', element: }, ], }, ], }, ]); // Legacy redirect components — translate old sidebar paths to current tab // (useParams is already imported at the top of this file) function LegacyAppRedirect() { const { appId, routeId } = useParams<{ appId: string; routeId?: string }>(); const path = routeId ? `/exchanges/${appId}/${routeId}` : `/exchanges/${appId}`; return ; } function LegacyAgentRedirect() { const { appId, instanceId } = useParams<{ appId: string; instanceId?: string }>(); const path = instanceId ? `/runtime/${appId}/${instanceId}` : `/runtime/${appId}`; return ; } ``` The `useParams` import from `react-router` is already at the top alongside the other router imports. The legacy redirects ensure the Sidebar's hardcoded `/apps/...` and `/agents/...` Links still work (they redirect to the Exchanges tab by default). - [ ] **Step 2: Verify build** Run: `cd ui && npx tsc --noEmit` - [ ] **Step 3: Commit** ```bash git add ui/src/router.tsx git commit -m "feat(ui): restructure router for tab-based navigation with legacy redirects" ``` --- ## Task 9: Update LayoutShell Wire ContentTabs, ScopeTrail, sidebar interception, and remove agents from sidebar data. **Files:** - Modify: `ui/src/components/LayoutShell.tsx` - [ ] **Step 1: Update imports** Add these imports to the top of `LayoutShell.tsx`: ```typescript import { ContentTabs } from './ContentTabs'; import { ScopeTrail } from './ScopeTrail'; import { useScope } from '../hooks/useScope'; ``` - [ ] **Step 2: Remove agents from sidebar data** In the `sidebarApps` useMemo (around line 106), change the agents mapping to always return an empty array: Replace: ```typescript agents: (app.agents || []).map((a: any) => ({ id: a.id, name: a.name, status: a.status as 'live' | 'stale' | 'dead', tps: a.tps, })), ``` With: ```typescript agents: [], ``` - [ ] **Step 3: Add scope + sidebar interception to LayoutContent** At the top of the `LayoutContent` function (after existing hooks around line 92), add: ```typescript const { scope, setTab, clearScope } = useScope(); ``` Add the sidebar click interceptor function (before the return statement): ```typescript // Intercept Sidebar's internal navigation to re-route through current tab const handleSidebarClick = useCallback((e: React.MouseEvent) => { const anchor = (e.target as HTMLElement).closest('a[href]'); if (!anchor) return; const href = anchor.getAttribute('href') || ''; // Intercept /apps/:appId and /apps/:appId/:routeId links const appMatch = href.match(/^\/apps\/([^/]+)(?:\/(.+))?$/); if (appMatch) { e.preventDefault(); const [, sAppId, sRouteId] = appMatch; navigate(sRouteId ? `/${scope.tab}/${sAppId}/${sRouteId}` : `/${scope.tab}/${sAppId}`); return; } // Intercept /agents/* links — redirect to runtime tab const agentMatch = href.match(/^\/agents\/([^/]+)(?:\/(.+))?$/); if (agentMatch) { e.preventDefault(); const [, sAppId, sInstanceId] = agentMatch; navigate(sInstanceId ? `/runtime/${sAppId}/${sInstanceId}` : `/runtime/${sAppId}`); } }, [navigate, scope.tab]); ``` - [ ] **Step 4: Replace breadcrumbs with ScopeTrail** Replace the existing `breadcrumb` useMemo block (lines 168-188) with: ```typescript // Breadcrumb is now the ScopeTrail — built from scope, not URL path // Keep the old breadcrumb generation for admin pages only const isAdminPage = location.pathname.startsWith('/admin'); const breadcrumb = useMemo(() => { if (!isAdminPage) return []; // ScopeTrail handles non-admin breadcrumbs const LABELS: Record = { admin: 'Admin', rbac: 'Users & Roles', audit: 'Audit Log', oidc: 'OIDC', database: 'Database', opensearch: 'OpenSearch', appconfig: 'App Config', }; const parts = location.pathname.split('/').filter(Boolean); return parts.map((part, i) => ({ label: LABELS[part] ?? part, ...(i < parts.length - 1 ? { href: '/' + parts.slice(0, i + 1).join('/') } : {}), })); }, [location.pathname, isAdminPage]); ``` - [ ] **Step 5: Update CommandPalette submit handler** Replace the `handlePaletteSubmit` callback (around line 202) so it uses the Exchanges tab path: ```typescript const handlePaletteSubmit = useCallback((query: string) => { // Full-text search: navigate to Exchanges tab with text param const baseParts = [`/exchanges`]; if (scope.appId) baseParts.push(scope.appId); if (scope.routeId) baseParts.push(scope.routeId); navigate(`${baseParts.join('/')}?text=${encodeURIComponent(query)}`); }, [navigate, scope.appId, scope.routeId]); ``` - [ ] **Step 6: Update the search result paths in buildSearchData** In `buildSearchData` function, update the `path` values for apps and routes: Replace: ```typescript path: `/apps/${app.appId}`, ``` With: ```typescript path: `/exchanges/${app.appId}`, ``` Replace: ```typescript path: `/apps/${app.appId}/${route.routeId}`, ``` With: ```typescript path: `/exchanges/${app.appId}/${route.routeId}`, ``` Replace: ```typescript path: `/agents/${agent.application}/${agent.id}`, ``` With: ```typescript path: `/runtime/${agent.application}/${agent.id}`, ``` - [ ] **Step 7: Update exchange search result paths** In the `searchData` useMemo (around line 132), update the exchange path: Replace: ```typescript path: `/exchanges/${e.executionId}`, ``` With a path that includes the app and route for proper 3-column view: ```typescript path: `/exchanges/${e.applicationName ?? ''}/${e.routeId}/${e.executionId}`, ``` Do the same for `attributeItems` path. - [ ] **Step 8: Update the JSX return** Replace the return block of `LayoutContent` with: ```typescript return ( } > setPaletteOpen(false)} onOpen={() => setPaletteOpen(true)} onSelect={handlePaletteSelect} onSubmit={handlePaletteSubmit} onQueryChange={setPaletteQuery} data={searchData} /> {/* Content tabs + scope trail — only for main content, not admin */} {!isAdminPage && ( <>
navigate(path)} />
)}
); ``` - [ ] **Step 9: Verify build** Run: `cd ui && npx tsc --noEmit` Fix any type errors. Then run the dev server: Run: `cd ui && npm run dev` Visually verify: - Tab bar appears below TopBar with Exchanges | Dashboard | Runtime - Scope trail shows "All Applications" by default - Clicking an app in sidebar scopes to that app and stays on current tab - Clicking a route transitions to 3-column layout (Exchanges tab) - Clicking Dashboard or Runtime tabs shows the correct content - Admin pages still work without tabs/scope trail - [ ] **Step 10: Commit** ```bash git add ui/src/components/LayoutShell.tsx git commit -m "feat(ui): integrate ContentTabs, ScopeTrail, and sidebar scope interception" ``` --- ## Task 10: Final cleanup and verification **Files:** - Modify: `ui/src/pages/Dashboard/Dashboard.tsx` (update inspect link path) - [ ] **Step 1: Update Dashboard inspect link** In `Dashboard.tsx`, the inspect button navigates to `/exchanges/:id` (the old exchange detail page). Update it to stay in the current scope. Find the inspect column render (around line 347): Replace: ```typescript navigate(`/exchanges/${row.executionId}`) ``` With: ```typescript navigate(`/exchanges/${row.applicationName}/${row.routeId}/${row.executionId}`) ``` This navigates to the 3-column view with the exchange pre-selected. - [ ] **Step 2: Update Dashboard "Open full details" link** In the detail panel section (around line 465): Replace: ```typescript onClick={() => navigate(`/exchanges/${detail.executionId}`)} ``` With: ```typescript onClick={() => navigate(`/exchanges/${detail.applicationName}/${detail.routeId}/${detail.executionId}`)} ``` - [ ] **Step 3: Verify all navigation flows** Run the dev server and verify: ```bash cd ui && npm run dev ``` Verification checklist: 1. `/exchanges` — Shows full-width exchange table with KPI strip 2. Click app in sidebar — URL updates to `/exchanges/:appId`, table filters 3. Click route in sidebar — URL updates to `/exchanges/:appId/:routeId`, 3-column layout appears 4. Click exchange in left list — Right panel shows exchange header + diagram + details 5. Click "Dashboard" tab — URL changes to `/dashboard/:appId/:routeId` (scope preserved) 6. Dashboard tab shows RoutesMetrics (no route) or RouteDetail (with route) 7. Click "Runtime" tab — URL changes to `/runtime`, shows AgentHealth 8. Click agent in Runtime content — Shows AgentInstance detail 9. Scope trail segments are clickable and navigate correctly 10. Cmd+K search works, results navigate to correct new URLs 11. Admin pages (gear icon or /admin) still work with breadcrumbs, no tabs 12. Sidebar shows apps with routes only, no agents section - [ ] **Step 4: Verify production build** Run: `cd ui && npm run build` Expected: Clean build with no errors - [ ] **Step 5: Commit** ```bash git add -A git commit -m "feat(ui): complete navigation redesign - tab-based layout with scope filtering Redesigns navigation from page-based routing to scope-based model: - Three content tabs: Exchanges, Dashboard, Runtime - Sidebar simplified to app/route hierarchy (scope filter) - Scope trail replaces breadcrumbs - Exchanges tab: full-width table or 3-column layout with diagram - Legacy URL redirects for backward compatibility" ``` --- ## Known Limitations and Future Work 1. **Sidebar design system update needed:** The click interception via event delegation is a workaround. A proper `onNavigate` prop should be added to the design system's `Sidebar` component. 2. **Dashboard tab content:** The analytics/dashboard content is deferred per spec. Currently wraps existing RoutesMetrics and RouteDetail pages. 3. **ExchangeDetail page:** The old full-page ExchangeDetail at `/exchanges/:id` is replaced by the inline 3-column view. The ExchangeDetail component file is not deleted — it may be useful for reference. Clean up when confident the new view covers all use cases. 4. **Exchange header:** Uses inline styles for brevity. Extract to CSS module if it grows. 5. **KPI hero per tab:** Currently only the Exchanges tab has a KPI strip (from Dashboard). The Dashboard and Runtime tabs will get their own KPI strips in future iterations. 6. **Application log and replay:** These features from ExchangeDetail are accessible through the ExecutionDiagram's detail panel tabs but not directly in the exchange header. A future iteration could add log/replay buttons.