fix(agent-graph): tune stable slot spacing

This commit is contained in:
777genius 2026-04-25 09:47:14 +03:00
parent 937b23be4b
commit ade312ad87
6 changed files with 163 additions and 52 deletions

View file

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

View file

@ -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<string, GraphOwnerSlotAssignment>
): Map<string, GraphOwnerSlotAssignment> {
@ -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 {

View file

@ -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]);
}

View file

@ -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) =>

View file

@ -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) =>

View file

@ -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
);
});