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(
translatedFrames.map((frame) => [frame.ownerId, frame] as const)
);
const leadFrame = snapshot.leadSlotFrame;
const leadId = snapshot.leadNodeId;
for (const node of nodes) {
if (node.kind === 'lead' && node.id === leadId) {
node.x = 0;
node.y = 0;
node.fx = 0;
node.fy = 0;
node.x = leadFrame.ownerX;
node.y = leadFrame.ownerY;
node.fx = leadFrame.ownerX;
node.fy = leadFrame.ownerY;
node.vx = 0;
node.vy = 0;
continue;
@ -278,9 +279,10 @@ function applySnapshotToNodes(
}
}
positionProcessNodes(nodes, translatedFrames);
positionProcessNodes(nodes, [snapshot.leadSlotFrame, ...translatedFrames]);
KanbanLayoutEngine.layout(nodes, {
memberSlotFrames: translatedFrames,
leadSlotFrame: snapshot.leadSlotFrame,
unassignedTaskRect: snapshot.unassignedTaskRect,
});
positionCrossTeamNodes(nodes, snapshot.fitBounds);
@ -322,16 +324,15 @@ function commitSnapshotGeometry(args: {
activityRectByNodeIdRef.current.clear();
extraWorldBoundsRef.current = snapshotToWorldBounds(snapshot);
if (snapshot.leadNodeId && snapshot.launchAnchor) {
launchAnchorPositionsRef.current.set(snapshot.leadNodeId, snapshot.launchAnchor);
}
for (const frame of getTranslatedMemberFrames(snapshot, dragOwnerPositionsRef.current)) {
activityRectByNodeIdRef.current.set(frame.ownerId, frame.activityColumnRect);
}
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 { KANBAN_ZONE, TASK_PILL } from '../constants/canvas-constants';
import { COLORS } from '../constants/colors';
import { resolveActivityLaneSide } from './activityLane';
import type { SlotFrame, StableRect } from './stableSlots';
/** Column header info for rendering */
@ -49,7 +48,7 @@ export function getOwnerKanbanBaseX(args: {
columnWidth: number;
leadX?: number | null;
}): number {
const { ownerX, ownerKind, activeColumnCount, columnWidth, leadX } = args;
const { ownerX, ownerKind, activeColumnCount, columnWidth } = args;
if (activeColumnCount <= 0) {
return ownerX;
}
@ -58,17 +57,7 @@ export function getOwnerKanbanBaseX(args: {
return ownerX - (activeColumnCount * columnWidth) / 2;
}
const side = resolveActivityLaneSide({
nodeKind: ownerKind,
nodeX: ownerX,
leadX,
});
if (side === 'left') {
return ownerX;
}
return ownerX - (activeColumnCount - 1) * columnWidth;
return ownerX - ((activeColumnCount - 1) * columnWidth) / 2;
}
export class KanbanLayoutEngine {
@ -89,6 +78,7 @@ export class KanbanLayoutEngine {
nodes: GraphNode[],
options?: {
memberSlotFrames?: readonly SlotFrame[];
leadSlotFrame?: SlotFrame | null;
unassignedTaskRect?: StableRect | null;
}
): void {
@ -96,9 +86,12 @@ export class KanbanLayoutEngine {
nodeMap.clear();
for (const n of nodes) nodeMap.set(n.id, n);
const leadX = nodes.find((node) => node.kind === 'lead')?.x ?? null;
const memberSlotFrameByOwnerId = new Map(
const ownerSlotFrameByOwnerId = new Map(
(options?.memberSlotFrames ?? []).map((frame) => [frame.ownerId, frame] as const)
);
if (options?.leadSlotFrame) {
ownerSlotFrameByOwnerId.set(options.leadSlotFrame.ownerId, options.leadSlotFrame);
}
const tasksByOwner = this.#tasksByOwner;
tasksByOwner.clear();
@ -110,10 +103,10 @@ export class KanbanLayoutEngine {
return false;
}
if (owner.kind === 'lead') {
return true;
return ownerSlotFrameByOwnerId.has(ownerId);
}
if (owner.kind === 'member') {
return memberSlotFrameByOwnerId.has(ownerId);
return ownerSlotFrameByOwnerId.has(ownerId);
}
return false;
};
@ -143,7 +136,7 @@ export class KanbanLayoutEngine {
owner,
ownerId,
leadX,
memberSlotFrameByOwnerId.get(ownerId) ?? null
ownerSlotFrameByOwnerId.get(ownerId) ?? null
);
if (zoneInfo) this.zones.push(zoneInfo);
}

View file

@ -1,7 +1,7 @@
import { KANBAN_ZONE, TASK_PILL } from '../constants/canvas-constants';
import type { GraphLayoutPort, GraphNode, GraphOwnerSlotAssignment } from '../ports/types';
import { ACTIVITY_LANE } from './activityLane';
import { LAUNCH_ANCHOR_LAYOUT, type WorldBounds } from './launchAnchor';
import type { WorldBounds } from './launchAnchor';
import {
STABLE_SLOT_GEOMETRY,
STABLE_SLOT_SECTOR_VECTORS,
@ -55,6 +55,7 @@ export interface StableSlotLayoutSnapshot {
teamName: string;
leadNodeId: string | null;
leadCoreRect: StableRect;
leadSlotFrame: SlotFrame;
leadActivityRect: StableRect;
launchHudRect: StableRect;
launchAnchor: { x: number; y: number } | null;
@ -128,27 +129,21 @@ export function buildStableSlotLayoutSnapshot({
return null;
}
const leadCoreRect = createCenteredRect(0, 0, 200, 168);
const leadActivityRect = createRect(
leadCoreRect.left - SLOT_GEOMETRY.centralBlockGap - ACTIVITY_LANE.width,
-SLOT_GEOMETRY.activityColumnHeight / 2,
ACTIVITY_LANE.width,
SLOT_GEOMETRY.activityColumnHeight
const leadCoreRect = createCenteredRect(0, 0, 200, 96);
const leadFootprint = computeOwnerFootprintForOwnerId(nodes, leadNode.id);
const leadSlotFrame = buildSlotFrameAtRadius(
leadFootprint,
{ ringIndex: 0, sectorIndex: 0 },
0
);
const launchHudRect = createRect(
leadCoreRect.right + SLOT_GEOMETRY.centralBlockGap,
-LAUNCH_ANCHOR_LAYOUT.compactHeight / 2,
LAUNCH_ANCHOR_LAYOUT.compactWidth,
LAUNCH_ANCHOR_LAYOUT.compactHeight
);
const leadCentralReservedBlock = unionRects([leadCoreRect, leadActivityRect, launchHudRect]);
const leadActivityRect = leadSlotFrame.activityColumnRect;
const launchHudRect = createRect(leadCoreRect.right, leadCoreRect.top, 0, 0);
const leadCentralReservedBlock = leadSlotFrame.bounds;
const ownerFootprints = computeOwnerFootprints(nodes, layout);
const unassignedTaskRect = buildUnassignedTaskRect(nodes, leadCentralReservedBlock);
const centralCollisionRects = buildCentralCollisionRects({
leadCoreRect,
leadActivityRect,
launchHudRect,
leadCentralReservedBlock,
unassignedTaskRect,
});
const runtimeCentralExclusion = padRect(
@ -177,12 +172,10 @@ export function buildStableSlotLayoutSnapshot({
teamName,
leadNodeId: leadNode.id,
leadCoreRect,
leadSlotFrame,
leadActivityRect,
launchHudRect,
launchAnchor: {
x: launchHudRect.left + launchHudRect.width / 2,
y: launchHudRect.top + launchHudRect.height / 2,
},
launchAnchor: null,
leadCentralReservedBlock,
runtimeCentralExclusion,
centralCollisionRects,
@ -194,12 +187,10 @@ export function buildStableSlotLayoutSnapshot({
}
function buildCentralCollisionRects(args: {
leadCoreRect: StableRect;
leadActivityRect: StableRect;
launchHudRect: StableRect;
leadCentralReservedBlock: StableRect;
unassignedTaskRect: StableRect | null;
}): StableRect[] {
const rects = [args.leadCoreRect, args.leadActivityRect, args.launchHudRect];
const rects = [args.leadCentralReservedBlock];
if (args.unassignedTaskRect) {
rects.push(args.unassignedTaskRect);
}
@ -253,64 +244,96 @@ export function computeOwnerFootprints(
return [];
}
const taskColumnCount = taskColumnsByOwnerId.get(ownerId)?.size ?? 0;
const kanbanBandWidth =
taskColumnCount <= 1
? TASK_PILL.width
: TASK_PILL.width + (taskColumnCount - 1) * KANBAN_ZONE.columnWidth;
const processCount = processCountByOwnerId.get(ownerId) ?? 0;
const processBandWidth = computeProcessBandWidth(processCount);
const boardBandWidth =
SLOT_GEOMETRY.activityColumnWidth +
SLOT_GEOMETRY.boardColumnGap +
kanbanBandWidth;
const boardBandHeight = Math.max(
SLOT_GEOMETRY.activityColumnHeight,
SLOT_GEOMETRY.kanbanBandHeight
);
const innerContentWidth = Math.max(
SLOT_GEOMETRY.ownerMinWidth,
processBandWidth,
boardBandWidth
);
const slotWidth = innerContentWidth + SLOT_GEOMETRY.memberSlotInnerPadding * 2;
const slotHeight =
SLOT_GEOMETRY.memberSlotInnerPadding * 2 +
SLOT_GEOMETRY.ownerBandHeight +
return [
buildOwnerFootprint({
ownerId,
taskColumnCount: taskColumnsByOwnerId.get(ownerId)?.size ?? 0,
processCount: processCountByOwnerId.get(ownerId) ?? 0,
}),
];
});
}
function computeOwnerFootprintForOwnerId(
nodes: readonly GraphNode[],
ownerId: string
): OwnerFootprint {
const taskColumns = new Set<string>();
let processCount = 0;
for (const node of nodes) {
if (node.kind === 'task' && node.ownerId === ownerId) {
taskColumns.add(resolveTaskColumnKey(node));
}
if (node.kind === 'process' && node.ownerId === ownerId) {
processCount += 1;
}
}
return buildOwnerFootprint({
ownerId,
taskColumnCount: taskColumns.size,
processCount,
});
}
function buildOwnerFootprint(args: {
ownerId: string;
taskColumnCount: number;
processCount: number;
}): OwnerFootprint {
const kanbanBandWidth =
args.taskColumnCount <= 1
? TASK_PILL.width
: TASK_PILL.width + (args.taskColumnCount - 1) * KANBAN_ZONE.columnWidth;
const processBandWidth = computeProcessBandWidth(args.processCount);
const boardBandWidth =
SLOT_GEOMETRY.activityColumnWidth +
SLOT_GEOMETRY.boardColumnGap +
kanbanBandWidth;
const boardBandHeight = Math.max(
SLOT_GEOMETRY.activityColumnHeight,
SLOT_GEOMETRY.kanbanBandHeight
);
const innerContentWidth = Math.max(
SLOT_GEOMETRY.ownerMinWidth,
processBandWidth,
boardBandWidth
);
const slotWidth = innerContentWidth + SLOT_GEOMETRY.memberSlotInnerPadding * 2;
const slotHeight =
SLOT_GEOMETRY.memberSlotInnerPadding * 2 +
SLOT_GEOMETRY.ownerBandHeight +
SLOT_GEOMETRY.ownerToProcessGap +
SLOT_GEOMETRY.processBandHeight +
SLOT_GEOMETRY.processToBoardGap +
boardBandHeight;
const radialDepth = Math.max(
SLOT_GEOMETRY.memberSlotInnerPadding + SLOT_GEOMETRY.ownerBandHeight / 2,
SLOT_GEOMETRY.memberSlotInnerPadding +
SLOT_GEOMETRY.ownerBandHeight / 2 +
SLOT_GEOMETRY.ownerToProcessGap +
SLOT_GEOMETRY.processBandHeight +
SLOT_GEOMETRY.processToBoardGap +
boardBandHeight;
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
);
boardBandHeight
);
return [
{
ownerId,
slotWidth,
slotHeight,
widthBucket: classifyWidthBucket(slotWidth),
radialDepth,
activityColumnWidth: SLOT_GEOMETRY.activityColumnWidth,
activityColumnHeight: SLOT_GEOMETRY.activityColumnHeight,
processBandWidth,
kanbanBandWidth,
kanbanBandHeight: SLOT_GEOMETRY.kanbanBandHeight,
boardBandWidth,
boardBandHeight,
taskColumnCount,
processCount,
} satisfies OwnerFootprint,
];
});
return {
ownerId: args.ownerId,
slotWidth,
slotHeight,
widthBucket: classifyWidthBucket(slotWidth),
radialDepth,
activityColumnWidth: SLOT_GEOMETRY.activityColumnWidth,
activityColumnHeight: SLOT_GEOMETRY.activityColumnHeight,
processBandWidth,
kanbanBandWidth,
kanbanBandHeight: SLOT_GEOMETRY.kanbanBandHeight,
boardBandWidth,
boardBandHeight,
taskColumnCount: args.taskColumnCount,
processCount: args.processCount,
} satisfies OwnerFootprint;
}
export function classifyWidthBucket(width: number): StableSlotWidthBucket {
@ -447,6 +470,11 @@ function validateStaticSnapshotRects(
): StableSlotLayoutValidationResult | null {
const staticRects: [string, StableRect][] = [
['leadCoreRect', snapshot.leadCoreRect],
['leadSlotFrame.bounds', snapshot.leadSlotFrame.bounds],
['leadSlotFrame.boardBandRect', snapshot.leadSlotFrame.boardBandRect],
['leadSlotFrame.activityColumnRect', snapshot.leadSlotFrame.activityColumnRect],
['leadSlotFrame.processBandRect', snapshot.leadSlotFrame.processBandRect],
['leadSlotFrame.kanbanBandRect', snapshot.leadSlotFrame.kanbanBandRect],
['leadActivityRect', snapshot.leadActivityRect],
['launchHudRect', snapshot.launchHudRect],
['leadCentralReservedBlock', snapshot.leadCentralReservedBlock],
@ -477,14 +505,34 @@ function validateStaticSnapshotRects(
function validateLeadSnapshotRects(
snapshot: StableSlotLayoutSnapshot
): StableSlotLayoutValidationResult | null {
const leadFrameValidation = validateSlotFrameGeometry(
snapshot.leadSlotFrame,
snapshot.fitBounds,
`leadSlotFrame(${snapshot.leadSlotFrame.ownerId})`
);
if (leadFrameValidation) {
return leadFrameValidation;
}
if (!rectContainsRect(snapshot.leadCentralReservedBlock, snapshot.leadCoreRect)) {
return { valid: false, reason: 'leadCoreRect must fit inside leadCentralReservedBlock' };
}
if (!rectContainsRect(snapshot.leadCentralReservedBlock, snapshot.leadActivityRect)) {
return { valid: false, reason: 'leadActivityRect must fit inside leadCentralReservedBlock' };
}
if (!rectContainsRect(snapshot.leadCentralReservedBlock, snapshot.launchHudRect)) {
return { valid: false, reason: 'launchHudRect must fit inside leadCentralReservedBlock' };
if (snapshot.leadActivityRect.left !== snapshot.leadSlotFrame.activityColumnRect.left) {
return {
valid: false,
reason: 'leadActivityRect must mirror leadSlotFrame.activityColumnRect',
};
}
if (snapshot.leadActivityRect.top !== snapshot.leadSlotFrame.activityColumnRect.top) {
return {
valid: false,
reason: 'leadActivityRect must mirror leadSlotFrame.activityColumnRect',
};
}
if (!rectContainsRect(snapshot.leadCentralReservedBlock, snapshot.leadSlotFrame.bounds)) {
return { valid: false, reason: 'leadSlotFrame must fit inside leadCentralReservedBlock' };
}
if (!rectContainsRect(snapshot.runtimeCentralExclusion, snapshot.leadCentralReservedBlock)) {
return { valid: false, reason: 'runtimeCentralExclusion must contain leadCentralReservedBlock' };
@ -513,11 +561,13 @@ function validateMemberSlotFrame(
seenOwnerIds: Set<string>,
seenAssignments: Set<string>
): StableSlotLayoutValidationResult | null {
if (!isFiniteRect(frame.bounds)) {
return { valid: false, reason: `slot frame for ${frame.ownerId} contains non-finite bounds` };
}
if (!Number.isFinite(frame.ownerX) || !Number.isFinite(frame.ownerY)) {
return { valid: false, reason: `slot frame for ${frame.ownerId} contains non-finite anchor` };
const geometryValidation = validateSlotFrameGeometry(
frame,
snapshot.fitBounds,
`slot frame for ${frame.ownerId}`
);
if (geometryValidation) {
return geometryValidation;
}
if (seenOwnerIds.has(frame.ownerId)) {
return { valid: false, reason: `duplicate owner frame for ${frame.ownerId}` };
@ -536,41 +586,55 @@ function validateMemberSlotFrame(
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)) {
return { valid: false, reason: `boardBandRect escapes slot bounds for ${frame.ownerId}` };
return { valid: false, reason: `boardBandRect escapes ${label}` };
}
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)) {
return { valid: false, reason: `processBandRect escapes slot bounds for ${frame.ownerId}` };
return { valid: false, reason: `processBandRect escapes ${label}` };
}
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)) {
return {
valid: false,
reason: `activityColumnRect escapes boardBandRect for ${frame.ownerId}`,
reason: `activityColumnRect escapes boardBandRect in ${label}`,
};
}
if (!rectContainsRect(frame.boardBandRect, frame.kanbanBandRect)) {
return {
valid: false,
reason: `kanbanBandRect escapes boardBandRect for ${frame.ownerId}`,
reason: `kanbanBandRect escapes boardBandRect in ${label}`,
};
}
if (rectsOverlap(frame.activityColumnRect, frame.kanbanBandRect)) {
return {
valid: false,
reason: `activityColumnRect overlaps kanbanBandRect for ${frame.ownerId}`,
reason: `activityColumnRect overlaps kanbanBandRect in ${label}`,
};
}
if (!pointInRect(frame.ownerX, frame.ownerY, frame.bounds)) {
return { valid: false, reason: `owner anchor escapes slot bounds for ${frame.ownerId}` };
return { valid: false, reason: `owner anchor escapes ${label}` };
}
if (!rectContainsRect(snapshot.fitBounds, frame.bounds)) {
return { valid: false, reason: `slot frame for ${frame.ownerId} escapes fitBounds` };
if (!rectContainsRect(fitBounds, frame.bounds)) {
return { valid: false, reason: `${label} escapes fitBounds` };
}
return null;

View file

@ -48,6 +48,7 @@ export interface GraphControlsProps {
teamName: string;
teamColor?: string;
isAlive?: boolean;
topToolbarContent?: React.ReactNode;
}
const TOPBAR_BUTTON_SIZE = 25;
@ -67,6 +68,7 @@ export function GraphControls({
onToggleSidebar,
isSidebarVisible = true,
teamColor,
topToolbarContent,
}: GraphControlsProps): React.JSX.Element {
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const settingsRef = useRef<HTMLDivElement>(null);
@ -105,160 +107,170 @@ export function GraphControls({
return (
<>
<div className="absolute left-3 top-3 z-20 flex items-center gap-0.5 pointer-events-none">
{onToggleSidebar ? (
<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 ${nameColor}25`,
}}
>
<ToolbarButton
onClick={onToggleSidebar}
icon={
isSidebarVisible ? (
<PanelLeftClose size={TOPBAR_ICON_SIZE} />
) : (
<PanelLeftOpen size={TOPBAR_ICON_SIZE} />
)
}
toolbar
title={isSidebarVisible ? 'Hide sidebar' : 'Show sidebar'}
/>
</div>
) : null}
{onOpenTeamPage ? (
<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 ${nameColor}25`,
}}
>
<ToolbarButton
onClick={onOpenTeamPage}
icon={<Users size={TOPBAR_ICON_SIZE} />}
toolbar
title="Open team page"
/>
</div>
) : null}
{onCreateTask ? (
<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 ${nameColor}25`,
}}
>
<ToolbarButton
onClick={onCreateTask}
icon={<Plus size={TOPBAR_ICON_SIZE} />}
toolbar
title="Create task"
/>
</div>
) : null}
</div>
<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 className="absolute inset-x-3 top-3 z-20 flex items-start gap-2 pointer-events-none">
<div className="flex shrink-0 items-center gap-0.5">
{onToggleSidebar ? (
<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 ${nameColor}25`,
}}
>
<ToolbarButton
onClick={onToggleSidebar}
icon={
isSidebarVisible ? (
<PanelLeftClose size={TOPBAR_ICON_SIZE} />
) : (
<PanelLeftOpen size={TOPBAR_ICON_SIZE} />
)
}
toolbar
title={isSidebarVisible ? 'Hide sidebar' : 'Show sidebar'}
/>
</div>
) : null}
{onOpenTeamPage ? (
<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 ${nameColor}25`,
}}
>
<ToolbarButton
onClick={onOpenTeamPage}
icon={<Users size={TOPBAR_ICON_SIZE} />}
toolbar
title="Open team page"
/>
</div>
) : null}
{onCreateTask ? (
<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 ${nameColor}25`,
}}
>
<ToolbarButton
onClick={onCreateTask}
icon={<Plus size={TOPBAR_ICON_SIZE} />}
toolbar
title="Create task"
/>
</div>
) : null}
</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
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={{
background: 'rgba(8, 12, 24, 0.8)',
border: '1px solid rgba(100, 200, 255, 0.08)',
}}
>
<ToolbarButton
onClick={() => setIsSettingsOpen((value) => !value)}
icon={<Settings2 size={TOPBAR_ICON_SIZE} />}
active={isSettingsOpen}
onClick={() => toggle('paused')}
icon={filters.paused ? <Play size={TOPBAR_ICON_SIZE} /> : <Pause size={TOPBAR_ICON_SIZE} />}
toolbar
title="Graph settings"
title={filters.paused ? 'Resume animation' : 'Pause animation'}
/>
</div>
{isSettingsOpen && (
<div ref={settingsRef} className="relative pointer-events-auto">
<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={{
background: 'rgba(8, 12, 24, 0.96)',
border: '1px solid rgba(100, 200, 255, 0.12)',
background: 'rgba(8, 12, 24, 0.8)',
border: '1px solid rgba(100, 200, 255, 0.08)',
}}
>
<ToolbarToggle
active={filters.showTasks}
onClick={() => toggle('showTasks')}
icon={<Columns3 size={13} />}
label="Tasks"
block
/>
<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
<ToolbarButton
onClick={() => setIsSettingsOpen((value) => !value)}
icon={<Settings2 size={TOPBAR_ICON_SIZE} />}
active={isSettingsOpen}
toolbar
title="Graph settings"
/>
</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"
/>
)}
{isSettingsOpen && (
<div
className="absolute right-0 top-[calc(100%+0.5rem)] w-44 rounded-xl p-1.5 shadow-2xl"
style={{
background: 'rgba(8, 12, 24, 0.96)',
border: '1px solid rgba(100, 200, 255, 0.12)',
}}
>
<ToolbarToggle
active={filters.showTasks}
onClick={() => toggle('showTasks')}
icon={<Columns3 size={13} />}
label="Tasks"
block
/>
<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
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>

View file

@ -47,6 +47,7 @@ export interface GraphViewProps {
onCreateTask?: () => void;
onToggleSidebar?: () => void;
isSidebarVisible?: boolean;
renderTopToolbarContent?: () => React.ReactNode;
onOwnerSlotDrop?: (payload: {
nodeId: string;
assignment: GraphOwnerSlotAssignment;
@ -92,6 +93,7 @@ export function GraphView({
onCreateTask,
onToggleSidebar,
isSidebarVisible = true,
renderTopToolbarContent,
onOwnerSlotDrop,
renderOverlay,
renderEdgeOverlay,
@ -748,6 +750,7 @@ export function GraphView({
teamName={data.teamName}
teamColor={data.teamColor}
isAlive={data.isAlive}
topToolbarContent={renderTopToolbarContent?.()}
/>
{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 { StepProgressBar } from '@renderer/components/team/StepProgressBar';
@ -13,7 +13,7 @@ import {
DialogTitle,
} from '@renderer/components/ui/dialog';
import { cn } from '@renderer/lib/utils';
import { AlertTriangle, CheckCircle2, ExternalLink, Loader2, X } from 'lucide-react';
import { AlertTriangle, CheckCircle2, Loader2 } from 'lucide-react';
import type { TeamProvisioningPresentation } from '@renderer/utils/teamProvisioningPresentation';
import type { CSSProperties } from 'react';
@ -51,28 +51,28 @@ function getToneClasses(tone: TeamProvisioningPresentation['compactTone']): {
return {
border: 'border-red-400/35 bg-[rgba(26,10,16,0.92)]',
badge: 'border-red-500/30 text-red-300',
icon: <AlertTriangle size={13} />,
icon: <AlertTriangle size={12} />,
iconClassName: 'text-red-400',
};
case 'warning':
return {
border: 'border-amber-400/35 bg-[rgba(31,18,8,0.92)]',
badge: 'border-amber-500/30 text-amber-200',
icon: <AlertTriangle size={13} />,
icon: <AlertTriangle size={12} />,
iconClassName: 'text-amber-400',
};
case 'success':
return {
border: 'border-emerald-400/35 bg-[rgba(8,24,18,0.92)]',
badge: 'border-emerald-500/30 text-emerald-200',
icon: <CheckCircle2 size={13} />,
icon: <CheckCircle2 size={12} />,
iconClassName: 'text-emerald-400',
};
default:
return {
border: 'border-cyan-400/25 bg-[rgba(8,14,26,0.92)]',
badge: 'border-cyan-500/20 text-cyan-200',
icon: <Loader2 size={13} className="animate-spin" />,
icon: <Loader2 size={12} className="animate-spin" />,
iconClassName: 'text-cyan-300',
};
}
@ -80,26 +80,17 @@ function getToneClasses(tone: TeamProvisioningPresentation['compactTone']): {
export interface GraphProvisioningHudProps {
teamName: string;
leadNodeId: string | null;
getLaunchAnchorScreenPlacement: (
leadNodeId: string
) => { x: number; y: number; scale: number; visible: boolean } | null;
enabled?: boolean;
}
export const GraphProvisioningHud = ({
teamName,
leadNodeId,
getLaunchAnchorScreenPlacement,
enabled = true,
}: GraphProvisioningHudProps): React.JSX.Element | null => {
const { presentation, runInstanceKey } = useTeamProvisioningPresentation(teamName);
const shellRef = useRef<HTMLDivElement>(null);
const lastActiveStepRef = useRef(-1);
const [detailsOpen, setDetailsOpen] = useState(false);
const [dismissed, setDismissed] = useState(false);
const shouldRender =
enabled && shouldRenderLaunchHud(presentation) && !dismissed && Boolean(leadNodeId);
const shouldRender = enabled && shouldRenderLaunchHud(presentation);
const tone = presentation ? getToneClasses(presentation.compactTone) : null;
const errorStepIndex = presentation?.isFailed
? lastActiveStepRef.current >= 0
@ -109,63 +100,21 @@ export const GraphProvisioningHud = ({
useEffect(() => {
setDetailsOpen(false);
setDismissed(false);
lastActiveStepRef.current = -1;
}, [runInstanceKey, teamName]);
useEffect(() => {
if (!shouldRender || !leadNodeId) {
setDetailsOpen(false);
}
}, [leadNodeId, shouldRender]);
useEffect(() => {
if (presentation && !presentation.isFailed && presentation.currentStepIndex >= 0) {
lastActiveStepRef.current = presentation.currentStepIndex;
}
}, [presentation]);
useLayoutEffect(() => {
if (!shouldRender || !leadNodeId) {
return;
}
let frameId = 0;
const updatePosition = (): void => {
const shell = shellRef.current;
if (!shell) {
frameId = window.requestAnimationFrame(updatePosition);
return;
}
const placement = getLaunchAnchorScreenPlacement(leadNodeId);
if (!placement) {
shell.style.opacity = '0';
frameId = window.requestAnimationFrame(updatePosition);
return;
}
if (!placement.visible) {
shell.style.opacity = '0';
frameId = window.requestAnimationFrame(updatePosition);
return;
}
shell.style.opacity = '1';
shell.style.transform = `translate(${Math.round(placement.x)}px, ${Math.round(placement.y)}px) scale(${placement.scale.toFixed(3)})`;
frameId = window.requestAnimationFrame(updatePosition);
};
updatePosition();
return () => {
window.cancelAnimationFrame(frameId);
};
}, [getLaunchAnchorScreenPlacement, leadNodeId, shouldRender]);
const compactLabel = useMemo(() => {
if (!presentation?.compactDetail) {
return null;
}
return presentation.compactDetail.length > 88
? `${presentation.compactDetail.slice(0, 88)}...`
return presentation.compactDetail.length > 54
? `${presentation.compactDetail.slice(0, 54)}...`
: presentation.compactDetail;
}, [presentation?.compactDetail]);
@ -174,21 +123,21 @@ export const GraphProvisioningHud = ({
}
return (
<div
ref={shellRef}
className="pointer-events-auto absolute z-10 w-[336px] origin-top-left opacity-0 transition-opacity"
>
<div
<>
<button
type="button"
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
)}
onClick={() => setDetailsOpen(true)}
aria-label="Open launch details"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className={cn('shrink-0', tone.iconClassName)}>{tone.icon}</span>
<div className="truncate text-sm font-semibold text-slate-50">
<div className="flex min-w-0 items-center gap-2">
<span className={cn('shrink-0', tone.iconClassName)}>{tone.icon}</span>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<div className="truncate text-[11px] font-semibold text-slate-50">
{presentation.compactTitle}
</div>
<Badge variant="outline" className={cn('px-1.5 py-0 text-[10px]', tone.badge)}>
@ -202,35 +151,16 @@ export const GraphProvisioningHud = ({
</Badge>
</div>
{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}
</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>
<button
type="button"
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)]"
<div
className="border-cyan-300/12 mt-2 overflow-hidden rounded-lg border bg-[rgba(4,10,20,0.58)] px-2 py-1.5"
style={HUD_STEPPER_STYLE}
onClick={() => setDetailsOpen(true)}
aria-label="Open full launch details"
>
<StepProgressBar
steps={MINI_STEPS}
@ -238,8 +168,8 @@ export const GraphProvisioningHud = ({
errorIndex={errorStepIndex}
className="w-full"
/>
</button>
</div>
</div>
</button>
<Dialog open={detailsOpen} onOpenChange={setDetailsOpen}>
<DialogContent className="w-[min(1120px,92vw)] max-w-5xl p-0">
@ -254,6 +184,6 @@ export const GraphProvisioningHud = ({
</div>
</DialogContent>
</Dialog>
</div>
</>
);
};

View file

@ -58,10 +58,6 @@ export const TeamGraphOverlay = ({
const { dialog: createTaskDialog, openCreateTaskDialog } = useGraphCreateTaskDialog(teamName);
const effectiveSidebarVisible = sidebarVisible ?? persistedSidebarVisible;
const handleToggleSidebar = onToggleSidebar ?? toggleSidebarVisible;
const leadNodeId = useMemo(
() => graphData.nodes.find((node) => node.kind === 'lead')?.id ?? null,
[graphData.nodes]
);
// Task action dispatchers (same pattern as TeamGraphTab)
const dispatchTaskAction = useCallback(
@ -126,6 +122,7 @@ export const TeamGraphOverlay = ({
onCreateTask={openCreateTask}
onToggleSidebar={handleToggleSidebar}
isSidebarVisible={effectiveSidebarVisible}
renderTopToolbarContent={() => <GraphProvisioningHud teamName={teamName} />}
onOwnerSlotDrop={commitOwnerSlotDrop}
className="team-graph-view min-w-0 flex-1"
renderHud={(hudProps) => {
@ -143,7 +140,7 @@ export const TeamGraphOverlay = ({
worldToScreen?: (x: number, y: number) => { x: number; y: number };
getNodeWorldPosition?: (nodeId: string) => { x: number; y: number } | null;
};
const { getLaunchAnchorScreenPlacement, getViewportSize, focusNodeIds } = extraHudProps;
const { getViewportSize, focusNodeIds } = extraHudProps;
return (
<>
@ -159,11 +156,6 @@ export const TeamGraphOverlay = ({
onOpenTaskDetail={onOpenTaskDetail}
onOpenMemberProfile={onOpenMemberProfile}
/>
<GraphProvisioningHud
teamName={teamName}
leadNodeId={leadNodeId}
getLaunchAnchorScreenPlacement={getLaunchAnchorScreenPlacement}
/>
</>
);
}}

View file

@ -46,10 +46,6 @@ export const TeamGraphTab = ({
}: TeamGraphTabProps): React.JSX.Element => {
const graphData = useTeamGraphAdapter(teamName);
const { openTeamPage, commitOwnerSlotDrop } = useTeamGraphSurfaceActions(teamName);
const leadNodeId = useMemo(
() => graphData.nodes.find((node) => node.kind === 'lead')?.id ?? null,
[graphData.nodes]
);
const [fullscreen, setFullscreen] = useState(false);
const { sidebarVisible, toggleSidebarVisible } = useGraphSidebarVisibility();
const { dialog: createTaskDialog, openCreateTaskDialog } = useGraphCreateTaskDialog(teamName);
@ -149,6 +145,9 @@ export const TeamGraphTab = ({
onCreateTask={openCreateTask}
onToggleSidebar={toggleSidebarVisible}
isSidebarVisible={sidebarVisible}
renderTopToolbarContent={() => (
<GraphProvisioningHud teamName={teamName} enabled={isActive} />
)}
onOwnerSlotDrop={commitOwnerSlotDrop}
renderHud={(hudProps) => {
const extraHudProps = hudProps as typeof hudProps & {
@ -165,7 +164,7 @@ export const TeamGraphTab = ({
worldToScreen?: (x: number, y: number) => { x: number; y: number };
getNodeWorldPosition?: (nodeId: string) => { x: number; y: number } | null;
};
const { getLaunchAnchorScreenPlacement, getViewportSize, focusNodeIds } = extraHudProps;
const { getViewportSize, focusNodeIds } = extraHudProps;
return (
<>
@ -182,12 +181,6 @@ export const TeamGraphTab = ({
onOpenTaskDetail={dispatchOpenTask}
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 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 {
return (
@ -943,7 +964,7 @@ export function selectTeamDataForName(
function migrateStableSlotAssignmentsForMembers(
assignments: TeamGraphSlotAssignments | undefined,
members: readonly Pick<TeamData['members'][number], 'name' | 'agentId'>[]
members: readonly TeamGraphMemberSeedInput[]
): { assignments: TeamGraphSlotAssignments; changed: boolean } {
const nextAssignments: TeamGraphSlotAssignments = { ...(assignments ?? {}) };
let changed = false;
@ -970,6 +991,36 @@ function migrateStableSlotAssignmentsForMembers(
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(
state: Pick<AppState, 'paneLayout'>,
teamName: string | null | undefined
@ -1077,7 +1128,7 @@ export interface TeamSlice {
clearKanbanFilter: () => void;
ensureTeamGraphSlotAssignments: (
teamName: string,
members: readonly Pick<TeamData['members'][number], 'name' | 'agentId'>[]
members: readonly TeamGraphMemberSeedInput[]
) => void;
setTeamGraphOwnerSlotAssignment: (
teamName: string,
@ -1783,10 +1834,11 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
const currentAssignments = nextSlotAssignmentsByTeam[teamName];
const migrated = migrateStableSlotAssignmentsForMembers(currentAssignments, members);
if (migrated.changed) {
const seeded = seedStableSlotAssignmentsForMembers(migrated.assignments, members);
if (migrated.changed || seeded.changed) {
nextSlotAssignmentsByTeam = {
...nextSlotAssignmentsByTeam,
[teamName]: migrated.assignments,
[teamName]: seeded.assignments,
};
changed = true;
}

View file

@ -46,8 +46,6 @@ vi.mock('@renderer/components/team/TeamProvisioningPanel', () => ({
),
}));
const placement = { x: 120, y: 80, scale: 1, visible: true };
describe('GraphProvisioningHud', () => {
afterEach(() => {
document.body.innerHTML = '';
@ -77,8 +75,6 @@ describe('GraphProvisioningHud', () => {
root.render(
React.createElement(GraphProvisioningHud, {
teamName: 'northstar-core',
leadNodeId: 'lead:northstar-core',
getLaunchAnchorScreenPlacement: () => placement,
})
);
await Promise.resolve();
@ -117,14 +113,12 @@ describe('GraphProvisioningHud', () => {
root.render(
React.createElement(GraphProvisioningHud, {
teamName: 'northstar-core',
leadNodeId: 'lead:northstar-core',
getLaunchAnchorScreenPlacement: () => placement,
})
);
await Promise.resolve();
});
const openButton = host.querySelector('button[aria-label="Open full launch details"]');
const openButton = host.querySelector('button[aria-label="Open launch details"]');
expect(openButton).not.toBeNull();
await act(async () => {
@ -165,8 +159,6 @@ describe('GraphProvisioningHud', () => {
root.render(
React.createElement(GraphProvisioningHud, {
teamName: 'northstar-core',
leadNodeId: 'lead:northstar-core',
getLaunchAnchorScreenPlacement: () => placement,
enabled: false,
})
);

View file

@ -98,7 +98,7 @@ describe('stable slot layout planner', () => {
expect(snapshot).toBeNull();
});
it('builds launch and activity geometry around the central lead block', () => {
it('builds lead activity inside the same central owner slot topology', () => {
const teamName = 'team-a';
const lead = createLead(teamName);
const alice = createMember(teamName, 'agent-alice', 'alice');
@ -118,11 +118,13 @@ describe('stable slot layout planner', () => {
expect(snapshot).not.toBeNull();
expect(snapshot?.leadNodeId).toBe(lead.id);
expect(snapshot?.launchAnchor).not.toBeNull();
expect(snapshot?.launchAnchor).toBeNull();
expect(snapshot?.leadSlotFrame.ownerId).toBe(lead.id);
expect(snapshot?.memberSlotFrames).toHaveLength(1);
expect(snapshot?.memberSlotFrames[0]?.ownerId).toBe(alice.id);
expect(snapshot?.leadActivityRect.left).toBeLessThan(snapshot?.leadCoreRect.left ?? 0);
expect(snapshot?.fitBounds.right).toBeGreaterThan(snapshot?.leadCoreRect.right ?? 0);
expect(snapshot?.leadActivityRect.top).toBeGreaterThan(snapshot?.leadCoreRect.bottom ?? 0);
expect(snapshot?.leadSlotFrame.activityColumnRect.left).toBe(snapshot?.leadActivityRect.left);
expect(snapshot?.leadSlotFrame.activityColumnRect.top).toBe(snapshot?.leadActivityRect.top);
expect(validateStableSlotLayout(snapshot!)).toEqual({ valid: true });
});
@ -309,7 +311,7 @@ describe('stable slot layout planner', () => {
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 lead = createLead(teamName);
const alice = createMember(teamName, 'agent-alice', 'alice');
@ -329,27 +331,16 @@ describe('stable slot layout planner', () => {
expect(snapshot).not.toBeNull();
const [frame] = snapshot!.memberSlotFrames;
const overlappingLeadActivity = translateSlotFrame(
const overlappingLeadBlock = translateSlotFrame(
frame,
snapshot!.leadActivityRect.left - frame.bounds.left + 1,
snapshot!.leadActivityRect.top - frame.bounds.top + 1
);
const overlappingLaunchHud = translateSlotFrame(
frame,
snapshot!.launchHudRect.left - frame.bounds.left + 1,
snapshot!.launchHudRect.top - frame.bounds.top + 1
snapshot!.leadCentralReservedBlock.left - frame.bounds.left + 1,
snapshot!.leadCentralReservedBlock.top - frame.bounds.top + 1
);
expect(
validateStableSlotLayout({
...snapshot!,
memberSlotFrames: [overlappingLeadActivity],
}).valid
).toBe(false);
expect(
validateStableSlotLayout({
...snapshot!,
memberSlotFrames: [overlappingLaunchHud],
memberSlotFrames: [overlappingLeadBlock],
}).valid
).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', () => {
const teamName = 'team-wide-spill';
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', () => {
const store = createSliceStore();
store.setState({
@ -278,7 +320,11 @@ describe('teamSlice actions', () => {
]);
expect(store.getState().slotLayoutVersion).toBe('stable-slots-v1');
expect(store.getState().slotAssignmentsByTeam).toEqual({});
expect(store.getState().slotAssignmentsByTeam).toEqual({
'my-team': {
'agent-alice': { ringIndex: 0, sectorIndex: 0 },
},
});
});
it('keeps hidden-member slot assignments so the same stable owner can reuse them later', () => {