From 57c384531aef85516462dac0a65c3c97dc16fa0c Mon Sep 17 00:00:00 2001 From: 777genius Date: Sun, 12 Apr 2026 21:31:53 +0300 Subject: [PATCH] feat(agent-graph): improve blocking visibility and inspection --- packages/agent-graph/src/canvas/draw-edges.ts | 16 +- packages/agent-graph/src/canvas/draw-tasks.ts | 13 +- .../agent-graph/src/canvas/hit-detection.ts | 194 ++++++++++++++- .../src/constants/canvas-constants.ts | 2 + packages/agent-graph/src/ports/types.ts | 6 + packages/agent-graph/src/ui/GraphCanvas.tsx | 39 ++- packages/agent-graph/src/ui/GraphControls.tsx | 17 ++ .../agent-graph/src/ui/GraphEdgeOverlay.tsx | 66 +++++ packages/agent-graph/src/ui/GraphView.tsx | 234 +++++++++++++++--- .../agent-graph/src/ui/buildFocusState.ts | 125 ++++++++-- .../src/ui/selectRenderableParticles.ts | 101 ++++++++ .../agent-graph/adapters/TeamGraphAdapter.ts | 120 +++++++-- .../ui/GraphBlockingEdgePopover.tsx | 207 ++++++++++++++++ .../agent-graph/ui/TeamGraphOverlay.tsx | 12 + .../features/agent-graph/ui/TeamGraphTab.tsx | 12 + .../utils/collapseOverflowStacks.ts | 57 ++++- .../GraphBlockingEdgePopover.test.ts | 195 +++++++++++++++ .../agent-graph/TeamGraphAdapter.test.ts | 130 +++++++++- .../agent-graph/buildFocusState.test.ts | 27 +- .../collapseOverflowStacks.test.ts | 17 +- .../agent-graph/edgeHitDetection.test.ts | 85 +++++++ .../selectRenderableParticles.test.ts | 76 ++++++ 22 files changed, 1642 insertions(+), 109 deletions(-) create mode 100644 packages/agent-graph/src/ui/GraphEdgeOverlay.tsx create mode 100644 packages/agent-graph/src/ui/selectRenderableParticles.ts create mode 100644 src/renderer/features/agent-graph/ui/GraphBlockingEdgePopover.tsx create mode 100644 test/renderer/features/agent-graph/GraphBlockingEdgePopover.test.ts create mode 100644 test/renderer/features/agent-graph/edgeHitDetection.test.ts create mode 100644 test/renderer/features/agent-graph/selectRenderableParticles.test.ts diff --git a/packages/agent-graph/src/canvas/draw-edges.ts b/packages/agent-graph/src/canvas/draw-edges.ts index 7731610b..d85cec27 100644 --- a/packages/agent-graph/src/canvas/draw-edges.ts +++ b/packages/agent-graph/src/canvas/draw-edges.ts @@ -75,6 +75,8 @@ export function drawEdges( _time: number, hasActiveParticles: Set, focusEdgeIds?: ReadonlySet | null, + hoveredEdgeId?: string | null, + selectedEdgeId?: string | null, ): void { for (const edge of edges) { const source = nodeMap.get(edge.source); @@ -84,23 +86,27 @@ export function drawEdges( const style = EDGE_STYLES[edge.type] ?? EDGE_STYLES['parent-child']; const isActive = hasActiveParticles.has(edge.id); + const isSelected = selectedEdgeId === edge.id; + const isHovered = !isSelected && hoveredEdgeId === edge.id; // Pulse alpha when particles are travelling: base 0.3 + 0.2 * sin wave const alpha = isActive ? BEAM.activeAlpha + 0.2 * Math.sin(_time * 6) : BEAM.idleAlpha; const focusAlpha = focusEdgeIds && !focusEdgeIds.has(edge.id) ? 0.1 : 1; + const interactionAlpha = isSelected ? 0.95 : isHovered ? 0.6 : 0; + const finalAlpha = Math.max(alpha * focusAlpha, interactionAlpha); - if (alpha * focusAlpha < MIN_VISIBLE_OPACITY) continue; + if (finalAlpha < MIN_VISIBLE_OPACITY) continue; const cp = computeControlPoints(source.x, source.y, target.x, target.y); ctx.save(); - ctx.globalAlpha = alpha * focusAlpha; + ctx.globalAlpha = finalAlpha; // Subtle glow pass when edge has active particles - if (isActive) { + if (isActive || isSelected || isHovered) { ctx.shadowColor = edge.color ?? style.color; - ctx.shadowBlur = 12; + ctx.shadowBlur = isSelected ? 16 : isHovered ? 10 : 12; } // Draw tapered bezier @@ -119,7 +125,7 @@ export function drawEdges( // Arrow for blocking edges if (edge.type === 'blocking') { - drawArrowHead(ctx, cp, target.x, target.y, style.color, alpha); + drawArrowHead(ctx, cp, target.x, target.y, style.color, finalAlpha); } ctx.restore(); diff --git a/packages/agent-graph/src/canvas/draw-tasks.ts b/packages/agent-graph/src/canvas/draw-tasks.ts index 3c825e62..26ac1644 100644 --- a/packages/agent-graph/src/canvas/draw-tasks.ts +++ b/packages/agent-graph/src/canvas/draw-tasks.ts @@ -266,10 +266,19 @@ function drawOverflowStack( ? 'rgba(15, 20, 40, 0.78)' : COLORS.cardBg; ctx.fill(); - ctx.strokeStyle = hexWithAlpha(COLORS.taskPending, isSelected ? 0.85 : 0.55); - ctx.lineWidth = isSelected ? 2 : 1; + ctx.strokeStyle = node.isBlocked + ? hexWithAlpha(COLORS.edgeBlocking, isSelected ? 0.85 : 0.65) + : hexWithAlpha(COLORS.taskPending, isSelected ? 0.85 : 0.55); + ctx.lineWidth = node.isBlocked ? (isSelected ? 2.4 : 1.5) : isSelected ? 2 : 1; ctx.stroke(); + if (node.isBlocked) { + ctx.fillStyle = hexWithAlpha(COLORS.edgeBlocking, 0.6); + ctx.beginPath(); + ctx.roundRect(-halfW, -halfH, 4, TASK_PILL.height, [r, 0, 0, r]); + ctx.fill(); + } + ctx.font = 'bold 12px sans-serif'; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; diff --git a/packages/agent-graph/src/canvas/hit-detection.ts b/packages/agent-graph/src/canvas/hit-detection.ts index 8e77998e..3d3577c3 100644 --- a/packages/agent-graph/src/canvas/hit-detection.ts +++ b/packages/agent-graph/src/canvas/hit-detection.ts @@ -3,8 +3,9 @@ * Adapted from agent-flow's hit-detection.ts (Apache 2.0). */ -import type { GraphNode } from '../ports/types'; -import { NODE, TASK_PILL, HIT_DETECTION } from '../constants/canvas-constants'; +import type { GraphEdge, GraphNode } from '../ports/types'; +import { BEAM, NODE, TASK_PILL, HIT_DETECTION } from '../constants/canvas-constants'; +import { bezierPoint, computeControlPoints } from './draw-edges'; /** * Find the node at the given world-space coordinates. @@ -65,3 +66,192 @@ export function findNodeAt( return hit; } + +const EDGE_HIT_PRIORITY: Record = { + blocking: 5, + related: 4, + message: 3, + ownership: 2, + 'parent-child': 1, +}; + +function getEdgeHitRadius(edgeType: GraphEdge['type']): number { + switch (edgeType) { + case 'parent-child': + return Math.max(BEAM.parentChild.startW, BEAM.parentChild.endW) * 0.5 + HIT_DETECTION.edgePadding; + case 'ownership': + return Math.max(BEAM.ownership.startW, BEAM.ownership.endW) * 0.5 + HIT_DETECTION.edgePadding; + case 'blocking': + return Math.max(BEAM.blocking.startW, BEAM.blocking.endW) * 0.5 + HIT_DETECTION.edgePadding; + case 'related': + return Math.max(BEAM.related.startW, BEAM.related.endW) * 0.5 + HIT_DETECTION.edgePadding; + case 'message': + return Math.max(BEAM.message.startW, BEAM.message.endW) * 0.5 + HIT_DETECTION.edgePadding; + } +} + +function distanceToSegmentSquared( + px: number, + py: number, + x1: number, + y1: number, + x2: number, + y2: number +): number { + const dx = x2 - x1; + const dy = y2 - y1; + if (dx === 0 && dy === 0) { + const ddx = px - x1; + const ddy = py - y1; + return ddx * ddx + ddy * ddy; + } + + const t = Math.max(0, Math.min(1, ((px - x1) * dx + (py - y1) * dy) / (dx * dx + dy * dy))); + const lx = x1 + dx * t; + const ly = y1 + dy * t; + const ddx = px - lx; + const ddy = py - ly; + return ddx * ddx + ddy * ddy; +} + +function distanceToBezierSquared( + px: number, + py: number, + x1: number, + y1: number, + x2: number, + y2: number +): number { + const cp = computeControlPoints(x1, y1, x2, y2); + let previous = { x: x1, y: y1 }; + let best = Number.POSITIVE_INFINITY; + + for (let segment = 1; segment <= 20; segment += 1) { + const next = bezierPoint(x1, y1, cp, x2, y2, segment / 20); + best = Math.min(best, distanceToSegmentSquared(px, py, previous.x, previous.y, next.x, next.y)); + previous = next; + } + + return best; +} + +function getBezierBounds( + x1: number, + y1: number, + x2: number, + y2: number, + padding: number +): { left: number; top: number; right: number; bottom: number } { + const cp = computeControlPoints(x1, y1, x2, y2); + const left = Math.min(x1, x2, cp.cp1x, cp.cp2x) - padding; + const right = Math.max(x1, x2, cp.cp1x, cp.cp2x) + padding; + const top = Math.min(y1, y2, cp.cp1y, cp.cp2y) - padding; + const bottom = Math.max(y1, y2, cp.cp1y, cp.cp2y) + padding; + return { left, top, right, bottom }; +} + +function boundsIntersect( + left: number, + top: number, + right: number, + bottom: number, + other: { left: number; top: number; right: number; bottom: number } +): boolean { + return left <= other.right && right >= other.left && top <= other.bottom && bottom >= other.top; +} + +export function collectInteractiveEdgesInViewport( + edges: GraphEdge[], + nodeMap: Map, + bounds: { left: number; top: number; right: number; bottom: number }, +): GraphEdge[] { + const candidates: GraphEdge[] = []; + + for (const edge of edges) { + if (edge.type !== 'blocking') { + continue; + } + + const source = nodeMap.get(edge.source); + const target = nodeMap.get(edge.target); + if (!source || !target) continue; + if (source.x == null || source.y == null || target.x == null || target.y == null) continue; + + const edgeBounds = getBezierBounds( + source.x, + source.y, + target.x, + target.y, + getEdgeHitRadius(edge.type) + 24 + ); + if (!boundsIntersect(edgeBounds.left, edgeBounds.top, edgeBounds.right, edgeBounds.bottom, bounds)) { + continue; + } + + candidates.push(edge); + } + + return candidates; +} + +export function findEdgeAt( + worldX: number, + worldY: number, + edges: GraphEdge[], + nodeMap: Map, +): string | null { + let bestHit: { id: string; distanceSquared: number; priority: number } | null = null; + + for (const edge of edges) { + const source = nodeMap.get(edge.source); + const target = nodeMap.get(edge.target); + if (!source || !target) continue; + if (source.x == null || source.y == null || target.x == null || target.y == null) continue; + + const radius = getEdgeHitRadius(edge.type); + const bounds = getBezierBounds(source.x, source.y, target.x, target.y, radius); + if ( + worldX < bounds.left || + worldX > bounds.right || + worldY < bounds.top || + worldY > bounds.bottom + ) { + continue; + } + const distanceSquared = distanceToBezierSquared( + worldX, + worldY, + source.x, + source.y, + target.x, + target.y + ); + if (distanceSquared > radius * radius) { + continue; + } + + const priority = EDGE_HIT_PRIORITY[edge.type]; + if ( + !bestHit || + distanceSquared < bestHit.distanceSquared || + (distanceSquared === bestHit.distanceSquared && priority > bestHit.priority) + ) { + bestHit = { id: edge.id, distanceSquared, priority }; + } + } + + return bestHit?.id ?? null; +} + +export function getEdgeMidpoint( + edge: GraphEdge, + nodeMap: Map +): { x: number; y: number } | null { + const source = nodeMap.get(edge.source); + const target = nodeMap.get(edge.target); + if (!source || !target) return null; + if (source.x == null || source.y == null || target.x == null || target.y == null) return null; + + const cp = computeControlPoints(source.x, source.y, target.x, target.y); + return bezierPoint(source.x, source.y, cp, target.x, target.y, 0.5); +} diff --git a/packages/agent-graph/src/constants/canvas-constants.ts b/packages/agent-graph/src/constants/canvas-constants.ts index 54c7129a..f56a236f 100644 --- a/packages/agent-graph/src/constants/canvas-constants.ts +++ b/packages/agent-graph/src/constants/canvas-constants.ts @@ -218,6 +218,8 @@ export const HIT_DETECTION = { agentPadding: 8, /** Task pill hit area padding */ taskPadding: 4, + /** Extra padding around curved edges for easier inspection */ + edgePadding: 6, } as const; // ─── Background ───────────────────────────────────────────────────────────── diff --git a/packages/agent-graph/src/ports/types.ts b/packages/agent-graph/src/ports/types.ts index 59a7beed..1deb3cce 100644 --- a/packages/agent-graph/src/ports/types.ts +++ b/packages/agent-graph/src/ports/types.ts @@ -155,6 +155,12 @@ export interface GraphEdge { label?: string; /** Edge color override */ color?: string; + /** Number of aggregated raw relations behind this visual edge */ + aggregateCount?: number; + /** Raw source-side task ids represented by this visual edge */ + sourceTaskIds?: string[]; + /** Raw target-side task ids represented by this visual edge */ + targetTaskIds?: string[]; } // ─── Graph Particle ────────────────────────────────────────────────────────── diff --git a/packages/agent-graph/src/ui/GraphCanvas.tsx b/packages/agent-graph/src/ui/GraphCanvas.tsx index 6fc2a726..eee3c1ba 100644 --- a/packages/agent-graph/src/ui/GraphCanvas.tsx +++ b/packages/agent-graph/src/ui/GraphCanvas.tsx @@ -17,6 +17,7 @@ import { drawProcesses } from '../canvas/draw-processes'; import { drawEffects, type VisualEffect } from '../canvas/draw-effects'; import { BloomRenderer } from '../canvas/bloom-renderer'; import { KanbanLayoutEngine } from '../layout/kanbanLayout'; +import { computeAdaptiveParticleBudget, selectRenderableParticles } from './selectRenderableParticles'; import type { CameraTransform } from '../hooks/useGraphCamera'; // ─── Draw State (passed by ref, not by props — no React re-renders) ───────── @@ -30,6 +31,8 @@ export interface GraphDrawState { camera: CameraTransform; selectedNodeId: string | null; hoveredNodeId: string | null; + selectedEdgeId: string | null; + hoveredEdgeId: string | null; focusNodeIds: ReadonlySet | null; focusEdgeIds: ReadonlySet | null; } @@ -118,6 +121,7 @@ export const GraphCanvas = forwardRef(funct const visibleNodesCache = useRef([]); const visibleEdgesCache = useRef([]); const visibleNodeIdsCache = useRef(new Set()); + const visibleEdgeIdsCache = useRef(new Set()); const activeParticleEdgesCache = useRef(new Set()); // Imperative draw function — called from RAF, NOT from React render @@ -196,18 +200,41 @@ export const GraphCanvas = forwardRef(funct const visibleEdges = visibleEdgesCache.current; visibleEdges.length = 0; + const visibleEdgeIds = visibleEdgeIdsCache.current; + visibleEdgeIds.clear(); for (const e of state.edges) { if (visibleNodeIds.has(e.source) || visibleNodeIds.has(e.target)) { visibleEdges.push(e); + visibleEdgeIds.add(e.id); } } - drawEdges(ctx, visibleEdges, nodeMap, state.time, activeParticleEdges, state.focusEdgeIds); + const prioritizedEdgeIds = + state.focusEdgeIds ?? (state.selectedEdgeId ? new Set([state.selectedEdgeId]) : null); + drawEdges( + ctx, + visibleEdges, + nodeMap, + state.time, + activeParticleEdges, + prioritizedEdgeIds, + state.hoveredEdgeId, + state.selectedEdgeId + ); - // 2b. Particles (cap at 100 for performance) - const cappedParticles = state.particles.length > 100 - ? state.particles.slice(-100) - : state.particles; - drawParticles(ctx, cappedParticles, edgeMap, nodeMap, state.time, state.focusEdgeIds); + // 2b. Particles - adaptive degradation keeps one visible particle per active edge + const particleBudget = computeAdaptiveParticleBudget({ + visibleNodeCount: visibleNodes.length, + visibleEdgeCount: visibleEdges.length, + frameTimeMs: perfRef.current.frameTimeMs, + hasFocusedEdges: (prioritizedEdgeIds?.size ?? 0) > 0, + }); + const renderableParticles = selectRenderableParticles({ + particles: state.particles, + visibleEdgeIds, + focusEdgeIds: prioritizedEdgeIds, + budget: particleBudget, + }); + drawParticles(ctx, renderableParticles, edgeMap, nodeMap, state.time, prioritizedEdgeIds); // 2c. Visible nodes only (back to front: process → task → member/lead) drawProcesses( diff --git a/packages/agent-graph/src/ui/GraphControls.tsx b/packages/agent-graph/src/ui/GraphControls.tsx index 135d0acf..08f23e58 100644 --- a/packages/agent-graph/src/ui/GraphControls.tsx +++ b/packages/agent-graph/src/ui/GraphControls.tsx @@ -39,6 +39,7 @@ export interface GraphControlsProps { teamName: string; teamColor?: string; isAlive?: boolean; + showBlockingHint?: boolean; } export function GraphControls({ @@ -53,6 +54,7 @@ export function GraphControls({ teamName, teamColor, isAlive, + showBlockingHint = false, }: GraphControlsProps): React.JSX.Element { const [isSettingsOpen, setIsSettingsOpen] = useState(false); const settingsRef = useRef(null); @@ -203,6 +205,21 @@ export function GraphControls({ } /> + + {showBlockingHint && ( +
+
+ Red lines - blockers, click to inspect +
+
+ )} ); } diff --git a/packages/agent-graph/src/ui/GraphEdgeOverlay.tsx b/packages/agent-graph/src/ui/GraphEdgeOverlay.tsx new file mode 100644 index 00000000..1a363744 --- /dev/null +++ b/packages/agent-graph/src/ui/GraphEdgeOverlay.tsx @@ -0,0 +1,66 @@ +import type { GraphEdge, GraphNode } from '../ports/types'; + +function getEdgeTypeLabel(edgeType: GraphEdge['type']): string { + switch (edgeType) { + case 'blocking': + return 'Blocking'; + case 'ownership': + return 'Ownership'; + case 'related': + return 'Related'; + case 'message': + return 'Message'; + case 'parent-child': + return 'Parent-child'; + } +} + +export interface GraphEdgeOverlayProps { + edge: GraphEdge; + sourceNode: GraphNode | undefined; + targetNode: GraphNode | undefined; + onClose: () => void; +} + +export function GraphEdgeOverlay({ + edge, + sourceNode, + targetNode, + onClose, +}: GraphEdgeOverlayProps): React.JSX.Element { + return ( +
+
+ {getEdgeTypeLabel(edge.type)} +
+
+ {sourceNode?.label ?? edge.source} -> {targetNode?.label ?? edge.target} +
+ {edge.label && ( +
+ {edge.label} +
+ )} +
+ +
+
+ ); +} diff --git a/packages/agent-graph/src/ui/GraphView.tsx b/packages/agent-graph/src/ui/GraphView.tsx index 63dd1636..35e0290d 100644 --- a/packages/agent-graph/src/ui/GraphView.tsx +++ b/packages/agent-graph/src/ui/GraphView.tsx @@ -15,15 +15,21 @@ import { autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/d import type { GraphDataPort } from '../ports/GraphDataPort'; import type { GraphEventPort } from '../ports/GraphEventPort'; import type { GraphConfigPort } from '../ports/GraphConfigPort'; -import type { GraphNode } from '../ports/types'; +import type { GraphEdge, GraphNode } from '../ports/types'; import { GraphCanvas, type GraphCanvasHandle, type GraphDrawState } from './GraphCanvas'; import { GraphControls, type GraphFilterState } from './GraphControls'; import { GraphOverlay } from './GraphOverlay'; +import { GraphEdgeOverlay } from './GraphEdgeOverlay'; import { buildFocusState } from './buildFocusState'; import { useGraphSimulation } from '../hooks/useGraphSimulation'; import { useGraphCamera } from '../hooks/useGraphCamera'; import { useGraphInteraction } from '../hooks/useGraphInteraction'; -import { findNodeAt } from '../canvas/hit-detection'; +import { + collectInteractiveEdgesInViewport, + findEdgeAt, + findNodeAt, + getEdgeMidpoint, +} from '../canvas/hit-detection'; import { ANIM_SPEED } from '../constants/canvas-constants'; export interface GraphViewProps { @@ -41,6 +47,13 @@ export interface GraphViewProps { screenPos: { x: number; y: number }; onClose: () => void; }) => React.ReactNode; + renderEdgeOverlay?: (props: { + edge: GraphEdge; + sourceNode: GraphNode | undefined; + targetNode: GraphNode | undefined; + onClose: () => void; + onSelectNode: (nodeId: string) => void; + }) => React.ReactNode; } export function GraphView({ @@ -53,9 +66,11 @@ export function GraphView({ onRequestPinAsTab, onRequestFullscreen, renderOverlay, + renderEdgeOverlay, }: GraphViewProps): React.JSX.Element { // ─── React state (user-facing only) ───────────────────────────────────── const [selectedNodeId, setSelectedNodeId] = useState(null); + const [selectedEdgeId, setSelectedEdgeId] = useState(null); const [filters, setFilters] = useState({ showTasks: config?.showTasks ?? true, showProcesses: config?.showProcesses ?? true, @@ -67,6 +82,9 @@ export function GraphView({ // Ref mirror of selectedNodeId — read by RAF loop to avoid recreating animate on selection change const selectedNodeIdRef = useRef(null); selectedNodeIdRef.current = selectedNodeId; + const selectedEdgeIdRef = useRef(null); + selectedEdgeIdRef.current = selectedEdgeId; + const hoveredEdgeIdRef = useRef(null); const containerRef = useRef(null); const canvasHandle = useRef(null); @@ -76,6 +94,8 @@ export function GraphView({ const runningRef = useRef(false); const hasAutoFit = useRef(false); const allowAutoFitRef = useRef(true); + const nodeMapRef = useRef(new Map()); + const nodeMapNodesRef = useRef(null); // ─── Hooks ────────────────────────────────────────────────────────────── const simulation = useGraphSimulation(); @@ -116,8 +136,37 @@ export function GraphView({ // ─── UNIFIED RAF LOOP: tick simulation + draw canvas ──────────────────── const idleFrameSkip = useRef(0); const focusState = useMemo( - () => buildFocusState(selectedNodeId, data.nodes, data.edges), - [selectedNodeId, data.edges, data.nodes] + () => buildFocusState(selectedNodeId, selectedEdgeId, data.nodes, data.edges), + [selectedEdgeId, selectedNodeId, data.edges, data.nodes] + ); + + const getNodeMap = useCallback((nodes: GraphNode[]): Map => { + if (nodeMapNodesRef.current === nodes) { + return nodeMapRef.current; + } + const nodeMap = nodeMapRef.current; + nodeMap.clear(); + for (const node of nodes) { + nodeMap.set(node.id, node); + } + nodeMapNodesRef.current = nodes; + return nodeMap; + }, []); + + const getInteractiveEdges = useCallback( + (canvas: HTMLCanvasElement, nodes: GraphNode[], edges: GraphEdge[]): GraphEdge[] => { + const nodeMap = getNodeMap(nodes); + const rect = canvas.getBoundingClientRect(); + const transform = camera.transformRef.current; + const bounds = { + left: -transform.x / transform.zoom, + top: -transform.y / transform.zoom, + right: (rect.width - transform.x) / transform.zoom, + bottom: (rect.height - transform.y) / transform.zoom, + }; + return collectInteractiveEdgesInViewport(edges, nodeMap, bounds); + }, + [camera.transformRef, getNodeMap] ); const animate = useCallback(() => { @@ -159,6 +208,8 @@ export function GraphView({ camera: cameraRef.current.transformRef.current, selectedNodeId: selectedNodeIdRef.current, hoveredNodeId: interaction.hoveredNodeId.current, + selectedEdgeId: selectedEdgeIdRef.current, + hoveredEdgeId: hoveredEdgeIdRef.current, focusNodeIds: focusState.focusNodeIds, focusEdgeIds: focusState.focusEdgeIds, }); @@ -243,6 +294,7 @@ export function GraphView({ // ─── Mouse handlers (Figma-style: drag empty space = pan, drag node = move) ─ const isPanningRef = useRef(false); + const edgeMouseDownRef = useRef<{ id: string; x: number; y: number } | null>(null); const handleMouseDown = useCallback((e: React.MouseEvent) => { if (e.button !== 0) return; // only left click @@ -251,22 +303,38 @@ export function GraphView({ if (!canvas) return; const rect = canvas.getBoundingClientRect(); const world = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top); + const nodes = simulation.stateRef.current.nodes; + const edges = simulation.stateRef.current.edges; + const nodeMap = getNodeMap(nodes); + const interactiveEdges = getInteractiveEdges(canvas, nodes, edges); // Check if we hit a node - interaction.handleMouseDown(world.x, world.y, simulation.stateRef.current.nodes); + interaction.handleMouseDown(world.x, world.y, nodes); // Hit a node (draggable or clickable) → don't pan - const hitNode = findNodeAt(world.x, world.y, simulation.stateRef.current.nodes); + const hitNode = findNodeAt(world.x, world.y, nodes); if (hitNode) { markUserInteracted(); isPanningRef.current = false; + edgeMouseDownRef.current = null; + hoveredEdgeIdRef.current = null; } else { - // Hit empty space → pan - markUserInteracted(); - isPanningRef.current = true; - camera.handlePanStart(e.clientX, e.clientY); + const hitEdge = findEdgeAt(world.x, world.y, interactiveEdges, nodeMap); + if (hitEdge) { + markUserInteracted(); + isPanningRef.current = false; + edgeMouseDownRef.current = { id: hitEdge, x: world.x, y: world.y }; + hoveredEdgeIdRef.current = hitEdge; + } else { + // Hit empty space → pan + markUserInteracted(); + isPanningRef.current = true; + edgeMouseDownRef.current = null; + hoveredEdgeIdRef.current = null; + camera.handlePanStart(e.clientX, e.clientY); + } } - }, [camera, interaction, markUserInteracted, simulation.stateRef]); + }, [camera, getInteractiveEdges, getNodeMap, interaction, markUserInteracted, simulation.stateRef]); const handleMouseMove = useCallback((e: React.MouseEvent) => { // Dragging with left button held @@ -288,26 +356,65 @@ export function GraphView({ if (!canvas) return; const rect = canvas.getBoundingClientRect(); const world = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top); - interaction.hoveredNodeId.current = findNodeAt(world.x, world.y, simulation.stateRef.current.nodes); - canvas.style.cursor = interaction.hoveredNodeId.current ? 'pointer' : 'grab'; - }, [camera, interaction, simulation.stateRef]); + const nodes = simulation.stateRef.current.nodes; + const edges = simulation.stateRef.current.edges; + const hoveredNodeId = findNodeAt(world.x, world.y, nodes); + interaction.hoveredNodeId.current = hoveredNodeId; - const handleMouseUp = useCallback(() => { + if (hoveredNodeId) { + hoveredEdgeIdRef.current = null; + canvas.style.cursor = 'pointer'; + return; + } + + const nodeMap = getNodeMap(nodes); + const interactiveEdges = getInteractiveEdges(canvas, nodes, edges); + hoveredEdgeIdRef.current = findEdgeAt(world.x, world.y, interactiveEdges, nodeMap); + canvas.style.cursor = hoveredEdgeIdRef.current ? 'pointer' : 'grab'; + }, [camera, getInteractiveEdges, getNodeMap, interaction, simulation.stateRef]); + + const handleMouseUp = useCallback((e: React.MouseEvent) => { if (isPanningRef.current) { camera.handlePanEnd(); isPanningRef.current = false; setSelectedNodeId(null); // hide popover after pan + setSelectedEdgeId(null); + edgeMouseDownRef.current = null; return; } const clickedId = interaction.handleMouseUp(); if (clickedId) { setSelectedNodeId(clickedId); + setSelectedEdgeId(null); const node = simulation.stateRef.current.nodes.find((n) => n.id === clickedId); if (node) events?.onNodeClick?.(node.domainRef); } else { - setSelectedNodeId(null); // click on empty space — hide popover - if (!interaction.isDragging.current) { + const canvas = canvasHandle.current?.getCanvas(); + let clickedEdgeId: string | null = null; + if (canvas && edgeMouseDownRef.current && !interaction.isDragging.current) { + const rect = canvas.getBoundingClientRect(); + const world = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top); + const dx = world.x - edgeMouseDownRef.current.x; + const dy = world.y - edgeMouseDownRef.current.y; + if (dx * dx + dy * dy <= 25) { + clickedEdgeId = edgeMouseDownRef.current.id; + } + } + edgeMouseDownRef.current = null; + + if (clickedEdgeId) { + setSelectedNodeId(null); + setSelectedEdgeId(clickedEdgeId); + const edge = simulation.stateRef.current.edges.find((candidate) => candidate.id === clickedEdgeId); + if (edge) { + events?.onEdgeClick?.(edge); + } + } else { + setSelectedNodeId(null); // click on empty space — hide popover + setSelectedEdgeId(null); + } + if (!interaction.isDragging.current && !clickedEdgeId) { events?.onBackgroundClick?.(); } } @@ -320,6 +427,7 @@ export function GraphView({ const world = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top); const nodeId = interaction.handleDoubleClick(world.x, world.y, simulation.stateRef.current.nodes); if (nodeId) { + setSelectedEdgeId(null); const node = simulation.stateRef.current.nodes.find((n) => n.id === nodeId); if (node) { // Unpin if pinned (toggle) @@ -340,8 +448,9 @@ export function GraphView({ if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) return; if (e.key === 'Escape') { - if (selectedNodeId) { + if (selectedNodeId || selectedEdgeId) { setSelectedNodeId(null); + setSelectedEdgeId(null); } else { onRequestClose?.(); } @@ -357,16 +466,28 @@ export function GraphView({ }; window.addEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler); - }, [selectedNodeId, onRequestClose, camera, simulation.stateRef]); + }, [selectedEdgeId, selectedNodeId, onRequestClose, camera, simulation.stateRef]); // ─── Selected node for overlay ────────────────────────────────────────── const selectedNode: GraphNode | null = selectedNodeId ? simulation.stateRef.current.nodes.find((n) => n.id === selectedNodeId) ?? null : null; + const selectedEdge: GraphEdge | null = + selectedEdgeId + ? simulation.stateRef.current.edges.find((edge) => edge.id === selectedEdgeId) ?? null + : null; + const hasBlockingEdges = useMemo( + () => data.edges.some((edge) => edge.type === 'blocking'), + [data.edges] + ); + const selectedEdgeNodeMap = useMemo( + () => getNodeMap(simulation.stateRef.current.nodes), + [data.nodes, getNodeMap, selectedEdgeId, simulation.stateRef] + ); useLayoutEffect(() => { - if (!selectedNode || !containerRef.current || !overlayRef.current) { + if ((!selectedNode && !selectedEdgeId) || !containerRef.current || !overlayRef.current) { return; } @@ -376,7 +497,25 @@ export function GraphView({ const reference = { getBoundingClientRect(): DOMRect { const containerRect = container.getBoundingClientRect(); - const screenPos = camera.worldToScreen(selectedNode.x ?? 0, selectedNode.y ?? 0); + const screenPos = (() => { + if (selectedNode) { + return camera.worldToScreen(selectedNode.x ?? 0, selectedNode.y ?? 0); + } + if (selectedEdgeId) { + const currentNodes = simulation.stateRef.current.nodes; + const currentEdge = simulation.stateRef.current.edges.find( + (edge) => edge.id === selectedEdgeId + ); + if (currentEdge) { + const nodeMap = getNodeMap(currentNodes); + const midpoint = getEdgeMidpoint(currentEdge, nodeMap); + if (midpoint) { + return camera.worldToScreen(midpoint.x, midpoint.y); + } + } + } + return camera.worldToScreen(0, 0); + })(); return DOMRect.fromRect({ x: containerRect.left + screenPos.x, y: containerRect.top + screenPos.y, @@ -415,7 +554,7 @@ export function GraphView({ void updatePosition(); return cleanup; - }, [camera, selectedNode]); + }, [camera, getNodeMap, selectedEdgeId, selectedNode, simulation.stateRef]); // ─── Render ───────────────────────────────────────────────────────────── return ( @@ -454,23 +593,46 @@ export function GraphView({ teamName={data.teamName} teamColor={data.teamColor} isAlive={data.isAlive} + showBlockingHint={filters.showEdges && hasBlockingEdges && !selectedNode && !selectedEdge} /> - {selectedNode && ( + {(selectedNode || selectedEdge) && (
- {renderOverlay ? ( - renderOverlay({ - node: selectedNode, - screenPos: camera.worldToScreen(selectedNode.x ?? 0, selectedNode.y ?? 0), - onClose: () => setSelectedNodeId(null), - }) - ) : ( - setSelectedNodeId(null)} - /> - )} + {selectedNode ? ( + renderOverlay ? ( + renderOverlay({ + node: selectedNode, + screenPos: camera.worldToScreen(selectedNode.x ?? 0, selectedNode.y ?? 0), + onClose: () => setSelectedNodeId(null), + }) + ) : ( + setSelectedNodeId(null)} + /> + ) + ) : selectedEdge ? ( + renderEdgeOverlay ? ( + renderEdgeOverlay({ + edge: selectedEdge, + sourceNode: selectedEdgeNodeMap.get(selectedEdge.source), + targetNode: selectedEdgeNodeMap.get(selectedEdge.target), + onClose: () => setSelectedEdgeId(null), + onSelectNode: (nodeId: string) => { + setSelectedEdgeId(null); + setSelectedNodeId(nodeId); + }, + }) + ) : ( + setSelectedEdgeId(null)} + /> + ) + ) : null}
)} diff --git a/packages/agent-graph/src/ui/buildFocusState.ts b/packages/agent-graph/src/ui/buildFocusState.ts index 71b82ad2..0481b8a6 100644 --- a/packages/agent-graph/src/ui/buildFocusState.ts +++ b/packages/agent-graph/src/ui/buildFocusState.ts @@ -28,25 +28,15 @@ function addNodeAndIncidentEdges( export function buildFocusState( selectedNodeId: string | null, + selectedEdgeId: string | null, nodes: GraphNode[], edges: GraphEdge[] ): GraphFocusState { - if (!selectedNodeId) { + if (!selectedNodeId && !selectedEdgeId) { return { focusNodeIds: null, focusEdgeIds: null }; } - const selectedNode = nodes.find((node) => node.id === selectedNodeId) ?? null; - if ( - !selectedNode || - selectedNode.kind === 'process' || - selectedNode.kind === 'crossteam' || - selectedNode.isOverflowStack - ) { - return { focusNodeIds: null, focusEdgeIds: null }; - } - - const nodeIds = new Set([selectedNodeId]); - const edgeIds = new Set(); + const nodeById = new Map(nodes.map((node) => [node.id, node] as const)); const adjacency = new Map(); for (const edge of edges) { @@ -59,20 +49,117 @@ export function buildFocusState( adjacency.set(edge.target, targetEdges); } + if (selectedNodeId == null && selectedEdgeId != null) { + const selectedEdge = edges.find((edge) => edge.id === selectedEdgeId) ?? null; + if (!selectedEdge || selectedEdge.type !== 'blocking') { + return { focusNodeIds: null, focusEdgeIds: null }; + } + + const sourceNode = nodeById.get(selectedEdge.source); + const targetNode = nodeById.get(selectedEdge.target); + if (!sourceNode || !targetNode) { + return { focusNodeIds: null, focusEdgeIds: null }; + } + + const nodeIds = new Set([selectedEdge.source, selectedEdge.target]); + const edgeIds = new Set([selectedEdge.id]); + const queue = [selectedEdge.source, selectedEdge.target]; + + while (queue.length > 0) { + const currentNodeId = queue.shift()!; + const currentNode = nodeById.get(currentNodeId); + if (!currentNode || currentNode.kind !== 'task') { + continue; + } + + for (const edge of adjacency.get(currentNodeId) ?? []) { + if (edge.type !== 'blocking') { + continue; + } + if (!edgeIds.has(edge.id)) { + edgeIds.add(edge.id); + } + const neighborId = edge.source === currentNodeId ? edge.target : edge.source; + if (!nodeIds.has(neighborId)) { + nodeIds.add(neighborId); + queue.push(neighborId); + } + } + } + + for (const nodeId of Array.from(nodeIds)) { + const node = nodeById.get(nodeId); + if (!node || node.kind !== 'task') { + continue; + } + if (node.ownerId) { + nodeIds.add(node.ownerId); + } + if (node.reviewerName) { + const reviewerNode = nodes.find( + (candidate) => + candidate.kind === 'member' && + candidate.domainRef.kind === 'member' && + candidate.domainRef.memberName === node.reviewerName + ); + if (reviewerNode) { + nodeIds.add(reviewerNode.id); + } + } + for (const edge of adjacency.get(node.id) ?? []) { + if (edge.type === 'ownership') { + edgeIds.add(edge.id); + nodeIds.add(edge.source); + nodeIds.add(edge.target); + } + } + } + + for (const nodeId of Array.from(nodeIds)) { + const node = nodeById.get(nodeId); + if (node?.kind !== 'member') continue; + for (const edge of adjacency.get(nodeId) ?? []) { + if (edge.type === 'parent-child') { + edgeIds.add(edge.id); + nodeIds.add(edge.source); + nodeIds.add(edge.target); + } + } + } + + return { + focusNodeIds: nodeIds, + focusEdgeIds: edgeIds, + }; + } + + const selectedNode = nodes.find((node) => node.id === selectedNodeId) ?? null; + if ( + !selectedNode || + selectedNode.kind === 'process' || + selectedNode.kind === 'crossteam' || + selectedNode.isOverflowStack + ) { + return { focusNodeIds: null, focusEdgeIds: null }; + } + + const nodeIds = new Set([selectedNode.id]); + const edgeIds = new Set(); + const selectedMemberName = selectedNode.domainRef.kind === 'member' || selectedNode.domainRef.kind === 'lead' ? selectedNode.domainRef.memberName : null; if (selectedNode.kind === 'lead') { - addNodeAndIncidentEdges(nodeIds, edgeIds, selectedNodeId, adjacency); + addNodeAndIncidentEdges(nodeIds, edgeIds, selectedNode.id, adjacency); } else if (selectedNode.kind === 'member') { - addNodeAndIncidentEdges(nodeIds, edgeIds, selectedNodeId, adjacency); + addNodeAndIncidentEdges(nodeIds, edgeIds, selectedNode.id, adjacency); for (const node of nodes) { if (node.kind !== 'task') continue; if (node.isOverflowStack) { - if (node.ownerId === selectedNodeId) { + if (node.ownerId === selectedNode.id) { nodeIds.add(node.id); for (const edge of adjacency.get(node.id) ?? []) { edgeIds.add(edge.id); @@ -81,7 +168,7 @@ export function buildFocusState( continue; } - const isOwnedTask = node.ownerId === selectedNodeId; + const isOwnedTask = node.ownerId === selectedNode.id; const isReviewTask = selectedMemberName != null && node.reviewerName === selectedMemberName && @@ -115,7 +202,7 @@ export function buildFocusState( } } - for (const edge of adjacency.get(selectedNodeId) ?? []) { + for (const edge of adjacency.get(selectedNode.id) ?? []) { if (edge.type === 'ownership' || edge.type === 'blocking') { edgeIds.add(edge.id); nodeIds.add(edge.source); @@ -125,7 +212,7 @@ export function buildFocusState( } const focusedMemberIds = Array.from(nodeIds).filter((nodeId) => { - const node = nodes.find((candidate) => candidate.id === nodeId); + const node = nodeById.get(nodeId); return node?.kind === 'member'; }); diff --git a/packages/agent-graph/src/ui/selectRenderableParticles.ts b/packages/agent-graph/src/ui/selectRenderableParticles.ts new file mode 100644 index 00000000..1247bf74 --- /dev/null +++ b/packages/agent-graph/src/ui/selectRenderableParticles.ts @@ -0,0 +1,101 @@ +import type { GraphParticle } from '../ports/types'; + +const MIN_PARTICLE_BUDGET = 120; +const MAX_PARTICLE_BUDGET = 360; +const FOCUSED_MIN_BUDGET = 180; + +export function computeAdaptiveParticleBudget(params: { + visibleNodeCount: number; + visibleEdgeCount: number; + frameTimeMs: number; + hasFocusedEdges: boolean; +}): number { + const baseBudget = Math.max( + MIN_PARTICLE_BUDGET, + Math.min(MAX_PARTICLE_BUDGET, 48 + params.visibleNodeCount * 3 + params.visibleEdgeCount * 2) + ); + + let adjustedBudget = baseBudget; + if (params.frameTimeMs >= 24) { + adjustedBudget = Math.floor(baseBudget * 0.55); + } else if (params.frameTimeMs >= 18) { + adjustedBudget = Math.floor(baseBudget * 0.72); + } else if (params.frameTimeMs >= 14) { + adjustedBudget = Math.floor(baseBudget * 0.88); + } + + if (params.hasFocusedEdges) { + adjustedBudget = Math.max(adjustedBudget, FOCUSED_MIN_BUDGET); + } + + return Math.max(48, adjustedBudget); +} + +function sampleEvenly(items: T[], limit: number): T[] { + if (items.length <= limit) { + return items; + } + if (limit <= 0) { + return []; + } + + const sampled: T[] = []; + for (let index = 0; index < limit; index += 1) { + const itemIndex = Math.min(items.length - 1, Math.floor((index * items.length) / limit)); + sampled.push(items[itemIndex]); + } + return sampled; +} + +export function selectRenderableParticles(params: { + particles: GraphParticle[]; + visibleEdgeIds: ReadonlySet; + focusEdgeIds?: ReadonlySet | null; + budget: number; +}): GraphParticle[] { + const visibleParticles = params.particles.filter( + (particle) => + params.visibleEdgeIds.has(particle.edgeId) || + (params.focusEdgeIds?.has(particle.edgeId) ?? false) + ); + if (visibleParticles.length <= params.budget) { + return visibleParticles; + } + + const indexed = visibleParticles.map((particle, index) => ({ particle, index })); + const focused = params.focusEdgeIds + ? indexed.filter(({ particle }) => params.focusEdgeIds?.has(particle.edgeId)) + : []; + const nonFocused = focused.length === indexed.length + ? [] + : indexed.filter(({ particle }) => !(params.focusEdgeIds?.has(particle.edgeId) ?? false)); + + const selectedById = new Set(); + const seenEdges = new Set(); + const seed: Array<{ particle: GraphParticle; index: number }> = []; + + for (const pool of [focused, nonFocused]) { + for (let cursor = pool.length - 1; cursor >= 0; cursor -= 1) { + const candidate = pool[cursor]; + if (seenEdges.has(candidate.particle.edgeId)) { + continue; + } + seenEdges.add(candidate.particle.edgeId); + selectedById.add(candidate.particle.id); + seed.push(candidate); + } + } + + const seedSorted = seed.sort((left, right) => left.index - right.index); + if (seedSorted.length >= params.budget) { + return sampleEvenly(seedSorted, params.budget).map(({ particle }) => particle); + } + + const remaining = indexed.filter(({ particle }) => !selectedById.has(particle.id)); + const remainingBudget = params.budget - seedSorted.length; + const extra = sampleEvenly(remaining, remainingBudget); + + return [...seedSorted, ...extra] + .sort((left, right) => left.index - right.index) + .map(({ particle }) => particle); +} diff --git a/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts b/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts index 3bb496cb..9dd764a0 100644 --- a/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts +++ b/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts @@ -18,7 +18,7 @@ import { import { isInboxNoiseMessage } from '@shared/utils/inboxNoise'; import { isLeadMember } from '@shared/utils/leadDetection'; -import { collapseOverflowStacks } from '../utils/collapseOverflowStacks'; +import { collapseOverflowStacksWithMeta } from '../utils/collapseOverflowStacks'; import { isTaskBlocked, isTaskInReviewCycle, @@ -47,8 +47,10 @@ export class TeamGraphAdapter { readonly #seenRelated = new Set(); readonly #seenMessageIds = new Set(); #initialMessagesSeen = false; + #messageParticleCutoffMs: number | null = null; readonly #seenCommentCounts = new Map(); #initialCommentsSeen = false; + #commentParticleCutoffMs: number | null = null; // ─── Static factory ────────────────────────────────────────────────────── static create(): TeamGraphAdapter { @@ -84,8 +86,10 @@ export class TeamGraphAdapter { if (teamName !== this.#lastTeamName) { this.#seenMessageIds.clear(); this.#initialMessagesSeen = false; + this.#messageParticleCutoffMs = null; this.#seenCommentCounts.clear(); this.#initialCommentsSeen = false; + this.#commentParticleCutoffMs = null; } this.#lastTeamName = teamName; @@ -152,8 +156,10 @@ export class TeamGraphAdapter { this.#seenRelated.clear(); this.#seenMessageIds.clear(); this.#initialMessagesSeen = false; + this.#messageParticleCutoffMs = null; this.#seenCommentCounts.clear(); this.#initialCommentsSeen = false; + this.#commentParticleCutoffMs = null; this.#lastTeamName = ''; } @@ -163,6 +169,14 @@ export class TeamGraphAdapter { return data.members.find((member) => isLeadMember(member))?.name ?? `${teamName}-lead`; } + static #isBeforeParticleCutoff(timestamp: string | undefined, cutoffMs: number | null): boolean { + if (!timestamp || cutoffMs == null) { + return false; + } + const parsed = Date.parse(timestamp); + return Number.isFinite(parsed) && parsed < cutoffMs; + } + static #getRuntimeLabel( providerId: TeamData['members'][number]['providerId'], model: TeamData['members'][number]['model'], @@ -425,7 +439,8 @@ export class TeamGraphAdapter { }); } - const visibleTaskNodes = collapseOverflowStacks(rawTaskNodes, teamName, 6); + const { visibleNodes: visibleTaskNodes, visibleNodeIdByTaskId } = + collapseOverflowStacksWithMeta(rawTaskNodes, teamName, 6); const visibleTaskIds = new Set( visibleTaskNodes.flatMap((taskNode) => taskNode.domainRef.kind === 'task' ? [taskNode.domainRef.taskId] : [] @@ -444,37 +459,60 @@ export class TeamGraphAdapter { }); } - const seenBlockingEdges = new Set(); + const seenBlockingRelations = new Set(); + const blockingEdges = new Map< + string, + { + source: string; + target: string; + aggregateCount: number; + sourceTaskIds: Set; + targetTaskIds: Set; + } + >(); + const addBlockingRelation = (blockerId: string, blockedId: string): void => { + if (blockerId === blockedId) return; + const rawRelationKey = `${blockerId}->${blockedId}`; + if (seenBlockingRelations.has(rawRelationKey)) return; + seenBlockingRelations.add(rawRelationKey); + + const sourceNodeId = visibleNodeIdByTaskId.get(blockerId); + const targetNodeId = visibleNodeIdByTaskId.get(blockedId); + if (!sourceNodeId || !targetNodeId || sourceNodeId === targetNodeId) { + return; + } + + const edgeId = TeamGraphAdapter.#buildBlockingEdgeId(sourceNodeId, targetNodeId); + const existing = blockingEdges.get(edgeId); + if (existing) { + existing.aggregateCount += 1; + existing.sourceTaskIds.add(blockerId); + existing.targetTaskIds.add(blockedId); + return; + } + blockingEdges.set(edgeId, { + source: sourceNodeId, + target: targetNodeId, + aggregateCount: 1, + sourceTaskIds: new Set([blockerId]), + targetTaskIds: new Set([blockedId]), + }); + }; + for (const task of data.tasks) { - if (task.status === 'deleted' || !visibleTaskIds.has(task.id)) continue; + if (task.status === 'deleted') continue; const taskNodeId = `task:${teamName}:${task.id}`; for (const blockerId of task.blockedBy ?? []) { - if (!visibleTaskIds.has(blockerId)) continue; - const edgeId = TeamGraphAdapter.#buildBlockingEdgeId(teamName, blockerId, task.id); - if (seenBlockingEdges.has(edgeId)) continue; - seenBlockingEdges.add(edgeId); - edges.push({ - id: edgeId, - source: `task:${teamName}:${blockerId}`, - target: taskNodeId, - type: 'blocking', - }); + addBlockingRelation(blockerId, task.id); } for (const blockedId of task.blocks ?? []) { - if (!visibleTaskIds.has(blockedId)) continue; - const edgeId = TeamGraphAdapter.#buildBlockingEdgeId(teamName, task.id, blockedId); - if (seenBlockingEdges.has(edgeId)) continue; - seenBlockingEdges.add(edgeId); - edges.push({ - id: edgeId, - source: taskNodeId, - target: `task:${teamName}:${blockedId}`, - type: 'blocking', - }); + addBlockingRelation(task.id, blockedId); } + if (!visibleTaskIds.has(task.id)) continue; + for (const relatedId of task.related ?? []) { if (!visibleTaskIds.has(relatedId)) continue; const key = [task.id, relatedId].sort().join(':'); @@ -488,6 +526,23 @@ export class TeamGraphAdapter { }); } } + + edges.push( + ...Array.from(blockingEdges.entries()).map(([edgeId, edge]) => ({ + id: edgeId, + source: edge.source, + target: edge.target, + type: 'blocking' as const, + aggregateCount: edge.aggregateCount, + sourceTaskIds: Array.from(edge.sourceTaskIds), + targetTaskIds: Array.from(edge.targetTaskIds), + label: + edge.aggregateCount > 1 && + (edge.source.includes(':overflow:') || edge.target.includes(':overflow:')) + ? `${edge.aggregateCount} hidden blocking links` + : undefined, + })) + ); } #buildProcessNodes( @@ -539,6 +594,7 @@ export class TeamGraphAdapter { // This prevents old messages from spawning particles when the graph opens. if (!this.#initialMessagesSeen) { this.#initialMessagesSeen = true; + this.#messageParticleCutoffMs = Date.now(); for (const msg of ordered) { const msgKey = TeamGraphAdapter.#getMessageParticleKey(msg); this.#seenMessageIds.add(msgKey); @@ -560,6 +616,9 @@ export class TeamGraphAdapter { const msgKey = TeamGraphAdapter.#getMessageParticleKey(msg); if (this.#seenMessageIds.has(msgKey)) continue; this.#seenMessageIds.add(msgKey); + if (TeamGraphAdapter.#isBeforeParticleCutoff(msg.timestamp, this.#messageParticleCutoffMs)) { + continue; + } // Skip comment notifications — #buildCommentParticles handles them with real text if (msg.summary?.startsWith('Comment on ')) continue; @@ -659,6 +718,7 @@ export class TeamGraphAdapter { // This prevents pre-existing comments from spawning particles when the graph opens. if (!this.#initialCommentsSeen) { this.#initialCommentsSeen = true; + this.#commentParticleCutoffMs = Date.now(); for (const task of data.tasks) { this.#seenCommentCounts.set(task.id, task.comments?.length ?? 0); } @@ -681,6 +741,14 @@ export class TeamGraphAdapter { for (let index = prevCount; index < currentCount; index += 1) { const newComment = task.comments?.[index]; if (!newComment) continue; + if ( + TeamGraphAdapter.#isBeforeParticleCutoff( + newComment.createdAt, + this.#commentParticleCutoffMs + ) + ) { + continue; + } const authorNodeId = TeamGraphAdapter.#resolveParticipantId( newComment.author, teamName, @@ -726,8 +794,8 @@ export class TeamGraphAdapter { // ─── Static mappers ────────────────────────────────────────────────────── - static #buildBlockingEdgeId(teamName: string, blockerId: string, blockedId: string): string { - return `edge:block:task:${teamName}:${blockerId}:task:${teamName}:${blockedId}`; + static #buildBlockingEdgeId(sourceNodeId: string, targetNodeId: string): string { + return `edge:block:${sourceNodeId}:${targetNodeId}`; } static #buildMemberException( diff --git a/src/renderer/features/agent-graph/ui/GraphBlockingEdgePopover.tsx b/src/renderer/features/agent-graph/ui/GraphBlockingEdgePopover.tsx new file mode 100644 index 00000000..d9f56ab0 --- /dev/null +++ b/src/renderer/features/agent-graph/ui/GraphBlockingEdgePopover.tsx @@ -0,0 +1,207 @@ +import { useMemo } from 'react'; + +import { Badge } from '@renderer/components/ui/badge'; +import { Button } from '@renderer/components/ui/button'; +import { useStore } from '@renderer/store'; +import { selectTeamDataForName } from '@renderer/store/slices/teamSlice'; + +import type { GraphEdge, GraphNode } from '@claude-teams/agent-graph'; +import type { TeamTaskWithKanban } from '@shared/types'; + +function isTaskNode(node: GraphNode | undefined): node is GraphNode & { + domainRef: Extract; +} { + return node?.kind === 'task' && node.domainRef.kind === 'task'; +} + +function isOverflowNode( + node: GraphNode | undefined +): node is GraphNode & { isOverflowStack: true } { + return Boolean(node?.kind === 'task' && node.isOverflowStack); +} + +function describeNode(node: GraphNode | undefined, fallback: string): string { + if (!node) return fallback; + if (isOverflowNode(node)) { + return node.overflowCount && node.overflowCount > 1 + ? `${node.overflowCount} hidden tasks` + : 'Hidden task stack'; + } + if (isTaskNode(node)) { + return `${node.displayId ?? node.label} - ${node.sublabel ?? 'Task'}`; + } + return node.label; +} + +function getActionLabel(node: GraphNode | undefined, role: 'blocker' | 'blocked'): string | null { + if (!node) return null; + if (isOverflowNode(node)) { + return role === 'blocker' ? 'Open blocker stack' : 'Open blocked stack'; + } + if (isTaskNode(node)) { + return role === 'blocker' ? 'Open blocker task' : 'Open blocked task'; + } + return null; +} + +export interface GraphBlockingEdgePopoverProps { + teamName: string; + edge: GraphEdge; + sourceNode: GraphNode | undefined; + targetNode: GraphNode | undefined; + onClose: () => void; + onSelectNode: (nodeId: string) => void; + onOpenTaskDetail?: (taskId: string) => void; +} + +export function GraphBlockingEdgePopover({ + teamName, + edge, + sourceNode, + targetNode, + onClose, + onSelectNode, + onOpenTaskDetail, +}: GraphBlockingEdgePopoverProps): React.JSX.Element { + const teamData = useStore((state) => selectTeamDataForName(state, teamName)); + const tasksById = useMemo( + () => new Map((teamData?.tasks ?? []).map((task) => [task.id, task] as const)), + [teamData?.tasks] + ); + const relationCount = edge.aggregateCount ?? 1; + const sourceLabel = describeNode(sourceNode, edge.source); + const targetLabel = describeNode(targetNode, edge.target); + const sourceActionLabel = getActionLabel(sourceNode, 'blocker'); + const targetActionLabel = getActionLabel(targetNode, 'blocked'); + const sourceHiddenTasks = resolveEdgeTaskPreview(sourceNode, edge.sourceTaskIds, tasksById); + const targetHiddenTasks = resolveEdgeTaskPreview(targetNode, edge.targetTaskIds, tasksById); + + const openSource = (): void => { + if (isTaskNode(sourceNode)) { + onOpenTaskDetail?.(sourceNode.domainRef.taskId); + onClose(); + return; + } + if (sourceNode) { + onSelectNode(sourceNode.id); + } + }; + + const openTarget = (): void => { + if (isTaskNode(targetNode)) { + onOpenTaskDetail?.(targetNode.domainRef.taskId); + onClose(); + return; + } + if (targetNode) { + onSelectNode(targetNode.id); + } + }; + + return ( +
+
+
+ Blocking Dependency +
+ {relationCount > 1 && ( + + {relationCount} links + + )} +
+ +
+
{sourceLabel}
+ {sourceHiddenTasks.length > 0 && ( + + )} +
blocks
+
{targetLabel}
+ {targetHiddenTasks.length > 0 && ( + + )} +
+ +
+ {sourceActionLabel && ( + + )} + {targetActionLabel && ( + + )} + +
+
+ ); +} + +function resolveEdgeTaskPreview( + node: GraphNode | undefined, + edgeTaskIds: string[] | undefined, + tasksById: ReadonlyMap +): TeamTaskWithKanban[] { + if (!node || !isOverflowNode(node)) { + return []; + } + + const candidateIds = + edgeTaskIds && edgeTaskIds.length > 0 ? edgeTaskIds : (node.overflowTaskIds ?? []); + + return candidateIds + .map((taskId) => tasksById.get(taskId) ?? null) + .filter((task): task is TeamTaskWithKanban => task != null) + .slice(0, 4); +} + +function HiddenTaskPreview({ + title, + tasks, + onOpenTaskDetail, + onClose, +}: { + title: string; + tasks: TeamTaskWithKanban[]; + onOpenTaskDetail?: (taskId: string) => void; + onClose: () => void; +}): React.JSX.Element { + return ( +
+
{title}
+
+ {tasks.map((task) => ( + + ))} +
+
+ ); +} diff --git a/src/renderer/features/agent-graph/ui/TeamGraphOverlay.tsx b/src/renderer/features/agent-graph/ui/TeamGraphOverlay.tsx index 84fe1f8b..954767d2 100644 --- a/src/renderer/features/agent-graph/ui/TeamGraphOverlay.tsx +++ b/src/renderer/features/agent-graph/ui/TeamGraphOverlay.tsx @@ -10,6 +10,7 @@ import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHo import { useTeamGraphAdapter } from '../adapters/useTeamGraphAdapter'; +import { GraphBlockingEdgePopover } from './GraphBlockingEdgePopover'; import { GraphNodePopover } from './GraphNodePopover'; import type { GraphDomainRef, GraphEventPort } from '@claude-teams/agent-graph'; @@ -84,6 +85,17 @@ export const TeamGraphOverlay = ({ onRequestClose={onClose} onRequestPinAsTab={onPinAsTab} className="min-w-0 flex-1" + renderEdgeOverlay={({ edge, sourceNode, targetNode, onClose: closeEdge, onSelectNode }) => ( + + )} renderOverlay={({ node, onClose: closePopover }) => ( setFullscreen(true)} + renderEdgeOverlay={({ edge, sourceNode, targetNode, onClose, onSelectNode }) => ( + + )} renderOverlay={({ node, onClose }) => ( ; +} + function resolveOverflowColumnKey(task: GraphNode): string { if (task.reviewState === 'approved') return 'approved'; if (task.reviewState === 'review' || task.reviewState === 'needsFix') return 'review'; @@ -19,8 +24,23 @@ export function collapseOverflowStacks( teamName: string, maxVisibleRows: number ): GraphNode[] { + return collapseOverflowStacksWithMeta(taskNodes, teamName, maxVisibleRows).visibleNodes; +} + +export function collapseOverflowStacksWithMeta( + taskNodes: GraphNode[], + teamName: string, + maxVisibleRows: number +): OverflowCollapseResult { if (maxVisibleRows <= 1) { - return taskNodes; + return { + visibleNodes: taskNodes, + visibleNodeIdByTaskId: new Map( + taskNodes.flatMap((task) => + task.domainRef.kind === 'task' ? [[task.domainRef.taskId, task.id] as const] : [] + ) + ), + }; } const grouped = new Map(); @@ -38,11 +58,17 @@ export function collapseOverflowStacks( } const visibleTasks: GraphNode[] = []; + const visibleNodeIdByTaskId = new Map(); for (const groupKey of groupOrder) { const groupTasks = grouped.get(groupKey) ?? []; if (groupTasks.length <= maxVisibleRows) { visibleTasks.push(...groupTasks); + for (const task of groupTasks) { + if (task.domainRef.kind === 'task') { + visibleNodeIdByTaskId.set(task.domainRef.taskId, task.id); + } + } continue; } @@ -53,21 +79,37 @@ export function collapseOverflowStacks( const ownerMemberName = extractOwnerMemberName(representative, teamName); visibleTasks.push(...keptTasks); + for (const task of keptTasks) { + if (task.domainRef.kind === 'task') { + visibleNodeIdByTaskId.set(task.domainRef.taskId, task.id); + } + } + + const stackNodeId = `task:${teamName}:overflow:${groupKey}`; + const overflowTaskIds = hiddenTasks.flatMap((task) => + task.domainRef.kind === 'task' ? [task.domainRef.taskId] : [] + ); + for (const taskId of overflowTaskIds) { + visibleNodeIdByTaskId.set(taskId, stackNodeId); + } + visibleTasks.push({ id: `task:${teamName}:overflow:${groupKey}`, kind: 'task', label: `+${hiddenTasks.length}`, - state: 'waiting', + state: representative.state, displayId: `+${hiddenTasks.length}`, sublabel: `${hiddenTasks.length} more tasks`, ownerId: representative.ownerId ?? null, taskStatus: representative.taskStatus, reviewState: representative.reviewState, + changePresence: hiddenTasks.some((task) => task.changePresence === 'has_changes') + ? 'has_changes' + : undefined, + isBlocked: hiddenTasks.some((task) => task.isBlocked), isOverflowStack: true, overflowCount: hiddenTasks.length, - overflowTaskIds: hiddenTasks.flatMap((task) => - task.domainRef.kind === 'task' ? [task.domainRef.taskId] : [] - ), + overflowTaskIds, domainRef: { kind: 'task_overflow', teamName, @@ -77,5 +119,8 @@ export function collapseOverflowStacks( }); } - return visibleTasks; + return { + visibleNodes: visibleTasks, + visibleNodeIdByTaskId, + }; } diff --git a/test/renderer/features/agent-graph/GraphBlockingEdgePopover.test.ts b/test/renderer/features/agent-graph/GraphBlockingEdgePopover.test.ts new file mode 100644 index 00000000..8740ecb6 --- /dev/null +++ b/test/renderer/features/agent-graph/GraphBlockingEdgePopover.test.ts @@ -0,0 +1,195 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { useStore } from '@renderer/store'; +import { GraphBlockingEdgePopover } from '@renderer/features/agent-graph/ui/GraphBlockingEdgePopover'; + +import type { GraphEdge, GraphNode } from '@claude-teams/agent-graph'; + +vi.mock('@renderer/components/ui/badge', () => ({ + Badge: ({ children }: { children: React.ReactNode }) => + React.createElement('span', null, children), +})); + +vi.mock('@renderer/components/ui/button', () => ({ + Button: ({ + children, + onClick, + }: { + children: React.ReactNode; + onClick?: () => void; + }) => React.createElement('button', { type: 'button', onClick }, children), +})); + +const sourceNode: GraphNode = { + id: 'task:my-team:overflow:alice:todo', + kind: 'task', + label: '+2', + state: 'waiting', + ownerId: 'member:my-team:alice', + taskStatus: 'pending', + reviewState: 'none', + isOverflowStack: true, + overflowCount: 2, + overflowTaskIds: ['task-hidden-1', 'task-hidden-2'], + domainRef: { + kind: 'task_overflow', + teamName: 'my-team', + ownerMemberName: 'alice', + columnKey: 'todo', + }, +}; + +const targetNode: GraphNode = { + id: 'task:my-team:task-visible', + kind: 'task', + label: '#8', + displayId: '#8', + sublabel: 'Visible blocked task', + state: 'waiting', + ownerId: 'member:my-team:bob', + taskStatus: 'pending', + reviewState: 'none', + domainRef: { kind: 'task', teamName: 'my-team', taskId: 'task-visible' }, +}; + +const edge: GraphEdge = { + id: 'edge:block:test', + source: sourceNode.id, + target: targetNode.id, + type: 'blocking', + aggregateCount: 2, + sourceTaskIds: ['task-hidden-1', 'task-hidden-2'], + targetTaskIds: ['task-visible'], +}; + +describe('GraphBlockingEdgePopover', () => { + afterEach(() => { + document.body.innerHTML = ''; + useStore.setState({ + selectedTeamName: null, + selectedTeamData: null, + teamDataCacheByName: {}, + } as never); + vi.unstubAllGlobals(); + }); + + it('renders the participating hidden tasks for aggregated overflow blockers', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + useStore.setState({ + selectedTeamName: 'my-team', + selectedTeamData: { + teamName: 'my-team', + config: { name: 'My Team', members: [], projectPath: '/repo' }, + tasks: [ + { + id: 'task-hidden-1', + displayId: '#1', + subject: 'Hidden blocker one', + owner: 'alice', + status: 'pending', + reviewState: 'none', + }, + { + id: 'task-hidden-2', + displayId: '#2', + subject: 'Hidden blocker two', + owner: 'alice', + status: 'pending', + reviewState: 'none', + }, + { + id: 'task-visible', + displayId: '#8', + subject: 'Visible blocked task', + owner: 'bob', + status: 'pending', + reviewState: 'none', + }, + ], + members: [], + messages: [], + kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, + processes: [], + }, + teamDataCacheByName: { + 'my-team': { + teamName: 'my-team', + config: { name: 'My Team', members: [], projectPath: '/repo' }, + tasks: [ + { + id: 'task-hidden-1', + displayId: '#1', + subject: 'Hidden blocker one', + owner: 'alice', + status: 'pending', + reviewState: 'none', + }, + { + id: 'task-hidden-2', + displayId: '#2', + subject: 'Hidden blocker two', + owner: 'alice', + status: 'pending', + reviewState: 'none', + }, + { + id: 'task-visible', + displayId: '#8', + subject: 'Visible blocked task', + owner: 'bob', + status: 'pending', + reviewState: 'none', + }, + ], + members: [], + messages: [], + kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, + processes: [], + }, + }, + } as never); + + const onOpenTaskDetail = vi.fn(); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(GraphBlockingEdgePopover, { + teamName: 'my-team', + edge, + sourceNode, + targetNode, + onClose: vi.fn(), + onSelectNode: vi.fn(), + onOpenTaskDetail, + }) + ); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('Blocking hidden tasks'); + expect(host.textContent).toContain('#1 - Hidden blocker one'); + expect(host.textContent).toContain('#2 - Hidden blocker two'); + + const hiddenTaskButton = Array.from(host.querySelectorAll('button')).find((button) => + button.textContent?.includes('#1 - Hidden blocker one') + ); + expect(hiddenTaskButton).toBeTruthy(); + + await act(async () => { + hiddenTaskButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await Promise.resolve(); + }); + + expect(onOpenTaskDetail).toHaveBeenCalledWith('task-hidden-1'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); +}); diff --git a/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts b/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts index 0464ea92..e454d4e6 100644 --- a/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts +++ b/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { TeamGraphAdapter } from '@renderer/features/agent-graph/adapters/TeamGraphAdapter'; @@ -59,6 +59,15 @@ function findNode(graph: GraphDataPort, nodeId: string) { } describe('TeamGraphAdapter particles', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-03-28T19:00:00.000Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + it('creates a message particle for a new incoming message from the newest message set', () => { const adapter = TeamGraphAdapter.create(); const baseline = createBaseTeamData(); @@ -135,6 +144,80 @@ describe('TeamGraphAdapter particles', () => { }); }); + it('does not replay old inbox messages that arrive after the graph already opened', () => { + vi.setSystemTime(new Date('2026-03-28T19:00:10.000Z')); + + const adapter = TeamGraphAdapter.create(); + adapter.adapt(createBaseTeamData(), 'my-team'); + + const graph = adapter.adapt( + createBaseTeamData({ + messages: [ + { + from: 'alice', + to: 'team-lead', + text: 'Old backlog message', + timestamp: '2026-03-28T19:00:01.000Z', + read: false, + messageId: 'msg-old', + }, + ], + }), + 'my-team' + ); + + expect(graph.particles).toHaveLength(0); + }); + + it('does not replay old task comments that appear after the graph already opened', () => { + vi.setSystemTime(new Date('2026-03-28T19:00:10.000Z')); + + const adapter = TeamGraphAdapter.create(); + adapter.adapt( + createBaseTeamData({ + tasks: [ + { + id: 'task-old-comment', + displayId: '#9', + subject: 'Review backlog', + owner: 'alice', + status: 'in_progress', + comments: [], + reviewState: 'none', + } as TeamTaskWithKanban, + ], + }), + 'my-team' + ); + + const graph = adapter.adapt( + createBaseTeamData({ + tasks: [ + { + id: 'task-old-comment', + displayId: '#9', + subject: 'Review backlog', + owner: 'alice', + status: 'in_progress', + comments: [ + { + id: 'comment-old', + author: 'alice', + text: 'Old backlog comment', + createdAt: '2026-03-28T19:00:01.000Z', + type: 'regular', + }, + ], + reviewState: 'none', + } as TeamTaskWithKanban, + ], + }), + 'my-team' + ); + + expect(graph.particles).toHaveLength(0); + }); + it('creates a synthetic message edge for comments from non-owner participants', () => { const adapter = TeamGraphAdapter.create(); const baseline = createBaseTeamData({ @@ -687,6 +770,51 @@ describe('TeamGraphAdapter particles', () => { expect(findNode(completedGraph, 'task:my-team:task-b')?.isBlocked).toBe(false); }); + it('aggregates blocking edges through overflow stacks so hidden blockers stay visible', () => { + const adapter = TeamGraphAdapter.create(); + const graph = adapter.adapt( + createBaseTeamData({ + tasks: [ + ...Array.from({ length: 7 }, (_, index) => ({ + id: `task-a-${index + 1}`, + displayId: `#A${index + 1}`, + subject: `Alice task ${index + 1}`, + owner: 'alice', + status: 'pending', + reviewState: 'none', + blocks: index >= 5 ? ['task-b-1'] : [], + })), + { + id: 'task-b-1', + displayId: '#B1', + subject: 'Visible blocked task', + owner: 'bob', + status: 'pending', + reviewState: 'none', + blockedBy: ['task-a-6', 'task-a-7'], + } as TeamTaskWithKanban, + ] as TeamTaskWithKanban[], + }), + 'my-team' + ); + + const overflowNode = graph.nodes.find( + (node) => node.kind === 'task' && node.isOverflowStack && node.ownerId === 'member:my-team:alice' + ); + const blockingEdges = graph.edges.filter((edge) => edge.type === 'blocking'); + + expect(overflowNode).toBeDefined(); + expect(blockingEdges).toContainEqual( + expect.objectContaining({ + source: overflowNode?.id, + target: 'task:my-team:task-b-1', + aggregateCount: 2, + sourceTaskIds: ['task-a-6', 'task-a-7'], + targetTaskIds: ['task-b-1'], + }) + ); + }); + it('adds compact review handoff metadata for active review tasks', () => { const adapter = TeamGraphAdapter.create(); const graph = adapter.adapt( diff --git a/test/renderer/features/agent-graph/buildFocusState.test.ts b/test/renderer/features/agent-graph/buildFocusState.test.ts index a5ae5502..8e529173 100644 --- a/test/renderer/features/agent-graph/buildFocusState.test.ts +++ b/test/renderer/features/agent-graph/buildFocusState.test.ts @@ -118,7 +118,7 @@ const nodes = [leadNode, aliceNode, bobNode, blockerNode, reviewTaskNode, overfl describe('buildFocusState', () => { it('focuses task selection on its owner, reviewer, direct blockers, and connecting edges', () => { - const focus = buildFocusState(reviewTaskNode.id, nodes, edges); + const focus = buildFocusState(reviewTaskNode.id, null, nodes, edges); expect(Array.from(focus.focusNodeIds ?? []).sort()).toEqual( [ @@ -141,7 +141,7 @@ describe('buildFocusState', () => { }); it('includes review-assigned tasks and owned overflow stacks when focusing a member', () => { - const focus = buildFocusState(bobNode.id, nodes, edges); + const focus = buildFocusState(bobNode.id, null, nodes, edges); expect(focus.focusNodeIds?.has(bobNode.id)).toBe(true); expect(focus.focusNodeIds?.has(reviewTaskNode.id)).toBe(true); @@ -149,12 +149,12 @@ describe('buildFocusState', () => { expect(focus.focusEdgeIds?.has('edge:parent:lead:bob')).toBe(true); expect(focus.focusEdgeIds?.has('edge:own:alice:review')).toBe(true); - const aliceFocus = buildFocusState(aliceNode.id, nodes, edges); + const aliceFocus = buildFocusState(aliceNode.id, null, nodes, edges); expect(aliceFocus.focusNodeIds?.has(overflowNode.id)).toBe(true); }); it('focuses a lead on direct neighbors only', () => { - const focus = buildFocusState(leadNode.id, nodes, edges); + const focus = buildFocusState(leadNode.id, null, nodes, edges); expect(focus.focusNodeIds).toEqual( new Set([leadNode.id, aliceNode.id, bobNode.id]) @@ -165,9 +165,26 @@ describe('buildFocusState', () => { }); it('does not enable global dimming for overflow stack selections', () => { - const focus = buildFocusState(overflowNode.id, nodes, edges); + const focus = buildFocusState(overflowNode.id, null, nodes, edges); expect(focus.focusNodeIds).toBeNull(); expect(focus.focusEdgeIds).toBeNull(); }); + + it('focuses the connected blocking chain when a blocking edge is selected', () => { + const focus = buildFocusState(null, 'edge:block:blocker:review', nodes, edges); + + expect(focus.focusNodeIds).toEqual( + new Set([leadNode.id, aliceNode.id, bobNode.id, blockerNode.id, reviewTaskNode.id]) + ); + expect(focus.focusEdgeIds).toEqual( + new Set([ + 'edge:block:blocker:review', + 'edge:own:alice:blocker', + 'edge:own:alice:review', + 'edge:parent:lead:alice', + 'edge:parent:lead:bob', + ]) + ); + }); }); diff --git a/test/renderer/features/agent-graph/collapseOverflowStacks.test.ts b/test/renderer/features/agent-graph/collapseOverflowStacks.test.ts index e9a635b4..a3e637b5 100644 --- a/test/renderer/features/agent-graph/collapseOverflowStacks.test.ts +++ b/test/renderer/features/agent-graph/collapseOverflowStacks.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from 'vitest'; -import { collapseOverflowStacks } from '@renderer/features/agent-graph/utils/collapseOverflowStacks'; +import { + collapseOverflowStacks, + collapseOverflowStacksWithMeta, +} from '@renderer/features/agent-graph/utils/collapseOverflowStacks'; import type { GraphNode } from '@claude-teams/agent-graph'; @@ -73,4 +76,16 @@ describe('collapseOverflowStacks', () => { }, }); }); + + it('returns a visible-node mapping for hidden tasks behind the stack', () => { + const nodes = Array.from({ length: 7 }, (_, index) => makeTaskNode(`task-${index + 1}`)); + + const result = collapseOverflowStacksWithMeta(nodes, 'my-team', 6); + const stackNode = result.visibleNodes.find((node) => node.isOverflowStack); + + expect(stackNode).toBeDefined(); + expect(result.visibleNodeIdByTaskId.get('task-1')).toBe('task:my-team:task-1'); + expect(result.visibleNodeIdByTaskId.get('task-6')).toBe(stackNode?.id); + expect(result.visibleNodeIdByTaskId.get('task-7')).toBe(stackNode?.id); + }); }); diff --git a/test/renderer/features/agent-graph/edgeHitDetection.test.ts b/test/renderer/features/agent-graph/edgeHitDetection.test.ts new file mode 100644 index 00000000..ba2d3ba1 --- /dev/null +++ b/test/renderer/features/agent-graph/edgeHitDetection.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest'; + +import { + collectInteractiveEdgesInViewport, + findEdgeAt, + getEdgeMidpoint, +} from '../../../../packages/agent-graph/src/canvas/hit-detection'; + +import type { GraphEdge, GraphNode } from '@claude-teams/agent-graph'; + +function makeNode(id: string, x: number, y: number): GraphNode { + return { + id, + kind: id.startsWith('task') ? 'task' : 'member', + label: id, + state: 'idle', + x, + y, + domainRef: + id.startsWith('task') + ? { kind: 'task', teamName: 'my-team', taskId: id } + : { kind: 'member', teamName: 'my-team', memberName: id }, + } as GraphNode; +} + +describe('edge hit detection', () => { + it('detects blocking edges near the curve midpoint', () => { + const nodes = [ + makeNode('member:alice', 0, 0), + makeNode('task:1', 160, 90), + ]; + const nodeMap = new Map(nodes.map((node) => [node.id, node] as const)); + const edge: GraphEdge = { + id: 'edge:blocking', + source: 'member:alice', + target: 'task:1', + type: 'blocking', + }; + const midpoint = getEdgeMidpoint(edge, nodeMap); + + expect(midpoint).not.toBeNull(); + expect(findEdgeAt(midpoint!.x, midpoint!.y, [edge], nodeMap)).toBe('edge:blocking'); + }); + + it('prefers the closest edge when multiple curves overlap', () => { + const nodes = [ + makeNode('member:alice', 0, 0), + makeNode('task:1', 160, 90), + makeNode('task:2', 160, 150), + ]; + const nodeMap = new Map(nodes.map((node) => [node.id, node] as const)); + const edges: GraphEdge[] = [ + { id: 'edge:1', source: 'member:alice', target: 'task:1', type: 'ownership' }, + { id: 'edge:2', source: 'member:alice', target: 'task:2', type: 'ownership' }, + ]; + + const midpoint = getEdgeMidpoint(edges[0], nodeMap); + expect(midpoint).not.toBeNull(); + expect(findEdgeAt(midpoint!.x, midpoint!.y, edges, nodeMap)).toBe('edge:1'); + }); + + it('only keeps visible blocking edges as interactive hit-test candidates', () => { + const nodes = [ + makeNode('task:blocker', 0, 0), + makeNode('task:blocked', 180, 90), + makeNode('task:offscreen-a', 1200, 1200), + makeNode('task:offscreen-b', 1360, 1280), + ]; + const nodeMap = new Map(nodes.map((node) => [node.id, node] as const)); + const edges: GraphEdge[] = [ + { id: 'edge:blocking:visible', source: 'task:blocker', target: 'task:blocked', type: 'blocking' }, + { id: 'edge:blocking:hidden', source: 'task:offscreen-a', target: 'task:offscreen-b', type: 'blocking' }, + { id: 'edge:ownership', source: 'task:blocker', target: 'task:blocked', type: 'ownership' }, + ]; + + const interactive = collectInteractiveEdgesInViewport(edges, nodeMap, { + left: -200, + top: -200, + right: 400, + bottom: 260, + }); + + expect(interactive.map((edge) => edge.id)).toEqual(['edge:blocking:visible']); + }); +}); diff --git a/test/renderer/features/agent-graph/selectRenderableParticles.test.ts b/test/renderer/features/agent-graph/selectRenderableParticles.test.ts new file mode 100644 index 00000000..3e4ba3bf --- /dev/null +++ b/test/renderer/features/agent-graph/selectRenderableParticles.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest'; + +import { + computeAdaptiveParticleBudget, + selectRenderableParticles, +} from '../../../../packages/agent-graph/src/ui/selectRenderableParticles'; + +import type { GraphParticle } from '@claude-teams/agent-graph'; + +function makeParticle(id: string, edgeId: string): GraphParticle { + return { + id, + edgeId, + progress: 0, + kind: 'inbox_message', + color: '#66ccff', + }; +} + +describe('selectRenderableParticles', () => { + it('keeps at least one particle per active visible edge when over budget', () => { + const particles = [ + makeParticle('p1', 'edge:a'), + makeParticle('p2', 'edge:a'), + makeParticle('p3', 'edge:b'), + makeParticle('p4', 'edge:b'), + makeParticle('p5', 'edge:c'), + makeParticle('p6', 'edge:c'), + ]; + + const selected = selectRenderableParticles({ + particles, + visibleEdgeIds: new Set(['edge:a', 'edge:b', 'edge:c']), + budget: 3, + }); + + expect(selected).toHaveLength(3); + expect(new Set(selected.map((particle) => particle.edgeId))).toEqual( + new Set(['edge:a', 'edge:b', 'edge:c']) + ); + }); + + it('does not spend budget on particles for offscreen edges', () => { + const selected = selectRenderableParticles({ + particles: [ + makeParticle('p1', 'edge:a'), + makeParticle('p2', 'edge:b'), + makeParticle('p3', 'edge:c'), + ], + visibleEdgeIds: new Set(['edge:b']), + budget: 10, + }); + + expect(selected).toEqual([expect.objectContaining({ id: 'p2', edgeId: 'edge:b' })]); + }); +}); + +describe('computeAdaptiveParticleBudget', () => { + it('reduces budget when frame time is already high', () => { + const fastBudget = computeAdaptiveParticleBudget({ + visibleNodeCount: 30, + visibleEdgeCount: 20, + frameTimeMs: 8, + hasFocusedEdges: false, + }); + const slowBudget = computeAdaptiveParticleBudget({ + visibleNodeCount: 30, + visibleEdgeCount: 20, + frameTimeMs: 26, + hasFocusedEdges: false, + }); + + expect(slowBudget).toBeLessThan(fastBudget); + expect(slowBudget).toBeGreaterThan(0); + }); +});