1461 lines
48 KiB
TypeScript
1461 lines
48 KiB
TypeScript
/**
|
|
* 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 {
|
|
isTeamTaskActivelyWorked,
|
|
isTeamTaskNeedsFixActionable,
|
|
} from '@shared/utils/teamTaskState';
|
|
|
|
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,
|
|
resolveTaskGraphColumn,
|
|
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[];
|
|
}
|
|
|
|
function toGraphLaunchVisualState(
|
|
visualState: ReturnType<typeof buildMemberLaunchPresentation>['launchVisualState'] | undefined
|
|
): GraphNode['launchVisualState'] {
|
|
if (!visualState) {
|
|
return undefined;
|
|
}
|
|
if (visualState === 'bootstrap_stalled') {
|
|
return 'runtime_pending';
|
|
}
|
|
if (visualState === 'starting_stale') {
|
|
return 'spawning';
|
|
}
|
|
return visualState;
|
|
}
|
|
|
|
export class TeamGraphAdapter {
|
|
// ─── ES #private fields ──────────────────────────────────────────────────
|
|
#lastTeamName = '';
|
|
readonly #seenRelated = new Set<string>();
|
|
readonly #seenMessageIds = new Set<string>();
|
|
#initialMessagesSeen = false;
|
|
#messageParticleCutoffMs: number | null = null;
|
|
readonly #seenCommentCounts = new Map<string, number>();
|
|
#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<string, MemberSpawnStatusEntry>,
|
|
leadActivity?: LeadActivityState,
|
|
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>,
|
|
provisioningProgress?: TeamProvisioningProgress | null,
|
|
memberSpawnSnapshot?: MemberSpawnStatusesSnapshot,
|
|
slotAssignments?: Record<string, GraphOwnerSlotAssignment>,
|
|
layoutMode: GraphLayoutMode = 'radial',
|
|
gridOwnerOrder?: readonly string[],
|
|
activeTaskLogActivity?: Record<string, true>
|
|
): 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,
|
|
activeTaskLogActivity
|
|
);
|
|
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<string, string> {
|
|
return buildGraphMemberNodeIdAliasMap(
|
|
teamName,
|
|
data.members.filter((member) => !isLeadMember(member))
|
|
);
|
|
}
|
|
|
|
static #buildLayoutPort(
|
|
data: TeamGraphData,
|
|
teamName: string,
|
|
slotAssignments?: Record<string, GraphOwnerSlotAssignment>,
|
|
mode: GraphLayoutMode = 'radial',
|
|
gridOwnerOrder?: readonly string[]
|
|
): GraphLayoutPort {
|
|
const ownerOrder: string[] = [];
|
|
const seenOwnerNodeIds = new Set<string>();
|
|
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<string>();
|
|
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<string, GraphOwnerSlotAssignment> = {};
|
|
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<string, number>();
|
|
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<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: TeamGraphData,
|
|
teamName: string,
|
|
leadName: string,
|
|
avatarMap: ReadonlyMap<string, string>,
|
|
pendingApprovalAgents?: Set<string>,
|
|
leadActivity?: LeadActivityState,
|
|
leadContext?: LeadContextUsage,
|
|
activeTools?: Record<string, Record<string, ActiveToolCall>>,
|
|
finishedVisible?: Record<string, Record<string, ActiveToolCall>>,
|
|
toolHistory?: Record<string, ActiveToolCall[]>,
|
|
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,
|
|
spawnBootstrapStalled: 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: toGraphLaunchVisualState(leadLaunchPresentation?.launchVisualState),
|
|
launchStatusLabel: leadLaunchPresentation?.launchStatusLabel ?? undefined,
|
|
contextUsage: percent != null ? Math.max(0, Math.min(1, percent / 100)) : undefined,
|
|
avatarUrl: leadMember
|
|
? resolveMemberAvatarUrl(leadMember, avatarMap, 96)
|
|
: agentAvatarUrl(leadName, 96),
|
|
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<string, string>,
|
|
avatarMap: ReadonlyMap<string, string>,
|
|
spawnStatuses?: Record<string, MemberSpawnStatusEntry>,
|
|
pendingApprovalAgents?: Set<string>,
|
|
activeTools?: Record<string, Record<string, ActiveToolCall>>,
|
|
finishedVisible?: Record<string, Record<string, ActiveToolCall>>,
|
|
toolHistory?: Record<string, ActiveToolCall[]>,
|
|
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 currentTask = member.currentTaskId
|
|
? data.tasks.find((task) => task.id === member.currentTaskId)
|
|
: undefined;
|
|
const displayableCurrentTask =
|
|
currentTask && isTeamTaskActivelyWorked(currentTask) ? currentTask : undefined;
|
|
const presentationMember =
|
|
member.currentTaskId && !displayableCurrentTask
|
|
? { ...member, currentTaskId: null }
|
|
: member;
|
|
const launchPresentation = buildMemberLaunchPresentation({
|
|
member: presentationMember,
|
|
spawnStatus: spawn?.status,
|
|
spawnLaunchState: spawn?.launchState,
|
|
spawnLivenessSource: spawn?.livenessSource,
|
|
spawnRuntimeAlive: spawn?.runtimeAlive,
|
|
spawnBootstrapConfirmed: spawn?.bootstrapConfirmed,
|
|
spawnBootstrapStalled: spawn?.bootstrapStalled,
|
|
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
|
|
? toGraphLaunchVisualState(launchPresentation.launchVisualState)
|
|
: undefined,
|
|
launchStatusLabel: isTeamVisualOnline
|
|
? (launchPresentation.launchStatusLabel ?? undefined)
|
|
: undefined,
|
|
avatarUrl: resolveMemberAvatarUrl(member, avatarMap, 96),
|
|
currentTaskId: displayableCurrentTask?.id,
|
|
currentTaskSubject: displayableCurrentTask?.subject,
|
|
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<string, unknown>,
|
|
memberNodeIdByAlias?: ReadonlyMap<string, string>,
|
|
leadId?: string,
|
|
leadName?: string,
|
|
activeTaskLogActivity?: Record<string, true>
|
|
): void {
|
|
const taskStateById = new Map<
|
|
string,
|
|
Pick<TeamGraphData['tasks'][number], 'status' | 'reviewState' | 'kanbanColumn' | 'deletedAt'>
|
|
>();
|
|
const taskDisplayIds = new Map<string, string>();
|
|
const memberColorByName = new Map<string, string>();
|
|
|
|
for (const t of data.tasks) {
|
|
taskStateById.set(t.id, {
|
|
status: t.status,
|
|
...(t.reviewState ? { reviewState: t.reviewState } : {}),
|
|
...(t.kanbanColumn ? { kanbanColumn: t.kanbanColumn } : {}),
|
|
...(t.deletedAt ? { deletedAt: t.deletedAt } : {}),
|
|
});
|
|
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 graphColumn = resolveTaskGraphColumn(task);
|
|
const taskStatus =
|
|
graphColumn === 'approved'
|
|
? 'completed'
|
|
: TeamGraphAdapter.#mapTaskStatusLiteral(task.status);
|
|
const reviewState =
|
|
graphColumn === 'approved'
|
|
? 'approved'
|
|
: graphColumn === 'review'
|
|
? isTeamTaskNeedsFixActionable(task)
|
|
? 'needsFix'
|
|
: 'review'
|
|
: 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<typeof getUnreadCount>[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:
|
|
graphColumn === 'approved' ? 'complete' : 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,
|
|
hasLiveTaskLogs: activeTaskLogActivity?.[task.id] === true ? true : 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<string>();
|
|
const blockingEdges = new Map<
|
|
string,
|
|
{
|
|
source: string;
|
|
target: string;
|
|
aggregateCount: number;
|
|
sourceTaskIds: Set<string>;
|
|
targetTaskIds: Set<string>;
|
|
}
|
|
>();
|
|
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<string, string>
|
|
): 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<string, string>
|
|
): { process: TeamProcess; ownerId: string }[] {
|
|
const selectedByOwnerId = new Map<string, TeamProcess>();
|
|
|
|
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<string>();
|
|
|
|
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<string, string>
|
|
): 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<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);
|
|
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<string, string>
|
|
): 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<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;
|
|
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<GraphNode, 'exceptionTone' | 'exceptionLabel'> | 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, string>
|
|
): 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, string>
|
|
): 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, string>
|
|
): 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'
|
|
);
|
|
}
|
|
}
|