fix: hoist DetailPanel into AppShell detail slot for proper slide-in
All checks were successful
CI / build (push) Successful in 1m22s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Successful in 51s
CI / deploy (push) Successful in 38s
CI / deploy-feature (push) Has been skipped

DetailPanel is a flex sibling that slides in from the right — it must
be rendered at the AppShell level via the detail prop, not inside the
page content. Add DetailPanelContext so pages can push their panel
content up to LayoutShell, which passes it to AppShell.detail.

Applied to Dashboard (exchange detail) and AgentHealth (instance detail).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-24 18:28:03 +01:00
parent 0a5f4a03b5
commit 5de792744e
4 changed files with 192 additions and 104 deletions

View File

@@ -0,0 +1,23 @@
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;
}

View File

@@ -5,6 +5,7 @@ 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) {
@@ -68,6 +69,7 @@ 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 [];
@@ -122,6 +124,7 @@ function LayoutContent() {
apps={sidebarApps} apps={sidebarApps}
/> />
} }
detail={detail}
> >
<TopBar <TopBar
breadcrumb={breadcrumb} breadcrumb={breadcrumb}
@@ -146,7 +149,9 @@ export function LayoutShell() {
<ToastProvider> <ToastProvider>
<CommandPaletteProvider> <CommandPaletteProvider>
<GlobalFilterProvider> <GlobalFilterProvider>
<DetailPanelProvider>
<LayoutContent /> <LayoutContent />
</DetailPanelProvider>
</GlobalFilterProvider> </GlobalFilterProvider>
</CommandPaletteProvider> </CommandPaletteProvider>
</ToastProvider> </ToastProvider>

View File

@@ -1,4 +1,4 @@
import { useState, useMemo } from 'react'; import { useState, useMemo, useEffect } from 'react';
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,6 +9,7 @@ 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 ──────────────────────────────────────────────────────────────────
@@ -218,6 +219,7 @@ 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);
@@ -503,18 +505,41 @@ export default function AgentHealth() {
</div> </div>
)} )}
{/* Detail panel */} {/* Detail panel — hoisted to AppShell via context */}
{selectedInstance && ( <AgentDetailPanelSlot
<DetailPanel
open={panelOpen} open={panelOpen}
onClose={() => { onClose={() => { setPanelOpen(false); setSelectedInstance(null); }}
setPanelOpen(false); selectedInstance={selectedInstance}
setSelectedInstance(null); detailTabs={detailTabs}
}} setDetail={setDetail}
title={selectedInstance.name ?? selectedInstance.id}
tabs={detailTabs}
/> />
)}
</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;
}

View File

@@ -1,4 +1,4 @@
import { useState, useMemo, useCallback } from 'react' import { useState, useMemo, useCallback, useEffect } from 'react'
import { useParams, useNavigate } from 'react-router' import { useParams, useNavigate } from 'react-router'
import { import {
DataTable, DataTable,
@@ -22,6 +22,7 @@ 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
@@ -174,6 +175,7 @@ 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')
@@ -414,14 +416,49 @@ export default function Dashboard() {
{/* Shortcuts bar */} {/* Shortcuts bar */}
<ShortcutsBar shortcuts={SHORTCUTS} /> <ShortcutsBar shortcuts={SHORTCUTS} />
{/* Detail panel */} {/* Detail panel — hoisted to AppShell via context */}
{selectedRow && detail && ( <DashboardDetailPanel
<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)}`}
> >
{/* Link to full detail page */}
<div className={styles.panelSection}> <div className={styles.panelSection}>
<button <button
className={styles.openDetailLink} className={styles.openDetailLink}
@@ -431,7 +468,6 @@ export default function Dashboard() {
</button> </button>
</div> </div>
{/* Overview */}
<div className={styles.panelSection}> <div className={styles.panelSection}>
<div className={styles.panelSectionTitle}>Overview</div> <div className={styles.panelSectionTitle}>Overview</div>
<div className={styles.overviewGrid}> <div className={styles.overviewGrid}>
@@ -465,7 +501,6 @@ export default function Dashboard() {
</div> </div>
</div> </div>
{/* Errors */}
{errorMsg && ( {errorMsg && (
<div className={styles.panelSection}> <div className={styles.panelSection}>
<div className={styles.panelSectionTitle}>Errors</div> <div className={styles.panelSectionTitle}>Errors</div>
@@ -476,7 +511,6 @@ export default function Dashboard() {
</div> </div>
)} )}
{/* Route Flow */}
<div className={styles.panelSection}> <div className={styles.panelSection}>
<div className={styles.panelSectionTitle}>Route Flow</div> <div className={styles.panelSectionTitle}>Route Flow</div>
{routeNodes.length > 0 ? ( {routeNodes.length > 0 ? (
@@ -486,7 +520,6 @@ export default function Dashboard() {
)} )}
</div> </div>
{/* Processor Timeline */}
<div className={styles.panelSection}> <div className={styles.panelSection}>
<div className={styles.panelSectionTitle}> <div className={styles.panelSectionTitle}>
Processor Timeline Processor Timeline
@@ -502,7 +535,9 @@ export default function Dashboard() {
)} )}
</div> </div>
</DetailPanel> </DetailPanel>
)}
</>
) )
return () => setDetail(null)
})
return null
} }