Fix blank portal: add root element heights, use dagre for graph layout

- Set explicit height on html/body/#root so React Flow container renders
- Replace @dagrejs/dagre (broken CJS dynamic require) with dagre v0.8.5
- Hierarchical top-down layout with dagre, arrow markers on edges
- Move React Flow controls to top-right to avoid floating button overlap
This commit is contained in:
2026-02-02 19:20:53 +01:00
parent fc075a377b
commit d1e3adcb3c
5 changed files with 101 additions and 29 deletions

View File

@@ -1,12 +1,12 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en" style="height:100%">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Cortex Portal</title> <title>Cortex Portal</title>
</head> </head>
<body class="bg-gray-950 text-gray-100"> <body class="bg-gray-950 text-gray-100" style="margin:0;height:100%;overflow:hidden">
<div id="root"></div> <div id="root" style="height:100%"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html> </html>

View File

@@ -9,7 +9,9 @@
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.62.0", "@tanstack/react-query": "^5.62.0",
"@types/dagre": "^0.7.53",
"@xyflow/react": "^12.4.0", "@xyflow/react": "^12.4.0",
"dagre": "^0.8.5",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0" "react-dom": "^19.0.0"
}, },
@@ -1546,6 +1548,12 @@
"@types/d3-selection": "*" "@types/d3-selection": "*"
} }
}, },
"node_modules/@types/dagre": {
"version": "0.7.53",
"resolved": "https://registry.npmjs.org/@types/dagre/-/dagre-0.7.53.tgz",
"integrity": "sha512-f4gkWqzPZvYmKhOsDnhq/R8mO4UMcKdxZo+i5SCkOU1wvGeHJeUXGIHeE9pnwGyPMDof1Vx5ZQo4nxpeg2TTVQ==",
"license": "MIT"
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1816,6 +1824,16 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/dagre": {
"version": "0.8.5",
"resolved": "https://registry.npmjs.org/dagre/-/dagre-0.8.5.tgz",
"integrity": "sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==",
"license": "MIT",
"dependencies": {
"graphlib": "^2.1.8",
"lodash": "^4.17.15"
}
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -1967,6 +1985,15 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/graphlib": {
"version": "2.1.8",
"resolved": "https://registry.npmjs.org/graphlib/-/graphlib-2.1.8.tgz",
"integrity": "sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==",
"license": "MIT",
"dependencies": {
"lodash": "^4.17.15"
}
},
"node_modules/jiti": { "node_modules/jiti": {
"version": "2.6.1", "version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
@@ -2271,6 +2298,12 @@
"url": "https://opencollective.com/parcel" "url": "https://opencollective.com/parcel"
} }
}, },
"node_modules/lodash": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"license": "MIT"
},
"node_modules/lru-cache": { "node_modules/lru-cache": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",

View File

@@ -10,7 +10,9 @@
}, },
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.62.0", "@tanstack/react-query": "^5.62.0",
"@types/dagre": "^0.7.53",
"@xyflow/react": "^12.4.0", "@xyflow/react": "^12.4.0",
"dagre": "^0.8.5",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0" "react-dom": "^19.0.0"
}, },
@@ -18,9 +20,9 @@
"@tailwindcss/vite": "^4.0.0", "@tailwindcss/vite": "^4.0.0",
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0",
"tailwindcss": "^4.0.0", "tailwindcss": "^4.0.0",
"typescript": "^5.9.0", "typescript": "^5.9.0",
"vite": "^6.0.0", "vite": "^6.0.0"
"@vitejs/plugin-react": "^4.3.0"
} }
} }

View File

