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) <noreply@anthropic.com>
This commit is contained in:
@@ -1,23 +0,0 @@
|
|||||||
import { createContext, useContext, useState, type ReactNode } from 'react';
|
|
||||||
|
|
||||||
interface DetailPanelContextValue {
|
|
||||||
detail: ReactNode;
|
|
||||||
setDetail: (node: ReactNode) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DetailPanelContext = createContext<DetailPanelContextValue | null>(null);
|
|
||||||
|
|
||||||
export function DetailPanelProvider({ children }: { children: ReactNode }) {
|
|
||||||
const [detail, setDetail] = useState<ReactNode>(null);
|
|
||||||
return (
|
|
||||||
<DetailPanelContext.Provider value={{ detail, setDetail }}>
|
|
||||||
{children}
|
|
||||||
</DetailPanelContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDetailPanelSlot(): DetailPanelContextValue {
|
|
||||||
const ctx = useContext(DetailPanelContext);
|
|
||||||
if (!ctx) throw new Error('useDetailPanelSlot must be used within DetailPanelProvider');
|
|
||||||
return ctx;
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,6 @@ import { useRouteCatalog } from '../api/queries/catalog';
|
|||||||
import { useAgents } from '../api/queries/agents';
|
import { useAgents } from '../api/queries/agents';
|
||||||
import { useAuthStore } from '../auth/auth-store';
|
import { useAuthStore } from '../auth/auth-store';
|
||||||
import { useMemo, useCallback } from 'react';
|
import { useMemo, useCallback } from 'react';
|
||||||
import { DetailPanelProvider, useDetailPanelSlot } from './DetailPanelContext';
|
|
||||||
|
|
||||||
function healthToColor(health: string): string {
|
function healthToColor(health: string): string {
|
||||||
switch (health) {
|
switch (health) {
|
||||||
@@ -69,7 +68,6 @@ function LayoutContent() {
|
|||||||
const { data: agents } = useAgents();
|
const { data: agents } = useAgents();
|
||||||
const { username, logout } = useAuthStore();
|
const { username, logout } = useAuthStore();
|
||||||
const { open: paletteOpen, setOpen: setPaletteOpen } = useCommandPalette();
|
const { open: paletteOpen, setOpen: setPaletteOpen } = useCommandPalette();
|
||||||
const { detail } = useDetailPanelSlot();
|
|
||||||
|
|
||||||
const sidebarApps: SidebarApp[] = useMemo(() => {
|
const sidebarApps: SidebarApp[] = useMemo(() => {
|
||||||
if (!catalog) return [];
|
if (!catalog) return [];
|
||||||
@@ -124,7 +122,6 @@ function LayoutContent() {
|
|||||||
apps={sidebarApps}
|
apps={sidebarApps}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
detail={detail}
|
|
||||||
>
|
>
|
||||||
<TopBar
|
<TopBar
|
||||||
breadcrumb={breadcrumb}
|
breadcrumb={breadcrumb}
|
||||||
@@ -140,6 +137,8 @@ function LayoutContent() {
|
|||||||
<main style={{ flex: 1, overflow: 'auto', padding: '1.5rem' }}>
|
<main style={{ flex: 1, overflow: 'auto', padding: '1.5rem' }}>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
|
{/* Portal target for DetailPanel — pages use createPortal to render here */}
|
||||||
|
<div id="detail-panel-portal" />
|
||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -149,9 +148,7 @@ export function LayoutShell() {
|
|||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<CommandPaletteProvider>
|
<CommandPaletteProvider>
|
||||||
<GlobalFilterProvider>
|
<GlobalFilterProvider>
|
||||||
<DetailPanelProvider>
|
|
||||||
<LayoutContent />
|
<LayoutContent />
|
||||||
</DetailPanelProvider>
|
|
||||||
</GlobalFilterProvider>
|
</GlobalFilterProvider>
|
||||||
</CommandPaletteProvider>
|
</CommandPaletteProvider>
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
|
|||||||
@@ -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 { useParams, Link } from 'react-router';
|
||||||
import {
|
import {
|
||||||
StatCard, StatusDot, Badge, MonoText, ProgressBar,
|
StatCard, StatusDot, Badge, MonoText, ProgressBar,
|
||||||
@@ -9,7 +10,6 @@ import styles from './AgentHealth.module.css';
|
|||||||
import { useAgents, useAgentEvents } from '../../api/queries/agents';
|
import { useAgents, useAgentEvents } from '../../api/queries/agents';
|
||||||
import { useAgentMetrics } from '../../api/queries/agent-metrics';
|
import { useAgentMetrics } from '../../api/queries/agent-metrics';
|
||||||
import type { AgentInstance } from '../../api/types';
|
import type { AgentInstance } from '../../api/types';
|
||||||
import { useDetailPanelSlot } from '../../components/DetailPanelContext';
|
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -219,7 +219,6 @@ export default function AgentHealth() {
|
|||||||
const { appId } = useParams();
|
const { appId } = useParams();
|
||||||
const { data: agents } = useAgents(undefined, appId);
|
const { data: agents } = useAgents(undefined, appId);
|
||||||
const { data: events } = useAgentEvents(appId);
|
const { data: events } = useAgentEvents(appId);
|
||||||
const { setDetail } = useDetailPanelSlot();
|
|
||||||
|
|
||||||
const [selectedInstance, setSelectedInstance] = useState<AgentInstance | null>(null);
|
const [selectedInstance, setSelectedInstance] = useState<AgentInstance | null>(null);
|
||||||
const [panelOpen, setPanelOpen] = useState(false);
|
const [panelOpen, setPanelOpen] = useState(false);
|
||||||
@@ -505,41 +504,18 @@ export default function AgentHealth() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Detail panel — hoisted to AppShell via context */}
|
{/* Detail panel — portaled to AppShell level for proper slide-in */}
|
||||||
<AgentDetailPanelSlot
|
{selectedInstance && document.getElementById('detail-panel-portal') &&
|
||||||
|
createPortal(
|
||||||
|
<DetailPanel
|
||||||
open={panelOpen}
|
open={panelOpen}
|
||||||
onClose={() => { setPanelOpen(false); setSelectedInstance(null); }}
|
onClose={() => { setPanelOpen(false); setSelectedInstance(null); }}
|
||||||
selectedInstance={selectedInstance}
|
title={selectedInstance.name ?? selectedInstance.id}
|
||||||
detailTabs={detailTabs}
|
tabs={detailTabs}
|
||||||
setDetail={setDetail}
|
/>,
|
||||||
/>
|
document.getElementById('detail-panel-portal')!,
|
||||||
|
)
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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(
|
|
||||||
<DetailPanel
|
|
||||||
open={open}
|
|
||||||
onClose={onClose}
|
|
||||||
title={selectedInstance.name ?? selectedInstance.id}
|
|
||||||
tabs={detailTabs}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
return () => setDetail(null);
|
|
||||||
});
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 { useParams, useNavigate } from 'react-router'
|
||||||
import {
|
import {
|
||||||
DataTable,
|
DataTable,
|
||||||
@@ -22,7 +23,6 @@ import {
|
|||||||
import { useDiagramLayout } from '../../api/queries/diagrams'
|
import { useDiagramLayout } from '../../api/queries/diagrams'
|
||||||
import type { ExecutionSummary } from '../../api/types'
|
import type { ExecutionSummary } from '../../api/types'
|
||||||
import { mapDiagramToRouteNodes } from '../../utils/diagram-mapping'
|
import { mapDiagramToRouteNodes } from '../../utils/diagram-mapping'
|
||||||
import { useDetailPanelSlot } from '../../components/DetailPanelContext'
|
|
||||||
import styles from './Dashboard.module.css'
|
import styles from './Dashboard.module.css'
|
||||||
|
|
||||||
// Row type extends ExecutionSummary with an `id` field for DataTable
|
// Row type extends ExecutionSummary with an `id` field for DataTable
|
||||||
@@ -175,7 +175,6 @@ const SHORTCUTS = [
|
|||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const { appId, routeId } = useParams<{ appId: string; routeId: string }>()
|
const { appId, routeId } = useParams<{ appId: string; routeId: string }>()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { setDetail } = useDetailPanelSlot()
|
|
||||||
const [selectedId, setSelectedId] = useState<string | undefined>()
|
const [selectedId, setSelectedId] = useState<string | undefined>()
|
||||||
const [panelOpen, setPanelOpen] = useState(false)
|
const [panelOpen, setPanelOpen] = useState(false)
|
||||||
const [sortField, setSortField] = useState<string>('startTime')
|
const [sortField, setSortField] = useState<string>('startTime')
|
||||||
@@ -416,47 +415,12 @@ export default function Dashboard() {
|
|||||||
{/* Shortcuts bar */}
|
{/* Shortcuts bar */}
|
||||||
<ShortcutsBar shortcuts={SHORTCUTS} />
|
<ShortcutsBar shortcuts={SHORTCUTS} />
|
||||||
|
|
||||||
{/* Detail panel — hoisted to AppShell via context */}
|
{/* Detail panel — portaled to AppShell level for proper slide-in */}
|
||||||
<DashboardDetailPanel
|
{selectedRow && detail && document.getElementById('detail-panel-portal') &&
|
||||||
|
createPortal(
|
||||||
|
<DetailPanel
|
||||||
open={panelOpen}
|
open={panelOpen}
|
||||||
onClose={() => setPanelOpen(false)}
|
onClose={() => setPanelOpen(false)}
|
||||||
selectedRow={selectedRow}
|
|
||||||
detail={detail}
|
|
||||||
errorClass={errorClass}
|
|
||||||
errorMsg={errorMsg}
|
|
||||||
routeNodes={routeNodes}
|
|
||||||
flatProcs={flatProcs}
|
|
||||||
navigate={navigate}
|
|
||||||
setDetail={setDetail}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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(
|
|
||||||
<DetailPanel
|
|
||||||
open={open}
|
|
||||||
onClose={onClose}
|
|
||||||
title={`${detail.routeId} \u2014 ${selectedRow.executionId.slice(0, 12)}`}
|
title={`${detail.routeId} \u2014 ${selectedRow.executionId.slice(0, 12)}`}
|
||||||
>
|
>
|
||||||
<div className={styles.panelSection}>
|
<div className={styles.panelSection}>
|
||||||
@@ -534,10 +498,10 @@ function DashboardDetailPanel({
|
|||||||
<div style={{ color: 'var(--text-muted)', fontSize: 12 }}>No processor data</div>
|
<div style={{ color: 'var(--text-muted)', fontSize: 12 }}>No processor data</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</DetailPanel>
|
</DetailPanel>,
|
||||||
|
document.getElementById('detail-panel-portal')!,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
return () => setDetail(null)
|
|
||||||
})
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user