/** * TeamGraphAdapter — transforms store-backed team graph input → GraphDataPort. * * This adapter owns the graph projection from team runtime state into the * reusable package port model. Renderer hooks may still read store state, but * projection rules stay here so the mapping logic has one main reason to change. * * Class-based with ES #private fields and DI-ready constructor. */ import { getUnreadCount } from '@renderer/services/commentReadStorage'; import { agentAvatarUrl, buildMemberAvatarMap, buildMemberLaunchPresentation, getMemberRuntimeAdvisoryLabel, resolveMemberAvatarUrl, } from '@renderer/utils/memberHelpers'; import { buildTeamProvisioningPresentation } from '@renderer/utils/teamProvisioningPresentation'; import { formatTeamRuntimeSummary } from '@renderer/utils/teamRuntimeSummary'; import { stripCrossTeamPrefix } from '@shared/constants/crossTeam'; import { classifyIdleNotificationText, getIdleGraphLabel, } from '@shared/utils/idleNotificationSemantics'; import { isInboxNoiseMessage } from '@shared/utils/inboxNoise'; import { isLeadMember } from '@shared/utils/leadDetection'; import { buildOrderedVisibleTeamGraphOwnerIds } from '@shared/utils/teamGraphDefaultLayout'; import { buildInlineActivityEntries, getGraphLeadMemberName, } from '../../core/domain/buildInlineActivityEntries'; import { collapseOverflowStacksWithMeta } from '../../core/domain/collapseOverflowStacks'; import { buildGraphMemberNodeIdAliasMap, buildGraphMemberNodeIdForMember, getGraphStableOwnerId, GRAPH_STABLE_SLOT_LAYOUT_VERSION, } from '../../core/domain/graphOwnerIdentity'; import { isTaskBlocked, isTaskInReviewCycle, resolveTaskReviewer, } from '../../core/domain/taskGraphSemantics'; import type { GraphDataPort, GraphEdge, GraphLayoutMode, GraphLayoutPort, GraphNode, GraphNodeState, GraphOwnerSlotAssignment, GraphParticle, } from '@claude-teams/agent-graph'; import type { ActiveToolCall, InboxMessage, LeadActivityState, MemberSpawnStatusEntry, MemberSpawnStatusesSnapshot, ResolvedTeamMember, TeamProcess, TeamProvisioningProgress, TeamViewSnapshot, } from '@shared/types/team'; import type { LeadContextUsage } from '@shared/types/team'; export interface TeamGraphData extends TeamViewSnapshot { members: ResolvedTeamMember[]; messageFeed: InboxMessage[]; } export class TeamGraphAdapter { // ─── ES #private fields ────────────────────────────────────────────────── #lastTeamName = ''; readonly #seenRelated = new Set(); readonly #seenMessageIds = new Set(); #initialMessagesSeen = false; #messageParticleCutoffMs: number | null = null; readonly #seenCommentCounts = new Map(); #initialCommentsSeen = false; #commentParticleCutoffMs: number | null = null; // ─── Static factory ────────────────────────────────────────────────────── static create(): TeamGraphAdapter { return new TeamGraphAdapter(); } static #emptyResult(teamName: string): GraphDataPort { return { nodes: [], edges: [], particles: [], teamName, isAlive: false }; } // ─── Public API ────────────────────────────────────────────────────────── /** * Adapt team data into a GraphDataPort snapshot. */ adapt( teamData: TeamGraphData | null, teamName: string, spawnStatuses?: Record, leadActivity?: LeadActivityState, leadContext?: LeadContextUsage, pendingApprovalAgents?: Set, activeTools?: Record>, finishedVisible?: Record>, toolHistory?: Record, commentReadState?: Record, provisioningProgress?: TeamProvisioningProgress | null, memberSpawnSnapshot?: MemberSpawnStatusesSnapshot, slotAssignments?: Record, layoutMode: GraphLayoutMode = 'radial', gridOwnerOrder?: readonly string[] ): GraphDataPort { if (teamData?.teamName !== teamName) { return TeamGraphAdapter.#emptyResult(teamName); } const duplicateStableOwnerIds = TeamGraphAdapter.#collectDuplicateStableOwnerIds( teamData.members.filter((member) => !member.removedAt && !isLeadMember(member)) ); if (duplicateStableOwnerIds.length > 0) { console.error( `[agent-graph] duplicate stable owner ids in team=${teamName}: ${duplicateStableOwnerIds.join(', ')}` ); return TeamGraphAdapter.#emptyResult(teamName); } // Reset particle tracking when team changes if (teamName !== this.#lastTeamName) { this.#seenMessageIds.clear(); this.#initialMessagesSeen = false; this.#messageParticleCutoffMs = null; this.#seenCommentCounts.clear(); this.#initialCommentsSeen = false; this.#commentParticleCutoffMs = null; } this.#lastTeamName = teamName; this.#seenRelated.clear(); const nodes: GraphNode[] = []; const edges: GraphEdge[] = []; const particles: GraphParticle[] = []; const leadId = `lead:${teamName}`; const leadName = TeamGraphAdapter.#getLeadMemberName(teamData, teamName); const memberNodeIdByAlias = TeamGraphAdapter.#buildMemberNodeIdByAlias(teamData, teamName); const avatarMap = buildMemberAvatarMap(teamData.members); const provisioningPresentation = buildTeamProvisioningPresentation({ progress: provisioningProgress, members: teamData.members, memberSpawnStatuses: spawnStatuses, memberSpawnSnapshot, }); const isTeamProvisioning = provisioningPresentation?.isActive ?? false; const isLaunchSettling = provisioningPresentation?.hasMembersStillJoining ?? false; this.#buildLeadNode( nodes, leadId, teamData, teamName, leadName, avatarMap, pendingApprovalAgents, leadActivity, leadContext, activeTools, finishedVisible, toolHistory, isTeamProvisioning ); this.#buildMemberNodes( nodes, edges, leadId, teamData, teamName, memberNodeIdByAlias, avatarMap, spawnStatuses, pendingApprovalAgents, activeTools, finishedVisible, toolHistory, isTeamProvisioning, isLaunchSettling ); this.#buildTaskNodes( nodes, edges, teamData, teamName, commentReadState, memberNodeIdByAlias, leadId, leadName ); this.#buildProcessNodes(nodes, edges, teamData, teamName, memberNodeIdByAlias); this.#attachActivityFeeds(nodes, teamData, teamName, leadId, leadName); this.#buildMessageParticles( particles, nodes, teamData.messageFeed, teamName, leadId, leadName, edges, memberNodeIdByAlias ); this.#buildCommentParticles( particles, teamData, teamName, leadId, leadName, edges, memberNodeIdByAlias ); return { nodes, edges, particles, teamName, teamColor: teamData.config.color ?? undefined, isAlive: teamData.isAlive, layout: TeamGraphAdapter.#buildLayoutPort( teamData, teamName, slotAssignments, layoutMode, gridOwnerOrder ), }; } // ─── Disposal ──────────────────────────────────────────────────────────── [Symbol.dispose](): void { this.#seenRelated.clear(); this.#seenMessageIds.clear(); this.#initialMessagesSeen = false; this.#messageParticleCutoffMs = null; this.#seenCommentCounts.clear(); this.#initialCommentsSeen = false; this.#commentParticleCutoffMs = null; this.#lastTeamName = ''; } // ─── Private: node builders ────────────────────────────────────────────── static #getLeadMemberName(data: TeamGraphData, teamName: string): string { return getGraphLeadMemberName(data, teamName); } static #buildMemberNodeIdByAlias(data: TeamGraphData, teamName: string): Map { return buildGraphMemberNodeIdAliasMap( teamName, data.members.filter((member) => !isLeadMember(member)) ); } static #buildLayoutPort( data: TeamGraphData, teamName: string, slotAssignments?: Record, mode: GraphLayoutMode = 'radial', gridOwnerOrder?: readonly string[] ): GraphLayoutPort { const ownerOrder: string[] = []; const seenOwnerNodeIds = new Set(); const visibleMembers = data.members.filter( (member) => !member.removedAt && !isLeadMember(member) ); const visibleMemberByStableOwnerId = new Map( visibleMembers.map((member) => [getGraphStableOwnerId(member), member] as const) ); const canonicalVisibleOwnerIds = buildOrderedVisibleTeamGraphOwnerIds( data.members, data.config.members ?? [] ); const assignedStableOwnerIds = new Set(Object.keys(slotAssignments ?? {})); const pushMember = (member: TeamGraphData['members'][number] | undefined): void => { if (!member) { return; } const nodeId = buildGraphMemberNodeIdForMember(teamName, member); if (seenOwnerNodeIds.has(nodeId)) { return; } seenOwnerNodeIds.add(nodeId); ownerOrder.push(nodeId); }; if (mode === 'grid-under-lead') { const seenStableOwnerIds = new Set(); for (const stableOwnerId of gridOwnerOrder ?? []) { if (seenStableOwnerIds.has(stableOwnerId)) { continue; } seenStableOwnerIds.add(stableOwnerId); pushMember(visibleMemberByStableOwnerId.get(stableOwnerId)); } for (const stableOwnerId of canonicalVisibleOwnerIds) { if (seenStableOwnerIds.has(stableOwnerId)) { continue; } pushMember(visibleMemberByStableOwnerId.get(stableOwnerId)); } } else { for (const stableOwnerId of canonicalVisibleOwnerIds) { const visibleMember = visibleMemberByStableOwnerId.get(stableOwnerId); if (!visibleMember) { continue; } if (!assignedStableOwnerIds.has(stableOwnerId)) { continue; } pushMember(visibleMember); } for (const stableOwnerId of canonicalVisibleOwnerIds) { const visibleMember = visibleMemberByStableOwnerId.get(stableOwnerId); if (!visibleMember) { continue; } if (assignedStableOwnerIds.has(stableOwnerId)) { continue; } pushMember(visibleMember); } } const normalizedAssignments: Record = {}; for (const member of visibleMembers) { const stableOwnerId = getGraphStableOwnerId(member); const assignment = slotAssignments?.[stableOwnerId]; if (!assignment) { continue; } normalizedAssignments[buildGraphMemberNodeIdForMember(teamName, member)] = assignment; } return { version: GRAPH_STABLE_SLOT_LAYOUT_VERSION, mode, ownerOrder, slotAssignments: normalizedAssignments, }; } static #collectDuplicateStableOwnerIds( members: readonly TeamGraphData['members'][number][] ): string[] { const counts = new Map(); for (const member of members) { const stableOwnerId = getGraphStableOwnerId(member); counts.set(stableOwnerId, (counts.get(stableOwnerId) ?? 0) + 1); } return Array.from(counts.entries()) .filter(([, count]) => count > 1) .map(([stableOwnerId]) => stableOwnerId) .sort((left, right) => left.localeCompare(right)); } 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( providerId: ResolvedTeamMember['providerId'], model: ResolvedTeamMember['model'], effort: ResolvedTeamMember['effort'] ): string | undefined { return formatTeamRuntimeSummary(providerId, model, effort); } static #selectVisibleTool( runningTools?: Record, finishedTools?: Record ): ActiveToolCall | undefined { const newestRunning = Object.values(runningTools ?? {}).sort((a, b) => b.startedAt.localeCompare(a.startedAt) )[0]; if (newestRunning) return newestRunning; return Object.values(finishedTools ?? {}).sort((a, b) => (b.finishedAt ?? '').localeCompare(a.finishedAt ?? '') )[0]; } #buildLeadNode( nodes: GraphNode[], leadId: string, data: TeamGraphData, teamName: string, leadName: string, avatarMap: ReadonlyMap, pendingApprovalAgents?: Set, leadActivity?: LeadActivityState, leadContext?: LeadContextUsage, activeTools?: Record>, finishedVisible?: Record>, toolHistory?: Record, isTeamProvisioning = false ): void { const percent = leadContext?.contextUsedPercent; const leadMember = data.members.find((member) => member.name === leadName); const isTeamVisualOnline = data.isAlive || isTeamProvisioning; const activeTool = TeamGraphAdapter.#selectVisibleTool( activeTools?.[leadName], finishedVisible?.[leadName] ); const hasRunningTool = Object.keys(activeTools?.[leadName] ?? {}).length > 0; const pendingApproval = pendingApprovalAgents?.has(leadName) || pendingApprovalAgents?.has('lead') || false; const leadLaunchPresentation = leadMember ? buildMemberLaunchPresentation({ member: leadMember, spawnStatus: undefined, spawnLaunchState: undefined, spawnLivenessSource: undefined, spawnRuntimeAlive: undefined, runtimeAdvisory: leadMember.runtimeAdvisory, isLaunchSettling: false, isTeamAlive: data.isAlive, isTeamProvisioning, leadActivity, }) : null; const leadState = !isTeamVisualOnline || leadActivity === 'offline' ? 'terminated' : leadActivity === 'idle' ? 'idle' : hasRunningTool ? 'tool_calling' : 'active'; const leadException = !isTeamVisualOnline || leadActivity === 'offline' ? { exceptionTone: 'error' as const, exceptionLabel: 'offline' } : pendingApproval ? { exceptionTone: 'warning' as const, exceptionLabel: 'awaiting approval' } : undefined; nodes.push({ id: leadId, kind: 'lead', label: data.config.name || teamName, state: leadState, color: isTeamVisualOnline ? (data.config.color ?? undefined) : undefined, runtimeLabel: TeamGraphAdapter.#getRuntimeLabel( leadMember?.providerId, leadMember?.model, leadMember?.effort ), launchVisualState: leadLaunchPresentation?.launchVisualState ?? undefined, launchStatusLabel: leadLaunchPresentation?.launchStatusLabel ?? undefined, contextUsage: percent != null ? Math.max(0, Math.min(1, percent / 100)) : undefined, avatarUrl: leadMember ? resolveMemberAvatarUrl(leadMember, avatarMap, 64) : agentAvatarUrl(leadName, 64), pendingApproval, activeTool: activeTool ? { name: activeTool.toolName, preview: activeTool.preview, state: activeTool.state, startedAt: activeTool.startedAt, finishedAt: activeTool.finishedAt, resultPreview: activeTool.resultPreview, source: activeTool.source, } : undefined, recentTools: (toolHistory?.[leadName] ?? []) .filter((tool) => tool.state !== 'running' && !!tool.finishedAt) .slice(0, 5) .map((tool) => ({ name: tool.toolName, preview: tool.preview, state: tool.state === 'error' ? 'error' : 'complete', startedAt: tool.startedAt, finishedAt: tool.finishedAt!, resultPreview: tool.resultPreview, source: tool.source, })), ...leadException, domainRef: { kind: 'lead', teamName, memberName: leadName }, }); } #buildMemberNodes( nodes: GraphNode[], edges: GraphEdge[], leadId: string, data: TeamGraphData, teamName: string, memberNodeIdByAlias: ReadonlyMap, avatarMap: ReadonlyMap, spawnStatuses?: Record, pendingApprovalAgents?: Set, activeTools?: Record>, finishedVisible?: Record>, toolHistory?: Record, isTeamProvisioning = false, isLaunchSettling = false ): void { for (const member of data.members) { if (member.removedAt) continue; if (isLeadMember(member)) continue; const isTeamVisualOnline = data.isAlive || isTeamProvisioning; const memberId = memberNodeIdByAlias.get(member.name) ?? buildGraphMemberNodeIdForMember(teamName, member); const spawn = spawnStatuses?.[member.name]; const activeTool = TeamGraphAdapter.#selectVisibleTool( activeTools?.[member.name], finishedVisible?.[member.name] ); const hasRunningTool = Object.keys(activeTools?.[member.name] ?? {}).length > 0; const exception = TeamGraphAdapter.#buildMemberException( member.runtimeAdvisory, member.providerId, spawn, pendingApprovalAgents?.has(member.name) ?? false ); const launchPresentation = buildMemberLaunchPresentation({ member, spawnStatus: spawn?.status, spawnLaunchState: spawn?.launchState, spawnLivenessSource: spawn?.livenessSource, spawnRuntimeAlive: spawn?.runtimeAlive, runtimeAdvisory: member.runtimeAdvisory, isLaunchSettling, isTeamAlive: data.isAlive, isTeamProvisioning, }); nodes.push({ id: memberId, kind: 'member', label: member.name, state: !isTeamVisualOnline ? 'terminated' : hasRunningTool ? 'tool_calling' : TeamGraphAdapter.#mapMemberStatus(member.status, spawn), color: isTeamVisualOnline ? (member.color ?? undefined) : undefined, role: member.role ?? undefined, runtimeLabel: TeamGraphAdapter.#getRuntimeLabel( member.providerId, member.model, member.effort ), spawnStatus: isTeamVisualOnline ? spawn?.status : undefined, launchVisualState: isTeamVisualOnline ? (launchPresentation.launchVisualState ?? undefined) : undefined, launchStatusLabel: isTeamVisualOnline ? (launchPresentation.launchStatusLabel ?? undefined) : undefined, avatarUrl: resolveMemberAvatarUrl(member, avatarMap, 64), currentTaskId: member.currentTaskId ?? undefined, currentTaskSubject: member.currentTaskId ? data.tasks.find((t) => t.id === member.currentTaskId)?.subject : undefined, pendingApproval: pendingApprovalAgents?.has(member.name) ?? false, exceptionTone: exception?.exceptionTone, exceptionLabel: exception?.exceptionLabel, activeTool: activeTool ? { name: activeTool.toolName, preview: activeTool.preview, state: activeTool.state, startedAt: activeTool.startedAt, finishedAt: activeTool.finishedAt, resultPreview: activeTool.resultPreview, source: activeTool.source, } : undefined, recentTools: (toolHistory?.[member.name] ?? []) .filter((tool) => tool.state !== 'running' && !!tool.finishedAt) .slice(0, 5) .map((tool) => ({ name: tool.toolName, preview: tool.preview, state: tool.state === 'error' ? 'error' : 'complete', startedAt: tool.startedAt, finishedAt: tool.finishedAt!, resultPreview: tool.resultPreview, source: tool.source, })), domainRef: { kind: 'member', teamName, memberName: member.name }, }); edges.push({ id: `edge:parent:${leadId}:${memberId}`, source: leadId, target: memberId, type: 'parent-child', }); } } #buildTaskNodes( nodes: GraphNode[], edges: GraphEdge[], data: TeamGraphData, teamName: string, commentReadState?: Record, memberNodeIdByAlias?: ReadonlyMap, leadId?: string, leadName?: string ): void { const taskStateById = new Map>(); const taskDisplayIds = new Map(); const memberColorByName = new Map(); for (const t of data.tasks) { taskStateById.set(t.id, { status: t.status }); taskDisplayIds.set(t.id, t.displayId ?? `#${t.id.slice(0, 6)}`); } for (const member of data.members) { if (member.color) { memberColorByName.set(member.name, member.color); } } const rawTaskNodes: GraphNode[] = []; for (const task of data.tasks) { if (task.status === 'deleted') continue; const taskId = `task:${teamName}:${task.id}`; const ownerMemberId = leadId && memberNodeIdByAlias ? TeamGraphAdapter.#resolveTaskOwnerId(task.owner, leadId, leadName, memberNodeIdByAlias) : task.owner ? (memberNodeIdByAlias?.get(task.owner) ?? null) : null; const kanbanTaskState = data.kanbanState.tasks[task.id]; const reviewerName = resolveTaskReviewer(task, kanbanTaskState); const isReviewCycle = isTaskInReviewCycle(task); const taskStatus = TeamGraphAdapter.#mapTaskStatusLiteral(task.status); const reviewState = TeamGraphAdapter.#mapReviewState(task.reviewState); const blockedByDisplayIds = task.blockedBy?.length ? task.blockedBy.map((id) => taskDisplayIds.get(id) ?? `#${id.slice(0, 6)}`) : undefined; const blocksDisplayIds = task.blocks?.length ? task.blocks.map((id) => taskDisplayIds.get(id) ?? `#${id.slice(0, 6)}`) : undefined; const totalCommentCount = task.comments?.length ?? 0; const unreadCommentCount = commentReadState ? getUnreadCount( commentReadState as Parameters[0], teamName, task.id, task.comments ?? [] ) : 0; rawTaskNodes.push({ id: taskId, kind: 'task', label: task.displayId ?? `#${task.id.slice(0, 6)}`, sublabel: task.subject, state: TeamGraphAdapter.#mapTaskStatus(task.status), taskStatus, reviewState, reviewerName: isReviewCycle ? reviewerName : null, reviewMode: isReviewCycle ? (reviewerName ? 'assigned' : 'manual') : undefined, reviewerColor: reviewerName ? memberColorByName.get(reviewerName) : undefined, changePresence: task.changePresence === 'needs_attention' ? 'unknown' : task.changePresence, displayId: task.displayId ?? undefined, ownerId: ownerMemberId, needsClarification: task.needsClarification ?? null, isBlocked: isTaskBlocked(task, taskStateById), blockedByDisplayIds, blocksDisplayIds, totalCommentCount: totalCommentCount > 0 ? totalCommentCount : undefined, unreadCommentCount: unreadCommentCount > 0 ? unreadCommentCount : undefined, domainRef: { kind: 'task', teamName, taskId: task.id }, }); } const { visibleNodes: visibleTaskNodes, visibleNodeIdByTaskId } = collapseOverflowStacksWithMeta(rawTaskNodes, teamName, 5); const visibleTaskIds = new Set( visibleTaskNodes.flatMap((taskNode) => taskNode.domainRef.kind === 'task' ? [taskNode.domainRef.taskId] : [] ) ); nodes.push(...visibleTaskNodes); for (const taskNode of visibleTaskNodes) { if (!taskNode.ownerId) continue; edges.push({ id: `edge:own:${taskNode.ownerId}:${taskNode.id}`, source: taskNode.ownerId, target: taskNode.id, type: 'ownership', }); } const seenBlockingRelations = new Set(); const blockingEdges = new Map< string, { source: string; target: string; aggregateCount: number; sourceTaskIds: Set; targetTaskIds: Set; } >(); 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) { if (task.status === 'deleted') continue; const taskNodeId = `task:${teamName}:${task.id}`; for (const blockerId of task.blockedBy ?? []) { addBlockingRelation(blockerId, task.id); } for (const blockedId of task.blocks ?? []) { addBlockingRelation(task.id, blockedId); } if (!visibleTaskIds.has(task.id)) continue; for (const relatedId of task.related ?? []) { if (!visibleTaskIds.has(relatedId)) continue; const key = task.id.localeCompare(relatedId) <= 0 ? `${task.id}:${relatedId}` : `${relatedId}:${task.id}`; if (this.#seenRelated.has(key)) continue; this.#seenRelated.add(key); edges.push({ id: `edge:rel:${key}`, source: taskNodeId, target: `task:${teamName}:${relatedId}`, type: 'related', }); } } 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( nodes: GraphNode[], edges: GraphEdge[], data: TeamGraphData, teamName: string, memberNodeIdByAlias?: ReadonlyMap ): void { for (const { process: proc, ownerId } of TeamGraphAdapter.#selectRelevantProcesses( data.processes, memberNodeIdByAlias )) { const procId = `process:${teamName}:${proc.id}`; nodes.push({ id: procId, kind: 'process', label: proc.label, state: 'active', ownerId, processUrl: proc.url ?? undefined, processRegisteredBy: proc.registeredBy ?? undefined, processCommand: proc.command ?? undefined, processRegisteredAt: proc.registeredAt, domainRef: { kind: 'process', teamName, processId: proc.id }, }); if (ownerId) { edges.push({ id: `edge:proc:${ownerId}:${procId}`, source: ownerId, target: procId, type: 'ownership', }); } } } static #selectRelevantProcesses( processes: readonly TeamProcess[], memberNodeIdByAlias?: ReadonlyMap ): { process: TeamProcess; ownerId: string }[] { const selectedByOwnerId = new Map(); for (const process of processes) { const ownerId = process.registeredBy ? (memberNodeIdByAlias?.get(process.registeredBy) ?? null) : null; if (!ownerId) { continue; } const existing = selectedByOwnerId.get(ownerId); if (!existing || TeamGraphAdapter.#compareProcessPriority(process, existing) < 0) { selectedByOwnerId.set(ownerId, process); } } return Array.from(selectedByOwnerId.entries()).map(([ownerId, process]) => ({ process, ownerId, })); } static #compareProcessPriority(left: TeamProcess, right: TeamProcess): number { const leftRank = left.stoppedAt ? 1 : 0; const rightRank = right.stoppedAt ? 1 : 0; if (leftRank !== rightRank) { return leftRank - rightRank; } const leftTimestamp = left.stoppedAt ?? left.registeredAt; const rightTimestamp = right.stoppedAt ?? right.registeredAt; if (leftTimestamp !== rightTimestamp) { return rightTimestamp.localeCompare(leftTimestamp); } return left.id.localeCompare(right.id); } #attachActivityFeeds( nodes: GraphNode[], data: TeamGraphData, teamName: string, leadId: string, leadName: string ): void { const ownerNodeIds = new Set(); for (const node of nodes) { if (node.kind !== 'lead' && node.kind !== 'member') { continue; } ownerNodeIds.add(node.id); node.activityItems = []; node.activityOverflowCount = 0; } const entriesByOwnerNodeId = buildInlineActivityEntries({ data: { ...data, messages: data.messageFeed, }, teamName, leadId, leadName, ownerNodeIds, }); for (const node of nodes) { if (node.kind !== 'lead' && node.kind !== 'member') { continue; } const activityItems = (entriesByOwnerNodeId.get(node.id) ?? []).map( (entry) => entry.graphItem ); node.activityItems = activityItems; node.activityOverflowCount = Math.max(0, activityItems.length - 3); } } #buildMessageParticles( particles: GraphParticle[], nodes: GraphNode[], messages: readonly InboxMessage[], teamName: string, leadId: string, leadName: string, edges: GraphEdge[], memberNodeIdByAlias: ReadonlyMap ): void { const ordered = [...messages].reverse(); // First call: record all existing message IDs without creating particles. // This prevents old messages from spawning particles when the graph opens. if (!this.#initialMessagesSeen) { this.#initialMessagesSeen = true; this.#messageParticleCutoffMs = Date.now(); for (const msg of ordered) { 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(); // Subsequent calls: only create particles for messages not yet seen. for (const msg of ordered) { const msgKey = TeamGraphAdapter.#getMessageParticleKey(msg); if (this.#seenMessageIds.has(msgKey)) continue; this.#seenMessageIds.add(msgKey); if (TeamGraphAdapter.#isBeforeParticleCutoff(msg.timestamp, this.#messageParticleCutoffMs)) { continue; } // Skip comment notifications — #buildCommentParticles handles them with real text if (msg.summary?.startsWith('Comment on ')) continue; // Handle noise messages: idle uses semantic label, others (shutdown, terminated) skip entirely const msgText = msg.text ?? ''; const idleSemantic = classifyIdleNotificationText(msgText); if (!idleSemantic && isInboxNoiseMessage(msgText)) { 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, preview: getIdleGraphLabel(msg.text ?? '') ?? TeamGraphAdapter.#buildParticlePreview(msg.summary ?? cleanText), reverse: !isIncoming, // ghost→lead edge: incoming = forward, sent = reverse }); continue; } const edgeId = TeamGraphAdapter.#resolveMessageEdge( msg, leadId, leadName, edges, memberNodeIdByAlias ); if (!edgeId) continue; // Determine direction: messages FROM a teammate TO lead should reverse // (edges are always lead→member, but message goes member→lead) const fromId = TeamGraphAdapter.#resolveParticipantId( msg.from ?? '', leadId, leadName, memberNodeIdByAlias ); const isFromTeammate = fromId !== leadId; const particleLabel = getIdleGraphLabel(msgText) ?? TeamGraphAdapter.#buildParticleLabel(msg.summary ?? msg.text, 'inbox'); particles.push({ id: `particle:msg:${teamName}:${msgKey}`, edgeId, progress: 0, kind: 'inbox_message', color: msg.color ?? '#66ccff', label: particleLabel, preview: getIdleGraphLabel(msgText) ?? TeamGraphAdapter.#buildParticlePreview(msg.summary ?? msg.text), 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( particles: GraphParticle[], data: TeamGraphData, teamName: string, leadId: string, leadName: string, edges: GraphEdge[], memberNodeIdByAlias: ReadonlyMap ): void { // First call: record current comment counts without creating particles. // This prevents pre-existing comments from spawning particles when the graph opens. if (!this.#initialCommentsSeen) { this.#initialCommentsSeen = true; this.#commentParticleCutoffMs = Date.now(); for (const task of data.tasks) { this.#seenCommentCounts.set(task.id, task.comments?.length ?? 0); } return; } // Build a member color lookup for assigning particle colors const memberColors = new Map(); for (const member of data.members) { if (member.color) memberColors.set(member.name, member.color); } for (const task of data.tasks) { if (task.status === 'deleted') continue; const prevCount = this.#seenCommentCounts.get(task.id) ?? 0; const currentCount = task.comments?.length ?? 0; if (currentCount > prevCount) { for (let index = prevCount; index < currentCount; index += 1) { const newComment = task.comments?.[index]; if (!newComment) continue; if ( TeamGraphAdapter.#isBeforeParticleCutoff( newComment.createdAt, this.#commentParticleCutoffMs ) ) { continue; } const authorNodeId = TeamGraphAdapter.#resolveParticipantId( newComment.author, leadId, leadName, memberNodeIdByAlias ); const taskNodeId = `task:${teamName}:${task.id}`; const authorEdge = edges.find((e) => e.source === authorNodeId && e.target === taskNodeId) ?? edges.find((e) => e.source === taskNodeId && e.target === authorNodeId); const edgeId = authorEdge?.id ?? (() => { const syntheticEdgeId = `edge:msg:${authorNodeId}:${taskNodeId}`; if (!edges.some((edge) => edge.id === syntheticEdgeId)) { edges.push({ id: syntheticEdgeId, source: authorNodeId, target: taskNodeId, type: 'message', }); } return syntheticEdgeId; })(); if (authorNodeId) { particles.push({ id: `particle:comment:${teamName}:${task.id}:${index + 1}`, edgeId, progress: 0, kind: 'task_comment', color: memberColors.get(newComment.author) ?? '#cc88ff', label: TeamGraphAdapter.#buildParticleLabel(newComment.text, 'comment'), preview: TeamGraphAdapter.#buildParticlePreview(newComment.text), }); } } } this.#seenCommentCounts.set(task.id, currentCount); } } // ─── Static mappers ────────────────────────────────────────────────────── static #buildBlockingEdgeId(sourceNodeId: string, targetNodeId: string): string { return `edge:block:${sourceNodeId}:${targetNodeId}`; } static #buildMemberException( runtimeAdvisory: ResolvedTeamMember['runtimeAdvisory'], providerId: ResolvedTeamMember['providerId'], spawn: MemberSpawnStatusEntry | undefined, pendingApproval: boolean ): Pick | undefined { if (spawn?.launchState === 'failed_to_start' || spawn?.status === 'error') { return { exceptionTone: 'error', exceptionLabel: 'spawn failed' }; } if (pendingApproval || spawn?.launchState === 'runtime_pending_permission') { return { exceptionTone: 'warning', exceptionLabel: 'awaiting approval' }; } if (spawn?.status === 'waiting' || spawn?.status === 'spawning') { return { exceptionTone: 'warning', exceptionLabel: 'starting' }; } const runtimeAdvisoryLabel = getMemberRuntimeAdvisoryLabel(runtimeAdvisory, providerId); if (runtimeAdvisoryLabel) { return { exceptionTone: 'warning', exceptionLabel: runtimeAdvisoryLabel, }; } return undefined; } static #mapMemberStatus(status: string, spawn?: MemberSpawnStatusEntry): GraphNodeState { if (spawn?.launchState === 'runtime_pending_permission') return 'waiting'; if (spawn?.status === 'spawning') return 'thinking'; if (spawn?.status === 'error') return 'error'; if (spawn?.status === 'waiting') return 'waiting'; switch (status) { case 'active': return 'active'; case 'idle': return 'idle'; case 'terminated': return 'terminated'; default: return 'idle'; } } static #mapTaskStatus(status: string): GraphNodeState { switch (status) { case 'pending': return 'waiting'; case 'in_progress': return 'active'; case 'completed': return 'complete'; default: return 'idle'; } } static #mapTaskStatusLiteral( status: string ): 'pending' | 'in_progress' | 'completed' | 'deleted' { switch (status) { case 'pending': return 'pending'; case 'in_progress': return 'in_progress'; case 'completed': return 'completed'; case 'deleted': return 'deleted'; default: return 'pending'; } } static #mapReviewState(state: string | undefined): 'none' | 'review' | 'needsFix' | 'approved' { switch (state) { case 'review': return 'review'; case 'needsFix': return 'needsFix'; case 'approved': return 'approved'; default: return 'none'; } } static #resolveMessageEdge( msg: InboxMessage, leadId: string, leadName: string, edges: GraphEdge[], memberNodeIdByAlias: ReadonlyMap ): string | null { const { from, to } = msg; if (from && to) { const fromId = TeamGraphAdapter.#resolveParticipantId( from, leadId, leadName, memberNodeIdByAlias ); const toId = TeamGraphAdapter.#resolveParticipantId( to, leadId, leadName, memberNodeIdByAlias ); return ( edges.find((e) => e.source === fromId && e.target === toId)?.id ?? edges.find((e) => e.source === toId && e.target === fromId)?.id ?? null ); } if (from && !to) { const fromId = TeamGraphAdapter.#resolveParticipantId( from, leadId, leadName, memberNodeIdByAlias ); return ( edges.find( (e) => (e.source === leadId && e.target === fromId) || (e.source === fromId && e.target === leadId) )?.id ?? null ); } return null; } static #resolveParticipantId( name: string, leadId: string, leadName: string | undefined, memberNodeIdByAlias: ReadonlyMap ): string { const normalized = name.trim().toLowerCase(); if (normalized === 'user' || normalized === 'team-lead') return leadId; if (normalized === leadName?.trim().toLowerCase()) return leadId; return memberNodeIdByAlias.get(name) ?? leadId; } static #resolveTaskOwnerId( ownerName: string | null | undefined, leadId: string, leadName: string | undefined, memberNodeIdByAlias: ReadonlyMap ): string | null { if (!ownerName?.trim()) { return null; } const normalized = ownerName.trim().toLowerCase(); if (normalized === 'user' || normalized === 'team-lead') { return leadId; } if (normalized === leadName?.trim().toLowerCase()) { return leadId; } return memberNodeIdByAlias.get(ownerName) ?? null; } /** 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', max = 52 ): string | undefined { const normalized = TeamGraphAdapter.#normalizeParticleText(text); const prefix = kind === 'comment' ? '\u{1F4AC}' : '\u{2709}'; if (!normalized) return prefix; const clipped = normalized.length > max ? `${normalized.slice(0, Math.max(0, max - 1)).trimEnd()}\u2026` : normalized; return `${prefix} ${clipped}`; } static #buildParticlePreview(text: string | undefined, max = 180): string | undefined { const normalized = TeamGraphAdapter.#normalizeParticleText(text); if (!normalized) return undefined; return normalized.length > max ? `${normalized.slice(0, Math.max(0, max - 1)).trimEnd()}\u2026` : normalized; } static #normalizeParticleText(text: string | undefined): string | undefined { let normalized = text?.replace(/\s+/g, ' ').trim(); if (!normalized) return normalized; normalized = normalized.replace(/#[a-f0-9]{6,}\s*/gi, '').trim(); normalized = normalized.replace(/\|/g, ' - '); return normalized; } static #getMessageParticleKey(msg: InboxMessage): string { if (msg.messageId && msg.messageId.trim().length > 0) { return msg.messageId; } return [msg.timestamp, msg.from ?? '', msg.to ?? '', msg.summary ?? '', msg.text ?? ''].join( '\u0000' ); } }