From 46304421498a4e296d9cd7ce8e409cb93f610790 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 16 Apr 2026 13:05:16 +0300 Subject: [PATCH] fix(agent-graph): stabilize slot layout interactions --- .../src/hooks/useGraphSimulation.ts | 12 + .../agent-graph/src/layout/stableSlots.ts | 274 +++++++++++++++++- packages/agent-graph/src/ui/GraphCanvas.tsx | 57 ++++ packages/agent-graph/src/ui/GraphView.tsx | 194 ++++++++++--- .../renderer/hooks/useTeamGraphAdapter.ts | 21 +- .../renderer/ui/TeamGraphOverlay.tsx | 15 +- .../agent-graph/renderer/ui/TeamGraphTab.tsx | 15 +- src/renderer/store/slices/teamSlice.ts | 126 +++++++- .../agent-graph/useGraphSimulation.test.ts | 133 +++++++++ test/renderer/store/teamSlice.test.ts | 112 ++++++- 10 files changed, 890 insertions(+), 69 deletions(-) diff --git a/packages/agent-graph/src/hooks/useGraphSimulation.ts b/packages/agent-graph/src/hooks/useGraphSimulation.ts index 5da1294c..cd4d62ad 100644 --- a/packages/agent-graph/src/hooks/useGraphSimulation.ts +++ b/packages/agent-graph/src/hooks/useGraphSimulation.ts @@ -38,6 +38,7 @@ export interface UseGraphSimulationResult { tick: (dt: number) => void; setNodePosition: (nodeId: string, x: number, y: number) => void; clearNodePosition: (nodeId: string) => void; + clearTransientOwnerPositions: () => void; resolveNearestOwnerSlot: ( nodeId: string, x: number, @@ -46,6 +47,8 @@ export interface UseGraphSimulationResult { assignment: GraphOwnerSlotAssignment; displacedOwnerId?: string; displacedAssignment?: GraphOwnerSlotAssignment; + previewOwnerX: number; + previewOwnerY: number; } | null; getLaunchAnchorWorldPosition: (leadNodeId: string) => { x: number; y: number } | null; getActivityWorldRect: (nodeId: string) => StableRect | null; @@ -199,6 +202,14 @@ export function useGraphSimulation(): UseGraphSimulationResult { [applyCurrentLayout] ); + const clearTransientOwnerPositions = useCallback(() => { + if (dragOwnerPositionsRef.current.size === 0) { + return; + } + dragOwnerPositionsRef.current.clear(); + applyCurrentLayout(); + }, [applyCurrentLayout]); + const resolveNearestOwnerSlot = useCallback( (nodeId: string, x: number, y: number) => { const snapshot = layoutSnapshotRef.current; @@ -234,6 +245,7 @@ export function useGraphSimulation(): UseGraphSimulationResult { tick, setNodePosition, clearNodePosition, + clearTransientOwnerPositions, resolveNearestOwnerSlot, getLaunchAnchorWorldPosition: (leadNodeId: string) => launchAnchorPositionsRef.current.get(leadNodeId) ?? null, diff --git a/packages/agent-graph/src/layout/stableSlots.ts b/packages/agent-graph/src/layout/stableSlots.ts index 76b9f110..070323ea 100644 --- a/packages/agent-graph/src/layout/stableSlots.ts +++ b/packages/agent-graph/src/layout/stableSlots.ts @@ -77,6 +77,8 @@ interface NearestSlotAssignmentResult { assignment: GraphOwnerSlotAssignment; displacedOwnerId?: string; displacedAssignment?: GraphOwnerSlotAssignment; + previewOwnerX: number; + previewOwnerY: number; } interface RankedNearestSlotAssignmentResult extends NearestSlotAssignmentResult { @@ -116,8 +118,41 @@ const SLOT_GEOMETRY = { const PROCESS_RAIL_NODE_GAP = 42; const PROCESS_RAIL_NODE_FOOTPRINT = 28; const GEOMETRY_EPSILON = 0.001; +const SMALL_TEAM_CARDINAL_RADIUS_STEP = 24; const SECTOR_VECTORS = STABLE_SLOT_SECTOR_VECTORS; +const SMALL_TEAM_CARDINAL_LAYOUTS: ReadonlyArray< + ReadonlyArray<{ + assignment: GraphOwnerSlotAssignment; + vector: { x: number; y: number }; + }> +> = [ + [], + [{ assignment: { ringIndex: 0, sectorIndex: 0 }, vector: { x: 0, y: -1 } }], + [ + { assignment: { ringIndex: 0, sectorIndex: 0 }, vector: { x: -1, y: 0 } }, + { assignment: { ringIndex: 0, sectorIndex: 1 }, vector: { x: 1, y: 0 } }, + ], + [ + { assignment: { ringIndex: 0, sectorIndex: 0 }, vector: { x: 0, y: -1 } }, + { assignment: { ringIndex: 0, sectorIndex: 1 }, vector: { x: -1, y: 0 } }, + { assignment: { ringIndex: 0, sectorIndex: 2 }, vector: { x: 1, y: 0 } }, + ], + [ + { assignment: { ringIndex: 0, sectorIndex: 0 }, vector: { x: 0, y: -1 } }, + { assignment: { ringIndex: 0, sectorIndex: 1 }, vector: { x: 1, y: 0 } }, + { assignment: { ringIndex: 0, sectorIndex: 2 }, vector: { x: 0, y: 1 } }, + { assignment: { ringIndex: 0, sectorIndex: 3 }, vector: { x: -1, y: 0 } }, + ], +]; + +const SMALL_TEAM_CARDINAL_ASSIGNMENTS: ReadonlyArray> = + SMALL_TEAM_CARDINAL_LAYOUTS.map((layout) => layout.map((slot) => slot.assignment)); +const SMALL_TEAM_CARDINAL_VECTOR_BY_ASSIGNMENT_KEY = new Map( + SMALL_TEAM_CARDINAL_LAYOUTS.flatMap((layout) => + layout.map((slot) => [buildAssignmentKey(slot.assignment), slot.vector] as const) + ) +); export function buildStableSlotLayoutSnapshot({ teamName, @@ -378,6 +413,17 @@ export function resolveNearestSlotAssignment(args: { return null; } + const strictSmallTeamCandidate = resolveStrictSmallTeamNearestSlotAssignment({ + ownerId: args.ownerId, + ownerX: args.ownerX, + ownerY: args.ownerY, + currentFrame, + snapshot: args.snapshot, + }); + if (strictSmallTeamCandidate) { + return strictSmallTeamCandidate; + } + const existingFrames = args.snapshot.memberSlotFrames.filter((frame) => frame.ownerId !== args.ownerId); const maxOccupiedRing = existingFrames.reduce((max, frame) => Math.max(max, frame.ringIndex), 0); const candidateAssignments = buildCandidateAssignments( @@ -423,10 +469,93 @@ export function resolveNearestSlotAssignment(args: { assignment: best.assignment, displacedOwnerId: best.displacedOwnerId, displacedAssignment: best.displacedAssignment, + previewOwnerX: best.previewOwnerX, + previewOwnerY: best.previewOwnerY, } : null; } +function resolveStrictSmallTeamNearestSlotAssignment(args: { + ownerId: string; + ownerX: number; + ownerY: number; + currentFrame: SlotFrame; + snapshot: StableSlotLayoutSnapshot; +}): NearestSlotAssignmentResult | null { + const strictFrames = getStrictSmallTeamFrames(args.snapshot.memberSlotFrames); + if (!strictFrames) { + return null; + } + + let best: + | { + frame: SlotFrame; + distanceSquared: number; + } + | null = null; + for (const frame of strictFrames) { + const dx = frame.ownerX - args.ownerX; + const dy = frame.ownerY - args.ownerY; + const distanceSquared = dx * dx + dy * dy; + if (!best || distanceSquared < best.distanceSquared) { + best = { frame, distanceSquared }; + } + } + + if (!best) { + return null; + } + + const targetFrame = best.frame; + if (targetFrame.ownerId === args.ownerId) { + return { + assignment: { + ringIndex: targetFrame.ringIndex, + sectorIndex: targetFrame.sectorIndex, + }, + previewOwnerX: targetFrame.ownerX, + previewOwnerY: targetFrame.ownerY, + }; + } + + return { + assignment: { + ringIndex: targetFrame.ringIndex, + sectorIndex: targetFrame.sectorIndex, + }, + displacedOwnerId: targetFrame.ownerId, + displacedAssignment: { + ringIndex: args.currentFrame.ringIndex, + sectorIndex: args.currentFrame.sectorIndex, + }, + previewOwnerX: targetFrame.ownerX, + previewOwnerY: targetFrame.ownerY, + }; +} + +function getStrictSmallTeamFrames(frames: readonly SlotFrame[]): readonly SlotFrame[] | null { + if (frames.length === 0 || frames.length > 4) { + return null; + } + const preset = SMALL_TEAM_CARDINAL_ASSIGNMENTS[frames.length]; + if (!preset || preset.length !== frames.length) { + return null; + } + + const actualAssignmentKeys = frames + .map((frame) => buildAssignmentKey({ ringIndex: frame.ringIndex, sectorIndex: frame.sectorIndex })) + .sort(); + const presetAssignmentKeys = preset.map((assignment) => buildAssignmentKey(assignment)).sort(); + + for (let index = 0; index < presetAssignmentKeys.length; index += 1) { + if (actualAssignmentKeys[index] !== presetAssignmentKeys[index]) { + return null; + } + } + + return frames; +} + export function validateStableSlotLayout( snapshot: StableSlotLayoutSnapshot ): StableSlotLayoutValidationResult { @@ -730,6 +859,18 @@ function planOwnerSlots( runtimeCentralExclusion: StableRect, layout?: GraphLayoutPort ): SlotFrame[] { + const strictSmallTeamFrames = shouldUseStrictSmallTeamCardinalLayout(ownerFootprints, layout) + ? planStrictSmallTeamOwnerSlots( + ownerFootprints, + centralCollisionRects, + runtimeCentralExclusion, + layout + ) + : null; + if (strictSmallTeamFrames) { + return strictSmallTeamFrames; + } + const placedFrames: SlotFrame[] = []; const preferredAssignments = buildPreferredAssignmentsMap(layout?.slotAssignments); const usedSlotKeys = new Set(); @@ -754,6 +895,105 @@ function planOwnerSlots( return placedFrames; } +function shouldUseStrictSmallTeamCardinalLayout( + ownerFootprints: readonly OwnerFootprint[], + layout?: GraphLayoutPort +): boolean { + if (ownerFootprints.length === 0 || ownerFootprints.length > 4) { + return false; + } + + const preset = SMALL_TEAM_CARDINAL_ASSIGNMENTS[ownerFootprints.length]; + if (!preset || preset.length !== ownerFootprints.length) { + return false; + } + + const actualAssignmentKeys = ownerFootprints + .map((footprint) => layout?.slotAssignments?.[footprint.ownerId]) + .filter((assignment): assignment is GraphOwnerSlotAssignment => assignment != null) + .map((assignment) => buildAssignmentKey(assignment)) + .sort(); + const presetAssignmentKeys = preset.map((assignment) => buildAssignmentKey(assignment)).sort(); + + if (actualAssignmentKeys.length !== presetAssignmentKeys.length) { + return false; + } + + for (let index = 0; index < presetAssignmentKeys.length; index += 1) { + if (actualAssignmentKeys[index] !== presetAssignmentKeys[index]) { + return false; + } + } + + return true; +} + +function planStrictSmallTeamOwnerSlots( + ownerFootprints: readonly OwnerFootprint[], + centralCollisionRects: readonly StableRect[], + runtimeCentralExclusion: StableRect, + layout?: GraphLayoutPort +): SlotFrame[] | null { + if (ownerFootprints.length === 0 || ownerFootprints.length > 4) { + return null; + } + + const preset = SMALL_TEAM_CARDINAL_LAYOUTS[ownerFootprints.length]; + if (!preset || preset.length !== ownerFootprints.length) { + return null; + } + + const slotConfigs = ownerFootprints.map((footprint) => { + const assignment = layout?.slotAssignments?.[footprint.ownerId]; + if (!assignment) { + return null; + } + const vector = SMALL_TEAM_CARDINAL_VECTOR_BY_ASSIGNMENT_KEY.get(buildAssignmentKey(assignment)); + if (!vector) { + return null; + } + return { + footprint, + assignment, + vector, + }; + }); + + if (slotConfigs.some((slot) => slot == null)) { + return null; + } + + let radius = Math.max( + ...slotConfigs.map((slot) => + resolveMinimumDirectionalRadiusForVector({ + vector: slot!.vector, + footprint: slot!.footprint, + centralCollisionRects, + runtimeCentralExclusion, + }) + ) + ); + + for (let iteration = 0; iteration < 48; iteration += 1) { + const frames = slotConfigs.map((slot) => + buildSlotFrameAtRadiusWithVector(slot!.footprint, slot!.assignment, radius, slot!.vector) + ); + const allValid = frames.every((frame, frameIndex) => + isSlotFramePlacementValid( + frame, + frames.filter((_, index) => index !== frameIndex), + centralCollisionRects + ) + ); + if (allValid) { + return frames; + } + radius += SMALL_TEAM_CARDINAL_RADIUS_STEP; + } + + return null; +} + function buildPreferredAssignmentsMap( assignments?: Record ): Map { @@ -870,6 +1110,15 @@ function buildSlotFrameAtRadius( radius: number ): SlotFrame { const vector = SECTOR_VECTORS[assignment.sectorIndex % SECTOR_VECTORS.length] ?? SECTOR_VECTORS[0]; + return buildSlotFrameAtRadiusWithVector(footprint, assignment, radius, vector); +} + +function buildSlotFrameAtRadiusWithVector( + footprint: OwnerFootprint, + assignment: GraphOwnerSlotAssignment, + radius: number, + vector: { x: number; y: number } +): SlotFrame { const ownerX = vector.x * radius; const ownerY = vector.y * radius; const slotTop = @@ -1122,6 +1371,8 @@ function buildRankedNearestSlotAssignmentResult(args: { assignment: args.assignment, displacedOwnerId: args.displacedOwnerId, displacedAssignment: args.displacedAssignment, + previewOwnerX: args.frame.ownerX, + previewOwnerY: args.frame.ownerY, distanceSquared: dx * dx + dy * dy, }; } @@ -1377,14 +1628,33 @@ function resolveMinimumDirectionalRadius(args: { footprint: OwnerFootprint; centralCollisionRects: readonly StableRect[]; runtimeCentralExclusion: StableRect; +}): number { + return resolveMinimumDirectionalRadiusForVector({ + vector: SECTOR_VECTORS[args.assignment.sectorIndex % SECTOR_VECTORS.length] ?? SECTOR_VECTORS[0], + footprint: args.footprint, + centralCollisionRects: args.centralCollisionRects, + runtimeCentralExclusion: args.runtimeCentralExclusion, + }); +} + +function resolveMinimumDirectionalRadiusForVector(args: { + vector: { x: number; y: number }; + footprint: OwnerFootprint; + centralCollisionRects: readonly StableRect[]; + runtimeCentralExclusion: StableRect; }): number { const legacyRadiusHint = computeLegacyMinimumRingRadius( - SECTOR_VECTORS[args.assignment.sectorIndex % SECTOR_VECTORS.length] ?? SECTOR_VECTORS[0], + args.vector, args.footprint, args.runtimeCentralExclusion ); const overlapsCentralCollision = (radius: number): boolean => { - const frame = buildSlotFrameAtRadius(args.footprint, args.assignment, radius); + const frame = buildSlotFrameAtRadiusWithVector( + args.footprint, + { ringIndex: 0, sectorIndex: 0 }, + radius, + args.vector + ); return rectOverlapsAnyCentralRect(frame.bounds, args.centralCollisionRects); }; diff --git a/packages/agent-graph/src/ui/GraphCanvas.tsx b/packages/agent-graph/src/ui/GraphCanvas.tsx index 1e8c893d..516e3a5f 100644 --- a/packages/agent-graph/src/ui/GraphCanvas.tsx +++ b/packages/agent-graph/src/ui/GraphCanvas.tsx @@ -24,6 +24,7 @@ import { drawAgents, drawCrossTeamNodes } from '../canvas/draw-agents'; import { drawTasks, drawColumnHeaders } from '../canvas/draw-tasks'; import { drawProcesses } from '../canvas/draw-processes'; import { drawEffects, type VisualEffect } from '../canvas/draw-effects'; +import { drawHexagon } from '../canvas/draw-misc'; import { BloomRenderer } from '../canvas/bloom-renderer'; import { KanbanLayoutEngine } from '../layout/kanbanLayout'; import { @@ -36,6 +37,7 @@ import { updateTransientHandoffState, } from './transientHandoffs'; import type { CameraTransform } from '../hooks/useGraphCamera'; +import { NODE } from '../constants/canvas-constants'; // ─── Draw State (passed by ref, not by props — no React re-renders) ───────── @@ -53,6 +55,14 @@ export interface GraphDrawState { hoveredEdgeId: string | null; focusNodeIds: ReadonlySet | null; focusEdgeIds: ReadonlySet | null; + dragPreview: + | { + nodeId: string; + x: number; + y: number; + color?: string | null; + } + | null; } export interface GraphCanvasHandle { @@ -341,6 +351,9 @@ export const GraphCanvas = forwardRef(funct state.focusNodeIds, zoom ); + if (state.dragPreview) { + drawOwnerSlotPreview(ctx, state.dragPreview, state.time); + } // 2d. Effects drawEffects(ctx, state.effects); @@ -437,3 +450,47 @@ export const GraphCanvas = forwardRef(funct ); }); + +function drawOwnerSlotPreview( + ctx: CanvasRenderingContext2D, + preview: NonNullable, + time: number +): void { + const radius = NODE.radiusMember; + const outerRadius = radius + 18; + const innerRadius = radius + 8; + const glowRadius = radius + 34; + const color = preview.color ?? '#8bd3ff'; + const pulse = 0.35 + 0.15 * Math.sin(time * 6); + + ctx.save(); + ctx.globalAlpha = 0.7 + pulse; + ctx.setLineDash([8, 6]); + ctx.lineDashOffset = -time * 48; + ctx.lineWidth = 2.5; + + drawHexagon(ctx, preview.x, preview.y, outerRadius); + ctx.strokeStyle = color; + ctx.stroke(); + + ctx.setLineDash([]); + drawHexagon(ctx, preview.x, preview.y, innerRadius); + ctx.fillStyle = 'rgba(120, 190, 255, 0.08)'; + ctx.fill(); + + const glow = ctx.createRadialGradient( + preview.x, + preview.y, + radius * 0.45, + preview.x, + preview.y, + glowRadius + ); + glow.addColorStop(0, 'rgba(120, 190, 255, 0.12)'); + glow.addColorStop(1, 'rgba(120, 190, 255, 0)'); + ctx.beginPath(); + ctx.arc(preview.x, preview.y, glowRadius, 0, Math.PI * 2); + ctx.fillStyle = glow; + ctx.fill(); + ctx.restore(); +} diff --git a/packages/agent-graph/src/ui/GraphView.tsx b/packages/agent-graph/src/ui/GraphView.tsx index 7daf3e57..a842bbf9 100644 --- a/packages/agent-graph/src/ui/GraphView.tsx +++ b/packages/agent-graph/src/ui/GraphView.tsx @@ -43,6 +43,7 @@ export interface GraphViewProps { onRequestClose?: () => void; onRequestPinAsTab?: () => void; onRequestFullscreen?: () => void; + isSurfaceActive?: boolean; onOpenTeamPage?: () => void; onCreateTask?: () => void; onToggleSidebar?: () => void; @@ -89,6 +90,7 @@ export function GraphView({ onRequestClose, onRequestPinAsTab, onRequestFullscreen, + isSurfaceActive = true, onOpenTeamPage, onCreateTask, onToggleSidebar, @@ -127,6 +129,12 @@ export function GraphView({ const allowAutoFitRef = useRef(true); const nodeMapRef = useRef(new Map()); const nodeMapNodesRef = useRef(null); + const dragPreviewRef = useRef<{ + nodeId: string; + x: number; + y: number; + color?: string | null; + } | null>(null); // ─── Hooks ────────────────────────────────────────────────────────────── const simulation = useGraphSimulation(); @@ -284,6 +292,7 @@ export function GraphView({ hoveredEdgeId: hoveredEdgeIdRef.current, focusNodeIds: focusState.focusNodeIds, focusEdgeIds: focusState.focusEdgeIds, + dragPreview: dragPreviewRef.current, }); rafRef.current = requestAnimationFrame(animate); @@ -370,6 +379,17 @@ export function GraphView({ allowAutoFitRef.current = false; }, []); + useLayoutEffect(() => { + if (!isSurfaceActive) { + return; + } + interaction.handleMouseUp(); + simulation.clearTransientOwnerPositions(); + dragPreviewRef.current = null; + isPanningRef.current = false; + edgeMouseDownRef.current = null; + }, [interaction, isSurfaceActive, simulation]); + const handleWheel = useCallback( (e: WheelEvent) => { markUserInteracted(); @@ -385,6 +405,7 @@ export function GraphView({ const handleMouseDown = useCallback( (e: React.MouseEvent) => { if (e.button !== 0) return; // only left click + dragPreviewRef.current = null; const canvas = canvasHandle.current?.getCanvas(); if (!canvas) return; @@ -435,59 +456,64 @@ export function GraphView({ ] ); - const handleMouseMove = useCallback( - (e: React.MouseEvent) => { - // Dragging with left button held - if (e.buttons & 1) { - if (isPanningRef.current) { - camera.handlePanMove(e.clientX, e.clientY); - return; - } - const canvas = canvasHandle.current?.getCanvas(); - if (!canvas) return; - const rect = canvas.getBoundingClientRect(); - const world = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top); - interaction.handleMouseMove(world.x, world.y, getVisibleNodes(simulation.stateRef.current.nodes)); - return; + const processActivePointerMove = useCallback( + (clientX: number, clientY: number, buttons: number) => { + if ((buttons & 1) === 0) { + dragPreviewRef.current = null; + return false; + } + + if (isPanningRef.current) { + camera.handlePanMove(clientX, clientY); + return true; } - // No button held — hover detection + cursor update const canvas = canvasHandle.current?.getCanvas(); - if (!canvas) return; - const rect = canvas.getBoundingClientRect(); - const world = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top); - const nodes = getVisibleNodes(simulation.stateRef.current.nodes); - const visibleNodeIds = new Set(nodes.map((node) => node.id)); - const edges = getVisibleEdges(simulation.stateRef.current.edges, visibleNodeIds); - - const hoveredNodeId = findNodeAt(world.x, world.y, nodes); - interaction.hoveredNodeId.current = hoveredNodeId; - - if (hoveredNodeId) { - hoveredEdgeIdRef.current = null; - canvas.style.cursor = 'pointer'; - return; + if (!canvas) { + dragPreviewRef.current = null; + return false; } - const nodeMap = getNodeMap(nodes); - const interactiveEdges = getInteractiveEdges(canvas, nodes, edges); - hoveredEdgeIdRef.current = findEdgeAt(world.x, world.y, interactiveEdges, nodeMap); - canvas.style.cursor = hoveredEdgeIdRef.current ? 'pointer' : 'grab'; + const rect = canvas.getBoundingClientRect(); + const world = camera.screenToWorld(clientX - rect.left, clientY - rect.top); + interaction.handleMouseMove(world.x, world.y, getVisibleNodes(simulation.stateRef.current.nodes)); + + const draggedNodeId = interaction.dragNodeId.current; + if (interaction.isDragging.current && draggedNodeId) { + const draggedNode = simulation.stateRef.current.nodes.find((node) => node.id === draggedNodeId); + if (draggedNode?.kind === 'member') { + const nearest = simulation.resolveNearestOwnerSlot(draggedNodeId, world.x, world.y); + if (nearest) { + dragPreviewRef.current = { + nodeId: draggedNodeId, + x: nearest.previewOwnerX, + y: nearest.previewOwnerY, + color: draggedNode.color, + }; + return true; + } + } + } + + dragPreviewRef.current = null; + return true; }, - [camera, getInteractiveEdges, getNodeMap, getVisibleEdges, getVisibleNodes, interaction, simulation.stateRef] + [camera, getVisibleNodes, interaction, simulation] ); - const handleMouseUp = useCallback( - (e: React.MouseEvent) => { + const completePointerInteraction = useCallback( + (clientX: number, clientY: number) => { const draggedNodeId = interaction.dragNodeId.current; const wasDragging = interaction.isDragging.current; if (isPanningRef.current) { camera.handlePanEnd(); isPanningRef.current = false; - setSelectedNodeId(null); // hide popover after pan + dragPreviewRef.current = null; + setSelectedNodeId(null); setSelectedEdgeId(null); edgeMouseDownRef.current = null; + interaction.handleMouseUp(); return; } @@ -510,11 +536,13 @@ export function GraphView({ requestAnimationFrame(() => { simulation.clearNodePosition(draggedNodeId); }); + dragPreviewRef.current = null; edgeMouseDownRef.current = null; return; } } simulation.clearNodePosition(draggedNodeId); + dragPreviewRef.current = null; edgeMouseDownRef.current = null; return; } @@ -529,7 +557,7 @@ export function GraphView({ let clickedEdgeId: string | null = null; if (canvas && edgeMouseDownRef.current && !interaction.isDragging.current) { const rect = canvas.getBoundingClientRect(); - const world = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top); + const world = camera.screenToWorld(clientX - rect.left, clientY - rect.top); const dx = world.x - edgeMouseDownRef.current.x; const dy = world.y - edgeMouseDownRef.current.y; if (dx * dx + dy * dy <= 25) { @@ -548,17 +576,103 @@ export function GraphView({ events?.onEdgeClick?.(edge); } } else { - setSelectedNodeId(null); // click on empty space — hide popover + setSelectedNodeId(null); setSelectedEdgeId(null); } if (!interaction.isDragging.current && !clickedEdgeId) { events?.onBackgroundClick?.(); } } + dragPreviewRef.current = null; }, - [camera, data.teamName, events, interaction, onOwnerSlotDrop, simulation] + [camera, events, interaction, onOwnerSlotDrop, simulation] ); + const handleMouseMove = useCallback( + (e: React.MouseEvent) => { + if (processActivePointerMove(e.clientX, e.clientY, e.buttons)) { + return; + } + + dragPreviewRef.current = null; + + const canvas = canvasHandle.current?.getCanvas(); + if (!canvas) return; + const rect = canvas.getBoundingClientRect(); + const world = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top); + const nodes = getVisibleNodes(simulation.stateRef.current.nodes); + const visibleNodeIds = new Set(nodes.map((node) => node.id)); + const edges = getVisibleEdges(simulation.stateRef.current.edges, visibleNodeIds); + + const hoveredNodeId = findNodeAt(world.x, world.y, nodes); + interaction.hoveredNodeId.current = hoveredNodeId; + + if (hoveredNodeId) { + hoveredEdgeIdRef.current = null; + canvas.style.cursor = 'pointer'; + return; + } + + const nodeMap = getNodeMap(nodes); + const interactiveEdges = getInteractiveEdges(canvas, nodes, edges); + hoveredEdgeIdRef.current = findEdgeAt(world.x, world.y, interactiveEdges, nodeMap); + canvas.style.cursor = hoveredEdgeIdRef.current ? 'pointer' : 'grab'; + }, + [ + camera, + getInteractiveEdges, + getNodeMap, + getVisibleEdges, + getVisibleNodes, + interaction, + processActivePointerMove, + simulation.stateRef, + ] + ); + + const handleMouseUp = useCallback( + (e: React.MouseEvent) => { + completePointerInteraction(e.clientX, e.clientY); + }, + [completePointerInteraction] + ); + + useEffect(() => { + const handleWindowMouseMove = (event: MouseEvent): void => { + if ((event.buttons & 1) === 0) { + return; + } + if ( + !isPanningRef.current && + !interaction.dragNodeId.current && + !interaction.isDragging.current && + !edgeMouseDownRef.current + ) { + return; + } + processActivePointerMove(event.clientX, event.clientY, event.buttons); + }; + + const handleWindowMouseUp = (event: MouseEvent): void => { + if ( + !isPanningRef.current && + !interaction.dragNodeId.current && + !interaction.isDragging.current && + !edgeMouseDownRef.current + ) { + return; + } + completePointerInteraction(event.clientX, event.clientY); + }; + + window.addEventListener('mousemove', handleWindowMouseMove); + window.addEventListener('mouseup', handleWindowMouseUp); + return () => { + window.removeEventListener('mousemove', handleWindowMouseMove); + window.removeEventListener('mouseup', handleWindowMouseUp); + }; + }, [completePointerInteraction, interaction, processActivePointerMove]); + const handleDoubleClick = useCallback( (e: React.MouseEvent) => { const canvas = canvasHandle.current?.getCanvas(); diff --git a/src/features/agent-graph/renderer/hooks/useTeamGraphAdapter.ts b/src/features/agent-graph/renderer/hooks/useTeamGraphAdapter.ts index 88e2127e..7f33931e 100644 --- a/src/features/agent-graph/renderer/hooks/useTeamGraphAdapter.ts +++ b/src/features/agent-graph/renderer/hooks/useTeamGraphAdapter.ts @@ -8,7 +8,10 @@ import { useEffect, 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 { useShallow } from 'zustand/react/shallow'; @@ -62,6 +65,20 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort { const commentReadState = useSyncExternalStore(subscribe, getSnapshot); + const effectiveSlotAssignments = useMemo(() => { + if (!teamData) { + return slotAssignments; + } + if (!isTeamGraphSlotPersistenceDisabled()) { + return slotAssignments; + } + if (hasAppliedDefaultTeamGraphSlotAssignments(teamName)) { + return slotAssignments; + } + const defaults = getDefaultTeamGraphSlotAssignmentsForMembers(teamData.members); + return Object.keys(defaults).length === 0 ? undefined : defaults; + }, [slotAssignments, teamData, teamName]); + useEffect(() => { if (!teamName || !teamData) { return; @@ -84,7 +101,7 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort { commentReadState, provisioningProgress, memberSpawnSnapshot, - slotAssignments + effectiveSlotAssignments ), [ teamData, @@ -99,7 +116,7 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort { commentReadState, provisioningProgress, memberSpawnSnapshot, - slotAssignments, + effectiveSlotAssignments, ] ); } diff --git a/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx b/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx index e5c7c6da..9780bef1 100644 --- a/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx +++ b/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx @@ -3,10 +3,12 @@ * Follows the exact ProjectEditorOverlay pattern (lazy-loaded, fixed z-50). */ -import { useCallback, useMemo } from 'react'; +import { useCallback, useLayoutEffect, useMemo } from 'react'; import { GraphView } from '@claude-teams/agent-graph'; import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHost'; +import { useStore } from '@renderer/store'; +import { isTeamGraphSlotPersistenceDisabled } from '@renderer/store/slices/teamSlice'; import { useGraphCreateTaskDialog } from '../hooks/useGraphCreateTaskDialog'; import { useGraphSidebarVisibility } from '../hooks/useGraphSidebarVisibility'; @@ -53,6 +55,9 @@ export const TeamGraphOverlay = ({ }: TeamGraphOverlayProps): React.JSX.Element => { const graphData = useTeamGraphAdapter(teamName); const { openTeamPage: openTeamTab, commitOwnerSlotDrop } = useTeamGraphSurfaceActions(teamName); + const resetTeamGraphSlotAssignmentsToDefaults = useStore( + (s) => s.resetTeamGraphSlotAssignmentsToDefaults + ); const { sidebarVisible: persistedSidebarVisible, toggleSidebarVisible } = useGraphSidebarVisibility(); const { dialog: createTaskDialog, openCreateTaskDialog } = useGraphCreateTaskDialog(teamName); @@ -86,6 +91,13 @@ export const TeamGraphOverlay = ({ openCreateTaskDialog(''); }, [openCreateTaskDialog]); + useLayoutEffect(() => { + if (!isTeamGraphSlotPersistenceDisabled()) { + return; + } + resetTeamGraphSlotAssignmentsToDefaults(teamName); + }, [resetTeamGraphSlotAssignmentsToDefaults, teamName]); + const events: GraphEventPort = { onNodeDoubleClick: useCallback( (ref: GraphDomainRef) => { @@ -116,6 +128,7 @@ export const TeamGraphOverlay = ({ { const graphData = useTeamGraphAdapter(teamName); const { openTeamPage, commitOwnerSlotDrop } = useTeamGraphSurfaceActions(teamName); + const resetTeamGraphSlotAssignmentsToDefaults = useStore( + (s) => s.resetTeamGraphSlotAssignmentsToDefaults + ); const [fullscreen, setFullscreen] = useState(false); const { sidebarVisible, toggleSidebarVisible } = useGraphSidebarVisibility(); const { dialog: createTaskDialog, openCreateTaskDialog } = useGraphCreateTaskDialog(teamName); @@ -76,6 +81,13 @@ export const TeamGraphTab = ({ openCreateTaskDialog(''); }, [openCreateTaskDialog]); + useLayoutEffect(() => { + if (!isTeamGraphSlotPersistenceDisabled() || !isActive) { + return; + } + resetTeamGraphSlotAssignmentsToDefaults(teamName); + }, [isActive, resetTeamGraphSlotAssignmentsToDefaults, teamName]); + // Task action dispatchers const dispatchTaskAction = useCallback( (action: string) => (taskId: string) => @@ -140,6 +152,7 @@ export const TeamGraphTab = ({ events={events} className="team-graph-view size-full" suspendAnimation={!isActive} + isSurfaceActive={isActive} onRequestFullscreen={() => setFullscreen(true)} onOpenTeamPage={openTeamPage} onCreateTask={openCreateTask} diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 8b9ca8e5..9434292c 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -10,6 +10,7 @@ import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc'; import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team'; +import { isLeadMember } from '@shared/utils/leadDetection'; import { createLogger } from '@shared/utils/logger'; import { getTaskKanbanColumn } from '@shared/utils/reviewState'; import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; @@ -55,6 +56,7 @@ import type { import type { StateCreator } from 'zustand'; const GRAPH_STABLE_SLOT_LAYOUT_VERSION = 'stable-slots-v1' as const; +const DISABLE_PERSISTED_TEAM_GRAPH_SLOT_ASSIGNMENTS = true; const logger = createLogger('teamSlice'); const TEAM_GET_DATA_TIMEOUT_MS = 30_000; @@ -81,6 +83,7 @@ const teamRefreshBurstDiagnostics = new Map< { windowStartedAt: number; count: number; lastWarnAt: number } >(); const memberSpawnUiEqualLastWarnAtByTeam = new Map(); +const sessionDefaultGraphSlotAssignmentsAppliedByTeam = new Set(); interface RefreshTeamDataOptions { withDedup?: boolean; } @@ -92,20 +95,20 @@ const SMALL_TEAM_CARDINAL_SLOT_PRESETS: ReadonlyArray !member.removedAt); + const visibleMembers = members.filter((member) => !member.removedAt && !isLeadMember(member)); if (visibleMembers.length === 0 || visibleMembers.length > 4) { return { assignments, changed: false }; } @@ -1021,6 +1025,44 @@ function seedStableSlotAssignmentsForMembers( return { assignments: nextAssignments, changed: true }; } +function areTeamGraphSlotAssignmentsEqual( + left: TeamGraphSlotAssignments | undefined, + right: TeamGraphSlotAssignments | undefined +): boolean { + const leftEntries = Object.entries(left ?? {}); + const rightEntries = Object.entries(right ?? {}); + if (leftEntries.length !== rightEntries.length) { + return false; + } + + for (const [stableOwnerId, leftAssignment] of leftEntries) { + const rightAssignment = right?.[stableOwnerId]; + if ( + !rightAssignment || + rightAssignment.ringIndex !== leftAssignment.ringIndex || + rightAssignment.sectorIndex !== leftAssignment.sectorIndex + ) { + return false; + } + } + + return true; +} + +export function getDefaultTeamGraphSlotAssignmentsForMembers( + members: readonly TeamGraphMemberSeedInput[] +): TeamGraphSlotAssignments { + return seedStableSlotAssignmentsForMembers({}, members).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, teamName: string | null | undefined @@ -1829,9 +1871,34 @@ export const createTeamSlice: StateCreator = (set, if (state.slotLayoutVersion !== GRAPH_STABLE_SLOT_LAYOUT_VERSION) { nextState.slotLayoutVersion = GRAPH_STABLE_SLOT_LAYOUT_VERSION; nextSlotAssignmentsByTeam = {}; + sessionDefaultGraphSlotAssignmentsAppliedByTeam.clear(); changed = true; } + if (DISABLE_PERSISTED_TEAM_GRAPH_SLOT_ASSIGNMENTS) { + if (!sessionDefaultGraphSlotAssignmentsAppliedByTeam.has(teamName)) { + const currentAssignments = nextSlotAssignmentsByTeam[teamName]; + const defaultAssignments = getDefaultTeamGraphSlotAssignmentsForMembers(members); + if (!areTeamGraphSlotAssignmentsEqual(currentAssignments, defaultAssignments)) { + nextSlotAssignmentsByTeam = { ...nextSlotAssignmentsByTeam }; + if (Object.keys(defaultAssignments).length === 0) { + delete nextSlotAssignmentsByTeam[teamName]; + } else { + nextSlotAssignmentsByTeam[teamName] = defaultAssignments; + } + changed = true; + } + sessionDefaultGraphSlotAssignmentsAppliedByTeam.add(teamName); + } + + if (!changed) { + return {}; + } + + nextState.slotAssignmentsByTeam = nextSlotAssignmentsByTeam; + return nextState; + } + const currentAssignments = nextSlotAssignmentsByTeam[teamName]; const migrated = migrateStableSlotAssignmentsForMembers(currentAssignments, members); const seeded = seedStableSlotAssignmentsForMembers(migrated.assignments, members); @@ -1979,6 +2046,7 @@ export const createTeamSlice: StateCreator = (set, clearTeamGraphSlotAssignments: (teamName) => { set((state) => { if (!teamName) { + sessionDefaultGraphSlotAssignmentsAppliedByTeam.clear(); if ( Object.keys(state.slotAssignmentsByTeam).length === 0 && state.slotLayoutVersion === GRAPH_STABLE_SLOT_LAYOUT_VERSION @@ -1997,6 +2065,7 @@ export const createTeamSlice: StateCreator = (set, const nextAssignmentsByTeam = { ...state.slotAssignmentsByTeam }; delete nextAssignmentsByTeam[teamName]; + sessionDefaultGraphSlotAssignmentsAppliedByTeam.delete(teamName); return { slotLayoutVersion: GRAPH_STABLE_SLOT_LAYOUT_VERSION, slotAssignmentsByTeam: nextAssignmentsByTeam, @@ -2006,13 +2075,48 @@ export const createTeamSlice: StateCreator = (set, resetTeamGraphSlotAssignmentsToDefaults: (teamName) => { set((state) => { + if (!DISABLE_PERSISTED_TEAM_GRAPH_SLOT_ASSIGNMENTS) { + const currentAssignments = state.slotAssignmentsByTeam[teamName]; + if (!currentAssignments || Object.keys(currentAssignments).length === 0) { + return {}; + } + + const nextAssignmentsByTeam = { ...state.slotAssignmentsByTeam }; + delete nextAssignmentsByTeam[teamName]; + return { + slotLayoutVersion: GRAPH_STABLE_SLOT_LAYOUT_VERSION, + slotAssignmentsByTeam: nextAssignmentsByTeam, + }; + } + + const teamData = selectTeamDataForName(state, teamName); + const defaultAssignments = teamData + ? getDefaultTeamGraphSlotAssignmentsForMembers(teamData.members) + : {}; const currentAssignments = state.slotAssignmentsByTeam[teamName]; - if (!currentAssignments || Object.keys(currentAssignments).length === 0) { + const hasCurrentAssignments = + currentAssignments && Object.keys(currentAssignments).length > 0; + + if ( + areTeamGraphSlotAssignmentsEqual(currentAssignments, defaultAssignments) && + sessionDefaultGraphSlotAssignmentsAppliedByTeam.has(teamName) + ) { return {}; } const nextAssignmentsByTeam = { ...state.slotAssignmentsByTeam }; - delete nextAssignmentsByTeam[teamName]; + if (Object.keys(defaultAssignments).length === 0) { + delete nextAssignmentsByTeam[teamName]; + sessionDefaultGraphSlotAssignmentsAppliedByTeam.delete(teamName); + } else { + nextAssignmentsByTeam[teamName] = defaultAssignments; + sessionDefaultGraphSlotAssignmentsAppliedByTeam.add(teamName); + } + + if (!hasCurrentAssignments && Object.keys(defaultAssignments).length === 0) { + return {}; + } + return { slotLayoutVersion: GRAPH_STABLE_SLOT_LAYOUT_VERSION, slotAssignmentsByTeam: nextAssignmentsByTeam, diff --git a/test/renderer/features/agent-graph/useGraphSimulation.test.ts b/test/renderer/features/agent-graph/useGraphSimulation.test.ts index 2db1509f..a485d673 100644 --- a/test/renderer/features/agent-graph/useGraphSimulation.test.ts +++ b/test/renderer/features/agent-graph/useGraphSimulation.test.ts @@ -155,6 +155,97 @@ describe('stable slot layout planner', () => { expect(frame?.processBandRect.width).toBe(computeProcessBandWidth(0)); }); + it('uses strict cardinal owner slots for teams with up to four members', () => { + const teamName = 'team-cardinal-four'; + const lead = createLead(teamName); + const top = createMember(teamName, 'agent-top', 'top'); + const right = createMember(teamName, 'agent-right', 'right'); + const bottom = createMember(teamName, 'agent-bottom', 'bottom'); + const left = createMember(teamName, 'agent-left', 'left'); + const layout: GraphLayoutPort = { + version: 'stable-slots-v1', + ownerOrder: [top.id, right.id, bottom.id, left.id], + slotAssignments: { + [top.id]: { ringIndex: 0, sectorIndex: 0 }, + [right.id]: { ringIndex: 0, sectorIndex: 1 }, + [bottom.id]: { ringIndex: 0, sectorIndex: 2 }, + [left.id]: { ringIndex: 0, sectorIndex: 3 }, + }, + }; + + const snapshot = buildStableSlotLayoutSnapshot({ + teamName, + nodes: [lead, top, right, bottom, left], + layout, + }); + + expect(snapshot).not.toBeNull(); + + const topFrame = snapshot!.memberSlotFrameByOwnerId.get(top.id)!; + const rightFrame = snapshot!.memberSlotFrameByOwnerId.get(right.id)!; + const bottomFrame = snapshot!.memberSlotFrameByOwnerId.get(bottom.id)!; + const leftFrame = snapshot!.memberSlotFrameByOwnerId.get(left.id)!; + + expect(Math.abs(topFrame.ownerX)).toBeLessThan(1); + expect(topFrame.ownerY).toBeLessThan(0); + + expect(rightFrame.ownerX).toBeGreaterThan(0); + expect(Math.abs(rightFrame.ownerY)).toBeLessThan(1); + + expect(Math.abs(bottomFrame.ownerX)).toBeLessThan(1); + expect(bottomFrame.ownerY).toBeGreaterThan(0); + + expect(leftFrame.ownerX).toBeLessThan(0); + expect(Math.abs(leftFrame.ownerY)).toBeLessThan(1); + + expect(Math.abs(Math.abs(leftFrame.ownerX) - Math.abs(rightFrame.ownerX))).toBeLessThan(1); + expect(Math.abs(Math.abs(topFrame.ownerY) - Math.abs(bottomFrame.ownerY))).toBeLessThan(1); + }); + + it('uses strict cardinal owner slots even when ownerOrder differs from assignment order', () => { + const teamName = 'team-cardinal-misaligned-order'; + const lead = createLead(teamName); + const alice = createMember(teamName, 'agent-alice', 'alice'); + const bob = createMember(teamName, 'agent-bob', 'bob'); + const tom = createMember(teamName, 'agent-tom', 'tom'); + const jack = createMember(teamName, 'agent-jack', 'jack'); + const layout: GraphLayoutPort = { + version: 'stable-slots-v1', + ownerOrder: [jack.id, alice.id, tom.id, bob.id], + slotAssignments: { + [alice.id]: { ringIndex: 0, sectorIndex: 0 }, + [bob.id]: { ringIndex: 0, sectorIndex: 1 }, + [tom.id]: { ringIndex: 0, sectorIndex: 2 }, + [jack.id]: { ringIndex: 0, sectorIndex: 3 }, + }, + }; + + const snapshot = buildStableSlotLayoutSnapshot({ + teamName, + nodes: [lead, alice, bob, tom, jack], + layout, + }); + + expect(snapshot).not.toBeNull(); + + const aliceFrame = snapshot!.memberSlotFrameByOwnerId.get(alice.id)!; + const bobFrame = snapshot!.memberSlotFrameByOwnerId.get(bob.id)!; + const tomFrame = snapshot!.memberSlotFrameByOwnerId.get(tom.id)!; + const jackFrame = snapshot!.memberSlotFrameByOwnerId.get(jack.id)!; + + expect(Math.abs(aliceFrame.ownerX)).toBeLessThan(1); + expect(aliceFrame.ownerY).toBeLessThan(0); + + expect(bobFrame.ownerX).toBeGreaterThan(0); + expect(Math.abs(bobFrame.ownerY)).toBeLessThan(1); + + expect(Math.abs(tomFrame.ownerX)).toBeLessThan(1); + expect(tomFrame.ownerY).toBeGreaterThan(0); + + expect(jackFrame.ownerX).toBeLessThan(0); + expect(Math.abs(jackFrame.ownerY)).toBeLessThan(1); + }); + it('reserves a full empty activity column and minimum kanban width for idle members', () => { const teamName = 'team-empty-slot'; const lead = createLead(teamName); @@ -384,6 +475,48 @@ describe('stable slot layout planner', () => { expect(nearest?.displacedAssignment).toEqual({ ringIndex: 0, sectorIndex: 1 }); }); + it('keeps drag resolution inside strict cardinal slots for four-member teams', () => { + const teamName = 'team-cardinal-drag'; + const lead = createLead(teamName); + const top = createMember(teamName, 'agent-top', 'top'); + const right = createMember(teamName, 'agent-right', 'right'); + const bottom = createMember(teamName, 'agent-bottom', 'bottom'); + const left = createMember(teamName, 'agent-left', 'left'); + const layout: GraphLayoutPort = { + version: 'stable-slots-v1', + ownerOrder: [top.id, right.id, bottom.id, left.id], + slotAssignments: { + [top.id]: { ringIndex: 0, sectorIndex: 0 }, + [right.id]: { ringIndex: 0, sectorIndex: 1 }, + [bottom.id]: { ringIndex: 0, sectorIndex: 2 }, + [left.id]: { ringIndex: 0, sectorIndex: 3 }, + }, + }; + + const snapshot = buildStableSlotLayoutSnapshot({ + teamName, + nodes: [lead, top, right, bottom, left], + layout, + }); + + expect(snapshot).not.toBeNull(); + const rightFrame = snapshot!.memberSlotFrameByOwnerId.get(right.id)!; + + const nearest = resolveNearestSlotAssignment({ + ownerId: top.id, + ownerX: rightFrame.ownerX, + ownerY: rightFrame.ownerY, + nodes: [lead, top, right, bottom, left], + snapshot: snapshot!, + layout, + }); + + expect(nearest).not.toBeNull(); + expect(nearest?.assignment).toEqual({ ringIndex: 0, sectorIndex: 1 }); + expect(nearest?.displacedOwnerId).toBe(right.id); + expect(nearest?.displacedAssignment).toEqual({ ringIndex: 0, sectorIndex: 0 }); + }); + it('keeps nearest-slot drag resolution on the same central collision model as the planner', () => { const teamName = 'team-drag-central-collision'; const lead = createLead(teamName); diff --git a/test/renderer/store/teamSlice.test.ts b/test/renderer/store/teamSlice.test.ts index a7e9e286..787ef9af 100644 --- a/test/renderer/store/teamSlice.test.ts +++ b/test/renderer/store/teamSlice.test.ts @@ -221,7 +221,7 @@ describe('teamSlice actions', () => { expect(store.getState().warmTaskChangeSummaries).not.toHaveBeenCalled(); }); - it('commits an owner slot drop atomically even when prior assignments were sparse', () => { + it('commits owner slot drops in the current session while persistence is disabled', () => { const store = createSliceStore(); store.getState().commitTeamGraphOwnerSlotDrop( @@ -238,7 +238,7 @@ describe('teamSlice actions', () => { }); }); - it('migrates fallback name-based slot assignments to agentId-based stable owner ids', () => { + it('replaces persisted slot assignments with defaults while persistence is disabled', () => { const store = createSliceStore(); store.setState({ slotLayoutVersion: 'stable-slots-v1', @@ -255,7 +255,8 @@ describe('teamSlice actions', () => { ]); expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({ - 'agent-alice': { ringIndex: 0, sectorIndex: 3 }, + 'agent-alice': { ringIndex: 0, sectorIndex: 0 }, + 'agent-bob': { ringIndex: 0, sectorIndex: 1 }, }); }); @@ -270,14 +271,33 @@ describe('teamSlice actions', () => { ]); expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({ - 'agent-alice': { ringIndex: 0, sectorIndex: 5 }, + 'agent-alice': { ringIndex: 0, sectorIndex: 0 }, 'agent-bob': { ringIndex: 0, sectorIndex: 1 }, - 'agent-tom': { ringIndex: 0, sectorIndex: 4 }, - 'agent-jack': { ringIndex: 0, sectorIndex: 2 }, + 'agent-tom': { ringIndex: 0, sectorIndex: 2 }, + 'agent-jack': { ringIndex: 0, sectorIndex: 3 }, }); }); - it('seeds visible members even when only hidden owners have saved placements', () => { + it('ignores the lead member when deriving small-team cardinal defaults', () => { + const store = createSliceStore(); + + store.getState().ensureTeamGraphSlotAssignments('my-team', [ + { name: 'team-lead', agentId: 'lead-id' }, + { 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-tom': { ringIndex: 0, sectorIndex: 2 }, + 'agent-jack': { ringIndex: 0, sectorIndex: 3 }, + }); + }); + + it('drops hidden persisted slot assignments and reseeds visible members while persistence is disabled', () => { const store = createSliceStore(); store.setState({ slotLayoutVersion: 'stable-slots-v1', @@ -295,8 +315,7 @@ describe('teamSlice actions', () => { ]); expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({ - 'agent-hidden': { ringIndex: 2, sectorIndex: 4 }, - 'agent-alice': { ringIndex: 0, sectorIndex: 5 }, + 'agent-alice': { ringIndex: 0, sectorIndex: 0 }, 'agent-bob': { ringIndex: 0, sectorIndex: 1 }, }); }); @@ -327,7 +346,7 @@ describe('teamSlice actions', () => { }); }); - it('keeps hidden-member slot assignments so the same stable owner can reuse them later', () => { + it('ignores hidden-member persisted slot assignments while persistence is disabled', () => { const store = createSliceStore(); store.setState({ slotLayoutVersion: 'stable-slots-v1', @@ -344,8 +363,77 @@ describe('teamSlice actions', () => { ]); expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({ - 'agent-hidden': { ringIndex: 1, sectorIndex: 5 }, - 'agent-visible': { ringIndex: 0, sectorIndex: 2 }, + 'agent-visible': { ringIndex: 0, sectorIndex: 0 }, + }); + }); + + it('does not reseed a team again after defaults were applied once in the session', () => { + const store = createSliceStore(); + + store.getState().ensureTeamGraphSlotAssignments('my-team', [ + { name: 'alice', agentId: 'agent-alice' }, + { name: 'bob', agentId: 'agent-bob' }, + ]); + + store.getState().setTeamGraphOwnerSlotAssignment('my-team', 'agent-alice', { + ringIndex: 1, + sectorIndex: 4, + }); + + store.getState().ensureTeamGraphSlotAssignments('my-team', [ + { name: 'alice', agentId: 'agent-alice' }, + { name: 'bob', agentId: 'agent-bob' }, + ]); + + expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({ + 'agent-alice': { ringIndex: 1, sectorIndex: 4 }, + 'agent-bob': { ringIndex: 0, sectorIndex: 1 }, + }); + }); + + it('resets graph slot assignments back to defaults when reopening the graph surface', () => { + const store = createSliceStore(); + store.setState({ + teamDataCacheByName: { + 'my-team': { + teamName: 'my-team', + config: { name: 'My Team' }, + tasks: [], + members: [ + { name: 'alice', agentId: 'agent-alice' }, + { name: 'bob', agentId: 'agent-bob' }, + { name: 'tom', agentId: 'agent-tom' }, + { name: 'jack', agentId: 'agent-jack' }, + ], + messages: [], + kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, + processes: [], + }, + }, + }); + + 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' }, + ]); + + store.getState().commitTeamGraphOwnerSlotDrop( + 'my-team', + 'agent-alice', + { ringIndex: 0, sectorIndex: 2 }, + 'agent-tom', + { ringIndex: 0, sectorIndex: 0 } + ); + + store.getState().resetTeamGraphSlotAssignmentsToDefaults('my-team'); + + 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 }, }); });