225 lines
7.4 KiB
TypeScript
225 lines
7.4 KiB
TypeScript
/**
|
|
* React hook bridge for TeamGraphAdapter class.
|
|
* Thin wrapper — instantiates the class adapter and calls adapt() with store data.
|
|
*/
|
|
|
|
import { useLayoutEffect, useMemo, useRef, useSyncExternalStore } from 'react';
|
|
|
|
import { getSnapshot, subscribe } from '@renderer/services/commentReadStorage';
|
|
import { useStore } from '@renderer/store';
|
|
import {
|
|
getCurrentProvisioningProgressForTeam,
|
|
isTeamGraphSlotPersistenceDisabled,
|
|
selectResolvedMembersForTeamName,
|
|
selectTeamDataForName,
|
|
selectTeamMessages,
|
|
} from '@renderer/store/slices/teamSlice';
|
|
import { buildTeamGraphDefaultLayoutSeed } from '@shared/utils/teamGraphDefaultLayout';
|
|
import { useShallow } from 'zustand/react/shallow';
|
|
|
|
import { TeamGraphAdapter } from '../adapters/TeamGraphAdapter';
|
|
|
|
import type { TeamGraphData } from '../adapters/TeamGraphAdapter';
|
|
import type { GraphDataPort } from '@claude-teams/agent-graph';
|
|
import type { InboxMessage, ResolvedTeamMember, ToolApprovalRequest } from '@shared/types/team';
|
|
|
|
interface UseTeamGraphAdapterOptions {
|
|
active?: boolean;
|
|
}
|
|
|
|
const EMPTY_MEMBERS: ResolvedTeamMember[] = [];
|
|
const EMPTY_MESSAGES: InboxMessage[] = [];
|
|
const EMPTY_PENDING_APPROVALS: ToolApprovalRequest[] = [];
|
|
const EMPTY_PENDING_APPROVAL_AGENTS = new Set<string>();
|
|
const EMPTY_COMMENT_READ_STATE: Record<string, unknown> = {};
|
|
|
|
function getEmptyCommentReadState(): Record<string, unknown> {
|
|
return EMPTY_COMMENT_READ_STATE;
|
|
}
|
|
|
|
function subscribeNoop(): () => void {
|
|
return () => undefined;
|
|
}
|
|
|
|
function emptyGraphData(teamName: string): GraphDataPort {
|
|
return { nodes: [], edges: [], particles: [], teamName, isAlive: false };
|
|
}
|
|
|
|
export function useTeamGraphAdapter(
|
|
teamName: string,
|
|
options?: UseTeamGraphAdapterOptions
|
|
): GraphDataPort {
|
|
const isActive = options?.active ?? true;
|
|
const adapterRef = useRef<TeamGraphAdapter>(TeamGraphAdapter.create());
|
|
const inactiveGraphData = useMemo(() => emptyGraphData(teamName), [teamName]);
|
|
const lastActiveGraphDataRef = useRef<GraphDataPort>(inactiveGraphData);
|
|
|
|
const {
|
|
teamSnapshot,
|
|
members,
|
|
messages,
|
|
spawnStatuses,
|
|
leadActivity,
|
|
leadContext,
|
|
pendingApprovals,
|
|
activeTools,
|
|
finishedVisible,
|
|
toolHistory,
|
|
provisioningProgress,
|
|
memberSpawnSnapshot,
|
|
graphLayoutMode,
|
|
gridOwnerOrder,
|
|
slotAssignments,
|
|
graphLayoutSession,
|
|
ensureTeamGraphSlotAssignments,
|
|
} = useStore(
|
|
useShallow((s) => ({
|
|
teamSnapshot: isActive ? selectTeamDataForName(s, teamName) : null,
|
|
members: isActive ? selectResolvedMembersForTeamName(s, teamName) : EMPTY_MEMBERS,
|
|
messages: isActive ? selectTeamMessages(s, teamName) : EMPTY_MESSAGES,
|
|
spawnStatuses: isActive && teamName ? s.memberSpawnStatusesByTeam[teamName] : undefined,
|
|
leadActivity: isActive && teamName ? s.leadActivityByTeam[teamName] : undefined,
|
|
leadContext: isActive && teamName ? s.leadContextByTeam[teamName] : undefined,
|
|
pendingApprovals: isActive ? s.pendingApprovals : EMPTY_PENDING_APPROVALS,
|
|
activeTools: isActive && teamName ? s.activeToolsByTeam[teamName] : undefined,
|
|
finishedVisible: isActive && teamName ? s.finishedVisibleByTeam[teamName] : undefined,
|
|
toolHistory: isActive && teamName ? s.toolHistoryByTeam[teamName] : undefined,
|
|
provisioningProgress:
|
|
isActive && teamName ? getCurrentProvisioningProgressForTeam(s, teamName) : null,
|
|
memberSpawnSnapshot:
|
|
isActive && teamName ? s.memberSpawnSnapshotsByTeam[teamName] : undefined,
|
|
graphLayoutMode: isActive && teamName ? s.graphLayoutModeByTeam[teamName] : undefined,
|
|
gridOwnerOrder: isActive && teamName ? s.gridOwnerOrderByTeam[teamName] : undefined,
|
|
slotAssignments: isActive && teamName ? s.slotAssignmentsByTeam[teamName] : undefined,
|
|
graphLayoutSession: isActive && teamName ? s.graphLayoutSessionByTeam[teamName] : undefined,
|
|
ensureTeamGraphSlotAssignments: s.ensureTeamGraphSlotAssignments,
|
|
}))
|
|
);
|
|
|
|
const pendingApprovalAgents = useMemo(() => {
|
|
if (!isActive) {
|
|
return EMPTY_PENDING_APPROVAL_AGENTS;
|
|
}
|
|
const agents = new Set<string>();
|
|
for (const a of pendingApprovals) {
|
|
if (a.teamName === teamName) {
|
|
agents.add(a.source);
|
|
}
|
|
}
|
|
return agents;
|
|
}, [isActive, pendingApprovals, teamName]);
|
|
|
|
const teamData = useMemo<TeamGraphData | null>(() => {
|
|
if (!teamSnapshot) {
|
|
return null;
|
|
}
|
|
return {
|
|
...teamSnapshot,
|
|
members,
|
|
messageFeed: messages,
|
|
};
|
|
}, [members, messages, teamSnapshot]);
|
|
|
|
const commentReadState = useSyncExternalStore(
|
|
isActive ? subscribe : subscribeNoop,
|
|
isActive ? getSnapshot : getEmptyCommentReadState
|
|
);
|
|
|
|
const effectiveSlotAssignments = useMemo(() => {
|
|
if (!teamData) {
|
|
return slotAssignments;
|
|
}
|
|
if (!isTeamGraphSlotPersistenceDisabled()) {
|
|
return slotAssignments;
|
|
}
|
|
if (graphLayoutSession?.mode === 'manual') {
|
|
return slotAssignments;
|
|
}
|
|
const defaultSeed = buildTeamGraphDefaultLayoutSeed(
|
|
teamData.members,
|
|
teamData.config.members ?? []
|
|
);
|
|
const defaultAssignments =
|
|
Object.keys(defaultSeed.assignments).length === 0 ? undefined : defaultSeed.assignments;
|
|
if (!slotAssignments) {
|
|
return defaultAssignments;
|
|
}
|
|
if (graphLayoutSession?.signature !== defaultSeed.signature) {
|
|
return defaultAssignments;
|
|
}
|
|
const visibleAssignmentKeys = defaultSeed.orderedVisibleOwnerIds.filter(
|
|
(stableOwnerId) => slotAssignments[stableOwnerId]
|
|
);
|
|
const hasExactVisibleDefaults =
|
|
visibleAssignmentKeys.length === Object.keys(defaultSeed.assignments).length &&
|
|
visibleAssignmentKeys.every((stableOwnerId) => {
|
|
const currentAssignment = slotAssignments[stableOwnerId];
|
|
const defaultAssignment = defaultSeed.assignments[stableOwnerId];
|
|
return (
|
|
currentAssignment?.ringIndex === defaultAssignment?.ringIndex &&
|
|
currentAssignment.sectorIndex === defaultAssignment.sectorIndex
|
|
);
|
|
});
|
|
return hasExactVisibleDefaults ? slotAssignments : defaultAssignments;
|
|
}, [graphLayoutSession, slotAssignments, teamData]);
|
|
|
|
useLayoutEffect(() => {
|
|
if (!isActive || !teamName || !teamData) {
|
|
return;
|
|
}
|
|
ensureTeamGraphSlotAssignments(teamName, teamData.members, teamData.config.members ?? []);
|
|
}, [ensureTeamGraphSlotAssignments, isActive, teamData, teamName]);
|
|
|
|
const activeGraphData = useMemo(() => {
|
|
if (!isActive) {
|
|
return null;
|
|
}
|
|
return adapterRef.current.adapt(
|
|
teamData,
|
|
teamName,
|
|
spawnStatuses,
|
|
leadActivity,
|
|
leadContext,
|
|
pendingApprovalAgents,
|
|
activeTools,
|
|
finishedVisible,
|
|
toolHistory,
|
|
commentReadState,
|
|
provisioningProgress,
|
|
memberSpawnSnapshot,
|
|
effectiveSlotAssignments,
|
|
graphLayoutMode ?? 'radial',
|
|
gridOwnerOrder
|
|
);
|
|
}, [
|
|
isActive,
|
|
teamData,
|
|
teamName,
|
|
spawnStatuses,
|
|
leadActivity,
|
|
leadContext,
|
|
pendingApprovalAgents,
|
|
activeTools,
|
|
finishedVisible,
|
|
toolHistory,
|
|
commentReadState,
|
|
provisioningProgress,
|
|
memberSpawnSnapshot,
|
|
effectiveSlotAssignments,
|
|
graphLayoutMode,
|
|
gridOwnerOrder,
|
|
]);
|
|
|
|
useLayoutEffect(() => {
|
|
if (activeGraphData) {
|
|
lastActiveGraphDataRef.current = activeGraphData;
|
|
}
|
|
}, [activeGraphData]);
|
|
|
|
if (!isActive) {
|
|
const lastActiveGraphData = lastActiveGraphDataRef.current;
|
|
return lastActiveGraphData.teamName === teamName ? lastActiveGraphData : inactiveGraphData;
|
|
}
|
|
|
|
return activeGraphData ?? inactiveGraphData;
|
|
}
|