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

View File

@@ -9,7 +9,9 @@
"version": "1.0.0",
"dependencies": {
"@tanstack/react-query": "^5.62.0",
"@types/dagre": "^0.7.53",
"@xyflow/react": "^12.4.0",
"dagre": "^0.8.5",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
@@ -1546,6 +1548,12 @@
"@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": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1816,6 +1824,16 @@
"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": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -1967,6 +1985,15 @@
"dev": true,
"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": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
@@ -2271,6 +2298,12 @@
"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": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",

View File

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

View File

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

View File

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