Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 21 additions & 8 deletions packages/web/src/components/InteractiveGraphVisualization.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<SVGSVGElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const { currentGraph, availableGraphs, descendInto } = useGraph();
Expand Down Expand Up @@ -281,6 +284,12 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap
const [showUpdateGraphModal, setShowUpdateGraphModal] = useState(false);
const [showDeleteGraphModal, setShowDeleteGraphModal] = useState(false);
const [selectedNode, setSelectedNode] = useState<WorkItem | null>(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<any>(null); // Track last selected node for centering
const [selectedEdge, setSelectedEdge] = useState<WorkItemEdge | null>(null);
const [createNodePosition, setCreateNodePosition] = useState<{ x: number; y: number; z: number } | undefined>(undefined);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down
134 changes: 134 additions & 0 deletions packages/web/src/components/NodeInspector.tsx
Original file line number Diff line number Diff line change
@@ -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<Record<string, Mode>>({});
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 (
<div
data-testid="node-inspector"
className="h-full flex flex-col bg-gray-900/95 backdrop-blur-sm border-l border-gray-700/60 w-full"
>
{/* Header */}
<div className="flex items-start gap-2 p-3 border-b border-gray-700/60">
<div className="flex-1 min-w-0">
<div className="text-[10px] uppercase tracking-wide" style={{ color: typeCfg.hexColor }}>{typeCfg.label}</div>
<div className="text-sm font-semibold text-white truncate" title={node.title}>{node.title}</div>
</div>
<button onClick={onClose} className="p-1 text-gray-400 hover:text-white rounded hover:bg-gray-700/50" title="Close">
<X className="h-4 w-4" />
</button>
</div>

{/* Mode toggle */}
<div className="flex gap-1 p-2 border-b border-gray-700/60">
<ModeBtn active={mode === 'card'} onClick={() => setMode('card')} icon={<CreditCard className="h-3.5 w-3.5" />} label="Card" />
<ModeBtn active={mode === 'contents'} onClick={() => setMode('contents')} icon={<FileText className="h-3.5 w-3.5" />} label="Contents" />
<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'} />
</div>

{/* Body */}
<div className="flex-1 overflow-y-auto">
{mode === 'card' && (
<div className="p-3 space-y-3 text-sm">
<Row label="Type" value={typeCfg.label} color={typeCfg.hexColor} />
<Row label="Status" value={statusCfg?.label ?? node.status} color={statusCfg?.hexColor} />
{typeof node.priority === 'number' && <Row label="Priority" value={`${Math.round(node.priority * 100)}%`} />}
{Array.isArray(node.tags) && node.tags.length > 0 && (
<div>
<div className="text-xs text-gray-500 mb-1">Tags</div>
<div className="flex flex-wrap gap-1">
{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>)}
</div>
</div>
)}
{node.description && (
<div>
<div className="text-xs text-gray-500 mb-1">Description (preview)</div>
<div className="text-xs text-gray-300 line-clamp-3 whitespace-pre-wrap">{node.description}</div>
<button onClick={() => setMode('contents')} className="text-xs text-blue-400 hover:text-blue-300 mt-1">Read full contents →</button>
</div>
)}
</div>
)}

{mode === 'contents' && (
<div className="p-3">
<Suspense fallback={<div className="text-sm text-gray-500">Loading…</div>}>
<NodeContentRenderer content={node.description ?? ''} />
</Suspense>
</div>
)}

{mode === 'diagram' && (
hasSubgraph ? (
<NodeSubgraphPreview
subgraphId={node.subgraphId}
subgraphName={node.subgraph?.name}
onOpen={() => descendInto(node.subgraphId)}
/>
) : (
<div className="p-4 text-sm text-gray-500">This node has no sub-graph diagram.</div>
)
)}
</div>
</div>
);
}

function ModeBtn({ active, onClick, icon, label, disabled, title }: { active: boolean; onClick: () => void; icon: React.ReactNode; label: string; disabled?: boolean; title?: string }) {
return (
<button
onClick={onClick}
disabled={disabled}
title={title}
className={`flex-1 flex items-center justify-center gap-1.5 px-2 py-1.5 rounded-lg text-xs font-medium transition-colors ${
disabled
? 'text-gray-600 cursor-not-allowed'
: active
? 'bg-emerald-500/25 text-emerald-200 border border-emerald-400/40'
: 'text-gray-300 hover:bg-gray-700/50 border border-transparent'
}`}
>
{icon}
{label}
</button>
);
}

