fix(agent-graph): stabilize slot layout interactions
This commit is contained in:
parent
c303a236a5
commit
4630442149
10 changed files with 890 additions and 69 deletions
|
|
@ -38,6 +38,7 @@ export interface UseGraphSimulationResult {
|
|||
tick: (dt: number) => void;
|
||||
setNodePosition: (nodeId: string, x: number, y: number) => void;
|
||||
clearNodePosition: (nodeId: string) => void;
|
||||
clearTransientOwnerPositions: () => void;
|
||||
resolveNearestOwnerSlot: (
|
||||
nodeId: string,
|
||||
x: number,
|
||||
|
|
@ -46,6 +47,8 @@ export interface UseGraphSimulationResult {
|
|||
assignment: GraphOwnerSlotAssignment;
|
||||
displacedOwnerId?: string;
|
||||
displacedAssignment?: GraphOwnerSlotAssignment;
|
||||
previewOwnerX: number;
|
||||
previewOwnerY: number;
|
||||
} | null;
|
||||
getLaunchAnchorWorldPosition: (leadNodeId: string) => { x: number; y: number } | null;
|
||||
getActivityWorldRect: (nodeId: string) => StableRect | null;
|
||||
|
|
@ -199,6 +202,14 @@ export function useGraphSimulation(): UseGraphSimulationResult {
|
|||
[applyCurrentLayout]
|
||||
);
|
||||
|
||||
const clearTransientOwnerPositions = useCallback(() => {
|
||||
if (dragOwnerPositionsRef.current.size === 0) {
|
||||
return;
|
||||
}
|
||||
dragOwnerPositionsRef.current.clear();
|
||||
applyCurrentLayout();
|
||||
}, [applyCurrentLayout]);
|
||||
|
||||
const resolveNearestOwnerSlot = useCallback(
|
||||
(nodeId: string, x: number, y: number) => {
|
||||
const snapshot = layoutSnapshotRef.current;
|
||||
|
|
@ -234,6 +245,7 @@ export function useGraphSimulation(): UseGraphSimulationResult {
|
|||
tick,
|
||||
setNodePosition,
|
||||
clearNodePosition,
|
||||
clearTransientOwnerPositions,
|
||||
resolveNearestOwnerSlot,
|
||||
getLaunchAnchorWorldPosition: (leadNodeId: string) =>
|
||||
launchAnchorPositionsRef.current.get(leadNodeId) ?? null,
|
||||
|
|
|
|||
|
|
@ -77,6 +77,8 @@ interface NearestSlotAssignmentResult {
|
|||
assignment: GraphOwnerSlotAssignment;
|
||||
displacedOwnerId?: string;
|
||||
displacedAssignment?: GraphOwnerSlotAssignment;
|
||||
previewOwnerX: number;
|
||||
previewOwnerY: number;
|
||||
}
|
||||
|
||||
interface RankedNearestSlotAssignmentResult extends NearestSlotAssignmentResult {
|
||||
|
|
@ -116,8 +118,41 @@ const SLOT_GEOMETRY = {
|
|||
const PROCESS_RAIL_NODE_GAP = 42;
|
||||
const PROCESS_RAIL_NODE_FOOTPRINT = 28;
|
||||
const GEOMETRY_EPSILON = 0.001;
|
||||
const SMALL_TEAM_CARDINAL_RADIUS_STEP = 24;
|
||||
|
||||
const SECTOR_VECTORS = STABLE_SLOT_SECTOR_VECTORS;
|
||||
const SMALL_TEAM_CARDINAL_LAYOUTS: ReadonlyArray<
|
||||
ReadonlyArray<{
|
||||
assignment: GraphOwnerSlotAssignment;
|
||||
vector: { x: number; y: number };
|
||||
}>
|
||||
> = [
|
||||
[],
|
||||
[{ assignment: { ringIndex: 0, sectorIndex: 0 }, vector: { x: 0, y: -1 } }],
|
||||
[
|
||||
{ assignment: { ringIndex: 0, sectorIndex: 0 }, vector: { x: -1, y: 0 } },
|
||||
{ assignment: { ringIndex: 0, sectorIndex: 1 }, vector: { x: 1, y: 0 } },
|
||||
],
|
||||
[
|
||||
{ assignment: { ringIndex: 0, sectorIndex: 0 }, vector: { x: 0, y: -1 } },
|
||||
{ assignment: { ringIndex: 0, sectorIndex: 1 }, vector: { x: -1, y: 0 } },
|
||||
{ assignment: { ringIndex: 0, sectorIndex: 2 }, vector: { x: 1, y: 0 } },
|
||||
],
|
||||
[
|
||||
{ assignment: { ringIndex: 0, sectorIndex: 0 }, vector: { x: 0, y: -1 } },
|
||||
{ assignment: { ringIndex: 0, sectorIndex: 1 }, vector: { x: 1, y: 0 } },
|
||||
{ assignment: { ringIndex: 0, sectorIndex: 2 }, vector: { x: 0, y: 1 } },
|
||||
{ assignment: { ringIndex: 0, sectorIndex: 3 }, vector: { x: -1, y: 0 } },
|
||||
],
|
||||
];
|
||||
|
||||
const SMALL_TEAM_CARDINAL_ASSIGNMENTS: ReadonlyArray<ReadonlyArray<GraphOwnerSlotAssignment>> =
|
||||
SMALL_TEAM_CARDINAL_LAYOUTS.map((layout) => layout.map((slot) => slot.assignment));
|
||||
const SMALL_TEAM_CARDINAL_VECTOR_BY_ASSIGNMENT_KEY = new Map(
|
||||
SMALL_TEAM_CARDINAL_LAYOUTS.flatMap((layout) =>
|
||||
layout.map((slot) => [buildAssignmentKey(slot.assignment), slot.vector] as const)
|
||||
)
|
||||
);
|
||||
|
||||
export function buildStableSlotLayoutSnapshot({
|
||||
teamName,
|
||||
|
|
@ -378,6 +413,17 @@ export function resolveNearestSlotAssignment(args: {
|
|||
return null;
|
||||
}
|
||||
|
||||
const strictSmallTeamCandidate = resolveStrictSmallTeamNearestSlotAssignment({
|
||||
ownerId: args.ownerId,
|
||||
ownerX: args.ownerX,
|
||||
ownerY: args.ownerY,
|
||||
currentFrame,
|
||||
snapshot: args.snapshot,
|
||||
});
|
||||
if (strictSmallTeamCandidate) {
|
||||
return strictSmallTeamCandidate;
|
||||
}
|
||||
|
||||
const existingFrames = args.snapshot.memberSlotFrames.filter((frame) => frame.ownerId !== args.ownerId);
|
||||
const maxOccupiedRing = existingFrames.reduce((max, frame) => Math.max(max, frame.ringIndex), 0);
|
||||
const candidateAssignments = buildCandidateAssignments(
|
||||
|
|
@ -423,10 +469,93 @@ export function resolveNearestSlotAssignment(args: {
|
|||
assignment: best.assignment,
|
||||
displacedOwnerId: best.displacedOwnerId,
|
||||
displacedAssignment: best.displacedAssignment,
|
||||
previewOwnerX: best.previewOwnerX,
|
||||
previewOwnerY: best.previewOwnerY,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
function resolveStrictSmallTeamNearestSlotAssignment(args: {
|
||||
ownerId: string;
|
||||
ownerX: number;
|
||||
ownerY: number;
|
||||
currentFrame: SlotFrame;
|
||||
snapshot: StableSlotLayoutSnapshot;
|
||||
}): NearestSlotAssignmentResult | null {
|
||||
const strictFrames = getStrictSmallTeamFrames(args.snapshot.memberSlotFrames);
|
||||
if (!strictFrames) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let best:
|
||||
| {
|
||||
frame: SlotFrame;
|
||||
distanceSquared: number;
|
||||
}
|
||||
| null = null;
|
||||
for (const frame of strictFrames) {
|
||||
const dx = frame.ownerX - args.ownerX;
|
||||
const dy = frame.ownerY - args.ownerY;
|
||||
const distanceSquared = dx * dx + dy * dy;
|
||||
if (!best || distanceSquared < best.distanceSquared) {
|
||||
best = { frame, distanceSquared };
|
||||
}
|
||||
}
|
||||
|
||||
if (!best) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const targetFrame = best.frame;
|
||||
if (targetFrame.ownerId === args.ownerId) {
|
||||
return {
|
||||
assignment: {
|
||||
ringIndex: targetFrame.ringIndex,
|
||||
sectorIndex: targetFrame.sectorIndex,
|
||||
},
|
||||
previewOwnerX: targetFrame.ownerX,
|
||||
previewOwnerY: targetFrame.ownerY,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
assignment: {
|
||||
ringIndex: targetFrame.ringIndex,
|
||||
sectorIndex: targetFrame.sectorIndex,
|
||||
},
|
||||
displacedOwnerId: targetFrame.ownerId,
|
||||
displacedAssignment: {
|
||||
ringIndex: args.currentFrame.ringIndex,
|
||||
sectorIndex: args.currentFrame.sectorIndex,
|
||||
},
|
||||
previewOwnerX: targetFrame.ownerX,
|
||||
previewOwnerY: targetFrame.ownerY,
|
||||
};
|
||||
}
|
||||
|
||||
function getStrictSmallTeamFrames(frames: readonly SlotFrame[]): readonly SlotFrame[] | null {
|
||||
if (frames.length === 0 || frames.length > 4) {
|
||||
return null;
|
||||
}
|
||||
const preset = SMALL_TEAM_CARDINAL_ASSIGNMENTS[frames.length];
|
||||
if (!preset || preset.length !== frames.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const actualAssignmentKeys = frames
|
||||
.map((frame) => buildAssignmentKey({ ringIndex: frame.ringIndex, sectorIndex: frame.sectorIndex }))
|
||||
.sort();
|
||||
const presetAssignmentKeys = preset.map((assignment) => buildAssignmentKey(assignment)).sort();
|
||||
|
||||
for (let index = 0; index < presetAssignmentKeys.length; index += 1) {
|
||||
if (actualAssignmentKeys[index] !== presetAssignmentKeys[index]) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return frames;
|
||||
}
|
||||
|
||||
export function validateStableSlotLayout(
|
||||
snapshot: StableSlotLayoutSnapshot
|
||||
): StableSlotLayoutValidationResult {
|
||||
|
|
@ -730,6 +859,18 @@ function planOwnerSlots(
|
|||
runtimeCentralExclusion: StableRect,
|
||||
layout?: GraphLayoutPort
|
||||
): SlotFrame[] {
|
||||
const strictSmallTeamFrames = shouldUseStrictSmallTeamCardinalLayout(ownerFootprints, layout)
|
||||
? planStrictSmallTeamOwnerSlots(
|
||||
ownerFootprints,
|
||||
centralCollisionRects,
|
||||
runtimeCentralExclusion,
|
||||
layout
|
||||
)
|
||||
: null;
|
||||
if (strictSmallTeamFrames) {
|
||||
return strictSmallTeamFrames;
|
||||
}
|
||||
|
||||
const placedFrames: SlotFrame[] = [];
|
||||
const preferredAssignments = buildPreferredAssignmentsMap(layout?.slotAssignments);
|
||||
const usedSlotKeys = new Set<string>();
|
||||
|
|
@ -754,6 +895,105 @@ function planOwnerSlots(
|
|||
return placedFrames;
|
||||
}
|
||||
|
||||
function shouldUseStrictSmallTeamCardinalLayout(
|
||||
ownerFootprints: readonly OwnerFootprint[],
|
||||
layout?: GraphLayoutPort
|
||||
): boolean {
|
||||
if (ownerFootprints.length === 0 || ownerFootprints.length > 4) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const preset = SMALL_TEAM_CARDINAL_ASSIGNMENTS[ownerFootprints.length];
|
||||
if (!preset || preset.length !== ownerFootprints.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const actualAssignmentKeys = ownerFootprints
|
||||
.map((footprint) => layout?.slotAssignments?.[footprint.ownerId])
|
||||
.filter((assignment): assignment is GraphOwnerSlotAssignment => assignment != null)
|
||||
.map((assignment) => buildAssignmentKey(assignment))
|
||||
.sort();
|
||||
const presetAssignmentKeys = preset.map((assignment) => buildAssignmentKey(assignment)).sort();
|
||||
|
||||
if (actualAssignmentKeys.length !== presetAssignmentKeys.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let index = 0; index < presetAssignmentKeys.length; index += 1) {
|
||||
if (actualAssignmentKeys[index] !== presetAssignmentKeys[index]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function planStrictSmallTeamOwnerSlots(
|
||||
ownerFootprints: readonly OwnerFootprint[],
|
||||
centralCollisionRects: readonly StableRect[],
|
||||
runtimeCentralExclusion: StableRect,
|
||||
layout?: GraphLayoutPort
|
||||
): SlotFrame[] | null {
|
||||
if (ownerFootprints.length === 0 || ownerFootprints.length > 4) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const preset = SMALL_TEAM_CARDINAL_LAYOUTS[ownerFootprints.length];
|
||||
if (!preset || preset.length !== ownerFootprints.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const slotConfigs = ownerFootprints.map((footprint) => {
|
||||
const assignment = layout?.slotAssignments?.[footprint.ownerId];
|
||||
if (!assignment) {
|
||||
return null;
|
||||
}
|
||||
const vector = SMALL_TEAM_CARDINAL_VECTOR_BY_ASSIGNMENT_KEY.get(buildAssignmentKey(assignment));
|
||||
if (!vector) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
footprint,
|
||||
assignment,
|
||||
vector,
|
||||
};
|
||||
});
|
||||
|
||||
if (slotConfigs.some((slot) => slot == null)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let radius = Math.max(
|
||||
...slotConfigs.map((slot) =>
|
||||
resolveMinimumDirectionalRadiusForVector({
|
||||
vector: slot!.vector,
|
||||
footprint: slot!.footprint,
|
||||
centralCollisionRects,
|
||||
runtimeCentralExclusion,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
for (let iteration = 0; iteration < 48; iteration += 1) {
|
||||
const frames = slotConfigs.map((slot) =>
|
||||
buildSlotFrameAtRadiusWithVector(slot!.footprint, slot!.assignment, radius, slot!.vector)
|
||||
);
|
||||
const allValid = frames.every((frame, frameIndex) =>
|
||||
isSlotFramePlacementValid(
|
||||
frame,
|
||||
frames.filter((_, index) => index !== frameIndex),
|
||||
centralCollisionRects
|
||||
)
|
||||
);
|
||||
if (allValid) {
|
||||
return frames;
|
||||
}
|
||||
radius += SMALL_TEAM_CARDINAL_RADIUS_STEP;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildPreferredAssignmentsMap(
|
||||
assignments?: Record<string, GraphOwnerSlotAssignment>
|
||||
): Map<string, GraphOwnerSlotAssignment> {
|
||||
|
|
@ -870,6 +1110,15 @@ function buildSlotFrameAtRadius(
|
|||
radius: number
|
||||
): SlotFrame {
|
||||
const vector = SECTOR_VECTORS[assignment.sectorIndex % SECTOR_VECTORS.length] ?? SECTOR_VECTORS[0];
|
||||
return buildSlotFrameAtRadiusWithVector(footprint, assignment, radius, vector);
|
||||
}
|
||||
|
||||
function buildSlotFrameAtRadiusWithVector(
|
||||
footprint: OwnerFootprint,
|
||||
assignment: GraphOwnerSlotAssignment,
|
||||
radius: number,
|
||||
vector: { x: number; y: number }
|
||||
): SlotFrame {
|
||||
const ownerX = vector.x * radius;
|
||||
const ownerY = vector.y * radius;
|
||||
const slotTop =
|
||||
|
|
@ -1122,6 +1371,8 @@ function buildRankedNearestSlotAssignmentResult(args: {
|
|||
assignment: args.assignment,
|
||||
displacedOwnerId: args.displacedOwnerId,
|
||||
displacedAssignment: args.displacedAssignment,
|
||||
previewOwnerX: args.frame.ownerX,
|
||||
previewOwnerY: args.frame.ownerY,
|
||||
distanceSquared: dx * dx + dy * dy,
|
||||
};
|
||||
}
|
||||
|
|
@ -1377,14 +1628,33 @@ function resolveMinimumDirectionalRadius(args: {
|
|||
footprint: OwnerFootprint;
|
||||
centralCollisionRects: readonly StableRect[];
|
||||
runtimeCentralExclusion: StableRect;
|
||||
}): number {
|
||||
return resolveMinimumDirectionalRadiusForVector({
|
||||
vector: SECTOR_VECTORS[args.assignment.sectorIndex % SECTOR_VECTORS.length] ?? SECTOR_VECTORS[0],
|
||||
footprint: args.footprint,
|
||||
centralCollisionRects: args.centralCollisionRects,
|
||||
runtimeCentralExclusion: args.runtimeCentralExclusion,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveMinimumDirectionalRadiusForVector(args: {
|
||||
vector: { x: number; y: number };
|
||||
footprint: OwnerFootprint;
|
||||
centralCollisionRects: readonly StableRect[];
|
||||
runtimeCentralExclusion: StableRect;
|
||||
}): number {
|
||||
const legacyRadiusHint = computeLegacyMinimumRingRadius(
|
||||
SECTOR_VECTORS[args.assignment.sectorIndex % SECTOR_VECTORS.length] ?? SECTOR_VECTORS[0],
|
||||
args.vector,
|
||||
args.footprint,
|
||||
args.runtimeCentralExclusion
|
||||
);
|
||||
const overlapsCentralCollision = (radius: number): boolean => {
|
||||
const frame = buildSlotFrameAtRadius(args.footprint, args.assignment, radius);
|
||||
const frame = buildSlotFrameAtRadiusWithVector(
|
||||
args.footprint,
|
||||
{ ringIndex: 0, sectorIndex: 0 },
|
||||
radius,
|
||||
args.vector
|
||||
);
|
||||
return rectOverlapsAnyCentralRect(frame.bounds, args.centralCollisionRects);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import { drawAgents, drawCrossTeamNodes } from '../canvas/draw-agents';
|
|||
import { drawTasks, drawColumnHeaders } from '../canvas/draw-tasks';
|
||||
import { drawProcesses } from '../canvas/draw-processes';
|
||||
import { drawEffects, type VisualEffect } from '../canvas/draw-effects';
|
||||
import { drawHexagon } from '../canvas/draw-misc';
|
||||
import { BloomRenderer } from '../canvas/bloom-renderer';
|
||||
import { KanbanLayoutEngine } from '../layout/kanbanLayout';
|
||||
import {
|
||||
|
|
@ -36,6 +37,7 @@ import {
|
|||
updateTransientHandoffState,
|
||||
} from './transientHandoffs';
|
||||
import type { CameraTransform } from '../hooks/useGraphCamera';
|
||||
import { NODE } from '../constants/canvas-constants';
|
||||
|
||||
// ─── Draw State (passed by ref, not by props — no React re-renders) ─────────
|
||||
|
||||
|
|
@ -53,6 +55,14 @@ export interface GraphDrawState {
|
|||
hoveredEdgeId: string | null;
|
||||
focusNodeIds: ReadonlySet<string> | null;
|
||||
focusEdgeIds: ReadonlySet<string> | null;
|
||||
dragPreview:
|
||||
| {
|
||||
nodeId: string;
|
||||
x: number;
|
||||
y: number;
|
||||
color?: string | null;
|
||||
}
|
||||
| null;
|
||||
}
|
||||
|
||||
export interface GraphCanvasHandle {
|
||||
|
|
@ -341,6 +351,9 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(funct
|
|||
state.focusNodeIds,
|
||||
zoom
|
||||
);
|
||||
if (state.dragPreview) {
|
||||
drawOwnerSlotPreview(ctx, state.dragPreview, state.time);
|
||||
}
|
||||
|
||||
// 2d. Effects
|
||||
drawEffects(ctx, state.effects);
|
||||
|
|
@ -437,3 +450,47 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(funct
|
|||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
function drawOwnerSlotPreview(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
preview: NonNullable<GraphDrawState['dragPreview']>,
|
||||
time: number
|
||||
): void {
|
||||
const radius = NODE.radiusMember;
|
||||
const outerRadius = radius + 18;
|
||||
const innerRadius = radius + 8;
|
||||
const glowRadius = radius + 34;
|
||||
const color = preview.color ?? '#8bd3ff';
|
||||
const pulse = 0.35 + 0.15 * Math.sin(time * 6);
|
||||
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.7 + pulse;
|
||||
ctx.setLineDash([8, 6]);
|
||||
ctx.lineDashOffset = -time * 48;
|
||||
ctx.lineWidth = 2.5;
|
||||
|
||||
drawHexagon(ctx, preview.x, preview.y, outerRadius);
|
||||
ctx.strokeStyle = color;
|
||||
ctx.stroke();
|
||||
|
||||
ctx.setLineDash([]);
|
||||
drawHexagon(ctx, preview.x, preview.y, innerRadius);
|
||||
ctx.fillStyle = 'rgba(120, 190, 255, 0.08)';
|
||||
ctx.fill();
|
||||
|
||||
const glow = ctx.createRadialGradient(
|
||||
preview.x,
|
||||
preview.y,
|
||||
radius * 0.45,
|
||||
preview.x,
|
||||
preview.y,
|
||||
glowRadius
|
||||
);
|
||||
glow.addColorStop(0, 'rgba(120, 190, 255, 0.12)');
|
||||
glow.addColorStop(1, 'rgba(120, 190, 255, 0)');
|
||||
ctx.beginPath();
|
||||
ctx.arc(preview.x, preview.y, glowRadius, 0, Math.PI * 2);
|
||||
ctx.fillStyle = glow;
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ export interface GraphViewProps {
|
|||
onRequestClose?: () => void;
|
||||
onRequestPinAsTab?: () => void;
|
||||
onRequestFullscreen?: () => void;
|
||||
isSurfaceActive?: boolean;
|
||||
onOpenTeamPage?: () => void;
|
||||
onCreateTask?: () => void;
|
||||
onToggleSidebar?: () => void;
|
||||
|
|
@ -89,6 +90,7 @@ export function GraphView({
|
|||
onRequestClose,
|
||||
onRequestPinAsTab,
|
||||
onRequestFullscreen,
|
||||
isSurfaceActive = true,
|
||||
onOpenTeamPage,
|
||||
onCreateTask,
|
||||
onToggleSidebar,
|
||||
|
|
@ -127,6 +129,12 @@ export function GraphView({
|
|||
const allowAutoFitRef = useRef(true);
|
||||
const nodeMapRef = useRef(new Map<string, GraphNode>());
|
||||
const nodeMapNodesRef = useRef<GraphNode[] | null>(null);
|
||||
const dragPreviewRef = useRef<{
|
||||
nodeId: string;
|
||||
x: number;
|
||||
y: number;
|
||||
color?: string | null;
|
||||
} | null>(null);
|
||||
|
||||
// ─── Hooks ──────────────────────────────────────────────────────────────
|
||||
const simulation = useGraphSimulation();
|
||||
|
|
@ -284,6 +292,7 @@ export function GraphView({
|
|||
hoveredEdgeId: hoveredEdgeIdRef.current,
|
||||
focusNodeIds: focusState.focusNodeIds,
|
||||
focusEdgeIds: focusState.focusEdgeIds,
|
||||
dragPreview: dragPreviewRef.current,
|
||||
});
|
||||
|
||||
rafRef.current = requestAnimationFrame(animate);
|
||||
|
|
@ -370,6 +379,17 @@ export function GraphView({
|
|||
allowAutoFitRef.current = false;
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isSurfaceActive) {
|
||||
return;
|
||||
}
|
||||
interaction.handleMouseUp();
|
||||
simulation.clearTransientOwnerPositions();
|
||||
dragPreviewRef.current = null;
|
||||
isPanningRef.current = false;
|
||||
edgeMouseDownRef.current = null;
|
||||
}, [interaction, isSurfaceActive, simulation]);
|
||||
|
||||
const handleWheel = useCallback(
|
||||
(e: WheelEvent) => {
|
||||
markUserInteracted();
|
||||
|
|
@ -385,6 +405,7 @@ export function GraphView({
|
|||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (e.button !== 0) return; // only left click
|
||||
dragPreviewRef.current = null;
|
||||
|
||||
const canvas = canvasHandle.current?.getCanvas();
|
||||
if (!canvas) return;
|
||||
|
|
@ -435,59 +456,64 @@ export function GraphView({
|
|||
]
|
||||
);
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
// Dragging with left button held
|
||||
if (e.buttons & 1) {
|
||||
if (isPanningRef.current) {
|
||||
camera.handlePanMove(e.clientX, e.clientY);
|
||||
return;
|
||||
}
|
||||
const canvas = canvasHandle.current?.getCanvas();
|
||||
if (!canvas) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const world = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top);
|
||||
interaction.handleMouseMove(world.x, world.y, getVisibleNodes(simulation.stateRef.current.nodes));
|
||||
return;
|
||||
const processActivePointerMove = useCallback(
|
||||
(clientX: number, clientY: number, buttons: number) => {
|
||||
if ((buttons & 1) === 0) {
|
||||
dragPreviewRef.current = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isPanningRef.current) {
|
||||
camera.handlePanMove(clientX, clientY);
|
||||
return true;
|
||||
}
|
||||
|
||||
// No button held — hover detection + cursor update
|
||||
const canvas = canvasHandle.current?.getCanvas();
|
||||
if (!canvas) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const world = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top);
|
||||
const nodes = getVisibleNodes(simulation.stateRef.current.nodes);
|
||||
const visibleNodeIds = new Set(nodes.map((node) => node.id));
|
||||
const edges = getVisibleEdges(simulation.stateRef.current.edges, visibleNodeIds);
|
||||
|
||||
const hoveredNodeId = findNodeAt(world.x, world.y, nodes);
|
||||
interaction.hoveredNodeId.current = hoveredNodeId;
|
||||
|
||||
if (hoveredNodeId) {
|
||||
hoveredEdgeIdRef.current = null;
|
||||
canvas.style.cursor = 'pointer';
|
||||
return;
|
||||
if (!canvas) {
|
||||
dragPreviewRef.current = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
const nodeMap = getNodeMap(nodes);
|
||||
const interactiveEdges = getInteractiveEdges(canvas, nodes, edges);
|
||||
hoveredEdgeIdRef.current = findEdgeAt(world.x, world.y, interactiveEdges, nodeMap);
|
||||
canvas.style.cursor = hoveredEdgeIdRef.current ? 'pointer' : 'grab';
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const world = camera.screenToWorld(clientX - rect.left, clientY - rect.top);
|
||||
interaction.handleMouseMove(world.x, world.y, getVisibleNodes(simulation.stateRef.current.nodes));
|
||||
|
||||
const draggedNodeId = interaction.dragNodeId.current;
|
||||
if (interaction.isDragging.current && draggedNodeId) {
|
||||
const draggedNode = simulation.stateRef.current.nodes.find((node) => node.id === draggedNodeId);
|
||||
if (draggedNode?.kind === 'member') {
|
||||
const nearest = simulation.resolveNearestOwnerSlot(draggedNodeId, world.x, world.y);
|
||||
if (nearest) {
|
||||
dragPreviewRef.current = {
|
||||
nodeId: draggedNodeId,
|
||||
x: nearest.previewOwnerX,
|
||||
y: nearest.previewOwnerY,
|
||||
color: draggedNode.color,
|
||||
};
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dragPreviewRef.current = null;
|
||||
return true;
|
||||
},
|
||||
[camera, getInteractiveEdges, getNodeMap, getVisibleEdges, getVisibleNodes, interaction, simulation.stateRef]
|
||||
[camera, getVisibleNodes, interaction, simulation]
|
||||
);
|
||||
|
||||
const handleMouseUp = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
const completePointerInteraction = useCallback(
|
||||
(clientX: number, clientY: number) => {
|
||||
const draggedNodeId = interaction.dragNodeId.current;
|
||||
const wasDragging = interaction.isDragging.current;
|
||||
|
||||
if (isPanningRef.current) {
|
||||
camera.handlePanEnd();
|
||||
isPanningRef.current = false;
|
||||
setSelectedNodeId(null); // hide popover after pan
|
||||
dragPreviewRef.current = null;
|
||||
setSelectedNodeId(null);
|
||||
setSelectedEdgeId(null);
|
||||
edgeMouseDownRef.current = null;
|
||||
interaction.handleMouseUp();
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -510,11 +536,13 @@ export function GraphView({
|
|||
requestAnimationFrame(() => {
|
||||
simulation.clearNodePosition(draggedNodeId);
|
||||
});
|
||||
dragPreviewRef.current = null;
|
||||
edgeMouseDownRef.current = null;
|
||||
return;
|
||||
}
|
||||
}
|
||||
simulation.clearNodePosition(draggedNodeId);
|
||||
dragPreviewRef.current = null;
|
||||
edgeMouseDownRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
|
@ -529,7 +557,7 @@ export function GraphView({
|
|||
let clickedEdgeId: string | null = null;
|
||||
if (canvas && edgeMouseDownRef.current && !interaction.isDragging.current) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const world = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top);
|
||||
const world = camera.screenToWorld(clientX - rect.left, clientY - rect.top);
|
||||
const dx = world.x - edgeMouseDownRef.current.x;
|
||||
const dy = world.y - edgeMouseDownRef.current.y;
|
||||
if (dx * dx + dy * dy <= 25) {
|
||||
|
|
@ -548,17 +576,103 @@ export function GraphView({
|
|||
events?.onEdgeClick?.(edge);
|
||||
}
|
||||
} else {
|
||||
setSelectedNodeId(null); // click on empty space — hide popover
|
||||
setSelectedNodeId(null);
|
||||
setSelectedEdgeId(null);
|
||||
}
|
||||
if (!interaction.isDragging.current && !clickedEdgeId) {
|
||||
events?.onBackgroundClick?.();
|
||||
}
|
||||
}
|
||||
dragPreviewRef.current = null;
|
||||
},
|
||||
[camera, data.teamName, events, interaction, onOwnerSlotDrop, simulation]
|
||||
[camera, events, interaction, onOwnerSlotDrop, simulation]
|
||||
);
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (processActivePointerMove(e.clientX, e.clientY, e.buttons)) {
|
||||
return;
|
||||
}
|
||||
|
||||
dragPreviewRef.current = null;
|
||||
|
||||
const canvas = canvasHandle.current?.getCanvas();
|
||||
if (!canvas) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const world = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top);
|
||||
const nodes = getVisibleNodes(simulation.stateRef.current.nodes);
|
||||
const visibleNodeIds = new Set(nodes.map((node) => node.id));
|
||||
const edges = getVisibleEdges(simulation.stateRef.current.edges, visibleNodeIds);
|
||||
|
||||
const hoveredNodeId = findNodeAt(world.x, world.y, nodes);
|
||||
interaction.hoveredNodeId.current = hoveredNodeId;
|
||||
|
||||
if (hoveredNodeId) {
|
||||
hoveredEdgeIdRef.current = null;
|
||||
canvas.style.cursor = 'pointer';
|
||||
return;
|
||||
}
|
||||
|
||||
const nodeMap = getNodeMap(nodes);
|
||||
const interactiveEdges = getInteractiveEdges(canvas, nodes, edges);
|
||||
hoveredEdgeIdRef.current = findEdgeAt(world.x, world.y, interactiveEdges, nodeMap);
|
||||
canvas.style.cursor = hoveredEdgeIdRef.current ? 'pointer' : 'grab';
|
||||
},
|
||||
[
|
||||
camera,
|
||||
getInteractiveEdges,
|
||||
getNodeMap,
|
||||
getVisibleEdges,
|
||||
getVisibleNodes,
|
||||
interaction,
|
||||
processActivePointerMove,
|
||||
simulation.stateRef,
|
||||
]
|
||||
);
|
||||
|
||||
const handleMouseUp = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
completePointerInteraction(e.clientX, e.clientY);
|
||||
},
|
||||
[completePointerInteraction]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleWindowMouseMove = (event: MouseEvent): void => {
|
||||
if ((event.buttons & 1) === 0) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!isPanningRef.current &&
|
||||
!interaction.dragNodeId.current &&
|
||||
!interaction.isDragging.current &&
|
||||
!edgeMouseDownRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
processActivePointerMove(event.clientX, event.clientY, event.buttons);
|
||||
};
|
||||
|
||||
const handleWindowMouseUp = (event: MouseEvent): void => {
|
||||
if (
|
||||
!isPanningRef.current &&
|
||||
!interaction.dragNodeId.current &&
|
||||
!interaction.isDragging.current &&
|
||||
!edgeMouseDownRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
completePointerInteraction(event.clientX, event.clientY);
|
||||
};
|
||||
|
||||
window.addEventListener('mousemove', handleWindowMouseMove);
|
||||
window.addEventListener('mouseup', handleWindowMouseUp);
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', handleWindowMouseMove);
|
||||
window.removeEventListener('mouseup', handleWindowMouseUp);
|
||||
};
|
||||
}, [completePointerInteraction, interaction, processActivePointerMove]);
|
||||
|
||||
const handleDoubleClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
const canvas = canvasHandle.current?.getCanvas();
|
||||
|
|
|
|||
|
|
@ -8,7 +8,10 @@ import { useEffect, useMemo, useRef, useSyncExternalStore } from 'react';
|
|||
import { getSnapshot, subscribe } from '@renderer/services/commentReadStorage';
|
||||
import { useStore } from '@renderer/store';
|
||||
import {
|
||||
getDefaultTeamGraphSlotAssignmentsForMembers,
|
||||
getCurrentProvisioningProgressForTeam,
|
||||
hasAppliedDefaultTeamGraphSlotAssignments,
|
||||
isTeamGraphSlotPersistenceDisabled,
|
||||
selectTeamDataForName,
|
||||
} from '@renderer/store/slices/teamSlice';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
|
@ -62,6 +65,20 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort {
|
|||
|
||||
const commentReadState = useSyncExternalStore(subscribe, getSnapshot);
|
||||
|
||||
const effectiveSlotAssignments = useMemo(() => {
|
||||
if (!teamData) {
|
||||
return slotAssignments;
|
||||
}
|
||||
if (!isTeamGraphSlotPersistenceDisabled()) {
|
||||
return slotAssignments;
|
||||
}
|
||||
if (hasAppliedDefaultTeamGraphSlotAssignments(teamName)) {
|
||||
return slotAssignments;
|
||||
}
|
||||
const defaults = getDefaultTeamGraphSlotAssignmentsForMembers(teamData.members);
|
||||
return Object.keys(defaults).length === 0 ? undefined : defaults;
|
||||
}, [slotAssignments, teamData, teamName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!teamName || !teamData) {
|
||||
return;
|
||||
|
|
@ -84,7 +101,7 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort {
|
|||
commentReadState,
|
||||
provisioningProgress,
|
||||
memberSpawnSnapshot,
|
||||
slotAssignments
|
||||
effectiveSlotAssignments
|
||||
),
|
||||
[
|
||||
teamData,
|
||||
|
|
@ -99,7 +116,7 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort {
|
|||
commentReadState,
|
||||
provisioningProgress,
|
||||
memberSpawnSnapshot,
|
||||
slotAssignments,
|
||||
effectiveSlotAssignments,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,10 +3,12 @@
|
|||
* Follows the exact ProjectEditorOverlay pattern (lazy-loaded, fixed z-50).
|
||||
*/
|
||||
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useCallback, useLayoutEffect, useMemo } from 'react';
|
||||
|
||||
import { GraphView } from '@claude-teams/agent-graph';
|
||||
import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHost';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { isTeamGraphSlotPersistenceDisabled } from '@renderer/store/slices/teamSlice';
|
||||
|
||||
import { useGraphCreateTaskDialog } from '../hooks/useGraphCreateTaskDialog';
|
||||
import { useGraphSidebarVisibility } from '../hooks/useGraphSidebarVisibility';
|
||||
|
|
@ -53,6 +55,9 @@ export const TeamGraphOverlay = ({
|
|||
}: TeamGraphOverlayProps): React.JSX.Element => {
|
||||
const graphData = useTeamGraphAdapter(teamName);
|
||||
const { openTeamPage: openTeamTab, commitOwnerSlotDrop } = useTeamGraphSurfaceActions(teamName);
|
||||
const resetTeamGraphSlotAssignmentsToDefaults = useStore(
|
||||
(s) => s.resetTeamGraphSlotAssignmentsToDefaults
|
||||
);
|
||||
const { sidebarVisible: persistedSidebarVisible, toggleSidebarVisible } =
|
||||
useGraphSidebarVisibility();
|
||||
const { dialog: createTaskDialog, openCreateTaskDialog } = useGraphCreateTaskDialog(teamName);
|
||||
|
|
@ -86,6 +91,13 @@ export const TeamGraphOverlay = ({
|
|||
openCreateTaskDialog('');
|
||||
}, [openCreateTaskDialog]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isTeamGraphSlotPersistenceDisabled()) {
|
||||
return;
|
||||
}
|
||||
resetTeamGraphSlotAssignmentsToDefaults(teamName);
|
||||
}, [resetTeamGraphSlotAssignmentsToDefaults, teamName]);
|
||||
|
||||
const events: GraphEventPort = {
|
||||
onNodeDoubleClick: useCallback(
|
||||
(ref: GraphDomainRef) => {
|
||||
|
|
@ -116,6 +128,7 @@ export const TeamGraphOverlay = ({
|
|||
<GraphView
|
||||
data={graphData}
|
||||
events={events}
|
||||
isSurfaceActive
|
||||
onRequestClose={onClose}
|
||||
onRequestPinAsTab={onPinAsTab}
|
||||
onOpenTeamPage={openTeamPage}
|
||||
|
|
|
|||
|
|
@ -3,10 +3,12 @@
|
|||
* Provides Fullscreen button that opens the overlay.
|
||||
*/
|
||||
|
||||
import { lazy, Suspense, useCallback, useMemo, useState } from 'react';
|
||||
import { lazy, Suspense, useCallback, useLayoutEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { GraphView } from '@claude-teams/agent-graph';
|
||||
import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHost';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { isTeamGraphSlotPersistenceDisabled } from '@renderer/store/slices/teamSlice';
|
||||
|
||||
import { useGraphCreateTaskDialog } from '../hooks/useGraphCreateTaskDialog';
|
||||
import { useGraphSidebarVisibility } from '../hooks/useGraphSidebarVisibility';
|
||||
|
|
@ -46,6 +48,9 @@ export const TeamGraphTab = ({
|
|||
}: TeamGraphTabProps): React.JSX.Element => {
|
||||
const graphData = useTeamGraphAdapter(teamName);
|
||||
const { openTeamPage, commitOwnerSlotDrop } = useTeamGraphSurfaceActions(teamName);
|
||||
const resetTeamGraphSlotAssignmentsToDefaults = useStore(
|
||||
(s) => s.resetTeamGraphSlotAssignmentsToDefaults
|
||||
);
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
const { sidebarVisible, toggleSidebarVisible } = useGraphSidebarVisibility();
|
||||
const { dialog: createTaskDialog, openCreateTaskDialog } = useGraphCreateTaskDialog(teamName);
|
||||
|
|
@ -76,6 +81,13 @@ export const TeamGraphTab = ({
|
|||
openCreateTaskDialog('');
|
||||
}, [openCreateTaskDialog]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isTeamGraphSlotPersistenceDisabled() || !isActive) {
|
||||
return;
|
||||
}
|
||||
resetTeamGraphSlotAssignmentsToDefaults(teamName);
|
||||
}, [isActive, resetTeamGraphSlotAssignmentsToDefaults, teamName]);
|
||||
|
||||
// Task action dispatchers
|
||||
const dispatchTaskAction = useCallback(
|
||||
(action: string) => (taskId: string) =>
|
||||
|
|
@ -140,6 +152,7 @@ export const TeamGraphTab = ({
|
|||
events={events}
|
||||
className="team-graph-view size-full"
|
||||
suspendAnimation={!isActive}
|
||||
isSurfaceActive={isActive}
|
||||
onRequestFullscreen={() => setFullscreen(true)}
|
||||
onOpenTeamPage={openTeamPage}
|
||||
onCreateTask={openCreateTask}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext
|
|||
import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc';
|
||||
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
||||
import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team';
|
||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
|
||||
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||
|
|
@ -55,6 +56,7 @@ import type {
|
|||
import type { StateCreator } from 'zustand';
|
||||
|
||||
const GRAPH_STABLE_SLOT_LAYOUT_VERSION = 'stable-slots-v1' as const;
|
||||
const DISABLE_PERSISTED_TEAM_GRAPH_SLOT_ASSIGNMENTS = true;
|
||||
const logger = createLogger('teamSlice');
|
||||
|
||||
const TEAM_GET_DATA_TIMEOUT_MS = 30_000;
|
||||
|
|
@ -81,6 +83,7 @@ const teamRefreshBurstDiagnostics = new Map<
|
|||
{ windowStartedAt: number; count: number; lastWarnAt: number }
|
||||
>();
|
||||
const memberSpawnUiEqualLastWarnAtByTeam = new Map<string, number>();
|
||||
const sessionDefaultGraphSlotAssignmentsAppliedByTeam = new Set<string>();
|
||||
interface RefreshTeamDataOptions {
|
||||
withDedup?: boolean;
|
||||
}
|
||||
|
|
@ -92,20 +95,20 @@ const SMALL_TEAM_CARDINAL_SLOT_PRESETS: ReadonlyArray<ReadonlyArray<GraphOwnerSl
|
|||
[],
|
||||
[{ ringIndex: 0, sectorIndex: 0 }],
|
||||
[
|
||||
{ ringIndex: 0, sectorIndex: 5 },
|
||||
{ ringIndex: 0, sectorIndex: 0 },
|
||||
{ ringIndex: 0, sectorIndex: 1 },
|
||||
],
|
||||
[
|
||||
{ ringIndex: 0, sectorIndex: 5 },
|
||||
{ ringIndex: 0, sectorIndex: 0 },
|
||||
{ ringIndex: 0, sectorIndex: 1 },
|
||||
{ ringIndex: 0, sectorIndex: 3 },
|
||||
],
|
||||
[
|
||||
{ ringIndex: 0, sectorIndex: 5 },
|
||||
{ ringIndex: 0, sectorIndex: 1 },
|
||||
{ ringIndex: 0, sectorIndex: 4 },
|
||||
{ ringIndex: 0, sectorIndex: 2 },
|
||||
],
|
||||
[
|
||||
{ ringIndex: 0, sectorIndex: 0 },
|
||||
{ ringIndex: 0, sectorIndex: 1 },
|
||||
{ ringIndex: 0, sectorIndex: 2 },
|
||||
{ ringIndex: 0, sectorIndex: 3 },
|
||||
],
|
||||
];
|
||||
|
||||
export function isTeamDataRefreshPending(teamName: string): boolean {
|
||||
|
|
@ -128,6 +131,7 @@ export function __resetTeamSliceModuleStateForTests(): void {
|
|||
memberSpawnStatusesIpcBackoffUntilByTeam.clear();
|
||||
teamRefreshBurstDiagnostics.clear();
|
||||
memberSpawnUiEqualLastWarnAtByTeam.clear();
|
||||
sessionDefaultGraphSlotAssignmentsAppliedByTeam.clear();
|
||||
}
|
||||
|
||||
function nowIso(): string {
|
||||
|
|
@ -995,7 +999,7 @@ function seedStableSlotAssignmentsForMembers(
|
|||
assignments: TeamGraphSlotAssignments,
|
||||
members: readonly TeamGraphMemberSeedInput[]
|
||||
): { assignments: TeamGraphSlotAssignments; changed: boolean } {
|
||||
const visibleMembers = members.filter((member) => !member.removedAt);
|
||||
const visibleMembers = members.filter((member) => !member.removedAt && !isLeadMember(member));
|
||||
if (visibleMembers.length === 0 || visibleMembers.length > 4) {
|
||||
return { assignments, changed: false };
|
||||
}
|
||||
|
|
@ -1021,6 +1025,44 @@ function seedStableSlotAssignmentsForMembers(
|
|||
return { assignments: nextAssignments, changed: true };
|
||||
}
|
||||
|
||||
function areTeamGraphSlotAssignmentsEqual(
|
||||
left: TeamGraphSlotAssignments | undefined,
|
||||
right: TeamGraphSlotAssignments | undefined
|
||||
): boolean {
|
||||
const leftEntries = Object.entries(left ?? {});
|
||||
const rightEntries = Object.entries(right ?? {});
|
||||
if (leftEntries.length !== rightEntries.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const [stableOwnerId, leftAssignment] of leftEntries) {
|
||||
const rightAssignment = right?.[stableOwnerId];
|
||||
if (
|
||||
!rightAssignment ||
|
||||
rightAssignment.ringIndex !== leftAssignment.ringIndex ||
|
||||
rightAssignment.sectorIndex !== leftAssignment.sectorIndex
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getDefaultTeamGraphSlotAssignmentsForMembers(
|
||||
members: readonly TeamGraphMemberSeedInput[]
|
||||
): TeamGraphSlotAssignments {
|
||||
return seedStableSlotAssignmentsForMembers({}, members).assignments;
|
||||
}
|
||||
|
||||
export function isTeamGraphSlotPersistenceDisabled(): boolean {
|
||||
return DISABLE_PERSISTED_TEAM_GRAPH_SLOT_ASSIGNMENTS;
|
||||
}
|
||||
|
||||
export function hasAppliedDefaultTeamGraphSlotAssignments(teamName: string): boolean {
|
||||
return sessionDefaultGraphSlotAssignmentsAppliedByTeam.has(teamName);
|
||||
}
|
||||
|
||||
function isVisibleInActiveTeamSurface(
|
||||
state: Pick<AppState, 'paneLayout'>,
|
||||
teamName: string | null | undefined
|
||||
|
|
@ -1829,9 +1871,34 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
if (state.slotLayoutVersion !== GRAPH_STABLE_SLOT_LAYOUT_VERSION) {
|
||||
nextState.slotLayoutVersion = GRAPH_STABLE_SLOT_LAYOUT_VERSION;
|
||||
nextSlotAssignmentsByTeam = {};
|
||||
sessionDefaultGraphSlotAssignmentsAppliedByTeam.clear();
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (DISABLE_PERSISTED_TEAM_GRAPH_SLOT_ASSIGNMENTS) {
|
||||
if (!sessionDefaultGraphSlotAssignmentsAppliedByTeam.has(teamName)) {
|
||||
const currentAssignments = nextSlotAssignmentsByTeam[teamName];
|
||||
const defaultAssignments = getDefaultTeamGraphSlotAssignmentsForMembers(members);
|
||||
if (!areTeamGraphSlotAssignmentsEqual(currentAssignments, defaultAssignments)) {
|
||||
nextSlotAssignmentsByTeam = { ...nextSlotAssignmentsByTeam };
|
||||
if (Object.keys(defaultAssignments).length === 0) {
|
||||
delete nextSlotAssignmentsByTeam[teamName];
|
||||
} else {
|
||||
nextSlotAssignmentsByTeam[teamName] = defaultAssignments;
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
sessionDefaultGraphSlotAssignmentsAppliedByTeam.add(teamName);
|
||||
}
|
||||
|
||||
if (!changed) {
|
||||
return {};
|
||||
}
|
||||
|
||||
nextState.slotAssignmentsByTeam = nextSlotAssignmentsByTeam;
|
||||
return nextState;
|
||||
}
|
||||
|
||||
const currentAssignments = nextSlotAssignmentsByTeam[teamName];
|
||||
const migrated = migrateStableSlotAssignmentsForMembers(currentAssignments, members);
|
||||
const seeded = seedStableSlotAssignmentsForMembers(migrated.assignments, members);
|
||||
|
|
@ -1979,6 +2046,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
clearTeamGraphSlotAssignments: (teamName) => {
|
||||
set((state) => {
|
||||
if (!teamName) {
|
||||
sessionDefaultGraphSlotAssignmentsAppliedByTeam.clear();
|
||||
if (
|
||||
Object.keys(state.slotAssignmentsByTeam).length === 0 &&
|
||||
state.slotLayoutVersion === GRAPH_STABLE_SLOT_LAYOUT_VERSION
|
||||
|
|
@ -1997,6 +2065,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
|
||||
const nextAssignmentsByTeam = { ...state.slotAssignmentsByTeam };
|
||||
delete nextAssignmentsByTeam[teamName];
|
||||
sessionDefaultGraphSlotAssignmentsAppliedByTeam.delete(teamName);
|
||||
return {
|
||||
slotLayoutVersion: GRAPH_STABLE_SLOT_LAYOUT_VERSION,
|
||||
slotAssignmentsByTeam: nextAssignmentsByTeam,
|
||||
|
|
@ -2006,13 +2075,48 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
|
||||
resetTeamGraphSlotAssignmentsToDefaults: (teamName) => {
|
||||
set((state) => {
|
||||
if (!DISABLE_PERSISTED_TEAM_GRAPH_SLOT_ASSIGNMENTS) {
|
||||
const currentAssignments = state.slotAssignmentsByTeam[teamName];
|
||||
if (!currentAssignments || Object.keys(currentAssignments).length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const nextAssignmentsByTeam = { ...state.slotAssignmentsByTeam };
|
||||
delete nextAssignmentsByTeam[teamName];
|
||||
return {
|
||||
slotLayoutVersion: GRAPH_STABLE_SLOT_LAYOUT_VERSION,
|
||||
slotAssignmentsByTeam: nextAssignmentsByTeam,
|
||||
};
|
||||
}
|
||||
|
||||
const teamData = selectTeamDataForName(state, teamName);
|
||||
const defaultAssignments = teamData
|
||||
? getDefaultTeamGraphSlotAssignmentsForMembers(teamData.members)
|
||||
: {};
|
||||
const currentAssignments = state.slotAssignmentsByTeam[teamName];
|
||||
if (!currentAssignments || Object.keys(currentAssignments).length === 0) {
|
||||
const hasCurrentAssignments =
|
||||
currentAssignments && Object.keys(currentAssignments).length > 0;
|
||||
|
||||
if (
|
||||
areTeamGraphSlotAssignmentsEqual(currentAssignments, defaultAssignments) &&
|
||||
sessionDefaultGraphSlotAssignmentsAppliedByTeam.has(teamName)
|
||||
) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const nextAssignmentsByTeam = { ...state.slotAssignmentsByTeam };
|
||||
delete nextAssignmentsByTeam[teamName];
|
||||
if (Object.keys(defaultAssignments).length === 0) {
|
||||
delete nextAssignmentsByTeam[teamName];
|
||||
sessionDefaultGraphSlotAssignmentsAppliedByTeam.delete(teamName);
|
||||
} else {
|
||||
nextAssignmentsByTeam[teamName] = defaultAssignments;
|
||||
sessionDefaultGraphSlotAssignmentsAppliedByTeam.add(teamName);
|
||||
}
|
||||
|
||||
if (!hasCurrentAssignments && Object.keys(defaultAssignments).length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
slotLayoutVersion: GRAPH_STABLE_SLOT_LAYOUT_VERSION,
|
||||
slotAssignmentsByTeam: nextAssignmentsByTeam,
|
||||
|
|
|
|||
|
|
@ -155,6 +155,97 @@ describe('stable slot layout planner', () => {
|
|||
expect(frame?.processBandRect.width).toBe(computeProcessBandWidth(0));
|
||||
});
|
||||
|
||||
it('uses strict cardinal owner slots for teams with up to four members', () => {
|
||||
const teamName = 'team-cardinal-four';
|
||||
const lead = createLead(teamName);
|
||||
const top = createMember(teamName, 'agent-top', 'top');
|
||||
const right = createMember(teamName, 'agent-right', 'right');
|
||||
const bottom = createMember(teamName, 'agent-bottom', 'bottom');
|
||||
const left = createMember(teamName, 'agent-left', 'left');
|
||||
const layout: GraphLayoutPort = {
|
||||
version: 'stable-slots-v1',
|
||||
ownerOrder: [top.id, right.id, bottom.id, left.id],
|
||||
slotAssignments: {
|
||||
[top.id]: { ringIndex: 0, sectorIndex: 0 },
|
||||
[right.id]: { ringIndex: 0, sectorIndex: 1 },
|
||||
[bottom.id]: { ringIndex: 0, sectorIndex: 2 },
|
||||
[left.id]: { ringIndex: 0, sectorIndex: 3 },
|
||||
},
|
||||
};
|
||||
|
||||
const snapshot = buildStableSlotLayoutSnapshot({
|
||||
teamName,
|
||||
nodes: [lead, top, right, bottom, left],
|
||||
layout,
|
||||
});
|
||||
|
||||
expect(snapshot).not.toBeNull();
|
||||
|
||||
const topFrame = snapshot!.memberSlotFrameByOwnerId.get(top.id)!;
|
||||
const rightFrame = snapshot!.memberSlotFrameByOwnerId.get(right.id)!;
|
||||
const bottomFrame = snapshot!.memberSlotFrameByOwnerId.get(bottom.id)!;
|
||||
const leftFrame = snapshot!.memberSlotFrameByOwnerId.get(left.id)!;
|
||||
|
||||
expect(Math.abs(topFrame.ownerX)).toBeLessThan(1);
|
||||
expect(topFrame.ownerY).toBeLessThan(0);
|
||||
|
||||
expect(rightFrame.ownerX).toBeGreaterThan(0);
|
||||
expect(Math.abs(rightFrame.ownerY)).toBeLessThan(1);
|
||||
|
||||
expect(Math.abs(bottomFrame.ownerX)).toBeLessThan(1);
|
||||
expect(bottomFrame.ownerY).toBeGreaterThan(0);
|
||||
|
||||
expect(leftFrame.ownerX).toBeLessThan(0);
|
||||
expect(Math.abs(leftFrame.ownerY)).toBeLessThan(1);
|
||||
|
||||
expect(Math.abs(Math.abs(leftFrame.ownerX) - Math.abs(rightFrame.ownerX))).toBeLessThan(1);
|
||||
expect(Math.abs(Math.abs(topFrame.ownerY) - Math.abs(bottomFrame.ownerY))).toBeLessThan(1);
|
||||
});
|
||||
|
||||
it('uses strict cardinal owner slots even when ownerOrder differs from assignment order', () => {
|
||||
const teamName = 'team-cardinal-misaligned-order';
|
||||
const lead = createLead(teamName);
|
||||
const alice = createMember(teamName, 'agent-alice', 'alice');
|
||||
const bob = createMember(teamName, 'agent-bob', 'bob');
|
||||
const tom = createMember(teamName, 'agent-tom', 'tom');
|
||||
const jack = createMember(teamName, 'agent-jack', 'jack');
|
||||
const layout: GraphLayoutPort = {
|
||||
version: 'stable-slots-v1',
|
||||
ownerOrder: [jack.id, alice.id, tom.id, bob.id],
|
||||
slotAssignments: {
|
||||
[alice.id]: { ringIndex: 0, sectorIndex: 0 },
|
||||
[bob.id]: { ringIndex: 0, sectorIndex: 1 },
|
||||
[tom.id]: { ringIndex: 0, sectorIndex: 2 },
|
||||
[jack.id]: { ringIndex: 0, sectorIndex: 3 },
|
||||
},
|
||||
};
|
||||
|
||||
const snapshot = buildStableSlotLayoutSnapshot({
|
||||
teamName,
|
||||
nodes: [lead, alice, bob, tom, jack],
|
||||
layout,
|
||||
});
|
||||
|
||||
expect(snapshot).not.toBeNull();
|
||||
|
||||
const aliceFrame = snapshot!.memberSlotFrameByOwnerId.get(alice.id)!;
|
||||
const bobFrame = snapshot!.memberSlotFrameByOwnerId.get(bob.id)!;
|
||||
const tomFrame = snapshot!.memberSlotFrameByOwnerId.get(tom.id)!;
|
||||
const jackFrame = snapshot!.memberSlotFrameByOwnerId.get(jack.id)!;
|
||||
|
||||
expect(Math.abs(aliceFrame.ownerX)).toBeLessThan(1);
|
||||
expect(aliceFrame.ownerY).toBeLessThan(0);
|
||||
|
||||
expect(bobFrame.ownerX).toBeGreaterThan(0);
|
||||
expect(Math.abs(bobFrame.ownerY)).toBeLessThan(1);
|
||||
|
||||
expect(Math.abs(tomFrame.ownerX)).toBeLessThan(1);
|
||||
expect(tomFrame.ownerY).toBeGreaterThan(0);
|
||||
|
||||
expect(jackFrame.ownerX).toBeLessThan(0);
|
||||
expect(Math.abs(jackFrame.ownerY)).toBeLessThan(1);
|
||||
});
|
||||
|
||||
it('reserves a full empty activity column and minimum kanban width for idle members', () => {
|
||||
const teamName = 'team-empty-slot';
|
||||
const lead = createLead(teamName);
|
||||
|
|
@ -384,6 +475,48 @@ describe('stable slot layout planner', () => {
|
|||
expect(nearest?.displacedAssignment).toEqual({ ringIndex: 0, sectorIndex: 1 });
|
||||
});
|
||||
|
||||
it('keeps drag resolution inside strict cardinal slots for four-member teams', () => {
|
||||
const teamName = 'team-cardinal-drag';
|
||||
const lead = createLead(teamName);
|
||||
const top = createMember(teamName, 'agent-top', 'top');
|
||||
const right = createMember(teamName, 'agent-right', 'right');
|
||||
const bottom = createMember(teamName, 'agent-bottom', 'bottom');
|
||||
const left = createMember(teamName, 'agent-left', 'left');
|
||||
const layout: GraphLayoutPort = {
|
||||
version: 'stable-slots-v1',
|
||||
ownerOrder: [top.id, right.id, bottom.id, left.id],
|
||||
slotAssignments: {
|
||||
[top.id]: { ringIndex: 0, sectorIndex: 0 },
|
||||
[right.id]: { ringIndex: 0, sectorIndex: 1 },
|
||||
[bottom.id]: { ringIndex: 0, sectorIndex: 2 },
|
||||
[left.id]: { ringIndex: 0, sectorIndex: 3 },
|
||||
},
|
||||
};
|
||||
|
||||
const snapshot = buildStableSlotLayoutSnapshot({
|
||||
teamName,
|
||||
nodes: [lead, top, right, bottom, left],
|
||||
layout,
|
||||
});
|
||||
|
||||
expect(snapshot).not.toBeNull();
|
||||
const rightFrame = snapshot!.memberSlotFrameByOwnerId.get(right.id)!;
|
||||
|
||||
const nearest = resolveNearestSlotAssignment({
|
||||
ownerId: top.id,
|
||||
ownerX: rightFrame.ownerX,
|
||||
ownerY: rightFrame.ownerY,
|
||||
nodes: [lead, top, right, bottom, left],
|
||||
snapshot: snapshot!,
|
||||
layout,
|
||||
});
|
||||
|
||||
expect(nearest).not.toBeNull();
|
||||
expect(nearest?.assignment).toEqual({ ringIndex: 0, sectorIndex: 1 });
|
||||
expect(nearest?.displacedOwnerId).toBe(right.id);
|
||||
expect(nearest?.displacedAssignment).toEqual({ ringIndex: 0, sectorIndex: 0 });
|
||||
});
|
||||
|
||||
it('keeps nearest-slot drag resolution on the same central collision model as the planner', () => {
|
||||
const teamName = 'team-drag-central-collision';
|
||||
const lead = createLead(teamName);
|
||||
|
|
|
|||
|
|
@ -221,7 +221,7 @@ describe('teamSlice actions', () => {
|
|||
expect(store.getState().warmTaskChangeSummaries).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('commits an owner slot drop atomically even when prior assignments were sparse', () => {
|
||||
it('commits owner slot drops in the current session while persistence is disabled', () => {
|
||||
const store = createSliceStore();
|
||||
|
||||
store.getState().commitTeamGraphOwnerSlotDrop(
|
||||
|
|
@ -238,7 +238,7 @@ describe('teamSlice actions', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('migrates fallback name-based slot assignments to agentId-based stable owner ids', () => {
|
||||
it('replaces persisted slot assignments with defaults while persistence is disabled', () => {
|
||||
const store = createSliceStore();
|
||||
store.setState({
|
||||
slotLayoutVersion: 'stable-slots-v1',
|
||||
|
|
@ -255,7 +255,8 @@ describe('teamSlice actions', () => {
|
|||
]);
|
||||
|
||||
expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({
|
||||
'agent-alice': { ringIndex: 0, sectorIndex: 3 },
|
||||
'agent-alice': { ringIndex: 0, sectorIndex: 0 },
|
||||
'agent-bob': { ringIndex: 0, sectorIndex: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -270,14 +271,33 @@ describe('teamSlice actions', () => {
|
|||
]);
|
||||
|
||||
expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({
|
||||
'agent-alice': { ringIndex: 0, sectorIndex: 5 },
|
||||
'agent-alice': { ringIndex: 0, sectorIndex: 0 },
|
||||
'agent-bob': { ringIndex: 0, sectorIndex: 1 },
|
||||
'agent-tom': { ringIndex: 0, sectorIndex: 4 },
|
||||
'agent-jack': { ringIndex: 0, sectorIndex: 2 },
|
||||
'agent-tom': { ringIndex: 0, sectorIndex: 2 },
|
||||
'agent-jack': { ringIndex: 0, sectorIndex: 3 },
|
||||
});
|
||||
});
|
||||
|
||||
it('seeds visible members even when only hidden owners have saved placements', () => {
|
||||
it('ignores the lead member when deriving small-team cardinal defaults', () => {
|
||||
const store = createSliceStore();
|
||||
|
||||
store.getState().ensureTeamGraphSlotAssignments('my-team', [
|
||||
{ name: 'team-lead', agentId: 'lead-id' },
|
||||
{ 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: 0 },
|
||||
'agent-bob': { ringIndex: 0, sectorIndex: 1 },
|
||||
'agent-tom': { ringIndex: 0, sectorIndex: 2 },
|
||||
'agent-jack': { ringIndex: 0, sectorIndex: 3 },
|
||||
});
|
||||
});
|
||||
|
||||
it('drops hidden persisted slot assignments and reseeds visible members while persistence is disabled', () => {
|
||||
const store = createSliceStore();
|
||||
store.setState({
|
||||
slotLayoutVersion: 'stable-slots-v1',
|
||||
|
|
@ -295,8 +315,7 @@ describe('teamSlice actions', () => {
|
|||
]);
|
||||
|
||||
expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({
|
||||
'agent-hidden': { ringIndex: 2, sectorIndex: 4 },
|
||||
'agent-alice': { ringIndex: 0, sectorIndex: 5 },
|
||||
'agent-alice': { ringIndex: 0, sectorIndex: 0 },
|
||||
'agent-bob': { ringIndex: 0, sectorIndex: 1 },
|
||||
});
|
||||
});
|
||||
|
|
@ -327,7 +346,7 @@ describe('teamSlice actions', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('keeps hidden-member slot assignments so the same stable owner can reuse them later', () => {
|
||||
it('ignores hidden-member persisted slot assignments while persistence is disabled', () => {
|
||||
const store = createSliceStore();
|
||||
store.setState({
|
||||
slotLayoutVersion: 'stable-slots-v1',
|
||||
|
|
@ -344,8 +363,77 @@ describe('teamSlice actions', () => {
|
|||
]);
|
||||
|
||||
expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({
|
||||
'agent-hidden': { ringIndex: 1, sectorIndex: 5 },
|
||||
'agent-visible': { ringIndex: 0, sectorIndex: 2 },
|
||||
'agent-visible': { ringIndex: 0, sectorIndex: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it('does not reseed a team again after defaults were applied once in the session', () => {
|
||||
const store = createSliceStore();
|
||||
|
||||
store.getState().ensureTeamGraphSlotAssignments('my-team', [
|
||||
{ name: 'alice', agentId: 'agent-alice' },
|
||||
{ name: 'bob', agentId: 'agent-bob' },
|
||||
]);
|
||||
|
||||
store.getState().setTeamGraphOwnerSlotAssignment('my-team', 'agent-alice', {
|
||||
ringIndex: 1,
|
||||
sectorIndex: 4,
|
||||
});
|
||||
|
||||
store.getState().ensureTeamGraphSlotAssignments('my-team', [
|
||||
{ name: 'alice', agentId: 'agent-alice' },
|
||||
{ name: 'bob', agentId: 'agent-bob' },
|
||||
]);
|
||||
|
||||
expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({
|
||||
'agent-alice': { ringIndex: 1, sectorIndex: 4 },
|
||||
'agent-bob': { ringIndex: 0, sectorIndex: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it('resets graph slot assignments back to defaults when reopening the graph surface', () => {
|
||||
const store = createSliceStore();
|
||||
store.setState({
|
||||
teamDataCacheByName: {
|
||||
'my-team': {
|
||||
teamName: 'my-team',
|
||||
config: { name: 'My Team' },
|
||||
tasks: [],
|
||||
members: [
|
||||
{ name: 'alice', agentId: 'agent-alice' },
|
||||
{ name: 'bob', agentId: 'agent-bob' },
|
||||
{ name: 'tom', agentId: 'agent-tom' },
|
||||
{ name: 'jack', agentId: 'agent-jack' },
|
||||
],
|
||||
messages: [],
|
||||
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||
processes: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
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' },
|
||||
]);
|
||||
|
||||
store.getState().commitTeamGraphOwnerSlotDrop(
|
||||
'my-team',
|
||||
'agent-alice',
|
||||
{ ringIndex: 0, sectorIndex: 2 },
|
||||
'agent-tom',
|
||||
{ ringIndex: 0, sectorIndex: 0 }
|
||||
);
|
||||
|
||||
store.getState().resetTeamGraphSlotAssignmentsToDefaults('my-team');
|
||||
|
||||
expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({
|
||||
'agent-alice': { ringIndex: 0, sectorIndex: 0 },
|
||||
'agent-bob': { ringIndex: 0, sectorIndex: 1 },
|
||||
'agent-tom': { ringIndex: 0, sectorIndex: 2 },
|
||||
'agent-jack': { ringIndex: 0, sectorIndex: 3 },
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue