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 ────────────────────────────────────────────────────────
|
// ─── Private Helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function getNodeOpacity(node: GraphNode): number {
|
function getNodeOpacity(node: GraphNode): number {
|
||||||
|
|
|
||||||
|
|
@ -49,8 +49,9 @@ export function findNodeAt(
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'process': {
|
case 'process':
|
||||||
const r = NODE.radiusProcess + HIT_DETECTION.agentPadding;
|
case 'crossteam': {
|
||||||
|
const r = (node.kind === 'crossteam' ? NODE.radiusCrossTeam : NODE.radiusProcess) + HIT_DETECTION.agentPadding;
|
||||||
const dx = worldX - x;
|
const dx = worldX - x;
|
||||||
const dy = worldY - y;
|
const dy = worldY - y;
|
||||||
if (dx * dx + dy * dy <= r * r) {
|
if (dx * dx + dy * dy <= r * r) {
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,8 @@ export const NODE = {
|
||||||
radiusMember: 24,
|
radiusMember: 24,
|
||||||
/** Process node radius */
|
/** Process node radius */
|
||||||
radiusProcess: 14,
|
radiusProcess: 14,
|
||||||
|
/** Cross-team ghost node radius */
|
||||||
|
radiusCrossTeam: 20,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// ─── Task pill dimensions ───────────────────────────────────────────────────
|
// ─── Task pill dimensions ───────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
|
|
||||||
// ─── Node Kinds ──────────────────────────────────────────────────────────────
|
// ─── Node Kinds ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export type GraphNodeKind = 'lead' | 'member' | 'task' | 'process';
|
export type GraphNodeKind = 'lead' | 'member' | 'task' | 'process' | 'crossteam';
|
||||||
|
|
||||||
export type GraphNodeState =
|
export type GraphNodeState =
|
||||||
| 'idle'
|
| 'idle'
|
||||||
|
|
@ -161,4 +161,5 @@ export type GraphDomainRef =
|
||||||
| { kind: 'lead'; teamName: string; memberName: string }
|
| { kind: 'lead'; teamName: string; memberName: string }
|
||||||
| { kind: 'member'; teamName: string; memberName: string }
|
| { kind: 'member'; teamName: string; memberName: string }
|
||||||
| { kind: 'task'; teamName: string; taskId: 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(),
|
member: new MemberStrategy(),
|
||||||
task: new TaskStrategy(),
|
task: new TaskStrategy(),
|
||||||
process: new ProcessStrategy(),
|
process: new ProcessStrategy(),
|
||||||
|
crossteam: new ProcessStrategy(), // Reuse process strategy (similar small node)
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getNodeStrategy(kind: GraphNodeKind): NodeRenderStrategy {
|
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 { drawBackground, createDepthParticles, updateDepthParticles, type DepthParticle } from '../canvas/background-layer';
|
||||||
import { drawEdges } from '../canvas/draw-edges';
|
import { drawEdges } from '../canvas/draw-edges';
|
||||||
import { drawParticles } from '../canvas/draw-particles';
|
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 { drawTasks, drawColumnHeaders } from '../canvas/draw-tasks';
|
||||||
import { drawProcesses } from '../canvas/draw-processes';
|
import { drawProcesses } from '../canvas/draw-processes';
|
||||||
import { drawEffects, type VisualEffect } from '../canvas/draw-effects';
|
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)
|
// 2c. Visible nodes only (back to front: process → task → member/lead)
|
||||||
drawProcesses(ctx, visibleNodes, state.time, state.selectedNodeId, state.hoveredNodeId);
|
drawProcesses(ctx, visibleNodes, state.time, state.selectedNodeId, state.hoveredNodeId);
|
||||||
|
drawCrossTeamNodes(ctx, visibleNodes, state.time, state.selectedNodeId, state.hoveredNodeId);
|
||||||
drawColumnHeaders(ctx, KanbanLayoutEngine.zones);
|
drawColumnHeaders(ctx, KanbanLayoutEngine.zones);
|
||||||
drawTasks(ctx, visibleNodes, state.time, state.selectedNodeId, state.hoveredNodeId);
|
drawTasks(ctx, visibleNodes, state.time, state.selectedNodeId, state.hoveredNodeId);
|
||||||
drawAgents(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
|
Graph
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
|
|
||||||
import { getUnreadCount } from '@renderer/services/commentReadStorage';
|
import { getUnreadCount } from '@renderer/services/commentReadStorage';
|
||||||
import { agentAvatarUrl } from '@renderer/utils/memberHelpers';
|
import { agentAvatarUrl } from '@renderer/utils/memberHelpers';
|
||||||
|
import { stripCrossTeamPrefix } from '@shared/constants/crossTeam';
|
||||||
import { getInboxJsonType, isInboxNoiseMessage } from '@shared/utils/inboxNoise';
|
import { getInboxJsonType, isInboxNoiseMessage } from '@shared/utils/inboxNoise';
|
||||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
|
|
||||||
|
|
@ -195,7 +196,15 @@ export class TeamGraphAdapter {
|
||||||
);
|
);
|
||||||
this.#buildTaskNodes(nodes, edges, teamData, teamName, commentReadState);
|
this.#buildTaskNodes(nodes, edges, teamData, teamName, commentReadState);
|
||||||
this.#buildProcessNodes(nodes, edges, teamData, teamName);
|
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.#buildCommentParticles(particles, teamData, teamName, leadId, leadName, edges);
|
||||||
|
|
||||||
this.#cachedResult = {
|
this.#cachedResult = {
|
||||||
|
|
@ -518,6 +527,7 @@ export class TeamGraphAdapter {
|
||||||
|
|
||||||
#buildMessageParticles(
|
#buildMessageParticles(
|
||||||
particles: GraphParticle[],
|
particles: GraphParticle[],
|
||||||
|
nodes: GraphNode[],
|
||||||
messages: readonly InboxMessage[],
|
messages: readonly InboxMessage[],
|
||||||
teamName: string,
|
teamName: string,
|
||||||
leadId: string,
|
leadId: string,
|
||||||
|
|
@ -534,9 +544,18 @@ export class TeamGraphAdapter {
|
||||||
const msgKey = TeamGraphAdapter.#getMessageParticleKey(msg);
|
const msgKey = TeamGraphAdapter.#getMessageParticleKey(msg);
|
||||||
this.#seenMessageIds.add(msgKey);
|
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;
|
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.
|
// Subsequent calls: only create particles for messages not yet seen.
|
||||||
for (const msg of ordered) {
|
for (const msg of ordered) {
|
||||||
const msgKey = TeamGraphAdapter.#getMessageParticleKey(msg);
|
const msgKey = TeamGraphAdapter.#getMessageParticleKey(msg);
|
||||||
|
|
@ -555,6 +574,42 @@ export class TeamGraphAdapter {
|
||||||
continue; // skip shutdown_approved, teammate_terminated, shutdown_request
|
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);
|
const edgeId = TeamGraphAdapter.#resolveMessageEdge(msg, teamName, leadId, leadName, edges);
|
||||||
if (!edgeId) continue;
|
if (!edgeId) continue;
|
||||||
|
|
||||||
|
|
@ -584,6 +639,17 @@ export class TeamGraphAdapter {
|
||||||
reverse: isFromTeammate,
|
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(
|
#buildCommentParticles(
|
||||||
|
|
@ -769,6 +835,52 @@ export class TeamGraphAdapter {
|
||||||
return `member:${teamName}:${name}`;
|
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(
|
static #buildParticleLabel(
|
||||||
text: string | undefined,
|
text: string | undefined,
|
||||||
kind: 'inbox' | 'comment',
|
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
|
// Process
|
||||||
return (
|
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">
|
<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