feat(agent-graph): unify lead slot layout defaults

This commit is contained in:
777genius 2026-04-16 11:26:30 +03:00
parent d81a45f15b
commit c303a236a5
12 changed files with 523 additions and 410 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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 ? (

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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', () => {