feat(agent-graph): improve blocking visibility and inspection
This commit is contained in:
parent
f74b7a3701
commit
57c384531a
22 changed files with 1642 additions and 109 deletions
|
|
@ -75,6 +75,8 @@ export function drawEdges(
|
|||
_time: number,
|
||||
hasActiveParticles: Set<string>,
|
||||
focusEdgeIds?: ReadonlySet<string> | 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();
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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<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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ─────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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 ──────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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<string> | null;
|
||||
focusEdgeIds: ReadonlySet<string> | null;
|
||||
}
|
||||
|
|
@ -118,6 +121,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(funct
|
|||
const visibleNodesCache = useRef<GraphNode[]>([]);
|
||||
const visibleEdgesCache = useRef<GraphEdge[]>([]);
|
||||
const visibleNodeIdsCache = useRef(new Set<string>());
|
||||
const visibleEdgeIdsCache = useRef(new Set<string>());
|
||||
const activeParticleEdgesCache = useRef(new Set<string>());
|
||||
|
||||
// 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;
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -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<HTMLDivElement>(null);
|
||||
|
|
@ -203,6 +205,21 @@ export function GraphControls({
|
|||
<ToolbarButton onClick={onZoomIn} icon={<ZoomIn size={14} />} />
|
||||
</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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
66
packages/agent-graph/src/ui/GraphEdgeOverlay.tsx
Normal file
66
packages/agent-graph/src/ui/GraphEdgeOverlay.tsx
Normal 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} -> {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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<string | null>(null);
|
||||
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
|
||||
const [filters, setFilters] = useState<GraphFilterState>({
|
||||
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<string | null>(null);
|
||||
selectedNodeIdRef.current = selectedNodeId;
|
||||
const selectedEdgeIdRef = useRef<string | null>(null);
|
||||
selectedEdgeIdRef.current = selectedEdgeId;
|
||||
const hoveredEdgeIdRef = useRef<string | null>(null);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const canvasHandle = useRef<GraphCanvasHandle>(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<string, GraphNode>());
|
||||
const nodeMapNodesRef = useRef<GraphNode[] | null>(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<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(() => {
|
||||
|
|
@ -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) && (
|
||||
<div ref={overlayRef} className="fixed z-20 pointer-events-auto">
|
||||
{renderOverlay ? (
|
||||
renderOverlay({
|
||||
node: selectedNode,
|
||||
screenPos: camera.worldToScreen(selectedNode.x ?? 0, selectedNode.y ?? 0),
|
||||
onClose: () => setSelectedNodeId(null),
|
||||
})
|
||||
) : (
|
||||
<GraphOverlay
|
||||
selectedNode={selectedNode}
|
||||
events={events}
|
||||
onDeselect={() => setSelectedNodeId(null)}
|
||||
/>
|
||||
)}
|
||||
{selectedNode ? (
|
||||
renderOverlay ? (
|
||||
renderOverlay({
|
||||
node: selectedNode,
|
||||
screenPos: camera.worldToScreen(selectedNode.x ?? 0, selectedNode.y ?? 0),
|
||||
onClose: () => setSelectedNodeId(null),
|
||||
})
|
||||
) : (
|
||||
<GraphOverlay
|
||||
selectedNode={selectedNode}
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -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<string>([selectedNodeId]);
|
||||
const edgeIds = new Set<string>();
|
||||
const nodeById = new Map(nodes.map((node) => [node.id, node] as const));
|
||||
const adjacency = new Map<string, GraphEdge[]>();
|
||||
|
||||
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<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 =
|
||||
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';
|
||||
});
|
||||
|
||||
|
|
|
|||
101
packages/agent-graph/src/ui/selectRenderableParticles.ts
Normal file
101
packages/agent-graph/src/ui/selectRenderableParticles.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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<string>();
|
||||
readonly #seenMessageIds = new Set<string>();
|
||||
#initialMessagesSeen = false;
|
||||
#messageParticleCutoffMs: number | null = null;
|
||||
readonly #seenCommentCounts = new Map<string, number>();
|
||||
#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<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) {
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 }) => (
|
||||
<GraphBlockingEdgePopover
|
||||
teamName={teamName}
|
||||
edge={edge}
|
||||
sourceNode={sourceNode}
|
||||
targetNode={targetNode}
|
||||
onClose={closeEdge}
|
||||
onSelectNode={onSelectNode}
|
||||
onOpenTaskDetail={onOpenTaskDetail}
|
||||
/>
|
||||
)}
|
||||
renderOverlay={({ node, onClose: closePopover }) => (
|
||||
<GraphNodePopover
|
||||
node={node}
|
||||
|
|
|
|||
|
|
@ -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, GraphNode } from '@claude-teams/agent-graph';
|
||||
|
|
@ -116,6 +117,17 @@ export const TeamGraphTab = ({
|
|||
className="size-full"
|
||||
suspendAnimation={!isActive}
|
||||
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 }) => (
|
||||
<GraphNodePopover
|
||||
node={node}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
import type { GraphNode } from '@claude-teams/agent-graph';
|
||||
|
||||
export interface OverflowCollapseResult {
|
||||
visibleNodes: GraphNode[];
|
||||
visibleNodeIdByTaskId: Map<string, string>;
|
||||
}
|
||||
|
||||
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<string, GraphNode[]>();
|
||||
|
|
@ -38,11 +58,17 @@ export function collapseOverflowStacks(
|
|||
}
|
||||
|
||||
const visibleTasks: GraphNode[] = [];
|
||||
const visibleNodeIdByTaskId = new Map<string, string>();
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
])
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
85
test/renderer/features/agent-graph/edgeHitDetection.test.ts
Normal file
85
test/renderer/features/agent-graph/edgeHitDetection.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue