933 lines
31 KiB
TypeScript
933 lines
31 KiB
TypeScript
/**
|
|
* TeamGraphAdapter — transforms Zustand TeamData → GraphDataPort.
|
|
*
|
|
* This is the ONLY file in this feature that imports from @renderer/store.
|
|
* If the project data model changes, ONLY this class needs updating.
|
|
*
|
|
* Class-based with ES #private fields, caching, and DI-ready constructor.
|
|
*/
|
|
|
|
import { getUnreadCount } from '@renderer/services/commentReadStorage';
|
|
import { formatTeamRuntimeSummary } from '@renderer/utils/teamRuntimeSummary';
|
|
import { agentAvatarUrl } from '@renderer/utils/memberHelpers';
|
|
import { stripCrossTeamPrefix } from '@shared/constants/crossTeam';
|
|
import {
|
|
getIdleGraphLabel,
|
|
classifyIdleNotificationText,
|
|
} from '@shared/utils/idleNotificationSemantics';
|
|
import { isInboxNoiseMessage } from '@shared/utils/inboxNoise';
|
|
import { isLeadMember } from '@shared/utils/leadDetection';
|
|
|
|
import type {
|
|
GraphDataPort,
|
|
GraphEdge,
|
|
GraphNode,
|
|
GraphNodeState,
|
|
GraphParticle,
|
|
} from '@claude-teams/agent-graph';
|
|
import type {
|
|
ActiveToolCall,
|
|
InboxMessage,
|
|
MemberSpawnStatusEntry,
|
|
TeamData,
|
|
} from '@shared/types/team';
|
|
import type { LeadContextUsage } from '@shared/types/team';
|
|
|
|
export class TeamGraphAdapter {
|
|
// ─── ES #private fields ──────────────────────────────────────────────────
|
|
#lastTeamName = '';
|
|
#lastDataHash = '';
|
|
#cachedResult: GraphDataPort = TeamGraphAdapter.#emptyResult('');
|
|
readonly #seenRelated = new Set<string>();
|
|
readonly #seenMessageIds = new Set<string>();
|
|
#initialMessagesSeen = false;
|
|
readonly #seenCommentCounts = new Map<string, number>();
|
|
#initialCommentsSeen = false;
|
|
|
|
// ─── 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.
|
|
* Returns cached result if inputs haven't changed (referential check).
|
|
*/
|
|
adapt(
|
|
teamData: TeamData | null,
|
|
teamName: string,
|
|
spawnStatuses?: Record<string, MemberSpawnStatusEntry>,
|
|
leadContext?: LeadContextUsage,
|
|
pendingApprovalAgents?: Set<string>,
|
|
activeTools?: Record<string, Record<string, ActiveToolCall>>,
|
|
finishedVisible?: Record<string, Record<string, ActiveToolCall>>,
|
|
toolHistory?: Record<string, ActiveToolCall[]>,
|
|
commentReadState?: Record<string, unknown>
|
|
): GraphDataPort {
|
|
if (teamData?.teamName !== teamName) {
|
|
return TeamGraphAdapter.#emptyResult(teamName);
|
|
}
|
|
|
|
// Simple hash for change detection (avoids full deep equality)
|
|
const totalComments = teamData.tasks.reduce((sum, t) => sum + (t.comments?.length ?? 0), 0);
|
|
const memberKey = teamData.members
|
|
.map(
|
|
(member) =>
|
|
`${member.name}:${member.status}:${member.currentTaskId ?? ''}:${member.role ?? ''}:${member.color ?? ''}:${member.agentType ?? ''}:${member.providerId ?? ''}:${member.model ?? ''}:${member.effort ?? ''}:${member.removedAt ?? ''}`
|
|
)
|
|
.sort()
|
|
.join('|');
|
|
const taskKey = teamData.tasks
|
|
.map(
|
|
(task) =>
|
|
`${task.id}:${task.status}:${task.owner ?? ''}:${task.reviewState ?? ''}:${task.displayId ?? ''}:${task.subject}:${task.updatedAt ?? ''}`
|
|
)
|
|
.sort()
|
|
.join('|');
|
|
const processKey = teamData.processes
|
|
.map(
|
|
(proc) =>
|
|
`${proc.id}:${proc.label}:${proc.registeredBy ?? ''}:${proc.url ?? ''}:${proc.stoppedAt ?? ''}`
|
|
)
|
|
.sort()
|
|
.join('|');
|
|
const messageKey = teamData.messages
|
|
.slice(0, 25)
|
|
.map((msg) => TeamGraphAdapter.#getMessageParticleKey(msg))
|
|
.join('|');
|
|
const commentKey = teamData.tasks
|
|
.map((task) => {
|
|
const comments = task.comments ?? [];
|
|
const tail = comments
|
|
.slice(Math.max(0, comments.length - 5))
|
|
.map((comment) => `${comment.id}:${comment.author}:${comment.createdAt}`)
|
|
.join(',');
|
|
return `${task.id}:${comments.length}:${tail}`;
|
|
})
|
|
.sort()
|
|
.join('|');
|
|
const approvalKey = pendingApprovalAgents?.size
|
|
? Array.from(pendingApprovalAgents).sort().join(',')
|
|
: '';
|
|
const activeToolKey = activeTools
|
|
? Object.entries(activeTools)
|
|
.flatMap(([memberName, tools]) =>
|
|
Object.values(tools).map(
|
|
(tool) =>
|
|
`${memberName}:${tool.toolUseId}:${tool.state}:${tool.toolName}:${tool.preview ?? ''}:${tool.resultPreview ?? ''}:${tool.startedAt}:${tool.finishedAt ?? ''}`
|
|
)
|
|
)
|
|
.sort()
|
|
.join('|')
|
|
: '';
|
|
const finishedVisibleKey = finishedVisible
|
|
? Object.entries(finishedVisible)
|
|
.flatMap(([memberName, tools]) =>
|
|
Object.values(tools).map(
|
|
(tool) =>
|
|
`${memberName}:${tool.toolUseId}:${tool.state}:${tool.toolName}:${tool.preview ?? ''}:${tool.resultPreview ?? ''}:${tool.startedAt}:${tool.finishedAt ?? ''}`
|
|
)
|
|
)
|
|
.sort()
|
|
.join('|')
|
|
: '';
|
|
const historyKey = toolHistory
|
|
? Object.entries(toolHistory)
|
|
.map(
|
|
([memberName, tools]) =>
|
|
`${memberName}:${tools
|
|
.slice(0, 3)
|
|
.map(
|
|
(tool) =>
|
|
`${tool.toolUseId}:${tool.state}:${tool.toolName}:${tool.preview ?? ''}:${tool.resultPreview ?? ''}:${tool.startedAt}:${tool.finishedAt ?? ''}`
|
|
)
|
|
.join(',')}`
|
|
)
|
|
.sort()
|
|
.join('|')
|
|
: '';
|
|
const hash = `${teamData.teamName}:${teamData.config.name ?? ''}:${teamData.config.color ?? ''}:${teamData.members.length}:${teamData.tasks.length}:${teamData.messages.length}:${teamData.processes.length}:${teamData.isAlive}:${leadContext?.percent}:${totalComments}:${memberKey}:${taskKey}:${processKey}:${messageKey}:${commentKey}:${approvalKey}:${activeToolKey}:${finishedVisibleKey}:${historyKey}:${commentReadState ? Object.keys(commentReadState).length : 0}`;
|
|
if (hash === this.#lastDataHash && teamName === this.#lastTeamName) {
|
|
return this.#cachedResult;
|
|
}
|
|
|
|
// Reset particle tracking when team changes
|
|
if (teamName !== this.#lastTeamName) {
|
|
this.#seenMessageIds.clear();
|
|
this.#initialMessagesSeen = false;
|
|
this.#seenCommentCounts.clear();
|
|
this.#initialCommentsSeen = false;
|
|
}
|
|
|
|
this.#lastTeamName = teamName;
|
|
this.#lastDataHash = hash;
|
|
this.#seenRelated.clear();
|
|
|
|
const nodes: GraphNode[] = [];
|
|
const edges: GraphEdge[] = [];
|
|
const particles: GraphParticle[] = [];
|
|
|
|
const leadId = `lead:${teamName}`;
|
|
const leadName = TeamGraphAdapter.#getLeadMemberName(teamData, teamName);
|
|
|
|
this.#buildLeadNode(
|
|
nodes,
|
|
leadId,
|
|
teamData,
|
|
teamName,
|
|
leadName,
|
|
leadContext,
|
|
activeTools,
|
|
finishedVisible,
|
|
toolHistory
|
|
);
|
|
this.#buildMemberNodes(
|
|
nodes,
|
|
edges,
|
|
leadId,
|
|
teamData,
|
|
teamName,
|
|
spawnStatuses,
|
|
pendingApprovalAgents,
|
|
activeTools,
|
|
finishedVisible,
|
|
toolHistory
|
|
);
|
|
this.#buildTaskNodes(nodes, edges, teamData, teamName, commentReadState);
|
|
this.#buildProcessNodes(nodes, edges, teamData, teamName);
|
|
this.#buildMessageParticles(
|
|
particles,
|
|
nodes,
|
|
teamData.messages,
|
|
teamName,
|
|
leadId,
|
|
leadName,
|
|
edges
|
|
);
|
|
this.#buildCommentParticles(particles, teamData, teamName, leadId, leadName, edges);
|
|
|
|
this.#cachedResult = {
|
|
nodes,
|
|
edges,
|
|
particles,
|
|
teamName,
|
|
teamColor: teamData.config.color ?? undefined,
|
|
isAlive: teamData.isAlive,
|
|
};
|
|
|
|
return this.#cachedResult;
|
|
}
|
|
|
|
// ─── Disposal ────────────────────────────────────────────────────────────
|
|
|
|
[Symbol.dispose](): void {
|
|
this.#cachedResult = TeamGraphAdapter.#emptyResult('');
|
|
this.#seenRelated.clear();
|
|
this.#seenMessageIds.clear();
|
|
this.#initialMessagesSeen = false;
|
|
this.#seenCommentCounts.clear();
|
|
this.#initialCommentsSeen = false;
|
|
this.#lastDataHash = '';
|
|
}
|
|
|
|
// ─── Private: node builders ──────────────────────────────────────────────
|
|
|
|
static #getLeadMemberName(data: TeamData, teamName: string): string {
|
|
return data.members.find((member) => isLeadMember(member))?.name ?? `${teamName}-lead`;
|
|
}
|
|
|
|
static #getRuntimeLabel(
|
|
providerId: TeamData['members'][number]['providerId'],
|
|
model: TeamData['members'][number]['model'],
|
|
effort: TeamData['members'][number]['effort']
|
|
): string | undefined {
|
|
return formatTeamRuntimeSummary(providerId, model, effort);
|
|
}
|
|
|
|
static #selectVisibleTool(
|
|
runningTools?: Record<string, ActiveToolCall>,
|
|
finishedTools?: Record<string, ActiveToolCall>
|
|
): 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: TeamData,
|
|
teamName: string,
|
|
leadName: string,
|
|
leadContext?: LeadContextUsage,
|
|
activeTools?: Record<string, Record<string, ActiveToolCall>>,
|
|
finishedVisible?: Record<string, Record<string, ActiveToolCall>>,
|
|
toolHistory?: Record<string, ActiveToolCall[]>
|
|
): void {
|
|
const percent = leadContext?.percent;
|
|
const leadMember = data.members.find((member) => member.name === leadName);
|
|
const activeTool = TeamGraphAdapter.#selectVisibleTool(
|
|
activeTools?.[leadName],
|
|
finishedVisible?.[leadName]
|
|
);
|
|
nodes.push({
|
|
id: leadId,
|
|
kind: 'lead',
|
|
label: data.config.name || teamName,
|
|
state: !data.isAlive
|
|
? 'idle'
|
|
: Object.keys(activeTools?.[leadName] ?? {}).length > 0
|
|
? 'tool_calling'
|
|
: 'active',
|
|
color: data.config.color ?? undefined,
|
|
runtimeLabel: TeamGraphAdapter.#getRuntimeLabel(
|
|
leadMember?.providerId,
|
|
leadMember?.model,
|
|
leadMember?.effort
|
|
),
|
|
contextUsage: percent != null ? Math.max(0, Math.min(1, percent / 100)) : undefined,
|
|
avatarUrl: agentAvatarUrl(leadName, 64),
|
|
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,
|
|
})),
|
|
domainRef: { kind: 'lead', teamName, memberName: leadName },
|
|
});
|
|
}
|
|
|
|
#buildMemberNodes(
|
|
nodes: GraphNode[],
|
|
edges: GraphEdge[],
|
|
leadId: string,
|
|
data: TeamData,
|
|
teamName: string,
|
|
spawnStatuses?: Record<string, MemberSpawnStatusEntry>,
|
|
pendingApprovalAgents?: Set<string>,
|
|
activeTools?: Record<string, Record<string, ActiveToolCall>>,
|
|
finishedVisible?: Record<string, Record<string, ActiveToolCall>>,
|
|
toolHistory?: Record<string, ActiveToolCall[]>
|
|
): void {
|
|
for (const member of data.members) {
|
|
if (member.removedAt) continue;
|
|
if (isLeadMember(member)) continue;
|
|
|
|
const memberId = `member:${teamName}:${member.name}`;
|
|
const spawn = spawnStatuses?.[member.name];
|
|
const activeTool = TeamGraphAdapter.#selectVisibleTool(
|
|
activeTools?.[member.name],
|
|
finishedVisible?.[member.name]
|
|
);
|
|
const hasRunningTool = Object.keys(activeTools?.[member.name] ?? {}).length > 0;
|
|
|
|
nodes.push({
|
|
id: memberId,
|
|
kind: 'member',
|
|
label: member.name,
|
|
state: hasRunningTool
|
|
? 'tool_calling'
|
|
: TeamGraphAdapter.#mapMemberStatus(member.status, spawn?.status),
|
|
color: member.color ?? undefined,
|
|
role: member.role ?? undefined,
|
|
runtimeLabel: TeamGraphAdapter.#getRuntimeLabel(
|
|
member.providerId,
|
|
member.model,
|
|
member.effort
|
|
),
|
|
spawnStatus: spawn?.status,
|
|
avatarUrl: agentAvatarUrl(member.name, 64),
|
|
currentTaskId: member.currentTaskId ?? undefined,
|
|
currentTaskSubject: member.currentTaskId
|
|
? data.tasks.find((t) => t.id === member.currentTaskId)?.subject
|
|
: undefined,
|
|
pendingApproval: pendingApprovalAgents?.has(member.name) ?? false,
|
|
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: TeamData,
|
|
teamName: string,
|
|
commentReadState?: Record<string, unknown>
|
|
): void {
|
|
// Build lookup tables for fast resolution
|
|
const completedTaskIds = new Set<string>();
|
|
const taskDisplayIds = new Map<string, string>();
|
|
for (const t of data.tasks) {
|
|
if (t.status === 'completed' || t.status === 'deleted') completedTaskIds.add(t.id);
|
|
taskDisplayIds.set(t.id, t.displayId ?? `#${t.id.slice(0, 6)}`);
|
|
}
|
|
|
|
for (const task of data.tasks) {
|
|
if (task.status === 'deleted') continue;
|
|
const taskId = `task:${teamName}:${task.id}`;
|
|
const ownerMemberId = task.owner ? `member:${teamName}:${task.owner}` : null;
|
|
|
|
// Task is blocked if any blockedBy task is still not completed
|
|
const isBlocked =
|
|
(task.blockedBy?.length ?? 0) > 0 &&
|
|
task.blockedBy!.some((id) => !completedTaskIds.has(id));
|
|
|
|
// Resolve display IDs for dependencies
|
|
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;
|
|
|
|
// Comment counts
|
|
const totalCommentCount = task.comments?.length ?? 0;
|
|
const unreadCommentCount = commentReadState
|
|
? getUnreadCount(
|
|
commentReadState as Parameters<typeof getUnreadCount>[0],
|
|
teamName,
|
|
task.id,
|
|
task.comments ?? []
|
|
)
|
|
: 0;
|
|
|
|
nodes.push({
|
|
id: taskId,
|
|
kind: 'task',
|
|
label: task.displayId ?? `#${task.id.slice(0, 6)}`,
|
|
sublabel: task.subject,
|
|
state: TeamGraphAdapter.#mapTaskStatus(task.status),
|
|
taskStatus: TeamGraphAdapter.#mapTaskStatusLiteral(task.status),
|
|
reviewState: TeamGraphAdapter.#mapReviewState(task.reviewState),
|
|
displayId: task.displayId ?? undefined,
|
|
ownerId: ownerMemberId,
|
|
needsClarification: task.needsClarification ?? null,
|
|
isBlocked,
|
|
blockedByDisplayIds,
|
|
blocksDisplayIds,
|
|
totalCommentCount: totalCommentCount > 0 ? totalCommentCount : undefined,
|
|
unreadCommentCount: unreadCommentCount > 0 ? unreadCommentCount : undefined,
|
|
domainRef: { kind: 'task', teamName, taskId: task.id },
|
|
});
|
|
|
|
if (ownerMemberId) {
|
|
edges.push({
|
|
id: `edge:own:${ownerMemberId}:${taskId}`,
|
|
source: ownerMemberId,
|
|
target: taskId,
|
|
type: 'ownership',
|
|
});
|
|
}
|
|
|
|
const seenBlockEdges = new Set<string>();
|
|
for (const blockedById of task.blockedBy ?? []) {
|
|
const edgeId = `edge:block:task:${teamName}:${blockedById}:${taskId}`;
|
|
if (seenBlockEdges.has(edgeId)) continue;
|
|
seenBlockEdges.add(edgeId);
|
|
edges.push({
|
|
id: edgeId,
|
|
source: `task:${teamName}:${blockedById}`,
|
|
target: taskId,
|
|
type: 'blocking',
|
|
});
|
|
}
|
|
|
|
for (const blocksId of task.blocks ?? []) {
|
|
const edgeId = `edge:block:${taskId}:task:${teamName}:${blocksId}`;
|
|
if (seenBlockEdges.has(edgeId)) continue;
|
|
seenBlockEdges.add(edgeId);
|
|
edges.push({
|
|
id: edgeId,
|
|
source: taskId,
|
|
target: `task:${teamName}:${blocksId}`,
|
|
type: 'blocking',
|
|
});
|
|
}
|
|
|
|
for (const relatedId of task.related ?? []) {
|
|
const key = [task.id, relatedId].sort().join(':');
|
|
if (this.#seenRelated.has(key)) continue;
|
|
this.#seenRelated.add(key);
|
|
edges.push({
|
|
id: `edge:rel:${key}`,
|
|
source: taskId,
|
|
target: `task:${teamName}:${relatedId}`,
|
|
type: 'related',
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
#buildProcessNodes(
|
|
nodes: GraphNode[],
|
|
edges: GraphEdge[],
|
|
data: TeamData,
|
|
teamName: string
|
|
): void {
|
|
for (const proc of data.processes) {
|
|
if (proc.stoppedAt) continue;
|
|
const procId = `process:${teamName}:${proc.id}`;
|
|
const ownerId = proc.registeredBy ? `member:${teamName}:${proc.registeredBy}` : null;
|
|
|
|
nodes.push({
|
|
id: procId,
|
|
kind: 'process',
|
|
label: proc.label,
|
|
state: 'active',
|
|
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',
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
#buildMessageParticles(
|
|
particles: GraphParticle[],
|
|
nodes: GraphNode[],
|
|
messages: readonly InboxMessage[],
|
|
teamName: string,
|
|
leadId: string,
|
|
leadName: string,
|
|
edges: GraphEdge[]
|
|
): 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;
|
|
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<string>();
|
|
|
|
// 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);
|
|
|
|
// 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,
|
|
reverse: !isIncoming, // ghost→lead edge: incoming = forward, sent = reverse
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const edgeId = TeamGraphAdapter.#resolveMessageEdge(msg, teamName, leadId, leadName, edges);
|
|
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 ?? '',
|
|
teamName,
|
|
leadId,
|
|
leadName
|
|
);
|
|
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,
|
|
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: TeamData,
|
|
teamName: string,
|
|
leadId: string,
|
|
leadName: string,
|
|
edges: GraphEdge[]
|
|
): 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;
|
|
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<string, string>();
|
|
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;
|
|
const authorNodeId = TeamGraphAdapter.#resolveParticipantId(
|
|
newComment.author,
|
|
teamName,
|
|
leadId,
|
|
leadName
|
|
);
|
|
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'),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
this.#seenCommentCounts.set(task.id, currentCount);
|
|
}
|
|
}
|
|
|
|
// ─── Static mappers ──────────────────────────────────────────────────────
|
|
|
|
static #mapMemberStatus(status: string, spawnStatus?: string): GraphNodeState {
|
|
if (spawnStatus === 'spawning') return 'thinking';
|
|
if (spawnStatus === 'error') return 'error';
|
|
if (spawnStatus === '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,
|
|
teamName: string,
|
|
leadId: string,
|
|
leadName: string,
|
|
edges: GraphEdge[]
|
|
): string | null {
|
|
const { from, to } = msg;
|
|
|
|
if (from && to) {
|
|
const fromId = TeamGraphAdapter.#resolveParticipantId(from, teamName, leadId, leadName);
|
|
const toId = TeamGraphAdapter.#resolveParticipantId(to, teamName, leadId, leadName);
|
|
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, teamName, leadId, leadName);
|
|
return (
|
|
edges.find(
|
|
(e) =>
|
|
(e.source === leadId && e.target === fromId) ||
|
|
(e.source === fromId && e.target === leadId)
|
|
)?.id ?? null
|
|
);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
static #resolveParticipantId(
|
|
name: string,
|
|
teamName: string,
|
|
leadId: string,
|
|
leadName?: string
|
|
): string {
|
|
const normalized = name.trim().toLowerCase();
|
|
if (normalized === 'user' || normalized === 'team-lead') return leadId;
|
|
if (leadName && normalized === leadName.trim().toLowerCase()) return leadId;
|
|
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',
|
|
max = 52
|
|
): string | undefined {
|
|
let normalized = text?.replace(/\s+/g, ' ').trim();
|
|
// Clean up raw task ID hashes like "#363e78de done|sent to review" → "done | sent to review"
|
|
if (normalized) {
|
|
normalized = normalized.replace(/#[a-f0-9]{6,}\s*/gi, '').trim();
|
|
// Clean pipe separators
|
|
normalized = normalized.replace(/\|/g, ' - ');
|
|
}
|
|
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 #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'
|
|
);
|
|
}
|
|
}
|