Skip to content

Commit 2ed0012

Browse files
mvalancyclaude
andauthored
Docked node inspector: Card / Contents / Diagram, decoupled from zoom (PR-2) (#67)
Selecting a node opens a docked right-hand inspector with an explicit Card · Contents · Diagram toggle (per-node, session-remembered), each rendered at full legible size regardless of canvas zoom — replacing the old "zoom in to make text readable" anti-pattern. - Contents renders node.description as readable markdown + syntax-highlighted code (lazy NodeContentRenderer). - Diagram draws the node's sub-graph statically from persisted positions (NodeSubgraphPreview), capped to 300 nodes / 600 edges so even the 1000-node Compute Core sub-graph previews instantly; "Open" descends in. - Plain node click now SELECTS (opens inspector) instead of descending; sheet nodes descend via the explicit ⤢ glyph or the inspector's Open button, so a click never navigates you away unexpectedly. - handleClickOutside ignores clicks inside the inspector so its own controls don't deselect the node and close it. - Selection lifted to Workspace via onNodeSelected through SafeGraphVisualization. Verified: tests/diagnostics/node-inspector.spec.ts (Contents/Diagram/Card + legible-when-zoomed-out) and hierarchy-navigation.spec.ts green; THE GATE 5/5. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 89b0a3a commit 2ed0012

7 files changed

Lines changed: 365 additions & 16 deletions

File tree

packages/web/src/components/InteractiveGraphVisualization.tsx

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -85,9 +85,12 @@ interface DragState {
8585

8686
interface InteractiveGraphVisualizationProps {
8787
onResetLayout?: () => void;
88+
/** Notifies the host (Workspace) which node is selected, so a docked
89+
* inspector can show its contents/diagram. Fires null on deselect. */
90+
onNodeSelected?: (node: WorkItem | null) => void;
8891
}
8992

90-
export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGraphVisualizationProps = {}) {
93+
export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected }: InteractiveGraphVisualizationProps = {}) {
9194
const svgRef = useRef<SVGSVGElement>(null);
9295
const containerRef = useRef<HTMLDivElement>(null);
9396
const { currentGraph, availableGraphs, descendInto } = useGraph();
@@ -281,6 +284,12 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap
281284
const [showUpdateGraphModal, setShowUpdateGraphModal] = useState(false);
282285
const [showDeleteGraphModal, setShowDeleteGraphModal] = useState(false);
283286
const [selectedNode, setSelectedNode] = useState<WorkItem | null>(null);
287+
// Lift selection to the host (Workspace) for the docked inspector. One effect
288+
// captures every path that changes selectedNode (node click, edit icon,
289+
// background-click deselect) without instrumenting each call site.
290+
useEffect(() => {
291+
onNodeSelected?.(selectedNode);
292+
}, [selectedNode, onNodeSelected]);
284293
const lastSelectedNodeRef = useRef<any>(null); // Track last selected node for centering
285294
const [selectedEdge, setSelectedEdge] = useState<WorkItemEdge | null>(null);
286295
const [createNodePosition, setCreateNodePosition] = useState<{ x: number; y: number; z: number } | undefined>(undefined);
@@ -963,7 +972,13 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap
963972

964973
// Close menus when clicking outside or pressing ESC
965974
useEffect(() => {
966-
const handleClickOutside = () => {
975+
const handleClickOutside = (event: MouseEvent) => {
976+
// Clicks inside the docked inspector (a sibling tree) must not deselect
977+
// the node — otherwise its own Card/Contents/Diagram controls close it.
978+
const target = event.target as Element | null;
979+
if (target && target.closest('[data-testid="node-inspector"]')) {
980+
return;
981+
}
967982
setNodeMenu(prev => ({ ...prev, visible: false }));
968983
setEdgeMenu(prev => ({ ...prev, visible: false }));
969984
setEditingEdge(null); // Close inline edge editor
@@ -1040,13 +1055,11 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap
10401055

10411056
setIsConnecting(false);
10421057
setConnectionSource(null);
1043-
} else if (node.subgraphId) {
1044-
// Altium-style sheet symbol: a plain click descends into its sub-graph.
1045-
// (Grow/connect is handled above; drag is suppressed by mousedownNodeRef;
1046-
// edit/relationship icons stopPropagation, so this only fires on a plain
1047-
// click of a sheet node.) Called via ref to avoid re-binding the handler.
1048-
descendIntoRef.current(node.subgraphId);
10491058
} else {
1059+
// A plain click SELECTS the node (opens the inspector). Descending into a
1060+
// sheet node's sub-graph is an explicit action — the descend glyph (⤢) on
1061+
// the card or the inspector's "Open" — so clicking never navigates you
1062+
// away unexpectedly (the user loses context otherwise).
10501063
// Handle node selection with 2-item ring buffer
10511064
setSelectedNodes(prev => {
10521065
const newSet = new Set(prev);
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { lazy, Suspense, useState } from 'react';
2+
import { X, FileText, Network, CreditCard } from 'lucide-react';
3+
import { useGraph } from '../contexts/GraphContext';
4+
import { getTypeConfig, getStatusConfig } from '../constants/workItemConstants';
5+
import type { WorkItemType } from '../constants/workItemConstants';
6+
import { NodeSubgraphPreview } from './NodeSubgraphPreview';
7+
8+
// Heavy (markdown + Prism) — lazy so it's out of the main bundle until a node's
9+
// contents are first opened.
10+
const NodeContentRenderer = lazy(() => import('./NodeContentRenderer'));
11+
12+
type Mode = 'card' | 'contents' | 'diagram';
13+
14+
interface NodeInspectorProps {
15+
node: any;
16+
onClose: () => void;
17+
}
18+
19+
/**
20+
* Docked inspector: shows the selected node's Card (summary), Contents (its
21+
* description rendered as readable markdown/code), or Diagram (its sub-graph),
22+
* each at full legible size regardless of canvas zoom. The mode is an explicit,
23+
* per-node toggle — not a side effect of zooming in.
24+
*/
25+
export function NodeInspector({ node, onClose }: NodeInspectorProps) {
26+
const { descendInto } = useGraph();
27+
const hasSubgraph = !!node?.subgraphId;
28+
const [modeByNode, setModeByNode] = useState<Record<string, Mode>>({});
29+
const mode: Mode = modeByNode[node.id] ?? (node.description ? 'contents' : 'card');
30+
const setMode = (m: Mode) => setModeByNode((prev) => ({ ...prev, [node.id]: m }));
31+
32+
const typeCfg = getTypeConfig(node.type as WorkItemType);
33+
const statusCfg = getStatusConfig(node.status as any);
34+
35+
return (
36+
<div
37+
data-testid="node-inspector"
38+
className="h-full flex flex-col bg-gray-900/95 backdrop-blur-sm border-l border-gray-700/60 w-full"
39+
>
40+
{/* Header */}
41+
<div className="flex items-start gap-2 p-3 border-b border-gray-700/60">
42+
<div className="flex-1 min-w-0">
43+
<div className="text-[10px] uppercase tracking-wide" style={{ color: typeCfg.hexColor }}>{typeCfg.label}</div>
44+
<div className="text-sm font-semibold text-white truncate" title={node.title}>{node.title}</div>
45+
</div>
46+
<button onClick={onClose} className="p-1 text-gray-400 hover:text-white rounded hover:bg-gray-700/50" title="Close">
47+
<X className="h-4 w-4" />
48+
</button>
49+
</div>
50+
51+
{/* Mode toggle */}
52+
<div className="flex gap-1 p-2 border-b border-gray-700/60">
53+
<ModeBtn active={mode === 'card'} onClick={() => setMode('card')} icon={<CreditCard className="h-3.5 w-3.5" />} label="Card" />
54+
<ModeBtn active={mode === 'contents'} onClick={() => setMode('contents')} icon={<FileText className="h-3.5 w-3.5" />} label="Contents" />
55+
<ModeBtn active={mode === 'diagram'} onClick={() => setMode('diagram')} icon={<Network className="h-3.5 w-3.5" />} label="Diagram" disabled={!hasSubgraph} title={hasSubgraph ? 'Sub-graph' : 'No sub-graph'} />
56+
</div>
57+
58+
{/* Body */}
59+
<div className="flex-1 overflow-y-auto">
60+
{mode === 'card' && (
61+
<div className="p-3 space-y-3 text-sm">
62+
<Row label="Type" value={typeCfg.label} color={typeCfg.hexColor} />
63+
<Row label="Status" value={statusCfg?.label ?? node.status} color={statusCfg?.hexColor} />
64+
{typeof node.priority === 'number' && <Row label="Priority" value={`${Math.round(node.priority * 100)}%`} />}
65+
{Array.isArray(node.tags) && node.tags.length > 0 && (
66+
<div>
67+
<div className="text-xs text-gray-500 mb-1">Tags</div>
68+
<div className="flex flex-wrap gap-1">
69+
{node.tags.map((t: string) => <span key={t} className="text-[11px] bg-gray-700/60 text-gray-200 rounded px-1.5 py-0.5">{t}</span>)}
70+
</div>
71+
</div>
72+
)}
73+
{node.description && (
74+
<div>
75+
<div className="text-xs text-gray-500 mb-1">Description (preview)</div>
76+
<div className="text-xs text-gray-300 line-clamp-3 whitespace-pre-wrap">{node.description}</div>
77+
<button onClick={() => setMode('contents')} className="text-xs text-blue-400 hover:text-blue-300 mt-1">Read full contents →</button>
78+
</div>
79+
)}
80+
</div>
81+
)}
82+
83+
{mode === 'contents' && (
84+
<div className="p-3">
85+
<Suspense fallback={<div className="text-sm text-gray-500">Loading…</div>}>
86+
<NodeContentRenderer content={node.description ?? ''} />
87+
</Suspense>
88+
</div>
89+
)}
90+
91+
{mode === 'diagram' && (
92+
hasSubgraph ? (
93+
<NodeSubgraphPreview
94+
subgraphId={node.subgraphId}
95+
subgraphName={node.subgraph?.name}
96+
onOpen={() => descendInto(node.subgraphId)}
97+
/>
98+
) : (
99+
<div className="p-4 text-sm text-gray-500">This node has no sub-graph diagram.</div>
100+
)
101+
)}
102+
</div>
103+
</div>
104+
);
105+
}
106+
107+
function ModeBtn({ active, onClick, icon, label, disabled, title }: { active: boolean; onClick: () => void; icon: React.ReactNode; label: string; disabled?: boolean; title?: string }) {
108+
return (
109+
<button
110+
onClick={onClick}
111+
disabled={disabled}
112+
title={title}
113+
className={`flex-1 flex items-center justify-center gap-1.5 px-2 py-1.5 rounded-lg text-xs font-medium transition-colors ${
114+
disabled
115+
? 'text-gray-600 cursor-not-allowed'
116+
: active
117+
? 'bg-emerald-500/25 text-emerald-200 border border-emerald-400/40'
118+
: 'text-gray-300 hover:bg-gray-700/50 border border-transparent'
119+
}`}
120+
>
121+
{icon}
122+
{label}
123+
</button>
124+
);
125+
}
126+
127+
function Row({ label, value, color }: { label: string; value: string; color?: string }) {
128+
return (
129+
<div className="flex items-center justify-between">
130+
<span className="text-xs text-gray-500">{label}</span>
131+
<span className="text-sm font-medium" style={color ? { color } : undefined}>{value}</span>
132+
</div>
133+
);
134+
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { useQuery } from '@apollo/client';
2+
import { Maximize2 } from 'lucide-react';
3+
import { GET_WORK_ITEMS, GET_EDGES } from '../lib/queries';
4+
import { getTypeConfig } from '../constants/workItemConstants';
5+
import type { WorkItemType } from '../constants/workItemConstants';
6+
7+
/**
8+
* A STATIC, legible render of a node's sub-graph (its "diagram"), drawn from the
9+
* sub-graph's persisted node positions — no force simulation. Lets you READ a
10+
* diagram at a useful scale without navigating away; "Open" descends into it.
11+
*/
12+
interface NodeSubgraphPreviewProps {
13+
subgraphId: string;
14+
subgraphName?: string;
15+
onOpen: () => void;
16+
}
17+
18+
export function NodeSubgraphPreview({ subgraphId, subgraphName, onOpen }: NodeSubgraphPreviewProps) {
19+
// A preview is a thumbnail — cap the payload so even a 1000-node sub-graph
20+
// loads fast and stays legible. "Open" shows the full thing.
21+
const PREVIEW_LIMIT = 300;
22+
const { data: wiData, loading } = useQuery(GET_WORK_ITEMS, {
23+
variables: { where: { graph: { id: subgraphId } }, options: { limit: PREVIEW_LIMIT } },
24+
fetchPolicy: 'cache-and-network',
25+
});
26+
const { data: edgeData } = useQuery(GET_EDGES, {
27+
variables: { where: { source: { graph: { id: subgraphId } } }, options: { limit: 600 } },
28+
fetchPolicy: 'cache-and-network',
29+
});
30+
31+
const nodes: any[] = wiData?.workItems ?? [];
32+
const edges: any[] = edgeData?.edges ?? [];
33+
34+
if (loading && nodes.length === 0) {
35+
return <div className="text-sm text-gray-500 p-4">Loading diagram…</div>;
36+
}
37+
if (nodes.length === 0) {
38+
return (
39+
<div className="p-4 space-y-3">
40+
<div className="text-sm text-gray-500">This sub-graph is empty.</div>
41+
<OpenButton onOpen={onOpen} name={subgraphName} />
42+
</div>
43+
);
44+
}
45+
46+
// Bounds from persisted positions, scaled to fit the preview viewBox.
47+
const W = 320;
48+
const H = 240;
49+
const pad = 30;
50+
const xs = nodes.map((n) => n.positionX ?? 0);
51+
const ys = nodes.map((n) => n.positionY ?? 0);
52+
const minX = Math.min(...xs);
53+
const maxX = Math.max(...xs);
54+
const minY = Math.min(...ys);
55+
const maxY = Math.max(...ys);
56+
const spanX = Math.max(1, maxX - minX);
57+
const spanY = Math.max(1, maxY - minY);
58+
const scale = Math.min((W - pad * 2) / spanX, (H - pad * 2) / spanY);
59+
const ox = (W - spanX * scale) / 2;
60+
const oy = (H - spanY * scale) / 2;
61+
const toX = (x: number) => ox + (x - minX) * scale;
62+
const toY = (y: number) => oy + (y - minY) * scale;
63+
const byId: Record<string, any> = {};
64+
for (const n of nodes) byId[n.id] = n;
65+
66+
// Cap labels so a dense sub-graph stays readable, not a wall of text.
67+
const showLabels = nodes.length <= 40;
68+
69+
return (
70+
<div className="p-3 space-y-3">
71+
<div className="flex items-center justify-between">
72+
<span className="text-xs text-gray-400">{nodes.length} nodes · {edges.length} edges</span>
73+
<OpenButton onOpen={onOpen} name={subgraphName} />
74+
</div>
75+
<svg viewBox={`0 0 ${W} ${H}`} className="w-full rounded-lg bg-gray-900/60 border border-gray-700/50" data-testid="subgraph-preview">
76+
{edges.map((e) => {
77+
const s = byId[typeof e.source === 'object' ? e.source?.id : e.source];
78+
const t = byId[typeof e.target === 'object' ? e.target?.id : e.target];
79+
if (!s || !t) return null;
80+
return (
81+
<line key={e.id} x1={toX(s.positionX ?? 0)} y1={toY(s.positionY ?? 0)} x2={toX(t.positionX ?? 0)} y2={toY(t.positionY ?? 0)} stroke="#4b5563" strokeWidth={0.75} strokeOpacity={0.6} />
82+
);
83+
})}
84+
{nodes.map((n) => {
85+
const color = getTypeConfig(n.type as WorkItemType).hexColor;
86+
const x = toX(n.positionX ?? 0);
87+
const y = toY(n.positionY ?? 0);
88+
return (
89+
<g key={n.id}>
90+
<circle cx={x} cy={y} r={3.5} fill={color} fillOpacity={0.9} />
91+
{showLabels && (
92+
<text x={x + 5} y={y + 3} fontSize={6} fill="#cbd5e1">
93+
{String(n.title).slice(0, 18)}
94+
</text>
95+
)}
96+
</g>
97+
);
98+
})}
99+
</svg>
100+
</div>
101+
);
102+
}
103+
104+
function OpenButton({ onOpen, name }: { onOpen: () => void; name?: string }) {
105+
return (
106+
<button
107+
onClick={onOpen}
108+
className="flex items-center gap-1.5 text-xs font-medium text-indigo-300 hover:text-white bg-indigo-500/20 hover:bg-indigo-500/40 border border-indigo-400/30 rounded-lg px-2.5 py-1 transition-colors"
109+
title={name ? `Open ${name}` : 'Open sub-graph'}
110+
>
111+
<Maximize2 className="h-3.5 w-3.5" />
112+
Open
113+
</button>
114+
);
115+
}
Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,24 @@
11
import { GraphErrorBoundary } from './GraphErrorBoundary';
22
import { InteractiveGraphVisualization } from './InteractiveGraphVisualization';
3+
import type { WorkItem } from '../types/graph';
34

45
/**
56
* Wrapper component that adds error handling to the graph visualization
67
* without modifying the core InteractiveGraphVisualization component.
78
* This prevents breaking the UI when implementing error handling.
89
*/
9-
export function SafeGraphVisualization() {
10+
interface SafeGraphVisualizationProps {
11+
onNodeSelected?: (node: WorkItem | null) => void;
12+
}
13+
14+
export function SafeGraphVisualization({ onNodeSelected }: SafeGraphVisualizationProps = {}) {
1015
return (
1116
<GraphErrorBoundary
1217
onError={() => {
1318
// Error logged by boundary for debugging
1419
}}
1520
>
16-
<InteractiveGraphVisualization />
21+
<InteractiveGraphVisualization onNodeSelected={onNodeSelected} />
1722
</GraphErrorBoundary>
1823
);
19-
}
24+
}

packages/web/src/pages/Workspace.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Plus, Share2, Users, Table, Activity, Network, CreditCard, Columns, Cal
33
import { createPortal } from 'react-dom';
44
import { useQuery } from '@apollo/client';
55
import { SafeGraphVisualization } from '../components/SafeGraphVisualization';
6+
import { NodeInspector } from '../components/NodeInspector';
67
import { GraphSelector } from '../components/GraphSelector';
78
import { MiniMap } from '../components/MiniMap';
89
import { CreateWorkItemModal } from '../components/CreateWorkItemModal';
@@ -28,6 +29,7 @@ export function Workspace() {
2829
const [showMiniMap, setShowMiniMap] = useState(true);
2930
const { currentGraph, availableGraphs, getBreadcrumb, ascendTo } = useGraph();
3031
const breadcrumb = getBreadcrumb();
32+
const [inspectorNode, setInspectorNode] = useState<any>(null);
3133
const { currentTeam, currentUser } = useAuth();
3234
const { health, loading: healthLoading, error: healthError } = useHealthStatus();
3335

@@ -375,7 +377,8 @@ export function Workspace() {
375377
</div>
376378
</div>
377379
) : viewMode === 'graph' ? (
378-
<div className="relative h-full">
380+
<div className="relative h-full flex">
381+
<div className="relative flex-1 min-w-0 h-full">
379382
{/* Neo4j Connection Warning */}
380383
{health?.services?.neo4j?.status !== 'healthy' && (
381384
<div className="absolute top-4 left-4 right-4 z-50">
@@ -397,7 +400,13 @@ export function Workspace() {
397400
</div>
398401
</div>
399402
)}
400-
<SafeGraphVisualization />
403+
<SafeGraphVisualization onNodeSelected={setInspectorNode} />
404+
</div>
405+
{inspectorNode && (
406+
<div className="w-96 flex-shrink-0 h-full hidden md:block">
407+
<NodeInspector node={inspectorNode} onClose={() => setInspectorNode(null)} />
408+
</div>
409+
)}
401410
</div>
402411
) : (
403412
<ViewManager viewMode={viewMode as 'dashboard' | 'table' | 'cards' | 'kanban' | 'gantt' | 'calendar' | 'activity'} />

tests/diagnostics/hierarchy-navigation.spec.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,14 +52,15 @@ test.describe('hierarchy navigation @geometry', () => {
5252
expect(overview.currentGraphId, 'on the overview graph').toBe(OVERVIEW_ID);
5353
expect(overview.sheetCount, 'overview has sheet-symbol nodes').toBeGreaterThan(0);
5454

55-
// Descend: click a sheet node's card.
55+
// Descend: click a sheet node's DESCEND glyph (plain card-click now selects
56+
// for the inspector; descending is the explicit ⤢ glyph or inspector Open).
5657
const targetSubgraphId = overview.firstSheetSubgraphId as string;
5758
await page.evaluate(() => {
5859
const sheet = [...document.querySelectorAll('.graph-container svg .node')].find(
5960
(n) => (n as any).__data__?.subgraphId
6061
) as SVGGElement | undefined;
61-
const bg = (sheet?.querySelector('.node-bg') ?? sheet) as Element | undefined;
62-
(bg as any)?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
62+
const glyph = sheet?.querySelector('.node-descend-icon') as Element | undefined;
63+
(glyph as any)?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
6364
});
6465
await page.waitForTimeout(5000);
6566

0 commit comments

Comments
 (0)