From c303a236a5107c010e80afb285762aa039142c4e Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 16 Apr 2026 11:26:30 +0300 Subject: [PATCH] feat(agent-graph): unify lead slot layout defaults --- .../src/hooks/useGraphSimulation.ts | 21 +- .../agent-graph/src/layout/kanbanLayout.ts | 27 +- .../agent-graph/src/layout/stableSlots.ts | 256 ++++++++++------ packages/agent-graph/src/ui/GraphControls.tsx | 280 +++++++++--------- packages/agent-graph/src/ui/GraphView.tsx | 3 + .../renderer/ui/GraphProvisioningHud.tsx | 126 ++------ .../renderer/ui/TeamGraphOverlay.tsx | 12 +- .../agent-graph/renderer/ui/TeamGraphTab.tsx | 15 +- src/renderer/store/slices/teamSlice.ts | 60 +++- .../agent-graph/GraphProvisioningHud.test.ts | 10 +- .../agent-graph/useGraphSimulation.test.ts | 75 +++-- test/renderer/store/teamSlice.test.ts | 48 ++- 12 files changed, 523 insertions(+), 410 deletions(-) diff --git a/packages/agent-graph/src/hooks/useGraphSimulation.ts b/packages/agent-graph/src/hooks/useGraphSimulation.ts index e1eed487..5da1294c 100644 --- a/packages/agent-graph/src/hooks/useGraphSimulation.ts +++ b/packages/agent-graph/src/hooks/useGraphSimulation.ts @@ -251,14 +251,15 @@ function applySnapshotToNodes( const translatedFrameByOwnerId = new Map( translatedFrames.map((frame) => [frame.ownerId, frame] as const) ); + const leadFrame = snapshot.leadSlotFrame; const leadId = snapshot.leadNodeId; for (const node of nodes) { if (node.kind === 'lead' && node.id === leadId) { - node.x = 0; - node.y = 0; - node.fx = 0; - node.fy = 0; + node.x = leadFrame.ownerX; + node.y = leadFrame.ownerY; + node.fx = leadFrame.ownerX; + node.fy = leadFrame.ownerY; node.vx = 0; node.vy = 0; continue; @@ -278,9 +279,10 @@ function applySnapshotToNodes( } } - positionProcessNodes(nodes, translatedFrames); + positionProcessNodes(nodes, [snapshot.leadSlotFrame, ...translatedFrames]); KanbanLayoutEngine.layout(nodes, { memberSlotFrames: translatedFrames, + leadSlotFrame: snapshot.leadSlotFrame, unassignedTaskRect: snapshot.unassignedTaskRect, }); positionCrossTeamNodes(nodes, snapshot.fitBounds); @@ -322,16 +324,15 @@ function commitSnapshotGeometry(args: { activityRectByNodeIdRef.current.clear(); extraWorldBoundsRef.current = snapshotToWorldBounds(snapshot); - if (snapshot.leadNodeId && snapshot.launchAnchor) { - launchAnchorPositionsRef.current.set(snapshot.leadNodeId, snapshot.launchAnchor); - } - for (const frame of getTranslatedMemberFrames(snapshot, dragOwnerPositionsRef.current)) { activityRectByNodeIdRef.current.set(frame.ownerId, frame.activityColumnRect); } if (snapshot.leadNodeId) { - activityRectByNodeIdRef.current.set(snapshot.leadNodeId, snapshot.leadActivityRect); + activityRectByNodeIdRef.current.set( + snapshot.leadNodeId, + snapshot.leadSlotFrame.activityColumnRect + ); } } diff --git a/packages/agent-graph/src/layout/kanbanLayout.ts b/packages/agent-graph/src/layout/kanbanLayout.ts index ce0fb704..4c1475ad 100644 --- a/packages/agent-graph/src/layout/kanbanLayout.ts +++ b/packages/agent-graph/src/layout/kanbanLayout.ts @@ -10,7 +10,6 @@ import type { GraphNode } from '../ports/types'; import { KANBAN_ZONE, TASK_PILL } from '../constants/canvas-constants'; import { COLORS } from '../constants/colors'; -import { resolveActivityLaneSide } from './activityLane'; import type { SlotFrame, StableRect } from './stableSlots'; /** Column header info for rendering */ @@ -49,7 +48,7 @@ export function getOwnerKanbanBaseX(args: { columnWidth: number; leadX?: number | null; }): number { - const { ownerX, ownerKind, activeColumnCount, columnWidth, leadX } = args; + const { ownerX, ownerKind, activeColumnCount, columnWidth } = args; if (activeColumnCount <= 0) { return ownerX; } @@ -58,17 +57,7 @@ export function getOwnerKanbanBaseX(args: { return ownerX - (activeColumnCount * columnWidth) / 2; } - const side = resolveActivityLaneSide({ - nodeKind: ownerKind, - nodeX: ownerX, - leadX, - }); - - if (side === 'left') { - return ownerX; - } - - return ownerX - (activeColumnCount - 1) * columnWidth; + return ownerX - ((activeColumnCount - 1) * columnWidth) / 2; } export class KanbanLayoutEngine { @@ -89,6 +78,7 @@ export class KanbanLayoutEngine { nodes: GraphNode[], options?: { memberSlotFrames?: readonly SlotFrame[]; + leadSlotFrame?: SlotFrame | null; unassignedTaskRect?: StableRect | null; } ): void { @@ -96,9 +86,12 @@ export class KanbanLayoutEngine { nodeMap.clear(); for (const n of nodes) nodeMap.set(n.id, n); const leadX = nodes.find((node) => node.kind === 'lead')?.x ?? null; - const memberSlotFrameByOwnerId = new Map( + const ownerSlotFrameByOwnerId = new Map( (options?.memberSlotFrames ?? []).map((frame) => [frame.ownerId, frame] as const) ); + if (options?.leadSlotFrame) { + ownerSlotFrameByOwnerId.set(options.leadSlotFrame.ownerId, options.leadSlotFrame); + } const tasksByOwner = this.#tasksByOwner; tasksByOwner.clear(); @@ -110,10 +103,10 @@ export class KanbanLayoutEngine { return false; } if (owner.kind === 'lead') { - return true; + return ownerSlotFrameByOwnerId.has(ownerId); } if (owner.kind === 'member') { - return memberSlotFrameByOwnerId.has(ownerId); + return ownerSlotFrameByOwnerId.has(ownerId); } return false; }; @@ -143,7 +136,7 @@ export class KanbanLayoutEngine { owner, ownerId, leadX, - memberSlotFrameByOwnerId.get(ownerId) ?? null + ownerSlotFrameByOwnerId.get(ownerId) ?? null ); if (zoneInfo) this.zones.push(zoneInfo); } diff --git a/packages/agent-graph/src/layout/stableSlots.ts b/packages/agent-graph/src/layout/stableSlots.ts index e606c3cd..76b9f110 100644 --- a/packages/agent-graph/src/layout/stableSlots.ts +++ b/packages/agent-graph/src/layout/stableSlots.ts @@ -1,7 +1,7 @@ import { KANBAN_ZONE, TASK_PILL } from '../constants/canvas-constants'; import type { GraphLayoutPort, GraphNode, GraphOwnerSlotAssignment } from '../ports/types'; import { ACTIVITY_LANE } from './activityLane'; -import { LAUNCH_ANCHOR_LAYOUT, type WorldBounds } from './launchAnchor'; +import type { WorldBounds } from './launchAnchor'; import { STABLE_SLOT_GEOMETRY, STABLE_SLOT_SECTOR_VECTORS, @@ -55,6 +55,7 @@ export interface StableSlotLayoutSnapshot { teamName: string; leadNodeId: string | null; leadCoreRect: StableRect; + leadSlotFrame: SlotFrame; leadActivityRect: StableRect; launchHudRect: StableRect; launchAnchor: { x: number; y: number } | null; @@ -128,27 +129,21 @@ export function buildStableSlotLayoutSnapshot({ return null; } - const leadCoreRect = createCenteredRect(0, 0, 200, 168); - const leadActivityRect = createRect( - leadCoreRect.left - SLOT_GEOMETRY.centralBlockGap - ACTIVITY_LANE.width, - -SLOT_GEOMETRY.activityColumnHeight / 2, - ACTIVITY_LANE.width, - SLOT_GEOMETRY.activityColumnHeight + const leadCoreRect = createCenteredRect(0, 0, 200, 96); + const leadFootprint = computeOwnerFootprintForOwnerId(nodes, leadNode.id); + const leadSlotFrame = buildSlotFrameAtRadius( + leadFootprint, + { ringIndex: 0, sectorIndex: 0 }, + 0 ); - const launchHudRect = createRect( - leadCoreRect.right + SLOT_GEOMETRY.centralBlockGap, - -LAUNCH_ANCHOR_LAYOUT.compactHeight / 2, - LAUNCH_ANCHOR_LAYOUT.compactWidth, - LAUNCH_ANCHOR_LAYOUT.compactHeight - ); - const leadCentralReservedBlock = unionRects([leadCoreRect, leadActivityRect, launchHudRect]); + const leadActivityRect = leadSlotFrame.activityColumnRect; + const launchHudRect = createRect(leadCoreRect.right, leadCoreRect.top, 0, 0); + const leadCentralReservedBlock = leadSlotFrame.bounds; const ownerFootprints = computeOwnerFootprints(nodes, layout); const unassignedTaskRect = buildUnassignedTaskRect(nodes, leadCentralReservedBlock); const centralCollisionRects = buildCentralCollisionRects({ - leadCoreRect, - leadActivityRect, - launchHudRect, + leadCentralReservedBlock, unassignedTaskRect, }); const runtimeCentralExclusion = padRect( @@ -177,12 +172,10 @@ export function buildStableSlotLayoutSnapshot({ teamName, leadNodeId: leadNode.id, leadCoreRect, + leadSlotFrame, leadActivityRect, launchHudRect, - launchAnchor: { - x: launchHudRect.left + launchHudRect.width / 2, - y: launchHudRect.top + launchHudRect.height / 2, - }, + launchAnchor: null, leadCentralReservedBlock, runtimeCentralExclusion, centralCollisionRects, @@ -194,12 +187,10 @@ export function buildStableSlotLayoutSnapshot({ } function buildCentralCollisionRects(args: { - leadCoreRect: StableRect; - leadActivityRect: StableRect; - launchHudRect: StableRect; + leadCentralReservedBlock: StableRect; unassignedTaskRect: StableRect | null; }): StableRect[] { - const rects = [args.leadCoreRect, args.leadActivityRect, args.launchHudRect]; + const rects = [args.leadCentralReservedBlock]; if (args.unassignedTaskRect) { rects.push(args.unassignedTaskRect); } @@ -253,64 +244,96 @@ export function computeOwnerFootprints( return []; } - const taskColumnCount = taskColumnsByOwnerId.get(ownerId)?.size ?? 0; - const kanbanBandWidth = - taskColumnCount <= 1 - ? TASK_PILL.width - : TASK_PILL.width + (taskColumnCount - 1) * KANBAN_ZONE.columnWidth; - const processCount = processCountByOwnerId.get(ownerId) ?? 0; - const processBandWidth = computeProcessBandWidth(processCount); - const boardBandWidth = - SLOT_GEOMETRY.activityColumnWidth + - SLOT_GEOMETRY.boardColumnGap + - kanbanBandWidth; - const boardBandHeight = Math.max( - SLOT_GEOMETRY.activityColumnHeight, - SLOT_GEOMETRY.kanbanBandHeight - ); - const innerContentWidth = Math.max( - SLOT_GEOMETRY.ownerMinWidth, - processBandWidth, - boardBandWidth - ); - const slotWidth = innerContentWidth + SLOT_GEOMETRY.memberSlotInnerPadding * 2; - const slotHeight = - SLOT_GEOMETRY.memberSlotInnerPadding * 2 + - SLOT_GEOMETRY.ownerBandHeight + + return [ + buildOwnerFootprint({ + ownerId, + taskColumnCount: taskColumnsByOwnerId.get(ownerId)?.size ?? 0, + processCount: processCountByOwnerId.get(ownerId) ?? 0, + }), + ]; + }); +} + +function computeOwnerFootprintForOwnerId( + nodes: readonly GraphNode[], + ownerId: string +): OwnerFootprint { + const taskColumns = new Set(); + let processCount = 0; + + for (const node of nodes) { + if (node.kind === 'task' && node.ownerId === ownerId) { + taskColumns.add(resolveTaskColumnKey(node)); + } + if (node.kind === 'process' && node.ownerId === ownerId) { + processCount += 1; + } + } + + return buildOwnerFootprint({ + ownerId, + taskColumnCount: taskColumns.size, + processCount, + }); +} + +function buildOwnerFootprint(args: { + ownerId: string; + taskColumnCount: number; + processCount: number; +}): OwnerFootprint { + const kanbanBandWidth = + args.taskColumnCount <= 1 + ? TASK_PILL.width + : TASK_PILL.width + (args.taskColumnCount - 1) * KANBAN_ZONE.columnWidth; + const processBandWidth = computeProcessBandWidth(args.processCount); + const boardBandWidth = + SLOT_GEOMETRY.activityColumnWidth + + SLOT_GEOMETRY.boardColumnGap + + kanbanBandWidth; + const boardBandHeight = Math.max( + SLOT_GEOMETRY.activityColumnHeight, + SLOT_GEOMETRY.kanbanBandHeight + ); + const innerContentWidth = Math.max( + SLOT_GEOMETRY.ownerMinWidth, + processBandWidth, + boardBandWidth + ); + const slotWidth = innerContentWidth + SLOT_GEOMETRY.memberSlotInnerPadding * 2; + const slotHeight = + SLOT_GEOMETRY.memberSlotInnerPadding * 2 + + SLOT_GEOMETRY.ownerBandHeight + + SLOT_GEOMETRY.ownerToProcessGap + + SLOT_GEOMETRY.processBandHeight + + SLOT_GEOMETRY.processToBoardGap + + boardBandHeight; + const radialDepth = Math.max( + SLOT_GEOMETRY.memberSlotInnerPadding + SLOT_GEOMETRY.ownerBandHeight / 2, + SLOT_GEOMETRY.memberSlotInnerPadding + + SLOT_GEOMETRY.ownerBandHeight / 2 + SLOT_GEOMETRY.ownerToProcessGap + SLOT_GEOMETRY.processBandHeight + SLOT_GEOMETRY.processToBoardGap + - boardBandHeight; - const radialDepth = Math.max( - SLOT_GEOMETRY.memberSlotInnerPadding + - SLOT_GEOMETRY.ownerBandHeight / 2, - SLOT_GEOMETRY.memberSlotInnerPadding + - SLOT_GEOMETRY.ownerBandHeight / 2 + - SLOT_GEOMETRY.ownerToProcessGap + - SLOT_GEOMETRY.processBandHeight + - SLOT_GEOMETRY.processToBoardGap + - boardBandHeight - ); + boardBandHeight + ); - return [ - { - ownerId, - slotWidth, - slotHeight, - widthBucket: classifyWidthBucket(slotWidth), - radialDepth, - activityColumnWidth: SLOT_GEOMETRY.activityColumnWidth, - activityColumnHeight: SLOT_GEOMETRY.activityColumnHeight, - processBandWidth, - kanbanBandWidth, - kanbanBandHeight: SLOT_GEOMETRY.kanbanBandHeight, - boardBandWidth, - boardBandHeight, - taskColumnCount, - processCount, - } satisfies OwnerFootprint, - ]; - }); + return { + ownerId: args.ownerId, + slotWidth, + slotHeight, + widthBucket: classifyWidthBucket(slotWidth), + radialDepth, + activityColumnWidth: SLOT_GEOMETRY.activityColumnWidth, + activityColumnHeight: SLOT_GEOMETRY.activityColumnHeight, + processBandWidth, + kanbanBandWidth, + kanbanBandHeight: SLOT_GEOMETRY.kanbanBandHeight, + boardBandWidth, + boardBandHeight, + taskColumnCount: args.taskColumnCount, + processCount: args.processCount, + } satisfies OwnerFootprint; } export function classifyWidthBucket(width: number): StableSlotWidthBucket { @@ -447,6 +470,11 @@ function validateStaticSnapshotRects( ): StableSlotLayoutValidationResult | null { const staticRects: [string, StableRect][] = [ ['leadCoreRect', snapshot.leadCoreRect], + ['leadSlotFrame.bounds', snapshot.leadSlotFrame.bounds], + ['leadSlotFrame.boardBandRect', snapshot.leadSlotFrame.boardBandRect], + ['leadSlotFrame.activityColumnRect', snapshot.leadSlotFrame.activityColumnRect], + ['leadSlotFrame.processBandRect', snapshot.leadSlotFrame.processBandRect], + ['leadSlotFrame.kanbanBandRect', snapshot.leadSlotFrame.kanbanBandRect], ['leadActivityRect', snapshot.leadActivityRect], ['launchHudRect', snapshot.launchHudRect], ['leadCentralReservedBlock', snapshot.leadCentralReservedBlock], @@ -477,14 +505,34 @@ function validateStaticSnapshotRects( function validateLeadSnapshotRects( snapshot: StableSlotLayoutSnapshot ): StableSlotLayoutValidationResult | null { + const leadFrameValidation = validateSlotFrameGeometry( + snapshot.leadSlotFrame, + snapshot.fitBounds, + `leadSlotFrame(${snapshot.leadSlotFrame.ownerId})` + ); + if (leadFrameValidation) { + return leadFrameValidation; + } if (!rectContainsRect(snapshot.leadCentralReservedBlock, snapshot.leadCoreRect)) { return { valid: false, reason: 'leadCoreRect must fit inside leadCentralReservedBlock' }; } if (!rectContainsRect(snapshot.leadCentralReservedBlock, snapshot.leadActivityRect)) { return { valid: false, reason: 'leadActivityRect must fit inside leadCentralReservedBlock' }; } - if (!rectContainsRect(snapshot.leadCentralReservedBlock, snapshot.launchHudRect)) { - return { valid: false, reason: 'launchHudRect must fit inside leadCentralReservedBlock' }; + if (snapshot.leadActivityRect.left !== snapshot.leadSlotFrame.activityColumnRect.left) { + return { + valid: false, + reason: 'leadActivityRect must mirror leadSlotFrame.activityColumnRect', + }; + } + if (snapshot.leadActivityRect.top !== snapshot.leadSlotFrame.activityColumnRect.top) { + return { + valid: false, + reason: 'leadActivityRect must mirror leadSlotFrame.activityColumnRect', + }; + } + if (!rectContainsRect(snapshot.leadCentralReservedBlock, snapshot.leadSlotFrame.bounds)) { + return { valid: false, reason: 'leadSlotFrame must fit inside leadCentralReservedBlock' }; } if (!rectContainsRect(snapshot.runtimeCentralExclusion, snapshot.leadCentralReservedBlock)) { return { valid: false, reason: 'runtimeCentralExclusion must contain leadCentralReservedBlock' }; @@ -513,11 +561,13 @@ function validateMemberSlotFrame( seenOwnerIds: Set, seenAssignments: Set ): StableSlotLayoutValidationResult | null { - if (!isFiniteRect(frame.bounds)) { - return { valid: false, reason: `slot frame for ${frame.ownerId} contains non-finite bounds` }; - } - if (!Number.isFinite(frame.ownerX) || !Number.isFinite(frame.ownerY)) { - return { valid: false, reason: `slot frame for ${frame.ownerId} contains non-finite anchor` }; + const geometryValidation = validateSlotFrameGeometry( + frame, + snapshot.fitBounds, + `slot frame for ${frame.ownerId}` + ); + if (geometryValidation) { + return geometryValidation; } if (seenOwnerIds.has(frame.ownerId)) { return { valid: false, reason: `duplicate owner frame for ${frame.ownerId}` }; @@ -536,41 +586,55 @@ function validateMemberSlotFrame( reason: `slot frame for ${frame.ownerId} overlaps centralCollisionRects`, }; } + return null; +} + +function validateSlotFrameGeometry( + frame: SlotFrame, + fitBounds: StableRect, + label: string +): StableSlotLayoutValidationResult | null { + if (!isFiniteRect(frame.bounds)) { + return { valid: false, reason: `${label} contains non-finite bounds` }; + } + if (!Number.isFinite(frame.ownerX) || !Number.isFinite(frame.ownerY)) { + return { valid: false, reason: `${label} contains non-finite anchor` }; + } if (!rectContainsRect(frame.bounds, frame.boardBandRect)) { - return { valid: false, reason: `boardBandRect escapes slot bounds for ${frame.ownerId}` }; + return { valid: false, reason: `boardBandRect escapes ${label}` }; } if (!rectContainsRect(frame.bounds, frame.activityColumnRect)) { - return { valid: false, reason: `activityColumnRect escapes slot bounds for ${frame.ownerId}` }; + return { valid: false, reason: `activityColumnRect escapes ${label}` }; } if (!rectContainsRect(frame.bounds, frame.processBandRect)) { - return { valid: false, reason: `processBandRect escapes slot bounds for ${frame.ownerId}` }; + return { valid: false, reason: `processBandRect escapes ${label}` }; } if (!rectContainsRect(frame.bounds, frame.kanbanBandRect)) { - return { valid: false, reason: `kanbanBandRect escapes slot bounds for ${frame.ownerId}` }; + return { valid: false, reason: `kanbanBandRect escapes ${label}` }; } if (!rectContainsRect(frame.boardBandRect, frame.activityColumnRect)) { return { valid: false, - reason: `activityColumnRect escapes boardBandRect for ${frame.ownerId}`, + reason: `activityColumnRect escapes boardBandRect in ${label}`, }; } if (!rectContainsRect(frame.boardBandRect, frame.kanbanBandRect)) { return { valid: false, - reason: `kanbanBandRect escapes boardBandRect for ${frame.ownerId}`, + reason: `kanbanBandRect escapes boardBandRect in ${label}`, }; } if (rectsOverlap(frame.activityColumnRect, frame.kanbanBandRect)) { return { valid: false, - reason: `activityColumnRect overlaps kanbanBandRect for ${frame.ownerId}`, + reason: `activityColumnRect overlaps kanbanBandRect in ${label}`, }; } if (!pointInRect(frame.ownerX, frame.ownerY, frame.bounds)) { - return { valid: false, reason: `owner anchor escapes slot bounds for ${frame.ownerId}` }; + return { valid: false, reason: `owner anchor escapes ${label}` }; } - if (!rectContainsRect(snapshot.fitBounds, frame.bounds)) { - return { valid: false, reason: `slot frame for ${frame.ownerId} escapes fitBounds` }; + if (!rectContainsRect(fitBounds, frame.bounds)) { + return { valid: false, reason: `${label} escapes fitBounds` }; } return null; diff --git a/packages/agent-graph/src/ui/GraphControls.tsx b/packages/agent-graph/src/ui/GraphControls.tsx index 4d0318f4..e933c9c1 100644 --- a/packages/agent-graph/src/ui/GraphControls.tsx +++ b/packages/agent-graph/src/ui/GraphControls.tsx @@ -48,6 +48,7 @@ export interface GraphControlsProps { teamName: string; teamColor?: string; isAlive?: boolean; + topToolbarContent?: React.ReactNode; } const TOPBAR_BUTTON_SIZE = 25; @@ -67,6 +68,7 @@ export function GraphControls({ onToggleSidebar, isSidebarVisible = true, teamColor, + topToolbarContent, }: GraphControlsProps): React.JSX.Element { const [isSettingsOpen, setIsSettingsOpen] = useState(false); const settingsRef = useRef(null); @@ -105,160 +107,170 @@ export function GraphControls({ return ( <> -
- {onToggleSidebar ? ( -
- - ) : ( - - ) - } - toolbar - title={isSidebarVisible ? 'Hide sidebar' : 'Show sidebar'} - /> -
- ) : null} - {onOpenTeamPage ? ( -
- } - toolbar - title="Open team page" - /> -
- ) : null} - {onCreateTask ? ( -
- } - toolbar - title="Create task" - /> -
- ) : null} -
- -
-
- toggle('paused')} - icon={filters.paused ? : } - toolbar - title={filters.paused ? 'Resume animation' : 'Pause animation'} - /> +
+
+ {onToggleSidebar ? ( +
+ + ) : ( + + ) + } + toolbar + title={isSidebarVisible ? 'Hide sidebar' : 'Show sidebar'} + /> +
+ ) : null} + {onOpenTeamPage ? ( +
+ } + toolbar + title="Open team page" + /> +
+ ) : null} + {onCreateTask ? ( +
+ } + toolbar + title="Create task" + /> +
+ ) : null}
-
+
+ {topToolbarContent ? ( +
+ {topToolbarContent} +
+ ) : null} +
+ +
setIsSettingsOpen((value) => !value)} - icon={} - active={isSettingsOpen} + onClick={() => toggle('paused')} + icon={filters.paused ? : } toolbar - title="Graph settings" + title={filters.paused ? 'Resume animation' : 'Pause animation'} />
- {isSettingsOpen && ( +
- toggle('showTasks')} - icon={} - label="Tasks" - block - /> - toggle('showProcesses')} - icon={} - label="Processes" - block - /> - toggle('showEdges')} - icon={filters.showEdges ? : } - label="Edges" - block + setIsSettingsOpen((value) => !value)} + icon={} + active={isSettingsOpen} + toolbar + title="Graph settings" />
- )} -
-
- {onRequestPinAsTab && ( - } - toolbar - title="Pin as tab" - /> - )} - {onRequestFullscreen && ( - } - toolbar - title="Fullscreen" - /> - )} - {onRequestClose && ( - } - toolbar - title="Close graph" - /> - )} + {isSettingsOpen && ( +
+ toggle('showTasks')} + icon={} + label="Tasks" + block + /> + toggle('showProcesses')} + icon={} + label="Processes" + block + /> + toggle('showEdges')} + icon={filters.showEdges ? : } + label="Edges" + block + /> +
+ )} +
+ +
+ {onRequestPinAsTab && ( + } + toolbar + title="Pin as tab" + /> + )} + {onRequestFullscreen && ( + } + toolbar + title="Fullscreen" + /> + )} + {onRequestClose && ( + } + toolbar + title="Close graph" + /> + )} +
diff --git a/packages/agent-graph/src/ui/GraphView.tsx b/packages/agent-graph/src/ui/GraphView.tsx index 052c1465..7daf3e57 100644 --- a/packages/agent-graph/src/ui/GraphView.tsx +++ b/packages/agent-graph/src/ui/GraphView.tsx @@ -47,6 +47,7 @@ export interface GraphViewProps { onCreateTask?: () => void; onToggleSidebar?: () => void; isSidebarVisible?: boolean; + renderTopToolbarContent?: () => React.ReactNode; onOwnerSlotDrop?: (payload: { nodeId: string; assignment: GraphOwnerSlotAssignment; @@ -92,6 +93,7 @@ export function GraphView({ onCreateTask, onToggleSidebar, isSidebarVisible = true, + renderTopToolbarContent, onOwnerSlotDrop, renderOverlay, renderEdgeOverlay, @@ -748,6 +750,7 @@ export function GraphView({ teamName={data.teamName} teamColor={data.teamColor} isAlive={data.isAlive} + topToolbarContent={renderTopToolbarContent?.()} /> {renderHud ? ( diff --git a/src/features/agent-graph/renderer/ui/GraphProvisioningHud.tsx b/src/features/agent-graph/renderer/ui/GraphProvisioningHud.tsx index 4f2a90c7..26f6f3ff 100644 --- a/src/features/agent-graph/renderer/ui/GraphProvisioningHud.tsx +++ b/src/features/agent-graph/renderer/ui/GraphProvisioningHud.tsx @@ -1,4 +1,4 @@ -import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { DISPLAY_STEPS } from '@renderer/components/team/provisioningSteps'; import { StepProgressBar } from '@renderer/components/team/StepProgressBar'; @@ -13,7 +13,7 @@ import { DialogTitle, } from '@renderer/components/ui/dialog'; import { cn } from '@renderer/lib/utils'; -import { AlertTriangle, CheckCircle2, ExternalLink, Loader2, X } from 'lucide-react'; +import { AlertTriangle, CheckCircle2, Loader2 } from 'lucide-react'; import type { TeamProvisioningPresentation } from '@renderer/utils/teamProvisioningPresentation'; import type { CSSProperties } from 'react'; @@ -51,28 +51,28 @@ function getToneClasses(tone: TeamProvisioningPresentation['compactTone']): { return { border: 'border-red-400/35 bg-[rgba(26,10,16,0.92)]', badge: 'border-red-500/30 text-red-300', - icon: , + icon: , iconClassName: 'text-red-400', }; case 'warning': return { border: 'border-amber-400/35 bg-[rgba(31,18,8,0.92)]', badge: 'border-amber-500/30 text-amber-200', - icon: , + icon: , iconClassName: 'text-amber-400', }; case 'success': return { border: 'border-emerald-400/35 bg-[rgba(8,24,18,0.92)]', badge: 'border-emerald-500/30 text-emerald-200', - icon: , + icon: , iconClassName: 'text-emerald-400', }; default: return { border: 'border-cyan-400/25 bg-[rgba(8,14,26,0.92)]', badge: 'border-cyan-500/20 text-cyan-200', - icon: , + icon: , iconClassName: 'text-cyan-300', }; } @@ -80,26 +80,17 @@ function getToneClasses(tone: TeamProvisioningPresentation['compactTone']): { export interface GraphProvisioningHudProps { teamName: string; - leadNodeId: string | null; - getLaunchAnchorScreenPlacement: ( - leadNodeId: string - ) => { x: number; y: number; scale: number; visible: boolean } | null; enabled?: boolean; } export const GraphProvisioningHud = ({ teamName, - leadNodeId, - getLaunchAnchorScreenPlacement, enabled = true, }: GraphProvisioningHudProps): React.JSX.Element | null => { const { presentation, runInstanceKey } = useTeamProvisioningPresentation(teamName); - const shellRef = useRef(null); const lastActiveStepRef = useRef(-1); const [detailsOpen, setDetailsOpen] = useState(false); - const [dismissed, setDismissed] = useState(false); - const shouldRender = - enabled && shouldRenderLaunchHud(presentation) && !dismissed && Boolean(leadNodeId); + const shouldRender = enabled && shouldRenderLaunchHud(presentation); const tone = presentation ? getToneClasses(presentation.compactTone) : null; const errorStepIndex = presentation?.isFailed ? lastActiveStepRef.current >= 0 @@ -109,63 +100,21 @@ export const GraphProvisioningHud = ({ useEffect(() => { setDetailsOpen(false); - setDismissed(false); lastActiveStepRef.current = -1; }, [runInstanceKey, teamName]); - useEffect(() => { - if (!shouldRender || !leadNodeId) { - setDetailsOpen(false); - } - }, [leadNodeId, shouldRender]); - useEffect(() => { if (presentation && !presentation.isFailed && presentation.currentStepIndex >= 0) { lastActiveStepRef.current = presentation.currentStepIndex; } }, [presentation]); - useLayoutEffect(() => { - if (!shouldRender || !leadNodeId) { - return; - } - let frameId = 0; - const updatePosition = (): void => { - const shell = shellRef.current; - if (!shell) { - frameId = window.requestAnimationFrame(updatePosition); - return; - } - const placement = getLaunchAnchorScreenPlacement(leadNodeId); - if (!placement) { - shell.style.opacity = '0'; - frameId = window.requestAnimationFrame(updatePosition); - return; - } - - if (!placement.visible) { - shell.style.opacity = '0'; - frameId = window.requestAnimationFrame(updatePosition); - return; - } - - shell.style.opacity = '1'; - shell.style.transform = `translate(${Math.round(placement.x)}px, ${Math.round(placement.y)}px) scale(${placement.scale.toFixed(3)})`; - frameId = window.requestAnimationFrame(updatePosition); - }; - - updatePosition(); - return () => { - window.cancelAnimationFrame(frameId); - }; - }, [getLaunchAnchorScreenPlacement, leadNodeId, shouldRender]); - const compactLabel = useMemo(() => { if (!presentation?.compactDetail) { return null; } - return presentation.compactDetail.length > 88 - ? `${presentation.compactDetail.slice(0, 88)}...` + return presentation.compactDetail.length > 54 + ? `${presentation.compactDetail.slice(0, 54)}...` : presentation.compactDetail; }, [presentation?.compactDetail]); @@ -174,21 +123,21 @@ export const GraphProvisioningHud = ({ } return ( -
-
+ - -
- -
+
+ @@ -254,6 +184,6 @@ export const GraphProvisioningHud = ({
- + ); }; diff --git a/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx b/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx index 74df726b..e5c7c6da 100644 --- a/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx +++ b/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx @@ -58,10 +58,6 @@ export const TeamGraphOverlay = ({ const { dialog: createTaskDialog, openCreateTaskDialog } = useGraphCreateTaskDialog(teamName); const effectiveSidebarVisible = sidebarVisible ?? persistedSidebarVisible; const handleToggleSidebar = onToggleSidebar ?? toggleSidebarVisible; - const leadNodeId = useMemo( - () => graphData.nodes.find((node) => node.kind === 'lead')?.id ?? null, - [graphData.nodes] - ); // Task action dispatchers (same pattern as TeamGraphTab) const dispatchTaskAction = useCallback( @@ -126,6 +122,7 @@ export const TeamGraphOverlay = ({ onCreateTask={openCreateTask} onToggleSidebar={handleToggleSidebar} isSidebarVisible={effectiveSidebarVisible} + renderTopToolbarContent={() => } onOwnerSlotDrop={commitOwnerSlotDrop} className="team-graph-view min-w-0 flex-1" renderHud={(hudProps) => { @@ -143,7 +140,7 @@ export const TeamGraphOverlay = ({ worldToScreen?: (x: number, y: number) => { x: number; y: number }; getNodeWorldPosition?: (nodeId: string) => { x: number; y: number } | null; }; - const { getLaunchAnchorScreenPlacement, getViewportSize, focusNodeIds } = extraHudProps; + const { getViewportSize, focusNodeIds } = extraHudProps; return ( <> @@ -159,11 +156,6 @@ export const TeamGraphOverlay = ({ onOpenTaskDetail={onOpenTaskDetail} onOpenMemberProfile={onOpenMemberProfile} /> - ); }} diff --git a/src/features/agent-graph/renderer/ui/TeamGraphTab.tsx b/src/features/agent-graph/renderer/ui/TeamGraphTab.tsx index 31976bf6..65f6bda0 100644 --- a/src/features/agent-graph/renderer/ui/TeamGraphTab.tsx +++ b/src/features/agent-graph/renderer/ui/TeamGraphTab.tsx @@ -46,10 +46,6 @@ export const TeamGraphTab = ({ }: TeamGraphTabProps): React.JSX.Element => { const graphData = useTeamGraphAdapter(teamName); const { openTeamPage, commitOwnerSlotDrop } = useTeamGraphSurfaceActions(teamName); - const leadNodeId = useMemo( - () => graphData.nodes.find((node) => node.kind === 'lead')?.id ?? null, - [graphData.nodes] - ); const [fullscreen, setFullscreen] = useState(false); const { sidebarVisible, toggleSidebarVisible } = useGraphSidebarVisibility(); const { dialog: createTaskDialog, openCreateTaskDialog } = useGraphCreateTaskDialog(teamName); @@ -149,6 +145,9 @@ export const TeamGraphTab = ({ onCreateTask={openCreateTask} onToggleSidebar={toggleSidebarVisible} isSidebarVisible={sidebarVisible} + renderTopToolbarContent={() => ( + + )} onOwnerSlotDrop={commitOwnerSlotDrop} renderHud={(hudProps) => { const extraHudProps = hudProps as typeof hudProps & { @@ -165,7 +164,7 @@ export const TeamGraphTab = ({ worldToScreen?: (x: number, y: number) => { x: number; y: number }; getNodeWorldPosition?: (nodeId: string) => { x: number; y: number } | null; }; - const { getLaunchAnchorScreenPlacement, getViewportSize, focusNodeIds } = extraHudProps; + const { getViewportSize, focusNodeIds } = extraHudProps; return ( <> @@ -182,12 +181,6 @@ export const TeamGraphTab = ({ onOpenTaskDetail={dispatchOpenTask} onOpenMemberProfile={dispatchOpenProfile} /> - ); }} diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index e1d10700..8b9ca8e5 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -86,6 +86,27 @@ interface RefreshTeamDataOptions { } type TeamGraphSlotAssignments = Record; +type TeamGraphMemberSeedInput = Pick; + +const SMALL_TEAM_CARDINAL_SLOT_PRESETS: ReadonlyArray> = [ + [], + [{ ringIndex: 0, sectorIndex: 0 }], + [ + { ringIndex: 0, sectorIndex: 5 }, + { ringIndex: 0, sectorIndex: 1 }, + ], + [ + { ringIndex: 0, sectorIndex: 5 }, + { ringIndex: 0, sectorIndex: 1 }, + { ringIndex: 0, sectorIndex: 3 }, + ], + [ + { ringIndex: 0, sectorIndex: 5 }, + { ringIndex: 0, sectorIndex: 1 }, + { ringIndex: 0, sectorIndex: 4 }, + { ringIndex: 0, sectorIndex: 2 }, + ], +]; export function isTeamDataRefreshPending(teamName: string): boolean { return ( @@ -943,7 +964,7 @@ export function selectTeamDataForName( function migrateStableSlotAssignmentsForMembers( assignments: TeamGraphSlotAssignments | undefined, - members: readonly Pick[] + members: readonly TeamGraphMemberSeedInput[] ): { assignments: TeamGraphSlotAssignments; changed: boolean } { const nextAssignments: TeamGraphSlotAssignments = { ...(assignments ?? {}) }; let changed = false; @@ -970,6 +991,36 @@ function migrateStableSlotAssignmentsForMembers( return { assignments: nextAssignments, changed }; } +function seedStableSlotAssignmentsForMembers( + assignments: TeamGraphSlotAssignments, + members: readonly TeamGraphMemberSeedInput[] +): { assignments: TeamGraphSlotAssignments; changed: boolean } { + const visibleMembers = members.filter((member) => !member.removedAt); + if (visibleMembers.length === 0 || visibleMembers.length > 4) { + return { assignments, changed: false }; + } + + const visibleStableOwnerIds = visibleMembers.map((member) => getStableTeamOwnerId(member)); + const hasAnyVisibleAssignments = visibleStableOwnerIds.some( + (stableOwnerId) => assignments[stableOwnerId] != null + ); + if (hasAnyVisibleAssignments) { + 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]!; + }); + + return { assignments: nextAssignments, changed: true }; +} + function isVisibleInActiveTeamSurface( state: Pick, teamName: string | null | undefined @@ -1077,7 +1128,7 @@ export interface TeamSlice { clearKanbanFilter: () => void; ensureTeamGraphSlotAssignments: ( teamName: string, - members: readonly Pick[] + members: readonly TeamGraphMemberSeedInput[] ) => void; setTeamGraphOwnerSlotAssignment: ( teamName: string, @@ -1783,10 +1834,11 @@ export const createTeamSlice: StateCreator = (set, const currentAssignments = nextSlotAssignmentsByTeam[teamName]; const migrated = migrateStableSlotAssignmentsForMembers(currentAssignments, members); - if (migrated.changed) { + const seeded = seedStableSlotAssignmentsForMembers(migrated.assignments, members); + if (migrated.changed || seeded.changed) { nextSlotAssignmentsByTeam = { ...nextSlotAssignmentsByTeam, - [teamName]: migrated.assignments, + [teamName]: seeded.assignments, }; changed = true; } diff --git a/test/renderer/features/agent-graph/GraphProvisioningHud.test.ts b/test/renderer/features/agent-graph/GraphProvisioningHud.test.ts index 49a29299..383f7951 100644 --- a/test/renderer/features/agent-graph/GraphProvisioningHud.test.ts +++ b/test/renderer/features/agent-graph/GraphProvisioningHud.test.ts @@ -46,8 +46,6 @@ vi.mock('@renderer/components/team/TeamProvisioningPanel', () => ({ ), })); -const placement = { x: 120, y: 80, scale: 1, visible: true }; - describe('GraphProvisioningHud', () => { afterEach(() => { document.body.innerHTML = ''; @@ -77,8 +75,6 @@ describe('GraphProvisioningHud', () => { root.render( React.createElement(GraphProvisioningHud, { teamName: 'northstar-core', - leadNodeId: 'lead:northstar-core', - getLaunchAnchorScreenPlacement: () => placement, }) ); await Promise.resolve(); @@ -117,14 +113,12 @@ describe('GraphProvisioningHud', () => { root.render( React.createElement(GraphProvisioningHud, { teamName: 'northstar-core', - leadNodeId: 'lead:northstar-core', - getLaunchAnchorScreenPlacement: () => placement, }) ); await Promise.resolve(); }); - const openButton = host.querySelector('button[aria-label="Open full launch details"]'); + const openButton = host.querySelector('button[aria-label="Open launch details"]'); expect(openButton).not.toBeNull(); await act(async () => { @@ -165,8 +159,6 @@ describe('GraphProvisioningHud', () => { root.render( React.createElement(GraphProvisioningHud, { teamName: 'northstar-core', - leadNodeId: 'lead:northstar-core', - getLaunchAnchorScreenPlacement: () => placement, enabled: false, }) ); diff --git a/test/renderer/features/agent-graph/useGraphSimulation.test.ts b/test/renderer/features/agent-graph/useGraphSimulation.test.ts index 7622f3a6..2db1509f 100644 --- a/test/renderer/features/agent-graph/useGraphSimulation.test.ts +++ b/test/renderer/features/agent-graph/useGraphSimulation.test.ts @@ -98,7 +98,7 @@ describe('stable slot layout planner', () => { expect(snapshot).toBeNull(); }); - it('builds launch and activity geometry around the central lead block', () => { + it('builds lead activity inside the same central owner slot topology', () => { const teamName = 'team-a'; const lead = createLead(teamName); const alice = createMember(teamName, 'agent-alice', 'alice'); @@ -118,11 +118,13 @@ describe('stable slot layout planner', () => { expect(snapshot).not.toBeNull(); expect(snapshot?.leadNodeId).toBe(lead.id); - expect(snapshot?.launchAnchor).not.toBeNull(); + expect(snapshot?.launchAnchor).toBeNull(); + expect(snapshot?.leadSlotFrame.ownerId).toBe(lead.id); expect(snapshot?.memberSlotFrames).toHaveLength(1); expect(snapshot?.memberSlotFrames[0]?.ownerId).toBe(alice.id); - expect(snapshot?.leadActivityRect.left).toBeLessThan(snapshot?.leadCoreRect.left ?? 0); - expect(snapshot?.fitBounds.right).toBeGreaterThan(snapshot?.leadCoreRect.right ?? 0); + expect(snapshot?.leadActivityRect.top).toBeGreaterThan(snapshot?.leadCoreRect.bottom ?? 0); + expect(snapshot?.leadSlotFrame.activityColumnRect.left).toBe(snapshot?.leadActivityRect.left); + expect(snapshot?.leadSlotFrame.activityColumnRect.top).toBe(snapshot?.leadActivityRect.top); expect(validateStableSlotLayout(snapshot!)).toEqual({ valid: true }); }); @@ -309,7 +311,7 @@ describe('stable slot layout planner', () => { expect(validateStableSlotLayout(invalid).valid).toBe(false); }); - it('rejects member frames that overlap lead activity and launch central collision rects', () => { + it('rejects member frames that overlap the lead central reserved block', () => { const teamName = 'team-central-rects'; const lead = createLead(teamName); const alice = createMember(teamName, 'agent-alice', 'alice'); @@ -329,27 +331,16 @@ describe('stable slot layout planner', () => { expect(snapshot).not.toBeNull(); const [frame] = snapshot!.memberSlotFrames; - const overlappingLeadActivity = translateSlotFrame( + const overlappingLeadBlock = translateSlotFrame( frame, - snapshot!.leadActivityRect.left - frame.bounds.left + 1, - snapshot!.leadActivityRect.top - frame.bounds.top + 1 - ); - const overlappingLaunchHud = translateSlotFrame( - frame, - snapshot!.launchHudRect.left - frame.bounds.left + 1, - snapshot!.launchHudRect.top - frame.bounds.top + 1 + snapshot!.leadCentralReservedBlock.left - frame.bounds.left + 1, + snapshot!.leadCentralReservedBlock.top - frame.bounds.top + 1 ); expect( validateStableSlotLayout({ ...snapshot!, - memberSlotFrames: [overlappingLeadActivity], - }).valid - ).toBe(false); - expect( - validateStableSlotLayout({ - ...snapshot!, - memberSlotFrames: [overlappingLaunchHud], + memberSlotFrames: [overlappingLeadBlock], }).valid ).toBe(false); }); @@ -629,6 +620,50 @@ describe('stable slot layout planner', () => { } }); + it('positions lead-owned tasks inside the lead kanban band instead of unassigned', () => { + const teamName = 'team-lead-owned-tasks'; + const lead = createLead(teamName); + const alice = createMember(teamName, 'agent-alice', 'alice'); + const leadTasks = [ + createTask(teamName, 'lead-a', lead.id, { taskStatus: 'completed' }), + createTask(teamName, 'lead-b', lead.id, { taskStatus: 'in_progress' }), + ]; + const layout: GraphLayoutPort = { + version: 'stable-slots-v1', + ownerOrder: [alice.id], + slotAssignments: { + [alice.id]: { ringIndex: 0, sectorIndex: 1 }, + }, + }; + + const nodes = [lead, alice, ...leadTasks]; + const snapshot = buildStableSlotLayoutSnapshot({ + teamName, + nodes, + layout, + }); + + expect(snapshot).not.toBeNull(); + expect(snapshot?.unassignedTaskRect).toBeNull(); + lead.x = snapshot!.leadSlotFrame.ownerX; + lead.y = snapshot!.leadSlotFrame.ownerY; + alice.x = snapshot!.memberSlotFrames[0]?.ownerX; + alice.y = snapshot!.memberSlotFrames[0]?.ownerY; + + KanbanLayoutEngine.layout(nodes, { + leadSlotFrame: snapshot!.leadSlotFrame, + memberSlotFrames: snapshot!.memberSlotFrames, + unassignedTaskRect: snapshot!.unassignedTaskRect, + }); + + for (const task of leadTasks) { + expect(task.x).toBeGreaterThanOrEqual(snapshot!.leadSlotFrame.kanbanBandRect.left); + expect(task.x).toBeLessThanOrEqual(snapshot!.leadSlotFrame.kanbanBandRect.right); + expect(task.y).toBeGreaterThanOrEqual(snapshot!.leadSlotFrame.kanbanBandRect.top); + expect(task.y).toBeLessThanOrEqual(snapshot!.leadSlotFrame.kanbanBandRect.bottom); + } + }); + it('keeps the same sector and spills to the next outer ring when the saved slot is already occupied', () => { const teamName = 'team-wide-spill'; const lead = createLead(teamName); diff --git a/test/renderer/store/teamSlice.test.ts b/test/renderer/store/teamSlice.test.ts index d91c919f..a7e9e286 100644 --- a/test/renderer/store/teamSlice.test.ts +++ b/test/renderer/store/teamSlice.test.ts @@ -259,6 +259,48 @@ describe('teamSlice actions', () => { }); }); + it('seeds first-open cardinal slot defaults for small visible teams with no saved placements', () => { + const store = createSliceStore(); + + 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: 5 }, + 'agent-bob': { ringIndex: 0, sectorIndex: 1 }, + 'agent-tom': { ringIndex: 0, sectorIndex: 4 }, + 'agent-jack': { ringIndex: 0, sectorIndex: 2 }, + }); + }); + + it('seeds visible members even when only hidden owners have saved placements', () => { + const store = createSliceStore(); + store.setState({ + slotLayoutVersion: 'stable-slots-v1', + slotAssignmentsByTeam: { + 'my-team': { + 'agent-hidden': { ringIndex: 2, sectorIndex: 4 }, + }, + }, + }); + + store.getState().ensureTeamGraphSlotAssignments('my-team', [ + { name: 'hidden', agentId: 'agent-hidden', removedAt: '2026-04-16T08:00:00.000Z' }, + { name: 'alice', agentId: 'agent-alice' }, + { name: 'bob', agentId: 'agent-bob' }, + ]); + + expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({ + 'agent-hidden': { ringIndex: 2, sectorIndex: 4 }, + 'agent-alice': { ringIndex: 0, sectorIndex: 5 }, + 'agent-bob': { ringIndex: 0, sectorIndex: 1 }, + }); + }); + it('resets stale slot assignments when slot layout version mismatches', () => { const store = createSliceStore(); store.setState({ @@ -278,7 +320,11 @@ describe('teamSlice actions', () => { ]); expect(store.getState().slotLayoutVersion).toBe('stable-slots-v1'); - expect(store.getState().slotAssignmentsByTeam).toEqual({}); + expect(store.getState().slotAssignmentsByTeam).toEqual({ + 'my-team': { + 'agent-alice': { ringIndex: 0, sectorIndex: 0 }, + }, + }); }); it('keeps hidden-member slot assignments so the same stable owner can reuse them later', () => {