fix(agent-graph): stabilize member slot layout
This commit is contained in:
parent
8398d29fc0
commit
77d3e9f7d8
15 changed files with 724 additions and 434 deletions
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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<string, StableSlotLayoutSnapshot>());
|
||||
const dragOwnerPositionsRef = useRef(new Map<string, { x: number; y: number }>());
|
||||
const launchAnchorPositionsRef = useRef(new Map<string, { x: number; y: number }>());
|
||||
const activityAnchorPositionsRef = useRef(new Map<string, { x: number; y: number }>());
|
||||
const activityRectByNodeIdRef = useRef(new Map<string, StableRect>());
|
||||
const extraWorldBoundsRef = useRef<WorldBounds[]>([]);
|
||||
|
||||
const prevNodeIdsRef = useRef(new Set<string>());
|
||||
|
|
@ -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<string, StableSlotLayoutSnapshot> };
|
||||
dragOwnerPositionsRef: { current: ReadonlyMap<string, { x: number; y: number }> };
|
||||
launchAnchorPositionsRef: { current: Map<string, { x: number; y: number }> };
|
||||
activityAnchorPositionsRef: { current: Map<string, { x: number; y: number }> };
|
||||
activityRectByNodeIdRef: { current: Map<string, StableRect> };
|
||||
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<string, { x: number; y: number }> };
|
||||
activityAnchorPositionsRef: { current: Map<string, { x: number; y: number }> };
|
||||
activityRectByNodeIdRef: { current: Map<string, StableRect> };
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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<string, { label: string; color: string }> = {
|
|||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, RingLayoutState>;
|
|||
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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<string> | 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({
|
|||
<div className="pointer-events-none absolute inset-0 z-[5] overflow-hidden">
|
||||
{renderHud({
|
||||
getLaunchAnchorScreenPlacement,
|
||||
getActivityAnchorScreenPlacement,
|
||||
getActivityAnchorWorldPosition,
|
||||
getActivityWorldRect,
|
||||
getCameraZoom,
|
||||
worldToScreen: camera.worldToScreen,
|
||||
getNodeWorldPosition,
|
||||
getViewportSize,
|
||||
getNodeScreenPosition,
|
||||
focusNodeIds: focusState.focusNodeIds,
|
||||
})}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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<string, InlineActivityEntry[]> {
|
||||
const entriesByOwnerNodeId = new Map<string, InlineActivityEntry[]>();
|
||||
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<string>;
|
||||
memberNodeIdByName: ReadonlyMap<string, string>;
|
||||
memberNodeIdByAlias: ReadonlyMap<string, string>;
|
||||
}): 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<string>;
|
||||
memberNodeIdByName: ReadonlyMap<string, string>;
|
||||
memberNodeIdByAlias: ReadonlyMap<string, string>;
|
||||
}): 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<string, string>
|
||||
memberNodeIdByAlias: ReadonlyMap<string, string>
|
||||
): 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 {
|
||||
|
|
|
|||
|
|
@ -17,6 +17,31 @@ export function buildGraphMemberNodeIdForMember(
|
|||
return buildGraphMemberNodeId(teamName, getGraphStableOwnerId(member));
|
||||
}
|
||||
|
||||
export function buildGraphMemberNodeIdAliasMap(
|
||||
teamName: string,
|
||||
members: readonly StableTeamOwnerLike[]
|
||||
): Map<string, string> {
|
||||
const aliases = new Map<string, string>();
|
||||
|
||||
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)) {
|
||||
|
|
|
|||
|
|
@ -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<string, string> {
|
||||
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<string, string> {
|
||||
return buildGraphMemberNodeIdAliasMap(
|
||||
teamName,
|
||||
data.members.filter((member) => !isLeadMember(member))
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -464,7 +464,7 @@ export class TeamGraphAdapter {
|
|||
leadId: string,
|
||||
data: TeamData,
|
||||
teamName: string,
|
||||
memberNodeIdByName: ReadonlyMap<string, string>,
|
||||
memberNodeIdByAlias: ReadonlyMap<string, string>,
|
||||
spawnStatuses?: Record<string, MemberSpawnStatusEntry>,
|
||||
pendingApprovalAgents?: Set<string>,
|
||||
activeTools?: Record<string, Record<string, ActiveToolCall>>,
|
||||
|
|
@ -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<string, unknown>,
|
||||
memberNodeIdByName?: ReadonlyMap<string, string>
|
||||
memberNodeIdByAlias?: ReadonlyMap<string, string>
|
||||
): void {
|
||||
const taskStateById = new Map<string, Pick<TeamData['tasks'][number], 'status'>>();
|
||||
const taskDisplayIds = new Map<string, string>();
|
||||
|
|
@ -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<string, string>
|
||||
memberNodeIdByAlias?: ReadonlyMap<string, string>
|
||||
): 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<string, string>
|
||||
memberNodeIdByAlias?: ReadonlyMap<string, string>
|
||||
): { process: TeamProcess; ownerId: string }[] {
|
||||
const selectedByOwnerId = new Map<string, TeamProcess>();
|
||||
|
||||
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<string, string>
|
||||
memberNodeIdByAlias: ReadonlyMap<string, string>
|
||||
): 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<string, string>
|
||||
memberNodeIdByAlias: ReadonlyMap<string, string>
|
||||
): 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<string, string>
|
||||
memberNodeIdByAlias: ReadonlyMap<string, string>
|
||||
): 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<string, string>
|
||||
memberNodeIdByAlias: ReadonlyMap<string, string>
|
||||
): 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" */
|
||||
|
|
|
|||
|
|
@ -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<string> | 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<ReturnType<typeof getActivityWorldRect>>;
|
||||
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) => (
|
||||
<div key={lane.node.id}>
|
||||
<svg
|
||||
ref={(element) => {
|
||||
connectorRefs.current.set(lane.node.id, element);
|
||||
}}
|
||||
className="pointer-events-none absolute z-[9] overflow-visible opacity-0"
|
||||
>
|
||||
<path
|
||||
ref={(element) => {
|
||||
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"
|
||||
/>
|
||||
</svg>
|
||||
<div
|
||||
ref={(element) => {
|
||||
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` }}
|
||||
>
|
||||
<div className="mb-1 px-1 text-[10px] font-semibold tracking-[0.2em] text-slate-400/70">
|
||||
Activity
|
||||
</div>
|
||||
<div className="min-w-0 max-w-full space-y-2 overflow-hidden">
|
||||
{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 (
|
||||
<div
|
||||
key={entry.graphItem.id}
|
||||
className="min-w-0 max-w-full cursor-pointer overflow-hidden"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => handleMessageClick(timelineItem)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
handleMessageClick(timelineItem);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ActivityItem
|
||||
message={entry.message}
|
||||
teamName={teamName}
|
||||
compactHeader
|
||||
collapseMode="managed"
|
||||
isCollapsed
|
||||
canToggleCollapse={false}
|
||||
isUnread={isUnread}
|
||||
expandItemKey={messageKey}
|
||||
onExpand={handleExpandItem}
|
||||
memberRole={renderProps.memberRole}
|
||||
memberColor={renderProps.memberColor}
|
||||
recipientColor={renderProps.recipientColor}
|
||||
memberColorMap={messageContext.colorMap}
|
||||
localMemberNames={messageContext.localMemberNames}
|
||||
onMemberNameClick={handleMemberNameClick}
|
||||
onTaskIdClick={onOpenTaskDetail}
|
||||
zebraShade={index % 2 === 1}
|
||||
teamNames={teamNames}
|
||||
teamColorByName={teamColorByName}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{lane.overflowCount > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded-md border border-white/10 bg-[rgba(8,14,28,0.64)] px-3 py-1 text-center text-[11px] font-medium text-slate-300 transition-colors hover:border-white/20 hover:bg-[rgba(12,20,40,0.78)]"
|
||||
onClick={() => handleOpenOwnerActivity(lane.node)}
|
||||
return (
|
||||
<>
|
||||
<svg
|
||||
ref={(element) => {
|
||||
connectorRefs.current.set(lane.node.id, element);
|
||||
}}
|
||||
className="pointer-events-none absolute z-[9] overflow-visible opacity-0"
|
||||
>
|
||||
+{lane.overflowCount} more
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<path
|
||||
ref={(element) => {
|
||||
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"
|
||||
/>
|
||||
</svg>
|
||||
<div
|
||||
ref={(element) => {
|
||||
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`,
|
||||
}}
|
||||
>
|
||||
<div className="flex h-full min-w-0 max-w-full flex-col overflow-hidden">
|
||||
<div className="mb-1 px-1 text-[10px] font-semibold tracking-[0.2em] text-slate-400/70">
|
||||
Activity
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden">
|
||||
{lane.entries.length === 0 && lane.overflowCount === 0 ? (
|
||||
<div className="flex h-[72px] min-h-[72px] items-center rounded-md border border-dashed border-white/10 bg-[rgba(8,14,28,0.28)] px-3 text-[11px] text-slate-400/60">
|
||||
No recent activity
|
||||
</div>
|
||||
) : 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 (
|
||||
<div
|
||||
key={entry.graphItem.id}
|
||||
className="h-[72px] min-h-[72px] min-w-0 max-w-full cursor-pointer overflow-hidden"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => handleMessageClick(timelineItem)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
handleMessageClick(timelineItem);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ActivityItem
|
||||
message={entry.message}
|
||||
teamName={teamName}
|
||||
compactHeader
|
||||
collapseMode="managed"
|
||||
isCollapsed
|
||||
canToggleCollapse={false}
|
||||
isUnread={isUnread}
|
||||
expandItemKey={messageKey}
|
||||
onExpand={handleExpandItem}
|
||||
memberRole={renderProps.memberRole}
|
||||
memberColor={renderProps.memberColor}
|
||||
recipientColor={renderProps.recipientColor}
|
||||
memberColorMap={messageContext.colorMap}
|
||||
localMemberNames={messageContext.localMemberNames}
|
||||
onMemberNameClick={handleMemberNameClick}
|
||||
onTaskIdClick={onOpenTaskDetail}
|
||||
zebraShade={index % 2 === 1}
|
||||
teamNames={teamNames}
|
||||
teamColorByName={teamColorByName}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{lane.overflowCount > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
className="h-8 min-h-8 w-full rounded-md border border-white/10 bg-[rgba(8,14,28,0.64)] px-3 py-1 text-center text-[11px] font-medium text-slate-300 transition-colors hover:border-white/20 hover:bg-[rgba(12,20,40,0.78)]"
|
||||
onClick={() => handleOpenOwnerActivity(lane.node)}
|
||||
>
|
||||
+{lane.overflowCount} more
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<>
|
||||
<GraphActivityHud
|
||||
teamName={teamName}
|
||||
nodes={graphData.nodes}
|
||||
getActivityAnchorScreenPlacement={getActivityAnchorScreenPlacement}
|
||||
getActivityAnchorWorldPosition={extraHudProps.getActivityAnchorWorldPosition}
|
||||
getActivityWorldRect={extraHudProps.getActivityWorldRect}
|
||||
getCameraZoom={extraHudProps.getCameraZoom}
|
||||
worldToScreen={extraHudProps.worldToScreen}
|
||||
getNodeWorldPosition={extraHudProps.getNodeWorldPosition}
|
||||
getViewportSize={getViewportSize}
|
||||
getNodeScreenPosition={getNodeScreenPosition}
|
||||
focusNodeIds={focusNodeIds}
|
||||
onOpenTaskDetail={onOpenTaskDetail}
|
||||
onOpenMemberProfile={onOpenMemberProfile}
|
||||
|
|
|
|||
|
|
@ -153,33 +153,30 @@ export const TeamGraphTab = ({
|
|||
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 (
|
||||
<>
|
||||
<GraphActivityHud
|
||||
teamName={teamName}
|
||||
nodes={graphData.nodes}
|
||||
getActivityAnchorScreenPlacement={getActivityAnchorScreenPlacement}
|
||||
getActivityAnchorWorldPosition={extraHudProps.getActivityAnchorWorldPosition}
|
||||
getActivityWorldRect={extraHudProps.getActivityWorldRect}
|
||||
getCameraZoom={extraHudProps.getCameraZoom}
|
||||
worldToScreen={extraHudProps.worldToScreen}
|
||||
getNodeWorldPosition={extraHudProps.getNodeWorldPosition}
|
||||
getViewportSize={getViewportSize}
|
||||
getNodeScreenPosition={getNodeScreenPosition}
|
||||
focusNodeIds={focusNodeIds}
|
||||
enabled={isActive}
|
||||
onOpenTaskDetail={dispatchOpenTask}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
@ -227,7 +226,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,
|
||||
})
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue