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:
parent
16f069fae3
commit
6621660376
9 changed files with 208 additions and 7 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 ───────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Reference in a new issue