diff --git a/packages/web/src/components/InteractiveGraphVisualization.tsx b/packages/web/src/components/InteractiveGraphVisualization.tsx index 70bfe946..7b5caf13 100644 --- a/packages/web/src/components/InteractiveGraphVisualization.tsx +++ b/packages/web/src/components/InteractiveGraphVisualization.tsx @@ -85,9 +85,12 @@ interface DragState { interface InteractiveGraphVisualizationProps { onResetLayout?: () => void; + /** Notifies the host (Workspace) which node is selected, so a docked + * inspector can show its contents/diagram. Fires null on deselect. */ + onNodeSelected?: (node: WorkItem | null) => void; } -export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGraphVisualizationProps = {}) { +export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected }: InteractiveGraphVisualizationProps = {}) { const svgRef = useRef(null); const containerRef = useRef(null); const { currentGraph, availableGraphs, descendInto } = useGraph(); @@ -281,6 +284,12 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap const [showUpdateGraphModal, setShowUpdateGraphModal] = useState(false); const [showDeleteGraphModal, setShowDeleteGraphModal] = useState(false); const [selectedNode, setSelectedNode] = useState(null); + // Lift selection to the host (Workspace) for the docked inspector. One effect + // captures every path that changes selectedNode (node click, edit icon, + // background-click deselect) without instrumenting each call site. + useEffect(() => { + onNodeSelected?.(selectedNode); + }, [selectedNode, onNodeSelected]); const lastSelectedNodeRef = useRef(null); // Track last selected node for centering const [selectedEdge, setSelectedEdge] = useState(null); const [createNodePosition, setCreateNodePosition] = useState<{ x: number; y: number; z: number } | undefined>(undefined); @@ -963,7 +972,13 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap // Close menus when clicking outside or pressing ESC useEffect(() => { - const handleClickOutside = () => { + const handleClickOutside = (event: MouseEvent) => { + // Clicks inside the docked inspector (a sibling tree) must not deselect + // the node — otherwise its own Card/Contents/Diagram controls close it. + const target = event.target as Element | null; + if (target && target.closest('[data-testid="node-inspector"]')) { + return; + } setNodeMenu(prev => ({ ...prev, visible: false })); setEdgeMenu(prev => ({ ...prev, visible: false })); setEditingEdge(null); // Close inline edge editor @@ -1040,13 +1055,11 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap setIsConnecting(false); setConnectionSource(null); - } else if (node.subgraphId) { - // Altium-style sheet symbol: a plain click descends into its sub-graph. - // (Grow/connect is handled above; drag is suppressed by mousedownNodeRef; - // edit/relationship icons stopPropagation, so this only fires on a plain - // click of a sheet node.) Called via ref to avoid re-binding the handler. - descendIntoRef.current(node.subgraphId); } else { + // A plain click SELECTS the node (opens the inspector). Descending into a + // sheet node's sub-graph is an explicit action — the descend glyph (⤢) on + // the card or the inspector's "Open" — so clicking never navigates you + // away unexpectedly (the user loses context otherwise). // Handle node selection with 2-item ring buffer setSelectedNodes(prev => { const newSet = new Set(prev); diff --git a/packages/web/src/components/NodeInspector.tsx b/packages/web/src/components/NodeInspector.tsx new file mode 100644 index 00000000..475d66b4 --- /dev/null +++ b/packages/web/src/components/NodeInspector.tsx @@ -0,0 +1,134 @@ +import { lazy, Suspense, useState } from 'react'; +import { X, FileText, Network, CreditCard } from 'lucide-react'; +import { useGraph } from '../contexts/GraphContext'; +import { getTypeConfig, getStatusConfig } from '../constants/workItemConstants'; +import type { WorkItemType } from '../constants/workItemConstants'; +import { NodeSubgraphPreview } from './NodeSubgraphPreview'; + +// Heavy (markdown + Prism) — lazy so it's out of the main bundle until a node's +// contents are first opened. +const NodeContentRenderer = lazy(() => import('./NodeContentRenderer')); + +type Mode = 'card' | 'contents' | 'diagram'; + +interface NodeInspectorProps { + node: any; + onClose: () => void; +} + +/** + * Docked inspector: shows the selected node's Card (summary), Contents (its + * description rendered as readable markdown/code), or Diagram (its sub-graph), + * each at full legible size regardless of canvas zoom. The mode is an explicit, + * per-node toggle — not a side effect of zooming in. + */ +export function NodeInspector({ node, onClose }: NodeInspectorProps) { + const { descendInto } = useGraph(); + const hasSubgraph = !!node?.subgraphId; + const [modeByNode, setModeByNode] = useState>({}); + const mode: Mode = modeByNode[node.id] ?? (node.description ? 'contents' : 'card'); + const setMode = (m: Mode) => setModeByNode((prev) => ({ ...prev, [node.id]: m })); + + const typeCfg = getTypeConfig(node.type as WorkItemType); + const statusCfg = getStatusConfig(node.status as any); + + return ( +
+ {/* Header */} +
+
+
{typeCfg.label}
+
{node.title}
+
+ +
+ + {/* Mode toggle */} +
+ setMode('card')} icon={} label="Card" /> + setMode('contents')} icon={} label="Contents" /> + setMode('diagram')} icon={} label="Diagram" disabled={!hasSubgraph} title={hasSubgraph ? 'Sub-graph' : 'No sub-graph'} /> +
+ + {/* Body */} +
+ {mode === 'card' && ( +
+ + + {typeof node.priority === 'number' && } + {Array.isArray(node.tags) && node.tags.length > 0 && ( +
+
Tags
+
+ {node.tags.map((t: string) => {t})} +
+
+ )} + {node.description && ( +
+
Description (preview)
+
{node.description}
+ +
+ )} +
+ )} + + {mode === 'contents' && ( +
+ Loading…
}> + + +
+ )} + + {mode === 'diagram' && ( + hasSubgraph ? ( + descendInto(node.subgraphId)} + /> + ) : ( +
This node has no sub-graph diagram.
+ ) + )} +
+ + ); +} + +function ModeBtn({ active, onClick, icon, label, disabled, title }: { active: boolean; onClick: () => void; icon: React.ReactNode; label: string; disabled?: boolean; title?: string }) { + return ( + + ); +} + +function Row({ label, value, color }: { label: string; value: string; color?: string }) { + return ( +
+ {label} + {value} +
+ ); +} diff --git a/packages/web/src/components/NodeSubgraphPreview.tsx b/packages/web/src/components/NodeSubgraphPreview.tsx new file mode 100644 index 00000000..4a18b7f1 --- /dev/null +++ b/packages/web/src/components/NodeSubgraphPreview.tsx @@ -0,0 +1,115 @@ +import { useQuery } from '@apollo/client'; +import { Maximize2 } from 'lucide-react'; +import { GET_WORK_ITEMS, GET_EDGES } from '../lib/queries'; +import { getTypeConfig } from '../constants/workItemConstants'; +import type { WorkItemType } from '../constants/workItemConstants'; + +/** + * A STATIC, legible render of a node's sub-graph (its "diagram"), drawn from the + * sub-graph's persisted node positions — no force simulation. Lets you READ a + * diagram at a useful scale without navigating away; "Open" descends into it. + */ +interface NodeSubgraphPreviewProps { + subgraphId: string; + subgraphName?: string; + onOpen: () => void; +} + +export function NodeSubgraphPreview({ subgraphId, subgraphName, onOpen }: NodeSubgraphPreviewProps) { + // A preview is a thumbnail — cap the payload so even a 1000-node sub-graph + // loads fast and stays legible. "Open" shows the full thing. + const PREVIEW_LIMIT = 300; + const { data: wiData, loading } = useQuery(GET_WORK_ITEMS, { + variables: { where: { graph: { id: subgraphId } }, options: { limit: PREVIEW_LIMIT } }, + fetchPolicy: 'cache-and-network', + }); + const { data: edgeData } = useQuery(GET_EDGES, { + variables: { where: { source: { graph: { id: subgraphId } } }, options: { limit: 600 } }, + fetchPolicy: 'cache-and-network', + }); + + const nodes: any[] = wiData?.workItems ?? []; + const edges: any[] = edgeData?.edges ?? []; + + if (loading && nodes.length === 0) { + return
Loading diagram…
; + } + if (nodes.length === 0) { + return ( +
+
This sub-graph is empty.
+ +
+ ); + } + + // Bounds from persisted positions, scaled to fit the preview viewBox. + const W = 320; + const H = 240; + const pad = 30; + const xs = nodes.map((n) => n.positionX ?? 0); + const ys = nodes.map((n) => n.positionY ?? 0); + const minX = Math.min(...xs); + const maxX = Math.max(...xs); + const minY = Math.min(...ys); + const maxY = Math.max(...ys); + const spanX = Math.max(1, maxX - minX); + const spanY = Math.max(1, maxY - minY); + const scale = Math.min((W - pad * 2) / spanX, (H - pad * 2) / spanY); + const ox = (W - spanX * scale) / 2; + const oy = (H - spanY * scale) / 2; + const toX = (x: number) => ox + (x - minX) * scale; + const toY = (y: number) => oy + (y - minY) * scale; + const byId: Record = {}; + for (const n of nodes) byId[n.id] = n; + + // Cap labels so a dense sub-graph stays readable, not a wall of text. + const showLabels = nodes.length <= 40; + + return ( +
+
+ {nodes.length} nodes · {edges.length} edges + +
+ + {edges.map((e) => { + const s = byId[typeof e.source === 'object' ? e.source?.id : e.source]; + const t = byId[typeof e.target === 'object' ? e.target?.id : e.target]; + if (!s || !t) return null; + return ( + + ); + })} + {nodes.map((n) => { + const color = getTypeConfig(n.type as WorkItemType).hexColor; + const x = toX(n.positionX ?? 0); + const y = toY(n.positionY ?? 0); + return ( + + + {showLabels && ( + + {String(n.title).slice(0, 18)} + + )} + + ); + })} + +
+ ); +} + +function OpenButton({ onOpen, name }: { onOpen: () => void; name?: string }) { + return ( + + ); +} diff --git a/packages/web/src/components/SafeGraphVisualization.tsx b/packages/web/src/components/SafeGraphVisualization.tsx index aed230a8..3b91e3ae 100644 --- a/packages/web/src/components/SafeGraphVisualization.tsx +++ b/packages/web/src/components/SafeGraphVisualization.tsx @@ -1,19 +1,24 @@ import { GraphErrorBoundary } from './GraphErrorBoundary'; import { InteractiveGraphVisualization } from './InteractiveGraphVisualization'; +import type { WorkItem } from '../types/graph'; /** * Wrapper component that adds error handling to the graph visualization * without modifying the core InteractiveGraphVisualization component. * This prevents breaking the UI when implementing error handling. */ -export function SafeGraphVisualization() { +interface SafeGraphVisualizationProps { + onNodeSelected?: (node: WorkItem | null) => void; +} + +export function SafeGraphVisualization({ onNodeSelected }: SafeGraphVisualizationProps = {}) { return ( { // Error logged by boundary for debugging }} > - + ); -} \ No newline at end of file +} diff --git a/packages/web/src/pages/Workspace.tsx b/packages/web/src/pages/Workspace.tsx index 0ff19cdd..82bc49b0 100644 --- a/packages/web/src/pages/Workspace.tsx +++ b/packages/web/src/pages/Workspace.tsx @@ -3,6 +3,7 @@ import { Plus, Share2, Users, Table, Activity, Network, CreditCard, Columns, Cal import { createPortal } from 'react-dom'; import { useQuery } from '@apollo/client'; import { SafeGraphVisualization } from '../components/SafeGraphVisualization'; +import { NodeInspector } from '../components/NodeInspector'; import { GraphSelector } from '../components/GraphSelector'; import { MiniMap } from '../components/MiniMap'; import { CreateWorkItemModal } from '../components/CreateWorkItemModal'; @@ -28,6 +29,7 @@ export function Workspace() { const [showMiniMap, setShowMiniMap] = useState(true); const { currentGraph, availableGraphs, getBreadcrumb, ascendTo } = useGraph(); const breadcrumb = getBreadcrumb(); + const [inspectorNode, setInspectorNode] = useState(null); const { currentTeam, currentUser } = useAuth(); const { health, loading: healthLoading, error: healthError } = useHealthStatus(); @@ -375,7 +377,8 @@ export function Workspace() { ) : viewMode === 'graph' ? ( -
+
+
{/* Neo4j Connection Warning */} {health?.services?.neo4j?.status !== 'healthy' && (
@@ -397,7 +400,13 @@ export function Workspace() {
)} - + +
+ {inspectorNode && ( +
+ setInspectorNode(null)} /> +
+ )}
) : ( diff --git a/tests/diagnostics/hierarchy-navigation.spec.ts b/tests/diagnostics/hierarchy-navigation.spec.ts index 3fdfd8ad..aed0a135 100644 --- a/tests/diagnostics/hierarchy-navigation.spec.ts +++ b/tests/diagnostics/hierarchy-navigation.spec.ts @@ -52,14 +52,15 @@ test.describe('hierarchy navigation @geometry', () => { expect(overview.currentGraphId, 'on the overview graph').toBe(OVERVIEW_ID); expect(overview.sheetCount, 'overview has sheet-symbol nodes').toBeGreaterThan(0); - // Descend: click a sheet node's card. + // Descend: click a sheet node's DESCEND glyph (plain card-click now selects + // for the inspector; descending is the explicit ⤢ glyph or inspector Open). const targetSubgraphId = overview.firstSheetSubgraphId as string; await page.evaluate(() => { const sheet = [...document.querySelectorAll('.graph-container svg .node')].find( (n) => (n as any).__data__?.subgraphId ) as SVGGElement | undefined; - const bg = (sheet?.querySelector('.node-bg') ?? sheet) as Element | undefined; - (bg as any)?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + const glyph = sheet?.querySelector('.node-descend-icon') as Element | undefined; + (glyph as any)?.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); await page.waitForTimeout(5000); diff --git a/tests/diagnostics/node-inspector.spec.ts b/tests/diagnostics/node-inspector.spec.ts new file mode 100644 index 00000000..1750586c --- /dev/null +++ b/tests/diagnostics/node-inspector.spec.ts @@ -0,0 +1,72 @@ +import { test, expect, Page } from '@playwright/test'; +import { login, TEST_USERS } from '../helpers/auth'; + +/** + * Docked inspector: selecting a node opens it; Contents renders its description + * as readable markdown; Diagram renders the sub-graph; modes switch explicitly + * (not via zoom). Needs the hierarchy demo seeded (System Overview). + */ +const OVERVIEW_ID = 'overview-graph-shared'; + +async function openOverview(page: Page) { + await page.evaluate((gid) => { + localStorage.setItem('currentGraphId', gid); + localStorage.setItem('graphdone.quality.override', 'HIGH'); + }, OVERVIEW_ID); + await page.reload(); + await page.waitForTimeout(6000); +} + +test.describe('node inspector diagnostic @geometry', () => { + test.describe.configure({ timeout: 120_000 }); + + test('select node → inspector Contents + Diagram + Card modes', async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await login(page, TEST_USERS.ADMIN); + await page.waitForTimeout(1500); + await openOverview(page); + + // Plain-click a sheet node's card → it SELECTS (no longer descends). + const box = await page.evaluate(() => { + const sheet = [...document.querySelectorAll('.graph-container svg .node')].find( + (n) => (n as any).__data__?.subgraphId + ) as SVGGElement | undefined; + const bg = sheet?.querySelector('.node-bg') as Element | undefined; + if (!bg) return null; + const r = bg.getBoundingClientRect(); + return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; + }); + expect(box, 'found a sheet node on screen').not.toBeNull(); + await page.mouse.click(box!.x, box!.y); + await page.waitForTimeout(1800); + + const inspector = page.locator('[data-testid="node-inspector"]'); + await expect(inspector, 'inspector opens on node select').toBeVisible({ timeout: 8000 }); + + // Contents (default for a node with a description) renders markdown. + const contents = page.locator('[data-testid="node-content-rendered"]'); + await expect(contents, 'Contents renders the description').toBeVisible({ timeout: 8000 }); + const contentsText = await contents.innerText(); + expect(contentsText.length, 'contents has readable text').toBeGreaterThan(5); + + // Switch to Diagram → static sub-graph preview renders. + await inspector.getByRole('button', { name: 'Diagram' }).click(); + await page.waitForTimeout(2000); + // eslint-disable-next-line no-console + console.log('[node-inspector] diagram pane: ' + (await inspector.innerText()).slice(0, 160).replace(/\n/g, ' ')); + await expect(page.locator('[data-testid="subgraph-preview"]'), 'Diagram renders the sub-graph').toBeVisible({ timeout: 15000 }); + + // Switch to Card → summary rows. + await inspector.getByRole('button', { name: 'Card' }).click(); + await expect(inspector.getByText('Type', { exact: true }), 'Card shows the summary').toBeVisible({ timeout: 5000 }); + + // Legibility independent of zoom: zoom the canvas way out, inspector text stays. + await page.mouse.move(400, 400); + for (let i = 0; i < 5; i++) { await page.mouse.wheel(0, 240); await page.waitForTimeout(100); } + await inspector.getByRole('button', { name: 'Contents', exact: true }).click(); + await expect(page.locator('[data-testid="node-content-rendered"]'), 'contents readable regardless of zoom').toBeVisible(); + + // eslint-disable-next-line no-console + console.log('[node-inspector] ok — inspector + Contents/Diagram/Card verified'); + }); +});