feat(agent-graph): improve blocking visibility and inspection

This commit is contained in:
777genius 2026-04-12 21:31:53 +03:00
parent f74b7a3701
commit 57c384531a
22 changed files with 1642 additions and 109 deletions

View file

@ -75,6 +75,8 @@ export function drawEdges(
_time: number, _time: number,
hasActiveParticles: Set<string>, hasActiveParticles: Set<string>,
focusEdgeIds?: ReadonlySet<string> | null, focusEdgeIds?: ReadonlySet<string> | null,
hoveredEdgeId?: string | null,
selectedEdgeId?: string | null,
): void { ): void {
for (const edge of edges) { for (const edge of edges) {
const source = nodeMap.get(edge.source); const source = nodeMap.get(edge.source);
@ -84,23 +86,27 @@ export function drawEdges(
const style = EDGE_STYLES[edge.type] ?? EDGE_STYLES['parent-child']; const style = EDGE_STYLES[edge.type] ?? EDGE_STYLES['parent-child'];
const isActive = hasActiveParticles.has(edge.id); 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 // Pulse alpha when particles are travelling: base 0.3 + 0.2 * sin wave
const alpha = isActive const alpha = isActive
? BEAM.activeAlpha + 0.2 * Math.sin(_time * 6) ? BEAM.activeAlpha + 0.2 * Math.sin(_time * 6)
: BEAM.idleAlpha; : BEAM.idleAlpha;
const focusAlpha = focusEdgeIds && !focusEdgeIds.has(edge.id) ? 0.1 : 1; 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); const cp = computeControlPoints(source.x, source.y, target.x, target.y);
ctx.save(); ctx.save();
ctx.globalAlpha = alpha * focusAlpha; ctx.globalAlpha = finalAlpha;
// Subtle glow pass when edge has active particles // Subtle glow pass when edge has active particles
if (isActive) { if (isActive || isSelected || isHovered) {
ctx.shadowColor = edge.color ?? style.color; ctx.shadowColor = edge.color ?? style.color;
ctx.shadowBlur = 12; ctx.shadowBlur = isSelected ? 16 : isHovered ? 10 : 12;
} }
// Draw tapered bezier // Draw tapered bezier
@ -119,7 +125,7 @@ export function drawEdges(
// Arrow for blocking edges // Arrow for blocking edges
if (edge.type === 'blocking') { 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(); ctx.restore();

View file

@ -266,10 +266,19 @@ function drawOverflowStack(
? 'rgba(15, 20, 40, 0.78)' ? 'rgba(15, 20, 40, 0.78)'
: COLORS.cardBg; : COLORS.cardBg;
ctx.fill(); ctx.fill();
ctx.strokeStyle = hexWithAlpha(COLORS.taskPending, isSelected ? 0.85 : 0.55); ctx.strokeStyle = node.isBlocked
ctx.lineWidth = isSelected ? 2 : 1; ? 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(); 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.font = 'bold 12px sans-serif';
ctx.textAlign = 'left'; ctx.textAlign = 'left';
ctx.textBaseline = 'middle'; ctx.textBaseline = 'middle';

View file

@ -3,8 +3,9 @@
* Adapted from agent-flow's hit-detection.ts (Apache 2.0). * Adapted from agent-flow's hit-detection.ts (Apache 2.0).
*/ */
import type { GraphNode } from '../ports/types'; import type { GraphEdge, GraphNode } from '../ports/types';
import { NODE, TASK_PILL, HIT_DETECTION } from '../constants/canvas-constants'; 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. * Find the node at the given world-space coordinates.
@ -65,3 +66,192 @@ export function findNodeAt(
return hit; return hit;
} }
const EDGE_HIT_PRIORITY: Record<GraphEdge['type'], number> = {
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<string, GraphNode>,
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, GraphNode>,
): 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<string, GraphNode>
): { 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);
}

View file

@ -218,6 +218,8 @@ export const HIT_DETECTION = {
agentPadding: 8, agentPadding: 8,
/** Task pill hit area padding */ /** Task pill hit area padding */
taskPadding: 4, taskPadding: 4,
/** Extra padding around curved edges for easier inspection */
edgePadding: 6,
} as const; } as const;
// ─── Background ───────────────────────────────────────────────────────────── // ─── Background ─────────────────────────────────────────────────────────────

View file

@ -155,6 +155,12 @@ export interface GraphEdge {
label?: string; label?: string;
/** Edge color override */ /** Edge color override */
color?: string; 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 ────────────────────────────────────────────────────────── // ─── Graph Particle ──────────────────────────────────────────────────────────

View file

@ -17,6 +17,7 @@ import { drawProcesses } from '../canvas/draw-processes';
import { drawEffects, type VisualEffect } from '../canvas/draw-effects'; import { drawEffects, type VisualEffect } from '../canvas/draw-effects';
import { BloomRenderer } from '../canvas/bloom-renderer'; import { BloomRenderer } from '../canvas/bloom-renderer';
import { KanbanLayoutEngine } from '../layout/kanbanLayout'; import { KanbanLayoutEngine } from '../layout/kanbanLayout';
import { computeAdaptiveParticleBudget, selectRenderableParticles } from './selectRenderableParticles';
import type { CameraTransform } from '../hooks/useGraphCamera'; import type { CameraTransform } from '../hooks/useGraphCamera';
// ─── Draw State (passed by ref, not by props — no React re-renders) ───────── // ─── Draw State (passed by ref, not by props — no React re-renders) ─────────
@ -30,6 +31,8 @@ export interface GraphDrawState {
camera: CameraTransform; camera: CameraTransform;
selectedNodeId: string | null; selectedNodeId: string | null;
hoveredNodeId: string | null; hoveredNodeId: string | null;
selectedEdgeId: string | null;
hoveredEdgeId: string | null;
focusNodeIds: ReadonlySet<string> | null; focusNodeIds: ReadonlySet<string> | null;
focusEdgeIds: ReadonlySet<string> | null; focusEdgeIds: ReadonlySet<string> | null;
} }
@ -118,6 +121,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(funct
const visibleNodesCache = useRef<GraphNode[]>([]); const visibleNodesCache = useRef<GraphNode[]>([]);
const visibleEdgesCache = useRef<GraphEdge[]>([]); const visibleEdgesCache = useRef<GraphEdge[]>([]);
const visibleNodeIdsCache = useRef(new Set<string>()); const visibleNodeIdsCache = useRef(new Set<string>());
const visibleEdgeIdsCache = useRef(new Set<string>());
const activeParticleEdgesCache = useRef(new Set<string>()); const activeParticleEdgesCache = useRef(new Set<string>());
// Imperative draw function — called from RAF, NOT from React render // Imperative draw function — called from RAF, NOT from React render
@ -196,18 +200,41 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(funct
const visibleEdges = visibleEdgesCache.current; const visibleEdges = visibleEdgesCache.current;
visibleEdges.length = 0; visibleEdges.length = 0;
const visibleEdgeIds = visibleEdgeIdsCache.current;
visibleEdgeIds.clear();
for (const e of state.edges) { for (const e of state.edges) {
if (visibleNodeIds.has(e.source) || visibleNodeIds.has(e.target)) { if (visibleNodeIds.has(e.source) || visibleNodeIds.has(e.target)) {
visibleEdges.push(e); 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) // 2b. Particles - adaptive degradation keeps one visible particle per active edge
const cappedParticles = state.particles.length > 100 const particleBudget = computeAdaptiveParticleBudget({
? state.particles.slice(-100) visibleNodeCount: visibleNodes.length,
: state.particles; visibleEdgeCount: visibleEdges.length,
drawParticles(ctx, cappedParticles, edgeMap, nodeMap, state.time, state.focusEdgeIds); 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) // 2c. Visible nodes only (back to front: process → task → member/lead)
drawProcesses( drawProcesses(

View file

@ -39,6 +39,7 @@ export interface GraphControlsProps {
teamName: string; teamName: string;
teamColor?: string; teamColor?: string;
isAlive?: boolean; isAlive?: boolean;
showBlockingHint?: boolean;
} }
export function GraphControls({ export function GraphControls({
@ -53,6 +54,7 @@ export function GraphControls({
teamName, teamName,
teamColor, teamColor,
isAlive, isAlive,
showBlockingHint = false,
}: GraphControlsProps): React.JSX.Element { }: GraphControlsProps): React.JSX.Element {
const [isSettingsOpen, setIsSettingsOpen] = useState(false); const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const settingsRef = useRef<HTMLDivElement>(null); const settingsRef = useRef<HTMLDivElement>(null);
@ -203,6 +205,21 @@ export function GraphControls({
<ToolbarButton onClick={onZoomIn} icon={<ZoomIn size={14} />} /> <ToolbarButton onClick={onZoomIn} icon={<ZoomIn size={14} />} />
</div> </div>
</div> </div>
{showBlockingHint && (
<div className="absolute bottom-3 left-3 z-10 pointer-events-none">
<div
className="pointer-events-auto rounded-lg px-2.5 py-1 text-[10px] font-mono backdrop-blur-sm"
style={{
background: 'rgba(40, 10, 18, 0.78)',
border: '1px solid rgba(239, 68, 68, 0.18)',
color: 'rgba(254, 202, 202, 0.95)',
}}
>
Red lines - blockers, click to inspect
</div>
</div>
)}
</> </>
); );
} }

View file

@ -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 (
<div
className="rounded-lg p-3 min-w-[180px] max-w-[240px] shadow-xl"
style={{
background: 'rgba(10, 15, 30, 0.92)',
border: '1px solid rgba(100, 200, 255, 0.15)',
backdropFilter: 'blur(8px)',
}}
>
<div className="text-[10px] font-mono uppercase tracking-[0.14em]" style={{ color: '#66ccff90' }}>
{getEdgeTypeLabel(edge.type)}
</div>
<div className="mt-1 text-xs font-mono font-bold" style={{ color: edge.color ?? '#aaeeff' }}>
{sourceNode?.label ?? edge.source} -&gt; {targetNode?.label ?? edge.target}
</div>
{edge.label && (
<div className="mt-1 text-[10px] leading-relaxed" style={{ color: '#d7f2ffcc' }}>
{edge.label}
</div>
)}
<div className="mt-2 flex gap-1">
<button
onClick={onClose}
className="text-[10px] px-2 py-1 rounded font-mono cursor-pointer"
style={{
background: 'rgba(100, 200, 255, 0.08)',
border: '1px solid rgba(100, 200, 255, 0.15)',
color: '#aaeeff',
}}
>
Close
</button>
</div>
</div>
);
}

View file

@ -15,15 +15,21 @@ import { autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/d
import type { GraphDataPort } from '../ports/GraphDataPort'; import type { GraphDataPort } from '../ports/GraphDataPort';
import type { GraphEventPort } from '../ports/GraphEventPort'; import type { GraphEventPort } from '../ports/GraphEventPort';
import type { GraphConfigPort } from '../ports/GraphConfigPort'; 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 { GraphCanvas, type GraphCanvasHandle, type GraphDrawState } from './GraphCanvas';
import { GraphControls, type GraphFilterState } from './GraphControls'; import { GraphControls, type GraphFilterState } from './GraphControls';
import { GraphOverlay } from './GraphOverlay'; import { GraphOverlay } from './GraphOverlay';
import { GraphEdgeOverlay } from './GraphEdgeOverlay';
import { buildFocusState } from './buildFocusState'; import { buildFocusState } from './buildFocusState';
import { useGraphSimulation } from '../hooks/useGraphSimulation'; import { useGraphSimulation } from '../hooks/useGraphSimulation';
import { useGraphCamera } from '../hooks/useGraphCamera'; import { useGraphCamera } from '../hooks/useGraphCamera';
import { useGraphInteraction } from '../hooks/useGraphInteraction'; 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'; import { ANIM_SPEED } from '../constants/canvas-constants';
export interface GraphViewProps { export interface GraphViewProps {
@ -41,6 +47,13 @@ export interface GraphViewProps {
screenPos: { x: number; y: number }; screenPos: { x: number; y: number };
onClose: () => void; onClose: () => void;
}) => React.ReactNode; }) => React.ReactNode;
renderEdgeOverlay?: (props: {
edge: GraphEdge;
sourceNode: GraphNode | undefined;
targetNode: GraphNode | undefined;
onClose: () => void;
onSelectNode: (nodeId: string) => void;
}) => React.ReactNode;
} }
export function GraphView({ export function GraphView({
@ -53,9 +66,11 @@ export function GraphView({
onRequestPinAsTab, onRequestPinAsTab,
onRequestFullscreen, onRequestFullscreen,
renderOverlay, renderOverlay,
renderEdgeOverlay,
}: GraphViewProps): React.JSX.Element { }: GraphViewProps): React.JSX.Element {
// ─── React state (user-facing only) ───────────────────────────────────── // ─── React state (user-facing only) ─────────────────────────────────────
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null); const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
const [filters, setFilters] = useState<GraphFilterState>({ const [filters, setFilters] = useState<GraphFilterState>({
showTasks: config?.showTasks ?? true, showTasks: config?.showTasks ?? true,
showProcesses: config?.showProcesses ?? 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 // Ref mirror of selectedNodeId — read by RAF loop to avoid recreating animate on selection change
const selectedNodeIdRef = useRef<string | null>(null); const selectedNodeIdRef = useRef<string | null>(null);
selectedNodeIdRef.current = selectedNodeId; selectedNodeIdRef.current = selectedNodeId;
const selectedEdgeIdRef = useRef<string | null>(null);
selectedEdgeIdRef.current = selectedEdgeId;
const hoveredEdgeIdRef = useRef<string | null>(null);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const canvasHandle = useRef<GraphCanvasHandle>(null); const canvasHandle = useRef<GraphCanvasHandle>(null);
@ -76,6 +94,8 @@ export function GraphView({
const runningRef = useRef(false); const runningRef = useRef(false);
const hasAutoFit = useRef(false); const hasAutoFit = useRef(false);
const allowAutoFitRef = useRef(true); const allowAutoFitRef = useRef(true);
const nodeMapRef = useRef(new Map<string, GraphNode>());
const nodeMapNodesRef = useRef<GraphNode[] | null>(null);
// ─── Hooks ────────────────────────────────────────────────────────────── // ─── Hooks ──────────────────────────────────────────────────────────────
const simulation = useGraphSimulation(); const simulation = useGraphSimulation();
@ -116,8 +136,37 @@ export function GraphView({
// ─── UNIFIED RAF LOOP: tick simulation + draw canvas ──────────────────── // ─── UNIFIED RAF LOOP: tick simulation + draw canvas ────────────────────
const idleFrameSkip = useRef(0); const idleFrameSkip = useRef(0);
const focusState = useMemo( const focusState = useMemo(
() => buildFocusState(selectedNodeId, data.nodes, data.edges), () => buildFocusState(selectedNodeId, selectedEdgeId, data.nodes, data.edges),
[selectedNodeId, data.edges, data.nodes] [selectedEdgeId, selectedNodeId, data.edges, data.nodes]
);
const getNodeMap = useCallback((nodes: GraphNode[]): Map<string, GraphNode> => {
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(() => { const animate = useCallback(() => {
@ -159,6 +208,8 @@ export function GraphView({
camera: cameraRef.current.transformRef.current, camera: cameraRef.current.transformRef.current,
selectedNodeId: selectedNodeIdRef.current, selectedNodeId: selectedNodeIdRef.current,
hoveredNodeId: interaction.hoveredNodeId.current, hoveredNodeId: interaction.hoveredNodeId.current,
selectedEdgeId: selectedEdgeIdRef.current,
hoveredEdgeId: hoveredEdgeIdRef.current,
focusNodeIds: focusState.focusNodeIds, focusNodeIds: focusState.focusNodeIds,
focusEdgeIds: focusState.focusEdgeIds, focusEdgeIds: focusState.focusEdgeIds,
}); });
@ -243,6 +294,7 @@ export function GraphView({
// ─── Mouse handlers (Figma-style: drag empty space = pan, drag node = move) ─ // ─── Mouse handlers (Figma-style: drag empty space = pan, drag node = move) ─
const isPanningRef = useRef(false); const isPanningRef = useRef(false);
const edgeMouseDownRef = useRef<{ id: string; x: number; y: number } | null>(null);
const handleMouseDown = useCallback((e: React.MouseEvent) => { const handleMouseDown = useCallback((e: React.MouseEvent) => {
if (e.button !== 0) return; // only left click if (e.button !== 0) return; // only left click
@ -251,22 +303,38 @@ export function GraphView({
if (!canvas) return; if (!canvas) return;
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
const world = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top); 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 // 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 // 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) { if (hitNode) {
markUserInteracted(); markUserInteracted();
isPanningRef.current = false; isPanningRef.current = false;
edgeMouseDownRef.current = null;
hoveredEdgeIdRef.current = null;
} else { } else {
// Hit empty space → pan const hitEdge = findEdgeAt(world.x, world.y, interactiveEdges, nodeMap);
markUserInteracted(); if (hitEdge) {
isPanningRef.current = true; markUserInteracted();
camera.handlePanStart(e.clientX, e.clientY); 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) => { const handleMouseMove = useCallback((e: React.MouseEvent) => {
// Dragging with left button held // Dragging with left button held
@ -288,26 +356,65 @@ export function GraphView({
if (!canvas) return; if (!canvas) return;
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
const world = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top); const world = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top);
interaction.hoveredNodeId.current = findNodeAt(world.x, world.y, simulation.stateRef.current.nodes); const nodes = simulation.stateRef.current.nodes;
canvas.style.cursor = interaction.hoveredNodeId.current ? 'pointer' : 'grab'; const edges = simulation.stateRef.current.edges;
}, [camera, interaction, simulation.stateRef]); 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) { if (isPanningRef.current) {
camera.handlePanEnd(); camera.handlePanEnd();
isPanningRef.current = false; isPanningRef.current = false;
setSelectedNodeId(null); // hide popover after pan setSelectedNodeId(null); // hide popover after pan
setSelectedEdgeId(null);
edgeMouseDownRef.current = null;
return; return;
} }
const clickedId = interaction.handleMouseUp(); const clickedId = interaction.handleMouseUp();
if (clickedId) { if (clickedId) {
setSelectedNodeId(clickedId); setSelectedNodeId(clickedId);
setSelectedEdgeId(null);
const node = simulation.stateRef.current.nodes.find((n) => n.id === clickedId); const node = simulation.stateRef.current.nodes.find((n) => n.id === clickedId);
if (node) events?.onNodeClick?.(node.domainRef); if (node) events?.onNodeClick?.(node.domainRef);
} else { } else {
setSelectedNodeId(null); // click on empty space — hide popover const canvas = canvasHandle.current?.getCanvas();
if (!interaction.isDragging.current) { 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?.(); events?.onBackgroundClick?.();
} }
} }
@ -320,6 +427,7 @@ export function GraphView({
const world = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top); const world = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top);
const nodeId = interaction.handleDoubleClick(world.x, world.y, simulation.stateRef.current.nodes); const nodeId = interaction.handleDoubleClick(world.x, world.y, simulation.stateRef.current.nodes);
if (nodeId) { if (nodeId) {
setSelectedEdgeId(null);
const node = simulation.stateRef.current.nodes.find((n) => n.id === nodeId); const node = simulation.stateRef.current.nodes.find((n) => n.id === nodeId);
if (node) { if (node) {
// Unpin if pinned (toggle) // Unpin if pinned (toggle)
@ -340,8 +448,9 @@ export function GraphView({
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) return; if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) return;
if (e.key === 'Escape') { if (e.key === 'Escape') {
if (selectedNodeId) { if (selectedNodeId || selectedEdgeId) {
setSelectedNodeId(null); setSelectedNodeId(null);
setSelectedEdgeId(null);
} else { } else {
onRequestClose?.(); onRequestClose?.();
} }
@ -357,16 +466,28 @@ export function GraphView({
}; };
window.addEventListener('keydown', handler); window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler);
}, [selectedNodeId, onRequestClose, camera, simulation.stateRef]); }, [selectedEdgeId, selectedNodeId, onRequestClose, camera, simulation.stateRef]);
// ─── Selected node for overlay ────────────────────────────────────────── // ─── Selected node for overlay ──────────────────────────────────────────
const selectedNode: GraphNode | null = const selectedNode: GraphNode | null =
selectedNodeId selectedNodeId
? simulation.stateRef.current.nodes.find((n) => n.id === selectedNodeId) ?? null ? simulation.stateRef.current.nodes.find((n) => n.id === selectedNodeId) ?? null
: 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(() => { useLayoutEffect(() => {
if (!selectedNode || !containerRef.current || !overlayRef.current) { if ((!selectedNode && !selectedEdgeId) || !containerRef.current || !overlayRef.current) {
return; return;
} }
@ -376,7 +497,25 @@ export function GraphView({
const reference = { const reference = {
getBoundingClientRect(): DOMRect { getBoundingClientRect(): DOMRect {
const containerRect = container.getBoundingClientRect(); 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({ return DOMRect.fromRect({
x: containerRect.left + screenPos.x, x: containerRect.left + screenPos.x,
y: containerRect.top + screenPos.y, y: containerRect.top + screenPos.y,
@ -415,7 +554,7 @@ export function GraphView({
void updatePosition(); void updatePosition();
return cleanup; return cleanup;
}, [camera, selectedNode]); }, [camera, getNodeMap, selectedEdgeId, selectedNode, simulation.stateRef]);
// ─── Render ───────────────────────────────────────────────────────────── // ─── Render ─────────────────────────────────────────────────────────────
return ( return (
@ -454,23 +593,46 @@ export function GraphView({
teamName={data.teamName} teamName={data.teamName}
teamColor={data.teamColor} teamColor={data.teamColor}
isAlive={data.isAlive} isAlive={data.isAlive}
showBlockingHint={filters.showEdges && hasBlockingEdges && !selectedNode && !selectedEdge}
/> />
{selectedNode && ( {(selectedNode || selectedEdge) && (
<div ref={overlayRef} className="fixed z-20 pointer-events-auto"> <div ref={overlayRef} className="fixed z-20 pointer-events-auto">
{renderOverlay ? ( {selectedNode ? (
renderOverlay({ renderOverlay ? (
node: selectedNode, renderOverlay({
screenPos: camera.worldToScreen(selectedNode.x ?? 0, selectedNode.y ?? 0), node: selectedNode,
onClose: () => setSelectedNodeId(null), screenPos: camera.worldToScreen(selectedNode.x ?? 0, selectedNode.y ?? 0),
}) onClose: () => setSelectedNodeId(null),
) : ( })
<GraphOverlay ) : (
selectedNode={selectedNode} <GraphOverlay
events={events} selectedNode={selectedNode}
onDeselect={() => setSelectedNodeId(null)} events={events}
/> onDeselect={() => 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);
},
})
) : (
<GraphEdgeOverlay
edge={selectedEdge}
sourceNode={selectedEdgeNodeMap.get(selectedEdge.source)}
targetNode={selectedEdgeNodeMap.get(selectedEdge.target)}
onClose={() => setSelectedEdgeId(null)}
/>
)
) : null}
</div> </div>
)} )}
</div> </div>

View file

@ -28,25 +28,15 @@ function addNodeAndIncidentEdges(
export function buildFocusState( export function buildFocusState(
selectedNodeId: string | null, selectedNodeId: string | null,
selectedEdgeId: string | null,
nodes: GraphNode[], nodes: GraphNode[],
edges: GraphEdge[] edges: GraphEdge[]
): GraphFocusState { ): GraphFocusState {
if (!selectedNodeId) { if (!selectedNodeId && !selectedEdgeId) {
return { focusNodeIds: null, focusEdgeIds: null }; return { focusNodeIds: null, focusEdgeIds: null };
} }
const selectedNode = nodes.find((node) => node.id === selectedNodeId) ?? null; const nodeById = new Map(nodes.map((node) => [node.id, node] as const));
if (
!selectedNode ||
selectedNode.kind === 'process' ||
selectedNode.kind === 'crossteam' ||
selectedNode.isOverflowStack
) {
return { focusNodeIds: null, focusEdgeIds: null };
}
const nodeIds = new Set<string>([selectedNodeId]);
const edgeIds = new Set<string>();
const adjacency = new Map<string, GraphEdge[]>(); const adjacency = new Map<string, GraphEdge[]>();
for (const edge of edges) { for (const edge of edges) {
@ -59,20 +49,117 @@ export function buildFocusState(
adjacency.set(edge.target, targetEdges); 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<string>([selectedEdge.source, selectedEdge.target]);
const edgeIds = new Set<string>([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<string>([selectedNode.id]);
const edgeIds = new Set<string>();
const selectedMemberName = const selectedMemberName =
selectedNode.domainRef.kind === 'member' || selectedNode.domainRef.kind === 'lead' selectedNode.domainRef.kind === 'member' || selectedNode.domainRef.kind === 'lead'
? selectedNode.domainRef.memberName ? selectedNode.domainRef.memberName
: null; : null;
if (selectedNode.kind === 'lead') { if (selectedNode.kind === 'lead') {
addNodeAndIncidentEdges(nodeIds, edgeIds, selectedNodeId, adjacency); addNodeAndIncidentEdges(nodeIds, edgeIds, selectedNode.id, adjacency);
} else if (selectedNode.kind === 'member') { } else if (selectedNode.kind === 'member') {
addNodeAndIncidentEdges(nodeIds, edgeIds, selectedNodeId, adjacency); addNodeAndIncidentEdges(nodeIds, edgeIds, selectedNode.id, adjacency);
for (const node of nodes) { for (const node of nodes) {
if (node.kind !== 'task') continue; if (node.kind !== 'task') continue;
if (node.isOverflowStack) { if (node.isOverflowStack) {
if (node.ownerId === selectedNodeId) { if (node.ownerId === selectedNode.id) {
nodeIds.add(node.id); nodeIds.add(node.id);
for (const edge of adjacency.get(node.id) ?? []) { for (const edge of adjacency.get(node.id) ?? []) {
edgeIds.add(edge.id); edgeIds.add(edge.id);
@ -81,7 +168,7 @@ export function buildFocusState(
continue; continue;
} }
const isOwnedTask = node.ownerId === selectedNodeId; const isOwnedTask = node.ownerId === selectedNode.id;
const isReviewTask = const isReviewTask =
selectedMemberName != null && selectedMemberName != null &&
node.reviewerName === selectedMemberName && 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') { if (edge.type === 'ownership' || edge.type === 'blocking') {
edgeIds.add(edge.id); edgeIds.add(edge.id);
nodeIds.add(edge.source); nodeIds.add(edge.source);
@ -125,7 +212,7 @@ export function buildFocusState(
} }
const focusedMemberIds = Array.from(nodeIds).filter((nodeId) => { const focusedMemberIds = Array.from(nodeIds).filter((nodeId) => {
const node = nodes.find((candidate) => candidate.id === nodeId); const node = nodeById.get(nodeId);
return node?.kind === 'member'; return node?.kind === 'member';
}); });

View file

@ -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<T>(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<string>;
focusEdgeIds?: ReadonlySet<string> | 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<string>();
const seenEdges = new Set<string>();
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);
}

View file

@ -18,7 +18,7 @@ import {
import { isInboxNoiseMessage } from '@shared/utils/inboxNoise'; import { isInboxNoiseMessage } from '@shared/utils/inboxNoise';
import { isLeadMember } from '@shared/utils/leadDetection'; import { isLeadMember } from '@shared/utils/leadDetection';
import { collapseOverflowStacks } from '../utils/collapseOverflowStacks'; import { collapseOverflowStacksWithMeta } from '../utils/collapseOverflowStacks';
import { import {
isTaskBlocked, isTaskBlocked,
isTaskInReviewCycle, isTaskInReviewCycle,
@ -47,8 +47,10 @@ export class TeamGraphAdapter {
readonly #seenRelated = new Set<string>(); readonly #seenRelated = new Set<string>();
readonly #seenMessageIds = new Set<string>(); readonly #seenMessageIds = new Set<string>();
#initialMessagesSeen = false; #initialMessagesSeen = false;
#messageParticleCutoffMs: number | null = null;
readonly #seenCommentCounts = new Map<string, number>(); readonly #seenCommentCounts = new Map<string, number>();
#initialCommentsSeen = false; #initialCommentsSeen = false;
#commentParticleCutoffMs: number | null = null;
// ─── Static factory ────────────────────────────────────────────────────── // ─── Static factory ──────────────────────────────────────────────────────
static create(): TeamGraphAdapter { static create(): TeamGraphAdapter {
@ -84,8 +86,10 @@ export class TeamGraphAdapter {
if (teamName !== this.#lastTeamName) { if (teamName !== this.#lastTeamName) {
this.#seenMessageIds.clear(); this.#seenMessageIds.clear();
this.#initialMessagesSeen = false; this.#initialMessagesSeen = false;
this.#messageParticleCutoffMs = null;
this.#seenCommentCounts.clear(); this.#seenCommentCounts.clear();
this.#initialCommentsSeen = false; this.#initialCommentsSeen = false;
this.#commentParticleCutoffMs = null;
} }
this.#lastTeamName = teamName; this.#lastTeamName = teamName;
@ -152,8 +156,10 @@ export class TeamGraphAdapter {
this.#seenRelated.clear(); this.#seenRelated.clear();
this.#seenMessageIds.clear(); this.#seenMessageIds.clear();
this.#initialMessagesSeen = false; this.#initialMessagesSeen = false;
this.#messageParticleCutoffMs = null;
this.#seenCommentCounts.clear(); this.#seenCommentCounts.clear();
this.#initialCommentsSeen = false; this.#initialCommentsSeen = false;
this.#commentParticleCutoffMs = null;
this.#lastTeamName = ''; this.#lastTeamName = '';
} }
@ -163,6 +169,14 @@ export class TeamGraphAdapter {
return data.members.find((member) => isLeadMember(member))?.name ?? `${teamName}-lead`; 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( static #getRuntimeLabel(
providerId: TeamData['members'][number]['providerId'], providerId: TeamData['members'][number]['providerId'],
model: TeamData['members'][number]['model'], 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( const visibleTaskIds = new Set(
visibleTaskNodes.flatMap((taskNode) => visibleTaskNodes.flatMap((taskNode) =>
taskNode.domainRef.kind === 'task' ? [taskNode.domainRef.taskId] : [] taskNode.domainRef.kind === 'task' ? [taskNode.domainRef.taskId] : []
@ -444,37 +459,60 @@ export class TeamGraphAdapter {
}); });
} }
const seenBlockingEdges = new Set<string>(); const seenBlockingRelations = new Set<string>();
const blockingEdges = new Map<
string,
{
source: string;
target: string;
aggregateCount: number;
sourceTaskIds: Set<string>;
targetTaskIds: Set<string>;
}
>();
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) { 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}`; const taskNodeId = `task:${teamName}:${task.id}`;
for (const blockerId of task.blockedBy ?? []) { for (const blockerId of task.blockedBy ?? []) {
if (!visibleTaskIds.has(blockerId)) continue; addBlockingRelation(blockerId, task.id);
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',
});
} }
for (const blockedId of task.blocks ?? []) { for (const blockedId of task.blocks ?? []) {
if (!visibleTaskIds.has(blockedId)) continue; addBlockingRelation(task.id, blockedId);
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',
});
} }
if (!visibleTaskIds.has(task.id)) continue;
for (const relatedId of task.related ?? []) { for (const relatedId of task.related ?? []) {
if (!visibleTaskIds.has(relatedId)) continue; if (!visibleTaskIds.has(relatedId)) continue;
const key = [task.id, relatedId].sort().join(':'); 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( #buildProcessNodes(
@ -539,6 +594,7 @@ export class TeamGraphAdapter {
// This prevents old messages from spawning particles when the graph opens. // This prevents old messages from spawning particles when the graph opens.
if (!this.#initialMessagesSeen) { if (!this.#initialMessagesSeen) {
this.#initialMessagesSeen = true; this.#initialMessagesSeen = true;
this.#messageParticleCutoffMs = Date.now();
for (const msg of ordered) { for (const msg of ordered) {
const msgKey = TeamGraphAdapter.#getMessageParticleKey(msg); const msgKey = TeamGraphAdapter.#getMessageParticleKey(msg);
this.#seenMessageIds.add(msgKey); this.#seenMessageIds.add(msgKey);
@ -560,6 +616,9 @@ export class TeamGraphAdapter {
const msgKey = TeamGraphAdapter.#getMessageParticleKey(msg); const msgKey = TeamGraphAdapter.#getMessageParticleKey(msg);
if (this.#seenMessageIds.has(msgKey)) continue; if (this.#seenMessageIds.has(msgKey)) continue;
this.#seenMessageIds.add(msgKey); this.#seenMessageIds.add(msgKey);
if (TeamGraphAdapter.#isBeforeParticleCutoff(msg.timestamp, this.#messageParticleCutoffMs)) {
continue;
}
// Skip comment notifications — #buildCommentParticles handles them with real text // Skip comment notifications — #buildCommentParticles handles them with real text
if (msg.summary?.startsWith('Comment on ')) continue; 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. // This prevents pre-existing comments from spawning particles when the graph opens.
if (!this.#initialCommentsSeen) { if (!this.#initialCommentsSeen) {
this.#initialCommentsSeen = true; this.#initialCommentsSeen = true;
this.#commentParticleCutoffMs = Date.now();
for (const task of data.tasks) { for (const task of data.tasks) {
this.#seenCommentCounts.set(task.id, task.comments?.length ?? 0); this.#seenCommentCounts.set(task.id, task.comments?.length ?? 0);
} }
@ -681,6 +741,14 @@ export class TeamGraphAdapter {
for (let index = prevCount; index < currentCount; index += 1) { for (let index = prevCount; index < currentCount; index += 1) {
const newComment = task.comments?.[index]; const newComment = task.comments?.[index];
if (!newComment) continue; if (!newComment) continue;
if (
TeamGraphAdapter.#isBeforeParticleCutoff(
newComment.createdAt,
this.#commentParticleCutoffMs
)
) {
continue;
}
const authorNodeId = TeamGraphAdapter.#resolveParticipantId( const authorNodeId = TeamGraphAdapter.#resolveParticipantId(
newComment.author, newComment.author,
teamName, teamName,
@ -726,8 +794,8 @@ export class TeamGraphAdapter {
// ─── Static mappers ────────────────────────────────────────────────────── // ─── Static mappers ──────────────────────────────────────────────────────
static #buildBlockingEdgeId(teamName: string, blockerId: string, blockedId: string): string { static #buildBlockingEdgeId(sourceNodeId: string, targetNodeId: string): string {
return `edge:block:task:${teamName}:${blockerId}:task:${teamName}:${blockedId}`; return `edge:block:${sourceNodeId}:${targetNodeId}`;
} }
static #buildMemberException( static #buildMemberException(

View file

@ -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<GraphNode['domainRef'], { kind: 'task' }>;
} {
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 (
<div className="min-w-[260px] max-w-[340px] rounded-lg border border-red-500/20 bg-[var(--color-surface-raised)] p-3 shadow-xl">
<div className="flex items-center justify-between gap-2">
<div className="font-mono text-[10px] uppercase tracking-[0.14em] text-red-400/90">
Blocking Dependency
</div>
{relationCount > 1 && (
<Badge
variant="outline"
className="border-red-500/30 px-1.5 py-0 text-[10px] text-red-300"
>
{relationCount} links
</Badge>
)}
</div>
<div className="mt-2 text-xs leading-relaxed text-[var(--color-text)]">
<div className="font-medium text-red-100">{sourceLabel}</div>
{sourceHiddenTasks.length > 0 && (
<HiddenTaskPreview
title="Blocking hidden tasks"
tasks={sourceHiddenTasks}
onOpenTaskDetail={onOpenTaskDetail}
onClose={onClose}
/>
)}
<div className="mt-1 text-[11px] text-red-300/85">blocks</div>
<div className="mt-1 font-medium text-red-100">{targetLabel}</div>
{targetHiddenTasks.length > 0 && (
<HiddenTaskPreview
title="Blocked hidden tasks"
tasks={targetHiddenTasks}
onOpenTaskDetail={onOpenTaskDetail}
onClose={onClose}
/>
)}
</div>
<div className="mt-3 flex flex-wrap gap-2">
{sourceActionLabel && (
<Button type="button" size="sm" variant="outline" onClick={openSource}>
{sourceActionLabel}
</Button>
)}
{targetActionLabel && (
<Button type="button" size="sm" variant="outline" onClick={openTarget}>
{targetActionLabel}
</Button>
)}
<Button type="button" size="sm" variant="ghost" onClick={onClose}>
Close
</Button>
</div>
</div>
);
}
function resolveEdgeTaskPreview(
node: GraphNode | undefined,
edgeTaskIds: string[] | undefined,
tasksById: ReadonlyMap<string, TeamTaskWithKanban>
): 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 (
<div className="mt-2 rounded border border-red-500/15 bg-red-500/5 px-2 py-2">
<div className="text-[10px] uppercase tracking-[0.1em] text-red-300/80">{title}</div>
<div className="mt-1 space-y-1">
{tasks.map((task) => (
<button
key={task.id}
type="button"
className="block w-full truncate text-left text-[11px] text-red-100/95 transition-opacity hover:opacity-80"
onClick={() => {
onOpenTaskDetail?.(task.id);
onClose();
}}
>
{task.displayId ?? `#${task.id.slice(0, 6)}`} - {task.subject}
</button>
))}
</div>
</div>
);
}

View file

@ -10,6 +10,7 @@ import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHo
import { useTeamGraphAdapter } from '../adapters/useTeamGraphAdapter'; import { useTeamGraphAdapter } from '../adapters/useTeamGraphAdapter';
import { GraphBlockingEdgePopover } from './GraphBlockingEdgePopover';
import { GraphNodePopover } from './GraphNodePopover'; import { GraphNodePopover } from './GraphNodePopover';
import type { GraphDomainRef, GraphEventPort } from '@claude-teams/agent-graph'; import type { GraphDomainRef, GraphEventPort } from '@claude-teams/agent-graph';
@ -84,6 +85,17 @@ export const TeamGraphOverlay = ({
onRequestClose={onClose} onRequestClose={onClose}
onRequestPinAsTab={onPinAsTab} onRequestPinAsTab={onPinAsTab}
className="min-w-0 flex-1" className="min-w-0 flex-1"
renderEdgeOverlay={({ edge, sourceNode, targetNode, onClose: closeEdge, onSelectNode }) => (
<GraphBlockingEdgePopover
teamName={teamName}
edge={edge}
sourceNode={sourceNode}
targetNode={targetNode}
onClose={closeEdge}
onSelectNode={onSelectNode}
onOpenTaskDetail={onOpenTaskDetail}
/>
)}
renderOverlay={({ node, onClose: closePopover }) => ( renderOverlay={({ node, onClose: closePopover }) => (
<GraphNodePopover <GraphNodePopover
node={node} node={node}

View file

@ -10,6 +10,7 @@ import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHo
import { useTeamGraphAdapter } from '../adapters/useTeamGraphAdapter'; import { useTeamGraphAdapter } from '../adapters/useTeamGraphAdapter';
import { GraphBlockingEdgePopover } from './GraphBlockingEdgePopover';
import { GraphNodePopover } from './GraphNodePopover'; import { GraphNodePopover } from './GraphNodePopover';
import type { GraphDomainRef, GraphEventPort, GraphNode } from '@claude-teams/agent-graph'; import type { GraphDomainRef, GraphEventPort, GraphNode } from '@claude-teams/agent-graph';
@ -116,6 +117,17 @@ export const TeamGraphTab = ({
className="size-full" className="size-full"
suspendAnimation={!isActive} suspendAnimation={!isActive}
onRequestFullscreen={() => setFullscreen(true)} onRequestFullscreen={() => setFullscreen(true)}
renderEdgeOverlay={({ edge, sourceNode, targetNode, onClose, onSelectNode }) => (
<GraphBlockingEdgePopover
teamName={teamName}
edge={edge}
sourceNode={sourceNode}
targetNode={targetNode}
onClose={onClose}
onSelectNode={onSelectNode}
onOpenTaskDetail={dispatchOpenTask}
/>
)}
renderOverlay={({ node, onClose }) => ( renderOverlay={({ node, onClose }) => (
<GraphNodePopover <GraphNodePopover
node={node} node={node}

View file

@ -1,5 +1,10 @@
import type { GraphNode } from '@claude-teams/agent-graph'; import type { GraphNode } from '@claude-teams/agent-graph';
export interface OverflowCollapseResult {
visibleNodes: GraphNode[];
visibleNodeIdByTaskId: Map<string, string>;
}
function resolveOverflowColumnKey(task: GraphNode): string { function resolveOverflowColumnKey(task: GraphNode): string {
if (task.reviewState === 'approved') return 'approved'; if (task.reviewState === 'approved') return 'approved';
if (task.reviewState === 'review' || task.reviewState === 'needsFix') return 'review'; if (task.reviewState === 'review' || task.reviewState === 'needsFix') return 'review';
@ -19,8 +24,23 @@ export function collapseOverflowStacks(
teamName: string, teamName: string,
maxVisibleRows: number maxVisibleRows: number
): GraphNode[] { ): GraphNode[] {
return collapseOverflowStacksWithMeta(taskNodes, teamName, maxVisibleRows).visibleNodes;
}
export function collapseOverflowStacksWithMeta(
taskNodes: GraphNode[],
teamName: string,
maxVisibleRows: number
): OverflowCollapseResult {
if (maxVisibleRows <= 1) { 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<string, GraphNode[]>(); const grouped = new Map<string, GraphNode[]>();
@ -38,11 +58,17 @@ export function collapseOverflowStacks(
} }
const visibleTasks: GraphNode[] = []; const visibleTasks: GraphNode[] = [];
const visibleNodeIdByTaskId = new Map<string, string>();
for (const groupKey of groupOrder) { for (const groupKey of groupOrder) {
const groupTasks = grouped.get(groupKey) ?? []; const groupTasks = grouped.get(groupKey) ?? [];
if (groupTasks.length <= maxVisibleRows) { if (groupTasks.length <= maxVisibleRows) {
visibleTasks.push(...groupTasks); visibleTasks.push(...groupTasks);
for (const task of groupTasks) {
if (task.domainRef.kind === 'task') {
visibleNodeIdByTaskId.set(task.domainRef.taskId, task.id);
}
}
continue; continue;
} }
@ -53,21 +79,37 @@ export function collapseOverflowStacks(
const ownerMemberName = extractOwnerMemberName(representative, teamName); const ownerMemberName = extractOwnerMemberName(representative, teamName);
visibleTasks.push(...keptTasks); 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({ visibleTasks.push({
id: `task:${teamName}:overflow:${groupKey}`, id: `task:${teamName}:overflow:${groupKey}`,
kind: 'task', kind: 'task',
label: `+${hiddenTasks.length}`, label: `+${hiddenTasks.length}`,
state: 'waiting', state: representative.state,
displayId: `+${hiddenTasks.length}`, displayId: `+${hiddenTasks.length}`,
sublabel: `${hiddenTasks.length} more tasks`, sublabel: `${hiddenTasks.length} more tasks`,
ownerId: representative.ownerId ?? null, ownerId: representative.ownerId ?? null,
taskStatus: representative.taskStatus, taskStatus: representative.taskStatus,
reviewState: representative.reviewState, reviewState: representative.reviewState,
changePresence: hiddenTasks.some((task) => task.changePresence === 'has_changes')
? 'has_changes'
: undefined,
isBlocked: hiddenTasks.some((task) => task.isBlocked),
isOverflowStack: true, isOverflowStack: true,
overflowCount: hiddenTasks.length, overflowCount: hiddenTasks.length,
overflowTaskIds: hiddenTasks.flatMap((task) => overflowTaskIds,
task.domainRef.kind === 'task' ? [task.domainRef.taskId] : []
),
domainRef: { domainRef: {
kind: 'task_overflow', kind: 'task_overflow',
teamName, teamName,
@ -77,5 +119,8 @@ export function collapseOverflowStacks(
}); });
} }
return visibleTasks; return {
visibleNodes: visibleTasks,
visibleNodeIdByTaskId,
};
} }

View file

@ -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();
});
});
});

View file

@ -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'; import { TeamGraphAdapter } from '@renderer/features/agent-graph/adapters/TeamGraphAdapter';
@ -59,6 +59,15 @@ function findNode(graph: GraphDataPort, nodeId: string) {
} }
describe('TeamGraphAdapter particles', () => { 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', () => { it('creates a message particle for a new incoming message from the newest message set', () => {
const adapter = TeamGraphAdapter.create(); const adapter = TeamGraphAdapter.create();
const baseline = createBaseTeamData(); 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', () => { it('creates a synthetic message edge for comments from non-owner participants', () => {
const adapter = TeamGraphAdapter.create(); const adapter = TeamGraphAdapter.create();
const baseline = createBaseTeamData({ const baseline = createBaseTeamData({
@ -687,6 +770,51 @@ describe('TeamGraphAdapter particles', () => {
expect(findNode(completedGraph, 'task:my-team:task-b')?.isBlocked).toBe(false); 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', () => { it('adds compact review handoff metadata for active review tasks', () => {
const adapter = TeamGraphAdapter.create(); const adapter = TeamGraphAdapter.create();
const graph = adapter.adapt( const graph = adapter.adapt(

View file

@ -118,7 +118,7 @@ const nodes = [leadNode, aliceNode, bobNode, blockerNode, reviewTaskNode, overfl
describe('buildFocusState', () => { describe('buildFocusState', () => {
it('focuses task selection on its owner, reviewer, direct blockers, and connecting edges', () => { 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( 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', () => { 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(bobNode.id)).toBe(true);
expect(focus.focusNodeIds?.has(reviewTaskNode.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:parent:lead:bob')).toBe(true);
expect(focus.focusEdgeIds?.has('edge:own:alice:review')).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); expect(aliceFocus.focusNodeIds?.has(overflowNode.id)).toBe(true);
}); });
it('focuses a lead on direct neighbors only', () => { 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( expect(focus.focusNodeIds).toEqual(
new Set([leadNode.id, aliceNode.id, bobNode.id]) new Set([leadNode.id, aliceNode.id, bobNode.id])
@ -165,9 +165,26 @@ describe('buildFocusState', () => {
}); });
it('does not enable global dimming for overflow stack selections', () => { 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.focusNodeIds).toBeNull();
expect(focus.focusEdgeIds).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',
])
);
});
}); });

View file

@ -1,6 +1,9 @@
import { describe, expect, it } from 'vitest'; 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'; 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);
});
}); });

View file

@ -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']);
});
});

View file

@ -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);
});
});