From 77d3e9f7d8089f52a1a2d94146a70166df251dae Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 15 Apr 2026 22:40:15 +0300 Subject: [PATCH] fix(agent-graph): stabilize member slot layout --- .../src/constants/canvas-constants.ts | 2 + .../src/hooks/useGraphSimulation.ts | 40 ++- .../agent-graph/src/layout/kanbanLayout.ts | 39 +-- .../agent-graph/src/layout/stableSlots.ts | 196 +++++++----- packages/agent-graph/src/ui/GraphView.tsx | 62 +--- .../core/domain/buildInlineActivityEntries.ts | 33 +-- .../core/domain/graphOwnerIdentity.ts | 25 ++ .../renderer/adapters/TeamGraphAdapter.ts | 65 ++-- .../renderer/ui/GraphActivityHud.tsx | 279 ++++++++++-------- .../renderer/ui/TeamGraphOverlay.tsx | 23 +- .../agent-graph/renderer/ui/TeamGraphTab.tsx | 23 +- .../agent-graph/GraphActivityHud.test.ts | 38 ++- .../agent-graph/TeamGraphAdapter.test.ts | 74 +++++ .../buildInlineActivityEntries.test.ts | 68 +++++ .../agent-graph/useGraphSimulation.test.ts | 191 +++++++++--- 15 files changed, 724 insertions(+), 434 deletions(-) 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..e1eed487 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'; @@ -47,7 +48,7 @@ export interface UseGraphSimulationResult { displacedAssignment?: GraphOwnerSlotAssignment; } | 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 +66,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 +92,7 @@ export function useGraphSimulation(): UseGraphSimulationResult { lastValidSnapshotByTeamRef, dragOwnerPositionsRef, launchAnchorPositionsRef, - activityAnchorPositionsRef, + activityRectByNodeIdRef, extraWorldBoundsRef, }); return; @@ -111,7 +112,7 @@ export function useGraphSimulation(): UseGraphSimulationResult { lastValidSnapshotByTeamRef, dragOwnerPositionsRef, launchAnchorPositionsRef, - activityAnchorPositionsRef, + activityRectByNodeIdRef, extraWorldBoundsRef, fillMissingFallbackPositions: true, }); @@ -123,7 +124,7 @@ export function useGraphSimulation(): UseGraphSimulationResult { nodes: state.nodes, layoutSnapshotRef, launchAnchorPositionsRef, - activityAnchorPositionsRef, + activityRectByNodeIdRef, extraWorldBoundsRef, }); }, []); @@ -220,7 +221,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(); @@ -236,8 +237,7 @@ export function useGraphSimulation(): UseGraphSimulationResult { 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, }; } @@ -294,7 +294,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 +306,7 @@ function commitSnapshotGeometry(args: { lastValidSnapshotByTeamRef, dragOwnerPositionsRef, launchAnchorPositionsRef, - activityAnchorPositionsRef, + activityRectByNodeIdRef, extraWorldBoundsRef, fillMissingFallbackPositions = false, } = args; @@ -319,7 +319,7 @@ function commitSnapshotGeometry(args: { } launchAnchorPositionsRef.current.clear(); - activityAnchorPositionsRef.current.clear(); + activityRectByNodeIdRef.current.clear(); extraWorldBoundsRef.current = snapshotToWorldBounds(snapshot); if (snapshot.leadNodeId && snapshot.launchAnchor) { @@ -327,36 +327,32 @@ function commitSnapshotGeometry(args: { } 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.leadActivityRect); + } } 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..ce0fb704 100644 --- a/packages/agent-graph/src/layout/kanbanLayout.ts +++ b/packages/agent-graph/src/layout/kanbanLayout.ts @@ -11,7 +11,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 +42,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']; @@ -91,7 +88,6 @@ export class KanbanLayoutEngine { static layout( nodes: GraphNode[], options?: { - activityLaneBounds?: readonly ActivityLaneWorldBounds[]; memberSlotFrames?: readonly SlotFrame[]; unassignedTaskRect?: StableRect | null; } @@ -100,7 +96,6 @@ 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( (options?.memberSlotFrames ?? []).map((frame) => [frame.ownerId, frame] as const) ); @@ -148,7 +143,6 @@ export class KanbanLayoutEngine { owner, ownerId, leadX, - activityLaneBounds, memberSlotFrameByOwnerId.get(ownerId) ?? null ); if (zoneInfo) this.zones.push(zoneInfo); @@ -164,11 +158,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 +185,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 +195,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 +349,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..448095e2 100644 --- a/packages/agent-graph/src/layout/stableSlots.ts +++ b/packages/agent-graph/src/layout/stableSlots.ts @@ -1,6 +1,6 @@ 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 { ACTIVITY_LANE } from './activityLane'; import { LAUNCH_ANCHOR_LAYOUT, type WorldBounds } from './launchAnchor'; import { STABLE_SLOT_GEOMETRY, @@ -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; } @@ -93,17 +96,24 @@ 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 SECTOR_VECTORS = STABLE_SLOT_SECTOR_VECTORS; export function buildStableSlotLayoutSnapshot({ @@ -119,9 +129,9 @@ export function buildStableSlotLayoutSnapshot({ const leadCoreRect = createCenteredRect(0, 0, 200, 168); const leadActivityRect = createRect( leadCoreRect.left - SLOT_GEOMETRY.centralBlockGap - ACTIVITY_LANE.width, - -ACTIVITY_ANCHOR_LAYOUT.reservedHeight / 2, + -SLOT_GEOMETRY.activityColumnHeight / 2, ACTIVITY_LANE.width, - ACTIVITY_ANCHOR_LAYOUT.reservedHeight + SLOT_GEOMETRY.activityColumnHeight ); const launchHudRect = createRect( leadCoreRect.right + SLOT_GEOMETRY.centralBlockGap, @@ -211,37 +221,42 @@ export function computeOwnerFootprints( } const taskColumnCount = taskColumnsByOwnerId.get(ownerId)?.size ?? 0; - const taskBandWidth = + const kanbanBandWidth = taskColumnCount <= 1 ? TASK_PILL.width : TASK_PILL.width + (taskColumnCount - 1) * KANBAN_ZONE.columnWidth; + const processCount = processCountByOwnerId.get(ownerId) ?? 0; + const processBandWidth = computeProcessBandWidth(processCount); + const boardBandWidth = + SLOT_GEOMETRY.activityColumnWidth + + SLOT_GEOMETRY.boardColumnGap + + kanbanBandWidth; + const boardBandHeight = Math.max( + SLOT_GEOMETRY.activityColumnHeight, + SLOT_GEOMETRY.kanbanBandHeight + ); const innerContentWidth = Math.max( - SLOT_GEOMETRY.activityWidth, SLOT_GEOMETRY.ownerMinWidth, - SLOT_GEOMETRY.processRailWidth, - taskBandWidth + processBandWidth, + boardBandWidth ); 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; + SLOT_GEOMETRY.processToBoardGap + + boardBandHeight; 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 + SLOT_GEOMETRY.processToBoardGap + + boardBandHeight ); return [ @@ -251,13 +266,15 @@ export function computeOwnerFootprints( slotHeight, widthBucket: classifyWidthBucket(slotWidth), radialDepth, - activityWidth: SLOT_GEOMETRY.activityWidth, - activityHeight: SLOT_GEOMETRY.activityHeight, - processRailWidth: SLOT_GEOMETRY.processRailWidth, - taskBandWidth, - taskBandHeight: SLOT_GEOMETRY.taskBandHeight, + activityColumnWidth: SLOT_GEOMETRY.activityColumnWidth, + activityColumnHeight: SLOT_GEOMETRY.activityColumnHeight, + processBandWidth, + kanbanBandWidth, + kanbanBandHeight: SLOT_GEOMETRY.kanbanBandHeight, + boardBandWidth, + boardBandHeight, taskColumnCount, - processCount: processCountByOwnerId.get(ownerId) ?? 0, + processCount, } satisfies OwnerFootprint, ]; }); @@ -273,6 +290,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; @@ -461,14 +488,35 @@ function validateMemberSlotFrame( if (rectsOverlap(frame.bounds, snapshot.runtimeCentralExclusion)) { return { valid: false, reason: `slot frame for ${frame.ownerId} overlaps runtimeCentralExclusion` }; } - if (!rectContainsRect(frame.bounds, frame.activityRect)) { - return { valid: false, reason: `activityRect escapes slot bounds for ${frame.ownerId}` }; + if (!rectContainsRect(frame.bounds, frame.boardBandRect)) { + return { valid: false, reason: `boardBandRect escapes slot bounds for ${frame.ownerId}` }; + } + if (!rectContainsRect(frame.bounds, frame.activityColumnRect)) { + return { valid: false, reason: `activityColumnRect escapes slot bounds for ${frame.ownerId}` }; } if (!rectContainsRect(frame.bounds, frame.processBandRect)) { return { valid: false, reason: `processBandRect escapes slot bounds for ${frame.ownerId}` }; } - 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 slot bounds for ${frame.ownerId}` }; + } + if (!rectContainsRect(frame.boardBandRect, frame.activityColumnRect)) { + return { + valid: false, + reason: `activityColumnRect escapes boardBandRect for ${frame.ownerId}`, + }; + } + if (!rectContainsRect(frame.boardBandRect, frame.kanbanBandRect)) { + return { + valid: false, + reason: `kanbanBandRect escapes boardBandRect for ${frame.ownerId}`, + }; + } + if (rectsOverlap(frame.activityColumnRect, frame.kanbanBandRect)) { + return { + valid: false, + reason: `activityColumnRect overlaps kanbanBandRect for ${frame.ownerId}`, + }; } if (!pointInRect(frame.ownerX, frame.ownerY, frame.bounds)) { return { valid: false, reason: `owner anchor escapes slot bounds for ${frame.ownerId}` }; @@ -502,9 +550,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 +603,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, @@ -695,32 +744,36 @@ function buildSlotFrame( 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 +784,10 @@ function buildSlotFrame( bounds, ownerX, ownerY, - activityRect, + boardBandRect, + activityColumnRect, processBandRect, - taskBandRect, + kanbanBandRect, taskColumnCount: footprint.taskColumnCount, }; } @@ -964,11 +1018,7 @@ function tryBuildValidSlotFrame( if (!frame) { return null; } - if ( - args.placedFrames.some((existing) => - rectsOverlapWithGap(existing.bounds, frame.bounds, SLOT_GEOMETRY.ringPadding) - ) - ) { + if (!isSlotFramePlacementValid(frame, args.placedFrames, args.centralExclusion)) { return null; } args.usedSlotKeys.add(slotKey); @@ -1079,11 +1129,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; diff --git a/packages/agent-graph/src/ui/GraphView.tsx b/packages/agent-graph/src/ui/GraphView.tsx index 807238ac..052c1465 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 { @@ -70,19 +70,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; } @@ -240,49 +232,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) { @@ -800,13 +754,11 @@ export function GraphView({
{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 9586f4d0..86b53c9f 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, @@ -134,7 +135,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, @@ -164,7 +165,7 @@ export class TeamGraphAdapter { leadId, teamData, teamName, - memberNodeIdByName, + memberNodeIdByAlias, spawnStatuses, pendingApprovalAgents, activeTools, @@ -173,8 +174,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, @@ -184,7 +185,7 @@ export class TeamGraphAdapter { leadId, leadName, edges, - memberNodeIdByName + memberNodeIdByAlias ); this.#buildCommentParticles( particles, @@ -193,7 +194,7 @@ export class TeamGraphAdapter { leadId, leadName, edges, - memberNodeIdByName + memberNodeIdByAlias ); return { @@ -226,11 +227,10 @@ export class TeamGraphAdapter { return getGraphLeadMemberName(data, teamName); } - static #buildMemberNodeIdByName(data: TeamData, teamName: string): Map { - return new Map( - data.members - .filter((member) => !isLeadMember(member)) - .map((member) => [member.name, buildGraphMemberNodeIdForMember(teamName, member)] as const) + static #buildMemberNodeIdByAlias(data: TeamData, teamName: string): Map { + return buildGraphMemberNodeIdAliasMap( + teamName, + data.members.filter((member) => !isLeadMember(member)) ); } @@ -464,7 +464,7 @@ export class TeamGraphAdapter { leadId: string, data: TeamData, teamName: string, - memberNodeIdByName: ReadonlyMap, + memberNodeIdByAlias: ReadonlyMap, spawnStatuses?: Record, pendingApprovalAgents?: Set, activeTools?: Record>, @@ -478,7 +478,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], @@ -568,7 +568,7 @@ export class TeamGraphAdapter { data: TeamData, teamName: string, commentReadState?: Record, - memberNodeIdByName?: ReadonlyMap + memberNodeIdByAlias?: ReadonlyMap ): void { const taskStateById = new Map>(); const taskDisplayIds = new Map(); @@ -589,7 +589,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); @@ -752,11 +752,11 @@ export class TeamGraphAdapter { edges: GraphEdge[], data: TeamData, 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}`; @@ -786,13 +786,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; @@ -872,7 +872,7 @@ export class TeamGraphAdapter { leadId: string, leadName: string, edges: GraphEdge[], - memberNodeIdByName: ReadonlyMap + memberNodeIdByAlias: ReadonlyMap ): void { const ordered = [...messages].reverse(); @@ -960,7 +960,7 @@ export class TeamGraphAdapter { leadId, leadName, edges, - memberNodeIdByName + memberNodeIdByAlias ); if (!edgeId) continue; @@ -970,7 +970,7 @@ export class TeamGraphAdapter { msg.from ?? '', leadId, leadName, - memberNodeIdByName + memberNodeIdByAlias ); const isFromTeammate = fromId !== leadId; @@ -1011,7 +1011,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. @@ -1052,7 +1052,7 @@ export class TeamGraphAdapter { newComment.author, leadId, leadName, - memberNodeIdByName + memberNodeIdByAlias ); const taskNodeId = `task:${teamName}:${task.id}`; const authorEdge = @@ -1187,7 +1187,7 @@ export class TeamGraphAdapter { leadId: string, leadName: string, edges: GraphEdge[], - memberNodeIdByName: ReadonlyMap + memberNodeIdByAlias: ReadonlyMap ): string | null { const { from, to } = msg; @@ -1196,9 +1196,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 ?? @@ -1211,7 +1216,7 @@ export class TeamGraphAdapter { from, leadId, leadName, - memberNodeIdByName + memberNodeIdByAlias ); return ( edges.find( @@ -1229,12 +1234,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/ui/GraphActivityHud.tsx b/src/features/agent-graph/renderer/ui/GraphActivityHud.tsx index b157fb78..42bb1a73 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, @@ -125,7 +131,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(() => { @@ -157,7 +165,7 @@ export const GraphActivityHud = ({ shell: HTMLDivElement; connector: SVGSVGElement | null; connectorPath: SVGPathElement | null; - laneTopLeft: { x: number; y: number }; + laneRect: NonNullable>; nodeWorld: { x: number; y: number }; }[] = []; @@ -169,10 +177,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'; @@ -180,19 +187,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'; @@ -205,31 +211,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); @@ -273,11 +271,9 @@ export const GraphActivityHud = ({ }, [ enabled, focusNodeIds, - getActivityAnchorScreenPlacement, - getActivityAnchorWorldPosition, + getActivityWorldRect, getCameraZoom, getNodeWorldPosition, - getNodeScreenPosition, getViewportSize, worldToScreen, visibleLanes, @@ -341,7 +337,9 @@ export const GraphActivityHud = ({ if (!(canvas instanceof HTMLCanvasElement)) { return; } - event.preventDefault(); + if (event.cancelable) { + event.preventDefault(); + } canvas.dispatchEvent( new WheelEvent('wheel', { deltaX: event.deltaX, @@ -395,91 +393,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/TeamGraphOverlay.tsx b/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx index fbf6287e..74df726b 100644 --- a/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx +++ b/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx @@ -131,33 +131,30 @@ export const TeamGraphOverlay = ({ 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 { getLaunchAnchorScreenPlacement, getViewportSize, focusNodeIds } = extraHudProps; return ( <> { 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 { getLaunchAnchorScreenPlacement, getViewportSize, focusNodeIds } = extraHudProps; return ( <> { 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, }) @@ -256,7 +266,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', @@ -307,17 +317,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, @@ -328,12 +344,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/TeamGraphAdapter.test.ts b/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts index 4c2ba66f..af760759 100644 --- a/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts +++ b/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts @@ -736,6 +736,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 6c97a6ff..2d556459 100644 --- a/test/renderer/features/agent-graph/buildInlineActivityEntries.test.ts +++ b/test/renderer/features/agent-graph/buildInlineActivityEntries.test.ts @@ -199,4 +199,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..96e084ed 100644 --- a/test/renderer/features/agent-graph/useGraphSimulation.test.ts +++ b/test/renderer/features/agent-graph/useGraphSimulation.test.ts @@ -3,12 +3,15 @@ import { describe, expect, it, vi } from 'vitest'; import { buildStableSlotLayoutSnapshot, computeOwnerFootprints, + computeProcessBandWidth, resolveNearestSlotAssignment, snapshotToWorldBounds, validateStableSlotLayout, } from '../../../../packages/agent-graph/src/layout/stableSlots'; +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 } from '../../../../packages/agent-graph/src/layout/stableSlotGeometry'; -import { ACTIVITY_ANCHOR_LAYOUT } from '../../../../packages/agent-graph/src/layout/activityLane'; import type { GraphLayoutPort, GraphNode } from '@claude-teams/agent-graph'; @@ -51,6 +54,17 @@ 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 }, + }; +} + describe('stable slot layout planner', () => { it('does not build a stable slot snapshot when the lead is missing', () => { const snapshot = buildStableSlotLayoutSnapshot({ @@ -96,7 +110,7 @@ describe('stable slot layout planner', () => { 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 +130,61 @@ 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('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('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', () => { @@ -252,40 +316,34 @@ describe('stable slot layout planner', () => { it('computes the next ring radius from previous ring depth, not member count', () => { const teamName = 'team-ring-depth'; const lead = createLead(teamName); - const members = Array.from({ length: 7 }, (_, index) => - createMember(teamName, `agent-${index + 1}`, `member-${index + 1}`) - ); + const first = createMember(teamName, 'agent-first', 'member-1'); + const second = createMember(teamName, 'agent-second', 'member-2'); 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: [first.id, second.id], + slotAssignments: { + [first.id]: { ringIndex: 0, sectorIndex: 1 }, + [second.id]: { ringIndex: 1, sectorIndex: 1 }, + }, }; const snapshot = buildStableSlotLayoutSnapshot({ teamName, - nodes: [lead, ...members], + nodes: [lead, first, second], layout, }); - const footprints = computeOwnerFootprints([lead, ...members], 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 +351,82 @@ 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('keeps the same sector and spills to the next outer ring when the saved slot is already occupied', () => {