function Row({ label, value, color }: { label: string; value: string; color?: string }) {
return (
<div className="flex items-center justify-between">
<span className="text-xs text-gray-500">{label}</span>
<span className="text-sm font-medium" style={color ? { color } : undefined}>{value}</span>
</div>
);
}
115 changes: 115 additions & 0 deletions packages/web/src/components/NodeSubgraphPreview.tsx
Original file line number Diff line number Diff line change
@@ -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 <div className="text-sm text-gray-500 p-4">Loading diagram…</div>;
}
if (nodes.length === 0) {
return (
<div className="p-4 space-y-3">
<div className="text-sm text-gray-500">This sub-graph is empty.</div>
<OpenButton onOpen={onOpen} name={subgraphName} />
</div>
);
}

// 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<string, any> = {};
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 (
<div className="p-3 space-y-3">
<div className="flex items-center justify-between">
<span className="text-xs text-gray-400">{nodes.length} nodes · {edges.length} edges</span>
<OpenButton onOpen={onOpen} name={subgraphName} />
</div>
<svg viewBox={`0 0 ${W} ${H}`} className="w-full rounded-lg bg-gray-900/60 border border-gray-700/50" data-testid="subgraph-preview">
{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 (
<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} />
);
})}
{nodes.map((n) => {
const color = getTypeConfig(n.type as WorkItemType).hexColor;
const x = toX(n.positionX ?? 0);
const y = toY(n.positionY ?? 0);
return (
<g key={n.id}>
<circle cx={x} cy={y} r={3.5} fill={color} fillOpacity={0.9} />
{showLabels && (
<text x={x + 5} y={y + 3} fontSize={6} fill="#cbd5e1">
{String(n.title).slice(0, 18)}
</text>
)}
</g>
);
})}
</svg>
</div>
);
}

function OpenButton({ onOpen, name }: { onOpen: () => void; name?: string }) {
return (
<button
onClick={onOpen}
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"
title={name ? `Open ${name}` : 'Open sub-graph'}
>
<Maximize2 className="h-3.5 w-3.5" />
Open
</button>
);
}
11 changes: 8 additions & 3 deletions packages/web/src/components/SafeGraphVisualization.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<GraphErrorBoundary
onError={() => {
// Error logged by boundary for debugging
}}
>
<InteractiveGraphVisualization />
<InteractiveGraphVisualization onNodeSelected={onNodeSelected} />
</GraphErrorBoundary>
);
}
}
13 changes: 11 additions & 2 deletions packages/web/src/pages/Workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<any>(null);
const { currentTeam, currentUser } = useAuth();
const { health, loading: healthLoading, error: healthError } = useHealthStatus();

Expand Down Expand Up @@ -375,7 +377,8 @@ export function Workspace() {
</div>
</div>
) : viewMode === 'graph' ? (
<div className="relative h-full">
<div className="relative h-full flex">
<div className="relative flex-1 min-w-0 h-full">
{/* Neo4j Connection Warning */}
{health?.services?.neo4j?.status !== 'healthy' && (
<div className="absolute top-4 left-4 right-4 z-50">
Expand All @@ -397,7 +400,13 @@ export function Workspace() {
</div>
</div>
)}
<SafeGraphVisualization />
<SafeGraphVisualization onNodeSelected={setInspectorNode} />
</div>
{inspectorNode && (
<div className="w-96 flex-shrink-0 h-full hidden md:block">
<NodeInspector node={inspectorNode} onClose={() => setInspectorNode(null)} />
</div>
)}
</div>
) : (
<ViewManager viewMode={viewMode as 'dashboard' | 'table' | 'cards' | 'kanban' | 'gantt' | 'calendar' | 'activity'} />
Expand Down
7 changes: 4 additions & 3 deletions tests/diagnostics/hierarchy-navigation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
Loading
Loading