diff --git a/packages/agent-graph/src/layout/stableSlotGeometry.ts b/packages/agent-graph/src/layout/stableSlotGeometry.ts index d1e5265f..c49016c5 100644 --- a/packages/agent-graph/src/layout/stableSlotGeometry.ts +++ b/packages/agent-graph/src/layout/stableSlotGeometry.ts @@ -1,7 +1,8 @@ export const STABLE_SLOT_GEOMETRY = { - slotVerticalGap: 24, - slotHorizontalGap: 32, + slotVerticalGap: 12, + slotHorizontalGap: 77.7, ringGap: 140, + centralHorizontalGap: 77.7, centralSafetyPadding: 48, memberSlotInnerPadding: 16, centralBlockGap: 56, @@ -9,7 +10,7 @@ export const STABLE_SLOT_GEOMETRY = { unassignedGap: 72, maxGeneratedRings: 12, ownerCollisionPadding: 28, - ownerBandHeight: 96, + ownerBandHeight: 32, ownerMinWidth: 200, processBandHeight: 32, processRailWidth: 220, diff --git a/packages/agent-graph/src/layout/stableSlots.ts b/packages/agent-graph/src/layout/stableSlots.ts index a0c31ab9..080a0ae4 100644 --- a/packages/agent-graph/src/layout/stableSlots.ts +++ b/packages/agent-graph/src/layout/stableSlots.ts @@ -119,6 +119,7 @@ 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 SMALL_TEAM_CARDINAL_VERTICAL_PADDING = 77.7; const SECTOR_VECTORS = STABLE_SLOT_SECTOR_VECTORS; const SMALL_TEAM_CARDINAL_LAYOUTS: ReadonlyArray< @@ -265,7 +266,9 @@ function rectOverlapsAnyCentralRect( rect: StableRect, centralCollisionRects: readonly StableRect[] ): boolean { - return centralCollisionRects.some((centralRect) => rectsOverlap(rect, centralRect)); + return centralCollisionRects.some((centralRect) => + rectsOverlapWithAxisGap(rect, centralRect, SLOT_GEOMETRY.centralHorizontalGap, 0) + ); } export function computeOwnerFootprints( @@ -988,21 +991,25 @@ function planStrictSmallTeamOwnerSlots( return null; } - let radius = Math.max( - ...slotConfigs.map((slot) => - resolveMinimumDirectionalRadiusForVector({ - vector: slot!.vector, - footprint: slot!.footprint, - centralCollisionRects, - runtimeCentralExclusion, - }) - ) + const baseRadiusByAxis = resolveStrictSmallTeamRadiusByAxis( + slotConfigs.map((slot) => slot!), + centralCollisionRects, + runtimeCentralExclusion ); for (let iteration = 0; iteration < 48; iteration += 1) { - const frames = slotConfigs.map((slot) => - buildSlotFrameAtRadiusWithVector(slot!.footprint, slot!.assignment, radius, slot!.vector) - ); + const radiusBump = iteration * SMALL_TEAM_CARDINAL_RADIUS_STEP; + const frames = slotConfigs.map((slot) => { + const axis = resolveStrictSmallTeamVectorAxis(slot!.vector); + return buildSlotFrameAtRadiusWithVector( + slot!.footprint, + slot!.assignment, + baseRadiusByAxis[axis] + + (axis === 'vertical' ? SMALL_TEAM_CARDINAL_VERTICAL_PADDING : 0) + + radiusBump, + slot!.vector + ); + }); const allValid = frames.every((frame, frameIndex) => isSlotFramePlacementValid( frame, @@ -1013,12 +1020,42 @@ function planStrictSmallTeamOwnerSlots( if (allValid) { return frames; } - radius += SMALL_TEAM_CARDINAL_RADIUS_STEP; } return null; } +function resolveStrictSmallTeamRadiusByAxis( + slotConfigs: readonly { + footprint: OwnerFootprint; + vector: { x: number; y: number }; + }[], + centralCollisionRects: readonly StableRect[], + runtimeCentralExclusion: StableRect +): Record<'horizontal' | 'vertical', number> { + const radiusByAxis = { + horizontal: 0, + vertical: 0, + }; + + for (const slot of slotConfigs) { + const axis = resolveStrictSmallTeamVectorAxis(slot.vector); + const radius = resolveMinimumDirectionalRadiusForVector({ + vector: slot.vector, + footprint: slot.footprint, + centralCollisionRects, + runtimeCentralExclusion, + }); + radiusByAxis[axis] = Math.max(radiusByAxis[axis], radius); + } + + return radiusByAxis; +} + +function resolveStrictSmallTeamVectorAxis(vector: { x: number; y: number }): 'horizontal' | 'vertical' { + return Math.abs(vector.x) >= Math.abs(vector.y) ? 'horizontal' : 'vertical'; +} + function buildPreferredAssignmentsMap( assignments?: Record ): Map { @@ -1329,7 +1366,7 @@ function rankNearestSlotAssignmentResult(args: { if ( !isSlotFramePlacementValid(frame, otherFrames, centralCollisionRects) || !isSlotFramePlacementValid(displacedFrame, otherFrames, centralCollisionRects) || - rectsOverlapWithGap(frame.bounds, displacedFrame.bounds, SLOT_GEOMETRY.ringPadding) + ownerSlotFramesOverlap(frame.bounds, displacedFrame.bounds) ) { return null; } @@ -1735,12 +1772,17 @@ function resolveTaskColumnKey(task: GraphNode): string { return 'todo'; } -function rectsOverlapWithGap(a: StableRect, b: StableRect, gap: number): boolean { +function rectsOverlapWithAxisGap( + a: StableRect, + b: StableRect, + horizontalGap: number, + verticalGap: number +): boolean { return ( - a.left - gap < b.right && - a.right + gap > b.left && - a.top - gap < b.bottom && - a.bottom + gap > b.top + a.left - horizontalGap < b.right && + a.right + horizontalGap > b.left && + a.top - verticalGap < b.bottom && + a.bottom + verticalGap > b.top ); } @@ -1748,6 +1790,15 @@ function rectsOverlap(a: StableRect, b: StableRect): boolean { return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top; } +function ownerSlotFramesOverlap(a: StableRect, b: StableRect): boolean { + return rectsOverlapWithAxisGap( + a, + b, + SLOT_GEOMETRY.slotHorizontalGap, + SLOT_GEOMETRY.ringPadding + ); +} + function rectContainsRect(outer: StableRect, inner: StableRect): boolean { return ( inner.left >= outer.left - GEOMETRY_EPSILON && @@ -1788,9 +1839,7 @@ function isSlotFramePlacementValid( if (rectOverlapsAnyCentralRect(frame.bounds, centralCollisionRects)) { return false; } - return !existingFrames.some((existing) => - rectsOverlapWithGap(frame.bounds, existing.bounds, SLOT_GEOMETRY.ringPadding) - ); + return !existingFrames.some((existing) => ownerSlotFramesOverlap(frame.bounds, existing.bounds)); } function buildAssignmentKey(assignment: GraphOwnerSlotAssignment): string { diff --git a/src/features/agent-graph/renderer/hooks/useTeamGraphSlotReset.ts b/src/features/agent-graph/renderer/hooks/useTeamGraphSlotReset.ts deleted file mode 100644 index edea698b..00000000 --- a/src/features/agent-graph/renderer/hooks/useTeamGraphSlotReset.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { useLayoutEffect } from 'react'; - -import { useStore } from '@renderer/store'; -import { isTeamGraphSlotPersistenceDisabled } from '@renderer/store/slices/teamSlice'; - -export function useTeamGraphSlotReset(teamName: string, enabled = true): void { - const resetTeamGraphSlotAssignmentsToDefaults = useStore( - (s) => s.resetTeamGraphSlotAssignmentsToDefaults - ); - - useLayoutEffect(() => { - if (!enabled || !isTeamGraphSlotPersistenceDisabled()) { - return; - } - - resetTeamGraphSlotAssignmentsToDefaults(teamName); - }, [enabled, resetTeamGraphSlotAssignmentsToDefaults, teamName]); -} diff --git a/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx b/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx index ea4163f3..c304b987 100644 --- a/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx +++ b/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx @@ -11,7 +11,6 @@ import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHo import { useGraphCreateTaskDialog } from '../hooks/useGraphCreateTaskDialog'; import { useGraphSidebarVisibility } from '../hooks/useGraphSidebarVisibility'; import { useTeamGraphAdapter } from '../hooks/useTeamGraphAdapter'; -import { useTeamGraphSlotReset } from '../hooks/useTeamGraphSlotReset'; import { useTeamGraphSurfaceActions } from '../hooks/useTeamGraphSurfaceActions'; import { GraphActivityHud } from './GraphActivityHud'; @@ -61,8 +60,6 @@ export const TeamGraphOverlay = ({ const effectiveSidebarVisible = sidebarVisible ?? persistedSidebarVisible; const handleToggleSidebar = onToggleSidebar ?? toggleSidebarVisible; - useTeamGraphSlotReset(teamName); - // Task action dispatchers (same pattern as TeamGraphTab) const dispatchTaskAction = useCallback( (action: string) => (taskId: string) => diff --git a/src/features/agent-graph/renderer/ui/TeamGraphTab.tsx b/src/features/agent-graph/renderer/ui/TeamGraphTab.tsx index b27a84d0..febc5511 100644 --- a/src/features/agent-graph/renderer/ui/TeamGraphTab.tsx +++ b/src/features/agent-graph/renderer/ui/TeamGraphTab.tsx @@ -11,7 +11,6 @@ import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHo import { useGraphCreateTaskDialog } from '../hooks/useGraphCreateTaskDialog'; import { useGraphSidebarVisibility } from '../hooks/useGraphSidebarVisibility'; import { useTeamGraphAdapter } from '../hooks/useTeamGraphAdapter'; -import { useTeamGraphSlotReset } from '../hooks/useTeamGraphSlotReset'; import { useTeamGraphSurfaceActions } from '../hooks/useTeamGraphSurfaceActions'; import { GraphActivityHud } from './GraphActivityHud'; @@ -52,8 +51,6 @@ export const TeamGraphTab = ({ const { sidebarVisible, toggleSidebarVisible } = useGraphSidebarVisibility(); const { dialog: createTaskDialog, openCreateTaskDialog } = useGraphCreateTaskDialog(teamName); - useTeamGraphSlotReset(teamName, isActive); - // Typed event dispatchers (DRY — used in both events + renderOverlay) const dispatchOpenTask = useCallback( (taskId: string) => diff --git a/test/renderer/features/agent-graph/useGraphSimulation.test.ts b/test/renderer/features/agent-graph/useGraphSimulation.test.ts index ea22cb6e..a1e1fb60 100644 --- a/test/renderer/features/agent-graph/useGraphSimulation.test.ts +++ b/test/renderer/features/agent-graph/useGraphSimulation.test.ts @@ -81,6 +81,26 @@ function rectsOverlap( ); } +function rectsOverlapVertically( + left: { top: number; bottom: number }, + right: { top: number; bottom: number } +): boolean { + return left.top < right.bottom && left.bottom > right.top; +} + +function horizontalGapBetween( + left: { left: number; right: number }, + right: { left: number; right: number } +): number { + if (left.right <= right.left) { + return right.left - left.right; + } + if (right.right <= left.left) { + return left.left - right.right; + } + return 0; +} + describe('stable slot layout planner', () => { it('does not build a stable slot snapshot when the lead is missing', () => { const snapshot = buildStableSlotLayoutSnapshot({ @@ -153,6 +173,7 @@ describe('stable slot layout planner', () => { expect(frame?.activityColumnRect.left).toBe(frame?.boardBandRect.left); expect(frame?.kanbanBandRect.left).toBeGreaterThan(frame?.activityColumnRect.right ?? 0); expect(frame?.processBandRect.width).toBe(computeProcessBandWidth(0)); + expect(frame?.processBandRect.height).toBe(STABLE_SLOT_GEOMETRY.processBandHeight); }); it('uses strict cardinal owner slots for teams with up to four members', () => { @@ -200,6 +221,7 @@ describe('stable slot layout planner', () => { 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); + expect(Math.abs(topFrame.ownerY)).toBeLessThan(Math.abs(rightFrame.ownerX)); }); it('uses strict cardinal owner slots even when ownerOrder differs from assignment order', () => { @@ -246,6 +268,67 @@ describe('stable slot layout planner', () => { expect(Math.abs(jackFrame.ownerY)).toBeLessThan(1); }); + it('keeps horizontal spacing around lead columns and between side-by-side owners', () => { + const teamName = 'team-horizontal-spacing'; + const lead = createLead(teamName); + const members = [ + createMember(teamName, 'agent-top', 'top'), + createMember(teamName, 'agent-right', 'right'), + createMember(teamName, 'agent-bottom', 'bottom'), + createMember(teamName, 'agent-left', 'left'), + createMember(teamName, 'agent-top-right', 'top-right'), + createMember(teamName, 'agent-bottom-right', 'bottom-right'), + ]; + const tasks = [ + createTask(teamName, 'lead-todo', lead.id), + createTask(teamName, 'lead-wip', lead.id, { taskStatus: 'in_progress' }), + ...members.map((member, index) => createTask(teamName, `task-${index + 1}`, member.id)), + ]; + const layout: GraphLayoutPort = { + version: 'stable-slots-v1', + ownerOrder: members.map((member) => member.id), + slotAssignments: { + [members[0]!.id]: { ringIndex: 0, sectorIndex: 0 }, + [members[1]!.id]: { ringIndex: 0, sectorIndex: 1 }, + [members[2]!.id]: { ringIndex: 0, sectorIndex: 2 }, + [members[3]!.id]: { ringIndex: 0, sectorIndex: 3 }, + [members[4]!.id]: { ringIndex: 0, sectorIndex: 4 }, + [members[5]!.id]: { ringIndex: 0, sectorIndex: 5 }, + }, + }; + + const snapshot = buildStableSlotLayoutSnapshot({ + teamName, + nodes: [lead, ...members, ...tasks], + layout, + }); + + expect(snapshot).not.toBeNull(); + expect(validateStableSlotLayout(snapshot!)).toEqual({ valid: true }); + + for (const frame of snapshot!.memberSlotFrames) { + for (const centralRect of snapshot!.centralCollisionRects) { + if (!rectsOverlapVertically(frame.bounds, centralRect)) { + continue; + } + expect(horizontalGapBetween(frame.bounds, centralRect)).toBeGreaterThanOrEqual( + STABLE_SLOT_GEOMETRY.centralHorizontalGap + ); + } + } + + for (const [index, left] of snapshot!.memberSlotFrames.entries()) { + for (const right of snapshot!.memberSlotFrames.slice(index + 1)) { + if (!rectsOverlapVertically(left.bounds, right.bounds)) { + continue; + } + expect(horizontalGapBetween(left.bounds, right.bounds)).toBeGreaterThanOrEqual( + STABLE_SLOT_GEOMETRY.slotHorizontalGap + ); + } + } + }); + it('reserves a full empty activity column and minimum kanban width for idle members', () => { const teamName = 'team-empty-slot'; const lead = createLead(teamName); @@ -825,8 +908,10 @@ describe('stable slot layout planner', () => { expect(snapshot!.centralCollisionRects).toContain(snapshot!.leadSlotFrame.processBandRect); expect(snapshot!.centralCollisionRects).toContain(snapshot!.leadSlotFrame.activityColumnRect); expect(snapshot!.centralCollisionRects).toContain(snapshot!.leadSlotFrame.kanbanBandRect); - expect(snapshot!.leadCentralReservedBlock.width).toBeLessThan(snapshot!.leadSlotFrame.bounds.width); - expect(snapshot!.leadCentralReservedBlock.height).toBeLessThan( + expect(snapshot!.leadCentralReservedBlock.width).toBeLessThan( + snapshot!.leadSlotFrame.bounds.width + ); + expect(snapshot!.leadCentralReservedBlock.height).toBeLessThanOrEqual( snapshot!.leadSlotFrame.bounds.height ); });