diff --git a/packages/agent-graph/src/constants/canvas-constants.ts b/packages/agent-graph/src/constants/canvas-constants.ts index e45353da..30f6363f 100644 --- a/packages/agent-graph/src/constants/canvas-constants.ts +++ b/packages/agent-graph/src/constants/canvas-constants.ts @@ -262,6 +262,8 @@ export const KANBAN_ZONE = { columnWidth: 180, /** Row height: pill (36) + gap (10) */ rowHeight: 46, + /** Space reserved for column header label */ + headerHeight: 20, /** Zone starts this far below member node center */ offsetY: 70, /** Column sequence: pending → wip → done → review → approved */ diff --git a/packages/agent-graph/src/hooks/useGraphSimulation.ts b/packages/agent-graph/src/hooks/useGraphSimulation.ts index ae2e8e39..cd4d62ad 100644 --- a/packages/agent-graph/src/hooks/useGraphSimulation.ts +++ b/packages/agent-graph/src/hooks/useGraphSimulation.ts @@ -9,6 +9,7 @@ import { translateSlotFrame, validateStableSlotLayout, type StableSlotLayoutSnapshot, + type StableRect, type SlotFrame, } from '../layout/stableSlots'; import { KanbanLayoutEngine } from '../layout/kanbanLayout'; @@ -37,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, @@ -45,9 +47,11 @@ export interface UseGraphSimulationResult { assignment: GraphOwnerSlotAssignment; displacedOwnerId?: string; displacedAssignment?: GraphOwnerSlotAssignment; + previewOwnerX: number; + previewOwnerY: number; } | null; getLaunchAnchorWorldPosition: (leadNodeId: string) => { x: number; y: number } | null; - getActivityAnchorWorldPosition: (nodeId: string) => { x: number; y: number } | null; + getActivityWorldRect: (nodeId: string) => StableRect | null; getExtraWorldBounds: () => WorldBounds[]; } @@ -65,7 +69,7 @@ export function useGraphSimulation(): UseGraphSimulationResult { const lastValidSnapshotByTeamRef = useRef(new Map()); const dragOwnerPositionsRef = useRef(new Map()); const launchAnchorPositionsRef = useRef(new Map()); - const activityAnchorPositionsRef = useRef(new Map()); + const activityRectByNodeIdRef = useRef(new Map()); const extraWorldBoundsRef = useRef([]); const prevNodeIdsRef = useRef(new Set()); @@ -91,7 +95,7 @@ export function useGraphSimulation(): UseGraphSimulationResult { lastValidSnapshotByTeamRef, dragOwnerPositionsRef, launchAnchorPositionsRef, - activityAnchorPositionsRef, + activityRectByNodeIdRef, extraWorldBoundsRef, }); return; @@ -111,7 +115,7 @@ export function useGraphSimulation(): UseGraphSimulationResult { lastValidSnapshotByTeamRef, dragOwnerPositionsRef, launchAnchorPositionsRef, - activityAnchorPositionsRef, + activityRectByNodeIdRef, extraWorldBoundsRef, fillMissingFallbackPositions: true, }); @@ -123,7 +127,7 @@ export function useGraphSimulation(): UseGraphSimulationResult { nodes: state.nodes, layoutSnapshotRef, launchAnchorPositionsRef, - activityAnchorPositionsRef, + activityRectByNodeIdRef, extraWorldBoundsRef, }); }, []); @@ -198,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; @@ -220,7 +232,7 @@ export function useGraphSimulation(): UseGraphSimulationResult { return () => { dragOwnerPositionsRef.current.clear(); launchAnchorPositionsRef.current.clear(); - activityAnchorPositionsRef.current.clear(); + activityRectByNodeIdRef.current.clear(); extraWorldBoundsRef.current = []; layoutSnapshotRef.current = null; lastValidSnapshotByTeamRef.current.clear(); @@ -233,11 +245,11 @@ export function useGraphSimulation(): UseGraphSimulationResult { tick, setNodePosition, clearNodePosition, + clearTransientOwnerPositions, resolveNearestOwnerSlot, getLaunchAnchorWorldPosition: (leadNodeId: string) => launchAnchorPositionsRef.current.get(leadNodeId) ?? null, - getActivityAnchorWorldPosition: (nodeId: string) => - activityAnchorPositionsRef.current.get(nodeId) ?? null, + getActivityWorldRect: (nodeId: string) => activityRectByNodeIdRef.current.get(nodeId) ?? null, getExtraWorldBounds: () => extraWorldBoundsRef.current, }; } @@ -251,14 +263,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 +291,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); @@ -294,7 +308,7 @@ function commitSnapshotGeometry(args: { lastValidSnapshotByTeamRef: { current: Map }; dragOwnerPositionsRef: { current: ReadonlyMap }; launchAnchorPositionsRef: { current: Map }; - activityAnchorPositionsRef: { current: Map }; + activityRectByNodeIdRef: { current: Map }; extraWorldBoundsRef: { current: WorldBounds[] }; fillMissingFallbackPositions?: boolean; }): void { @@ -306,7 +320,7 @@ function commitSnapshotGeometry(args: { lastValidSnapshotByTeamRef, dragOwnerPositionsRef, launchAnchorPositionsRef, - activityAnchorPositionsRef, + activityRectByNodeIdRef, extraWorldBoundsRef, fillMissingFallbackPositions = false, } = args; @@ -319,44 +333,39 @@ function commitSnapshotGeometry(args: { } launchAnchorPositionsRef.current.clear(); - activityAnchorPositionsRef.current.clear(); + 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)) { - activityAnchorPositionsRef.current.set(frame.ownerId, { - x: frame.activityRect.left, - y: frame.activityRect.top, - }); + activityRectByNodeIdRef.current.set(frame.ownerId, frame.activityColumnRect); } - activityAnchorPositionsRef.current.set(`lead:${teamName}`, { - x: snapshot.leadActivityRect.left, - y: snapshot.leadActivityRect.top, - }); + if (snapshot.leadNodeId) { + activityRectByNodeIdRef.current.set( + snapshot.leadNodeId, + snapshot.leadSlotFrame.activityColumnRect + ); + } } function resetToFallbackLayout(args: { nodes: GraphNode[]; layoutSnapshotRef: { current: StableSlotLayoutSnapshot | null }; launchAnchorPositionsRef: { current: Map }; - activityAnchorPositionsRef: { current: Map }; + activityRectByNodeIdRef: { current: Map }; extraWorldBoundsRef: { current: WorldBounds[] }; }): void { const { nodes, layoutSnapshotRef, launchAnchorPositionsRef, - activityAnchorPositionsRef, + activityRectByNodeIdRef, extraWorldBoundsRef, } = args; layoutSnapshotRef.current = null; launchAnchorPositionsRef.current.clear(); - activityAnchorPositionsRef.current.clear(); + activityRectByNodeIdRef.current.clear(); extraWorldBoundsRef.current = []; fallbackPositionNodes(nodes); KanbanLayoutEngine.layout(nodes); diff --git a/packages/agent-graph/src/layout/kanbanLayout.ts b/packages/agent-graph/src/layout/kanbanLayout.ts index 9356f7c0..4c1475ad 100644 --- a/packages/agent-graph/src/layout/kanbanLayout.ts +++ b/packages/agent-graph/src/layout/kanbanLayout.ts @@ -10,8 +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 { ActivityLaneWorldBounds } from './activityLane'; import type { SlotFrame, StableRect } from './stableSlots'; /** Column header info for rendering */ @@ -43,8 +41,6 @@ const COLUMN_LABELS: Record = { approved: { label: 'Approved', color: COLORS.reviewApproved }, }; -const ACTIVITY_KANBAN_CLEARANCE = 24; - export function getOwnerKanbanBaseX(args: { ownerX: number; ownerKind: GraphNode['kind']; @@ -52,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; } @@ -61,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 { @@ -91,8 +77,8 @@ export class KanbanLayoutEngine { static layout( nodes: GraphNode[], options?: { - activityLaneBounds?: readonly ActivityLaneWorldBounds[]; memberSlotFrames?: readonly SlotFrame[]; + leadSlotFrame?: SlotFrame | null; unassignedTaskRect?: StableRect | null; } ): void { @@ -100,10 +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 activityLaneBounds = options?.activityLaneBounds ?? []; - 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(); @@ -115,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; }; @@ -148,8 +136,7 @@ export class KanbanLayoutEngine { owner, ownerId, leadX, - activityLaneBounds, - memberSlotFrameByOwnerId.get(ownerId) ?? null + ownerSlotFrameByOwnerId.get(ownerId) ?? null ); if (zoneInfo) this.zones.push(zoneInfo); } @@ -164,11 +151,9 @@ export class KanbanLayoutEngine { owner: GraphNode, ownerId: string, leadX: number | null, - activityLaneBounds: readonly ActivityLaneWorldBounds[], slotFrame: SlotFrame | null ): KanbanZoneInfo | null { - const { columnWidth, rowHeight, offsetY, columns } = KANBAN_ZONE; - const headerHeight = 20; // space for column header label + const { columnWidth, rowHeight, offsetY, columns, headerHeight } = KANBAN_ZONE; const ownerX = owner.x ?? 0; const ownerY = owner.y ?? 0; @@ -193,8 +178,6 @@ export class KanbanLayoutEngine { if (activeColumns.length === 0) return null; - // Keep kanban columns on the open side of the owner, away from the reserved activity lane. - // This makes member lanes reserve real visual space instead of only affecting the force layout. let baseX = getOwnerKanbanBaseX({ ownerX, ownerKind: owner.kind, @@ -205,27 +188,10 @@ export class KanbanLayoutEngine { let baseY: number; if (slotFrame) { - baseX = slotFrame.taskBandRect.left + TASK_PILL.width / 2; - baseY = slotFrame.taskBandRect.top; + baseX = slotFrame.kanbanBandRect.left + TASK_PILL.width / 2; + baseY = slotFrame.kanbanBandRect.top; } else { - const taskZoneLeft = baseX - TASK_PILL.width / 2; - const taskZoneRight = - baseX + (activeColumns.length - 1) * columnWidth + TASK_PILL.width / 2; - const overlappingActivityBottom = activityLaneBounds.reduce((maxBottom, bounds) => { - if (bounds.ownerId === ownerId) { - return Math.max(maxBottom, bounds.bottom); - } - if (!rangesOverlap(taskZoneLeft, taskZoneRight, bounds.left, bounds.right)) { - return maxBottom; - } - return Math.max(maxBottom, bounds.bottom); - }, -Infinity); - baseY = Math.max( - ownerY + offsetY, - overlappingActivityBottom > -Infinity - ? overlappingActivityBottom + ACTIVITY_KANBAN_CLEARANCE - : -Infinity - ); + baseY = ownerY + offsetY; } // Build headers + position tasks @@ -376,7 +342,3 @@ export class KanbanLayoutEngine { } } } - -function rangesOverlap(aStart: number, aEnd: number, bStart: number, bEnd: number): boolean { - return aStart < bEnd && bStart < aEnd; -} diff --git a/packages/agent-graph/src/layout/stableSlots.ts b/packages/agent-graph/src/layout/stableSlots.ts index cf11900f..070323ea 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_ANCHOR_LAYOUT, ACTIVITY_LANE } from './activityLane'; -import { LAUNCH_ANCHOR_LAYOUT, type WorldBounds } from './launchAnchor'; +import { ACTIVITY_LANE } from './activityLane'; +import type { WorldBounds } from './launchAnchor'; import { STABLE_SLOT_GEOMETRY, STABLE_SLOT_SECTOR_VECTORS, @@ -24,11 +24,13 @@ export interface OwnerFootprint { slotHeight: number; widthBucket: StableSlotWidthBucket; radialDepth: number; - activityWidth: number; - activityHeight: number; - processRailWidth: number; - taskBandWidth: number; - taskBandHeight: number; + activityColumnWidth: number; + activityColumnHeight: number; + processBandWidth: number; + kanbanBandWidth: number; + kanbanBandHeight: number; + boardBandWidth: number; + boardBandHeight: number; taskColumnCount: number; processCount: number; } @@ -41,9 +43,10 @@ export interface SlotFrame { bounds: StableRect; ownerX: number; ownerY: number; - activityRect: StableRect; + boardBandRect: StableRect; + activityColumnRect: StableRect; processBandRect: StableRect; - taskBandRect: StableRect; + kanbanBandRect: StableRect; taskColumnCount: number; } @@ -52,11 +55,13 @@ export interface StableSlotLayoutSnapshot { teamName: string; leadNodeId: string | null; leadCoreRect: StableRect; + leadSlotFrame: SlotFrame; leadActivityRect: StableRect; launchHudRect: StableRect; launchAnchor: { x: number; y: number } | null; leadCentralReservedBlock: StableRect; runtimeCentralExclusion: StableRect; + centralCollisionRects: StableRect[]; memberSlotFrames: SlotFrame[]; memberSlotFrameByOwnerId: Map; unassignedTaskRect: StableRect | null; @@ -72,6 +77,8 @@ interface NearestSlotAssignmentResult { assignment: GraphOwnerSlotAssignment; displacedOwnerId?: string; displacedAssignment?: GraphOwnerSlotAssignment; + previewOwnerX: number; + previewOwnerY: number; } interface RankedNearestSlotAssignmentResult extends NearestSlotAssignmentResult { @@ -93,18 +100,59 @@ type RingLayoutStateMap = ReadonlyMap; const SLOT_GEOMETRY = { ...STABLE_SLOT_GEOMETRY, - activityHeight: ACTIVITY_ANCHOR_LAYOUT.reservedHeight, - activityWidth: ACTIVITY_LANE.width, - activityToOwnerGap: STABLE_SLOT_GEOMETRY.slotVerticalGap, - ownerToProcessGap: STABLE_SLOT_GEOMETRY.slotVerticalGap, - processToTaskGap: STABLE_SLOT_GEOMETRY.slotVerticalGap, - taskBandHeight: + activityColumnHeight: ACTIVITY_LANE.headerHeight + + ACTIVITY_LANE.maxVisibleItems * ACTIVITY_LANE.rowHeight + + ACTIVITY_LANE.overflowHeight, + activityColumnWidth: ACTIVITY_LANE.width, + ownerToProcessGap: STABLE_SLOT_GEOMETRY.slotVerticalGap, + processToBoardGap: STABLE_SLOT_GEOMETRY.slotVerticalGap, + boardColumnGap: 24, + processRailMinWidth: STABLE_SLOT_GEOMETRY.processRailWidth, + kanbanBandHeight: + KANBAN_ZONE.headerHeight + STABLE_SLOT_GEOMETRY.taskMaxVisibleRows * KANBAN_ZONE.rowHeight, centralPadding: STABLE_SLOT_GEOMETRY.centralSafetyPadding, } as const; +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, @@ -116,44 +164,41 @@ export function buildStableSlotLayoutSnapshot({ return null; } - const leadCoreRect = createCenteredRect(0, 0, 200, 168); - const leadActivityRect = createRect( - leadCoreRect.left - SLOT_GEOMETRY.centralBlockGap - ACTIVITY_LANE.width, - -ACTIVITY_ANCHOR_LAYOUT.reservedHeight / 2, - ACTIVITY_LANE.width, - ACTIVITY_ANCHOR_LAYOUT.reservedHeight + 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({ + leadCentralReservedBlock, + unassignedTaskRect, + }); const runtimeCentralExclusion = padRect( - unionRects( - unassignedTaskRect - ? [leadCentralReservedBlock, unassignedTaskRect] - : [leadCentralReservedBlock] - ), + unionRects(centralCollisionRects), SLOT_GEOMETRY.centralPadding ); - const memberSlotFrames = planOwnerSlots(ownerFootprints, runtimeCentralExclusion, layout); + const memberSlotFrames = planOwnerSlots( + ownerFootprints, + centralCollisionRects, + runtimeCentralExclusion, + layout + ); const memberSlotFrameByOwnerId = new Map( memberSlotFrames.map((frame) => [frame.ownerId, frame] as const) ); const fitBounds = unionRects( [ - leadCentralReservedBlock, - leadActivityRect, - launchHudRect, runtimeCentralExclusion, ...memberSlotFrames.map((frame) => frame.bounds), - ...(unassignedTaskRect ? [unassignedTaskRect] : []), ].filter(Boolean) ); @@ -162,14 +207,13 @@ 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, memberSlotFrames, memberSlotFrameByOwnerId, unassignedTaskRect, @@ -177,6 +221,31 @@ export function buildStableSlotLayoutSnapshot({ }; } +function buildCentralCollisionRects(args: { + leadCentralReservedBlock: StableRect; + unassignedTaskRect: StableRect | null; +}): StableRect[] { + const rects = [args.leadCentralReservedBlock]; + if (args.unassignedTaskRect) { + rects.push(args.unassignedTaskRect); + } + return rects; +} + +function padCentralCollisionRects( + rects: readonly StableRect[], + padding: number +): StableRect[] { + return rects.map((rect) => padRect(rect, padding)); +} + +function rectOverlapsAnyCentralRect( + rect: StableRect, + centralCollisionRects: readonly StableRect[] +): boolean { + return centralCollisionRects.some((centralRect) => rectsOverlap(rect, centralRect)); +} + export function computeOwnerFootprints( nodes: GraphNode[], layout?: GraphLayoutPort @@ -210,59 +279,98 @@ export function computeOwnerFootprints( return []; } - const taskColumnCount = taskColumnsByOwnerId.get(ownerId)?.size ?? 0; - const taskBandWidth = - taskColumnCount <= 1 - ? TASK_PILL.width - : TASK_PILL.width + (taskColumnCount - 1) * KANBAN_ZONE.columnWidth; - const innerContentWidth = Math.max( - SLOT_GEOMETRY.activityWidth, - SLOT_GEOMETRY.ownerMinWidth, - SLOT_GEOMETRY.processRailWidth, - taskBandWidth - ); - const slotWidth = innerContentWidth + SLOT_GEOMETRY.memberSlotInnerPadding * 2; - const slotHeight = - SLOT_GEOMETRY.memberSlotInnerPadding * 2 + - SLOT_GEOMETRY.activityHeight + - SLOT_GEOMETRY.activityToOwnerGap + - SLOT_GEOMETRY.ownerBandHeight + - SLOT_GEOMETRY.ownerToProcessGap + - SLOT_GEOMETRY.processBandHeight + - SLOT_GEOMETRY.processToTaskGap + - SLOT_GEOMETRY.taskBandHeight; - const radialDepth = Math.max( - SLOT_GEOMETRY.memberSlotInnerPadding + - SLOT_GEOMETRY.activityHeight + - SLOT_GEOMETRY.activityToOwnerGap + - SLOT_GEOMETRY.ownerBandHeight / 2, - SLOT_GEOMETRY.memberSlotInnerPadding + - SLOT_GEOMETRY.ownerBandHeight / 2 + - SLOT_GEOMETRY.ownerToProcessGap + - SLOT_GEOMETRY.processBandHeight + - SLOT_GEOMETRY.processToTaskGap + - SLOT_GEOMETRY.taskBandHeight - ); - return [ - { + buildOwnerFootprint({ ownerId, - slotWidth, - slotHeight, - widthBucket: classifyWidthBucket(slotWidth), - radialDepth, - activityWidth: SLOT_GEOMETRY.activityWidth, - activityHeight: SLOT_GEOMETRY.activityHeight, - processRailWidth: SLOT_GEOMETRY.processRailWidth, - taskBandWidth, - taskBandHeight: SLOT_GEOMETRY.taskBandHeight, - taskColumnCount, + taskColumnCount: taskColumnsByOwnerId.get(ownerId)?.size ?? 0, processCount: processCountByOwnerId.get(ownerId) ?? 0, - } satisfies OwnerFootprint, + }), ]; }); } +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 + ); + + 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 { if (width <= 340) { return 'S'; @@ -273,6 +381,16 @@ export function classifyWidthBucket(width: number): StableSlotWidthBucket { return 'L'; } +export function computeProcessBandWidth(processCount: number): number { + if (processCount <= 1) { + return SLOT_GEOMETRY.processRailMinWidth; + } + + const occupiedWidth = + (processCount - 1) * PROCESS_RAIL_NODE_GAP + PROCESS_RAIL_NODE_FOOTPRINT; + return Math.max(SLOT_GEOMETRY.processRailMinWidth, occupiedWidth); +} + export function resolveNearestSlotAssignment(args: { ownerId: string; ownerX: number; @@ -295,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( @@ -320,6 +449,7 @@ export function resolveNearestSlotAssignment(args: { footprintByOwnerId, currentFrame, existingFrames, + centralCollisionRects: args.snapshot.centralCollisionRects, runtimeCentralExclusion: args.snapshot.runtimeCentralExclusion, ringStates, pointerX: args.ownerX, @@ -339,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 { @@ -386,11 +599,19 @@ 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], ['runtimeCentralExclusion', snapshot.runtimeCentralExclusion], ['fitBounds', snapshot.fitBounds], + ...snapshot.centralCollisionRects.map( + (rect, index) => [`centralCollisionRects[${index}]`, rect] as [string, StableRect] + ), ]; if (snapshot.unassignedTaskRect) { @@ -413,23 +634,51 @@ 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' }; } + const paddedCentralCollisionRects = padCentralCollisionRects( + snapshot.centralCollisionRects, + SLOT_GEOMETRY.centralPadding + ); if ( - snapshot.unassignedTaskRect && - !rectContainsRect(snapshot.runtimeCentralExclusion, snapshot.unassignedTaskRect) + paddedCentralCollisionRects.some( + (rect) => !rectContainsRect(snapshot.runtimeCentralExclusion, rect) + ) ) { - return { valid: false, reason: 'runtimeCentralExclusion must contain unassignedTaskRect' }; + return { + valid: false, + reason: 'runtimeCentralExclusion must contain all centralCollisionRects', + }; } return null; @@ -441,11 +690,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}` }; @@ -458,23 +709,61 @@ function validateMemberSlotFrame( } seenAssignments.add(assignmentKey); - if (rectsOverlap(frame.bounds, snapshot.runtimeCentralExclusion)) { - return { valid: false, reason: `slot frame for ${frame.ownerId} overlaps runtimeCentralExclusion` }; + if (rectOverlapsAnyCentralRect(frame.bounds, snapshot.centralCollisionRects)) { + return { + valid: false, + reason: `slot frame for ${frame.ownerId} overlaps centralCollisionRects`, + }; } - if (!rectContainsRect(frame.bounds, frame.activityRect)) { - return { valid: false, reason: `activityRect escapes slot bounds for ${frame.ownerId}` }; + 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 ${label}` }; + } + if (!rectContainsRect(frame.bounds, frame.activityColumnRect)) { + 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.taskBandRect)) { - return { valid: false, reason: `taskBandRect escapes slot bounds for ${frame.ownerId}` }; + if (!rectContainsRect(frame.bounds, frame.kanbanBandRect)) { + return { valid: false, reason: `kanbanBandRect escapes ${label}` }; + } + if (!rectContainsRect(frame.boardBandRect, frame.activityColumnRect)) { + return { + valid: false, + reason: `activityColumnRect escapes boardBandRect in ${label}`, + }; + } + if (!rectContainsRect(frame.boardBandRect, frame.kanbanBandRect)) { + return { + valid: false, + reason: `kanbanBandRect escapes boardBandRect in ${label}`, + }; + } + if (rectsOverlap(frame.activityColumnRect, frame.kanbanBandRect)) { + return { + valid: false, + 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; @@ -502,9 +791,10 @@ export function translateSlotFrame(frame: SlotFrame, dx: number, dy: number): Sl bounds: translateRect(frame.bounds, dx, dy), ownerX: frame.ownerX + dx, ownerY: frame.ownerY + dy, - activityRect: translateRect(frame.activityRect, dx, dy), + boardBandRect: translateRect(frame.boardBandRect, dx, dy), + activityColumnRect: translateRect(frame.activityColumnRect, dx, dy), processBandRect: translateRect(frame.processBandRect, dx, dy), - taskBandRect: translateRect(frame.taskBandRect, dx, dy), + kanbanBandRect: translateRect(frame.kanbanBandRect, dx, dy), }; } @@ -554,7 +844,7 @@ function buildUnassignedTaskRect( columnCount <= 1 ? TASK_PILL.width : TASK_PILL.width + (columnCount - 1) * KANBAN_ZONE.columnWidth; - const height = SLOT_GEOMETRY.taskBandHeight; + const height = SLOT_GEOMETRY.kanbanBandHeight; return createRect( -width / 2, leadCentralReservedBlock.bottom + SLOT_GEOMETRY.unassignedGap, @@ -565,9 +855,22 @@ function buildUnassignedTaskRect( function planOwnerSlots( ownerFootprints: OwnerFootprint[], - centralExclusion: StableRect, + centralCollisionRects: readonly StableRect[], + 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(); @@ -577,7 +880,8 @@ function planOwnerSlots( for (const footprint of ownerFootprints) { const resolvedFrame = resolveOwnerSlotFrame({ footprint, - centralExclusion, + centralCollisionRects, + runtimeCentralExclusion, ringStates, preferredAssignment: preferredAssignments.get(footprint.ownerId), usedSlotKeys, @@ -591,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 { @@ -618,7 +1021,8 @@ function buildPreferredAssignmentsMap( function resolveOwnerSlotFrame(args: { footprint: OwnerFootprint; - centralExclusion: StableRect; + centralCollisionRects: readonly StableRect[]; + runtimeCentralExclusion: StableRect; ringStates: RingLayoutStateMap; preferredAssignment?: GraphOwnerSlotAssignment; usedSlotKeys: Set; @@ -627,7 +1031,8 @@ function resolveOwnerSlotFrame(args: { }): SlotFrame { const { footprint, - centralExclusion, + centralCollisionRects, + runtimeCentralExclusion, ringStates, preferredAssignment, usedSlotKeys, @@ -641,7 +1046,8 @@ function resolveOwnerSlotFrame(args: { const directMatch = findFirstValidSlotFrame({ candidateAssignments: candidates, footprint, - centralExclusion, + centralCollisionRects, + runtimeCentralExclusion, ringStates, usedSlotKeys, placedFrames, @@ -657,7 +1063,8 @@ function resolveOwnerSlotFrame(args: { const spilloverMatch = findFirstValidSlotFrame({ candidateAssignments: spilloverCandidates, footprint, - centralExclusion, + centralCollisionRects, + runtimeCentralExclusion, ringStates, usedSlotKeys, placedFrames, @@ -668,7 +1075,8 @@ function resolveOwnerSlotFrame(args: { return buildEmergencyFallbackSlotFrame({ footprint, - centralExclusion, + centralCollisionRects, + runtimeCentralExclusion, ringStates, usedSlotKeys, placedOwnerCount: placedFrames.length, @@ -679,48 +1087,71 @@ function resolveOwnerSlotFrame(args: { function buildSlotFrame( footprint: OwnerFootprint, assignment: GraphOwnerSlotAssignment, - centralExclusion: StableRect, + centralCollisionRects: readonly StableRect[], + runtimeCentralExclusion: StableRect, options: { ringStates: RingLayoutStateMap } ): SlotFrame | null { - const vector = SECTOR_VECTORS[assignment.sectorIndex % SECTOR_VECTORS.length] ?? SECTOR_VECTORS[0]; const radius = resolveRingRadiusForAssignment({ assignment, footprint, - centralExclusion, + centralCollisionRects, + runtimeCentralExclusion, ringStates: options.ringStates, }); if (radius == null) { return null; } + return buildSlotFrameAtRadius(footprint, assignment, radius); +} + +function buildSlotFrameAtRadius( + footprint: OwnerFootprint, + assignment: GraphOwnerSlotAssignment, + 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 = - ownerY - - (SLOT_GEOMETRY.memberSlotInnerPadding + - SLOT_GEOMETRY.activityHeight + - SLOT_GEOMETRY.activityToOwnerGap + - SLOT_GEOMETRY.ownerBandHeight / 2); - const bounds = createRect(ownerX - footprint.slotWidth / 2, slotTop, footprint.slotWidth, footprint.slotHeight); - const activityRect = createRect( - bounds.left + (bounds.width - footprint.activityWidth) / 2, - bounds.top + SLOT_GEOMETRY.memberSlotInnerPadding, - footprint.activityWidth, - footprint.activityHeight + ownerY - (SLOT_GEOMETRY.memberSlotInnerPadding + SLOT_GEOMETRY.ownerBandHeight / 2); + const bounds = createRect( + ownerX - footprint.slotWidth / 2, + slotTop, + footprint.slotWidth, + footprint.slotHeight ); const processBandRect = createRect( - bounds.left + (bounds.width - footprint.processRailWidth) / 2, - activityRect.bottom + - SLOT_GEOMETRY.activityToOwnerGap + - SLOT_GEOMETRY.ownerBandHeight + - SLOT_GEOMETRY.ownerToProcessGap, - footprint.processRailWidth, + bounds.left + (bounds.width - footprint.processBandWidth) / 2, + ownerY + SLOT_GEOMETRY.ownerBandHeight / 2 + SLOT_GEOMETRY.ownerToProcessGap, + footprint.processBandWidth, SLOT_GEOMETRY.processBandHeight ); - const taskBandRect = createRect( - bounds.left + (bounds.width - footprint.taskBandWidth) / 2, - processBandRect.bottom + SLOT_GEOMETRY.processToTaskGap, - footprint.taskBandWidth, - footprint.taskBandHeight + const boardBandRect = createRect( + bounds.left + (bounds.width - footprint.boardBandWidth) / 2, + processBandRect.bottom + SLOT_GEOMETRY.processToBoardGap, + footprint.boardBandWidth, + footprint.boardBandHeight + ); + const activityColumnRect = createRect( + boardBandRect.left, + boardBandRect.top, + footprint.activityColumnWidth, + footprint.activityColumnHeight + ); + const kanbanBandRect = createRect( + activityColumnRect.right + SLOT_GEOMETRY.boardColumnGap, + boardBandRect.top, + footprint.kanbanBandWidth, + footprint.kanbanBandHeight ); return { @@ -731,9 +1162,10 @@ function buildSlotFrame( bounds, ownerX, ownerY, - activityRect, + boardBandRect, + activityColumnRect, processBandRect, - taskBandRect, + kanbanBandRect, taskColumnCount: footprint.taskColumnCount, }; } @@ -790,7 +1222,8 @@ function ownerFootprintsSpillBudget(placedOwnerCount: number): number { function buildEmergencyFallbackSlotFrame(args: { footprint: OwnerFootprint; - centralExclusion: StableRect; + centralCollisionRects: readonly StableRect[]; + runtimeCentralExclusion: StableRect; ringStates: RingLayoutStateMap; usedSlotKeys: Set; placedOwnerCount: number; @@ -801,9 +1234,15 @@ function buildEmergencyFallbackSlotFrame(args: { sectorIndex: 0, }; args.usedSlotKeys.add(buildAssignmentKey(assignment)); - const frame = buildSlotFrame(args.footprint, assignment, args.centralExclusion, { - ringStates: args.ringStates, - }); + const frame = buildSlotFrame( + args.footprint, + assignment, + args.centralCollisionRects, + args.runtimeCentralExclusion, + { + ringStates: args.ringStates, + } + ); if (!frame) { throw new Error(`failed to build emergency fallback slot frame for ${args.footprint.ownerId}`); } @@ -817,6 +1256,7 @@ function rankNearestSlotAssignmentResult(args: { footprintByOwnerId: ReadonlyMap; currentFrame: SlotFrame; existingFrames: readonly SlotFrame[]; + centralCollisionRects: readonly StableRect[]; runtimeCentralExclusion: StableRect; ringStates: RingLayoutStateMap; pointerX: number; @@ -829,14 +1269,21 @@ function rankNearestSlotAssignmentResult(args: { footprintByOwnerId, currentFrame, existingFrames, + centralCollisionRects, runtimeCentralExclusion, ringStates, pointerX, pointerY, } = args; - const frame = buildSlotFrame(footprint, assignment, runtimeCentralExclusion, { - ringStates, - }); + const frame = buildSlotFrame( + footprint, + assignment, + centralCollisionRects, + runtimeCentralExclusion, + { + ringStates, + } + ); if (!frame) { return null; } @@ -846,6 +1293,7 @@ function rankNearestSlotAssignmentResult(args: { occupiedFrame, footprintByOwnerId, currentFrame, + centralCollisionRects, runtimeCentralExclusion, ringStates, }); @@ -854,8 +1302,8 @@ function rankNearestSlotAssignmentResult(args: { } const otherFrames = existingFrames.filter((existing) => existing.ownerId !== occupiedFrame.ownerId); if ( - !isSlotFramePlacementValid(frame, otherFrames, runtimeCentralExclusion) || - !isSlotFramePlacementValid(displacedFrame, otherFrames, runtimeCentralExclusion) || + !isSlotFramePlacementValid(frame, otherFrames, centralCollisionRects) || + !isSlotFramePlacementValid(displacedFrame, otherFrames, centralCollisionRects) || rectsOverlapWithGap(frame.bounds, displacedFrame.bounds, SLOT_GEOMETRY.ringPadding) ) { return null; @@ -873,7 +1321,7 @@ function rankNearestSlotAssignmentResult(args: { }); } - if (!isSlotFramePlacementValid(frame, existingFrames, runtimeCentralExclusion)) { + if (!isSlotFramePlacementValid(frame, existingFrames, centralCollisionRects)) { return null; } @@ -889,6 +1337,7 @@ function buildDisplacedFrameForNearestAssignment(args: { occupiedFrame: SlotFrame; footprintByOwnerId: ReadonlyMap; currentFrame: SlotFrame; + centralCollisionRects: readonly StableRect[]; runtimeCentralExclusion: StableRect; ringStates: RingLayoutStateMap; }): SlotFrame | null { @@ -902,6 +1351,7 @@ function buildDisplacedFrameForNearestAssignment(args: { ringIndex: args.currentFrame.ringIndex, sectorIndex: args.currentFrame.sectorIndex, }, + args.centralCollisionRects, args.runtimeCentralExclusion, { ringStates: args.ringStates } ); @@ -921,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, }; } @@ -928,7 +1380,8 @@ function buildRankedNearestSlotAssignmentResult(args: { function findFirstValidSlotFrame(args: { candidateAssignments: readonly GraphOwnerSlotAssignment[]; footprint: OwnerFootprint; - centralExclusion: StableRect; + centralCollisionRects: readonly StableRect[]; + runtimeCentralExclusion: StableRect; ringStates: RingLayoutStateMap; usedSlotKeys: Set; placedFrames: readonly SlotFrame[]; @@ -946,7 +1399,8 @@ function findFirstValidSlotFrame(args: { function tryBuildValidSlotFrame( args: { footprint: OwnerFootprint; - centralExclusion: StableRect; + centralCollisionRects: readonly StableRect[]; + runtimeCentralExclusion: StableRect; ringStates: RingLayoutStateMap; usedSlotKeys: Set; placedFrames: readonly SlotFrame[]; @@ -958,17 +1412,19 @@ function tryBuildValidSlotFrame( if (args.usedSlotKeys.has(slotKey) && !isSameAssignment(args.preferredAssignment, assignment)) { return null; } - const frame = buildSlotFrame(args.footprint, assignment, args.centralExclusion, { - ringStates: args.ringStates, - }); + const frame = buildSlotFrame( + args.footprint, + assignment, + args.centralCollisionRects, + args.runtimeCentralExclusion, + { + ringStates: args.ringStates, + } + ); if (!frame) { return null; } - if ( - args.placedFrames.some((existing) => - rectsOverlapWithGap(existing.bounds, frame.bounds, SLOT_GEOMETRY.ringPadding) - ) - ) { + if (!isSlotFramePlacementValid(frame, args.placedFrames, args.centralCollisionRects)) { return null; } args.usedSlotKeys.add(slotKey); @@ -1079,11 +1535,7 @@ function computeSlotDirectionalDepths( footprint: OwnerFootprint, vector: { x: number; y: number } ): { outwardDepth: number; inwardDepth: number } { - const ownerLocalY = - SLOT_GEOMETRY.memberSlotInnerPadding + - SLOT_GEOMETRY.activityHeight + - SLOT_GEOMETRY.activityToOwnerGap + - SLOT_GEOMETRY.ownerBandHeight / 2; + const ownerLocalY = SLOT_GEOMETRY.memberSlotInnerPadding + SLOT_GEOMETRY.ownerBandHeight / 2; const topOffset = -ownerLocalY; const bottomOffset = footprint.slotHeight - ownerLocalY; const halfWidth = footprint.slotWidth / 2; @@ -1106,12 +1558,18 @@ function computeSlotDirectionalDepths( function resolveRingRadiusForAssignment(args: { assignment: GraphOwnerSlotAssignment; footprint: OwnerFootprint; - centralExclusion: StableRect; + centralCollisionRects: readonly StableRect[]; + runtimeCentralExclusion: StableRect; ringStates: RingLayoutStateMap; }): number | null { const vector = SECTOR_VECTORS[args.assignment.sectorIndex % SECTOR_VECTORS.length] ?? SECTOR_VECTORS[0]; - const minRadius = computeMinimumRingRadius(vector, args.footprint, args.centralExclusion); + const minRadius = resolveMinimumDirectionalRadius({ + assignment: args.assignment, + footprint: args.footprint, + centralCollisionRects: args.centralCollisionRects, + runtimeCentralExclusion: args.runtimeCentralExclusion, + }); const directionalDepths = computeSlotDirectionalDepths(args.footprint, vector); const ringState = resolveVirtualRingState( args.assignment.sectorIndex, @@ -1165,7 +1623,67 @@ function buildSectorRingStateKey(sectorIndex: number, ringIndex: number): string return `${sectorIndex}:${ringIndex}`; } -function computeMinimumRingRadius( +function resolveMinimumDirectionalRadius(args: { + assignment: GraphOwnerSlotAssignment; + 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( + args.vector, + args.footprint, + args.runtimeCentralExclusion + ); + const overlapsCentralCollision = (radius: number): boolean => { + const frame = buildSlotFrameAtRadiusWithVector( + args.footprint, + { ringIndex: 0, sectorIndex: 0 }, + radius, + args.vector + ); + return rectOverlapsAnyCentralRect(frame.bounds, args.centralCollisionRects); + }; + + if (!overlapsCentralCollision(0)) { + return 0; + } + + let low = 0; + let high = Math.max(legacyRadiusHint, SLOT_GEOMETRY.ringGap); + let expansionCount = 0; + while (overlapsCentralCollision(high) && expansionCount < 24) { + low = high; + high = Math.max(high * 2, high + SLOT_GEOMETRY.ringGap); + expansionCount += 1; + } + + for (let iteration = 0; iteration < 24; iteration += 1) { + const mid = (low + high) / 2; + if (overlapsCentralCollision(mid)) { + low = mid; + } else { + high = mid; + } + } + + return Math.ceil(high); +} + +function computeLegacyMinimumRingRadius( vector: { x: number; y: number }, footprint: OwnerFootprint, centralExclusion: StableRect @@ -1207,15 +1725,20 @@ function rectsOverlap(a: StableRect, b: StableRect): boolean { function rectContainsRect(outer: StableRect, inner: StableRect): boolean { return ( - inner.left >= outer.left && - inner.right <= outer.right && - inner.top >= outer.top && - inner.bottom <= outer.bottom + inner.left >= outer.left - GEOMETRY_EPSILON && + inner.right <= outer.right + GEOMETRY_EPSILON && + inner.top >= outer.top - GEOMETRY_EPSILON && + inner.bottom <= outer.bottom + GEOMETRY_EPSILON ); } function pointInRect(x: number, y: number, rect: StableRect): boolean { - return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom; + return ( + x >= rect.left - GEOMETRY_EPSILON && + x <= rect.right + GEOMETRY_EPSILON && + y >= rect.top - GEOMETRY_EPSILON && + y <= rect.bottom + GEOMETRY_EPSILON + ); } function isFiniteRect(rect: StableRect): boolean { @@ -1232,12 +1755,12 @@ function isFiniteRect(rect: StableRect): boolean { function isSlotFramePlacementValid( frame: SlotFrame, existingFrames: readonly SlotFrame[], - runtimeCentralExclusion: StableRect + centralCollisionRects: readonly StableRect[] ): boolean { if (!isFiniteRect(frame.bounds)) { return false; } - if (rectsOverlap(frame.bounds, runtimeCentralExclusion)) { + if (rectOverlapsAnyCentralRect(frame.bounds, centralCollisionRects)) { return false; } return !existingFrames.some((existing) => 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/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 807238ac..a842bbf9 100644 --- a/packages/agent-graph/src/ui/GraphView.tsx +++ b/packages/agent-graph/src/ui/GraphView.tsx @@ -16,6 +16,7 @@ import type { GraphDataPort } from '../ports/GraphDataPort'; import type { GraphEventPort } from '../ports/GraphEventPort'; import type { GraphConfigPort } from '../ports/GraphConfigPort'; import type { GraphEdge, GraphNode, GraphOwnerSlotAssignment } from '../ports/types'; +import type { StableRect } from '../layout/stableSlots'; import { GraphCanvas, type GraphCanvasHandle } from './GraphCanvas'; import { GraphControls, type GraphFilterState } from './GraphControls'; import { GraphOverlay } from './GraphOverlay'; @@ -31,7 +32,6 @@ import { getEdgeMidpoint, } from '../canvas/hit-detection'; import { ANIM_SPEED } from '../constants/canvas-constants'; -import { getActivityAnchorScreenPlacement as buildActivityAnchorScreenPlacement } from '../layout/activityLane'; import { getLaunchAnchorScreenPlacement as buildLaunchAnchorScreenPlacement } from '../layout/launchAnchor'; export interface GraphViewProps { @@ -43,10 +43,12 @@ export interface GraphViewProps { onRequestClose?: () => void; onRequestPinAsTab?: () => void; onRequestFullscreen?: () => void; + isSurfaceActive?: boolean; onOpenTeamPage?: () => void; onCreateTask?: () => void; onToggleSidebar?: () => void; isSidebarVisible?: boolean; + renderTopToolbarContent?: () => React.ReactNode; onOwnerSlotDrop?: (payload: { nodeId: string; assignment: GraphOwnerSlotAssignment; @@ -70,19 +72,11 @@ export interface GraphViewProps { getLaunchAnchorScreenPlacement: ( leadNodeId: string, ) => { x: number; y: number; scale: number; visible: boolean } | null; - getActivityAnchorScreenPlacement: ( - ownerNodeId: string, - ) => { x: number; y: number; scale: number; visible: boolean } | null; - getActivityAnchorWorldPosition: ( - ownerNodeId: string, - ) => { x: number; y: number } | null; + getActivityWorldRect: (ownerNodeId: string) => StableRect | null; getCameraZoom: () => number; worldToScreen: (x: number, y: number) => { x: number; y: number }; getNodeWorldPosition: (nodeId: string) => { x: number; y: number } | null; getViewportSize: () => { width: number; height: number }; - getNodeScreenPosition: ( - nodeId: string, - ) => { x: number; y: number; visible: boolean } | null; focusNodeIds: ReadonlySet | null; }) => React.ReactNode; } @@ -96,10 +90,12 @@ export function GraphView({ onRequestClose, onRequestPinAsTab, onRequestFullscreen, + isSurfaceActive = true, onOpenTeamPage, onCreateTask, onToggleSidebar, isSidebarVisible = true, + renderTopToolbarContent, onOwnerSlotDrop, renderOverlay, renderEdgeOverlay, @@ -133,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(); @@ -240,49 +242,11 @@ export function GraphView({ viewportHeight: viewport.height, }); }, [getViewportSize]); - const getActivityAnchorScreenPlacement = useCallback((ownerNodeId: string) => { - const anchor = simulationRef.current.getActivityAnchorWorldPosition(ownerNodeId); - if (!anchor) { - return null; - } - const viewport = getViewportSize(); - if (viewport.width <= 0 || viewport.height <= 0) { - return null; - } - const transform = cameraRef.current.transformRef.current; - return buildActivityAnchorScreenPlacement({ - anchorX: anchor.x, - anchorY: anchor.y, - cameraX: transform.x, - cameraY: transform.y, - zoom: transform.zoom, - viewportWidth: viewport.width, - viewportHeight: viewport.height, - }); - }, [getViewportSize]); - const getActivityAnchorWorldPosition = useCallback( - (ownerNodeId: string) => simulationRef.current.getActivityAnchorWorldPosition(ownerNodeId), - [], - ); const getCameraZoom = useCallback(() => cameraRef.current.transformRef.current.zoom, []); - const getNodeScreenPosition = useCallback((nodeId: string) => { - const viewport = getViewportSize(); - if (viewport.width <= 0 || viewport.height <= 0) { - return null; - } - const node = simulationRef.current.stateRef.current.nodes.find((candidate) => candidate.id === nodeId); - if (node?.x == null || node?.y == null) { - return null; - } - const transform = cameraRef.current.transformRef.current; - const x = node.x * transform.zoom + transform.x; - const y = node.y * transform.zoom + transform.y; - return { - x, - y, - visible: x > -80 && x < viewport.width + 80 && y > -80 && y < viewport.height + 80, - }; - }, [getViewportSize]); + const getActivityWorldRect = useCallback( + (ownerNodeId: string) => simulationRef.current.getActivityWorldRect(ownerNodeId), + [] + ); const getNodeWorldPosition = useCallback((nodeId: string) => { const node = simulationRef.current.stateRef.current.nodes.find((candidate) => candidate.id === nodeId); if (node?.x == null || node?.y == null) { @@ -328,6 +292,7 @@ export function GraphView({ hoveredEdgeId: hoveredEdgeIdRef.current, focusNodeIds: focusState.focusNodeIds, focusEdgeIds: focusState.focusEdgeIds, + dragPreview: dragPreviewRef.current, }); rafRef.current = requestAnimationFrame(animate); @@ -414,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(); @@ -429,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; @@ -479,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; } @@ -554,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; } @@ -573,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) { @@ -592,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(); @@ -794,19 +864,18 @@ export function GraphView({ teamName={data.teamName} teamColor={data.teamColor} isAlive={data.isAlive} + topToolbarContent={renderTopToolbarContent?.()} /> {renderHud ? (
{renderHud({ getLaunchAnchorScreenPlacement, - getActivityAnchorScreenPlacement, - getActivityAnchorWorldPosition, + getActivityWorldRect, getCameraZoom, worldToScreen: camera.worldToScreen, getNodeWorldPosition, getViewportSize, - getNodeScreenPosition, focusNodeIds: focusState.focusNodeIds, })}
diff --git a/src/features/agent-graph/core/domain/buildInlineActivityEntries.ts b/src/features/agent-graph/core/domain/buildInlineActivityEntries.ts index 13304ff3..a5ddfa5c 100644 --- a/src/features/agent-graph/core/domain/buildInlineActivityEntries.ts +++ b/src/features/agent-graph/core/domain/buildInlineActivityEntries.ts @@ -3,7 +3,7 @@ import { getIdleGraphLabel } from '@shared/utils/idleNotificationSemantics'; import { isInboxNoiseMessage } from '@shared/utils/inboxNoise'; import { isLeadMember } from '@shared/utils/leadDetection'; -import { buildGraphMemberNodeIdForMember } from './graphOwnerIdentity'; +import { buildGraphMemberNodeIdAliasMap } from './graphOwnerIdentity'; import type { GraphActivityItem } from '@claude-teams/agent-graph'; import type { @@ -53,10 +53,9 @@ export function buildInlineActivityEntries({ ownerNodeIds, }: BuildInlineActivityEntriesArgs): Map { const entriesByOwnerNodeId = new Map(); - const memberNodeIdByName = new Map( - data.members - .filter((member) => !isLeadMember(member)) - .map((member) => [member.name, buildGraphMemberNodeIdForMember(teamName, member)] as const) + const memberNodeIdByAlias = buildGraphMemberNodeIdAliasMap( + teamName, + data.members.filter((member) => !isLeadMember(member)) ); const appendEntry = (entry: InlineActivityEntry): void => { @@ -95,7 +94,7 @@ export function buildInlineActivityEntries({ leadId, leadName, ownerNodeIds, - memberNodeIdByName, + memberNodeIdByAlias, }); if (!ownerNodeId) { continue; @@ -140,7 +139,7 @@ export function buildInlineActivityEntries({ leadId, leadName, ownerNodeIds, - memberNodeIdByName, + memberNodeIdByAlias, }); if (!ownerNodeId) { continue; @@ -220,16 +219,16 @@ function resolveMessageOwnerNodeId(args: { leadId: string; leadName: string; ownerNodeIds: ReadonlySet; - memberNodeIdByName: ReadonlyMap; + memberNodeIdByAlias: ReadonlyMap; }): string | null { - const { message, leadId, leadName, ownerNodeIds, memberNodeIdByName } = args; + const { message, leadId, leadName, ownerNodeIds, memberNodeIdByAlias } = args; if (message.source === 'cross_team' || message.source === 'cross_team_sent') { return leadId; } - const fromId = resolveParticipantId(message.from ?? '', leadId, leadName, memberNodeIdByName); + const fromId = resolveParticipantId(message.from ?? '', leadId, leadName, memberNodeIdByAlias); const toId = message.to - ? resolveParticipantId(message.to, leadId, leadName, memberNodeIdByName) + ? resolveParticipantId(message.to, leadId, leadName, memberNodeIdByAlias) : leadId; if (toId !== leadId && ownerNodeIds.has(toId)) { @@ -247,17 +246,17 @@ function resolveCommentOwnerNodeId(args: { leadId: string; leadName: string; ownerNodeIds: ReadonlySet; - memberNodeIdByName: ReadonlyMap; + memberNodeIdByAlias: ReadonlyMap; }): string | null { - const { taskOwner, author, leadId, leadName, ownerNodeIds, memberNodeIdByName } = args; + const { taskOwner, author, leadId, leadName, ownerNodeIds, memberNodeIdByAlias } = args; if (taskOwner) { - const ownerId = resolveParticipantId(taskOwner, leadId, leadName, memberNodeIdByName); + const ownerId = resolveParticipantId(taskOwner, leadId, leadName, memberNodeIdByAlias); if (ownerNodeIds.has(ownerId)) { return ownerId; } } - const authorId = resolveParticipantId(author, leadId, leadName, memberNodeIdByName); + const authorId = resolveParticipantId(author, leadId, leadName, memberNodeIdByAlias); if (ownerNodeIds.has(authorId)) { return authorId; } @@ -367,7 +366,7 @@ function resolveParticipantId( name: string, leadId: string, leadName: string | undefined, - memberNodeIdByName: ReadonlyMap + memberNodeIdByAlias: ReadonlyMap ): string { const normalized = name.trim().toLowerCase(); if (normalized === 'user' || normalized === 'team-lead') { @@ -376,7 +375,7 @@ function resolveParticipantId( if (normalized === leadName?.trim().toLowerCase()) { return leadId; } - return memberNodeIdByName.get(name) ?? leadId; + return memberNodeIdByAlias.get(name) ?? leadId; } function buildParticipantLabel(name: string | undefined, leadName: string): string { diff --git a/src/features/agent-graph/core/domain/graphOwnerIdentity.ts b/src/features/agent-graph/core/domain/graphOwnerIdentity.ts index bb050616..02a02aa8 100644 --- a/src/features/agent-graph/core/domain/graphOwnerIdentity.ts +++ b/src/features/agent-graph/core/domain/graphOwnerIdentity.ts @@ -17,6 +17,31 @@ export function buildGraphMemberNodeIdForMember( return buildGraphMemberNodeId(teamName, getGraphStableOwnerId(member)); } +export function buildGraphMemberNodeIdAliasMap( + teamName: string, + members: readonly StableTeamOwnerLike[] +): Map { + const aliases = new Map(); + + for (const member of members) { + const stableOwnerId = getGraphStableOwnerId(member).trim(); + if (!stableOwnerId) { + continue; + } + aliases.set(stableOwnerId, buildGraphMemberNodeId(teamName, stableOwnerId)); + } + + for (const member of members) { + const memberName = member.name.trim(); + if (!memberName || aliases.has(memberName)) { + continue; + } + aliases.set(memberName, buildGraphMemberNodeIdForMember(teamName, member)); + } + + return aliases; +} + export function parseGraphMemberNodeId(nodeId: string, teamName?: string): string | null { const prefix = teamName ? `member:${teamName}:` : 'member:'; if (!nodeId.startsWith(prefix)) { diff --git a/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts b/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts index 57abe5de..55a19a5f 100644 --- a/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts +++ b/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts @@ -30,6 +30,7 @@ import { } from '../../core/domain/buildInlineActivityEntries'; import { collapseOverflowStacksWithMeta } from '../../core/domain/collapseOverflowStacks'; import { + buildGraphMemberNodeIdAliasMap, buildGraphMemberNodeIdForMember, getGraphStableOwnerId, GRAPH_STABLE_SLOT_LAYOUT_VERSION, @@ -140,7 +141,7 @@ export class TeamGraphAdapter { const leadId = `lead:${teamName}`; const leadName = TeamGraphAdapter.#getLeadMemberName(teamData, teamName); - const memberNodeIdByName = TeamGraphAdapter.#buildMemberNodeIdByName(teamData, teamName); + const memberNodeIdByAlias = TeamGraphAdapter.#buildMemberNodeIdByAlias(teamData, teamName); const provisioningPresentation = buildTeamProvisioningPresentation({ progress: provisioningProgress, members: teamData.members, @@ -170,7 +171,7 @@ export class TeamGraphAdapter { leadId, teamData, teamName, - memberNodeIdByName, + memberNodeIdByAlias, spawnStatuses, pendingApprovalAgents, activeTools, @@ -179,8 +180,8 @@ export class TeamGraphAdapter { isTeamProvisioning, isLaunchSettling ); - this.#buildTaskNodes(nodes, edges, teamData, teamName, commentReadState, memberNodeIdByName); - this.#buildProcessNodes(nodes, edges, teamData, teamName, memberNodeIdByName); + this.#buildTaskNodes(nodes, edges, teamData, teamName, commentReadState, memberNodeIdByAlias); + this.#buildProcessNodes(nodes, edges, teamData, teamName, memberNodeIdByAlias); this.#attachActivityFeeds(nodes, teamData, teamName, leadId, leadName); this.#buildMessageParticles( particles, @@ -190,7 +191,7 @@ export class TeamGraphAdapter { leadId, leadName, edges, - memberNodeIdByName + memberNodeIdByAlias ); this.#buildCommentParticles( particles, @@ -199,7 +200,7 @@ export class TeamGraphAdapter { leadId, leadName, edges, - memberNodeIdByName + memberNodeIdByAlias ); return { @@ -232,11 +233,10 @@ export class TeamGraphAdapter { return getGraphLeadMemberName(data, teamName); } - static #buildMemberNodeIdByName(data: TeamGraphData, teamName: string): Map { - return new Map( - data.members - .filter((member) => !isLeadMember(member)) - .map((member) => [member.name, buildGraphMemberNodeIdForMember(teamName, member)] as const) + static #buildMemberNodeIdByAlias(data: TeamGraphData, teamName: string): Map { + return buildGraphMemberNodeIdAliasMap( + teamName, + data.members.filter((member) => !isLeadMember(member)) ); } @@ -470,7 +470,7 @@ export class TeamGraphAdapter { leadId: string, data: TeamGraphData, teamName: string, - memberNodeIdByName: ReadonlyMap, + memberNodeIdByAlias: ReadonlyMap, spawnStatuses?: Record, pendingApprovalAgents?: Set, activeTools?: Record>, @@ -484,7 +484,7 @@ export class TeamGraphAdapter { if (isLeadMember(member)) continue; const memberId = - memberNodeIdByName.get(member.name) ?? buildGraphMemberNodeIdForMember(teamName, member); + memberNodeIdByAlias.get(member.name) ?? buildGraphMemberNodeIdForMember(teamName, member); const spawn = spawnStatuses?.[member.name]; const activeTool = TeamGraphAdapter.#selectVisibleTool( activeTools?.[member.name], @@ -574,7 +574,7 @@ export class TeamGraphAdapter { data: TeamGraphData, teamName: string, commentReadState?: Record, - memberNodeIdByName?: ReadonlyMap + memberNodeIdByAlias?: ReadonlyMap ): void { const taskStateById = new Map>(); const taskDisplayIds = new Map(); @@ -595,7 +595,7 @@ export class TeamGraphAdapter { for (const task of data.tasks) { if (task.status === 'deleted') continue; const taskId = `task:${teamName}:${task.id}`; - const ownerMemberId = task.owner ? (memberNodeIdByName?.get(task.owner) ?? null) : null; + const ownerMemberId = task.owner ? (memberNodeIdByAlias?.get(task.owner) ?? null) : null; const kanbanTaskState = data.kanbanState.tasks[task.id]; const reviewerName = resolveTaskReviewer(task, kanbanTaskState); const isReviewCycle = isTaskInReviewCycle(task); @@ -758,11 +758,11 @@ export class TeamGraphAdapter { edges: GraphEdge[], data: TeamGraphData, teamName: string, - memberNodeIdByName?: ReadonlyMap + memberNodeIdByAlias?: ReadonlyMap ): void { for (const { process: proc, ownerId } of TeamGraphAdapter.#selectRelevantProcesses( data.processes, - memberNodeIdByName + memberNodeIdByAlias )) { const procId = `process:${teamName}:${proc.id}`; @@ -792,13 +792,13 @@ export class TeamGraphAdapter { static #selectRelevantProcesses( processes: readonly TeamProcess[], - memberNodeIdByName?: ReadonlyMap + memberNodeIdByAlias?: ReadonlyMap ): { process: TeamProcess; ownerId: string }[] { const selectedByOwnerId = new Map(); for (const process of processes) { const ownerId = process.registeredBy - ? (memberNodeIdByName?.get(process.registeredBy) ?? null) + ? (memberNodeIdByAlias?.get(process.registeredBy) ?? null) : null; if (!ownerId) { continue; @@ -881,7 +881,7 @@ export class TeamGraphAdapter { leadId: string, leadName: string, edges: GraphEdge[], - memberNodeIdByName: ReadonlyMap + memberNodeIdByAlias: ReadonlyMap ): void { const ordered = [...messages].reverse(); @@ -969,7 +969,7 @@ export class TeamGraphAdapter { leadId, leadName, edges, - memberNodeIdByName + memberNodeIdByAlias ); if (!edgeId) continue; @@ -979,7 +979,7 @@ export class TeamGraphAdapter { msg.from ?? '', leadId, leadName, - memberNodeIdByName + memberNodeIdByAlias ); const isFromTeammate = fromId !== leadId; @@ -1020,7 +1020,7 @@ export class TeamGraphAdapter { leadId: string, leadName: string, edges: GraphEdge[], - memberNodeIdByName: ReadonlyMap + memberNodeIdByAlias: ReadonlyMap ): void { // First call: record current comment counts without creating particles. // This prevents pre-existing comments from spawning particles when the graph opens. @@ -1061,7 +1061,7 @@ export class TeamGraphAdapter { newComment.author, leadId, leadName, - memberNodeIdByName + memberNodeIdByAlias ); const taskNodeId = `task:${teamName}:${task.id}`; const authorEdge = @@ -1196,7 +1196,7 @@ export class TeamGraphAdapter { leadId: string, leadName: string, edges: GraphEdge[], - memberNodeIdByName: ReadonlyMap + memberNodeIdByAlias: ReadonlyMap ): string | null { const { from, to } = msg; @@ -1205,9 +1205,14 @@ export class TeamGraphAdapter { from, leadId, leadName, - memberNodeIdByName + memberNodeIdByAlias + ); + const toId = TeamGraphAdapter.#resolveParticipantId( + to, + leadId, + leadName, + memberNodeIdByAlias ); - const toId = TeamGraphAdapter.#resolveParticipantId(to, leadId, leadName, memberNodeIdByName); return ( edges.find((e) => e.source === fromId && e.target === toId)?.id ?? edges.find((e) => e.source === toId && e.target === fromId)?.id ?? @@ -1220,7 +1225,7 @@ export class TeamGraphAdapter { from, leadId, leadName, - memberNodeIdByName + memberNodeIdByAlias ); return ( edges.find( @@ -1238,12 +1243,12 @@ export class TeamGraphAdapter { name: string, leadId: string, leadName: string | undefined, - memberNodeIdByName: ReadonlyMap + memberNodeIdByAlias: ReadonlyMap ): string { const normalized = name.trim().toLowerCase(); if (normalized === 'user' || normalized === 'team-lead') return leadId; if (normalized === leadName?.trim().toLowerCase()) return leadId; - return memberNodeIdByName.get(name) ?? leadId; + return memberNodeIdByAlias.get(name) ?? leadId; } /** Extract external team name from cross-team "from" field like "team-b.alice" */ diff --git a/src/features/agent-graph/renderer/hooks/useTeamGraphAdapter.ts b/src/features/agent-graph/renderer/hooks/useTeamGraphAdapter.ts index 214a61c1..7d8e23a4 100644 --- a/src/features/agent-graph/renderer/hooks/useTeamGraphAdapter.ts +++ b/src/features/agent-graph/renderer/hooks/useTeamGraphAdapter.ts @@ -8,8 +8,11 @@ import { useEffect, useMemo, useRef, useSyncExternalStore } from 'react'; import { getSnapshot, subscribe } from '@renderer/services/commentReadStorage'; import { useStore } from '@renderer/store'; import { + getDefaultTeamGraphSlotAssignmentsForMembers, getCurrentProvisioningProgressForTeam, selectResolvedMembersForTeamName, + hasAppliedDefaultTeamGraphSlotAssignments, + isTeamGraphSlotPersistenceDisabled, selectTeamDataForName, selectTeamMessages, } from '@renderer/store/slices/teamSlice'; @@ -80,6 +83,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; @@ -102,7 +119,7 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort { commentReadState, provisioningProgress, memberSpawnSnapshot, - slotAssignments + effectiveSlotAssignments ), [ teamData, @@ -117,7 +134,7 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort { commentReadState, provisioningProgress, memberSpawnSnapshot, - slotAssignments, + effectiveSlotAssignments, ] ); } diff --git a/src/features/agent-graph/renderer/ui/GraphActivityHud.tsx b/src/features/agent-graph/renderer/ui/GraphActivityHud.tsx index 61211dbf..ffbc46e2 100644 --- a/src/features/agent-graph/renderer/ui/GraphActivityHud.tsx +++ b/src/features/agent-graph/renderer/ui/GraphActivityHud.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; -import { ACTIVITY_ANCHOR_LAYOUT, ACTIVITY_LANE } from '@claude-teams/agent-graph'; +import { ACTIVITY_LANE } from '@claude-teams/agent-graph'; import { ActivityItem } from '@renderer/components/team/activity/ActivityItem'; import { buildMessageContext, @@ -26,18 +26,26 @@ import type { } from '@renderer/components/team/members/memberDetailTypes'; import type { ResolvedTeamMember } from '@shared/types/team'; +const ACTIVITY_SHELL_HEIGHT = + ACTIVITY_LANE.headerHeight + + ACTIVITY_LANE.maxVisibleItems * ACTIVITY_LANE.rowHeight + + ACTIVITY_LANE.overflowHeight; + interface GraphActivityHudProps { teamName: string; nodes: GraphNode[]; - getActivityAnchorScreenPlacement: ( - ownerNodeId: string - ) => { x: number; y: number; scale: number; visible: boolean } | null; - getActivityAnchorWorldPosition?: (ownerNodeId: string) => { x: number; y: number } | null; + getActivityWorldRect?: (ownerNodeId: string) => { + left: number; + top: number; + right: number; + bottom: number; + width: number; + height: number; + } | null; getCameraZoom?: () => number; worldToScreen?: (x: number, y: number) => { x: number; y: number }; getNodeWorldPosition?: (nodeId: string) => { x: number; y: number } | null; getViewportSize?: () => { width: number; height: number }; - getNodeScreenPosition?: (nodeId: string) => { x: number; y: number; visible: boolean } | null; focusNodeIds: ReadonlySet | null; enabled?: boolean; onOpenTaskDetail?: (taskId: string) => void; @@ -53,13 +61,11 @@ interface GraphActivityHudProps { export const GraphActivityHud = ({ teamName, nodes, - getActivityAnchorScreenPlacement, - getActivityAnchorWorldPosition = () => null, + getActivityWorldRect = () => null, getCameraZoom = () => 1, worldToScreen, getNodeWorldPosition = () => null, getViewportSize, - getNodeScreenPosition = () => null, focusNodeIds, enabled = true, onOpenTaskDetail, @@ -134,7 +140,9 @@ export const GraphActivityHud = ({ overflowCount, }; }) - .filter((lane) => lane.entries.length > 0 || lane.overflowCount > 0); + .filter( + (lane) => lane.node.kind === 'member' || lane.entries.length > 0 || lane.overflowCount > 0 + ); }, [entryMapByOwnerNodeId, ownerNodes]); useLayoutEffect(() => { @@ -166,7 +174,7 @@ export const GraphActivityHud = ({ shell: HTMLDivElement; connector: SVGSVGElement | null; connectorPath: SVGPathElement | null; - laneTopLeft: { x: number; y: number }; + laneRect: NonNullable>; nodeWorld: { x: number; y: number }; }[] = []; @@ -178,10 +186,9 @@ export const GraphActivityHud = ({ const connector = connectorRefs.current.get(lane.node.id) ?? null; const connectorPath = connectorPathRefs.current.get(lane.node.id) ?? null; - const placement = getActivityAnchorScreenPlacement(lane.node.id); - const laneTopLeft = getActivityAnchorWorldPosition(lane.node.id); + const laneRect = getActivityWorldRect(lane.node.id); const nodeWorld = getNodeWorldPosition(lane.node.id); - if (!placement || !laneTopLeft || !nodeWorld) { + if (!laneRect || !nodeWorld || !worldToScreen) { shell.style.opacity = '0'; if (connector) { connector.style.opacity = '0'; @@ -189,19 +196,18 @@ export const GraphActivityHud = ({ continue; } - const scale = Math.max(getCameraZoom(), 0.001); - const widthScreen = Math.max(1, (shell.offsetWidth || ACTIVITY_LANE.width) * scale); - const heightScreen = Math.max(1, (shell.offsetHeight || 220) * scale); + const zoom = Math.max(getCameraZoom(), 0.001); + const screenTopLeft = worldToScreen(laneRect.left, laneRect.top); + const widthScreen = Math.max(1, laneRect.width * zoom); + const heightScreen = Math.max(1, laneRect.height * zoom); const viewport = getViewportSize?.(); - const laneVisible = viewport - ? placement.x + widthScreen > -80 && - placement.x < viewport.width + 80 && - placement.y + heightScreen > -80 && - placement.y < viewport.height + 80 - : placement.visible; - - const nodeScreen = getNodeScreenPosition(lane.node.id); - if (!nodeScreen?.visible || !laneVisible) { + const laneVisible = + !viewport || + (screenTopLeft.x + widthScreen > -80 && + screenTopLeft.x < viewport.width + 80 && + screenTopLeft.y + heightScreen > -80 && + screenTopLeft.y < viewport.height + 80); + if (!laneVisible) { shell.style.opacity = '0'; if (connector) { connector.style.opacity = '0'; @@ -214,31 +220,23 @@ export const GraphActivityHud = ({ shell, connector, connectorPath, - laneTopLeft, + laneRect, nodeWorld, }); } for (const entry of measurableLanes) { - const { lane, shell, connector, connectorPath, laneTopLeft, nodeWorld } = entry; + const { lane, shell, connector, connectorPath, laneRect, nodeWorld } = entry; const baseOpacity = focusNodeIds && !focusNodeIds.has(lane.node.id) ? 0.25 : 1; - const widthWorld = shell.offsetWidth || ACTIVITY_LANE.width; - const heightWorld = shell.offsetHeight || 220; - const ownerBottomLimit = - nodeWorld.y + - (lane.node.kind === 'lead' - ? ACTIVITY_ANCHOR_LAYOUT.leadOffsetY + ACTIVITY_ANCHOR_LAYOUT.reservedHeight - : ACTIVITY_ANCHOR_LAYOUT.memberOffsetY + ACTIVITY_ANCHOR_LAYOUT.reservedHeight); - const adjustedLaneTop = Math.min(laneTopLeft.y, ownerBottomLimit - heightWorld); shell.style.opacity = String(baseOpacity); - shell.style.left = `${Math.round(laneTopLeft.x)}px`; - shell.style.top = `${Math.round(adjustedLaneTop)}px`; + shell.style.left = `${Math.round(laneRect.left)}px`; + shell.style.top = `${Math.round(laneRect.top)}px`; shell.style.transform = ''; if (connector && connectorPath) { - const endX = laneTopLeft.x + widthWorld / 2; - const endY = adjustedLaneTop + heightWorld - 6; + const endX = laneRect.left + laneRect.width / 2; + const endY = laneRect.top >= nodeWorld.y ? laneRect.top + 10 : laneRect.bottom - 10; const startX = nodeWorld.x; const startY = nodeWorld.y - 18; const minX = Math.min(startX, endX); @@ -282,11 +280,9 @@ export const GraphActivityHud = ({ }, [ enabled, focusNodeIds, - getActivityAnchorScreenPlacement, - getActivityAnchorWorldPosition, + getActivityWorldRect, getCameraZoom, getNodeWorldPosition, - getNodeScreenPosition, getViewportSize, worldToScreen, visibleLanes, @@ -350,7 +346,9 @@ export const GraphActivityHud = ({ if (!(canvas instanceof HTMLCanvasElement)) { return; } - event.preventDefault(); + if (event.cancelable) { + event.preventDefault(); + } canvas.dispatchEvent( new WheelEvent('wheel', { deltaX: event.deltaX, @@ -404,91 +402,118 @@ export const GraphActivityHud = ({ > {visibleLanes.map((lane) => (
- { - connectorRefs.current.set(lane.node.id, element); - }} - className="pointer-events-none absolute z-[9] overflow-visible opacity-0" - > - { - connectorPathRefs.current.set(lane.node.id, element); - }} - d="" - fill="none" - stroke="rgba(148, 163, 184, 0.3)" - strokeWidth="1.25" - strokeLinecap="round" - strokeDasharray="3 4" - /> - -
{ - shellRefs.current.set(lane.node.id, element); - }} - className="pointer-events-auto absolute z-10 origin-top-left opacity-0" - style={{ width: `${ACTIVITY_LANE.width}px`, maxWidth: `${ACTIVITY_LANE.width}px` }} - > -
- Activity -
-
- {lane.entries.map((entry, index) => { - const messageKey = toMessageKey(entry.message); - const renderProps = resolveMessageRenderProps(entry.message, messageContext); - const timelineItem: TimelineItem = { type: 'message', message: entry.message }; - const isUnread = !entry.message.read && !readSet.has(messageKey); + {(() => { + const laneRect = getActivityWorldRect(lane.node.id); + const laneWidth = laneRect?.width ?? ACTIVITY_LANE.width; + const laneHeight = laneRect?.height ?? ACTIVITY_SHELL_HEIGHT; - return ( -
handleMessageClick(timelineItem)} - onKeyDown={(event) => { - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault(); - handleMessageClick(timelineItem); - } - }} - > - -
- ); - })} - - {lane.overflowCount > 0 ? ( - - ) : null} -
-
+ { + connectorPathRefs.current.set(lane.node.id, element); + }} + d="" + fill="none" + stroke="rgba(148, 163, 184, 0.3)" + strokeWidth="1.25" + strokeLinecap="round" + strokeDasharray="3 4" + /> + +
{ + shellRefs.current.set(lane.node.id, element); + }} + className="pointer-events-auto absolute z-10 origin-top-left opacity-0" + style={{ + width: `${laneWidth}px`, + maxWidth: `${laneWidth}px`, + height: `${laneHeight}px`, + }} + > +
+
+ Activity +
+
+ {lane.entries.length === 0 && lane.overflowCount === 0 ? ( +
+ No recent activity +
+ ) : null} + {lane.entries.map((entry, index) => { + const messageKey = toMessageKey(entry.message); + const renderProps = resolveMessageRenderProps( + entry.message, + messageContext + ); + const timelineItem: TimelineItem = { + type: 'message', + message: entry.message, + }; + const isUnread = !entry.message.read && !readSet.has(messageKey); + + return ( +
handleMessageClick(timelineItem)} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + handleMessageClick(timelineItem); + } + }} + > + +
+ ); + })} + + {lane.overflowCount > 0 ? ( + + ) : null} +
+
+
+ + ); + })()}
))}
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 fbf6287e..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,15 +55,14 @@ 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); 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( @@ -90,6 +91,13 @@ export const TeamGraphOverlay = ({ openCreateTaskDialog(''); }, [openCreateTaskDialog]); + useLayoutEffect(() => { + if (!isTeamGraphSlotPersistenceDisabled()) { + return; + } + resetTeamGraphSlotAssignmentsToDefaults(teamName); + }, [resetTeamGraphSlotAssignmentsToDefaults, teamName]); + const events: GraphEventPort = { onNodeDoubleClick: useCallback( (ref: GraphDomainRef) => { @@ -120,53 +128,47 @@ export const TeamGraphOverlay = ({ } onOwnerSlotDrop={commitOwnerSlotDrop} className="team-graph-view min-w-0 flex-1" renderHud={(hudProps) => { const extraHudProps = hudProps as typeof hudProps & { getViewportSize?: () => { width: number; height: number }; - getActivityAnchorWorldPosition?: ( - ownerNodeId: string - ) => { x: number; y: number } | null; + getActivityWorldRect?: (ownerNodeId: string) => { + left: number; + top: number; + right: number; + bottom: number; + width: number; + height: number; + } | null; getCameraZoom?: () => number; worldToScreen?: (x: number, y: number) => { x: number; y: number }; getNodeWorldPosition?: (nodeId: string) => { x: number; y: number } | null; }; - const { - getLaunchAnchorScreenPlacement, - getActivityAnchorScreenPlacement, - getViewportSize, - getNodeScreenPosition, - focusNodeIds, - } = extraHudProps; + const { getViewportSize, focusNodeIds } = extraHudProps; return ( <> - ); }} diff --git a/src/features/agent-graph/renderer/ui/TeamGraphTab.tsx b/src/features/agent-graph/renderer/ui/TeamGraphTab.tsx index 18be6f19..a5e8ec10 100644 --- a/src/features/agent-graph/renderer/ui/TeamGraphTab.tsx +++ b/src/features/agent-graph/renderer/ui/TeamGraphTab.tsx @@ -3,10 +3,12 @@ * Provides Fullscreen button that opens the overlay. */ -import { lazy, Suspense, useCallback, useMemo, useState } from 'react'; +import { lazy, Suspense, useCallback, useLayoutEffect, useMemo, useState } 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'; @@ -46,9 +48,8 @@ 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 resetTeamGraphSlotAssignmentsToDefaults = useStore( + (s) => s.resetTeamGraphSlotAssignmentsToDefaults ); const [fullscreen, setFullscreen] = useState(false); const { sidebarVisible, toggleSidebarVisible } = useGraphSidebarVisibility(); @@ -80,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) => @@ -144,53 +152,48 @@ export const TeamGraphTab = ({ events={events} className="team-graph-view size-full" suspendAnimation={!isActive} + isSurfaceActive={isActive} onRequestFullscreen={() => setFullscreen(true)} onOpenTeamPage={openTeamPage} onCreateTask={openCreateTask} onToggleSidebar={toggleSidebarVisible} isSidebarVisible={sidebarVisible} + renderTopToolbarContent={() => ( + + )} onOwnerSlotDrop={commitOwnerSlotDrop} renderHud={(hudProps) => { const extraHudProps = hudProps as typeof hudProps & { getViewportSize?: () => { width: number; height: number }; - getActivityAnchorWorldPosition?: ( - ownerNodeId: string - ) => { x: number; y: number } | null; + getActivityWorldRect?: (ownerNodeId: string) => { + left: number; + top: number; + right: number; + bottom: number; + width: number; + height: number; + } | null; getCameraZoom?: () => number; worldToScreen?: (x: number, y: number) => { x: number; y: number }; getNodeWorldPosition?: (nodeId: string) => { x: number; y: number } | null; }; - const { - getLaunchAnchorScreenPlacement, - getActivityAnchorScreenPlacement, - getViewportSize, - getNodeScreenPosition, - focusNodeIds, - } = extraHudProps; + const { getViewportSize, focusNodeIds } = extraHudProps; return ( <> - ); }} diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 0e8a4475..32df2f5a 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -12,6 +12,7 @@ import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc'; import { mergeTeamMessages } from '@renderer/utils/mergeTeamMessages'; 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'; @@ -61,6 +62,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; @@ -98,11 +100,33 @@ const teamRefreshBurstDiagnostics = new Map< { windowStartedAt: number; count: number; lastWarnAt: number } >(); const memberSpawnUiEqualLastWarnAtByTeam = new Map(); +const sessionDefaultGraphSlotAssignmentsAppliedByTeam = new Set(); interface RefreshTeamDataOptions { withDedup?: boolean; } type TeamGraphSlotAssignments = Record; +type TeamGraphMemberSeedInput = Pick; + +const SMALL_TEAM_CARDINAL_SLOT_PRESETS: ReadonlyArray> = [ + [], + [{ ringIndex: 0, sectorIndex: 0 }], + [ + { ringIndex: 0, sectorIndex: 0 }, + { ringIndex: 0, sectorIndex: 1 }, + ], + [ + { ringIndex: 0, sectorIndex: 0 }, + { ringIndex: 0, sectorIndex: 1 }, + { ringIndex: 0, sectorIndex: 2 }, + ], + [ + { ringIndex: 0, sectorIndex: 0 }, + { ringIndex: 0, sectorIndex: 1 }, + { ringIndex: 0, sectorIndex: 2 }, + { ringIndex: 0, sectorIndex: 3 }, + ], +]; export function isTeamDataRefreshPending(teamName: string): boolean { return ( @@ -147,6 +171,7 @@ export function __resetTeamSliceModuleStateForTests(): void { resolvedMemberSelectorCache.clear(); mergedMessagesSelectorCache.clear(); memberMessagesSelectorCache.clear(); + sessionDefaultGraphSlotAssignmentsAppliedByTeam.clear(); } function nowIso(): string { @@ -1307,7 +1332,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; @@ -1485,6 +1510,74 @@ function isMemberActivityMetaStale( return meta.feedRevision !== feedRevision; } +function seedStableSlotAssignmentsForMembers( + assignments: TeamGraphSlotAssignments, + members: readonly TeamGraphMemberSeedInput[] +): { assignments: TeamGraphSlotAssignments; changed: boolean } { + const visibleMembers = members.filter((member) => !member.removedAt && !isLeadMember(member)); + 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 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 @@ -1594,7 +1687,7 @@ export interface TeamSlice { clearKanbanFilter: () => void; ensureTeamGraphSlotAssignments: ( teamName: string, - members: readonly Pick[] + members: readonly TeamGraphMemberSeedInput[] ) => void; setTeamGraphOwnerSlotAssignment: ( teamName: string, @@ -2301,15 +2394,41 @@ 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); - if (migrated.changed) { + const seeded = seedStableSlotAssignmentsForMembers(migrated.assignments, members); + if (migrated.changed || seeded.changed) { nextSlotAssignmentsByTeam = { ...nextSlotAssignmentsByTeam, - [teamName]: migrated.assignments, + [teamName]: seeded.assignments, }; changed = true; } @@ -2450,6 +2569,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 @@ -2468,6 +2588,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, @@ -2477,13 +2598,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/GraphActivityHud.test.ts b/test/renderer/features/agent-graph/GraphActivityHud.test.ts index a51b7d3e..dd04d705 100644 --- a/test/renderer/features/agent-graph/GraphActivityHud.test.ts +++ b/test/renderer/features/agent-graph/GraphActivityHud.test.ts @@ -2,7 +2,6 @@ import React, { act } from 'react'; import { createRoot } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { ACTIVITY_ANCHOR_LAYOUT } from '@claude-teams/agent-graph'; import { GraphActivityHud } from '@features/agent-graph/renderer/ui/GraphActivityHud'; import type { GraphNode } from '@claude-teams/agent-graph'; @@ -231,7 +230,18 @@ describe('GraphActivityHud', () => { React.createElement(GraphActivityHud, { teamName: 'demo-team', nodes: [node], - getActivityAnchorScreenPlacement: () => ({ x: 40, y: 80, scale: 1, visible: true }), + getActivityWorldRect: () => ({ + left: 40, + top: 80, + right: 336, + bottom: 372, + width: 296, + height: 292, + }), + getCameraZoom: () => 1, + worldToScreen: (x: number, y: number) => ({ x, y }), + getNodeWorldPosition: () => ({ x: 120, y: 40 }), + getViewportSize: () => ({ width: 1200, height: 800 }), focusNodeIds: null, onOpenMemberProfile, }) @@ -260,7 +270,7 @@ describe('GraphActivityHud', () => { }); }); - it('keeps the activity lane above the owner label area when packed anchor drifts too low', async () => { + it('pins the activity lane to the provided world rect without post-hoc repositioning', async () => { const message: InboxMessage = { from: 'team-lead', to: 'jack', @@ -311,17 +321,23 @@ describe('GraphActivityHud', () => { document.body.appendChild(host); const root = createRoot(host); const nodeWorld = { x: 320, y: 300 }; - const packedAnchor = { x: 120, y: 260 }; + const laneRect = { + left: 120, + top: 340, + right: 416, + bottom: 632, + width: 296, + height: 292, + }; await act(async () => { root.render( React.createElement(GraphActivityHud, { teamName: 'demo-team', nodes: [node], - getActivityAnchorScreenPlacement: () => ({ x: 40, y: 80, scale: 1, visible: true }), - getActivityAnchorWorldPosition: () => packedAnchor, + getActivityWorldRect: () => laneRect, + getCameraZoom: () => 1, getNodeWorldPosition: () => nodeWorld, - getNodeScreenPosition: () => ({ x: 400, y: 300, visible: true }), getViewportSize: () => ({ width: 1200, height: 800 }), worldToScreen: (x: number, y: number) => ({ x, y }), focusNodeIds: null, @@ -332,12 +348,8 @@ describe('GraphActivityHud', () => { const shell = host.querySelector('.z-10'); expect(shell).not.toBeNull(); - const expectedTop = - nodeWorld.y + - ACTIVITY_ANCHOR_LAYOUT.memberOffsetY + - ACTIVITY_ANCHOR_LAYOUT.reservedHeight - - 220; - expect((shell as HTMLDivElement).style.top).toBe(`${Math.round(expectedTop)}px`); + expect((shell as HTMLDivElement).style.left).toBe(`${laneRect.left}px`); + expect((shell as HTMLDivElement).style.top).toBe(`${laneRect.top}px`); await act(async () => { root.unmount(); 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/TeamGraphAdapter.test.ts b/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts index 70b33eeb..fe7b4554 100644 --- a/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts +++ b/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts @@ -740,6 +740,80 @@ describe('TeamGraphAdapter particles', () => { ]); }); + it('resolves task and process owners by stable owner id aliases, not only member names', () => { + const adapter = TeamGraphAdapter.create(); + + const graph = adapter.adapt( + createBaseTeamData({ + config: { + name: 'My Team', + members: [ + { name: 'team-lead', agentId: 'lead-agent' }, + { name: 'alice', agentId: 'agent-alice' }, + ], + projectPath: '/repo', + }, + members: [ + { + name: 'team-lead', + status: 'active', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + agentType: 'team-lead', + agentId: 'lead-agent', + }, + { + name: 'alice', + status: 'active', + currentTaskId: null, + taskCount: 1, + lastActiveAt: null, + messageCount: 0, + agentId: 'agent-alice', + }, + ], + tasks: [ + { + id: 'task-owned-by-stable-id', + displayId: '#42', + subject: 'Stable owner task', + owner: 'agent-alice', + status: 'completed', + comments: [], + reviewState: 'none', + } as TeamTaskWithKanban, + ], + processes: [ + { + id: 'proc-owned-by-stable-id', + label: 'Stable owner process', + pid: 4242, + registeredBy: 'agent-alice', + registeredAt: '2026-03-28T19:00:02.000Z', + }, + ], + }), + 'my-team' + ); + + expect(findNode(graph, 'task:my-team:task-owned-by-stable-id')).toMatchObject({ + ownerId: 'member:my-team:agent-alice', + taskStatus: 'completed', + }); + expect(findNode(graph, 'process:my-team:proc-owned-by-stable-id')).toMatchObject({ + ownerId: 'member:my-team:agent-alice', + }); + expect( + graph.edges.some( + (edge) => + edge.id === + 'edge:own:member:my-team:agent-alice:task:my-team:task-owned-by-stable-id' + ) + ).toBe(true); + }); + it('skips noisy idle inbox rows in the activity feed while keeping cross-team traffic on the lead lane', () => { const adapter = TeamGraphAdapter.create(); diff --git a/test/renderer/features/agent-graph/buildInlineActivityEntries.test.ts b/test/renderer/features/agent-graph/buildInlineActivityEntries.test.ts index 8ae5e030..c1dcaa4c 100644 --- a/test/renderer/features/agent-graph/buildInlineActivityEntries.test.ts +++ b/test/renderer/features/agent-graph/buildInlineActivityEntries.test.ts @@ -191,4 +191,72 @@ describe('buildInlineActivityEntries', () => { taskRefs: [{ taskId: 'task-1', displayId: '#8fdd6803', teamName: 'my-team' }], }); }); + + it('routes comment activity to a member lane when task.owner is stored as stable owner id', () => { + const data = createBaseTeamData({ + config: { + name: 'My Team', + members: [{ name: 'team-lead', agentId: 'lead-agent' }, { name: 'jack', agentId: 'agent-jack' }], + projectPath: '/repo', + }, + tasks: [ + { + id: 'task-stable-owner', + displayId: '#91', + subject: 'Stable owner routing', + owner: 'agent-jack', + status: 'in_progress', + comments: [ + { + id: 'comment-stable-owner', + author: 'team-lead', + text: 'Проверь финальную сводку перед merge', + createdAt: '2026-03-28T19:00:03.000Z', + type: 'regular', + }, + ], + reviewState: 'none', + } as unknown as TeamTaskWithKanban, + ], + members: [ + { + name: 'team-lead', + status: 'active', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + agentType: 'team-lead', + agentId: 'lead-agent', + }, + { + name: 'jack', + status: 'active', + currentTaskId: null, + taskCount: 1, + lastActiveAt: null, + messageCount: 0, + agentId: 'agent-jack', + }, + ], + }); + + const entries = buildInlineActivityEntries({ + data, + teamName: 'my-team', + leadId: 'lead:my-team', + leadName: getGraphLeadMemberName(data, 'my-team'), + ownerNodeIds: new Set(['lead:my-team', 'member:my-team:agent-jack']), + }); + + expect(entries.get('member:my-team:agent-jack')).toEqual([ + expect.objectContaining({ + graphItem: expect.objectContaining({ + id: 'activity:comment:my-team:task-stable-owner:comment-stable-owner', + title: '#91 Stable owner routing', + taskId: 'task-stable-owner', + }), + }), + ]); + }); }); diff --git a/test/renderer/features/agent-graph/useGraphSimulation.test.ts b/test/renderer/features/agent-graph/useGraphSimulation.test.ts index 7abed6f4..a485d673 100644 --- a/test/renderer/features/agent-graph/useGraphSimulation.test.ts +++ b/test/renderer/features/agent-graph/useGraphSimulation.test.ts @@ -3,12 +3,19 @@ import { describe, expect, it, vi } from 'vitest'; import { buildStableSlotLayoutSnapshot, computeOwnerFootprints, + computeProcessBandWidth, resolveNearestSlotAssignment, snapshotToWorldBounds, + translateSlotFrame, validateStableSlotLayout, } from '../../../../packages/agent-graph/src/layout/stableSlots'; -import { STABLE_SLOT_GEOMETRY } from '../../../../packages/agent-graph/src/layout/stableSlotGeometry'; -import { ACTIVITY_ANCHOR_LAYOUT } from '../../../../packages/agent-graph/src/layout/activityLane'; +import { KanbanLayoutEngine } from '../../../../packages/agent-graph/src/layout/kanbanLayout'; +import { TASK_PILL } from '../../../../packages/agent-graph/src/constants/canvas-constants'; +import { ACTIVITY_LANE } from '../../../../packages/agent-graph/src/layout/activityLane'; +import { + STABLE_SLOT_GEOMETRY, + STABLE_SLOT_SECTOR_VECTORS, +} from '../../../../packages/agent-graph/src/layout/stableSlotGeometry'; import type { GraphLayoutPort, GraphNode } from '@claude-teams/agent-graph'; @@ -51,6 +58,29 @@ function createTask( }; } +function createProcess(teamName: string, processId: string, ownerId: string): GraphNode { + return { + id: `process:${teamName}:${processId}`, + kind: 'process', + label: processId, + state: 'active', + ownerId, + domainRef: { kind: 'process', teamName, processId }, + }; +} + +function rectsOverlap( + left: { left: number; right: number; top: number; bottom: number }, + right: { left: number; right: number; top: number; bottom: number } +): boolean { + return ( + left.left < right.right && + left.right > right.left && + left.top < right.bottom && + left.bottom > right.top + ); +} + describe('stable slot layout planner', () => { it('does not build a stable slot snapshot when the lead is missing', () => { const snapshot = buildStableSlotLayoutSnapshot({ @@ -68,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'); @@ -88,15 +118,17 @@ 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 }); }); - it('keeps a fixed process rail width centered inside the owner slot', () => { + it('builds a board band that contains both the activity column and kanban band', () => { const teamName = 'team-process-width'; const lead = createLead(teamName); const alice = createMember(teamName, 'agent-alice', 'alice'); @@ -116,11 +148,194 @@ describe('stable slot layout planner', () => { const frame = snapshot?.memberSlotFrames[0]; expect(frame).toBeDefined(); - expect(frame?.processBandRect.width).toBe(STABLE_SLOT_GEOMETRY.processRailWidth); - expect(frame?.processBandRect.left).toBeCloseTo( - (frame?.bounds.left ?? 0) + ((frame?.bounds.width ?? 0) - STABLE_SLOT_GEOMETRY.processRailWidth) / 2, - 6 + expect(frame?.boardBandRect.top).toBe(frame?.activityColumnRect.top); + expect(frame?.boardBandRect.top).toBe(frame?.kanbanBandRect.top); + expect(frame?.activityColumnRect.left).toBe(frame?.boardBandRect.left); + expect(frame?.kanbanBandRect.left).toBeGreaterThan(frame?.activityColumnRect.right ?? 0); + 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); + const alice = createMember(teamName, 'agent-alice', 'alice'); + const layout: GraphLayoutPort = { + version: 'stable-slots-v1', + ownerOrder: [alice.id], + slotAssignments: { + [alice.id]: { ringIndex: 0, sectorIndex: 1 }, + }, + }; + + const [footprint] = computeOwnerFootprints([lead, alice], layout); + + expect(footprint).toBeDefined(); + expect(footprint?.activityColumnWidth).toBe(ACTIVITY_LANE.width); + expect(footprint?.activityColumnHeight).toBe( + ACTIVITY_LANE.headerHeight + + ACTIVITY_LANE.maxVisibleItems * ACTIVITY_LANE.rowHeight + + ACTIVITY_LANE.overflowHeight ); + expect(footprint?.kanbanBandWidth).toBe(TASK_PILL.width); + expect(footprint?.boardBandHeight).toBe( + Math.max(footprint?.activityColumnHeight ?? 0, footprint?.kanbanBandHeight ?? 0) + ); + }); + + it('keeps diagonal ring-zero sectors closer than the legacy coarse central box radius', () => { + const teamName = 'team-directional-radius'; + const lead = createLead(teamName); + const alice = createMember(teamName, 'agent-alice', 'alice'); + const layout: GraphLayoutPort = { + version: 'stable-slots-v1', + ownerOrder: [alice.id], + slotAssignments: { + [alice.id]: { ringIndex: 0, sectorIndex: 1 }, + }, + }; + + const snapshot = buildStableSlotLayoutSnapshot({ + teamName, + nodes: [lead, alice], + layout, + }); + const [footprint] = computeOwnerFootprints([lead, alice], layout); + const frame = snapshot?.memberSlotFrames[0]; + const sectorVector = STABLE_SLOT_SECTOR_VECTORS[1]; + + expect(snapshot).not.toBeNull(); + expect(frame).toBeDefined(); + expect(footprint).toBeDefined(); + + const legacyHorizontalExtent = snapshot!.runtimeCentralExclusion.right; + const legacyVerticalExtent = Math.abs(snapshot!.runtimeCentralExclusion.top); + const legacyRequiredX = + (legacyHorizontalExtent + footprint!.slotWidth / 2 + STABLE_SLOT_GEOMETRY.ringPadding) / + Math.abs(sectorVector.x); + const legacyRequiredY = + (legacyVerticalExtent + footprint!.slotHeight / 2 + STABLE_SLOT_GEOMETRY.ringPadding) / + Math.abs(sectorVector.y); + const legacyMinRadius = Math.max(legacyRequiredX, legacyRequiredY, 0); + const actualRadius = Math.abs(frame!.ownerX / sectorVector.x); + + expect(actualRadius).toBeLessThan(legacyMinRadius); + expect( + snapshot!.centralCollisionRects.some((rect) => rectsOverlap(frame!.bounds, rect)) + ).toBe(false); + }); + + it('grows process band width when an owner has multiple visible process nodes', () => { + const teamName = 'team-process-growth'; + const lead = createLead(teamName); + const alice = createMember(teamName, 'agent-alice', 'alice'); + const processes = Array.from({ length: 7 }, (_, index) => + createProcess(teamName, `proc-${index + 1}`, alice.id) + ); + const layout: GraphLayoutPort = { + version: 'stable-slots-v1', + ownerOrder: [alice.id], + slotAssignments: { + [alice.id]: { ringIndex: 0, sectorIndex: 1 }, + }, + }; + + const [footprint] = computeOwnerFootprints([lead, alice, ...processes], layout); + + expect(footprint).toBeDefined(); + expect(footprint?.processCount).toBe(7); + expect(footprint?.processBandWidth).toBe(computeProcessBandWidth(7)); + expect((footprint?.processBandWidth ?? 0) > STABLE_SLOT_GEOMETRY.processRailWidth).toBe(true); }); it('includes full topology bounds for fit, not only activity overlays', () => { @@ -187,6 +402,40 @@ describe('stable slot layout planner', () => { expect(validateStableSlotLayout(invalid).valid).toBe(false); }); + 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'); + const layout: GraphLayoutPort = { + version: 'stable-slots-v1', + ownerOrder: [alice.id], + slotAssignments: { + [alice.id]: { ringIndex: 0, sectorIndex: 1 }, + }, + }; + + const snapshot = buildStableSlotLayoutSnapshot({ + teamName, + nodes: [lead, alice], + layout, + }); + + expect(snapshot).not.toBeNull(); + const [frame] = snapshot!.memberSlotFrames; + const overlappingLeadBlock = translateSlotFrame( + frame, + snapshot!.leadCentralReservedBlock.left - frame.bounds.left + 1, + snapshot!.leadCentralReservedBlock.top - frame.bounds.top + 1 + ); + + expect( + validateStableSlotLayout({ + ...snapshot!, + memberSlotFrames: [overlappingLeadBlock], + }).valid + ).toBe(false); + }); + it('prefers the occupied target slot when dragging near another owner anchor', () => { const teamName = 'team-b'; const lead = createLead(teamName); @@ -226,6 +475,106 @@ 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); + const alice = createMember(teamName, 'agent-alice', 'alice'); + const bob = createMember(teamName, 'agent-bob', 'bob'); + const layout: GraphLayoutPort = { + version: 'stable-slots-v1', + ownerOrder: [alice.id, bob.id], + slotAssignments: { + [alice.id]: { ringIndex: 0, sectorIndex: 1 }, + [bob.id]: { ringIndex: 0, sectorIndex: 2 }, + }, + }; + + const snapshot = buildStableSlotLayoutSnapshot({ + teamName, + nodes: [lead, alice, bob], + layout, + }); + + expect(snapshot).not.toBeNull(); + const nearest = resolveNearestSlotAssignment({ + ownerId: alice.id, + ownerX: snapshot!.leadActivityRect.left + snapshot!.leadActivityRect.width / 2, + ownerY: snapshot!.leadActivityRect.top + snapshot!.leadActivityRect.height / 2, + nodes: [lead, alice, bob], + snapshot: snapshot!, + layout, + }); + + expect(nearest).not.toBeNull(); + const replannedSnapshot = buildStableSlotLayoutSnapshot({ + teamName, + nodes: [lead, alice, bob], + layout: { + ...layout, + slotAssignments: { + ...layout.slotAssignments, + [alice.id]: nearest!.assignment, + ...(nearest?.displacedOwnerId && nearest.displacedAssignment + ? { [nearest.displacedOwnerId]: nearest.displacedAssignment } + : {}), + }, + }, + }); + const replannedFrame = replannedSnapshot?.memberSlotFrames.find( + (frame) => frame.ownerId === alice.id + ); + + expect(replannedSnapshot).not.toBeNull(); + expect(replannedFrame).toBeDefined(); + expect( + replannedSnapshot!.centralCollisionRects.some((rect) => + rectsOverlap(replannedFrame!.bounds, rect) + ) + ).toBe(false); + }); + it('treats tasks with missing owner nodes as unassigned topology actors', () => { const teamName = 'team-orphan-task'; const lead = createLead(teamName); @@ -249,43 +598,76 @@ describe('stable slot layout planner', () => { expect(snapshot?.unassignedTaskRect).not.toBeNull(); }); - it('computes the next ring radius from previous ring depth, not member count', () => { - const teamName = 'team-ring-depth'; + it('rejects member frames that overlap the unassigned central collision rect', () => { + const teamName = 'team-unassigned-central-rect'; const lead = createLead(teamName); - const members = Array.from({ length: 7 }, (_, index) => - createMember(teamName, `agent-${index + 1}`, `member-${index + 1}`) + const alice = createMember(teamName, 'agent-alice', 'alice'); + const orphanTask = createTask( + teamName, + 'task-orphan', + 'member:team-unassigned-central-rect:ghost' ); const layout: GraphLayoutPort = { version: 'stable-slots-v1', - ownerOrder: members.map((member) => member.id), - slotAssignments: Object.fromEntries( - members.map((member, index) => [ - member.id, - { - ringIndex: index < 6 ? 0 : 1, - sectorIndex: index % 6, - }, - ]) - ), + ownerOrder: [alice.id], + slotAssignments: { + [alice.id]: { ringIndex: 0, sectorIndex: 1 }, + }, }; const snapshot = buildStableSlotLayoutSnapshot({ teamName, - nodes: [lead, ...members], + nodes: [lead, alice, orphanTask], layout, }); - const footprints = computeOwnerFootprints([lead, ...members], layout); + + expect(snapshot?.unassignedTaskRect).not.toBeNull(); + const [frame] = snapshot!.memberSlotFrames; + const overlappingUnassigned = translateSlotFrame( + frame, + snapshot!.unassignedTaskRect!.left - frame.bounds.left + 1, + snapshot!.unassignedTaskRect!.top - frame.bounds.top + 1 + ); + + expect( + validateStableSlotLayout({ + ...snapshot!, + memberSlotFrames: [overlappingUnassigned], + }).valid + ).toBe(false); + }); + + it('computes the next ring radius from previous ring depth, not member count', () => { + const teamName = 'team-ring-depth'; + const lead = createLead(teamName); + const first = createMember(teamName, 'agent-first', 'member-1'); + const second = createMember(teamName, 'agent-second', 'member-2'); + const layout: GraphLayoutPort = { + version: 'stable-slots-v1', + ownerOrder: [first.id, second.id], + slotAssignments: { + [first.id]: { ringIndex: 0, sectorIndex: 1 }, + [second.id]: { ringIndex: 1, sectorIndex: 1 }, + }, + }; + + const snapshot = buildStableSlotLayoutSnapshot({ + teamName, + nodes: [lead, first, second], + layout, + }); + const footprints = computeOwnerFootprints([lead, first, second], layout); const firstRingFrame = snapshot?.memberSlotFrames.find( - (frame) => frame.ringIndex === 0 && frame.sectorIndex === 0 + (frame) => frame.ownerId === first.id ); const secondRingFrame = snapshot?.memberSlotFrames.find( - (frame) => frame.ringIndex === 1 && frame.sectorIndex === 0 + (frame) => frame.ownerId === second.id ); expect(snapshot).not.toBeNull(); expect(firstRingFrame).toBeDefined(); expect(secondRingFrame).toBeDefined(); - const firstFootprint = footprints[0]; + const firstFootprint = footprints.find((footprint) => footprint.ownerId === first.id); expect(firstFootprint).toBeDefined(); if (!firstFootprint) { throw new Error('expected first footprint for ring-depth test'); @@ -293,17 +675,126 @@ describe('stable slot layout planner', () => { const ringDelta = Math.hypot(secondRingFrame!.ownerX, secondRingFrame!.ownerY) - Math.hypot(firstRingFrame!.ownerX, firstRingFrame!.ownerY); - const ownerAnchorOffsetY = - STABLE_SLOT_GEOMETRY.memberSlotInnerPadding + - ACTIVITY_ANCHOR_LAYOUT.reservedHeight + - STABLE_SLOT_GEOMETRY.slotVerticalGap + - STABLE_SLOT_GEOMETRY.ownerBandHeight / 2; - const expectedRingDelta = - ownerAnchorOffsetY + - (firstFootprint.slotHeight - ownerAnchorOffsetY) + - STABLE_SLOT_GEOMETRY.ringGap; + const sectorVector = { x: 0.82, y: -0.57 }; + const ownerLocalY = + STABLE_SLOT_GEOMETRY.memberSlotInnerPadding + STABLE_SLOT_GEOMETRY.ownerBandHeight / 2; + const topOffset = -ownerLocalY; + const bottomOffset = firstFootprint.slotHeight - ownerLocalY; + const halfWidth = firstFootprint.slotWidth / 2; + const vectorLength = Math.hypot(sectorVector.x, sectorVector.y) || 1; + const unitX = sectorVector.x / vectorLength; + const unitY = sectorVector.y / vectorLength; + const cornerProjections = [ + { x: -halfWidth, y: topOffset }, + { x: halfWidth, y: topOffset }, + { x: halfWidth, y: bottomOffset }, + { x: -halfWidth, y: bottomOffset }, + ].map((corner) => corner.x * unitX + corner.y * unitY); + const outwardDepth = Math.max(...cornerProjections); + const inwardDepth = Math.max(...cornerProjections.map((projection) => -projection)); + const expectedRingDelta = outwardDepth + inwardDepth + STABLE_SLOT_GEOMETRY.ringGap; - expect(ringDelta).toBeCloseTo(expectedRingDelta, 6); + expect(Math.abs(ringDelta - expectedRingDelta)).toBeLessThan(2); + }); + + it('keeps owned tasks out of unassigned topology when default sector candidates near the lead are invalid', () => { + const teamName = 'team-owned-tasks'; + const lead = createLead(teamName); + const members = [ + createMember(teamName, 'agent-alice', 'alice'), + createMember(teamName, 'agent-bob', 'bob'), + createMember(teamName, 'agent-tom', 'tom'), + createMember(teamName, 'agent-jack', 'jack'), + ]; + const tasks = [ + createTask(teamName, 'task-a', members[0].id, { taskStatus: 'completed' }), + createTask(teamName, 'task-b', members[1].id, { taskStatus: 'completed' }), + createTask(teamName, 'task-c', members[2].id, { taskStatus: 'completed' }), + createTask(teamName, 'task-d', members[3].id, { taskStatus: 'completed' }), + ]; + const layout: GraphLayoutPort = { + version: 'stable-slots-v1', + ownerOrder: members.map((member) => member.id), + slotAssignments: {}, + }; + + const nodes = [lead, ...members, ...tasks]; + const snapshot = buildStableSlotLayoutSnapshot({ + teamName, + nodes, + layout, + }); + + expect(snapshot).not.toBeNull(); + expect(validateStableSlotLayout(snapshot!)).toEqual({ valid: true }); + expect(snapshot?.unassignedTaskRect).toBeNull(); + + const memberSlotFrames = snapshot!.memberSlotFrames; + for (const frame of memberSlotFrames) { + const ownerNode = nodes.find((node) => node.id === frame.ownerId); + if (!ownerNode) { + continue; + } + ownerNode.x = frame.ownerX; + ownerNode.y = frame.ownerY; + } + KanbanLayoutEngine.layout(nodes, { + memberSlotFrames, + unassignedTaskRect: snapshot!.unassignedTaskRect, + }); + + for (const task of tasks) { + const ownerFrame = memberSlotFrames.find((frame) => frame.ownerId === task.ownerId); + expect(ownerFrame).toBeDefined(); + expect(task.x).toBeGreaterThanOrEqual(ownerFrame!.kanbanBandRect.left); + expect(task.x).toBeLessThanOrEqual(ownerFrame!.kanbanBandRect.right); + expect(task.y).toBeGreaterThanOrEqual(ownerFrame!.kanbanBandRect.top); + expect(task.y).toBeLessThanOrEqual(ownerFrame!.kanbanBandRect.bottom); + } + }); + + 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', () => { diff --git a/test/renderer/store/teamSlice.test.ts b/test/renderer/store/teamSlice.test.ts index 5fe48ef9..bfd4b365 100644 --- a/test/renderer/store/teamSlice.test.ts +++ b/test/renderer/store/teamSlice.test.ts @@ -264,7 +264,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( @@ -281,7 +281,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', @@ -298,7 +298,68 @@ 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 }, + }); + }); + + 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: 0 }, + 'agent-bob': { ringIndex: 0, sectorIndex: 1 }, + 'agent-tom': { ringIndex: 0, sectorIndex: 2 }, + 'agent-jack': { ringIndex: 0, sectorIndex: 3 }, + }); + }); + + 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', + 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-alice': { ringIndex: 0, sectorIndex: 0 }, + 'agent-bob': { ringIndex: 0, sectorIndex: 1 }, }); }); @@ -321,10 +382,14 @@ 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', () => { + it('ignores hidden-member persisted slot assignments while persistence is disabled', () => { const store = createSliceStore(); store.setState({ slotLayoutVersion: 'stable-slots-v1', @@ -341,8 +406,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 }, }); });