feat(agent-graph): unify lead slot layout defaults
This commit is contained in:
parent
d81a45f15b
commit
c303a236a5
12 changed files with 523 additions and 410 deletions
|
|
@ -251,14 +251,15 @@ function applySnapshotToNodes(
|
||||||
const translatedFrameByOwnerId = new Map(
|
const translatedFrameByOwnerId = new Map(
|
||||||
translatedFrames.map((frame) => [frame.ownerId, frame] as const)
|
translatedFrames.map((frame) => [frame.ownerId, frame] as const)
|
||||||
);
|
);
|
||||||
|
const leadFrame = snapshot.leadSlotFrame;
|
||||||
const leadId = snapshot.leadNodeId;
|
const leadId = snapshot.leadNodeId;
|
||||||
|
|
||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
if (node.kind === 'lead' && node.id === leadId) {
|
if (node.kind === 'lead' && node.id === leadId) {
|
||||||
node.x = 0;
|
node.x = leadFrame.ownerX;
|
||||||
node.y = 0;
|
node.y = leadFrame.ownerY;
|
||||||
node.fx = 0;
|
node.fx = leadFrame.ownerX;
|
||||||
node.fy = 0;
|
node.fy = leadFrame.ownerY;
|
||||||
node.vx = 0;
|
node.vx = 0;
|
||||||
node.vy = 0;
|
node.vy = 0;
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -278,9 +279,10 @@ function applySnapshotToNodes(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
positionProcessNodes(nodes, translatedFrames);
|
positionProcessNodes(nodes, [snapshot.leadSlotFrame, ...translatedFrames]);
|
||||||
KanbanLayoutEngine.layout(nodes, {
|
KanbanLayoutEngine.layout(nodes, {
|
||||||
memberSlotFrames: translatedFrames,
|
memberSlotFrames: translatedFrames,
|
||||||
|
leadSlotFrame: snapshot.leadSlotFrame,
|
||||||
unassignedTaskRect: snapshot.unassignedTaskRect,
|
unassignedTaskRect: snapshot.unassignedTaskRect,
|
||||||
});
|
});
|
||||||
positionCrossTeamNodes(nodes, snapshot.fitBounds);
|
positionCrossTeamNodes(nodes, snapshot.fitBounds);
|
||||||
|
|
@ -322,16 +324,15 @@ function commitSnapshotGeometry(args: {
|
||||||
activityRectByNodeIdRef.current.clear();
|
activityRectByNodeIdRef.current.clear();
|
||||||
extraWorldBoundsRef.current = snapshotToWorldBounds(snapshot);
|
extraWorldBoundsRef.current = snapshotToWorldBounds(snapshot);
|
||||||
|
|
||||||
if (snapshot.leadNodeId && snapshot.launchAnchor) {
|
|
||||||
launchAnchorPositionsRef.current.set(snapshot.leadNodeId, snapshot.launchAnchor);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const frame of getTranslatedMemberFrames(snapshot, dragOwnerPositionsRef.current)) {
|
for (const frame of getTranslatedMemberFrames(snapshot, dragOwnerPositionsRef.current)) {
|
||||||
activityRectByNodeIdRef.current.set(frame.ownerId, frame.activityColumnRect);
|
activityRectByNodeIdRef.current.set(frame.ownerId, frame.activityColumnRect);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (snapshot.leadNodeId) {
|
if (snapshot.leadNodeId) {
|
||||||
activityRectByNodeIdRef.current.set(snapshot.leadNodeId, snapshot.leadActivityRect);
|
activityRectByNodeIdRef.current.set(
|
||||||
|
snapshot.leadNodeId,
|
||||||
|
snapshot.leadSlotFrame.activityColumnRect
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@
|
||||||
import type { GraphNode } from '../ports/types';
|
import type { GraphNode } from '../ports/types';
|
||||||
import { KANBAN_ZONE, TASK_PILL } from '../constants/canvas-constants';
|
import { KANBAN_ZONE, TASK_PILL } from '../constants/canvas-constants';
|
||||||
import { COLORS } from '../constants/colors';
|
import { COLORS } from '../constants/colors';
|
||||||
import { resolveActivityLaneSide } from './activityLane';
|
|
||||||
import type { SlotFrame, StableRect } from './stableSlots';
|
import type { SlotFrame, StableRect } from './stableSlots';
|
||||||
|
|
||||||
/** Column header info for rendering */
|
/** Column header info for rendering */
|
||||||
|
|
@ -49,7 +48,7 @@ export function getOwnerKanbanBaseX(args: {
|
||||||
columnWidth: number;
|
columnWidth: number;
|
||||||
leadX?: number | null;
|
leadX?: number | null;
|
||||||
}): number {
|
}): number {
|
||||||
const { ownerX, ownerKind, activeColumnCount, columnWidth, leadX } = args;
|
const { ownerX, ownerKind, activeColumnCount, columnWidth } = args;
|
||||||
if (activeColumnCount <= 0) {
|
if (activeColumnCount <= 0) {
|
||||||
return ownerX;
|
return ownerX;
|
||||||
}
|
}
|
||||||
|
|
@ -58,17 +57,7 @@ export function getOwnerKanbanBaseX(args: {
|
||||||
return ownerX - (activeColumnCount * columnWidth) / 2;
|
return ownerX - (activeColumnCount * columnWidth) / 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
const side = resolveActivityLaneSide({
|
return ownerX - ((activeColumnCount - 1) * columnWidth) / 2;
|
||||||
nodeKind: ownerKind,
|
|
||||||
nodeX: ownerX,
|
|
||||||
leadX,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (side === 'left') {
|
|
||||||
return ownerX;
|
|
||||||
}
|
|
||||||
|
|
||||||
return ownerX - (activeColumnCount - 1) * columnWidth;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class KanbanLayoutEngine {
|
export class KanbanLayoutEngine {
|
||||||
|
|
@ -89,6 +78,7 @@ export class KanbanLayoutEngine {
|
||||||
nodes: GraphNode[],
|
nodes: GraphNode[],
|
||||||
options?: {
|
options?: {
|
||||||
memberSlotFrames?: readonly SlotFrame[];
|
memberSlotFrames?: readonly SlotFrame[];
|
||||||
|
leadSlotFrame?: SlotFrame | null;
|
||||||
unassignedTaskRect?: StableRect | null;
|
unassignedTaskRect?: StableRect | null;
|
||||||
}
|
}
|
||||||
): void {
|
): void {
|
||||||
|
|
@ -96,9 +86,12 @@ export class KanbanLayoutEngine {
|
||||||
nodeMap.clear();
|
nodeMap.clear();
|
||||||
for (const n of nodes) nodeMap.set(n.id, n);
|
for (const n of nodes) nodeMap.set(n.id, n);
|
||||||
const leadX = nodes.find((node) => node.kind === 'lead')?.x ?? null;
|
const leadX = nodes.find((node) => node.kind === 'lead')?.x ?? null;
|
||||||
const memberSlotFrameByOwnerId = new Map(
|
const ownerSlotFrameByOwnerId = new Map(
|
||||||
(options?.memberSlotFrames ?? []).map((frame) => [frame.ownerId, frame] as const)
|
(options?.memberSlotFrames ?? []).map((frame) => [frame.ownerId, frame] as const)
|
||||||
);
|
);
|
||||||
|
if (options?.leadSlotFrame) {
|
||||||
|
ownerSlotFrameByOwnerId.set(options.leadSlotFrame.ownerId, options.leadSlotFrame);
|
||||||
|
}
|
||||||
|
|
||||||
const tasksByOwner = this.#tasksByOwner;
|
const tasksByOwner = this.#tasksByOwner;
|
||||||
tasksByOwner.clear();
|
tasksByOwner.clear();
|
||||||
|
|
@ -110,10 +103,10 @@ export class KanbanLayoutEngine {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (owner.kind === 'lead') {
|
if (owner.kind === 'lead') {
|
||||||
return true;
|
return ownerSlotFrameByOwnerId.has(ownerId);
|
||||||
}
|
}
|
||||||
if (owner.kind === 'member') {
|
if (owner.kind === 'member') {
|
||||||
return memberSlotFrameByOwnerId.has(ownerId);
|
return ownerSlotFrameByOwnerId.has(ownerId);
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
@ -143,7 +136,7 @@ export class KanbanLayoutEngine {
|
||||||
owner,
|
owner,
|
||||||
ownerId,
|
ownerId,
|
||||||
leadX,
|
leadX,
|
||||||
memberSlotFrameByOwnerId.get(ownerId) ?? null
|
ownerSlotFrameByOwnerId.get(ownerId) ?? null
|
||||||
);
|
);
|
||||||
if (zoneInfo) this.zones.push(zoneInfo);
|
if (zoneInfo) this.zones.push(zoneInfo);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { KANBAN_ZONE, TASK_PILL } from '../constants/canvas-constants';
|
import { KANBAN_ZONE, TASK_PILL } from '../constants/canvas-constants';
|
||||||
import type { GraphLayoutPort, GraphNode, GraphOwnerSlotAssignment } from '../ports/types';
|
import type { GraphLayoutPort, GraphNode, GraphOwnerSlotAssignment } from '../ports/types';
|
||||||
import { ACTIVITY_LANE } from './activityLane';
|
import { ACTIVITY_LANE } from './activityLane';
|
||||||
import { LAUNCH_ANCHOR_LAYOUT, type WorldBounds } from './launchAnchor';
|
import type { WorldBounds } from './launchAnchor';
|
||||||
import {
|
import {
|
||||||
STABLE_SLOT_GEOMETRY,
|
STABLE_SLOT_GEOMETRY,
|
||||||
STABLE_SLOT_SECTOR_VECTORS,
|
STABLE_SLOT_SECTOR_VECTORS,
|
||||||
|
|
@ -55,6 +55,7 @@ export interface StableSlotLayoutSnapshot {
|
||||||
teamName: string;
|
teamName: string;
|
||||||
leadNodeId: string | null;
|
leadNodeId: string | null;
|
||||||
leadCoreRect: StableRect;
|
leadCoreRect: StableRect;
|
||||||
|
leadSlotFrame: SlotFrame;
|
||||||
leadActivityRect: StableRect;
|
leadActivityRect: StableRect;
|
||||||
launchHudRect: StableRect;
|
launchHudRect: StableRect;
|
||||||
launchAnchor: { x: number; y: number } | null;
|
launchAnchor: { x: number; y: number } | null;
|
||||||
|
|
@ -128,27 +129,21 @@ export function buildStableSlotLayoutSnapshot({
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const leadCoreRect = createCenteredRect(0, 0, 200, 168);
|
const leadCoreRect = createCenteredRect(0, 0, 200, 96);
|
||||||
const leadActivityRect = createRect(
|
const leadFootprint = computeOwnerFootprintForOwnerId(nodes, leadNode.id);
|
||||||
leadCoreRect.left - SLOT_GEOMETRY.centralBlockGap - ACTIVITY_LANE.width,
|
const leadSlotFrame = buildSlotFrameAtRadius(
|
||||||
-SLOT_GEOMETRY.activityColumnHeight / 2,
|
leadFootprint,
|
||||||
ACTIVITY_LANE.width,
|
{ ringIndex: 0, sectorIndex: 0 },
|
||||||
SLOT_GEOMETRY.activityColumnHeight
|
0
|
||||||
);
|
);
|
||||||
const launchHudRect = createRect(
|
const leadActivityRect = leadSlotFrame.activityColumnRect;
|
||||||
leadCoreRect.right + SLOT_GEOMETRY.centralBlockGap,
|
const launchHudRect = createRect(leadCoreRect.right, leadCoreRect.top, 0, 0);
|
||||||
-LAUNCH_ANCHOR_LAYOUT.compactHeight / 2,
|
const leadCentralReservedBlock = leadSlotFrame.bounds;
|
||||||
LAUNCH_ANCHOR_LAYOUT.compactWidth,
|
|
||||||
LAUNCH_ANCHOR_LAYOUT.compactHeight
|
|
||||||
);
|
|
||||||
const leadCentralReservedBlock = unionRects([leadCoreRect, leadActivityRect, launchHudRect]);
|
|
||||||
|
|
||||||
const ownerFootprints = computeOwnerFootprints(nodes, layout);
|
const ownerFootprints = computeOwnerFootprints(nodes, layout);
|
||||||
const unassignedTaskRect = buildUnassignedTaskRect(nodes, leadCentralReservedBlock);
|
const unassignedTaskRect = buildUnassignedTaskRect(nodes, leadCentralReservedBlock);
|
||||||
const centralCollisionRects = buildCentralCollisionRects({
|
const centralCollisionRects = buildCentralCollisionRects({
|
||||||
leadCoreRect,
|
leadCentralReservedBlock,
|
||||||
leadActivityRect,
|
|
||||||
launchHudRect,
|
|
||||||
unassignedTaskRect,
|
unassignedTaskRect,
|
||||||
});
|
});
|
||||||
const runtimeCentralExclusion = padRect(
|
const runtimeCentralExclusion = padRect(
|
||||||
|
|
@ -177,12 +172,10 @@ export function buildStableSlotLayoutSnapshot({
|
||||||
teamName,
|
teamName,
|
||||||
leadNodeId: leadNode.id,
|
leadNodeId: leadNode.id,
|
||||||
leadCoreRect,
|
leadCoreRect,
|
||||||
|
leadSlotFrame,
|
||||||
leadActivityRect,
|
leadActivityRect,
|
||||||
launchHudRect,
|
launchHudRect,
|
||||||
launchAnchor: {
|
launchAnchor: null,
|
||||||
x: launchHudRect.left + launchHudRect.width / 2,
|
|
||||||
y: launchHudRect.top + launchHudRect.height / 2,
|
|
||||||
},
|
|
||||||
leadCentralReservedBlock,
|
leadCentralReservedBlock,
|
||||||
runtimeCentralExclusion,
|
runtimeCentralExclusion,
|
||||||
centralCollisionRects,
|
centralCollisionRects,
|
||||||
|
|
@ -194,12 +187,10 @@ export function buildStableSlotLayoutSnapshot({
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildCentralCollisionRects(args: {
|
function buildCentralCollisionRects(args: {
|
||||||
leadCoreRect: StableRect;
|
leadCentralReservedBlock: StableRect;
|
||||||
leadActivityRect: StableRect;
|
|
||||||
launchHudRect: StableRect;
|
|
||||||
unassignedTaskRect: StableRect | null;
|
unassignedTaskRect: StableRect | null;
|
||||||
}): StableRect[] {
|
}): StableRect[] {
|
||||||
const rects = [args.leadCoreRect, args.leadActivityRect, args.launchHudRect];
|
const rects = [args.leadCentralReservedBlock];
|
||||||
if (args.unassignedTaskRect) {
|
if (args.unassignedTaskRect) {
|
||||||
rects.push(args.unassignedTaskRect);
|
rects.push(args.unassignedTaskRect);
|
||||||
}
|
}
|
||||||
|
|
@ -253,64 +244,96 @@ export function computeOwnerFootprints(
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const taskColumnCount = taskColumnsByOwnerId.get(ownerId)?.size ?? 0;
|
return [
|
||||||
const kanbanBandWidth =
|
buildOwnerFootprint({
|
||||||
taskColumnCount <= 1
|
ownerId,
|
||||||
? TASK_PILL.width
|
taskColumnCount: taskColumnsByOwnerId.get(ownerId)?.size ?? 0,
|
||||||
: TASK_PILL.width + (taskColumnCount - 1) * KANBAN_ZONE.columnWidth;
|
processCount: processCountByOwnerId.get(ownerId) ?? 0,
|
||||||
const processCount = processCountByOwnerId.get(ownerId) ?? 0;
|
}),
|
||||||
const processBandWidth = computeProcessBandWidth(processCount);
|
];
|
||||||
const boardBandWidth =
|
});
|
||||||
SLOT_GEOMETRY.activityColumnWidth +
|
}
|
||||||
SLOT_GEOMETRY.boardColumnGap +
|
|
||||||
kanbanBandWidth;
|
function computeOwnerFootprintForOwnerId(
|
||||||
const boardBandHeight = Math.max(
|
nodes: readonly GraphNode[],
|
||||||
SLOT_GEOMETRY.activityColumnHeight,
|
ownerId: string
|
||||||
SLOT_GEOMETRY.kanbanBandHeight
|
): OwnerFootprint {
|
||||||
);
|
const taskColumns = new Set<string>();
|
||||||
const innerContentWidth = Math.max(
|
let processCount = 0;
|
||||||
SLOT_GEOMETRY.ownerMinWidth,
|
|
||||||
processBandWidth,
|
for (const node of nodes) {
|
||||||
boardBandWidth
|
if (node.kind === 'task' && node.ownerId === ownerId) {
|
||||||
);
|
taskColumns.add(resolveTaskColumnKey(node));
|
||||||
const slotWidth = innerContentWidth + SLOT_GEOMETRY.memberSlotInnerPadding * 2;
|
}
|
||||||
const slotHeight =
|
if (node.kind === 'process' && node.ownerId === ownerId) {
|
||||||
SLOT_GEOMETRY.memberSlotInnerPadding * 2 +
|
processCount += 1;
|
||||||
SLOT_GEOMETRY.ownerBandHeight +
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildOwnerFootprint({
|
||||||
|
ownerId,
|
||||||
|
taskColumnCount: taskColumns.size,
|
||||||
|
processCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOwnerFootprint(args: {
|
||||||
|
ownerId: string;
|
||||||
|
taskColumnCount: number;
|
||||||
|
processCount: number;
|
||||||
|
}): OwnerFootprint {
|
||||||
|
const kanbanBandWidth =
|
||||||
|
args.taskColumnCount <= 1
|
||||||
|
? TASK_PILL.width
|
||||||
|
: TASK_PILL.width + (args.taskColumnCount - 1) * KANBAN_ZONE.columnWidth;
|
||||||
|
const processBandWidth = computeProcessBandWidth(args.processCount);
|
||||||
|
const boardBandWidth =
|
||||||
|
SLOT_GEOMETRY.activityColumnWidth +
|
||||||
|
SLOT_GEOMETRY.boardColumnGap +
|
||||||
|
kanbanBandWidth;
|
||||||
|
const boardBandHeight = Math.max(
|
||||||
|
SLOT_GEOMETRY.activityColumnHeight,
|
||||||
|
SLOT_GEOMETRY.kanbanBandHeight
|
||||||
|
);
|
||||||
|
const innerContentWidth = Math.max(
|
||||||
|
SLOT_GEOMETRY.ownerMinWidth,
|
||||||
|
processBandWidth,
|
||||||
|
boardBandWidth
|
||||||
|
);
|
||||||
|
const slotWidth = innerContentWidth + SLOT_GEOMETRY.memberSlotInnerPadding * 2;
|
||||||
|
const slotHeight =
|
||||||
|
SLOT_GEOMETRY.memberSlotInnerPadding * 2 +
|
||||||
|
SLOT_GEOMETRY.ownerBandHeight +
|
||||||
|
SLOT_GEOMETRY.ownerToProcessGap +
|
||||||
|
SLOT_GEOMETRY.processBandHeight +
|
||||||
|
SLOT_GEOMETRY.processToBoardGap +
|
||||||
|
boardBandHeight;
|
||||||
|
const radialDepth = Math.max(
|
||||||
|
SLOT_GEOMETRY.memberSlotInnerPadding + SLOT_GEOMETRY.ownerBandHeight / 2,
|
||||||
|
SLOT_GEOMETRY.memberSlotInnerPadding +
|
||||||
|
SLOT_GEOMETRY.ownerBandHeight / 2 +
|
||||||
SLOT_GEOMETRY.ownerToProcessGap +
|
SLOT_GEOMETRY.ownerToProcessGap +
|
||||||
SLOT_GEOMETRY.processBandHeight +
|
SLOT_GEOMETRY.processBandHeight +
|
||||||
SLOT_GEOMETRY.processToBoardGap +
|
SLOT_GEOMETRY.processToBoardGap +
|
||||||
boardBandHeight;
|
boardBandHeight
|
||||||
const radialDepth = Math.max(
|
);
|
||||||
SLOT_GEOMETRY.memberSlotInnerPadding +
|
|
||||||
SLOT_GEOMETRY.ownerBandHeight / 2,
|
|
||||||
SLOT_GEOMETRY.memberSlotInnerPadding +
|
|
||||||
SLOT_GEOMETRY.ownerBandHeight / 2 +
|
|
||||||
SLOT_GEOMETRY.ownerToProcessGap +
|
|
||||||
SLOT_GEOMETRY.processBandHeight +
|
|
||||||
SLOT_GEOMETRY.processToBoardGap +
|
|
||||||
boardBandHeight
|
|
||||||
);
|
|
||||||
|
|
||||||
return [
|
return {
|
||||||
{
|
ownerId: args.ownerId,
|
||||||
ownerId,
|
slotWidth,
|
||||||
slotWidth,
|
slotHeight,
|
||||||
slotHeight,
|
widthBucket: classifyWidthBucket(slotWidth),
|
||||||
widthBucket: classifyWidthBucket(slotWidth),
|
radialDepth,
|
||||||
radialDepth,
|
activityColumnWidth: SLOT_GEOMETRY.activityColumnWidth,
|
||||||
activityColumnWidth: SLOT_GEOMETRY.activityColumnWidth,
|
activityColumnHeight: SLOT_GEOMETRY.activityColumnHeight,
|
||||||
activityColumnHeight: SLOT_GEOMETRY.activityColumnHeight,
|
processBandWidth,
|
||||||
processBandWidth,
|
kanbanBandWidth,
|
||||||
kanbanBandWidth,
|
kanbanBandHeight: SLOT_GEOMETRY.kanbanBandHeight,
|
||||||
kanbanBandHeight: SLOT_GEOMETRY.kanbanBandHeight,
|
boardBandWidth,
|
||||||
boardBandWidth,
|
boardBandHeight,
|
||||||
boardBandHeight,
|
taskColumnCount: args.taskColumnCount,
|
||||||
taskColumnCount,
|
processCount: args.processCount,
|
||||||
processCount,
|
} satisfies OwnerFootprint;
|
||||||
} satisfies OwnerFootprint,
|
|
||||||
];
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function classifyWidthBucket(width: number): StableSlotWidthBucket {
|
export function classifyWidthBucket(width: number): StableSlotWidthBucket {
|
||||||
|
|
@ -447,6 +470,11 @@ function validateStaticSnapshotRects(
|
||||||
): StableSlotLayoutValidationResult | null {
|
): StableSlotLayoutValidationResult | null {
|
||||||
const staticRects: [string, StableRect][] = [
|
const staticRects: [string, StableRect][] = [
|
||||||
['leadCoreRect', snapshot.leadCoreRect],
|
['leadCoreRect', snapshot.leadCoreRect],
|
||||||
|
['leadSlotFrame.bounds', snapshot.leadSlotFrame.bounds],
|
||||||
|
['leadSlotFrame.boardBandRect', snapshot.leadSlotFrame.boardBandRect],
|
||||||
|
['leadSlotFrame.activityColumnRect', snapshot.leadSlotFrame.activityColumnRect],
|
||||||
|
['leadSlotFrame.processBandRect', snapshot.leadSlotFrame.processBandRect],
|
||||||
|
['leadSlotFrame.kanbanBandRect', snapshot.leadSlotFrame.kanbanBandRect],
|
||||||
['leadActivityRect', snapshot.leadActivityRect],
|
['leadActivityRect', snapshot.leadActivityRect],
|
||||||
['launchHudRect', snapshot.launchHudRect],
|
['launchHudRect', snapshot.launchHudRect],
|
||||||
['leadCentralReservedBlock', snapshot.leadCentralReservedBlock],
|
['leadCentralReservedBlock', snapshot.leadCentralReservedBlock],
|
||||||
|
|
@ -477,14 +505,34 @@ function validateStaticSnapshotRects(
|
||||||
function validateLeadSnapshotRects(
|
function validateLeadSnapshotRects(
|
||||||
snapshot: StableSlotLayoutSnapshot
|
snapshot: StableSlotLayoutSnapshot
|
||||||
): StableSlotLayoutValidationResult | null {
|
): StableSlotLayoutValidationResult | null {
|
||||||
|
const leadFrameValidation = validateSlotFrameGeometry(
|
||||||
|
snapshot.leadSlotFrame,
|
||||||
|
snapshot.fitBounds,
|
||||||
|
`leadSlotFrame(${snapshot.leadSlotFrame.ownerId})`
|
||||||
|
);
|
||||||
|
if (leadFrameValidation) {
|
||||||
|
return leadFrameValidation;
|
||||||
|
}
|
||||||
if (!rectContainsRect(snapshot.leadCentralReservedBlock, snapshot.leadCoreRect)) {
|
if (!rectContainsRect(snapshot.leadCentralReservedBlock, snapshot.leadCoreRect)) {
|
||||||
return { valid: false, reason: 'leadCoreRect must fit inside leadCentralReservedBlock' };
|
return { valid: false, reason: 'leadCoreRect must fit inside leadCentralReservedBlock' };
|
||||||
}
|
}
|
||||||
if (!rectContainsRect(snapshot.leadCentralReservedBlock, snapshot.leadActivityRect)) {
|
if (!rectContainsRect(snapshot.leadCentralReservedBlock, snapshot.leadActivityRect)) {
|
||||||
return { valid: false, reason: 'leadActivityRect must fit inside leadCentralReservedBlock' };
|
return { valid: false, reason: 'leadActivityRect must fit inside leadCentralReservedBlock' };
|
||||||
}
|
}
|
||||||
if (!rectContainsRect(snapshot.leadCentralReservedBlock, snapshot.launchHudRect)) {
|
if (snapshot.leadActivityRect.left !== snapshot.leadSlotFrame.activityColumnRect.left) {
|
||||||
return { valid: false, reason: 'launchHudRect must fit inside leadCentralReservedBlock' };
|
return {
|
||||||
|
valid: false,
|
||||||
|
reason: 'leadActivityRect must mirror leadSlotFrame.activityColumnRect',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (snapshot.leadActivityRect.top !== snapshot.leadSlotFrame.activityColumnRect.top) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
reason: 'leadActivityRect must mirror leadSlotFrame.activityColumnRect',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!rectContainsRect(snapshot.leadCentralReservedBlock, snapshot.leadSlotFrame.bounds)) {
|
||||||
|
return { valid: false, reason: 'leadSlotFrame must fit inside leadCentralReservedBlock' };
|
||||||
}
|
}
|
||||||
if (!rectContainsRect(snapshot.runtimeCentralExclusion, snapshot.leadCentralReservedBlock)) {
|
if (!rectContainsRect(snapshot.runtimeCentralExclusion, snapshot.leadCentralReservedBlock)) {
|
||||||
return { valid: false, reason: 'runtimeCentralExclusion must contain leadCentralReservedBlock' };
|
return { valid: false, reason: 'runtimeCentralExclusion must contain leadCentralReservedBlock' };
|
||||||
|
|
@ -513,11 +561,13 @@ function validateMemberSlotFrame(
|
||||||
seenOwnerIds: Set<string>,
|
seenOwnerIds: Set<string>,
|
||||||
seenAssignments: Set<string>
|
seenAssignments: Set<string>
|
||||||
): StableSlotLayoutValidationResult | null {
|
): StableSlotLayoutValidationResult | null {
|
||||||
if (!isFiniteRect(frame.bounds)) {
|
const geometryValidation = validateSlotFrameGeometry(
|
||||||
return { valid: false, reason: `slot frame for ${frame.ownerId} contains non-finite bounds` };
|
frame,
|
||||||
}
|
snapshot.fitBounds,
|
||||||
if (!Number.isFinite(frame.ownerX) || !Number.isFinite(frame.ownerY)) {
|
`slot frame for ${frame.ownerId}`
|
||||||
return { valid: false, reason: `slot frame for ${frame.ownerId} contains non-finite anchor` };
|
);
|
||||||
|
if (geometryValidation) {
|
||||||
|
return geometryValidation;
|
||||||
}
|
}
|
||||||
if (seenOwnerIds.has(frame.ownerId)) {
|
if (seenOwnerIds.has(frame.ownerId)) {
|
||||||
return { valid: false, reason: `duplicate owner frame for ${frame.ownerId}` };
|
return { valid: false, reason: `duplicate owner frame for ${frame.ownerId}` };
|
||||||
|
|
@ -536,41 +586,55 @@ function validateMemberSlotFrame(
|
||||||
reason: `slot frame for ${frame.ownerId} overlaps centralCollisionRects`,
|
reason: `slot frame for ${frame.ownerId} overlaps centralCollisionRects`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateSlotFrameGeometry(
|
||||||
|
frame: SlotFrame,
|
||||||
|
fitBounds: StableRect,
|
||||||
|
label: string
|
||||||
|
): StableSlotLayoutValidationResult | null {
|
||||||
|
if (!isFiniteRect(frame.bounds)) {
|
||||||
|
return { valid: false, reason: `${label} contains non-finite bounds` };
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(frame.ownerX) || !Number.isFinite(frame.ownerY)) {
|
||||||
|
return { valid: false, reason: `${label} contains non-finite anchor` };
|
||||||
|
}
|
||||||
if (!rectContainsRect(frame.bounds, frame.boardBandRect)) {
|
if (!rectContainsRect(frame.bounds, frame.boardBandRect)) {
|
||||||
return { valid: false, reason: `boardBandRect escapes slot bounds for ${frame.ownerId}` };
|
return { valid: false, reason: `boardBandRect escapes ${label}` };
|
||||||
}
|
}
|
||||||
if (!rectContainsRect(frame.bounds, frame.activityColumnRect)) {
|
if (!rectContainsRect(frame.bounds, frame.activityColumnRect)) {
|
||||||
return { valid: false, reason: `activityColumnRect escapes slot bounds for ${frame.ownerId}` };
|
return { valid: false, reason: `activityColumnRect escapes ${label}` };
|
||||||
}
|
}
|
||||||
if (!rectContainsRect(frame.bounds, frame.processBandRect)) {
|
if (!rectContainsRect(frame.bounds, frame.processBandRect)) {
|
||||||
return { valid: false, reason: `processBandRect escapes slot bounds for ${frame.ownerId}` };
|
return { valid: false, reason: `processBandRect escapes ${label}` };
|
||||||
}
|
}
|
||||||
if (!rectContainsRect(frame.bounds, frame.kanbanBandRect)) {
|
if (!rectContainsRect(frame.bounds, frame.kanbanBandRect)) {
|
||||||
return { valid: false, reason: `kanbanBandRect escapes slot bounds for ${frame.ownerId}` };
|
return { valid: false, reason: `kanbanBandRect escapes ${label}` };
|
||||||
}
|
}
|
||||||
if (!rectContainsRect(frame.boardBandRect, frame.activityColumnRect)) {
|
if (!rectContainsRect(frame.boardBandRect, frame.activityColumnRect)) {
|
||||||
return {
|
return {
|
||||||
valid: false,
|
valid: false,
|
||||||
reason: `activityColumnRect escapes boardBandRect for ${frame.ownerId}`,
|
reason: `activityColumnRect escapes boardBandRect in ${label}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (!rectContainsRect(frame.boardBandRect, frame.kanbanBandRect)) {
|
if (!rectContainsRect(frame.boardBandRect, frame.kanbanBandRect)) {
|
||||||
return {
|
return {
|
||||||
valid: false,
|
valid: false,
|
||||||
reason: `kanbanBandRect escapes boardBandRect for ${frame.ownerId}`,
|
reason: `kanbanBandRect escapes boardBandRect in ${label}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (rectsOverlap(frame.activityColumnRect, frame.kanbanBandRect)) {
|
if (rectsOverlap(frame.activityColumnRect, frame.kanbanBandRect)) {
|
||||||
return {
|
return {
|
||||||
valid: false,
|
valid: false,
|
||||||
reason: `activityColumnRect overlaps kanbanBandRect for ${frame.ownerId}`,
|
reason: `activityColumnRect overlaps kanbanBandRect in ${label}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (!pointInRect(frame.ownerX, frame.ownerY, frame.bounds)) {
|
if (!pointInRect(frame.ownerX, frame.ownerY, frame.bounds)) {
|
||||||
return { valid: false, reason: `owner anchor escapes slot bounds for ${frame.ownerId}` };
|
return { valid: false, reason: `owner anchor escapes ${label}` };
|
||||||
}
|
}
|
||||||
if (!rectContainsRect(snapshot.fitBounds, frame.bounds)) {
|
if (!rectContainsRect(fitBounds, frame.bounds)) {
|
||||||
return { valid: false, reason: `slot frame for ${frame.ownerId} escapes fitBounds` };
|
return { valid: false, reason: `${label} escapes fitBounds` };
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ export interface GraphControlsProps {
|
||||||
teamName: string;
|
teamName: string;
|
||||||
teamColor?: string;
|
teamColor?: string;
|
||||||
isAlive?: boolean;
|
isAlive?: boolean;
|
||||||
|
topToolbarContent?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TOPBAR_BUTTON_SIZE = 25;
|
const TOPBAR_BUTTON_SIZE = 25;
|
||||||
|
|
@ -67,6 +68,7 @@ export function GraphControls({
|
||||||
onToggleSidebar,
|
onToggleSidebar,
|
||||||
isSidebarVisible = true,
|
isSidebarVisible = true,
|
||||||
teamColor,
|
teamColor,
|
||||||
|
topToolbarContent,
|
||||||
}: GraphControlsProps): React.JSX.Element {
|
}: GraphControlsProps): React.JSX.Element {
|
||||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||||
const settingsRef = useRef<HTMLDivElement>(null);
|
const settingsRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
@ -105,160 +107,170 @@ export function GraphControls({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="absolute left-3 top-3 z-20 flex items-center gap-0.5 pointer-events-none">
|
<div className="absolute inset-x-3 top-3 z-20 flex items-start gap-2 pointer-events-none">
|
||||||
{onToggleSidebar ? (
|
<div className="flex shrink-0 items-center gap-0.5">
|
||||||
<div
|
{onToggleSidebar ? (
|
||||||
className="pointer-events-auto flex items-center rounded-md p-0 backdrop-blur-sm"
|
<div
|
||||||
style={{
|
className="pointer-events-auto flex items-center rounded-md p-0 backdrop-blur-sm"
|
||||||
background: 'rgba(8, 12, 24, 0.8)',
|
style={{
|
||||||
border: `1px solid ${nameColor}25`,
|
background: 'rgba(8, 12, 24, 0.8)',
|
||||||
}}
|
border: `1px solid ${nameColor}25`,
|
||||||
>
|
}}
|
||||||
<ToolbarButton
|
>
|
||||||
onClick={onToggleSidebar}
|
<ToolbarButton
|
||||||
icon={
|
onClick={onToggleSidebar}
|
||||||
isSidebarVisible ? (
|
icon={
|
||||||
<PanelLeftClose size={TOPBAR_ICON_SIZE} />
|
isSidebarVisible ? (
|
||||||
) : (
|
<PanelLeftClose size={TOPBAR_ICON_SIZE} />
|
||||||
<PanelLeftOpen size={TOPBAR_ICON_SIZE} />
|
) : (
|
||||||
)
|
<PanelLeftOpen size={TOPBAR_ICON_SIZE} />
|
||||||
}
|
)
|
||||||
toolbar
|
}
|
||||||
title={isSidebarVisible ? 'Hide sidebar' : 'Show sidebar'}
|
toolbar
|
||||||
/>
|
title={isSidebarVisible ? 'Hide sidebar' : 'Show sidebar'}
|
||||||
</div>
|
/>
|
||||||
) : null}
|
</div>
|
||||||
{onOpenTeamPage ? (
|
) : null}
|
||||||
<div
|
{onOpenTeamPage ? (
|
||||||
className="pointer-events-auto flex items-center rounded-md p-0 backdrop-blur-sm"
|
<div
|
||||||
style={{
|
className="pointer-events-auto flex items-center rounded-md p-0 backdrop-blur-sm"
|
||||||
background: 'rgba(8, 12, 24, 0.8)',
|
style={{
|
||||||
border: `1px solid ${nameColor}25`,
|
background: 'rgba(8, 12, 24, 0.8)',
|
||||||
}}
|
border: `1px solid ${nameColor}25`,
|
||||||
>
|
}}
|
||||||
<ToolbarButton
|
>
|
||||||
onClick={onOpenTeamPage}
|
<ToolbarButton
|
||||||
icon={<Users size={TOPBAR_ICON_SIZE} />}
|
onClick={onOpenTeamPage}
|
||||||
toolbar
|
icon={<Users size={TOPBAR_ICON_SIZE} />}
|
||||||
title="Open team page"
|
toolbar
|
||||||
/>
|
title="Open team page"
|
||||||
</div>
|
/>
|
||||||
) : null}
|
</div>
|
||||||
{onCreateTask ? (
|
) : null}
|
||||||
<div
|
{onCreateTask ? (
|
||||||
className="pointer-events-auto flex items-center rounded-md p-0 backdrop-blur-sm"
|
<div
|
||||||
style={{
|
className="pointer-events-auto flex items-center rounded-md p-0 backdrop-blur-sm"
|
||||||
background: 'rgba(8, 12, 24, 0.8)',
|
style={{
|
||||||
border: `1px solid ${nameColor}25`,
|
background: 'rgba(8, 12, 24, 0.8)',
|
||||||
}}
|
border: `1px solid ${nameColor}25`,
|
||||||
>
|
}}
|
||||||
<ToolbarButton
|
>
|
||||||
onClick={onCreateTask}
|
<ToolbarButton
|
||||||
icon={<Plus size={TOPBAR_ICON_SIZE} />}
|
onClick={onCreateTask}
|
||||||
toolbar
|
icon={<Plus size={TOPBAR_ICON_SIZE} />}
|
||||||
title="Create task"
|
toolbar
|
||||||
/>
|
title="Create task"
|
||||||
</div>
|
/>
|
||||||
) : null}
|
</div>
|
||||||
</div>
|
) : null}
|
||||||
|
|
||||||
<div className="absolute right-3 top-3 z-20 flex items-center gap-0.5 pointer-events-none">
|
|
||||||
<div
|
|
||||||
className="pointer-events-auto flex items-center rounded-md p-0 backdrop-blur-sm"
|
|
||||||
style={{
|
|
||||||
background: 'rgba(8, 12, 24, 0.8)',
|
|
||||||
border: '1px solid rgba(100, 200, 255, 0.08)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ToolbarButton
|
|
||||||
onClick={() => toggle('paused')}
|
|
||||||
icon={filters.paused ? <Play size={TOPBAR_ICON_SIZE} /> : <Pause size={TOPBAR_ICON_SIZE} />}
|
|
||||||
toolbar
|
|
||||||
title={filters.paused ? 'Resume animation' : 'Pause animation'}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div ref={settingsRef} className="relative pointer-events-auto">
|
<div className="flex min-w-0 flex-1 justify-end px-2">
|
||||||
|
{topToolbarContent ? (
|
||||||
|
<div className="pointer-events-auto min-w-0 max-w-[min(360px,42vw)]">
|
||||||
|
{topToolbarContent}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 items-center gap-0.5">
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-0.5 rounded-md p-0 backdrop-blur-sm"
|
className="pointer-events-auto flex items-center rounded-md p-0 backdrop-blur-sm"
|
||||||
style={{
|
style={{
|
||||||
background: 'rgba(8, 12, 24, 0.8)',
|
background: 'rgba(8, 12, 24, 0.8)',
|
||||||
border: '1px solid rgba(100, 200, 255, 0.08)',
|
border: '1px solid rgba(100, 200, 255, 0.08)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
onClick={() => setIsSettingsOpen((value) => !value)}
|
onClick={() => toggle('paused')}
|
||||||
icon={<Settings2 size={TOPBAR_ICON_SIZE} />}
|
icon={filters.paused ? <Play size={TOPBAR_ICON_SIZE} /> : <Pause size={TOPBAR_ICON_SIZE} />}
|
||||||
active={isSettingsOpen}
|
|
||||||
toolbar
|
toolbar
|
||||||
title="Graph settings"
|
title={filters.paused ? 'Resume animation' : 'Pause animation'}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isSettingsOpen && (
|
<div ref={settingsRef} className="relative pointer-events-auto">
|
||||||
<div
|
<div
|
||||||
className="absolute right-0 top-[calc(100%+0.5rem)] w-44 rounded-xl p-1.5 shadow-2xl"
|
className="flex items-center gap-0.5 rounded-md p-0 backdrop-blur-sm"
|
||||||
style={{
|
style={{
|
||||||
background: 'rgba(8, 12, 24, 0.96)',
|
background: 'rgba(8, 12, 24, 0.8)',
|
||||||
border: '1px solid rgba(100, 200, 255, 0.12)',
|
border: '1px solid rgba(100, 200, 255, 0.08)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ToolbarToggle
|
<ToolbarButton
|
||||||
active={filters.showTasks}
|
onClick={() => setIsSettingsOpen((value) => !value)}
|
||||||
onClick={() => toggle('showTasks')}
|
icon={<Settings2 size={TOPBAR_ICON_SIZE} />}
|
||||||
icon={<Columns3 size={13} />}
|
active={isSettingsOpen}
|
||||||
label="Tasks"
|
toolbar
|
||||||
block
|
title="Graph settings"
|
||||||
/>
|
|
||||||
<ToolbarToggle
|
|
||||||
active={filters.showProcesses}
|
|
||||||
onClick={() => toggle('showProcesses')}
|
|
||||||
icon={<Server size={13} />}
|
|
||||||
label="Processes"
|
|
||||||
block
|
|
||||||
/>
|
|
||||||
<ToolbarToggle
|
|
||||||
active={filters.showEdges}
|
|
||||||
onClick={() => toggle('showEdges')}
|
|
||||||
icon={filters.showEdges ? <Eye size={13} /> : <EyeOff size={13} />}
|
|
||||||
label="Edges"
|
|
||||||
block
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
{isSettingsOpen && (
|
||||||
className="pointer-events-auto flex items-center gap-0.5 rounded-md p-0 backdrop-blur-sm"
|
<div
|
||||||
style={{
|
className="absolute right-0 top-[calc(100%+0.5rem)] w-44 rounded-xl p-1.5 shadow-2xl"
|
||||||
background: 'rgba(8, 12, 24, 0.8)',
|
style={{
|
||||||
border: '1px solid rgba(100, 200, 255, 0.08)',
|
background: 'rgba(8, 12, 24, 0.96)',
|
||||||
}}
|
border: '1px solid rgba(100, 200, 255, 0.12)',
|
||||||
>
|
}}
|
||||||
{onRequestPinAsTab && (
|
>
|
||||||
<ToolbarButton
|
<ToolbarToggle
|
||||||
onClick={onRequestPinAsTab}
|
active={filters.showTasks}
|
||||||
icon={<Pin size={TOPBAR_ICON_SIZE} />}
|
onClick={() => toggle('showTasks')}
|
||||||
toolbar
|
icon={<Columns3 size={13} />}
|
||||||
title="Pin as tab"
|
label="Tasks"
|
||||||
/>
|
block
|
||||||
)}
|
/>
|
||||||
{onRequestFullscreen && (
|
<ToolbarToggle
|
||||||
<ToolbarButton
|
active={filters.showProcesses}
|
||||||
onClick={onRequestFullscreen}
|
onClick={() => toggle('showProcesses')}
|
||||||
icon={<Expand size={TOPBAR_ICON_SIZE} />}
|
icon={<Server size={13} />}
|
||||||
toolbar
|
label="Processes"
|
||||||
title="Fullscreen"
|
block
|
||||||
/>
|
/>
|
||||||
)}
|
<ToolbarToggle
|
||||||
{onRequestClose && (
|
active={filters.showEdges}
|
||||||
<ToolbarButton
|
onClick={() => toggle('showEdges')}
|
||||||
onClick={onRequestClose}
|
icon={filters.showEdges ? <Eye size={13} /> : <EyeOff size={13} />}
|
||||||
icon={<X size={TOPBAR_ICON_SIZE} />}
|
label="Edges"
|
||||||
toolbar
|
block
|
||||||
title="Close graph"
|
/>
|
||||||
/>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="pointer-events-auto flex items-center gap-0.5 rounded-md p-0 backdrop-blur-sm"
|
||||||
|
style={{
|
||||||
|
background: 'rgba(8, 12, 24, 0.8)',
|
||||||
|
border: '1px solid rgba(100, 200, 255, 0.08)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{onRequestPinAsTab && (
|
||||||
|
<ToolbarButton
|
||||||
|
onClick={onRequestPinAsTab}
|
||||||
|
icon={<Pin size={TOPBAR_ICON_SIZE} />}
|
||||||
|
toolbar
|
||||||
|
title="Pin as tab"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{onRequestFullscreen && (
|
||||||
|
<ToolbarButton
|
||||||
|
onClick={onRequestFullscreen}
|
||||||
|
icon={<Expand size={TOPBAR_ICON_SIZE} />}
|
||||||
|
toolbar
|
||||||
|
title="Fullscreen"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{onRequestClose && (
|
||||||
|
<ToolbarButton
|
||||||
|
onClick={onRequestClose}
|
||||||
|
icon={<X size={TOPBAR_ICON_SIZE} />}
|
||||||
|
toolbar
|
||||||
|
title="Close graph"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ export interface GraphViewProps {
|
||||||
onCreateTask?: () => void;
|
onCreateTask?: () => void;
|
||||||
onToggleSidebar?: () => void;
|
onToggleSidebar?: () => void;
|
||||||
isSidebarVisible?: boolean;
|
isSidebarVisible?: boolean;
|
||||||
|
renderTopToolbarContent?: () => React.ReactNode;
|
||||||
onOwnerSlotDrop?: (payload: {
|
onOwnerSlotDrop?: (payload: {
|
||||||
nodeId: string;
|
nodeId: string;
|
||||||
assignment: GraphOwnerSlotAssignment;
|
assignment: GraphOwnerSlotAssignment;
|
||||||
|
|
@ -92,6 +93,7 @@ export function GraphView({
|
||||||
onCreateTask,
|
onCreateTask,
|
||||||
onToggleSidebar,
|
onToggleSidebar,
|
||||||
isSidebarVisible = true,
|
isSidebarVisible = true,
|
||||||
|
renderTopToolbarContent,
|
||||||
onOwnerSlotDrop,
|
onOwnerSlotDrop,
|
||||||
renderOverlay,
|
renderOverlay,
|
||||||
renderEdgeOverlay,
|
renderEdgeOverlay,
|
||||||
|
|
@ -748,6 +750,7 @@ export function GraphView({
|
||||||
teamName={data.teamName}
|
teamName={data.teamName}
|
||||||
teamColor={data.teamColor}
|
teamColor={data.teamColor}
|
||||||
isAlive={data.isAlive}
|
isAlive={data.isAlive}
|
||||||
|
topToolbarContent={renderTopToolbarContent?.()}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{renderHud ? (
|
{renderHud ? (
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { DISPLAY_STEPS } from '@renderer/components/team/provisioningSteps';
|
import { DISPLAY_STEPS } from '@renderer/components/team/provisioningSteps';
|
||||||
import { StepProgressBar } from '@renderer/components/team/StepProgressBar';
|
import { StepProgressBar } from '@renderer/components/team/StepProgressBar';
|
||||||
|
|
@ -13,7 +13,7 @@ import {
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@renderer/components/ui/dialog';
|
} from '@renderer/components/ui/dialog';
|
||||||
import { cn } from '@renderer/lib/utils';
|
import { cn } from '@renderer/lib/utils';
|
||||||
import { AlertTriangle, CheckCircle2, ExternalLink, Loader2, X } from 'lucide-react';
|
import { AlertTriangle, CheckCircle2, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
import type { TeamProvisioningPresentation } from '@renderer/utils/teamProvisioningPresentation';
|
import type { TeamProvisioningPresentation } from '@renderer/utils/teamProvisioningPresentation';
|
||||||
import type { CSSProperties } from 'react';
|
import type { CSSProperties } from 'react';
|
||||||
|
|
@ -51,28 +51,28 @@ function getToneClasses(tone: TeamProvisioningPresentation['compactTone']): {
|
||||||
return {
|
return {
|
||||||
border: 'border-red-400/35 bg-[rgba(26,10,16,0.92)]',
|
border: 'border-red-400/35 bg-[rgba(26,10,16,0.92)]',
|
||||||
badge: 'border-red-500/30 text-red-300',
|
badge: 'border-red-500/30 text-red-300',
|
||||||
icon: <AlertTriangle size={13} />,
|
icon: <AlertTriangle size={12} />,
|
||||||
iconClassName: 'text-red-400',
|
iconClassName: 'text-red-400',
|
||||||
};
|
};
|
||||||
case 'warning':
|
case 'warning':
|
||||||
return {
|
return {
|
||||||
border: 'border-amber-400/35 bg-[rgba(31,18,8,0.92)]',
|
border: 'border-amber-400/35 bg-[rgba(31,18,8,0.92)]',
|
||||||
badge: 'border-amber-500/30 text-amber-200',
|
badge: 'border-amber-500/30 text-amber-200',
|
||||||
icon: <AlertTriangle size={13} />,
|
icon: <AlertTriangle size={12} />,
|
||||||
iconClassName: 'text-amber-400',
|
iconClassName: 'text-amber-400',
|
||||||
};
|
};
|
||||||
case 'success':
|
case 'success':
|
||||||
return {
|
return {
|
||||||
border: 'border-emerald-400/35 bg-[rgba(8,24,18,0.92)]',
|
border: 'border-emerald-400/35 bg-[rgba(8,24,18,0.92)]',
|
||||||
badge: 'border-emerald-500/30 text-emerald-200',
|
badge: 'border-emerald-500/30 text-emerald-200',
|
||||||
icon: <CheckCircle2 size={13} />,
|
icon: <CheckCircle2 size={12} />,
|
||||||
iconClassName: 'text-emerald-400',
|
iconClassName: 'text-emerald-400',
|
||||||
};
|
};
|
||||||
default:
|
default:
|
||||||
return {
|
return {
|
||||||
border: 'border-cyan-400/25 bg-[rgba(8,14,26,0.92)]',
|
border: 'border-cyan-400/25 bg-[rgba(8,14,26,0.92)]',
|
||||||
badge: 'border-cyan-500/20 text-cyan-200',
|
badge: 'border-cyan-500/20 text-cyan-200',
|
||||||
icon: <Loader2 size={13} className="animate-spin" />,
|
icon: <Loader2 size={12} className="animate-spin" />,
|
||||||
iconClassName: 'text-cyan-300',
|
iconClassName: 'text-cyan-300',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -80,26 +80,17 @@ function getToneClasses(tone: TeamProvisioningPresentation['compactTone']): {
|
||||||
|
|
||||||
export interface GraphProvisioningHudProps {
|
export interface GraphProvisioningHudProps {
|
||||||
teamName: string;
|
teamName: string;
|
||||||
leadNodeId: string | null;
|
|
||||||
getLaunchAnchorScreenPlacement: (
|
|
||||||
leadNodeId: string
|
|
||||||
) => { x: number; y: number; scale: number; visible: boolean } | null;
|
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GraphProvisioningHud = ({
|
export const GraphProvisioningHud = ({
|
||||||
teamName,
|
teamName,
|
||||||
leadNodeId,
|
|
||||||
getLaunchAnchorScreenPlacement,
|
|
||||||
enabled = true,
|
enabled = true,
|
||||||
}: GraphProvisioningHudProps): React.JSX.Element | null => {
|
}: GraphProvisioningHudProps): React.JSX.Element | null => {
|
||||||
const { presentation, runInstanceKey } = useTeamProvisioningPresentation(teamName);
|
const { presentation, runInstanceKey } = useTeamProvisioningPresentation(teamName);
|
||||||
const shellRef = useRef<HTMLDivElement>(null);
|
|
||||||
const lastActiveStepRef = useRef(-1);
|
const lastActiveStepRef = useRef(-1);
|
||||||
const [detailsOpen, setDetailsOpen] = useState(false);
|
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||||
const [dismissed, setDismissed] = useState(false);
|
const shouldRender = enabled && shouldRenderLaunchHud(presentation);
|
||||||
const shouldRender =
|
|
||||||
enabled && shouldRenderLaunchHud(presentation) && !dismissed && Boolean(leadNodeId);
|
|
||||||
const tone = presentation ? getToneClasses(presentation.compactTone) : null;
|
const tone = presentation ? getToneClasses(presentation.compactTone) : null;
|
||||||
const errorStepIndex = presentation?.isFailed
|
const errorStepIndex = presentation?.isFailed
|
||||||
? lastActiveStepRef.current >= 0
|
? lastActiveStepRef.current >= 0
|
||||||
|
|
@ -109,63 +100,21 @@ export const GraphProvisioningHud = ({
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDetailsOpen(false);
|
setDetailsOpen(false);
|
||||||
setDismissed(false);
|
|
||||||
lastActiveStepRef.current = -1;
|
lastActiveStepRef.current = -1;
|
||||||
}, [runInstanceKey, teamName]);
|
}, [runInstanceKey, teamName]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!shouldRender || !leadNodeId) {
|
|
||||||
setDetailsOpen(false);
|
|
||||||
}
|
|
||||||
}, [leadNodeId, shouldRender]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (presentation && !presentation.isFailed && presentation.currentStepIndex >= 0) {
|
if (presentation && !presentation.isFailed && presentation.currentStepIndex >= 0) {
|
||||||
lastActiveStepRef.current = presentation.currentStepIndex;
|
lastActiveStepRef.current = presentation.currentStepIndex;
|
||||||
}
|
}
|
||||||
}, [presentation]);
|
}, [presentation]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
if (!shouldRender || !leadNodeId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let frameId = 0;
|
|
||||||
const updatePosition = (): void => {
|
|
||||||
const shell = shellRef.current;
|
|
||||||
if (!shell) {
|
|
||||||
frameId = window.requestAnimationFrame(updatePosition);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const placement = getLaunchAnchorScreenPlacement(leadNodeId);
|
|
||||||
if (!placement) {
|
|
||||||
shell.style.opacity = '0';
|
|
||||||
frameId = window.requestAnimationFrame(updatePosition);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!placement.visible) {
|
|
||||||
shell.style.opacity = '0';
|
|
||||||
frameId = window.requestAnimationFrame(updatePosition);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
shell.style.opacity = '1';
|
|
||||||
shell.style.transform = `translate(${Math.round(placement.x)}px, ${Math.round(placement.y)}px) scale(${placement.scale.toFixed(3)})`;
|
|
||||||
frameId = window.requestAnimationFrame(updatePosition);
|
|
||||||
};
|
|
||||||
|
|
||||||
updatePosition();
|
|
||||||
return () => {
|
|
||||||
window.cancelAnimationFrame(frameId);
|
|
||||||
};
|
|
||||||
}, [getLaunchAnchorScreenPlacement, leadNodeId, shouldRender]);
|
|
||||||
|
|
||||||
const compactLabel = useMemo(() => {
|
const compactLabel = useMemo(() => {
|
||||||
if (!presentation?.compactDetail) {
|
if (!presentation?.compactDetail) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return presentation.compactDetail.length > 88
|
return presentation.compactDetail.length > 54
|
||||||
? `${presentation.compactDetail.slice(0, 88)}...`
|
? `${presentation.compactDetail.slice(0, 54)}...`
|
||||||
: presentation.compactDetail;
|
: presentation.compactDetail;
|
||||||
}, [presentation?.compactDetail]);
|
}, [presentation?.compactDetail]);
|
||||||
|
|
||||||
|
|
@ -174,21 +123,21 @@ export const GraphProvisioningHud = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<>
|
||||||
ref={shellRef}
|
<button
|
||||||
className="pointer-events-auto absolute z-10 w-[336px] origin-top-left opacity-0 transition-opacity"
|
type="button"
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded-xl border p-3 text-slate-100 shadow-[0_18px_48px_rgba(5,5,16,0.38)] backdrop-blur-xl',
|
'w-full rounded-xl border px-3 py-2 text-left text-slate-100 shadow-[0_14px_34px_rgba(5,5,16,0.24)] backdrop-blur-xl transition-colors hover:bg-[rgba(12,18,32,0.96)]',
|
||||||
tone.border
|
tone.border
|
||||||
)}
|
)}
|
||||||
|
onClick={() => setDetailsOpen(true)}
|
||||||
|
aria-label="Open launch details"
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<div className="min-w-0">
|
<span className={cn('shrink-0', tone.iconClassName)}>{tone.icon}</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="min-w-0 flex-1">
|
||||||
<span className={cn('shrink-0', tone.iconClassName)}>{tone.icon}</span>
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<div className="truncate text-sm font-semibold text-slate-50">
|
<div className="truncate text-[11px] font-semibold text-slate-50">
|
||||||
{presentation.compactTitle}
|
{presentation.compactTitle}
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="outline" className={cn('px-1.5 py-0 text-[10px]', tone.badge)}>
|
<Badge variant="outline" className={cn('px-1.5 py-0 text-[10px]', tone.badge)}>
|
||||||
|
|
@ -202,35 +151,16 @@ export const GraphProvisioningHud = ({
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
{compactLabel ? (
|
{compactLabel ? (
|
||||||
<div className="mt-1 text-[11px] leading-5 text-slate-300">{compactLabel}</div>
|
<div className="mt-0.5 truncate text-[10px] leading-4 text-slate-300">
|
||||||
|
{compactLabel}
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="inline-flex size-7 items-center justify-center rounded-md border border-white/10 bg-white/5 text-slate-400 transition-colors hover:bg-white/10 hover:text-slate-100"
|
|
||||||
onClick={() => setDetailsOpen(true)}
|
|
||||||
aria-label="Open launch details"
|
|
||||||
>
|
|
||||||
<ExternalLink size={14} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="inline-flex size-7 items-center justify-center rounded-md border border-white/10 bg-white/5 text-slate-400 transition-colors hover:bg-white/10 hover:text-slate-100"
|
|
||||||
onClick={() => setDismissed(true)}
|
|
||||||
aria-label="Dismiss launch overlay"
|
|
||||||
>
|
|
||||||
<X size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<div
|
||||||
type="button"
|
className="border-cyan-300/12 mt-2 overflow-hidden rounded-lg border bg-[rgba(4,10,20,0.58)] px-2 py-1.5"
|
||||||
className="border-cyan-300/12 mt-3 w-full rounded-xl border bg-[rgba(4,10,20,0.58)] p-3 text-left transition-colors hover:bg-[rgba(8,18,32,0.76)]"
|
|
||||||
style={HUD_STEPPER_STYLE}
|
style={HUD_STEPPER_STYLE}
|
||||||
onClick={() => setDetailsOpen(true)}
|
|
||||||
aria-label="Open full launch details"
|
|
||||||
>
|
>
|
||||||
<StepProgressBar
|
<StepProgressBar
|
||||||
steps={MINI_STEPS}
|
steps={MINI_STEPS}
|
||||||
|
|
@ -238,8 +168,8 @@ export const GraphProvisioningHud = ({
|
||||||
errorIndex={errorStepIndex}
|
errorIndex={errorStepIndex}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
</button>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
|
|
||||||
<Dialog open={detailsOpen} onOpenChange={setDetailsOpen}>
|
<Dialog open={detailsOpen} onOpenChange={setDetailsOpen}>
|
||||||
<DialogContent className="w-[min(1120px,92vw)] max-w-5xl p-0">
|
<DialogContent className="w-[min(1120px,92vw)] max-w-5xl p-0">
|
||||||
|
|
@ -254,6 +184,6 @@ export const GraphProvisioningHud = ({
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -58,10 +58,6 @@ export const TeamGraphOverlay = ({
|
||||||
const { dialog: createTaskDialog, openCreateTaskDialog } = useGraphCreateTaskDialog(teamName);
|
const { dialog: createTaskDialog, openCreateTaskDialog } = useGraphCreateTaskDialog(teamName);
|
||||||
const effectiveSidebarVisible = sidebarVisible ?? persistedSidebarVisible;
|
const effectiveSidebarVisible = sidebarVisible ?? persistedSidebarVisible;
|
||||||
const handleToggleSidebar = onToggleSidebar ?? toggleSidebarVisible;
|
const handleToggleSidebar = onToggleSidebar ?? toggleSidebarVisible;
|
||||||
const leadNodeId = useMemo(
|
|
||||||
() => graphData.nodes.find((node) => node.kind === 'lead')?.id ?? null,
|
|
||||||
[graphData.nodes]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Task action dispatchers (same pattern as TeamGraphTab)
|
// Task action dispatchers (same pattern as TeamGraphTab)
|
||||||
const dispatchTaskAction = useCallback(
|
const dispatchTaskAction = useCallback(
|
||||||
|
|
@ -126,6 +122,7 @@ export const TeamGraphOverlay = ({
|
||||||
onCreateTask={openCreateTask}
|
onCreateTask={openCreateTask}
|
||||||
onToggleSidebar={handleToggleSidebar}
|
onToggleSidebar={handleToggleSidebar}
|
||||||
isSidebarVisible={effectiveSidebarVisible}
|
isSidebarVisible={effectiveSidebarVisible}
|
||||||
|
renderTopToolbarContent={() => <GraphProvisioningHud teamName={teamName} />}
|
||||||
onOwnerSlotDrop={commitOwnerSlotDrop}
|
onOwnerSlotDrop={commitOwnerSlotDrop}
|
||||||
className="team-graph-view min-w-0 flex-1"
|
className="team-graph-view min-w-0 flex-1"
|
||||||
renderHud={(hudProps) => {
|
renderHud={(hudProps) => {
|
||||||
|
|
@ -143,7 +140,7 @@ export const TeamGraphOverlay = ({
|
||||||
worldToScreen?: (x: number, y: number) => { x: number; y: number };
|
worldToScreen?: (x: number, y: number) => { x: number; y: number };
|
||||||
getNodeWorldPosition?: (nodeId: string) => { x: number; y: number } | null;
|
getNodeWorldPosition?: (nodeId: string) => { x: number; y: number } | null;
|
||||||
};
|
};
|
||||||
const { getLaunchAnchorScreenPlacement, getViewportSize, focusNodeIds } = extraHudProps;
|
const { getViewportSize, focusNodeIds } = extraHudProps;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -159,11 +156,6 @@ export const TeamGraphOverlay = ({
|
||||||
onOpenTaskDetail={onOpenTaskDetail}
|
onOpenTaskDetail={onOpenTaskDetail}
|
||||||
onOpenMemberProfile={onOpenMemberProfile}
|
onOpenMemberProfile={onOpenMemberProfile}
|
||||||
/>
|
/>
|
||||||
<GraphProvisioningHud
|
|
||||||
teamName={teamName}
|
|
||||||
leadNodeId={leadNodeId}
|
|
||||||
getLaunchAnchorScreenPlacement={getLaunchAnchorScreenPlacement}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -46,10 +46,6 @@ export const TeamGraphTab = ({
|
||||||
}: TeamGraphTabProps): React.JSX.Element => {
|
}: TeamGraphTabProps): React.JSX.Element => {
|
||||||
const graphData = useTeamGraphAdapter(teamName);
|
const graphData = useTeamGraphAdapter(teamName);
|
||||||
const { openTeamPage, commitOwnerSlotDrop } = useTeamGraphSurfaceActions(teamName);
|
const { openTeamPage, commitOwnerSlotDrop } = useTeamGraphSurfaceActions(teamName);
|
||||||
const leadNodeId = useMemo(
|
|
||||||
() => graphData.nodes.find((node) => node.kind === 'lead')?.id ?? null,
|
|
||||||
[graphData.nodes]
|
|
||||||
);
|
|
||||||
const [fullscreen, setFullscreen] = useState(false);
|
const [fullscreen, setFullscreen] = useState(false);
|
||||||
const { sidebarVisible, toggleSidebarVisible } = useGraphSidebarVisibility();
|
const { sidebarVisible, toggleSidebarVisible } = useGraphSidebarVisibility();
|
||||||
const { dialog: createTaskDialog, openCreateTaskDialog } = useGraphCreateTaskDialog(teamName);
|
const { dialog: createTaskDialog, openCreateTaskDialog } = useGraphCreateTaskDialog(teamName);
|
||||||
|
|
@ -149,6 +145,9 @@ export const TeamGraphTab = ({
|
||||||
onCreateTask={openCreateTask}
|
onCreateTask={openCreateTask}
|
||||||
onToggleSidebar={toggleSidebarVisible}
|
onToggleSidebar={toggleSidebarVisible}
|
||||||
isSidebarVisible={sidebarVisible}
|
isSidebarVisible={sidebarVisible}
|
||||||
|
renderTopToolbarContent={() => (
|
||||||
|
<GraphProvisioningHud teamName={teamName} enabled={isActive} />
|
||||||
|
)}
|
||||||
onOwnerSlotDrop={commitOwnerSlotDrop}
|
onOwnerSlotDrop={commitOwnerSlotDrop}
|
||||||
renderHud={(hudProps) => {
|
renderHud={(hudProps) => {
|
||||||
const extraHudProps = hudProps as typeof hudProps & {
|
const extraHudProps = hudProps as typeof hudProps & {
|
||||||
|
|
@ -165,7 +164,7 @@ export const TeamGraphTab = ({
|
||||||
worldToScreen?: (x: number, y: number) => { x: number; y: number };
|
worldToScreen?: (x: number, y: number) => { x: number; y: number };
|
||||||
getNodeWorldPosition?: (nodeId: string) => { x: number; y: number } | null;
|
getNodeWorldPosition?: (nodeId: string) => { x: number; y: number } | null;
|
||||||
};
|
};
|
||||||
const { getLaunchAnchorScreenPlacement, getViewportSize, focusNodeIds } = extraHudProps;
|
const { getViewportSize, focusNodeIds } = extraHudProps;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -182,12 +181,6 @@ export const TeamGraphTab = ({
|
||||||
onOpenTaskDetail={dispatchOpenTask}
|
onOpenTaskDetail={dispatchOpenTask}
|
||||||
onOpenMemberProfile={dispatchOpenProfile}
|
onOpenMemberProfile={dispatchOpenProfile}
|
||||||
/>
|
/>
|
||||||
<GraphProvisioningHud
|
|
||||||
teamName={teamName}
|
|
||||||
leadNodeId={leadNodeId}
|
|
||||||
getLaunchAnchorScreenPlacement={getLaunchAnchorScreenPlacement}
|
|
||||||
enabled={isActive}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,27 @@ interface RefreshTeamDataOptions {
|
||||||
}
|
}
|
||||||
|
|
||||||
type TeamGraphSlotAssignments = Record<string, GraphOwnerSlotAssignment>;
|
type TeamGraphSlotAssignments = Record<string, GraphOwnerSlotAssignment>;
|
||||||
|
type TeamGraphMemberSeedInput = Pick<TeamData['members'][number], 'name' | 'agentId' | 'removedAt'>;
|
||||||
|
|
||||||
|
const SMALL_TEAM_CARDINAL_SLOT_PRESETS: ReadonlyArray<ReadonlyArray<GraphOwnerSlotAssignment>> = [
|
||||||
|
[],
|
||||||
|
[{ ringIndex: 0, sectorIndex: 0 }],
|
||||||
|
[
|
||||||
|
{ ringIndex: 0, sectorIndex: 5 },
|
||||||
|
{ ringIndex: 0, sectorIndex: 1 },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ ringIndex: 0, sectorIndex: 5 },
|
||||||
|
{ ringIndex: 0, sectorIndex: 1 },
|
||||||
|
{ ringIndex: 0, sectorIndex: 3 },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ ringIndex: 0, sectorIndex: 5 },
|
||||||
|
{ ringIndex: 0, sectorIndex: 1 },
|
||||||
|
{ ringIndex: 0, sectorIndex: 4 },
|
||||||
|
{ ringIndex: 0, sectorIndex: 2 },
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
export function isTeamDataRefreshPending(teamName: string): boolean {
|
export function isTeamDataRefreshPending(teamName: string): boolean {
|
||||||
return (
|
return (
|
||||||
|
|
@ -943,7 +964,7 @@ export function selectTeamDataForName(
|
||||||
|
|
||||||
function migrateStableSlotAssignmentsForMembers(
|
function migrateStableSlotAssignmentsForMembers(
|
||||||
assignments: TeamGraphSlotAssignments | undefined,
|
assignments: TeamGraphSlotAssignments | undefined,
|
||||||
members: readonly Pick<TeamData['members'][number], 'name' | 'agentId'>[]
|
members: readonly TeamGraphMemberSeedInput[]
|
||||||
): { assignments: TeamGraphSlotAssignments; changed: boolean } {
|
): { assignments: TeamGraphSlotAssignments; changed: boolean } {
|
||||||
const nextAssignments: TeamGraphSlotAssignments = { ...(assignments ?? {}) };
|
const nextAssignments: TeamGraphSlotAssignments = { ...(assignments ?? {}) };
|
||||||
let changed = false;
|
let changed = false;
|
||||||
|
|
@ -970,6 +991,36 @@ function migrateStableSlotAssignmentsForMembers(
|
||||||
return { assignments: nextAssignments, changed };
|
return { assignments: nextAssignments, changed };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function seedStableSlotAssignmentsForMembers(
|
||||||
|
assignments: TeamGraphSlotAssignments,
|
||||||
|
members: readonly TeamGraphMemberSeedInput[]
|
||||||
|
): { assignments: TeamGraphSlotAssignments; changed: boolean } {
|
||||||
|
const visibleMembers = members.filter((member) => !member.removedAt);
|
||||||
|
if (visibleMembers.length === 0 || visibleMembers.length > 4) {
|
||||||
|
return { assignments, changed: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleStableOwnerIds = visibleMembers.map((member) => getStableTeamOwnerId(member));
|
||||||
|
const hasAnyVisibleAssignments = visibleStableOwnerIds.some(
|
||||||
|
(stableOwnerId) => assignments[stableOwnerId] != null
|
||||||
|
);
|
||||||
|
if (hasAnyVisibleAssignments) {
|
||||||
|
return { assignments, changed: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const preset = SMALL_TEAM_CARDINAL_SLOT_PRESETS[visibleMembers.length];
|
||||||
|
if (!preset || preset.length !== visibleMembers.length) {
|
||||||
|
return { assignments, changed: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextAssignments: TeamGraphSlotAssignments = { ...assignments };
|
||||||
|
visibleMembers.forEach((member, index) => {
|
||||||
|
nextAssignments[getStableTeamOwnerId(member)] = preset[index]!;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { assignments: nextAssignments, changed: true };
|
||||||
|
}
|
||||||
|
|
||||||
function isVisibleInActiveTeamSurface(
|
function isVisibleInActiveTeamSurface(
|
||||||
state: Pick<AppState, 'paneLayout'>,
|
state: Pick<AppState, 'paneLayout'>,
|
||||||
teamName: string | null | undefined
|
teamName: string | null | undefined
|
||||||
|
|
@ -1077,7 +1128,7 @@ export interface TeamSlice {
|
||||||
clearKanbanFilter: () => void;
|
clearKanbanFilter: () => void;
|
||||||
ensureTeamGraphSlotAssignments: (
|
ensureTeamGraphSlotAssignments: (
|
||||||
teamName: string,
|
teamName: string,
|
||||||
members: readonly Pick<TeamData['members'][number], 'name' | 'agentId'>[]
|
members: readonly TeamGraphMemberSeedInput[]
|
||||||
) => void;
|
) => void;
|
||||||
setTeamGraphOwnerSlotAssignment: (
|
setTeamGraphOwnerSlotAssignment: (
|
||||||
teamName: string,
|
teamName: string,
|
||||||
|
|
@ -1783,10 +1834,11 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
|
|
||||||
const currentAssignments = nextSlotAssignmentsByTeam[teamName];
|
const currentAssignments = nextSlotAssignmentsByTeam[teamName];
|
||||||
const migrated = migrateStableSlotAssignmentsForMembers(currentAssignments, members);
|
const migrated = migrateStableSlotAssignmentsForMembers(currentAssignments, members);
|
||||||
if (migrated.changed) {
|
const seeded = seedStableSlotAssignmentsForMembers(migrated.assignments, members);
|
||||||
|
if (migrated.changed || seeded.changed) {
|
||||||
nextSlotAssignmentsByTeam = {
|
nextSlotAssignmentsByTeam = {
|
||||||
...nextSlotAssignmentsByTeam,
|
...nextSlotAssignmentsByTeam,
|
||||||
[teamName]: migrated.assignments,
|
[teamName]: seeded.assignments,
|
||||||
};
|
};
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,8 +46,6 @@ vi.mock('@renderer/components/team/TeamProvisioningPanel', () => ({
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const placement = { x: 120, y: 80, scale: 1, visible: true };
|
|
||||||
|
|
||||||
describe('GraphProvisioningHud', () => {
|
describe('GraphProvisioningHud', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
document.body.innerHTML = '';
|
document.body.innerHTML = '';
|
||||||
|
|
@ -77,8 +75,6 @@ describe('GraphProvisioningHud', () => {
|
||||||
root.render(
|
root.render(
|
||||||
React.createElement(GraphProvisioningHud, {
|
React.createElement(GraphProvisioningHud, {
|
||||||
teamName: 'northstar-core',
|
teamName: 'northstar-core',
|
||||||
leadNodeId: 'lead:northstar-core',
|
|
||||||
getLaunchAnchorScreenPlacement: () => placement,
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
|
|
@ -117,14 +113,12 @@ describe('GraphProvisioningHud', () => {
|
||||||
root.render(
|
root.render(
|
||||||
React.createElement(GraphProvisioningHud, {
|
React.createElement(GraphProvisioningHud, {
|
||||||
teamName: 'northstar-core',
|
teamName: 'northstar-core',
|
||||||
leadNodeId: 'lead:northstar-core',
|
|
||||||
getLaunchAnchorScreenPlacement: () => placement,
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
});
|
});
|
||||||
|
|
||||||
const openButton = host.querySelector('button[aria-label="Open full launch details"]');
|
const openButton = host.querySelector('button[aria-label="Open launch details"]');
|
||||||
expect(openButton).not.toBeNull();
|
expect(openButton).not.toBeNull();
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
|
|
@ -165,8 +159,6 @@ describe('GraphProvisioningHud', () => {
|
||||||
root.render(
|
root.render(
|
||||||
React.createElement(GraphProvisioningHud, {
|
React.createElement(GraphProvisioningHud, {
|
||||||
teamName: 'northstar-core',
|
teamName: 'northstar-core',
|
||||||
leadNodeId: 'lead:northstar-core',
|
|
||||||
getLaunchAnchorScreenPlacement: () => placement,
|
|
||||||
enabled: false,
|
enabled: false,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -98,7 +98,7 @@ describe('stable slot layout planner', () => {
|
||||||
expect(snapshot).toBeNull();
|
expect(snapshot).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('builds launch and activity geometry around the central lead block', () => {
|
it('builds lead activity inside the same central owner slot topology', () => {
|
||||||
const teamName = 'team-a';
|
const teamName = 'team-a';
|
||||||
const lead = createLead(teamName);
|
const lead = createLead(teamName);
|
||||||
const alice = createMember(teamName, 'agent-alice', 'alice');
|
const alice = createMember(teamName, 'agent-alice', 'alice');
|
||||||
|
|
@ -118,11 +118,13 @@ describe('stable slot layout planner', () => {
|
||||||
|
|
||||||
expect(snapshot).not.toBeNull();
|
expect(snapshot).not.toBeNull();
|
||||||
expect(snapshot?.leadNodeId).toBe(lead.id);
|
expect(snapshot?.leadNodeId).toBe(lead.id);
|
||||||
expect(snapshot?.launchAnchor).not.toBeNull();
|
expect(snapshot?.launchAnchor).toBeNull();
|
||||||
|
expect(snapshot?.leadSlotFrame.ownerId).toBe(lead.id);
|
||||||
expect(snapshot?.memberSlotFrames).toHaveLength(1);
|
expect(snapshot?.memberSlotFrames).toHaveLength(1);
|
||||||
expect(snapshot?.memberSlotFrames[0]?.ownerId).toBe(alice.id);
|
expect(snapshot?.memberSlotFrames[0]?.ownerId).toBe(alice.id);
|
||||||
expect(snapshot?.leadActivityRect.left).toBeLessThan(snapshot?.leadCoreRect.left ?? 0);
|
expect(snapshot?.leadActivityRect.top).toBeGreaterThan(snapshot?.leadCoreRect.bottom ?? 0);
|
||||||
expect(snapshot?.fitBounds.right).toBeGreaterThan(snapshot?.leadCoreRect.right ?? 0);
|
expect(snapshot?.leadSlotFrame.activityColumnRect.left).toBe(snapshot?.leadActivityRect.left);
|
||||||
|
expect(snapshot?.leadSlotFrame.activityColumnRect.top).toBe(snapshot?.leadActivityRect.top);
|
||||||
expect(validateStableSlotLayout(snapshot!)).toEqual({ valid: true });
|
expect(validateStableSlotLayout(snapshot!)).toEqual({ valid: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -309,7 +311,7 @@ describe('stable slot layout planner', () => {
|
||||||
expect(validateStableSlotLayout(invalid).valid).toBe(false);
|
expect(validateStableSlotLayout(invalid).valid).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects member frames that overlap lead activity and launch central collision rects', () => {
|
it('rejects member frames that overlap the lead central reserved block', () => {
|
||||||
const teamName = 'team-central-rects';
|
const teamName = 'team-central-rects';
|
||||||
const lead = createLead(teamName);
|
const lead = createLead(teamName);
|
||||||
const alice = createMember(teamName, 'agent-alice', 'alice');
|
const alice = createMember(teamName, 'agent-alice', 'alice');
|
||||||
|
|
@ -329,27 +331,16 @@ describe('stable slot layout planner', () => {
|
||||||
|
|
||||||
expect(snapshot).not.toBeNull();
|
expect(snapshot).not.toBeNull();
|
||||||
const [frame] = snapshot!.memberSlotFrames;
|
const [frame] = snapshot!.memberSlotFrames;
|
||||||
const overlappingLeadActivity = translateSlotFrame(
|
const overlappingLeadBlock = translateSlotFrame(
|
||||||
frame,
|
frame,
|
||||||
snapshot!.leadActivityRect.left - frame.bounds.left + 1,
|
snapshot!.leadCentralReservedBlock.left - frame.bounds.left + 1,
|
||||||
snapshot!.leadActivityRect.top - frame.bounds.top + 1
|
snapshot!.leadCentralReservedBlock.top - frame.bounds.top + 1
|
||||||
);
|
|
||||||
const overlappingLaunchHud = translateSlotFrame(
|
|
||||||
frame,
|
|
||||||
snapshot!.launchHudRect.left - frame.bounds.left + 1,
|
|
||||||
snapshot!.launchHudRect.top - frame.bounds.top + 1
|
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
validateStableSlotLayout({
|
validateStableSlotLayout({
|
||||||
...snapshot!,
|
...snapshot!,
|
||||||
memberSlotFrames: [overlappingLeadActivity],
|
memberSlotFrames: [overlappingLeadBlock],
|
||||||
}).valid
|
|
||||||
).toBe(false);
|
|
||||||
expect(
|
|
||||||
validateStableSlotLayout({
|
|
||||||
...snapshot!,
|
|
||||||
memberSlotFrames: [overlappingLaunchHud],
|
|
||||||
}).valid
|
}).valid
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
@ -629,6 +620,50 @@ describe('stable slot layout planner', () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('positions lead-owned tasks inside the lead kanban band instead of unassigned', () => {
|
||||||
|
const teamName = 'team-lead-owned-tasks';
|
||||||
|
const lead = createLead(teamName);
|
||||||
|
const alice = createMember(teamName, 'agent-alice', 'alice');
|
||||||
|
const leadTasks = [
|
||||||
|
createTask(teamName, 'lead-a', lead.id, { taskStatus: 'completed' }),
|
||||||
|
createTask(teamName, 'lead-b', lead.id, { taskStatus: 'in_progress' }),
|
||||||
|
];
|
||||||
|
const layout: GraphLayoutPort = {
|
||||||
|
version: 'stable-slots-v1',
|
||||||
|
ownerOrder: [alice.id],
|
||||||
|
slotAssignments: {
|
||||||
|
[alice.id]: { ringIndex: 0, sectorIndex: 1 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const nodes = [lead, alice, ...leadTasks];
|
||||||
|
const snapshot = buildStableSlotLayoutSnapshot({
|
||||||
|
teamName,
|
||||||
|
nodes,
|
||||||
|
layout,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(snapshot).not.toBeNull();
|
||||||
|
expect(snapshot?.unassignedTaskRect).toBeNull();
|
||||||
|
lead.x = snapshot!.leadSlotFrame.ownerX;
|
||||||
|
lead.y = snapshot!.leadSlotFrame.ownerY;
|
||||||
|
alice.x = snapshot!.memberSlotFrames[0]?.ownerX;
|
||||||
|
alice.y = snapshot!.memberSlotFrames[0]?.ownerY;
|
||||||
|
|
||||||
|
KanbanLayoutEngine.layout(nodes, {
|
||||||
|
leadSlotFrame: snapshot!.leadSlotFrame,
|
||||||
|
memberSlotFrames: snapshot!.memberSlotFrames,
|
||||||
|
unassignedTaskRect: snapshot!.unassignedTaskRect,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const task of leadTasks) {
|
||||||
|
expect(task.x).toBeGreaterThanOrEqual(snapshot!.leadSlotFrame.kanbanBandRect.left);
|
||||||
|
expect(task.x).toBeLessThanOrEqual(snapshot!.leadSlotFrame.kanbanBandRect.right);
|
||||||
|
expect(task.y).toBeGreaterThanOrEqual(snapshot!.leadSlotFrame.kanbanBandRect.top);
|
||||||
|
expect(task.y).toBeLessThanOrEqual(snapshot!.leadSlotFrame.kanbanBandRect.bottom);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps the same sector and spills to the next outer ring when the saved slot is already occupied', () => {
|
it('keeps the same sector and spills to the next outer ring when the saved slot is already occupied', () => {
|
||||||
const teamName = 'team-wide-spill';
|
const teamName = 'team-wide-spill';
|
||||||
const lead = createLead(teamName);
|
const lead = createLead(teamName);
|
||||||
|
|
|
||||||
|
|
@ -259,6 +259,48 @@ describe('teamSlice actions', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('seeds first-open cardinal slot defaults for small visible teams with no saved placements', () => {
|
||||||
|
const store = createSliceStore();
|
||||||
|
|
||||||
|
store.getState().ensureTeamGraphSlotAssignments('my-team', [
|
||||||
|
{ name: 'alice', agentId: 'agent-alice' },
|
||||||
|
{ name: 'bob', agentId: 'agent-bob' },
|
||||||
|
{ name: 'tom', agentId: 'agent-tom' },
|
||||||
|
{ name: 'jack', agentId: 'agent-jack' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({
|
||||||
|
'agent-alice': { ringIndex: 0, sectorIndex: 5 },
|
||||||
|
'agent-bob': { ringIndex: 0, sectorIndex: 1 },
|
||||||
|
'agent-tom': { ringIndex: 0, sectorIndex: 4 },
|
||||||
|
'agent-jack': { ringIndex: 0, sectorIndex: 2 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('seeds visible members even when only hidden owners have saved placements', () => {
|
||||||
|
const store = createSliceStore();
|
||||||
|
store.setState({
|
||||||
|
slotLayoutVersion: 'stable-slots-v1',
|
||||||
|
slotAssignmentsByTeam: {
|
||||||
|
'my-team': {
|
||||||
|
'agent-hidden': { ringIndex: 2, sectorIndex: 4 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
store.getState().ensureTeamGraphSlotAssignments('my-team', [
|
||||||
|
{ name: 'hidden', agentId: 'agent-hidden', removedAt: '2026-04-16T08:00:00.000Z' },
|
||||||
|
{ name: 'alice', agentId: 'agent-alice' },
|
||||||
|
{ name: 'bob', agentId: 'agent-bob' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({
|
||||||
|
'agent-hidden': { ringIndex: 2, sectorIndex: 4 },
|
||||||
|
'agent-alice': { ringIndex: 0, sectorIndex: 5 },
|
||||||
|
'agent-bob': { ringIndex: 0, sectorIndex: 1 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('resets stale slot assignments when slot layout version mismatches', () => {
|
it('resets stale slot assignments when slot layout version mismatches', () => {
|
||||||
const store = createSliceStore();
|
const store = createSliceStore();
|
||||||
store.setState({
|
store.setState({
|
||||||
|
|
@ -278,7 +320,11 @@ describe('teamSlice actions', () => {
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(store.getState().slotLayoutVersion).toBe('stable-slots-v1');
|
expect(store.getState().slotLayoutVersion).toBe('stable-slots-v1');
|
||||||
expect(store.getState().slotAssignmentsByTeam).toEqual({});
|
expect(store.getState().slotAssignmentsByTeam).toEqual({
|
||||||
|
'my-team': {
|
||||||
|
'agent-alice': { ringIndex: 0, sectorIndex: 0 },
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps hidden-member slot assignments so the same stable owner can reuse them later', () => {
|
it('keeps hidden-member slot assignments so the same stable owner can reuse them later', () => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue