// ── router.js — SPA routing, navigation, breadcrumbs ───── // Circular imports with page modules are safe: all cross-module // accesses happen inside function bodies, never at module init time. import { escapeHTML } from './utils.js'; import { agentsState } from './state.js'; import { resetNavController } from './nav-signal.js'; import { renderDashboard, cleanup as cleanupDashboard } from './pages/dashboard.js'; import { renderSessions, cleanup as cleanupSessions } from './pages/sessions.js'; import { renderSession, cleanup as cleanupSessionDetail } from './pages/session-detail.js'; import { renderRun, cleanup as cleanupRunDetail } from './pages/run-detail.js'; import { renderAgents, cleanup as cleanupAgents } from './pages/agents.js'; import { renderInfrastructure, cleanup as cleanupInfra } from './pages/infrastructure.js'; import { renderSettings } from './pages/settings.js'; import { renderUsage, cleanup as cleanupUsage } from './pages/usage.js'; // Exported so all page modules can write into it without querying the DOM each time export const app = document.getElementById('app'); let currentRouteToken = 0; export function isRouteCurrent(token) { return token === currentRouteToken; } export function cleanupLiveViews() { cleanupInfra(); cleanupAgents(); cleanupSessions(); cleanupSessionDetail(); cleanupRunDetail(); cleanupDashboard(); cleanupUsage(); } export function route() { const routeToken = ++currentRouteToken; resetNavController(); cleanupLiveViews(); renderBreadcrumbs(); app.classList.add('transitioning'); const path = window.location.pathname; const safeRender = (fn) => { if (!isRouteCurrent(routeToken)) return; Promise.resolve().then(() => { if (!isRouteCurrent(routeToken)) return null; return fn(routeToken); }).catch(err => { if (!isRouteCurrent(routeToken)) return; if (err && err.name === 'AbortError') return; // route changed mid-fetch console.error('Render error:', err); app.innerHTML = `

Something went wrong

An error occurred while rendering this page.

${escapeHTML(err.message)}
Back to Dashboard
`; }); }; if (path === '/') { safeRender((token) => renderDashboard(token)); } else if (path === '/sessions') { safeRender((token) => renderSessions(token)); } else if (path.startsWith('/agents/')) { const agentKey = decodeURIComponent(path.split('/agents/')[1]); safeRender((token) => renderAgents(agentKey, token)); } else if (path.startsWith('/agents')) { safeRender((token) => renderAgents(undefined, token)); } else if (path.startsWith('/infrastructure')) { safeRender((token) => renderInfrastructure(token)); } else if (path.startsWith('/sessions/')) { safeRender((token) => renderSession(path.split('/sessions/')[1], token)); } else if (path.startsWith('/runs/')) { safeRender((token) => renderRun(path.split('/runs/')[1], token)); } else if (path === '/settings') { safeRender((token) => renderSettings(token)); } else if (path === '/usage') { safeRender((token) => renderUsage(token)); } else { app.innerHTML = '

Page not found

The page you\'re looking for doesn\'t exist.

Go to Dashboard
'; } updateActiveNav(); requestAnimationFrame(() => { if (isRouteCurrent(routeToken)) app.classList.remove('transitioning'); }); } export function renderBreadcrumbs() { const el = document.getElementById('breadcrumbs'); if (!el) return; const path = window.location.pathname; const parts = path.split('/').filter(Boolean); if (parts.length === 0) { el.innerHTML = ''; return; } const items = [{ label: 'Dashboard', path: '/' }]; let currentPath = ''; parts.forEach((part, i) => { currentPath += '/' + part; let label = part.charAt(0).toUpperCase() + part.slice(1); // Special labels for IDs if (part.length > 20 || /^[a-f0-9-]{32,}$/.test(part)) { label = part.substring(0, 8) + '…'; } // For /agents/:key, show the human-readable agent name if (parts[0] === 'agents' && i === 1) { const agentKey = decodeURIComponent(part); const agent = agentsState && agentsState.agents && agentsState.agents[agentKey]; label = (agent && agent.name) ? agent.name : agentKey; } if (i === parts.length - 1) { items.push({ label, current: true }); } else { items.push({ label, path: currentPath }); } }); // No inline onclick — the delegated click handler in app.js picks these up. el.innerHTML = items.map(item => { if (item.current) { return `${escapeHTML(item.label)}`; } return `${escapeHTML(item.label)}/`; }).join(''); } export function navigate(path) { history.pushState(null, '', path); route(); } export function updateActiveNav() { const path = window.location.pathname; document.querySelectorAll('header nav a').forEach(a => { const href = a.getAttribute('href'); const isActive = href === '/' ? path === '/' : path.startsWith(href); a.classList.toggle('active', isActive); }); } window.addEventListener('popstate', route);