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
This commit is contained in:
iliya 2026-03-31 01:48:15 +03:00
parent 16f069fae3
commit 6621660376
9 changed files with 208 additions and 7 deletions

View file

@ -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 {

View file

@ -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) {

View file

@ -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 ───────────────────────────────────────────────────

View file

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

View file

@ -14,6 +14,7 @@ const STRATEGIES: Record<GraphNodeKind, NodeRenderStrategy> = {
member: new MemberStrategy(),
task: new TaskStrategy(),
process: new ProcessStrategy(),
crossteam: new ProcessStrategy(), // Reuse process strategy (similar small node)
};
export function getNodeStrategy(kind: GraphNodeKind): NodeRenderStrategy {

View file

@ -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<GraphCanvasHandle, GraphCanvasProps>(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);

View file

@ -1584,7 +1584,12 @@ export const TeamDetailView = ({
});
}}
>
<Network size={12} />
<span className="relative">
<Network size={12} />
<span className="absolute -right-3.5 -top-2.5 rounded-sm bg-emerald-500/20 px-0.5 py-px text-[7px] font-bold leading-none text-emerald-400">
NEW
</span>
</span>
Graph
</Button>
<Button

View file

@ -9,6 +9,7 @@
import { getUnreadCount } from '@renderer/services/commentReadStorage';
import { agentAvatarUrl } from '@renderer/utils/memberHelpers';
import { stripCrossTeamPrefix } from '@shared/constants/crossTeam';
import { getInboxJsonType, isInboxNoiseMessage } from '@shared/utils/inboxNoise';
import { isLeadMember } from '@shared/utils/leadDetection';
@ -195,7 +196,15 @@ export class TeamGraphAdapter {
);
this.#buildTaskNodes(nodes, edges, teamData, teamName, commentReadState);
this.#buildProcessNodes(nodes, edges, teamData, teamName);
this.#buildMessageParticles(particles, teamData.messages, teamName, leadId, leadName, edges);
this.#buildMessageParticles(
particles,
nodes,
teamData.messages,
teamName,
leadId,
leadName,
edges
);
this.#buildCommentParticles(particles, teamData, teamName, leadId, leadName, edges);
this.#cachedResult = {
@ -518,6 +527,7 @@ export class TeamGraphAdapter {
#buildMessageParticles(
particles: GraphParticle[],
nodes: GraphNode[],
messages: readonly InboxMessage[],
teamName: string,
leadId: string,
@ -534,9 +544,18 @@ export class TeamGraphAdapter {
const msgKey = TeamGraphAdapter.#getMessageParticleKey(msg);
this.#seenMessageIds.add(msgKey);
}
// Still create ghost nodes for cross-team (without particles)
for (const msg of ordered) {
if (msg.source === 'cross_team' || msg.source === 'cross_team_sent') {
TeamGraphAdapter.#ensureCrossTeamNode(nodes, edges, msg, teamName, leadId);
}
}
return;
}
// Track which ghost nodes we've already created this cycle
const seenGhostTeams = new Set<string>();
// Subsequent calls: only create particles for messages not yet seen.
for (const msg of ordered) {
const msgKey = TeamGraphAdapter.#getMessageParticleKey(msg);
@ -555,6 +574,42 @@ export class TeamGraphAdapter {
continue; // skip shutdown_approved, teammate_terminated, shutdown_request
}
// Cross-team messages: create ghost node + edge + particle
if (msg.source === 'cross_team' || msg.source === 'cross_team_sent') {
const ghostNodeId = TeamGraphAdapter.#ensureCrossTeamNode(
nodes,
edges,
msg,
teamName,
leadId
);
if (!ghostNodeId) continue;
const edgeId = edges.find(
(e) =>
(e.source === ghostNodeId && e.target === leadId) ||
(e.source === leadId && e.target === ghostNodeId)
)?.id;
if (!edgeId) continue;
// incoming = from external team → lead (reverse on lead→ghost edge)
// sent = from lead → external team (forward on lead→ghost edge)
const isIncoming = msg.source === 'cross_team';
const cleanText = stripCrossTeamPrefix(msg.text ?? '');
const label = TeamGraphAdapter.#buildParticleLabel(msg.summary ?? cleanText, 'inbox');
particles.push({
id: `particle:msg:${teamName}:${msgKey}`,
edgeId,
progress: 0,
kind: 'inbox_message',
color: '#cc88ff',
label,
reverse: !isIncoming, // ghost→lead edge: incoming = forward, sent = reverse
});
continue;
}
const edgeId = TeamGraphAdapter.#resolveMessageEdge(msg, teamName, leadId, leadName, edges);
if (!edgeId) continue;
@ -584,6 +639,17 @@ export class TeamGraphAdapter {
reverse: isFromTeammate,
});
}
// Also ensure ghost nodes exist for ALL cross-team messages (not just new ones)
for (const msg of ordered) {
if (msg.source === 'cross_team' || msg.source === 'cross_team_sent') {
const extTeam = TeamGraphAdapter.#extractExternalTeamName(msg.from ?? '');
if (extTeam && !seenGhostTeams.has(extTeam)) {
seenGhostTeams.add(extTeam);
TeamGraphAdapter.#ensureCrossTeamNode(nodes, edges, msg, teamName, leadId);
}
}
}
}
#buildCommentParticles(
@ -769,6 +835,52 @@ export class TeamGraphAdapter {
return `member:${teamName}:${name}`;
}
/** Extract external team name from cross-team "from" field like "team-b.alice" */
static #extractExternalTeamName(from: string): string | null {
const dotIdx = from.indexOf('.');
if (dotIdx <= 0) return null;
return from.slice(0, dotIdx);
}
/** Create or find ghost node + edge for an external team. Returns ghost node ID. */
static #ensureCrossTeamNode(
nodes: GraphNode[],
edges: GraphEdge[],
msg: InboxMessage,
teamName: string,
leadId: string
): string | null {
const extTeam = TeamGraphAdapter.#extractExternalTeamName(msg.from ?? '');
if (!extTeam) return null;
const ghostId = `crossteam:${extTeam}`;
// Create ghost node if not exists
if (!nodes.some((n) => n.id === ghostId)) {
nodes.push({
id: ghostId,
kind: 'crossteam',
label: extTeam,
state: 'active',
color: '#cc88ff',
domainRef: { kind: 'crossteam', teamName, externalTeamName: extTeam },
});
}
// Create edge ghost↔lead if not exists
const edgeId = `edge:crossteam:${ghostId}:${leadId}`;
if (!edges.some((e) => e.id === edgeId)) {
edges.push({
id: edgeId,
source: ghostId,
target: leadId,
type: 'message',
});
}
return ghostId;
}
static #buildParticleLabel(
text: string | undefined,
kind: 'inbox' | 'comment',

View file

@ -111,6 +111,21 @@ export const GraphNodePopover = ({
);
}
// Cross-team ghost node
if (node.kind === 'crossteam') {
const extTeamName =
node.domainRef.kind === 'crossteam' ? node.domainRef.externalTeamName : node.label;
return (
<div className="min-w-[180px] rounded-lg border border-purple-500/30 bg-[var(--color-surface-raised)] p-3 shadow-xl">
<div className="flex items-center gap-2">
<span className="text-sm text-purple-400">{'\u{2194}'}</span>
<span className="font-mono text-xs font-bold text-purple-300">{extTeamName}</span>
</div>
<div className="mt-1 text-[10px] text-[var(--color-text-muted)]">External team</div>
</div>
);
}
// Process
return (
<div className="min-w-[180px] max-w-[260px] rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-raised)] p-3 shadow-xl">