From 0b97cc0794cff683c876939e9ec7089059ae2565 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 16 Apr 2026 14:57:42 +0300 Subject: [PATCH] fix(agent-graph): stabilize startup slots and launch hud --- .../renderer/adapters/TeamGraphAdapter.ts | 54 ++-- .../renderer/hooks/useTeamGraphAdapter.ts | 45 +++- .../renderer/ui/GraphProvisioningHud.tsx | 39 +-- src/renderer/store/slices/teamSlice.ts | 233 ++++++++++++------ src/shared/utils/teamGraphDefaultLayout.ts | 97 ++++++++ .../agent-graph/TeamGraphAdapter.test.ts | 59 +++++ test/renderer/store/teamSlice.test.ts | 80 +++++- 7 files changed, 451 insertions(+), 156 deletions(-) create mode 100644 src/shared/utils/teamGraphDefaultLayout.ts diff --git a/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts b/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts index 86b53c9f..9c9233b7 100644 --- a/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts +++ b/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts @@ -23,6 +23,7 @@ import { } from '@shared/utils/idleNotificationSemantics'; import { isInboxNoiseMessage } from '@shared/utils/inboxNoise'; import { isLeadMember } from '@shared/utils/leadDetection'; +import { buildOrderedVisibleTeamGraphOwnerIds } from '@shared/utils/teamGraphDefaultLayout'; import { buildInlineActivityEntries, @@ -247,10 +248,11 @@ export class TeamGraphAdapter { const visibleMemberByStableOwnerId = new Map( visibleMembers.map((member) => [getGraphStableOwnerId(member), member] as const) ); - const assignedStableOwnerIds = new Set(Object.keys(slotAssignments ?? {})); - const configStableOwnerIds = new Set( - (data.config.members ?? []).map((member) => getGraphStableOwnerId(member)) + const canonicalVisibleOwnerIds = buildOrderedVisibleTeamGraphOwnerIds( + data.members, + data.config.members ?? [] ); + const assignedStableOwnerIds = new Set(Object.keys(slotAssignments ?? {})); const pushMember = (member: TeamData['members'][number] | undefined): void => { if (!member) { @@ -264,44 +266,26 @@ export class TeamGraphAdapter { ownerOrder.push(nodeId); }; - const assignedVisibleMembersOutsideConfig = visibleMembers - .filter((member) => { - const stableOwnerId = getGraphStableOwnerId(member); - return ( - assignedStableOwnerIds.has(stableOwnerId) && !configStableOwnerIds.has(stableOwnerId) - ); - }) - .toSorted((left, right) => - getGraphStableOwnerId(left).localeCompare(getGraphStableOwnerId(right)) - ); - - for (const configMember of data.config.members ?? []) { - const stableOwnerId = getGraphStableOwnerId(configMember); + for (const stableOwnerId of canonicalVisibleOwnerIds) { + const visibleMember = visibleMemberByStableOwnerId.get(stableOwnerId); + if (!visibleMember) { + continue; + } if (!assignedStableOwnerIds.has(stableOwnerId)) { continue; } - pushMember(visibleMemberByStableOwnerId.get(stableOwnerId)); - } - - for (const member of assignedVisibleMembersOutsideConfig) { - pushMember(member); - } - - for (const configMember of data.config.members ?? []) { - const stableOwnerId = getGraphStableOwnerId(configMember); - if (assignedStableOwnerIds.has(stableOwnerId)) { - continue; - } - const visibleMember = visibleMemberByStableOwnerId.get(stableOwnerId); pushMember(visibleMember); } - const remainingMembers = visibleMembers.toSorted((left, right) => - getGraphStableOwnerId(left).localeCompare(getGraphStableOwnerId(right)) - ); - - for (const member of remainingMembers) { - pushMember(member); + for (const stableOwnerId of canonicalVisibleOwnerIds) { + const visibleMember = visibleMemberByStableOwnerId.get(stableOwnerId); + if (!visibleMember) { + continue; + } + if (assignedStableOwnerIds.has(stableOwnerId)) { + continue; + } + pushMember(visibleMember); } const normalizedAssignments: Record = {}; diff --git a/src/features/agent-graph/renderer/hooks/useTeamGraphAdapter.ts b/src/features/agent-graph/renderer/hooks/useTeamGraphAdapter.ts index 7f33931e..42545bb3 100644 --- a/src/features/agent-graph/renderer/hooks/useTeamGraphAdapter.ts +++ b/src/features/agent-graph/renderer/hooks/useTeamGraphAdapter.ts @@ -3,17 +3,16 @@ * Thin wrapper — instantiates the class adapter and calls adapt() with store data. */ -import { useEffect, useMemo, useRef, useSyncExternalStore } from 'react'; +import { useLayoutEffect, useMemo, useRef, useSyncExternalStore } from 'react'; import { getSnapshot, subscribe } from '@renderer/services/commentReadStorage'; import { useStore } from '@renderer/store'; import { - getDefaultTeamGraphSlotAssignmentsForMembers, getCurrentProvisioningProgressForTeam, - hasAppliedDefaultTeamGraphSlotAssignments, isTeamGraphSlotPersistenceDisabled, selectTeamDataForName, } from '@renderer/store/slices/teamSlice'; +import { buildTeamGraphDefaultLayoutSeed } from '@shared/utils/teamGraphDefaultLayout'; import { useShallow } from 'zustand/react/shallow'; import { TeamGraphAdapter } from '../adapters/TeamGraphAdapter'; @@ -35,6 +34,7 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort { provisioningProgress, memberSpawnSnapshot, slotAssignments, + graphLayoutSession, ensureTeamGraphSlotAssignments, } = useStore( useShallow((s) => ({ @@ -49,6 +49,7 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort { provisioningProgress: teamName ? getCurrentProvisioningProgressForTeam(s, teamName) : null, memberSpawnSnapshot: teamName ? s.memberSpawnSnapshotsByTeam[teamName] : undefined, slotAssignments: teamName ? s.slotAssignmentsByTeam[teamName] : undefined, + graphLayoutSession: teamName ? s.graphLayoutSessionByTeam[teamName] : undefined, ensureTeamGraphSlotAssignments: s.ensureTeamGraphSlotAssignments, })) ); @@ -72,18 +73,44 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort { if (!isTeamGraphSlotPersistenceDisabled()) { return slotAssignments; } - if (hasAppliedDefaultTeamGraphSlotAssignments(teamName)) { + if (graphLayoutSession?.mode === 'manual') { return slotAssignments; } - const defaults = getDefaultTeamGraphSlotAssignmentsForMembers(teamData.members); - return Object.keys(defaults).length === 0 ? undefined : defaults; - }, [slotAssignments, teamData, teamName]); + 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 && + defaultAssignment && + currentAssignment.ringIndex === defaultAssignment.ringIndex && + currentAssignment.sectorIndex === defaultAssignment.sectorIndex + ); + }); + return hasExactVisibleDefaults ? slotAssignments : defaultAssignments; + }, [graphLayoutSession, slotAssignments, teamData]); - useEffect(() => { + useLayoutEffect(() => { if (!teamName || !teamData) { return; } - ensureTeamGraphSlotAssignments(teamName, teamData.members); + ensureTeamGraphSlotAssignments(teamName, teamData.members, teamData.config.members ?? []); }, [ensureTeamGraphSlotAssignments, teamData, teamName]); return useMemo( diff --git a/src/features/agent-graph/renderer/ui/GraphProvisioningHud.tsx b/src/features/agent-graph/renderer/ui/GraphProvisioningHud.tsx index 1fe09548..06dc290a 100644 --- a/src/features/agent-graph/renderer/ui/GraphProvisioningHud.tsx +++ b/src/features/agent-graph/renderer/ui/GraphProvisioningHud.tsx @@ -11,7 +11,6 @@ import { DialogHeader, DialogTitle, } from '@renderer/components/ui/dialog'; -import { cn } from '@renderer/lib/utils'; import type { TeamProvisioningPresentation } from '@renderer/utils/teamProvisioningPresentation'; import type { CSSProperties } from 'react'; @@ -38,29 +37,6 @@ function shouldRenderLaunchHud(presentation: TeamProvisioningPresentation | null return presentation != null; } -function getToneClasses(tone: TeamProvisioningPresentation['compactTone']): { - border: string; -} { - switch (tone) { - case 'error': - return { - border: 'border-red-400/35 bg-[rgba(26,10,16,0.9)]', - }; - case 'warning': - return { - border: 'border-amber-400/35 bg-[rgba(31,18,8,0.9)]', - }; - case 'success': - return { - border: 'border-emerald-400/35 bg-[rgba(8,24,18,0.9)]', - }; - default: - return { - border: 'border-cyan-400/25 bg-[rgba(8,14,26,0.9)]', - }; - } -} - export interface GraphProvisioningHudProps { teamName: string; enabled?: boolean; @@ -74,7 +50,6 @@ export const GraphProvisioningHud = ({ const lastActiveStepRef = useRef(-1); const [detailsOpen, setDetailsOpen] = useState(false); const shouldRender = enabled && shouldRenderLaunchHud(presentation); - const tone = presentation ? getToneClasses(presentation.compactTone) : null; const errorStepIndex = presentation?.isFailed ? lastActiveStepRef.current >= 0 ? lastActiveStepRef.current @@ -97,7 +72,7 @@ export const GraphProvisioningHud = ({ return parts.join(' - ') || 'Open launch details'; }, [presentation?.compactDetail, presentation?.compactTitle]); - if (!shouldRender || !presentation || !tone) { + if (!shouldRender || !presentation) { return null; } @@ -105,22 +80,16 @@ export const GraphProvisioningHud = ({ <>