fix(agent-graph): stabilize startup slots and launch hud

This commit is contained in:
777genius 2026-04-16 14:57:42 +03:00
parent 19463edfc9
commit 0b97cc0794
7 changed files with 451 additions and 156 deletions

View file

@ -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<string, GraphOwnerSlotAssignment> = {};

View file

@ -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(

View file

@ -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 = ({
<>
<button
type="button"
className={cn(
'w-full rounded-xl border px-3 py-2 text-left text-slate-100 shadow-[0_14px_34px_rgba(5,5,16,0.24)] backdrop-blur-xl transition-colors hover:bg-[rgba(12,18,32,0.96)]',
tone.border
)}
className="focus-visible:ring-white/18 w-full rounded-xl bg-transparent px-1 py-0.5 text-left text-slate-100 transition-opacity hover:opacity-95 focus-visible:outline-none focus-visible:ring-1"
onClick={() => setDetailsOpen(true)}
aria-label={ariaLabel}
>
<div
className="overflow-hidden rounded-lg border border-white/10 bg-[rgba(4,10,20,0.54)] px-2.5 py-1.5"
style={HUD_STEPPER_STYLE}
>
<div className="px-1 py-0.5" style={HUD_STEPPER_STYLE}>
<StepProgressBar
steps={MINI_STEPS}
currentIndex={presentation.currentStepIndex}
errorIndex={errorStepIndex}
className="w-full origin-top scale-[0.9]"
className="w-full origin-top scale-[0.88]"
/>
</div>
<span className="sr-only">{ariaLabel}</span>

View file

@ -14,6 +14,7 @@ import { isLeadMember } from '@shared/utils/leadDetection';
import { createLogger } from '@shared/utils/logger';
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import { buildTeamGraphDefaultLayoutSeed } from '@shared/utils/teamGraphDefaultLayout';
import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId';
import { getWorktreeNavigationState } from '../utils/stateResetHelpers';
@ -83,33 +84,20 @@ const teamRefreshBurstDiagnostics = new Map<
{ windowStartedAt: number; count: number; lastWarnAt: number }
>();
const memberSpawnUiEqualLastWarnAtByTeam = new Map<string, number>();
const sessionDefaultGraphSlotAssignmentsAppliedByTeam = new Set<string>();
interface RefreshTeamDataOptions {
withDedup?: boolean;
}
type TeamGraphSlotAssignments = Record<string, GraphOwnerSlotAssignment>;
type TeamGraphMemberSeedInput = Pick<TeamData['members'][number], 'name' | 'agentId' | 'removedAt'>;
const SMALL_TEAM_CARDINAL_SLOT_PRESETS: ReadonlyArray<ReadonlyArray<GraphOwnerSlotAssignment>> = [
[],
[{ ringIndex: 0, sectorIndex: 0 }],
[
{ ringIndex: 0, sectorIndex: 0 },
{ ringIndex: 0, sectorIndex: 1 },
],
[
{ ringIndex: 0, sectorIndex: 0 },
{ ringIndex: 0, sectorIndex: 1 },
{ ringIndex: 0, sectorIndex: 2 },
],
[
{ ringIndex: 0, sectorIndex: 0 },
{ ringIndex: 0, sectorIndex: 1 },
{ ringIndex: 0, sectorIndex: 2 },
{ ringIndex: 0, sectorIndex: 3 },
],
];
type TeamGraphConfigMemberSeedInput = Pick<
NonNullable<TeamData['config']['members']>[number],
'name' | 'agentId' | 'removedAt'
>;
type TeamGraphLayoutSessionState = {
mode: 'default' | 'manual';
signature: string | null;
};
export function isTeamDataRefreshPending(teamName: string): boolean {
return (
@ -131,7 +119,6 @@ export function __resetTeamSliceModuleStateForTests(): void {
memberSpawnStatusesIpcBackoffUntilByTeam.clear();
teamRefreshBurstDiagnostics.clear();
memberSpawnUiEqualLastWarnAtByTeam.clear();
sessionDefaultGraphSlotAssignmentsAppliedByTeam.clear();
}
function nowIso(): string {
@ -997,14 +984,18 @@ function migrateStableSlotAssignmentsForMembers(
function seedStableSlotAssignmentsForMembers(
assignments: TeamGraphSlotAssignments,
members: readonly TeamGraphMemberSeedInput[]
members: readonly TeamGraphMemberSeedInput[],
configMembers: readonly TeamGraphConfigMemberSeedInput[] = []
): { assignments: TeamGraphSlotAssignments; changed: boolean } {
const visibleMembers = members.filter((member) => !member.removedAt && !isLeadMember(member));
if (visibleMembers.length === 0 || visibleMembers.length > 4) {
const defaultSeed = buildTeamGraphDefaultLayoutSeed(members, configMembers);
if (
defaultSeed.orderedVisibleOwnerIds.length === 0 ||
Object.keys(defaultSeed.assignments).length === 0
) {
return { assignments, changed: false };
}
const visibleStableOwnerIds = visibleMembers.map((member) => getStableTeamOwnerId(member));
const visibleStableOwnerIds = defaultSeed.orderedVisibleOwnerIds;
const hasAnyVisibleAssignments = visibleStableOwnerIds.some(
(stableOwnerId) => assignments[stableOwnerId] != null
);
@ -1012,14 +1003,9 @@ function seedStableSlotAssignmentsForMembers(
return { assignments, changed: false };
}
const preset = SMALL_TEAM_CARDINAL_SLOT_PRESETS[visibleMembers.length];
if (!preset || preset.length !== visibleMembers.length) {
return { assignments, changed: false };
}
const nextAssignments: TeamGraphSlotAssignments = { ...assignments };
visibleMembers.forEach((member, index) => {
nextAssignments[getStableTeamOwnerId(member)] = preset[index]!;
visibleStableOwnerIds.forEach((stableOwnerId) => {
nextAssignments[stableOwnerId] = defaultSeed.assignments[stableOwnerId]!;
});
return { assignments: nextAssignments, changed: true };
@ -1049,20 +1035,47 @@ function areTeamGraphSlotAssignmentsEqual(
return true;
}
export function getDefaultTeamGraphSlotAssignmentsForMembers(
members: readonly TeamGraphMemberSeedInput[]
function normalizeTeamGraphSlotAssignmentsForVisibleOwners(
assignments: TeamGraphSlotAssignments | undefined,
visibleOwnerIds: readonly string[]
): TeamGraphSlotAssignments {
return seedStableSlotAssignmentsForMembers({}, members).assignments;
if (visibleOwnerIds.length === 0 || !assignments) {
return {};
}
const normalizedAssignments: TeamGraphSlotAssignments = {};
for (const stableOwnerId of visibleOwnerIds) {
const assignment = assignments[stableOwnerId];
if (!assignment) {
continue;
}
normalizedAssignments[stableOwnerId] = assignment;
}
return normalizedAssignments;
}
function pruneTeamGraphSlotAssignmentsForVisibleOwners(
assignments: TeamGraphSlotAssignments | undefined,
visibleOwnerIds: readonly string[]
): TeamGraphSlotAssignments | undefined {
const normalizedAssignments = normalizeTeamGraphSlotAssignmentsForVisibleOwners(
assignments,
visibleOwnerIds
);
return Object.keys(normalizedAssignments).length > 0 ? normalizedAssignments : undefined;
}
export function getDefaultTeamGraphSlotAssignmentsForMembers(
members: readonly TeamGraphMemberSeedInput[],
configMembers: readonly TeamGraphConfigMemberSeedInput[] = []
): TeamGraphSlotAssignments {
return buildTeamGraphDefaultLayoutSeed(members, configMembers).assignments;
}
export function isTeamGraphSlotPersistenceDisabled(): boolean {
return DISABLE_PERSISTED_TEAM_GRAPH_SLOT_ASSIGNMENTS;
}
export function hasAppliedDefaultTeamGraphSlotAssignments(teamName: string): boolean {
return sessionDefaultGraphSlotAssignmentsAppliedByTeam.has(teamName);
}
function isVisibleInActiveTeamSurface(
state: Pick<AppState, 'paneLayout'>,
teamName: string | null | undefined
@ -1126,6 +1139,7 @@ export interface TeamSlice {
teamDataCacheByName: Record<string, TeamData>;
slotLayoutVersion: string;
slotAssignmentsByTeam: Record<string, TeamGraphSlotAssignments>;
graphLayoutSessionByTeam: Record<string, TeamGraphLayoutSessionState>;
selectedTeamLoading: boolean;
selectedTeamLoadNonce: number;
selectedTeamError: string | null;
@ -1170,7 +1184,8 @@ export interface TeamSlice {
clearKanbanFilter: () => void;
ensureTeamGraphSlotAssignments: (
teamName: string,
members: readonly TeamGraphMemberSeedInput[]
members: readonly TeamGraphMemberSeedInput[],
configMembers?: readonly TeamGraphConfigMemberSeedInput[]
) => void;
setTeamGraphOwnerSlotAssignment: (
teamName: string,
@ -1443,6 +1458,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
teamDataCacheByName: {},
slotLayoutVersion: GRAPH_STABLE_SLOT_LAYOUT_VERSION,
slotAssignmentsByTeam: {},
graphLayoutSessionByTeam: {},
selectedTeamLoading: false,
selectedTeamLoadNonce: 0,
selectedTeamError: null,
@ -1862,33 +1878,72 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
set({ kanbanFilterQuery: null });
},
ensureTeamGraphSlotAssignments: (teamName, members) => {
ensureTeamGraphSlotAssignments: (teamName, members, configMembers = []) => {
set((state) => {
const nextState: Partial<TeamSlice> = {};
let changed = false;
let nextSlotAssignmentsByTeam = state.slotAssignmentsByTeam;
let nextGraphLayoutSessionByTeam = state.graphLayoutSessionByTeam;
if (state.slotLayoutVersion !== GRAPH_STABLE_SLOT_LAYOUT_VERSION) {
nextState.slotLayoutVersion = GRAPH_STABLE_SLOT_LAYOUT_VERSION;
nextSlotAssignmentsByTeam = {};
sessionDefaultGraphSlotAssignmentsAppliedByTeam.clear();
nextGraphLayoutSessionByTeam = {};
changed = true;
}
const defaultSeed = buildTeamGraphDefaultLayoutSeed(members, configMembers);
const visibleAssignments = pruneTeamGraphSlotAssignmentsForVisibleOwners(
nextSlotAssignmentsByTeam[teamName],
defaultSeed.orderedVisibleOwnerIds
);
const currentSession = nextGraphLayoutSessionByTeam[teamName];
if (DISABLE_PERSISTED_TEAM_GRAPH_SLOT_ASSIGNMENTS) {
if (!sessionDefaultGraphSlotAssignmentsAppliedByTeam.has(teamName)) {
const currentAssignments = nextSlotAssignmentsByTeam[teamName];
const defaultAssignments = getDefaultTeamGraphSlotAssignmentsForMembers(members);
if (!areTeamGraphSlotAssignmentsEqual(currentAssignments, defaultAssignments)) {
if (currentSession?.mode === 'manual') {
if (
!areTeamGraphSlotAssignmentsEqual(
nextSlotAssignmentsByTeam[teamName],
visibleAssignments
)
) {
nextSlotAssignmentsByTeam = { ...nextSlotAssignmentsByTeam };
if (Object.keys(defaultAssignments).length === 0) {
delete nextSlotAssignmentsByTeam[teamName];
if (visibleAssignments) {
nextSlotAssignmentsByTeam[teamName] = visibleAssignments;
} else {
nextSlotAssignmentsByTeam[teamName] = defaultAssignments;
delete nextSlotAssignmentsByTeam[teamName];
}
changed = true;
}
sessionDefaultGraphSlotAssignmentsAppliedByTeam.add(teamName);
} else {
if (
!areTeamGraphSlotAssignmentsEqual(
nextSlotAssignmentsByTeam[teamName],
visibleAssignments
) ||
!areTeamGraphSlotAssignmentsEqual(visibleAssignments, defaultSeed.assignments)
) {
nextSlotAssignmentsByTeam = { ...nextSlotAssignmentsByTeam };
if (Object.keys(defaultSeed.assignments).length === 0) {
delete nextSlotAssignmentsByTeam[teamName];
} else {
nextSlotAssignmentsByTeam[teamName] = defaultSeed.assignments;
}
changed = true;
}
if (
currentSession?.mode !== 'default' ||
currentSession?.signature !== defaultSeed.signature
) {
nextGraphLayoutSessionByTeam = {
...nextGraphLayoutSessionByTeam,
[teamName]: {
mode: 'default',
signature: defaultSeed.signature,
},
};
changed = true;
}
}
if (!changed) {
@ -1896,12 +1951,17 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
}
nextState.slotAssignmentsByTeam = nextSlotAssignmentsByTeam;
nextState.graphLayoutSessionByTeam = nextGraphLayoutSessionByTeam;
return nextState;
}
const currentAssignments = nextSlotAssignmentsByTeam[teamName];
const migrated = migrateStableSlotAssignmentsForMembers(currentAssignments, members);
const seeded = seedStableSlotAssignmentsForMembers(migrated.assignments, members);
const seeded = seedStableSlotAssignmentsForMembers(
migrated.assignments,
members,
configMembers
);
if (migrated.changed || seeded.changed) {
nextSlotAssignmentsByTeam = {
...nextSlotAssignmentsByTeam,
@ -1915,6 +1975,9 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
}
nextState.slotAssignmentsByTeam = nextSlotAssignmentsByTeam;
if (nextGraphLayoutSessionByTeam !== state.graphLayoutSessionByTeam) {
nextState.graphLayoutSessionByTeam = nextGraphLayoutSessionByTeam;
}
return nextState;
});
},
@ -1952,6 +2015,13 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
[stableOwnerId]: assignment,
},
},
graphLayoutSessionByTeam: {
...state.graphLayoutSessionByTeam,
[teamName]: {
mode: 'manual',
signature: state.graphLayoutSessionByTeam[teamName]?.signature ?? null,
},
},
};
});
},
@ -2012,6 +2082,13 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
...state.slotAssignmentsByTeam,
[teamName]: nextAssignments,
},
graphLayoutSessionByTeam: {
...state.graphLayoutSessionByTeam,
[teamName]: {
mode: 'manual',
signature: state.graphLayoutSessionByTeam[teamName]?.signature ?? null,
},
},
};
});
},
@ -2039,6 +2116,13 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
[otherStableOwnerId]: left,
},
},
graphLayoutSessionByTeam: {
...state.graphLayoutSessionByTeam,
[teamName]: {
mode: 'manual',
signature: state.graphLayoutSessionByTeam[teamName]?.signature ?? null,
},
},
};
});
},
@ -2046,29 +2130,35 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
clearTeamGraphSlotAssignments: (teamName) => {
set((state) => {
if (!teamName) {
sessionDefaultGraphSlotAssignmentsAppliedByTeam.clear();
if (
Object.keys(state.slotAssignmentsByTeam).length === 0 &&
state.slotLayoutVersion === GRAPH_STABLE_SLOT_LAYOUT_VERSION
state.slotLayoutVersion === GRAPH_STABLE_SLOT_LAYOUT_VERSION &&
Object.keys(state.graphLayoutSessionByTeam).length === 0
) {
return {};
}
return {
slotLayoutVersion: GRAPH_STABLE_SLOT_LAYOUT_VERSION,
slotAssignmentsByTeam: {},
graphLayoutSessionByTeam: {},
};
}
if (!(teamName in state.slotAssignmentsByTeam)) {
if (
!(teamName in state.slotAssignmentsByTeam) &&
!(teamName in state.graphLayoutSessionByTeam)
) {
return {};
}
const nextAssignmentsByTeam = { ...state.slotAssignmentsByTeam };
const nextGraphLayoutSessionByTeam = { ...state.graphLayoutSessionByTeam };
delete nextAssignmentsByTeam[teamName];
sessionDefaultGraphSlotAssignmentsAppliedByTeam.delete(teamName);
delete nextGraphLayoutSessionByTeam[teamName];
return {
slotLayoutVersion: GRAPH_STABLE_SLOT_LAYOUT_VERSION,
slotAssignmentsByTeam: nextAssignmentsByTeam,
graphLayoutSessionByTeam: nextGraphLayoutSessionByTeam,
};
});
},
@ -2090,36 +2180,37 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
}
const teamData = selectTeamDataForName(state, teamName);
const defaultAssignments = teamData
? getDefaultTeamGraphSlotAssignmentsForMembers(teamData.members)
: {};
const defaultSeed = teamData
? buildTeamGraphDefaultLayoutSeed(teamData.members, teamData.config.members ?? [])
: { orderedVisibleOwnerIds: [], signature: null, assignments: {} };
const currentAssignments = state.slotAssignmentsByTeam[teamName];
const hasCurrentAssignments =
currentAssignments && Object.keys(currentAssignments).length > 0;
const currentSession = state.graphLayoutSessionByTeam[teamName];
if (
areTeamGraphSlotAssignmentsEqual(currentAssignments, defaultAssignments) &&
sessionDefaultGraphSlotAssignmentsAppliedByTeam.has(teamName)
areTeamGraphSlotAssignmentsEqual(currentAssignments, defaultSeed.assignments) &&
currentSession?.mode === 'default' &&
currentSession.signature === defaultSeed.signature
) {
return {};
}
const nextAssignmentsByTeam = { ...state.slotAssignmentsByTeam };
if (Object.keys(defaultAssignments).length === 0) {
if (Object.keys(defaultSeed.assignments).length === 0) {
delete nextAssignmentsByTeam[teamName];
sessionDefaultGraphSlotAssignmentsAppliedByTeam.delete(teamName);
} else {
nextAssignmentsByTeam[teamName] = defaultAssignments;
sessionDefaultGraphSlotAssignmentsAppliedByTeam.add(teamName);
}
if (!hasCurrentAssignments && Object.keys(defaultAssignments).length === 0) {
return {};
nextAssignmentsByTeam[teamName] = defaultSeed.assignments;
}
return {
slotLayoutVersion: GRAPH_STABLE_SLOT_LAYOUT_VERSION,
slotAssignmentsByTeam: nextAssignmentsByTeam,
graphLayoutSessionByTeam: {
...state.graphLayoutSessionByTeam,
[teamName]: {
mode: 'default',
signature: defaultSeed.signature,
},
},
};
});
},

View file

@ -0,0 +1,97 @@
import { isLeadMember } from '@shared/utils/leadDetection';
import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId';
import type { GraphOwnerSlotAssignment } from '@claude-teams/agent-graph';
export interface TeamGraphDefaultLayoutMemberInput {
name: string;
agentId?: string | null;
removedAt?: number | null;
}
export interface TeamGraphDefaultLayoutSeed {
orderedVisibleOwnerIds: string[];
signature: string | null;
assignments: Record<string, GraphOwnerSlotAssignment>;
}
const SMALL_TEAM_CARDINAL_SLOT_PRESETS: ReadonlyArray<ReadonlyArray<GraphOwnerSlotAssignment>> = [
[],
[{ ringIndex: 0, sectorIndex: 0 }],
[
{ ringIndex: 0, sectorIndex: 0 },
{ ringIndex: 0, sectorIndex: 1 },
],
[
{ ringIndex: 0, sectorIndex: 0 },
{ ringIndex: 0, sectorIndex: 1 },
{ ringIndex: 0, sectorIndex: 2 },
],
[
{ ringIndex: 0, sectorIndex: 0 },
{ ringIndex: 0, sectorIndex: 1 },
{ ringIndex: 0, sectorIndex: 2 },
{ ringIndex: 0, sectorIndex: 3 },
],
];
export function buildOrderedVisibleTeamGraphOwnerIds(
members: readonly TeamGraphDefaultLayoutMemberInput[],
configMembers: readonly TeamGraphDefaultLayoutMemberInput[] = []
): string[] {
const visibleMembers = members.filter((member) => !member.removedAt && !isLeadMember(member));
if (visibleMembers.length === 0) {
return [];
}
const visibleMemberByStableOwnerId = new Map(
visibleMembers.map((member) => [getStableTeamOwnerId(member), member] as const)
);
const orderedVisibleOwnerIds: string[] = [];
const seenVisibleOwnerIds = new Set<string>();
for (const configMember of configMembers) {
if (configMember.removedAt || isLeadMember(configMember)) {
continue;
}
const stableOwnerId = getStableTeamOwnerId(configMember);
if (
!visibleMemberByStableOwnerId.has(stableOwnerId) ||
seenVisibleOwnerIds.has(stableOwnerId)
) {
continue;
}
orderedVisibleOwnerIds.push(stableOwnerId);
seenVisibleOwnerIds.add(stableOwnerId);
}
const remainingVisibleOwnerIds = visibleMembers
.map((member) => getStableTeamOwnerId(member))
.filter((stableOwnerId) => !seenVisibleOwnerIds.has(stableOwnerId))
.toSorted((left, right) => left.localeCompare(right));
orderedVisibleOwnerIds.push(...remainingVisibleOwnerIds);
return orderedVisibleOwnerIds;
}
export function buildTeamGraphDefaultLayoutSeed(
members: readonly TeamGraphDefaultLayoutMemberInput[],
configMembers: readonly TeamGraphDefaultLayoutMemberInput[] = []
): TeamGraphDefaultLayoutSeed {
const orderedVisibleOwnerIds = buildOrderedVisibleTeamGraphOwnerIds(members, configMembers);
const signature = orderedVisibleOwnerIds.length > 0 ? orderedVisibleOwnerIds.join('|') : null;
const preset = SMALL_TEAM_CARDINAL_SLOT_PRESETS[orderedVisibleOwnerIds.length];
const assignments: Record<string, GraphOwnerSlotAssignment> = {};
if (preset && preset.length === orderedVisibleOwnerIds.length) {
orderedVisibleOwnerIds.forEach((stableOwnerId, index) => {
assignments[stableOwnerId] = preset[index]!;
});
}
return {
orderedVisibleOwnerIds,
signature,
assignments,
};
}

View file

@ -68,6 +68,65 @@ describe('TeamGraphAdapter particles', () => {
vi.useRealTimers();
});
it('builds ownerOrder from config member order instead of transient member array order', () => {
const adapter = TeamGraphAdapter.create();
const graph = adapter.adapt(
createBaseTeamData({
config: {
name: 'My Team',
members: [{ name: 'team-lead' }, { name: 'alice' }, { name: 'bob' }, { name: 'tom' }],
projectPath: '/repo',
},
members: [
{
name: 'team-lead',
status: 'active',
currentTaskId: null,
taskCount: 0,
lastActiveAt: null,
messageCount: 0,
agentType: 'team-lead',
},
{
name: 'tom',
status: 'active',
currentTaskId: null,
taskCount: 0,
lastActiveAt: null,
messageCount: 0,
},
{
name: 'bob',
status: 'active',
currentTaskId: null,
taskCount: 0,
lastActiveAt: null,
messageCount: 0,
},
{
name: 'alice',
status: 'active',
currentTaskId: null,
taskCount: 0,
lastActiveAt: null,
messageCount: 0,
},
],
}),
'my-team',
undefined,
undefined,
undefined,
new Set()
);
expect(graph.layout?.ownerOrder).toEqual([
'member:my-team:alice',
'member:my-team:bob',
'member:my-team:tom',
]);
});
it('creates a message particle for a new incoming message from the newest message set', () => {
const adapter = TeamGraphAdapter.create();
const baseline = createBaseTeamData();

View file

@ -236,6 +236,10 @@ describe('teamSlice actions', () => {
'agent-alice': { ringIndex: 0, sectorIndex: 2 },
'agent-bob': { ringIndex: 0, sectorIndex: 1 },
});
expect(store.getState().graphLayoutSessionByTeam['my-team']).toEqual({
mode: 'manual',
signature: null,
});
});
it('replaces persisted slot assignments with defaults while persistence is disabled', () => {
@ -270,6 +274,33 @@ describe('teamSlice actions', () => {
{ name: 'jack', agentId: 'agent-jack' },
]);
expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({
'agent-alice': { ringIndex: 0, sectorIndex: 0 },
'agent-bob': { ringIndex: 0, sectorIndex: 1 },
'agent-jack': { ringIndex: 0, sectorIndex: 2 },
'agent-tom': { ringIndex: 0, sectorIndex: 3 },
});
});
it('uses config member order instead of transient visible member array order for defaults', () => {
const store = createSliceStore();
store.getState().ensureTeamGraphSlotAssignments(
'my-team',
[
{ name: 'jack', agentId: 'agent-jack' },
{ name: 'tom', agentId: 'agent-tom' },
{ name: 'alice', agentId: 'agent-alice' },
{ name: 'bob', agentId: 'agent-bob' },
],
[
{ name: 'alice', agentId: 'agent-alice' },
{ name: 'bob', agentId: 'agent-bob' },
{ name: 'tom', agentId: 'agent-tom' },
{ name: 'jack', agentId: 'agent-jack' },
]
);
expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({
'agent-alice': { ringIndex: 0, sectorIndex: 0 },
'agent-bob': { ringIndex: 0, sectorIndex: 1 },
@ -292,8 +323,8 @@ describe('teamSlice actions', () => {
expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({
'agent-alice': { ringIndex: 0, sectorIndex: 0 },
'agent-bob': { ringIndex: 0, sectorIndex: 1 },
'agent-tom': { ringIndex: 0, sectorIndex: 2 },
'agent-jack': { ringIndex: 0, sectorIndex: 3 },
'agent-jack': { ringIndex: 0, sectorIndex: 2 },
'agent-tom': { ringIndex: 0, sectorIndex: 3 },
});
});
@ -367,7 +398,34 @@ describe('teamSlice actions', () => {
});
});
it('does not reseed a team again after defaults were applied once in the session', () => {
it('reseeds defaults again while the team remains in default mode and visible owners change', () => {
const store = createSliceStore();
store.getState().ensureTeamGraphSlotAssignments('my-team', [
{ name: 'alice', agentId: 'agent-alice' },
{ name: 'bob', agentId: 'agent-bob' },
]);
store.getState().ensureTeamGraphSlotAssignments('my-team', [
{ name: 'alice', agentId: 'agent-alice' },
{ name: 'bob', agentId: 'agent-bob' },
{ name: 'tom', agentId: 'agent-tom' },
{ name: 'jack', agentId: 'agent-jack' },
]);
expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({
'agent-alice': { ringIndex: 0, sectorIndex: 0 },
'agent-bob': { ringIndex: 0, sectorIndex: 1 },
'agent-jack': { ringIndex: 0, sectorIndex: 2 },
'agent-tom': { ringIndex: 0, sectorIndex: 3 },
});
expect(store.getState().graphLayoutSessionByTeam['my-team']).toEqual({
mode: 'default',
signature: 'agent-alice|agent-bob|agent-jack|agent-tom',
});
});
it('does not reshuffle existing owners after the team enters manual mode', () => {
const store = createSliceStore();
store.getState().ensureTeamGraphSlotAssignments('my-team', [
@ -383,12 +441,18 @@ describe('teamSlice actions', () => {
store.getState().ensureTeamGraphSlotAssignments('my-team', [
{ name: 'alice', agentId: 'agent-alice' },
{ name: 'bob', agentId: 'agent-bob' },
{ name: 'tom', agentId: 'agent-tom' },
{ name: 'jack', agentId: 'agent-jack' },
]);
expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({
'agent-alice': { ringIndex: 1, sectorIndex: 4 },
'agent-bob': { ringIndex: 0, sectorIndex: 1 },
});
expect(store.getState().graphLayoutSessionByTeam['my-team']).toEqual({
mode: 'manual',
signature: 'agent-alice|agent-bob',
});
});
it('resets graph slot assignments back to defaults when reopening the graph surface', () => {
@ -423,7 +487,7 @@ describe('teamSlice actions', () => {
'my-team',
'agent-alice',
{ ringIndex: 0, sectorIndex: 2 },
'agent-tom',
'agent-jack',
{ ringIndex: 0, sectorIndex: 0 }
);
@ -432,8 +496,12 @@ describe('teamSlice actions', () => {
expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({
'agent-alice': { ringIndex: 0, sectorIndex: 0 },
'agent-bob': { ringIndex: 0, sectorIndex: 1 },
'agent-tom': { ringIndex: 0, sectorIndex: 2 },
'agent-jack': { ringIndex: 0, sectorIndex: 3 },
'agent-jack': { ringIndex: 0, sectorIndex: 2 },
'agent-tom': { ringIndex: 0, sectorIndex: 3 },
});
expect(store.getState().graphLayoutSessionByTeam['my-team']).toEqual({
mode: 'default',
signature: 'agent-alice|agent-bob|agent-jack|agent-tom',
});
});