Files
cameleer-server/docs/superpowers/plans/2026-03-28-navigation-redesign.md
hsiegeln 673f0958c5
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m4s
CI / docker (push) Successful in 58s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 38s
revert: temporarily revert EIP_CIRCUIT_BREAKER compound rendering
Reverting e8039f9 to diagnose compound rendering regression affecting
all compound types (SPLIT, CHOICE, LOOP, DO_TRY) and error handlers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 18:15:10 +02:00

41 KiB

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 <Link> paths (/apps/:appId, /agents/:appId/:instanceId). Since SidebarProps has no onNavigate callback, we intercept clicks via event delegation on a wrapper <div>, 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

// 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<TabKey>(['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
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

/* 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
// 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 (
    <nav className={styles.trail}>
      {segments.map((seg, i) => (
        <span key={seg.path} className={styles.segment}>
          {i > 0 && <span className={styles.separator}>&gt;</span>}
          {i < segments.length - 1 ? (
            <button className={styles.link} onClick={() => onNavigate(seg.path)}>
              {seg.label}
            </button>
          ) : (
            <span className={styles.current}>{seg.label}</span>
          )}
        </span>
      ))}
    </nav>
  );
}
  • Step 3: Verify build

Run: cd ui && npx tsc --noEmit

  • Step 4: Commit
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

/* ui/src/components/ContentTabs.module.css */
.wrapper {
  padding: 0 1.5rem;
  padding-top: 0.75rem;
  padding-bottom: 0;
}
  • Step 2: Create the component
// 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 (
    <div className={styles.wrapper}>
      <SegmentedTabs
        tabs={TABS}
        active={active}
        onChange={(v) => onChange(v as TabKey)}
      />
    </div>
  );
}
  • Step 3: Verify build

Run: cd ui && npx tsc --noEmit

  • Step 4: Commit
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

/* 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
// 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 <div className={styles.empty}>No exchanges found</div>;
  }

  return (
    <div className={styles.list}>
      {exchanges.map((ex) => (
        <div
          key={ex.executionId}
          className={`${styles.item} ${selectedId === ex.executionId ? styles.itemSelected : ''}`}
          onClick={() => onSelect(ex)}
        >
          <span className={`${styles.dot} ${dotClass(ex.status)}`} />
          <div className={styles.meta}>
            <div className={styles.exchangeId}>{ex.executionId.slice(0, 12)}</div>
          </div>
          <span className={styles.duration}>{formatDuration(ex.durationMs)}</span>
          <span className={styles.timestamp}>{formatTime(ex.startTime)}</span>
        </div>
      ))}
    </div>
  );
}
  • Step 3: Verify build

Run: cd ui && npx tsc --noEmit

  • Step 4: Commit
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).

// 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 (
    <div style={{
      display: 'flex', flexDirection: 'column', gap: '0.5rem',
      padding: '0.75rem', borderBottom: '1px solid var(--border)',
      background: 'var(--surface)', fontSize: '0.8125rem',
    }}>
      <div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
        <StatusDot variant={statusVariant(detail.status)} />
        <MonoText size="xs">{detail.exchangeId || detail.executionId}</MonoText>
        <Badge label={detail.status === 'COMPLETED' ? 'OK' : detail.status} color={statusVariant(detail.status)} />
        <span style={{ color: 'var(--text-muted)' }}>{detail.routeId}</span>
        <span style={{ marginLeft: 'auto', fontFamily: 'var(--font-mono)', fontSize: '0.75rem' }}>
          {formatDuration(detail.durationMs)}
        </span>
      </div>

      {/* Correlation chain */}
      {correlatedExchanges.length > 1 && (
        <div style={{ display: 'flex', gap: '0.25rem', flexWrap: 'wrap', alignItems: 'center' }}>
          <span style={{ fontSize: '0.6875rem', color: 'var(--text-muted)', marginRight: '0.25rem' }}>
            Correlated:
          </span>
          {correlatedExchanges.map((e: any) => (
            <button
              key={e.executionId}
              onClick={() => onExchangeClick?.(e.executionId)}
              style={{
                background: e.executionId === detail.executionId ? 'var(--surface-active)' : 'var(--surface)',
                border: '1px solid var(--border)',
                borderRadius: '4px',
                padding: '0.125rem 0.375rem',
                cursor: 'pointer',
                fontSize: '0.6875rem',
                fontFamily: 'var(--font-mono)',
                color: e.status === 'FAILED' ? 'var(--error)' : 'var(--text-secondary)',
              }}
            >
              {e.executionId.slice(0, 8)}
            </button>
          ))}
        </div>
      )}
    </div>
  );
}

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
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

