From f3241e904f4a536580e4f567e10c980663bc1c3b Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Tue, 24 Mar 2026 18:38:59 +0100 Subject: [PATCH] fix: use createPortal for DetailPanel instead of context+useEffect The previous approach used useEffect+context to hoist DetailPanel content to the AppShell level, but the dependency-free useEffect caused a re-render loop that broke sidebar navigation. Replace with createPortal: pages render DetailPanel inline in their JSX but portal it to a target div (#detail-panel-portal) at the AppShell level. No state lifting, no re-render loops. Co-Authored-By: Claude Opus 4.6 (1M context) --- ui/src/components/DetailPanelContext.tsx | 23 --- ui/src/components/LayoutShell.tsx | 9 +- ui/src/pages/AgentHealth/AgentHealth.tsx | 52 ++---- ui/src/pages/Dashboard/Dashboard.tsx | 214 ++++++++++------------- 4 files changed, 106 insertions(+), 192 deletions(-) delete mode 100644 ui/src/components/DetailPanelContext.tsx diff --git a/ui/src/components/DetailPanelContext.tsx b/ui/src/components/DetailPanelContext.tsx deleted file mode 100644 index a6651744..00000000 --- a/ui/src/components/DetailPanelContext.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { createContext, useContext, useState, type ReactNode } from 'react'; - -interface DetailPanelContextValue { - detail: ReactNode; - setDetail: (node: ReactNode) => void; -} - -const DetailPanelContext = createContext(null); - -export function DetailPanelProvider({ children }: { children: ReactNode }) { - const [detail, setDetail] = useState(null); - return ( - - {children} - - ); -} - -export function useDetailPanelSlot(): DetailPanelContextValue { - const ctx = useContext(DetailPanelContext); - if (!ctx) throw new Error('useDetailPanelSlot must be used within DetailPanelProvider'); - return ctx; -} diff --git a/ui/src/components/LayoutShell.tsx b/ui/src/components/LayoutShell.tsx index 47cc1e81..33fac29e 100644 --- a/ui/src/components/LayoutShell.tsx +++ b/ui/src/components/LayoutShell.tsx @@ -5,7 +5,6 @@ import { useRouteCatalog } from '../api/queries/catalog'; import { useAgents } from '../api/queries/agents'; import { useAuthStore } from '../auth/auth-store'; import { useMemo, useCallback } from 'react'; -import { DetailPanelProvider, useDetailPanelSlot } from './DetailPanelContext'; function healthToColor(health: string): string { switch (health) { @@ -69,7 +68,6 @@ function LayoutContent() { const { data: agents } = useAgents(); const { username, logout } = useAuthStore(); const { open: paletteOpen, setOpen: setPaletteOpen } = useCommandPalette(); - const { detail } = useDetailPanelSlot(); const sidebarApps: SidebarApp[] = useMemo(() => { if (!catalog) return []; @@ -124,7 +122,6 @@ function LayoutContent() { apps={sidebarApps} /> } - detail={detail} > + {/* Portal target for DetailPanel — pages use createPortal to render here */} +
); } @@ -149,9 +148,7 @@ export function LayoutShell() { - - - + diff --git a/ui/src/pages/AgentHealth/AgentHealth.tsx b/ui/src/pages/AgentHealth/AgentHealth.tsx index 224b9c30..e17ef5ac 100644 --- a/ui/src/pages/AgentHealth/AgentHealth.tsx +++ b/ui/src/pages/AgentHealth/AgentHealth.tsx @@ -1,4 +1,5 @@ -import { useState, useMemo, useEffect } from 'react'; +import { useState, useMemo } from 'react'; +import { createPortal } from 'react-dom'; import { useParams, Link } from 'react-router'; import { StatCard, StatusDot, Badge, MonoText, ProgressBar, @@ -9,7 +10,6 @@ import styles from './AgentHealth.module.css'; import { useAgents, useAgentEvents } from '../../api/queries/agents'; import { useAgentMetrics } from '../../api/queries/agent-metrics'; import type { AgentInstance } from '../../api/types'; -import { useDetailPanelSlot } from '../../components/DetailPanelContext'; // ── Helpers ────────────────────────────────────────────────────────────────── @@ -219,7 +219,6 @@ export default function AgentHealth() { const { appId } = useParams(); const { data: agents } = useAgents(undefined, appId); const { data: events } = useAgentEvents(appId); - const { setDetail } = useDetailPanelSlot(); const [selectedInstance, setSelectedInstance] = useState(null); const [panelOpen, setPanelOpen] = useState(false); @@ -505,41 +504,18 @@ export default function AgentHealth() {
)} - {/* Detail panel — hoisted to AppShell via context */} - { setPanelOpen(false); setSelectedInstance(null); }} - selectedInstance={selectedInstance} - detailTabs={detailTabs} - setDetail={setDetail} - /> + {/* Detail panel — portaled to AppShell level for proper slide-in */} + {selectedInstance && document.getElementById('detail-panel-portal') && + createPortal( + { setPanelOpen(false); setSelectedInstance(null); }} + title={selectedInstance.name ?? selectedInstance.id} + tabs={detailTabs} + />, + document.getElementById('detail-panel-portal')!, + ) + } ); } - -function AgentDetailPanelSlot({ - open, onClose, selectedInstance, detailTabs, setDetail, -}: { - open: boolean - onClose: () => void - selectedInstance: AgentInstance | null - detailTabs: any - setDetail: (node: React.ReactNode) => void -}) { - useEffect(() => { - if (!selectedInstance) { - setDetail(null); - return; - } - setDetail( - - ); - return () => setDetail(null); - }); - return null; -} diff --git a/ui/src/pages/Dashboard/Dashboard.tsx b/ui/src/pages/Dashboard/Dashboard.tsx index 949707b1..9035a147 100644 --- a/ui/src/pages/Dashboard/Dashboard.tsx +++ b/ui/src/pages/Dashboard/Dashboard.tsx @@ -1,4 +1,5 @@ -import { useState, useMemo, useCallback, useEffect } from 'react' +import { useState, useMemo, useCallback } from 'react' +import { createPortal } from 'react-dom' import { useParams, useNavigate } from 'react-router' import { DataTable, @@ -22,7 +23,6 @@ import { import { useDiagramLayout } from '../../api/queries/diagrams' import type { ExecutionSummary } from '../../api/types' import { mapDiagramToRouteNodes } from '../../utils/diagram-mapping' -import { useDetailPanelSlot } from '../../components/DetailPanelContext' import styles from './Dashboard.module.css' // Row type extends ExecutionSummary with an `id` field for DataTable @@ -175,7 +175,6 @@ const SHORTCUTS = [ export default function Dashboard() { const { appId, routeId } = useParams<{ appId: string; routeId: string }>() const navigate = useNavigate() - const { setDetail } = useDetailPanelSlot() const [selectedId, setSelectedId] = useState() const [panelOpen, setPanelOpen] = useState(false) const [sortField, setSortField] = useState('startTime') @@ -416,128 +415,93 @@ export default function Dashboard() { {/* Shortcuts bar */} - {/* Detail panel — hoisted to AppShell via context */} - setPanelOpen(false)} - selectedRow={selectedRow} - detail={detail} - errorClass={errorClass} - errorMsg={errorMsg} - routeNodes={routeNodes} - flatProcs={flatProcs} - navigate={navigate} - setDetail={setDetail} - /> + {/* Detail panel — portaled to AppShell level for proper slide-in */} + {selectedRow && detail && document.getElementById('detail-panel-portal') && + createPortal( + setPanelOpen(false)} + title={`${detail.routeId} \u2014 ${selectedRow.executionId.slice(0, 12)}`} + > +
+ +
+ +
+
Overview
+
+
+ Status + + + {statusLabel(detail.status)} + +
+
+ Duration + {formatDuration(detail.durationMs)} +
+
+ Route + {detail.routeId} +
+
+ Agent + {detail.agentId ?? '\u2014'} +
+
+ Correlation + {detail.correlationId ?? '\u2014'} +
+
+ Timestamp + {detail.startTime ? new Date(detail.startTime).toISOString() : '\u2014'} +
+
+
+ + {errorMsg && ( +
+
Errors
+
+
{errorClass}
+
{errorMsg}
+
+
+ )} + +
+
Route Flow
+ {routeNodes.length > 0 ? ( + + ) : ( +
No diagram available
+ )} +
+ +
+
+ Processor Timeline + {formatDuration(detail.durationMs)} +
+ {flatProcs.length > 0 ? ( + + ) : ( +
No processor data
+ )} +
+
, + document.getElementById('detail-panel-portal')!, + ) + } ) } - -/** Renders the DetailPanel into the AppShell detail slot via context. */ -function DashboardDetailPanel({ - open, onClose, selectedRow, detail, errorClass, errorMsg, routeNodes, flatProcs, navigate, setDetail, -}: { - open: boolean - onClose: () => void - selectedRow: Row | undefined - detail: any - errorClass: string - errorMsg: string - routeNodes: RouteNode[] - flatProcs: any[] - navigate: (path: string) => void - setDetail: (node: React.ReactNode) => void -}) { - useEffect(() => { - if (!selectedRow || !detail) { - setDetail(null) - return - } - setDetail( - -
- -
- -
-
Overview
-
-
- Status - - - {statusLabel(detail.status)} - -
-
- Duration - {formatDuration(detail.durationMs)} -
-
- Route - {detail.routeId} -
-
- Agent - {detail.agentId ?? '\u2014'} -
-
- Correlation - {detail.correlationId ?? '\u2014'} -
-
- Timestamp - {detail.startTime ? new Date(detail.startTime).toISOString() : '\u2014'} -
-
-
- - {errorMsg && ( -
-
Errors
-
-
{errorClass}
-
{errorMsg}
-
-
- )} - -
-
Route Flow
- {routeNodes.length > 0 ? ( - - ) : ( -
No diagram available
- )} -
- -
-
- Processor Timeline - {formatDuration(detail.durationMs)} -
- {flatProcs.length > 0 ? ( - - ) : ( -
No processor data
- )} -
-
- ) - return () => setDetail(null) - }) - - return null -}