@@ -44,7 +44,9 @@ export default function App() {
return ( return (
<div className="h-screen w-screen overflow-hidden relative"> <div className="h-screen w-screen overflow-hidden relative">
{/* Full-screen graph */} {/* Full-screen graph */}
<GraphView selectedId={selectedId} onSelect={selectNode} /> <div className="absolute inset-0">
<GraphView selectedId={selectedId} onSelect={selectNode} />
</div>
{/* Floating action buttons — bottom-left */} {/* Floating action buttons — bottom-left */}
<div className="fixed bottom-5 left-5 flex flex-col gap-2 z-30"> <div className="fixed bottom-5 left-5 flex flex-col gap-2 z-30">

View File

@@ -8,9 +8,12 @@ import {
useEdgesState, useEdgesState,
type Node as FlowNode, type Node as FlowNode,
type Edge as FlowEdge, type Edge as FlowEdge,
Position,
MarkerType,
} from '@xyflow/react'; } from '@xyflow/react';
import Dagre from 'dagre';
import { useGraph } from '../hooks/useGraph'; import { useGraph } from '../hooks/useGraph';
import type { CortexNode } from '../types'; import type { CortexNode, CortexEdge } from '../types';
const KIND_COLORS: Record<string, string> = { const KIND_COLORS: Record<string, string> = {
memory: '#6366f1', memory: '#6366f1',
@@ -19,23 +22,43 @@ const KIND_COLORS: Record<string, string> = {
decision: '#ef4444', decision: '#ef4444',
}; };
function layoutNodes(nodes: CortexNode[]): FlowNode[] { const NODE_WIDTH = 180;
const cols = Math.max(Math.ceil(Math.sqrt(nodes.length)), 1); const NODE_HEIGHT = 60;
return nodes.map((n, i) => ({
id: n.id, function layoutGraph(nodes: CortexNode[], edges: CortexEdge[]): FlowNode[] {
position: { x: (i % cols) * 280 + 50, y: Math.floor(i / cols) * 140 + 50 }, const g = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({}));
data: { label: n.title, kind: n.kind, status: n.status }, g.setGraph({ rankdir: 'TB', nodesep: 60, ranksep: 100, marginx: 40, marginy: 40 });
style: {
background: KIND_COLORS[n.kind] || '#6b7280', for (const n of nodes) {
color: '#fff', g.setNode(n.id, { width: NODE_WIDTH, height: NODE_HEIGHT });
border: 'none', }
borderRadius: 8, for (const e of edges) {
padding: '12px 16px', g.setEdge(e.fromId, e.toId);
fontSize: 13, }
fontWeight: 500,
minWidth: 160, Dagre.layout(g);
},
})); return nodes.map((n) => {
const pos = g.node(n.id);
return {
id: n.id,
position: { x: pos.x - NODE_WIDTH / 2, y: pos.y - NODE_HEIGHT / 2 },
sourcePosition: Position.Bottom,
targetPosition: Position.Top,
data: { label: n.title, kind: n.kind, status: n.status },
style: {
background: KIND_COLORS[n.kind] || '#6b7280',
color: '#fff',
border: 'none',
borderRadius: 8,
padding: '10px 16px',
fontSize: 13,
fontWeight: 500,
width: NODE_WIDTH,
textAlign: 'center' as const,
},
};
});
} }
export default function GraphView({ export default function GraphView({
@@ -47,7 +70,11 @@ export default function GraphView({
}) { }) {
const { data, isLoading } = useGraph(); const { data, isLoading } = useGraph();
const initialNodes = useMemo(() => (data ? layoutNodes(data.nodes) : []), [data]); const initialNodes = useMemo(
() => (data ? layoutGraph(data.nodes, data.edges) : []),
[data],
);
const initialEdges = useMemo<FlowEdge[]>( const initialEdges = useMemo<FlowEdge[]>(
() => () =>
data data
@@ -57,8 +84,12 @@ export default function GraphView({
target: e.toId, target: e.toId,
label: e.type, label: e.type,
animated: e.type === 'blocked_by', animated: e.type === 'blocked_by',
style: { stroke: '#64748b' }, style: { stroke: '#64748b', strokeWidth: 1.5 },
labelStyle: { fill: '#94a3b8', fontSize: 11 }, labelStyle: { fill: '#94a3b8', fontSize: 10 },
labelBgStyle: { fill: '#1e293b', fillOpacity: 0.9 },
labelBgPadding: [6, 3] as [number, number],
labelBgBorderRadius: 4,
markerEnd: { type: MarkerType.ArrowClosed, color: '#64748b', width: 16, height: 16 },
})) }))
: [], : [],
[data], [data],
@@ -67,7 +98,6 @@ export default function GraphView({
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
// Sync when data changes
useMemo(() => { useMemo(() => {
setNodes(initialNodes); setNodes(initialNodes);
setEdges(initialEdges); setEdges(initialEdges);
@@ -94,11 +124,16 @@ export default function GraphView({
onEdgesChange={onEdgesChange} onEdgesChange={onEdgesChange}
onNodeClick={onNodeClick} onNodeClick={onNodeClick}
fitView fitView
fitViewOptions={{ padding: 0.3 }}
className="bg-gray-950" className="bg-gray-950"
> >
<Background color="#334155" gap={20} /> <Background color="#334155" gap={20} />
<Controls className="!bg-gray-800 !border-gray-700 [&>button]:!bg-gray-800 [&>button]:!border-gray-700 [&>button]:!text-gray-300" /> <Controls
position="top-right"
className="!bg-gray-800 !border-gray-700 [&>button]:!bg-gray-800 [&>button]:!border-gray-700 [&>button]:!text-gray-300"
/>
<MiniMap <MiniMap
position="bottom-right"
nodeColor={(n) => KIND_COLORS[(n.data as any)?.kind] || '#6b7280'} nodeColor={(n) => KIND_COLORS[(n.data as any)?.kind] || '#6b7280'}
className="!bg-gray-900 !border-gray-700" className="!bg-gray-900 !border-gray-700"
/> />