/* 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
// 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 <Dashboard />;
  }

  // Route is scoped: render 3-column layout
  return (
    <RouteExchangeView appId={appId!} routeId={routeId} initialExchangeId={exchangeId} />
  );
}

// ─── 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<string | undefined>(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<string>();
    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 (
    <div className={styles.threeColumn}>
      {/* Left column: exchange list */}
      <ExchangeList
        exchanges={exchanges}
        selectedId={selectedExchangeId}
        onSelect={handleExchangeSelect}
      />

      {/* Right column: exchange header + diagram + detail */}
      <div className={styles.rightPanel}>
        {selectedExchangeId && detail ? (
          <>
            <ExchangeHeader detail={detail} />
            <ExecutionDiagram
              executionId={selectedExchangeId}
              executionDetail={detail}
              knownRouteIds={knownRouteIds}
            />
          </>
        ) : (
          /* No exchange selected: show topology-only diagram */
          diagramQuery.data ? (
            <ProcessDiagram
              application={appId}
              routeId={routeId}
              diagramLayout={diagramQuery.data}
              knownRouteIds={knownRouteIds}
            />
          ) : (
            <div className={styles.emptyRight}>
              Select an exchange to view execution details
            </div>
          )
        )}
      </div>
    </div>
  );
}
  • 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

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

// 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 = <div style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}><Spinner size="lg" /></div>;

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 <Suspense fallback={Fallback}><AgentInstance /></Suspense>;
  }
  return <Suspense fallback={Fallback}><AgentHealth /></Suspense>;
}
  • Step 2: Create DashboardPage
// 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 = <div style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}><Spinner size="lg" /></div>;

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 <Suspense fallback={Fallback}><RouteDetail /></Suspense>;
  }
  return <Suspense fallback={Fallback}><RoutesMetrics /></Suspense>;
}
  • Step 3: Verify build

Run: cd ui && npx tsc --noEmit

  • Step 4: Commit
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:

// 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 (
    <Suspense fallback={<div style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}><Spinner size="lg" /></div>}>
      {children}
    </Suspense>
  );
}

export const router = createBrowserRouter([
  { path: '/login', element: <LoginPage /> },
  { path: '/oidc/callback', element: <OidcCallback /> },
  {
    element: <ProtectedRoute />,
    children: [
      {
        element: <LayoutShell />,
        children: [
          // Default redirect
          { index: true, element: <Navigate to="/exchanges" replace /> },

          // Exchanges tab
          { path: 'exchanges', element: <SuspenseWrapper><ExchangesPage /></SuspenseWrapper> },
          { path: 'exchanges/:appId', element: <SuspenseWrapper><ExchangesPage /></SuspenseWrapper> },
          { path: 'exchanges/:appId/:routeId', element: <SuspenseWrapper><ExchangesPage /></SuspenseWrapper> },
          { path: 'exchanges/:appId/:routeId/:exchangeId', element: <SuspenseWrapper><ExchangesPage /></SuspenseWrapper> },

          // Dashboard tab
          { path: 'dashboard', element: <SuspenseWrapper><DashboardPage /></SuspenseWrapper> },
          { path: 'dashboard/:appId', element: <SuspenseWrapper><DashboardPage /></SuspenseWrapper> },
          { path: 'dashboard/:appId/:routeId', element: <SuspenseWrapper><DashboardPage /></SuspenseWrapper> },

          // Runtime tab
          { path: 'runtime', element: <SuspenseWrapper><RuntimePage /></SuspenseWrapper> },
          { path: 'runtime/:appId', element: <SuspenseWrapper><RuntimePage /></SuspenseWrapper> },
          { path: 'runtime/:appId/:instanceId', element: <SuspenseWrapper><RuntimePage /></SuspenseWrapper> },

          // Legacy redirects (sidebar uses /apps/... and /agents/... paths)
          { path: 'apps', element: <Navigate to="/exchanges" replace /> },
          { path: 'apps/:appId', element: <LegacyAppRedirect /> },
          { path: 'apps/:appId/:routeId', element: <LegacyAppRedirect /> },
          { path: 'agents', element: <Navigate to="/runtime" replace /> },
          { path: 'agents/:appId', element: <LegacyAgentRedirect /> },
          { path: 'agents/:appId/:instanceId', element: <LegacyAgentRedirect /> },

          // Old exchange detail redirect
          { path: 'exchanges-old/:id', element: <Navigate to="/exchanges" replace /> },

          // Admin (unchanged)
          {
            path: 'admin',
            element: <SuspenseWrapper><AdminLayout /></SuspenseWrapper>,
            children: [
              { index: true, element: <Navigate to="/admin/rbac" replace /> },
              { path: 'rbac', element: <SuspenseWrapper><RbacPage /></SuspenseWrapper> },
              { path: 'audit', element: <SuspenseWrapper><AuditLogPage /></SuspenseWrapper> },
              { path: 'oidc', element: <SuspenseWrapper><OidcConfigPage /></SuspenseWrapper> },
              { path: 'appconfig', element: <SuspenseWrapper><AppConfigPage /></SuspenseWrapper> },
              { path: 'database', element: <SuspenseWrapper><DatabaseAdminPage /></SuspenseWrapper> },
              { path: 'opensearch', element: <SuspenseWrapper><OpenSearchAdminPage /></SuspenseWrapper> },
            ],
          },
          { path: 'api-docs', element: <SuspenseWrapper><SwaggerPage /></SuspenseWrapper> },
        ],
      },
    ],
  },
]);

