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,
|
_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();
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 ─────────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -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 ──────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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 { 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>
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
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 { 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(
|
||||||
|
|
|
||||||
|
|
@ -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 { 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}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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';
|
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(
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
])
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
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