From 66216603763cb2363359d86abb1194e999adcf34 Mon Sep 17 00:00:00 2001 From: iliya Date: Tue, 31 Mar 2026 01:48:15 +0300 Subject: [PATCH] feat(graph): add cross-team ghost nodes and task card improvements - Cross-team messages now show ghost nodes (dashed hexagons) for external teams - Ghost nodes have purple color, link icon, and connect to lead via message edge - Particles flow between ghost node and lead with cross-team message labels - Cross-team popover shows external team name - Task click opens full KanbanTaskCard with glow effects and action buttons - All kanban task actions wired through CustomEvent to TeamDetailView --- .../agent-graph/src/canvas/draw-agents.ts | 63 ++++++++++ .../agent-graph/src/canvas/hit-detection.ts | 5 +- .../src/constants/canvas-constants.ts | 2 + packages/agent-graph/src/ports/types.ts | 5 +- packages/agent-graph/src/strategies/index.ts | 1 + packages/agent-graph/src/ui/GraphCanvas.tsx | 3 +- .../components/team/TeamDetailView.tsx | 7 +- .../agent-graph/adapters/TeamGraphAdapter.ts | 114 +++++++++++++++++- .../agent-graph/ui/GraphNodePopover.tsx | 15 +++ 9 files changed, 208 insertions(+), 7 deletions(-) diff --git a/packages/agent-graph/src/canvas/draw-agents.ts b/packages/agent-graph/src/canvas/draw-agents.ts index e30b5c17..44bdd28d 100644 --- a/packages/agent-graph/src/canvas/draw-agents.ts +++ b/packages/agent-graph/src/canvas/draw-agents.ts @@ -106,6 +106,69 @@ export function drawAgents( } } +/** + * Draw cross-team ghost nodes — semi-transparent dashed hexagons. + */ +export function drawCrossTeamNodes( + ctx: CanvasRenderingContext2D, + nodes: GraphNode[], + time: number, + selectedId: string | null, + hoveredId: string | null, +): void { + for (const node of nodes) { + if (node.kind !== 'crossteam') continue; + + const x = node.x ?? 0; + const y = node.y ?? 0; + const r = NODE.radiusCrossTeam; + const color = node.color ?? '#cc88ff'; + const isSelected = node.id === selectedId; + const isHovered = node.id === hoveredId; + + ctx.save(); + ctx.globalAlpha = isHovered ? 0.7 : 0.5; + + // Subtle glow + const glowR = r + AGENT_DRAW.glowPadding; + const sprite = getAgentGlowSprite(color, r, glowR); + ctx.drawImage(sprite, x - glowR, y - glowR); + + // Dashed hexagon body + drawHexagon(ctx, x, y, r); + ctx.fillStyle = 'rgba(10, 15, 40, 0.4)'; + ctx.fill(); + + ctx.setLineDash([4, 4]); + ctx.strokeStyle = hexWithAlpha(color, 0.6); + ctx.lineWidth = 1.5; + ctx.stroke(); + ctx.setLineDash([]); + + // Link icon (two arrows ↔) in center + ctx.font = 'bold 12px sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = hexWithAlpha(color, 0.8); + ctx.fillText('\u{2194}', x, y); // ↔ + + // Label below + ctx.globalAlpha = 0.7; + ctx.font = '8px monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'top'; + ctx.fillStyle = hexWithAlpha(color, 0.7); + ctx.fillText(node.label, x, y + r + 6); + + // Selection ring + if (isSelected) { + drawSelectionRing(ctx, x, y, r, color); + } + + ctx.restore(); + } +} + // ─── Private Helpers ──────────────────────────────────────────────────────── function getNodeOpacity(node: GraphNode): number { diff --git a/packages/agent-graph/src/canvas/hit-detection.ts b/packages/agent-graph/src/canvas/hit-detection.ts index 3895b9fd..8e77998e 100644 --- a/packages/agent-graph/src/canvas/hit-detection.ts +++ b/packages/agent-graph/src/canvas/hit-detection.ts @@ -49,8 +49,9 @@ export function findNodeAt( } break; } - case 'process': { - const r = NODE.radiusProcess + HIT_DETECTION.agentPadding; + case 'process': + case 'crossteam': { + const r = (node.kind === 'crossteam' ? NODE.radiusCrossTeam : NODE.radiusProcess) + HIT_DETECTION.agentPadding; const dx = worldX - x; const dy = worldY - y; if (dx * dx + dy * dy <= r * r) { diff --git a/packages/agent-graph/src/constants/canvas-constants.ts b/packages/agent-graph/src/constants/canvas-constants.ts index 1228f389..54c7129a 100644 --- a/packages/agent-graph/src/constants/canvas-constants.ts +++ b/packages/agent-graph/src/constants/canvas-constants.ts @@ -61,6 +61,8 @@ export const NODE = { radiusMember: 24, /** Process node radius */ radiusProcess: 14, + /** Cross-team ghost node radius */ + radiusCrossTeam: 20, } as const; // ─── Task pill dimensions ─────────────────────────────────────────────────── diff --git a/packages/agent-graph/src/ports/types.ts b/packages/agent-graph/src/ports/types.ts index 67b1c9a3..8bcc43f0 100644 --- a/packages/agent-graph/src/ports/types.ts +++ b/packages/agent-graph/src/ports/types.ts @@ -5,7 +5,7 @@ // ─── Node Kinds ────────────────────────────────────────────────────────────── -export type GraphNodeKind = 'lead' | 'member' | 'task' | 'process'; +export type GraphNodeKind = 'lead' | 'member' | 'task' | 'process' | 'crossteam'; export type GraphNodeState = | 'idle' @@ -161,4 +161,5 @@ export type GraphDomainRef = | { kind: 'lead'; teamName: string; memberName: string } | { kind: 'member'; teamName: string; memberName: string } | { kind: 'task'; teamName: string; taskId: string } - | { kind: 'process'; teamName: string; processId: string }; + | { kind: 'process'; teamName: string; processId: string } + | { kind: 'crossteam'; teamName: string; externalTeamName: string }; diff --git a/packages/agent-graph/src/strategies/index.ts b/packages/agent-graph/src/strategies/index.ts index 8f0fc9f0..ea7d4e9c 100644 --- a/packages/agent-graph/src/strategies/index.ts +++ b/packages/agent-graph/src/strategies/index.ts @@ -14,6 +14,7 @@ const STRATEGIES: Record = { member: new MemberStrategy(), task: new TaskStrategy(), process: new ProcessStrategy(), + crossteam: new ProcessStrategy(), // Reuse process strategy (similar small node) }; export function getNodeStrategy(kind: GraphNodeKind): NodeRenderStrategy { diff --git a/packages/agent-graph/src/ui/GraphCanvas.tsx b/packages/agent-graph/src/ui/GraphCanvas.tsx index d832871d..3548c49b 100644 --- a/packages/agent-graph/src/ui/GraphCanvas.tsx +++ b/packages/agent-graph/src/ui/GraphCanvas.tsx @@ -11,7 +11,7 @@ import type { GraphNode, GraphEdge, GraphParticle } from '../ports/types'; import { drawBackground, createDepthParticles, updateDepthParticles, type DepthParticle } from '../canvas/background-layer'; import { drawEdges } from '../canvas/draw-edges'; import { drawParticles } from '../canvas/draw-particles'; -import { drawAgents } from '../canvas/draw-agents'; +import { drawAgents, drawCrossTeamNodes } from '../canvas/draw-agents'; import { drawTasks, drawColumnHeaders } from '../canvas/draw-tasks'; import { drawProcesses } from '../canvas/draw-processes'; import { drawEffects, type VisualEffect } from '../canvas/draw-effects'; @@ -209,6 +209,7 @@ export const GraphCanvas = forwardRef(funct // 2c. Visible nodes only (back to front: process → task → member/lead) drawProcesses(ctx, visibleNodes, state.time, state.selectedNodeId, state.hoveredNodeId); + drawCrossTeamNodes(ctx, visibleNodes, state.time, state.selectedNodeId, state.hoveredNodeId); drawColumnHeaders(ctx, KanbanLayoutEngine.zones); drawTasks(ctx, visibleNodes, state.time, state.selectedNodeId, state.hoveredNodeId); drawAgents(ctx, visibleNodes, state.time, state.selectedNodeId, state.hoveredNodeId); diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 59087c61..da7dec5a 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -1584,7 +1584,12 @@ export const TeamDetailView = ({ }); }} > - + + + + NEW + + Graph