// 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 <Navigate to={path} replace />;
}

function LegacyAgentRedirect() {
  const { appId, instanceId } = useParams<{ appId: string; instanceId?: string }>();
  const path = instanceId ? `/runtime/${appId}/${instanceId}` : `/runtime/${appId}`;
  return <Navigate to={path} replace />;
}

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
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:

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:

      agents: (app.agents || []).map((a: any) => ({
        id: a.id,
        name: a.name,
        status: a.status as 'live' | 'stale' | 'dead',
        tps: a.tps,
      })),

With:

      agents: [],
  • Step 3: Add scope + sidebar interception to LayoutContent

At the top of the LayoutContent function (after existing hooks around line 92), add:

  const { scope, setTab, clearScope } = useScope();

Add the sidebar click interceptor function (before the return statement):

  // Intercept Sidebar's internal <Link> 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:

  // 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<string, string> = {
      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:

  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:

      path: `/apps/${app.appId}`,

With:

      path: `/exchanges/${app.appId}`,

Replace:

        path: `/apps/${app.appId}/${route.routeId}`,

With:

        path: `/exchanges/${app.appId}/${route.routeId}`,

Replace:

        path: `/agents/${agent.application}/${agent.id}`,

With:

        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:

      path: `/exchanges/${e.executionId}`,

With a path that includes the app and route for proper 3-column view:

      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:

  return (
    <AppShell
      sidebar={
        <div onClick={handleSidebarClick}>
          <Sidebar apps={sidebarApps} />
        </div>
      }
    >
      <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}
      />

      {/* Content tabs + scope trail — only for main content, not admin */}
      {!isAdminPage && (
        <>
          <ContentTabs active={scope.tab} onChange={setTab} />
          <div style={{ padding: '0 1.5rem', paddingTop: '0.5rem' }}>
            <ScopeTrail scope={scope} onNavigate={(path) => navigate(path)} />
          </div>
        </>
      )}

      <main style={{ flex: 1, overflow: 'auto', padding: isAdminPage ? '1.5rem' : '0.75rem 1.5rem' }}>
        <Outlet />
      </main>
    </AppShell>
  );
  • 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

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:

            navigate(`/exchanges/${row.executionId}`)

With:

            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:

                onClick={() => navigate(`/exchanges/${detail.executionId}`)}

With:

                onClick={() => navigate(`/exchanges/${detail.applicationName}/${detail.routeId}/${detail.executionId}`)}
  • Step 3: Verify all navigation flows

Run the dev server and verify:

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
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.