chore(merge): sync dev into team snapshot split spike
This commit is contained in:
commit
fd76944141
22 changed files with 2454 additions and 887 deletions
|
|
@ -262,6 +262,8 @@ export const KANBAN_ZONE = {
|
||||||
columnWidth: 180,
|
columnWidth: 180,
|
||||||
/** Row height: pill (36) + gap (10) */
|
/** Row height: pill (36) + gap (10) */
|
||||||
rowHeight: 46,
|
rowHeight: 46,
|
||||||
|
/** Space reserved for column header label */
|
||||||
|
headerHeight: 20,
|
||||||
/** Zone starts this far below member node center */
|
/** Zone starts this far below member node center */
|
||||||
offsetY: 70,
|
offsetY: 70,
|
||||||
/** Column sequence: pending → wip → done → review → approved */
|
/** Column sequence: pending → wip → done → review → approved */
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
translateSlotFrame,
|
translateSlotFrame,
|
||||||
validateStableSlotLayout,
|
validateStableSlotLayout,
|
||||||
type StableSlotLayoutSnapshot,
|
type StableSlotLayoutSnapshot,
|
||||||
|
type StableRect,
|
||||||
type SlotFrame,
|
type SlotFrame,
|
||||||
} from '../layout/stableSlots';
|
} from '../layout/stableSlots';
|
||||||
import { KanbanLayoutEngine } from '../layout/kanbanLayout';
|
import { KanbanLayoutEngine } from '../layout/kanbanLayout';
|
||||||
|
|
@ -37,6 +38,7 @@ export interface UseGraphSimulationResult {
|
||||||
tick: (dt: number) => void;
|
tick: (dt: number) => void;
|
||||||
setNodePosition: (nodeId: string, x: number, y: number) => void;
|
setNodePosition: (nodeId: string, x: number, y: number) => void;
|
||||||
clearNodePosition: (nodeId: string) => void;
|
clearNodePosition: (nodeId: string) => void;
|
||||||
|
clearTransientOwnerPositions: () => void;
|
||||||
resolveNearestOwnerSlot: (
|
resolveNearestOwnerSlot: (
|
||||||
nodeId: string,
|
nodeId: string,
|
||||||
x: number,
|
x: number,
|
||||||
|
|
@ -45,9 +47,11 @@ export interface UseGraphSimulationResult {
|
||||||
assignment: GraphOwnerSlotAssignment;
|
assignment: GraphOwnerSlotAssignment;
|
||||||
displacedOwnerId?: string;
|
displacedOwnerId?: string;
|
||||||
displacedAssignment?: GraphOwnerSlotAssignment;
|
displacedAssignment?: GraphOwnerSlotAssignment;
|
||||||
|
previewOwnerX: number;
|
||||||
|
previewOwnerY: number;
|
||||||
} | null;
|
} | null;
|
||||||
getLaunchAnchorWorldPosition: (leadNodeId: string) => { x: number; y: number } | null;
|
getLaunchAnchorWorldPosition: (leadNodeId: string) => { x: number; y: number } | null;
|
||||||
getActivityAnchorWorldPosition: (nodeId: string) => { x: number; y: number } | null;
|
getActivityWorldRect: (nodeId: string) => StableRect | null;
|
||||||
getExtraWorldBounds: () => WorldBounds[];
|
getExtraWorldBounds: () => WorldBounds[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -65,7 +69,7 @@ export function useGraphSimulation(): UseGraphSimulationResult {
|
||||||
const lastValidSnapshotByTeamRef = useRef(new Map<string, StableSlotLayoutSnapshot>());
|
const lastValidSnapshotByTeamRef = useRef(new Map<string, StableSlotLayoutSnapshot>());
|
||||||
const dragOwnerPositionsRef = useRef(new Map<string, { x: number; y: number }>());
|
const dragOwnerPositionsRef = useRef(new Map<string, { x: number; y: number }>());
|
||||||
const launchAnchorPositionsRef = useRef(new Map<string, { x: number; y: number }>());
|
const launchAnchorPositionsRef = useRef(new Map<string, { x: number; y: number }>());
|
||||||
const activityAnchorPositionsRef = useRef(new Map<string, { x: number; y: number }>());
|
const activityRectByNodeIdRef = useRef(new Map<string, StableRect>());
|
||||||
const extraWorldBoundsRef = useRef<WorldBounds[]>([]);
|
const extraWorldBoundsRef = useRef<WorldBounds[]>([]);
|
||||||
|
|
||||||
const prevNodeIdsRef = useRef(new Set<string>());
|
const prevNodeIdsRef = useRef(new Set<string>());
|
||||||
|
|
@ -91,7 +95,7 @@ export function useGraphSimulation(): UseGraphSimulationResult {
|
||||||
lastValidSnapshotByTeamRef,
|
lastValidSnapshotByTeamRef,
|
||||||
dragOwnerPositionsRef,
|
dragOwnerPositionsRef,
|
||||||
launchAnchorPositionsRef,
|
launchAnchorPositionsRef,
|
||||||
activityAnchorPositionsRef,
|
activityRectByNodeIdRef,
|
||||||
extraWorldBoundsRef,
|
extraWorldBoundsRef,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
|
|
@ -111,7 +115,7 @@ export function useGraphSimulation(): UseGraphSimulationResult {
|
||||||
lastValidSnapshotByTeamRef,
|
lastValidSnapshotByTeamRef,
|
||||||
dragOwnerPositionsRef,
|
dragOwnerPositionsRef,
|
||||||
launchAnchorPositionsRef,
|
launchAnchorPositionsRef,
|
||||||
activityAnchorPositionsRef,
|
activityRectByNodeIdRef,
|
||||||
extraWorldBoundsRef,
|
extraWorldBoundsRef,
|
||||||
fillMissingFallbackPositions: true,
|
fillMissingFallbackPositions: true,
|
||||||
});
|
});
|
||||||
|
|
@ -123,7 +127,7 @@ export function useGraphSimulation(): UseGraphSimulationResult {
|
||||||
nodes: state.nodes,
|
nodes: state.nodes,
|
||||||
layoutSnapshotRef,
|
layoutSnapshotRef,
|
||||||
launchAnchorPositionsRef,
|
launchAnchorPositionsRef,
|
||||||
activityAnchorPositionsRef,
|
activityRectByNodeIdRef,
|
||||||
extraWorldBoundsRef,
|
extraWorldBoundsRef,
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -198,6 +202,14 @@ export function useGraphSimulation(): UseGraphSimulationResult {
|
||||||
[applyCurrentLayout]
|
[applyCurrentLayout]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const clearTransientOwnerPositions = useCallback(() => {
|
||||||
|
if (dragOwnerPositionsRef.current.size === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dragOwnerPositionsRef.current.clear();
|
||||||
|
applyCurrentLayout();
|
||||||
|
}, [applyCurrentLayout]);
|
||||||
|
|
||||||
const resolveNearestOwnerSlot = useCallback(
|
const resolveNearestOwnerSlot = useCallback(
|
||||||
(nodeId: string, x: number, y: number) => {
|
(nodeId: string, x: number, y: number) => {
|
||||||
const snapshot = layoutSnapshotRef.current;
|
const snapshot = layoutSnapshotRef.current;
|
||||||
|
|
@ -220,7 +232,7 @@ export function useGraphSimulation(): UseGraphSimulationResult {
|
||||||
return () => {
|
return () => {
|
||||||
dragOwnerPositionsRef.current.clear();
|
dragOwnerPositionsRef.current.clear();
|
||||||
launchAnchorPositionsRef.current.clear();
|
launchAnchorPositionsRef.current.clear();
|
||||||
activityAnchorPositionsRef.current.clear();
|
activityRectByNodeIdRef.current.clear();
|
||||||
extraWorldBoundsRef.current = [];
|
extraWorldBoundsRef.current = [];
|
||||||
layoutSnapshotRef.current = null;
|
layoutSnapshotRef.current = null;
|
||||||
lastValidSnapshotByTeamRef.current.clear();
|
lastValidSnapshotByTeamRef.current.clear();
|
||||||
|
|
@ -233,11 +245,11 @@ export function useGraphSimulation(): UseGraphSimulationResult {
|
||||||
tick,
|
tick,
|
||||||
setNodePosition,
|
setNodePosition,
|
||||||
clearNodePosition,
|
clearNodePosition,
|
||||||
|
clearTransientOwnerPositions,
|
||||||
resolveNearestOwnerSlot,
|
resolveNearestOwnerSlot,
|
||||||
getLaunchAnchorWorldPosition: (leadNodeId: string) =>
|
getLaunchAnchorWorldPosition: (leadNodeId: string) =>
|
||||||
launchAnchorPositionsRef.current.get(leadNodeId) ?? null,
|
launchAnchorPositionsRef.current.get(leadNodeId) ?? null,
|
||||||
getActivityAnchorWorldPosition: (nodeId: string) =>
|
getActivityWorldRect: (nodeId: string) => activityRectByNodeIdRef.current.get(nodeId) ?? null,
|
||||||
activityAnchorPositionsRef.current.get(nodeId) ?? null,
|
|
||||||
getExtraWorldBounds: () => extraWorldBoundsRef.current,
|
getExtraWorldBounds: () => extraWorldBoundsRef.current,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -251,14 +263,15 @@ function applySnapshotToNodes(
|
||||||
const translatedFrameByOwnerId = new Map(
|
const translatedFrameByOwnerId = new Map(
|
||||||
translatedFrames.map((frame) => [frame.ownerId, frame] as const)
|
translatedFrames.map((frame) => [frame.ownerId, frame] as const)
|
||||||
);
|
);
|
||||||
|
const leadFrame = snapshot.leadSlotFrame;
|
||||||
const leadId = snapshot.leadNodeId;
|
const leadId = snapshot.leadNodeId;
|
||||||
|
|
||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
if (node.kind === 'lead' && node.id === leadId) {
|
if (node.kind === 'lead' && node.id === leadId) {
|
||||||
node.x = 0;
|
node.x = leadFrame.ownerX;
|
||||||
node.y = 0;
|
node.y = leadFrame.ownerY;
|
||||||
node.fx = 0;
|
node.fx = leadFrame.ownerX;
|
||||||
node.fy = 0;
|
node.fy = leadFrame.ownerY;
|
||||||
node.vx = 0;
|
node.vx = 0;
|
||||||
node.vy = 0;
|
node.vy = 0;
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -278,9 +291,10 @@ function applySnapshotToNodes(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
positionProcessNodes(nodes, translatedFrames);
|
positionProcessNodes(nodes, [snapshot.leadSlotFrame, ...translatedFrames]);
|
||||||
KanbanLayoutEngine.layout(nodes, {
|
KanbanLayoutEngine.layout(nodes, {
|
||||||
memberSlotFrames: translatedFrames,
|
memberSlotFrames: translatedFrames,
|
||||||
|
leadSlotFrame: snapshot.leadSlotFrame,
|
||||||
unassignedTaskRect: snapshot.unassignedTaskRect,
|
unassignedTaskRect: snapshot.unassignedTaskRect,
|
||||||
});
|
});
|
||||||
positionCrossTeamNodes(nodes, snapshot.fitBounds);
|
positionCrossTeamNodes(nodes, snapshot.fitBounds);
|
||||||
|
|
@ -294,7 +308,7 @@ function commitSnapshotGeometry(args: {
|
||||||
lastValidSnapshotByTeamRef: { current: Map<string, StableSlotLayoutSnapshot> };
|
lastValidSnapshotByTeamRef: { current: Map<string, StableSlotLayoutSnapshot> };
|
||||||
dragOwnerPositionsRef: { current: ReadonlyMap<string, { x: number; y: number }> };
|
dragOwnerPositionsRef: { current: ReadonlyMap<string, { x: number; y: number }> };
|
||||||
launchAnchorPositionsRef: { current: Map<string, { x: number; y: number }> };
|
launchAnchorPositionsRef: { current: Map<string, { x: number; y: number }> };
|
||||||
activityAnchorPositionsRef: { current: Map<string, { x: number; y: number }> };
|
activityRectByNodeIdRef: { current: Map<string, StableRect> };
|
||||||
extraWorldBoundsRef: { current: WorldBounds[] };
|
extraWorldBoundsRef: { current: WorldBounds[] };
|
||||||
fillMissingFallbackPositions?: boolean;
|
fillMissingFallbackPositions?: boolean;
|
||||||
}): void {
|
}): void {
|
||||||
|
|
@ -306,7 +320,7 @@ function commitSnapshotGeometry(args: {
|
||||||
lastValidSnapshotByTeamRef,
|
lastValidSnapshotByTeamRef,
|
||||||
dragOwnerPositionsRef,
|
dragOwnerPositionsRef,
|
||||||
launchAnchorPositionsRef,
|
launchAnchorPositionsRef,
|
||||||
activityAnchorPositionsRef,
|
activityRectByNodeIdRef,
|
||||||
extraWorldBoundsRef,
|
extraWorldBoundsRef,
|
||||||
fillMissingFallbackPositions = false,
|
fillMissingFallbackPositions = false,
|
||||||
} = args;
|
} = args;
|
||||||
|
|
@ -319,44 +333,39 @@ function commitSnapshotGeometry(args: {
|
||||||
}
|
}
|
||||||
|
|
||||||
launchAnchorPositionsRef.current.clear();
|
launchAnchorPositionsRef.current.clear();
|
||||||
activityAnchorPositionsRef.current.clear();
|
activityRectByNodeIdRef.current.clear();
|
||||||
extraWorldBoundsRef.current = snapshotToWorldBounds(snapshot);
|
extraWorldBoundsRef.current = snapshotToWorldBounds(snapshot);
|
||||||
|
|
||||||
if (snapshot.leadNodeId && snapshot.launchAnchor) {
|
|
||||||
launchAnchorPositionsRef.current.set(snapshot.leadNodeId, snapshot.launchAnchor);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const frame of getTranslatedMemberFrames(snapshot, dragOwnerPositionsRef.current)) {
|
for (const frame of getTranslatedMemberFrames(snapshot, dragOwnerPositionsRef.current)) {
|
||||||
activityAnchorPositionsRef.current.set(frame.ownerId, {
|
activityRectByNodeIdRef.current.set(frame.ownerId, frame.activityColumnRect);
|
||||||
x: frame.activityRect.left,
|
|
||||||
y: frame.activityRect.top,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
activityAnchorPositionsRef.current.set(`lead:${teamName}`, {
|
if (snapshot.leadNodeId) {
|
||||||
x: snapshot.leadActivityRect.left,
|
activityRectByNodeIdRef.current.set(
|
||||||
y: snapshot.leadActivityRect.top,
|
snapshot.leadNodeId,
|
||||||
});
|
snapshot.leadSlotFrame.activityColumnRect
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetToFallbackLayout(args: {
|
function resetToFallbackLayout(args: {
|
||||||
nodes: GraphNode[];
|
nodes: GraphNode[];
|
||||||
layoutSnapshotRef: { current: StableSlotLayoutSnapshot | null };
|
layoutSnapshotRef: { current: StableSlotLayoutSnapshot | null };
|
||||||
launchAnchorPositionsRef: { current: Map<string, { x: number; y: number }> };
|
launchAnchorPositionsRef: { current: Map<string, { x: number; y: number }> };
|
||||||
activityAnchorPositionsRef: { current: Map<string, { x: number; y: number }> };
|
activityRectByNodeIdRef: { current: Map<string, StableRect> };
|
||||||
extraWorldBoundsRef: { current: WorldBounds[] };
|
extraWorldBoundsRef: { current: WorldBounds[] };
|
||||||
}): void {
|
}): void {
|
||||||
const {
|
const {
|
||||||
nodes,
|
nodes,
|
||||||
layoutSnapshotRef,
|
layoutSnapshotRef,
|
||||||
launchAnchorPositionsRef,
|
launchAnchorPositionsRef,
|
||||||
activityAnchorPositionsRef,
|
activityRectByNodeIdRef,
|
||||||
extraWorldBoundsRef,
|
extraWorldBoundsRef,
|
||||||
} = args;
|
} = args;
|
||||||
|
|
||||||
layoutSnapshotRef.current = null;
|
layoutSnapshotRef.current = null;
|
||||||
launchAnchorPositionsRef.current.clear();
|
launchAnchorPositionsRef.current.clear();
|
||||||
activityAnchorPositionsRef.current.clear();
|
activityRectByNodeIdRef.current.clear();
|
||||||
extraWorldBoundsRef.current = [];
|
extraWorldBoundsRef.current = [];
|
||||||
fallbackPositionNodes(nodes);
|
fallbackPositionNodes(nodes);
|
||||||
KanbanLayoutEngine.layout(nodes);
|
KanbanLayoutEngine.layout(nodes);
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,6 @@
|
||||||
import type { GraphNode } from '../ports/types';
|
import type { GraphNode } from '../ports/types';
|
||||||
import { KANBAN_ZONE, TASK_PILL } from '../constants/canvas-constants';
|
import { KANBAN_ZONE, TASK_PILL } from '../constants/canvas-constants';
|
||||||
import { COLORS } from '../constants/colors';
|
import { COLORS } from '../constants/colors';
|
||||||
import { resolveActivityLaneSide } from './activityLane';
|
|
||||||
import type { ActivityLaneWorldBounds } from './activityLane';
|
|
||||||
import type { SlotFrame, StableRect } from './stableSlots';
|
import type { SlotFrame, StableRect } from './stableSlots';
|
||||||
|
|
||||||
/** Column header info for rendering */
|
/** Column header info for rendering */
|
||||||
|
|
@ -43,8 +41,6 @@ const COLUMN_LABELS: Record<string, { label: string; color: string }> = {
|
||||||
approved: { label: 'Approved', color: COLORS.reviewApproved },
|
approved: { label: 'Approved', color: COLORS.reviewApproved },
|
||||||
};
|
};
|
||||||
|
|
||||||
const ACTIVITY_KANBAN_CLEARANCE = 24;
|
|
||||||
|
|
||||||
export function getOwnerKanbanBaseX(args: {
|
export function getOwnerKanbanBaseX(args: {
|
||||||
ownerX: number;
|
ownerX: number;
|
||||||
ownerKind: GraphNode['kind'];
|
ownerKind: GraphNode['kind'];
|
||||||
|
|
@ -52,7 +48,7 @@ export function getOwnerKanbanBaseX(args: {
|
||||||
columnWidth: number;
|
columnWidth: number;
|
||||||
leadX?: number | null;
|
leadX?: number | null;
|
||||||
}): number {
|
}): number {
|
||||||
const { ownerX, ownerKind, activeColumnCount, columnWidth, leadX } = args;
|
const { ownerX, ownerKind, activeColumnCount, columnWidth } = args;
|
||||||
if (activeColumnCount <= 0) {
|
if (activeColumnCount <= 0) {
|
||||||
return ownerX;
|
return ownerX;
|
||||||
}
|
}
|
||||||
|
|
@ -61,17 +57,7 @@ export function getOwnerKanbanBaseX(args: {
|
||||||
return ownerX - (activeColumnCount * columnWidth) / 2;
|
return ownerX - (activeColumnCount * columnWidth) / 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
const side = resolveActivityLaneSide({
|
return ownerX - ((activeColumnCount - 1) * columnWidth) / 2;
|
||||||
nodeKind: ownerKind,
|
|
||||||
nodeX: ownerX,
|
|
||||||
leadX,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (side === 'left') {
|
|
||||||
return ownerX;
|
|
||||||
}
|
|
||||||
|
|
||||||
return ownerX - (activeColumnCount - 1) * columnWidth;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class KanbanLayoutEngine {
|
export class KanbanLayoutEngine {
|
||||||
|
|
@ -91,8 +77,8 @@ export class KanbanLayoutEngine {
|
||||||
static layout(
|
static layout(
|
||||||
nodes: GraphNode[],
|
nodes: GraphNode[],
|
||||||
options?: {
|
options?: {
|
||||||
activityLaneBounds?: readonly ActivityLaneWorldBounds[];
|
|
||||||
memberSlotFrames?: readonly SlotFrame[];
|
memberSlotFrames?: readonly SlotFrame[];
|
||||||
|
leadSlotFrame?: SlotFrame | null;
|
||||||
unassignedTaskRect?: StableRect | null;
|
unassignedTaskRect?: StableRect | null;
|
||||||
}
|
}
|
||||||
): void {
|
): void {
|
||||||
|
|
@ -100,10 +86,12 @@ export class KanbanLayoutEngine {
|
||||||
nodeMap.clear();
|
nodeMap.clear();
|
||||||
for (const n of nodes) nodeMap.set(n.id, n);
|
for (const n of nodes) nodeMap.set(n.id, n);
|
||||||
const leadX = nodes.find((node) => node.kind === 'lead')?.x ?? null;
|
const leadX = nodes.find((node) => node.kind === 'lead')?.x ?? null;
|
||||||
const activityLaneBounds = options?.activityLaneBounds ?? [];
|
const ownerSlotFrameByOwnerId = new Map(
|
||||||
const memberSlotFrameByOwnerId = new Map(
|
|
||||||
(options?.memberSlotFrames ?? []).map((frame) => [frame.ownerId, frame] as const)
|
(options?.memberSlotFrames ?? []).map((frame) => [frame.ownerId, frame] as const)
|
||||||
);
|
);
|
||||||
|
if (options?.leadSlotFrame) {
|
||||||
|
ownerSlotFrameByOwnerId.set(options.leadSlotFrame.ownerId, options.leadSlotFrame);
|
||||||
|
}
|
||||||
|
|
||||||
const tasksByOwner = this.#tasksByOwner;
|
const tasksByOwner = this.#tasksByOwner;
|
||||||
tasksByOwner.clear();
|
tasksByOwner.clear();
|
||||||
|
|
@ -115,10 +103,10 @@ export class KanbanLayoutEngine {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (owner.kind === 'lead') {
|
if (owner.kind === 'lead') {
|
||||||
return true;
|
return ownerSlotFrameByOwnerId.has(ownerId);
|
||||||
}
|
}
|
||||||
if (owner.kind === 'member') {
|
if (owner.kind === 'member') {
|
||||||
return memberSlotFrameByOwnerId.has(ownerId);
|
return ownerSlotFrameByOwnerId.has(ownerId);
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
@ -148,8 +136,7 @@ export class KanbanLayoutEngine {
|
||||||
owner,
|
owner,
|
||||||
ownerId,
|
ownerId,
|
||||||
leadX,
|
leadX,
|
||||||
activityLaneBounds,
|
ownerSlotFrameByOwnerId.get(ownerId) ?? null
|
||||||
memberSlotFrameByOwnerId.get(ownerId) ?? null
|
|
||||||
);
|
);
|
||||||
if (zoneInfo) this.zones.push(zoneInfo);
|
if (zoneInfo) this.zones.push(zoneInfo);
|
||||||
}
|
}
|
||||||
|
|
@ -164,11 +151,9 @@ export class KanbanLayoutEngine {
|
||||||
owner: GraphNode,
|
owner: GraphNode,
|
||||||
ownerId: string,
|
ownerId: string,
|
||||||
leadX: number | null,
|
leadX: number | null,
|
||||||
activityLaneBounds: readonly ActivityLaneWorldBounds[],
|
|
||||||
slotFrame: SlotFrame | null
|
slotFrame: SlotFrame | null
|
||||||
): KanbanZoneInfo | null {
|
): KanbanZoneInfo | null {
|
||||||
const { columnWidth, rowHeight, offsetY, columns } = KANBAN_ZONE;
|
const { columnWidth, rowHeight, offsetY, columns, headerHeight } = KANBAN_ZONE;
|
||||||
const headerHeight = 20; // space for column header label
|
|
||||||
const ownerX = owner.x ?? 0;
|
const ownerX = owner.x ?? 0;
|
||||||
const ownerY = owner.y ?? 0;
|
const ownerY = owner.y ?? 0;
|
||||||
|
|
||||||
|
|
@ -193,8 +178,6 @@ export class KanbanLayoutEngine {
|
||||||
|
|
||||||
if (activeColumns.length === 0) return null;
|
if (activeColumns.length === 0) return null;
|
||||||
|
|
||||||
// Keep kanban columns on the open side of the owner, away from the reserved activity lane.
|
|
||||||
// This makes member lanes reserve real visual space instead of only affecting the force layout.
|
|
||||||
let baseX = getOwnerKanbanBaseX({
|
let baseX = getOwnerKanbanBaseX({
|
||||||
ownerX,
|
ownerX,
|
||||||
ownerKind: owner.kind,
|
ownerKind: owner.kind,
|
||||||
|
|
@ -205,27 +188,10 @@ export class KanbanLayoutEngine {
|
||||||
let baseY: number;
|
let baseY: number;
|
||||||
|
|
||||||
if (slotFrame) {
|
if (slotFrame) {
|
||||||
baseX = slotFrame.taskBandRect.left + TASK_PILL.width / 2;
|
baseX = slotFrame.kanbanBandRect.left + TASK_PILL.width / 2;
|
||||||
baseY = slotFrame.taskBandRect.top;
|
baseY = slotFrame.kanbanBandRect.top;
|
||||||
} else {
|
} else {
|
||||||
const taskZoneLeft = baseX - TASK_PILL.width / 2;
|
baseY = ownerY + offsetY;
|
||||||
const taskZoneRight =
|
|
||||||
baseX + (activeColumns.length - 1) * columnWidth + TASK_PILL.width / 2;
|
|
||||||
const overlappingActivityBottom = activityLaneBounds.reduce((maxBottom, bounds) => {
|
|
||||||
if (bounds.ownerId === ownerId) {
|
|
||||||
return Math.max(maxBottom, bounds.bottom);
|
|
||||||
}
|
|
||||||
if (!rangesOverlap(taskZoneLeft, taskZoneRight, bounds.left, bounds.right)) {
|
|
||||||
return maxBottom;
|
|
||||||
}
|
|
||||||
return Math.max(maxBottom, bounds.bottom);
|
|
||||||
}, -Infinity);
|
|
||||||
baseY = Math.max(
|
|
||||||
ownerY + offsetY,
|
|
||||||
overlappingActivityBottom > -Infinity
|
|
||||||
? overlappingActivityBottom + ACTIVITY_KANBAN_CLEARANCE
|
|
||||||
: -Infinity
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build headers + position tasks
|
// Build headers + position tasks
|
||||||
|
|
@ -376,7 +342,3 @@ export class KanbanLayoutEngine {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function rangesOverlap(aStart: number, aEnd: number, bStart: number, bEnd: number): boolean {
|
|
||||||
return aStart < bEnd && bStart < aEnd;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -24,6 +24,7 @@ import { drawAgents, drawCrossTeamNodes } from '../canvas/draw-agents';
|
||||||
import { drawTasks, drawColumnHeaders } from '../canvas/draw-tasks';
|
import { drawTasks, drawColumnHeaders } from '../canvas/draw-tasks';
|
||||||
import { drawProcesses } from '../canvas/draw-processes';
|
import { drawProcesses } from '../canvas/draw-processes';
|
||||||
import { drawEffects, type VisualEffect } from '../canvas/draw-effects';
|
import { drawEffects, type VisualEffect } from '../canvas/draw-effects';
|
||||||
|
import { drawHexagon } from '../canvas/draw-misc';
|
||||||
import { BloomRenderer } from '../canvas/bloom-renderer';
|
import { BloomRenderer } from '../canvas/bloom-renderer';
|
||||||
import { KanbanLayoutEngine } from '../layout/kanbanLayout';
|
import { KanbanLayoutEngine } from '../layout/kanbanLayout';
|
||||||
import {
|
import {
|
||||||
|
|
@ -36,6 +37,7 @@ import {
|
||||||
updateTransientHandoffState,
|
updateTransientHandoffState,
|
||||||
} from './transientHandoffs';
|
} from './transientHandoffs';
|
||||||
import type { CameraTransform } from '../hooks/useGraphCamera';
|
import type { CameraTransform } from '../hooks/useGraphCamera';
|
||||||
|
import { NODE } from '../constants/canvas-constants';
|
||||||
|
|
||||||
// ─── Draw State (passed by ref, not by props — no React re-renders) ─────────
|
// ─── Draw State (passed by ref, not by props — no React re-renders) ─────────
|
||||||
|
|
||||||
|
|
@ -53,6 +55,14 @@ export interface GraphDrawState {
|
||||||
hoveredEdgeId: string | null;
|
hoveredEdgeId: string | null;
|
||||||
focusNodeIds: ReadonlySet<string> | null;
|
focusNodeIds: ReadonlySet<string> | null;
|
||||||
focusEdgeIds: ReadonlySet<string> | null;
|
focusEdgeIds: ReadonlySet<string> | null;
|
||||||
|
dragPreview:
|
||||||
|
| {
|
||||||
|
nodeId: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
color?: string | null;
|
||||||
|
}
|
||||||
|
| null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GraphCanvasHandle {
|
export interface GraphCanvasHandle {
|
||||||
|
|
@ -341,6 +351,9 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(funct
|
||||||
state.focusNodeIds,
|
state.focusNodeIds,
|
||||||
zoom
|
zoom
|
||||||
);
|
);
|
||||||
|
if (state.dragPreview) {
|
||||||
|
drawOwnerSlotPreview(ctx, state.dragPreview, state.time);
|
||||||
|
}
|
||||||
|
|
||||||
// 2d. Effects
|
// 2d. Effects
|
||||||
drawEffects(ctx, state.effects);
|
drawEffects(ctx, state.effects);
|
||||||
|
|
@ -437,3 +450,47 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(funct
|
||||||
</div>
|
</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();
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ export interface GraphControlsProps {
|
||||||
teamName: string;
|
teamName: string;
|
||||||
teamColor?: string;
|
teamColor?: string;
|
||||||
isAlive?: boolean;
|
isAlive?: boolean;
|
||||||
|
topToolbarContent?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TOPBAR_BUTTON_SIZE = 25;
|
const TOPBAR_BUTTON_SIZE = 25;
|
||||||
|
|
@ -67,6 +68,7 @@ export function GraphControls({
|
||||||
onToggleSidebar,
|
onToggleSidebar,
|
||||||
isSidebarVisible = true,
|
isSidebarVisible = true,
|
||||||
teamColor,
|
teamColor,
|
||||||
|
topToolbarContent,
|
||||||
}: GraphControlsProps): React.JSX.Element {
|
}: GraphControlsProps): React.JSX.Element {
|
||||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||||
const settingsRef = useRef<HTMLDivElement>(null);
|
const settingsRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
@ -105,160 +107,170 @@ export function GraphControls({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="absolute left-3 top-3 z-20 flex items-center gap-0.5 pointer-events-none">
|
<div className="absolute inset-x-3 top-3 z-20 flex items-start gap-2 pointer-events-none">
|
||||||
{onToggleSidebar ? (
|
<div className="flex shrink-0 items-center gap-0.5">
|
||||||
<div
|
{onToggleSidebar ? (
|
||||||
className="pointer-events-auto flex items-center rounded-md p-0 backdrop-blur-sm"
|
<div
|
||||||
style={{
|
className="pointer-events-auto flex items-center rounded-md p-0 backdrop-blur-sm"
|
||||||
background: 'rgba(8, 12, 24, 0.8)',
|
style={{
|
||||||
border: `1px solid ${nameColor}25`,
|
background: 'rgba(8, 12, 24, 0.8)',
|
||||||
}}
|
border: `1px solid ${nameColor}25`,
|
||||||
>
|
}}
|
||||||
<ToolbarButton
|
>
|
||||||
onClick={onToggleSidebar}
|
<ToolbarButton
|
||||||
icon={
|
onClick={onToggleSidebar}
|
||||||
isSidebarVisible ? (
|
icon={
|
||||||
<PanelLeftClose size={TOPBAR_ICON_SIZE} />
|
isSidebarVisible ? (
|
||||||
) : (
|
<PanelLeftClose size={TOPBAR_ICON_SIZE} />
|
||||||
<PanelLeftOpen size={TOPBAR_ICON_SIZE} />
|
) : (
|
||||||
)
|
<PanelLeftOpen size={TOPBAR_ICON_SIZE} />
|
||||||
}
|
)
|
||||||
toolbar
|
}
|
||||||
title={isSidebarVisible ? 'Hide sidebar' : 'Show sidebar'}
|
toolbar
|
||||||
/>
|
title={isSidebarVisible ? 'Hide sidebar' : 'Show sidebar'}
|
||||||
</div>
|
/>
|
||||||
) : null}
|
</div>
|
||||||
{onOpenTeamPage ? (
|
) : null}
|
||||||
<div
|
{onOpenTeamPage ? (
|
||||||
className="pointer-events-auto flex items-center rounded-md p-0 backdrop-blur-sm"
|
<div
|
||||||
style={{
|
className="pointer-events-auto flex items-center rounded-md p-0 backdrop-blur-sm"
|
||||||
background: 'rgba(8, 12, 24, 0.8)',
|
style={{
|
||||||
border: `1px solid ${nameColor}25`,
|
background: 'rgba(8, 12, 24, 0.8)',
|
||||||
}}
|
border: `1px solid ${nameColor}25`,
|
||||||
>
|
}}
|
||||||
<ToolbarButton
|
>
|
||||||
onClick={onOpenTeamPage}
|
<ToolbarButton
|
||||||
icon={<Users size={TOPBAR_ICON_SIZE} />}
|
onClick={onOpenTeamPage}
|
||||||
toolbar
|
icon={<Users size={TOPBAR_ICON_SIZE} />}
|
||||||
title="Open team page"
|
toolbar
|
||||||
/>
|
title="Open team page"
|
||||||
</div>
|
/>
|
||||||
) : null}
|
</div>
|
||||||
{onCreateTask ? (
|
) : null}
|
||||||
<div
|
{onCreateTask ? (
|
||||||
className="pointer-events-auto flex items-center rounded-md p-0 backdrop-blur-sm"
|
<div
|
||||||
style={{
|
className="pointer-events-auto flex items-center rounded-md p-0 backdrop-blur-sm"
|
||||||
background: 'rgba(8, 12, 24, 0.8)',
|
style={{
|
||||||
border: `1px solid ${nameColor}25`,
|
background: 'rgba(8, 12, 24, 0.8)',
|
||||||
}}
|
border: `1px solid ${nameColor}25`,
|
||||||
>
|
}}
|
||||||
<ToolbarButton
|
>
|
||||||
onClick={onCreateTask}
|
<ToolbarButton
|
||||||
icon={<Plus size={TOPBAR_ICON_SIZE} />}
|
onClick={onCreateTask}
|
||||||
toolbar
|
icon={<Plus size={TOPBAR_ICON_SIZE} />}
|
||||||
title="Create task"
|
toolbar
|
||||||
/>
|
title="Create task"
|
||||||
</div>
|
/>
|
||||||
) : null}
|
</div>
|
||||||
</div>
|
) : null}
|
||||||
|
|
||||||
<div className="absolute right-3 top-3 z-20 flex items-center gap-0.5 pointer-events-none">
|
|
||||||
<div
|
|
||||||
className="pointer-events-auto flex items-center rounded-md p-0 backdrop-blur-sm"
|
|
||||||
style={{
|
|
||||||
background: 'rgba(8, 12, 24, 0.8)',
|
|
||||||
border: '1px solid rgba(100, 200, 255, 0.08)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ToolbarButton
|
|
||||||
onClick={() => toggle('paused')}
|
|
||||||
icon={filters.paused ? <Play size={TOPBAR_ICON_SIZE} /> : <Pause size={TOPBAR_ICON_SIZE} />}
|
|
||||||
toolbar
|
|
||||||
title={filters.paused ? 'Resume animation' : 'Pause animation'}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div ref={settingsRef} className="relative pointer-events-auto">
|
<div className="flex min-w-0 flex-1 justify-end px-2">
|
||||||
|
{topToolbarContent ? (
|
||||||
|
<div className="pointer-events-auto min-w-0 max-w-[min(360px,42vw)]">
|
||||||
|
{topToolbarContent}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 items-center gap-0.5">
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-0.5 rounded-md p-0 backdrop-blur-sm"
|
className="pointer-events-auto flex items-center rounded-md p-0 backdrop-blur-sm"
|
||||||
style={{
|
style={{
|
||||||
background: 'rgba(8, 12, 24, 0.8)',
|
background: 'rgba(8, 12, 24, 0.8)',
|
||||||
border: '1px solid rgba(100, 200, 255, 0.08)',
|
border: '1px solid rgba(100, 200, 255, 0.08)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
onClick={() => setIsSettingsOpen((value) => !value)}
|
onClick={() => toggle('paused')}
|
||||||
icon={<Settings2 size={TOPBAR_ICON_SIZE} />}
|
icon={filters.paused ? <Play size={TOPBAR_ICON_SIZE} /> : <Pause size={TOPBAR_ICON_SIZE} />}
|
||||||
active={isSettingsOpen}
|
|
||||||
toolbar
|
toolbar
|
||||||
title="Graph settings"
|
title={filters.paused ? 'Resume animation' : 'Pause animation'}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isSettingsOpen && (
|
<div ref={settingsRef} className="relative pointer-events-auto">
|
||||||
<div
|
<div
|
||||||
className="absolute right-0 top-[calc(100%+0.5rem)] w-44 rounded-xl p-1.5 shadow-2xl"
|
className="flex items-center gap-0.5 rounded-md p-0 backdrop-blur-sm"
|
||||||
style={{
|
style={{
|
||||||
background: 'rgba(8, 12, 24, 0.96)',
|
background: 'rgba(8, 12, 24, 0.8)',
|
||||||
border: '1px solid rgba(100, 200, 255, 0.12)',
|
border: '1px solid rgba(100, 200, 255, 0.08)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ToolbarToggle
|
<ToolbarButton
|
||||||
active={filters.showTasks}
|
onClick={() => setIsSettingsOpen((value) => !value)}
|
||||||
onClick={() => toggle('showTasks')}
|
icon={<Settings2 size={TOPBAR_ICON_SIZE} />}
|
||||||
icon={<Columns3 size={13} />}
|
active={isSettingsOpen}
|
||||||
label="Tasks"
|
toolbar
|
||||||
block
|
title="Graph settings"
|
||||||
/>
|
|
||||||
<ToolbarToggle
|
|
||||||
active={filters.showProcesses}
|
|
||||||
onClick={() => toggle('showProcesses')}
|
|
||||||
icon={<Server size={13} />}
|
|
||||||
label="Processes"
|
|
||||||
block
|
|
||||||
/>
|
|
||||||
<ToolbarToggle
|
|
||||||
active={filters.showEdges}
|
|
||||||
onClick={() => toggle('showEdges')}
|
|
||||||
icon={filters.showEdges ? <Eye size={13} /> : <EyeOff size={13} />}
|
|
||||||
label="Edges"
|
|
||||||
block
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
{isSettingsOpen && (
|
||||||
className="pointer-events-auto flex items-center gap-0.5 rounded-md p-0 backdrop-blur-sm"
|
<div
|
||||||
style={{
|
className="absolute right-0 top-[calc(100%+0.5rem)] w-44 rounded-xl p-1.5 shadow-2xl"
|
||||||
background: 'rgba(8, 12, 24, 0.8)',
|
style={{
|
||||||
border: '1px solid rgba(100, 200, 255, 0.08)',
|
background: 'rgba(8, 12, 24, 0.96)',
|
||||||
}}
|
border: '1px solid rgba(100, 200, 255, 0.12)',
|
||||||
>
|
}}
|
||||||
{onRequestPinAsTab && (
|
>
|
||||||
<ToolbarButton
|
<ToolbarToggle
|
||||||
onClick={onRequestPinAsTab}
|
active={filters.showTasks}
|
||||||
icon={<Pin size={TOPBAR_ICON_SIZE} />}
|
onClick={() => toggle('showTasks')}
|
||||||
toolbar
|
icon={<Columns3 size={13} />}
|
||||||
title="Pin as tab"
|
label="Tasks"
|
||||||
/>
|
block
|
||||||
)}
|
/>
|
||||||
{onRequestFullscreen && (
|
<ToolbarToggle
|
||||||
<ToolbarButton
|
active={filters.showProcesses}
|
||||||
onClick={onRequestFullscreen}
|
onClick={() => toggle('showProcesses')}
|
||||||
icon={<Expand size={TOPBAR_ICON_SIZE} />}
|
icon={<Server size={13} />}
|
||||||
toolbar
|
label="Processes"
|
||||||
title="Fullscreen"
|
block
|
||||||
/>
|
/>
|
||||||
)}
|
<ToolbarToggle
|
||||||
{onRequestClose && (
|
active={filters.showEdges}
|
||||||
<ToolbarButton
|
onClick={() => toggle('showEdges')}
|
||||||
onClick={onRequestClose}
|
icon={filters.showEdges ? <Eye size={13} /> : <EyeOff size={13} />}
|
||||||
icon={<X size={TOPBAR_ICON_SIZE} />}
|
label="Edges"
|
||||||
toolbar
|
block
|
||||||
title="Close graph"
|
/>
|
||||||
/>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="pointer-events-auto flex items-center gap-0.5 rounded-md p-0 backdrop-blur-sm"
|
||||||
|
style={{
|
||||||
|
background: 'rgba(8, 12, 24, 0.8)',
|
||||||
|
border: '1px solid rgba(100, 200, 255, 0.08)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{onRequestPinAsTab && (
|
||||||
|
<ToolbarButton
|
||||||
|
onClick={onRequestPinAsTab}
|
||||||
|
icon={<Pin size={TOPBAR_ICON_SIZE} />}
|
||||||
|
toolbar
|
||||||
|
title="Pin as tab"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{onRequestFullscreen && (
|
||||||
|
<ToolbarButton
|
||||||
|
onClick={onRequestFullscreen}
|
||||||
|
icon={<Expand size={TOPBAR_ICON_SIZE} />}
|
||||||
|
toolbar
|
||||||
|
title="Fullscreen"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{onRequestClose && (
|
||||||
|
<ToolbarButton
|
||||||
|
onClick={onRequestClose}
|
||||||
|
icon={<X size={TOPBAR_ICON_SIZE} />}
|
||||||
|
toolbar
|
||||||
|
title="Close graph"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import type { GraphDataPort } from '../ports/GraphDataPort';
|
||||||
import type { GraphEventPort } from '../ports/GraphEventPort';
|
import type { GraphEventPort } from '../ports/GraphEventPort';
|
||||||
import type { GraphConfigPort } from '../ports/GraphConfigPort';
|
import type { GraphConfigPort } from '../ports/GraphConfigPort';
|
||||||
import type { GraphEdge, GraphNode, GraphOwnerSlotAssignment } from '../ports/types';
|
import type { GraphEdge, GraphNode, GraphOwnerSlotAssignment } from '../ports/types';
|
||||||
|
import type { StableRect } from '../layout/stableSlots';
|
||||||
import { GraphCanvas, type GraphCanvasHandle } from './GraphCanvas';
|
import { GraphCanvas, type GraphCanvasHandle } from './GraphCanvas';
|
||||||
import { GraphControls, type GraphFilterState } from './GraphControls';
|
import { GraphControls, type GraphFilterState } from './GraphControls';
|
||||||
import { GraphOverlay } from './GraphOverlay';
|
import { GraphOverlay } from './GraphOverlay';
|
||||||
|
|
@ -31,7 +32,6 @@ import {
|
||||||
getEdgeMidpoint,
|
getEdgeMidpoint,
|
||||||
} from '../canvas/hit-detection';
|
} from '../canvas/hit-detection';
|
||||||
import { ANIM_SPEED } from '../constants/canvas-constants';
|
import { ANIM_SPEED } from '../constants/canvas-constants';
|
||||||
import { getActivityAnchorScreenPlacement as buildActivityAnchorScreenPlacement } from '../layout/activityLane';
|
|
||||||
import { getLaunchAnchorScreenPlacement as buildLaunchAnchorScreenPlacement } from '../layout/launchAnchor';
|
import { getLaunchAnchorScreenPlacement as buildLaunchAnchorScreenPlacement } from '../layout/launchAnchor';
|
||||||
|
|
||||||
export interface GraphViewProps {
|
export interface GraphViewProps {
|
||||||
|
|
@ -43,10 +43,12 @@ export interface GraphViewProps {
|
||||||
onRequestClose?: () => void;
|
onRequestClose?: () => void;
|
||||||
onRequestPinAsTab?: () => void;
|
onRequestPinAsTab?: () => void;
|
||||||
onRequestFullscreen?: () => void;
|
onRequestFullscreen?: () => void;
|
||||||
|
isSurfaceActive?: boolean;
|
||||||
onOpenTeamPage?: () => void;
|
onOpenTeamPage?: () => void;
|
||||||
onCreateTask?: () => void;
|
onCreateTask?: () => void;
|
||||||
onToggleSidebar?: () => void;
|
onToggleSidebar?: () => void;
|
||||||
isSidebarVisible?: boolean;
|
isSidebarVisible?: boolean;
|
||||||
|
renderTopToolbarContent?: () => React.ReactNode;
|
||||||
onOwnerSlotDrop?: (payload: {
|
onOwnerSlotDrop?: (payload: {
|
||||||
nodeId: string;
|
nodeId: string;
|
||||||
assignment: GraphOwnerSlotAssignment;
|
assignment: GraphOwnerSlotAssignment;
|
||||||
|
|
@ -70,19 +72,11 @@ export interface GraphViewProps {
|
||||||
getLaunchAnchorScreenPlacement: (
|
getLaunchAnchorScreenPlacement: (
|
||||||
leadNodeId: string,
|
leadNodeId: string,
|
||||||
) => { x: number; y: number; scale: number; visible: boolean } | null;
|
) => { x: number; y: number; scale: number; visible: boolean } | null;
|
||||||
getActivityAnchorScreenPlacement: (
|
getActivityWorldRect: (ownerNodeId: string) => StableRect | null;
|
||||||
ownerNodeId: string,
|
|
||||||
) => { x: number; y: number; scale: number; visible: boolean } | null;
|
|
||||||
getActivityAnchorWorldPosition: (
|
|
||||||
ownerNodeId: string,
|
|
||||||
) => { x: number; y: number } | null;
|
|
||||||
getCameraZoom: () => number;
|
getCameraZoom: () => number;
|
||||||
worldToScreen: (x: number, y: number) => { x: number; y: number };
|
worldToScreen: (x: number, y: number) => { x: number; y: number };
|
||||||
getNodeWorldPosition: (nodeId: string) => { x: number; y: number } | null;
|
getNodeWorldPosition: (nodeId: string) => { x: number; y: number } | null;
|
||||||
getViewportSize: () => { width: number; height: number };
|
getViewportSize: () => { width: number; height: number };
|
||||||
getNodeScreenPosition: (
|
|
||||||
nodeId: string,
|
|
||||||
) => { x: number; y: number; visible: boolean } | null;
|
|
||||||
focusNodeIds: ReadonlySet<string> | null;
|
focusNodeIds: ReadonlySet<string> | null;
|
||||||
}) => React.ReactNode;
|
}) => React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
@ -96,10 +90,12 @@ export function GraphView({
|
||||||
onRequestClose,
|
onRequestClose,
|
||||||
onRequestPinAsTab,
|
onRequestPinAsTab,
|
||||||
onRequestFullscreen,
|
onRequestFullscreen,
|
||||||
|
isSurfaceActive = true,
|
||||||
onOpenTeamPage,
|
onOpenTeamPage,
|
||||||
onCreateTask,
|
onCreateTask,
|
||||||
onToggleSidebar,
|
onToggleSidebar,
|
||||||
isSidebarVisible = true,
|
isSidebarVisible = true,
|
||||||
|
renderTopToolbarContent,
|
||||||
onOwnerSlotDrop,
|
onOwnerSlotDrop,
|
||||||
renderOverlay,
|
renderOverlay,
|
||||||
renderEdgeOverlay,
|
renderEdgeOverlay,
|
||||||
|
|
@ -133,6 +129,12 @@ export function GraphView({
|
||||||
const allowAutoFitRef = useRef(true);
|
const allowAutoFitRef = useRef(true);
|
||||||
const nodeMapRef = useRef(new Map<string, GraphNode>());
|
const nodeMapRef = useRef(new Map<string, GraphNode>());
|
||||||
const nodeMapNodesRef = useRef<GraphNode[] | null>(null);
|
const nodeMapNodesRef = useRef<GraphNode[] | null>(null);
|
||||||
|
const dragPreviewRef = useRef<{
|
||||||
|
nodeId: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
color?: string | null;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
// ─── Hooks ──────────────────────────────────────────────────────────────
|
// ─── Hooks ──────────────────────────────────────────────────────────────
|
||||||
const simulation = useGraphSimulation();
|
const simulation = useGraphSimulation();
|
||||||
|
|
@ -240,49 +242,11 @@ export function GraphView({
|
||||||
viewportHeight: viewport.height,
|
viewportHeight: viewport.height,
|
||||||
});
|
});
|
||||||
}, [getViewportSize]);
|
}, [getViewportSize]);
|
||||||
const getActivityAnchorScreenPlacement = useCallback((ownerNodeId: string) => {
|
|
||||||
const anchor = simulationRef.current.getActivityAnchorWorldPosition(ownerNodeId);
|
|
||||||
if (!anchor) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const viewport = getViewportSize();
|
|
||||||
if (viewport.width <= 0 || viewport.height <= 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const transform = cameraRef.current.transformRef.current;
|
|
||||||
return buildActivityAnchorScreenPlacement({
|
|
||||||
anchorX: anchor.x,
|
|
||||||
anchorY: anchor.y,
|
|
||||||
cameraX: transform.x,
|
|
||||||
cameraY: transform.y,
|
|
||||||
zoom: transform.zoom,
|
|
||||||
viewportWidth: viewport.width,
|
|
||||||
viewportHeight: viewport.height,
|
|
||||||
});
|
|
||||||
}, [getViewportSize]);
|
|
||||||
const getActivityAnchorWorldPosition = useCallback(
|
|
||||||
(ownerNodeId: string) => simulationRef.current.getActivityAnchorWorldPosition(ownerNodeId),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
const getCameraZoom = useCallback(() => cameraRef.current.transformRef.current.zoom, []);
|
const getCameraZoom = useCallback(() => cameraRef.current.transformRef.current.zoom, []);
|
||||||
const getNodeScreenPosition = useCallback((nodeId: string) => {
|
const getActivityWorldRect = useCallback(
|
||||||
const viewport = getViewportSize();
|
(ownerNodeId: string) => simulationRef.current.getActivityWorldRect(ownerNodeId),
|
||||||
if (viewport.width <= 0 || viewport.height <= 0) {
|
[]
|
||||||
return null;
|
);
|
||||||
}
|
|
||||||
const node = simulationRef.current.stateRef.current.nodes.find((candidate) => candidate.id === nodeId);
|
|
||||||
if (node?.x == null || node?.y == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const transform = cameraRef.current.transformRef.current;
|
|
||||||
const x = node.x * transform.zoom + transform.x;
|
|
||||||
const y = node.y * transform.zoom + transform.y;
|
|
||||||
return {
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
visible: x > -80 && x < viewport.width + 80 && y > -80 && y < viewport.height + 80,
|
|
||||||
};
|
|
||||||
}, [getViewportSize]);
|
|
||||||
const getNodeWorldPosition = useCallback((nodeId: string) => {
|
const getNodeWorldPosition = useCallback((nodeId: string) => {
|
||||||
const node = simulationRef.current.stateRef.current.nodes.find((candidate) => candidate.id === nodeId);
|
const node = simulationRef.current.stateRef.current.nodes.find((candidate) => candidate.id === nodeId);
|
||||||
if (node?.x == null || node?.y == null) {
|
if (node?.x == null || node?.y == null) {
|
||||||
|
|
@ -328,6 +292,7 @@ export function GraphView({
|
||||||
hoveredEdgeId: hoveredEdgeIdRef.current,
|
hoveredEdgeId: hoveredEdgeIdRef.current,
|
||||||
focusNodeIds: focusState.focusNodeIds,
|
focusNodeIds: focusState.focusNodeIds,
|
||||||
focusEdgeIds: focusState.focusEdgeIds,
|
focusEdgeIds: focusState.focusEdgeIds,
|
||||||
|
dragPreview: dragPreviewRef.current,
|
||||||
});
|
});
|
||||||
|
|
||||||
rafRef.current = requestAnimationFrame(animate);
|
rafRef.current = requestAnimationFrame(animate);
|
||||||
|
|
@ -414,6 +379,17 @@ export function GraphView({
|
||||||
allowAutoFitRef.current = false;
|
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(
|
const handleWheel = useCallback(
|
||||||
(e: WheelEvent) => {
|
(e: WheelEvent) => {
|
||||||
markUserInteracted();
|
markUserInteracted();
|
||||||
|
|
@ -429,6 +405,7 @@ export function GraphView({
|
||||||
const handleMouseDown = useCallback(
|
const handleMouseDown = useCallback(
|
||||||
(e: React.MouseEvent) => {
|
(e: React.MouseEvent) => {
|
||||||
if (e.button !== 0) return; // only left click
|
if (e.button !== 0) return; // only left click
|
||||||
|
dragPreviewRef.current = null;
|
||||||
|
|
||||||
const canvas = canvasHandle.current?.getCanvas();
|
const canvas = canvasHandle.current?.getCanvas();
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
|
|
@ -479,59 +456,64 @@ export function GraphView({
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleMouseMove = useCallback(
|
const processActivePointerMove = useCallback(
|
||||||
(e: React.MouseEvent) => {
|
(clientX: number, clientY: number, buttons: number) => {
|
||||||
// Dragging with left button held
|
if ((buttons & 1) === 0) {
|
||||||
if (e.buttons & 1) {
|
dragPreviewRef.current = null;
|
||||||
if (isPanningRef.current) {
|
return false;
|
||||||
camera.handlePanMove(e.clientX, e.clientY);
|
}
|
||||||
return;
|
|
||||||
}
|
if (isPanningRef.current) {
|
||||||
const canvas = canvasHandle.current?.getCanvas();
|
camera.handlePanMove(clientX, clientY);
|
||||||
if (!canvas) return;
|
return true;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// No button held — hover detection + cursor update
|
|
||||||
const canvas = canvasHandle.current?.getCanvas();
|
const canvas = canvasHandle.current?.getCanvas();
|
||||||
if (!canvas) return;
|
if (!canvas) {
|
||||||
const rect = canvas.getBoundingClientRect();
|
dragPreviewRef.current = null;
|
||||||
const world = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top);
|
return false;
|
||||||
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 rect = canvas.getBoundingClientRect();
|
||||||
const interactiveEdges = getInteractiveEdges(canvas, nodes, edges);
|
const world = camera.screenToWorld(clientX - rect.left, clientY - rect.top);
|
||||||
hoveredEdgeIdRef.current = findEdgeAt(world.x, world.y, interactiveEdges, nodeMap);
|
interaction.handleMouseMove(world.x, world.y, getVisibleNodes(simulation.stateRef.current.nodes));
|
||||||
canvas.style.cursor = hoveredEdgeIdRef.current ? 'pointer' : 'grab';
|
|
||||||
|
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(
|
const completePointerInteraction = useCallback(
|
||||||
(e: React.MouseEvent) => {
|
(clientX: number, clientY: number) => {
|
||||||
const draggedNodeId = interaction.dragNodeId.current;
|
const draggedNodeId = interaction.dragNodeId.current;
|
||||||
const wasDragging = interaction.isDragging.current;
|
const wasDragging = interaction.isDragging.current;
|
||||||
|
|
||||||
if (isPanningRef.current) {
|
if (isPanningRef.current) {
|
||||||
camera.handlePanEnd();
|
camera.handlePanEnd();
|
||||||
isPanningRef.current = false;
|
isPanningRef.current = false;
|
||||||
setSelectedNodeId(null); // hide popover after pan
|
dragPreviewRef.current = null;
|
||||||
|
setSelectedNodeId(null);
|
||||||
setSelectedEdgeId(null);
|
setSelectedEdgeId(null);
|
||||||
edgeMouseDownRef.current = null;
|
edgeMouseDownRef.current = null;
|
||||||
|
interaction.handleMouseUp();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -554,11 +536,13 @@ export function GraphView({
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
simulation.clearNodePosition(draggedNodeId);
|
simulation.clearNodePosition(draggedNodeId);
|
||||||
});
|
});
|
||||||
|
dragPreviewRef.current = null;
|
||||||
edgeMouseDownRef.current = null;
|
edgeMouseDownRef.current = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
simulation.clearNodePosition(draggedNodeId);
|
simulation.clearNodePosition(draggedNodeId);
|
||||||
|
dragPreviewRef.current = null;
|
||||||
edgeMouseDownRef.current = null;
|
edgeMouseDownRef.current = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -573,7 +557,7 @@ export function GraphView({
|
||||||
let clickedEdgeId: string | null = null;
|
let clickedEdgeId: string | null = null;
|
||||||
if (canvas && edgeMouseDownRef.current && !interaction.isDragging.current) {
|
if (canvas && edgeMouseDownRef.current && !interaction.isDragging.current) {
|
||||||
const rect = canvas.getBoundingClientRect();
|
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 dx = world.x - edgeMouseDownRef.current.x;
|
||||||
const dy = world.y - edgeMouseDownRef.current.y;
|
const dy = world.y - edgeMouseDownRef.current.y;
|
||||||
if (dx * dx + dy * dy <= 25) {
|
if (dx * dx + dy * dy <= 25) {
|
||||||
|
|
@ -592,17 +576,103 @@ export function GraphView({
|
||||||
events?.onEdgeClick?.(edge);
|
events?.onEdgeClick?.(edge);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setSelectedNodeId(null); // click on empty space — hide popover
|
setSelectedNodeId(null);
|
||||||
setSelectedEdgeId(null);
|
setSelectedEdgeId(null);
|
||||||
}
|
}
|
||||||
if (!interaction.isDragging.current && !clickedEdgeId) {
|
if (!interaction.isDragging.current && !clickedEdgeId) {
|
||||||
events?.onBackgroundClick?.();
|
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(
|
const handleDoubleClick = useCallback(
|
||||||
(e: React.MouseEvent) => {
|
(e: React.MouseEvent) => {
|
||||||
const canvas = canvasHandle.current?.getCanvas();
|
const canvas = canvasHandle.current?.getCanvas();
|
||||||
|
|
@ -794,19 +864,18 @@ export function GraphView({
|
||||||
teamName={data.teamName}
|
teamName={data.teamName}
|
||||||
teamColor={data.teamColor}
|
teamColor={data.teamColor}
|
||||||
isAlive={data.isAlive}
|
isAlive={data.isAlive}
|
||||||
|
topToolbarContent={renderTopToolbarContent?.()}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{renderHud ? (
|
{renderHud ? (
|
||||||
<div className="pointer-events-none absolute inset-0 z-[5] overflow-hidden">
|
<div className="pointer-events-none absolute inset-0 z-[5] overflow-hidden">
|
||||||
{renderHud({
|
{renderHud({
|
||||||
getLaunchAnchorScreenPlacement,
|
getLaunchAnchorScreenPlacement,
|
||||||
getActivityAnchorScreenPlacement,
|
getActivityWorldRect,
|
||||||
getActivityAnchorWorldPosition,
|
|
||||||
getCameraZoom,
|
getCameraZoom,
|
||||||
worldToScreen: camera.worldToScreen,
|
worldToScreen: camera.worldToScreen,
|
||||||
getNodeWorldPosition,
|
getNodeWorldPosition,
|
||||||
getViewportSize,
|
getViewportSize,
|
||||||
getNodeScreenPosition,
|
|
||||||
focusNodeIds: focusState.focusNodeIds,
|
focusNodeIds: focusState.focusNodeIds,
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { getIdleGraphLabel } from '@shared/utils/idleNotificationSemantics';
|
||||||
import { isInboxNoiseMessage } from '@shared/utils/inboxNoise';
|
import { isInboxNoiseMessage } from '@shared/utils/inboxNoise';
|
||||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
|
|
||||||
import { buildGraphMemberNodeIdForMember } from './graphOwnerIdentity';
|
import { buildGraphMemberNodeIdAliasMap } from './graphOwnerIdentity';
|
||||||
|
|
||||||
import type { GraphActivityItem } from '@claude-teams/agent-graph';
|
import type { GraphActivityItem } from '@claude-teams/agent-graph';
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -53,10 +53,9 @@ export function buildInlineActivityEntries({
|
||||||
ownerNodeIds,
|
ownerNodeIds,
|
||||||
}: BuildInlineActivityEntriesArgs): Map<string, InlineActivityEntry[]> {
|
}: BuildInlineActivityEntriesArgs): Map<string, InlineActivityEntry[]> {
|
||||||
const entriesByOwnerNodeId = new Map<string, InlineActivityEntry[]>();
|
const entriesByOwnerNodeId = new Map<string, InlineActivityEntry[]>();
|
||||||
const memberNodeIdByName = new Map(
|
const memberNodeIdByAlias = buildGraphMemberNodeIdAliasMap(
|
||||||
data.members
|
teamName,
|
||||||
.filter((member) => !isLeadMember(member))
|
data.members.filter((member) => !isLeadMember(member))
|
||||||
.map((member) => [member.name, buildGraphMemberNodeIdForMember(teamName, member)] as const)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const appendEntry = (entry: InlineActivityEntry): void => {
|
const appendEntry = (entry: InlineActivityEntry): void => {
|
||||||
|
|
@ -95,7 +94,7 @@ export function buildInlineActivityEntries({
|
||||||
leadId,
|
leadId,
|
||||||
leadName,
|
leadName,
|
||||||
ownerNodeIds,
|
ownerNodeIds,
|
||||||
memberNodeIdByName,
|
memberNodeIdByAlias,
|
||||||
});
|
});
|
||||||
if (!ownerNodeId) {
|
if (!ownerNodeId) {
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -140,7 +139,7 @@ export function buildInlineActivityEntries({
|
||||||
leadId,
|
leadId,
|
||||||
leadName,
|
leadName,
|
||||||
ownerNodeIds,
|
ownerNodeIds,
|
||||||
memberNodeIdByName,
|
memberNodeIdByAlias,
|
||||||
});
|
});
|
||||||
if (!ownerNodeId) {
|
if (!ownerNodeId) {
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -220,16 +219,16 @@ function resolveMessageOwnerNodeId(args: {
|
||||||
leadId: string;
|
leadId: string;
|
||||||
leadName: string;
|
leadName: string;
|
||||||
ownerNodeIds: ReadonlySet<string>;
|
ownerNodeIds: ReadonlySet<string>;
|
||||||
memberNodeIdByName: ReadonlyMap<string, string>;
|
memberNodeIdByAlias: ReadonlyMap<string, string>;
|
||||||
}): string | null {
|
}): string | null {
|
||||||
const { message, leadId, leadName, ownerNodeIds, memberNodeIdByName } = args;
|
const { message, leadId, leadName, ownerNodeIds, memberNodeIdByAlias } = args;
|
||||||
if (message.source === 'cross_team' || message.source === 'cross_team_sent') {
|
if (message.source === 'cross_team' || message.source === 'cross_team_sent') {
|
||||||
return leadId;
|
return leadId;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fromId = resolveParticipantId(message.from ?? '', leadId, leadName, memberNodeIdByName);
|
const fromId = resolveParticipantId(message.from ?? '', leadId, leadName, memberNodeIdByAlias);
|
||||||
const toId = message.to
|
const toId = message.to
|
||||||
? resolveParticipantId(message.to, leadId, leadName, memberNodeIdByName)
|
? resolveParticipantId(message.to, leadId, leadName, memberNodeIdByAlias)
|
||||||
: leadId;
|
: leadId;
|
||||||
|
|
||||||
if (toId !== leadId && ownerNodeIds.has(toId)) {
|
if (toId !== leadId && ownerNodeIds.has(toId)) {
|
||||||
|
|
@ -247,17 +246,17 @@ function resolveCommentOwnerNodeId(args: {
|
||||||
leadId: string;
|
leadId: string;
|
||||||
leadName: string;
|
leadName: string;
|
||||||
ownerNodeIds: ReadonlySet<string>;
|
ownerNodeIds: ReadonlySet<string>;
|
||||||
memberNodeIdByName: ReadonlyMap<string, string>;
|
memberNodeIdByAlias: ReadonlyMap<string, string>;
|
||||||
}): string | null {
|
}): string | null {
|
||||||
const { taskOwner, author, leadId, leadName, ownerNodeIds, memberNodeIdByName } = args;
|
const { taskOwner, author, leadId, leadName, ownerNodeIds, memberNodeIdByAlias } = args;
|
||||||
if (taskOwner) {
|
if (taskOwner) {
|
||||||
const ownerId = resolveParticipantId(taskOwner, leadId, leadName, memberNodeIdByName);
|
const ownerId = resolveParticipantId(taskOwner, leadId, leadName, memberNodeIdByAlias);
|
||||||
if (ownerNodeIds.has(ownerId)) {
|
if (ownerNodeIds.has(ownerId)) {
|
||||||
return ownerId;
|
return ownerId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const authorId = resolveParticipantId(author, leadId, leadName, memberNodeIdByName);
|
const authorId = resolveParticipantId(author, leadId, leadName, memberNodeIdByAlias);
|
||||||
if (ownerNodeIds.has(authorId)) {
|
if (ownerNodeIds.has(authorId)) {
|
||||||
return authorId;
|
return authorId;
|
||||||
}
|
}
|
||||||
|
|
@ -367,7 +366,7 @@ function resolveParticipantId(
|
||||||
name: string,
|
name: string,
|
||||||
leadId: string,
|
leadId: string,
|
||||||
leadName: string | undefined,
|
leadName: string | undefined,
|
||||||
memberNodeIdByName: ReadonlyMap<string, string>
|
memberNodeIdByAlias: ReadonlyMap<string, string>
|
||||||
): string {
|
): string {
|
||||||
const normalized = name.trim().toLowerCase();
|
const normalized = name.trim().toLowerCase();
|
||||||
if (normalized === 'user' || normalized === 'team-lead') {
|
if (normalized === 'user' || normalized === 'team-lead') {
|
||||||
|
|
@ -376,7 +375,7 @@ function resolveParticipantId(
|
||||||
if (normalized === leadName?.trim().toLowerCase()) {
|
if (normalized === leadName?.trim().toLowerCase()) {
|
||||||
return leadId;
|
return leadId;
|
||||||
}
|
}
|
||||||
return memberNodeIdByName.get(name) ?? leadId;
|
return memberNodeIdByAlias.get(name) ?? leadId;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildParticipantLabel(name: string | undefined, leadName: string): string {
|
function buildParticipantLabel(name: string | undefined, leadName: string): string {
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,31 @@ export function buildGraphMemberNodeIdForMember(
|
||||||
return buildGraphMemberNodeId(teamName, getGraphStableOwnerId(member));
|
return buildGraphMemberNodeId(teamName, getGraphStableOwnerId(member));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildGraphMemberNodeIdAliasMap(
|
||||||
|
teamName: string,
|
||||||
|
members: readonly StableTeamOwnerLike[]
|
||||||
|
): Map<string, string> {
|
||||||
|
const aliases = new Map<string, string>();
|
||||||
|
|
||||||
|
for (const member of members) {
|
||||||
|
const stableOwnerId = getGraphStableOwnerId(member).trim();
|
||||||
|
if (!stableOwnerId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
aliases.set(stableOwnerId, buildGraphMemberNodeId(teamName, stableOwnerId));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const member of members) {
|
||||||
|
const memberName = member.name.trim();
|
||||||
|
if (!memberName || aliases.has(memberName)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
aliases.set(memberName, buildGraphMemberNodeIdForMember(teamName, member));
|
||||||
|
}
|
||||||
|
|
||||||
|
return aliases;
|
||||||
|
}
|
||||||
|
|
||||||
export function parseGraphMemberNodeId(nodeId: string, teamName?: string): string | null {
|
export function parseGraphMemberNodeId(nodeId: string, teamName?: string): string | null {
|
||||||
const prefix = teamName ? `member:${teamName}:` : 'member:';
|
const prefix = teamName ? `member:${teamName}:` : 'member:';
|
||||||
if (!nodeId.startsWith(prefix)) {
|
if (!nodeId.startsWith(prefix)) {
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ import {
|
||||||
} from '../../core/domain/buildInlineActivityEntries';
|
} from '../../core/domain/buildInlineActivityEntries';
|
||||||
import { collapseOverflowStacksWithMeta } from '../../core/domain/collapseOverflowStacks';
|
import { collapseOverflowStacksWithMeta } from '../../core/domain/collapseOverflowStacks';
|
||||||
import {
|
import {
|
||||||
|
buildGraphMemberNodeIdAliasMap,
|
||||||
buildGraphMemberNodeIdForMember,
|
buildGraphMemberNodeIdForMember,
|
||||||
getGraphStableOwnerId,
|
getGraphStableOwnerId,
|
||||||
GRAPH_STABLE_SLOT_LAYOUT_VERSION,
|
GRAPH_STABLE_SLOT_LAYOUT_VERSION,
|
||||||
|
|
@ -140,7 +141,7 @@ export class TeamGraphAdapter {
|
||||||
|
|
||||||
const leadId = `lead:${teamName}`;
|
const leadId = `lead:${teamName}`;
|
||||||
const leadName = TeamGraphAdapter.#getLeadMemberName(teamData, teamName);
|
const leadName = TeamGraphAdapter.#getLeadMemberName(teamData, teamName);
|
||||||
const memberNodeIdByName = TeamGraphAdapter.#buildMemberNodeIdByName(teamData, teamName);
|
const memberNodeIdByAlias = TeamGraphAdapter.#buildMemberNodeIdByAlias(teamData, teamName);
|
||||||
const provisioningPresentation = buildTeamProvisioningPresentation({
|
const provisioningPresentation = buildTeamProvisioningPresentation({
|
||||||
progress: provisioningProgress,
|
progress: provisioningProgress,
|
||||||
members: teamData.members,
|
members: teamData.members,
|
||||||
|
|
@ -170,7 +171,7 @@ export class TeamGraphAdapter {
|
||||||
leadId,
|
leadId,
|
||||||
teamData,
|
teamData,
|
||||||
teamName,
|
teamName,
|
||||||
memberNodeIdByName,
|
memberNodeIdByAlias,
|
||||||
spawnStatuses,
|
spawnStatuses,
|
||||||
pendingApprovalAgents,
|
pendingApprovalAgents,
|
||||||
activeTools,
|
activeTools,
|
||||||
|
|
@ -179,8 +180,8 @@ export class TeamGraphAdapter {
|
||||||
isTeamProvisioning,
|
isTeamProvisioning,
|
||||||
isLaunchSettling
|
isLaunchSettling
|
||||||
);
|
);
|
||||||
this.#buildTaskNodes(nodes, edges, teamData, teamName, commentReadState, memberNodeIdByName);
|
this.#buildTaskNodes(nodes, edges, teamData, teamName, commentReadState, memberNodeIdByAlias);
|
||||||
this.#buildProcessNodes(nodes, edges, teamData, teamName, memberNodeIdByName);
|
this.#buildProcessNodes(nodes, edges, teamData, teamName, memberNodeIdByAlias);
|
||||||
this.#attachActivityFeeds(nodes, teamData, teamName, leadId, leadName);
|
this.#attachActivityFeeds(nodes, teamData, teamName, leadId, leadName);
|
||||||
this.#buildMessageParticles(
|
this.#buildMessageParticles(
|
||||||
particles,
|
particles,
|
||||||
|
|
@ -190,7 +191,7 @@ export class TeamGraphAdapter {
|
||||||
leadId,
|
leadId,
|
||||||
leadName,
|
leadName,
|
||||||
edges,
|
edges,
|
||||||
memberNodeIdByName
|
memberNodeIdByAlias
|
||||||
);
|
);
|
||||||
this.#buildCommentParticles(
|
this.#buildCommentParticles(
|
||||||
particles,
|
particles,
|
||||||
|
|
@ -199,7 +200,7 @@ export class TeamGraphAdapter {
|
||||||
leadId,
|
leadId,
|
||||||
leadName,
|
leadName,
|
||||||
edges,
|
edges,
|
||||||
memberNodeIdByName
|
memberNodeIdByAlias
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -232,11 +233,10 @@ export class TeamGraphAdapter {
|
||||||
return getGraphLeadMemberName(data, teamName);
|
return getGraphLeadMemberName(data, teamName);
|
||||||
}
|
}
|
||||||
|
|
||||||
static #buildMemberNodeIdByName(data: TeamGraphData, teamName: string): Map<string, string> {
|
static #buildMemberNodeIdByAlias(data: TeamGraphData, teamName: string): Map<string, string> {
|
||||||
return new Map(
|
return buildGraphMemberNodeIdAliasMap(
|
||||||
data.members
|
teamName,
|
||||||
.filter((member) => !isLeadMember(member))
|
data.members.filter((member) => !isLeadMember(member))
|
||||||
.map((member) => [member.name, buildGraphMemberNodeIdForMember(teamName, member)] as const)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -470,7 +470,7 @@ export class TeamGraphAdapter {
|
||||||
leadId: string,
|
leadId: string,
|
||||||
data: TeamGraphData,
|
data: TeamGraphData,
|
||||||
teamName: string,
|
teamName: string,
|
||||||
memberNodeIdByName: ReadonlyMap<string, string>,
|
memberNodeIdByAlias: ReadonlyMap<string, string>,
|
||||||
spawnStatuses?: Record<string, MemberSpawnStatusEntry>,
|
spawnStatuses?: Record<string, MemberSpawnStatusEntry>,
|
||||||
pendingApprovalAgents?: Set<string>,
|
pendingApprovalAgents?: Set<string>,
|
||||||
activeTools?: Record<string, Record<string, ActiveToolCall>>,
|
activeTools?: Record<string, Record<string, ActiveToolCall>>,
|
||||||
|
|
@ -484,7 +484,7 @@ export class TeamGraphAdapter {
|
||||||
if (isLeadMember(member)) continue;
|
if (isLeadMember(member)) continue;
|
||||||
|
|
||||||
const memberId =
|
const memberId =
|
||||||
memberNodeIdByName.get(member.name) ?? buildGraphMemberNodeIdForMember(teamName, member);
|
memberNodeIdByAlias.get(member.name) ?? buildGraphMemberNodeIdForMember(teamName, member);
|
||||||
const spawn = spawnStatuses?.[member.name];
|
const spawn = spawnStatuses?.[member.name];
|
||||||
const activeTool = TeamGraphAdapter.#selectVisibleTool(
|
const activeTool = TeamGraphAdapter.#selectVisibleTool(
|
||||||
activeTools?.[member.name],
|
activeTools?.[member.name],
|
||||||
|
|
@ -574,7 +574,7 @@ export class TeamGraphAdapter {
|
||||||
data: TeamGraphData,
|
data: TeamGraphData,
|
||||||
teamName: string,
|
teamName: string,
|
||||||
commentReadState?: Record<string, unknown>,
|
commentReadState?: Record<string, unknown>,
|
||||||
memberNodeIdByName?: ReadonlyMap<string, string>
|
memberNodeIdByAlias?: ReadonlyMap<string, string>
|
||||||
): void {
|
): void {
|
||||||
const taskStateById = new Map<string, Pick<TeamGraphData['tasks'][number], 'status'>>();
|
const taskStateById = new Map<string, Pick<TeamGraphData['tasks'][number], 'status'>>();
|
||||||
const taskDisplayIds = new Map<string, string>();
|
const taskDisplayIds = new Map<string, string>();
|
||||||
|
|
@ -595,7 +595,7 @@ export class TeamGraphAdapter {
|
||||||
for (const task of data.tasks) {
|
for (const task of data.tasks) {
|
||||||
if (task.status === 'deleted') continue;
|
if (task.status === 'deleted') continue;
|
||||||
const taskId = `task:${teamName}:${task.id}`;
|
const taskId = `task:${teamName}:${task.id}`;
|
||||||
const ownerMemberId = task.owner ? (memberNodeIdByName?.get(task.owner) ?? null) : null;
|
const ownerMemberId = task.owner ? (memberNodeIdByAlias?.get(task.owner) ?? null) : null;
|
||||||
const kanbanTaskState = data.kanbanState.tasks[task.id];
|
const kanbanTaskState = data.kanbanState.tasks[task.id];
|
||||||
const reviewerName = resolveTaskReviewer(task, kanbanTaskState);
|
const reviewerName = resolveTaskReviewer(task, kanbanTaskState);
|
||||||
const isReviewCycle = isTaskInReviewCycle(task);
|
const isReviewCycle = isTaskInReviewCycle(task);
|
||||||
|
|
@ -758,11 +758,11 @@ export class TeamGraphAdapter {
|
||||||
edges: GraphEdge[],
|
edges: GraphEdge[],
|
||||||
data: TeamGraphData,
|
data: TeamGraphData,
|
||||||
teamName: string,
|
teamName: string,
|
||||||
memberNodeIdByName?: ReadonlyMap<string, string>
|
memberNodeIdByAlias?: ReadonlyMap<string, string>
|
||||||
): void {
|
): void {
|
||||||
for (const { process: proc, ownerId } of TeamGraphAdapter.#selectRelevantProcesses(
|
for (const { process: proc, ownerId } of TeamGraphAdapter.#selectRelevantProcesses(
|
||||||
data.processes,
|
data.processes,
|
||||||
memberNodeIdByName
|
memberNodeIdByAlias
|
||||||
)) {
|
)) {
|
||||||
const procId = `process:${teamName}:${proc.id}`;
|
const procId = `process:${teamName}:${proc.id}`;
|
||||||
|
|
||||||
|
|
@ -792,13 +792,13 @@ export class TeamGraphAdapter {
|
||||||
|
|
||||||
static #selectRelevantProcesses(
|
static #selectRelevantProcesses(
|
||||||
processes: readonly TeamProcess[],
|
processes: readonly TeamProcess[],
|
||||||
memberNodeIdByName?: ReadonlyMap<string, string>
|
memberNodeIdByAlias?: ReadonlyMap<string, string>
|
||||||
): { process: TeamProcess; ownerId: string }[] {
|
): { process: TeamProcess; ownerId: string }[] {
|
||||||
const selectedByOwnerId = new Map<string, TeamProcess>();
|
const selectedByOwnerId = new Map<string, TeamProcess>();
|
||||||
|
|
||||||
for (const process of processes) {
|
for (const process of processes) {
|
||||||
const ownerId = process.registeredBy
|
const ownerId = process.registeredBy
|
||||||
? (memberNodeIdByName?.get(process.registeredBy) ?? null)
|
? (memberNodeIdByAlias?.get(process.registeredBy) ?? null)
|
||||||
: null;
|
: null;
|
||||||
if (!ownerId) {
|
if (!ownerId) {
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -881,7 +881,7 @@ export class TeamGraphAdapter {
|
||||||
leadId: string,
|
leadId: string,
|
||||||
leadName: string,
|
leadName: string,
|
||||||
edges: GraphEdge[],
|
edges: GraphEdge[],
|
||||||
memberNodeIdByName: ReadonlyMap<string, string>
|
memberNodeIdByAlias: ReadonlyMap<string, string>
|
||||||
): void {
|
): void {
|
||||||
const ordered = [...messages].reverse();
|
const ordered = [...messages].reverse();
|
||||||
|
|
||||||
|
|
@ -969,7 +969,7 @@ export class TeamGraphAdapter {
|
||||||
leadId,
|
leadId,
|
||||||
leadName,
|
leadName,
|
||||||
edges,
|
edges,
|
||||||
memberNodeIdByName
|
memberNodeIdByAlias
|
||||||
);
|
);
|
||||||
if (!edgeId) continue;
|
if (!edgeId) continue;
|
||||||
|
|
||||||
|
|
@ -979,7 +979,7 @@ export class TeamGraphAdapter {
|
||||||
msg.from ?? '',
|
msg.from ?? '',
|
||||||
leadId,
|
leadId,
|
||||||
leadName,
|
leadName,
|
||||||
memberNodeIdByName
|
memberNodeIdByAlias
|
||||||
);
|
);
|
||||||
const isFromTeammate = fromId !== leadId;
|
const isFromTeammate = fromId !== leadId;
|
||||||
|
|
||||||
|
|
@ -1020,7 +1020,7 @@ export class TeamGraphAdapter {
|
||||||
leadId: string,
|
leadId: string,
|
||||||
leadName: string,
|
leadName: string,
|
||||||
edges: GraphEdge[],
|
edges: GraphEdge[],
|
||||||
memberNodeIdByName: ReadonlyMap<string, string>
|
memberNodeIdByAlias: ReadonlyMap<string, string>
|
||||||
): void {
|
): void {
|
||||||
// First call: record current comment counts without creating particles.
|
// First call: record current comment counts without creating particles.
|
||||||
// This prevents pre-existing comments from spawning particles when the graph opens.
|
// This prevents pre-existing comments from spawning particles when the graph opens.
|
||||||
|
|
@ -1061,7 +1061,7 @@ export class TeamGraphAdapter {
|
||||||
newComment.author,
|
newComment.author,
|
||||||
leadId,
|
leadId,
|
||||||
leadName,
|
leadName,
|
||||||
memberNodeIdByName
|
memberNodeIdByAlias
|
||||||
);
|
);
|
||||||
const taskNodeId = `task:${teamName}:${task.id}`;
|
const taskNodeId = `task:${teamName}:${task.id}`;
|
||||||
const authorEdge =
|
const authorEdge =
|
||||||
|
|
@ -1196,7 +1196,7 @@ export class TeamGraphAdapter {
|
||||||
leadId: string,
|
leadId: string,
|
||||||
leadName: string,
|
leadName: string,
|
||||||
edges: GraphEdge[],
|
edges: GraphEdge[],
|
||||||
memberNodeIdByName: ReadonlyMap<string, string>
|
memberNodeIdByAlias: ReadonlyMap<string, string>
|
||||||
): string | null {
|
): string | null {
|
||||||
const { from, to } = msg;
|
const { from, to } = msg;
|
||||||
|
|
||||||
|
|
@ -1205,9 +1205,14 @@ export class TeamGraphAdapter {
|
||||||
from,
|
from,
|
||||||
leadId,
|
leadId,
|
||||||
leadName,
|
leadName,
|
||||||
memberNodeIdByName
|
memberNodeIdByAlias
|
||||||
|
);
|
||||||
|
const toId = TeamGraphAdapter.#resolveParticipantId(
|
||||||
|
to,
|
||||||
|
leadId,
|
||||||
|
leadName,
|
||||||
|
memberNodeIdByAlias
|
||||||
);
|
);
|
||||||
const toId = TeamGraphAdapter.#resolveParticipantId(to, leadId, leadName, memberNodeIdByName);
|
|
||||||
return (
|
return (
|
||||||
edges.find((e) => e.source === fromId && e.target === toId)?.id ??
|
edges.find((e) => e.source === fromId && e.target === toId)?.id ??
|
||||||
edges.find((e) => e.source === toId && e.target === fromId)?.id ??
|
edges.find((e) => e.source === toId && e.target === fromId)?.id ??
|
||||||
|
|
@ -1220,7 +1225,7 @@ export class TeamGraphAdapter {
|
||||||
from,
|
from,
|
||||||
leadId,
|
leadId,
|
||||||
leadName,
|
leadName,
|
||||||
memberNodeIdByName
|
memberNodeIdByAlias
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
edges.find(
|
edges.find(
|
||||||
|
|
@ -1238,12 +1243,12 @@ export class TeamGraphAdapter {
|
||||||
name: string,
|
name: string,
|
||||||
leadId: string,
|
leadId: string,
|
||||||
leadName: string | undefined,
|
leadName: string | undefined,
|
||||||
memberNodeIdByName: ReadonlyMap<string, string>
|
memberNodeIdByAlias: ReadonlyMap<string, string>
|
||||||
): string {
|
): string {
|
||||||
const normalized = name.trim().toLowerCase();
|
const normalized = name.trim().toLowerCase();
|
||||||
if (normalized === 'user' || normalized === 'team-lead') return leadId;
|
if (normalized === 'user' || normalized === 'team-lead') return leadId;
|
||||||
if (normalized === leadName?.trim().toLowerCase()) return leadId;
|
if (normalized === leadName?.trim().toLowerCase()) return leadId;
|
||||||
return memberNodeIdByName.get(name) ?? leadId;
|
return memberNodeIdByAlias.get(name) ?? leadId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Extract external team name from cross-team "from" field like "team-b.alice" */
|
/** Extract external team name from cross-team "from" field like "team-b.alice" */
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,11 @@ import { useEffect, useMemo, useRef, useSyncExternalStore } from 'react';
|
||||||
import { getSnapshot, subscribe } from '@renderer/services/commentReadStorage';
|
import { getSnapshot, subscribe } from '@renderer/services/commentReadStorage';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import {
|
import {
|
||||||
|
getDefaultTeamGraphSlotAssignmentsForMembers,
|
||||||
getCurrentProvisioningProgressForTeam,
|
getCurrentProvisioningProgressForTeam,
|
||||||
selectResolvedMembersForTeamName,
|
selectResolvedMembersForTeamName,
|
||||||
|
hasAppliedDefaultTeamGraphSlotAssignments,
|
||||||
|
isTeamGraphSlotPersistenceDisabled,
|
||||||
selectTeamDataForName,
|
selectTeamDataForName,
|
||||||
selectTeamMessages,
|
selectTeamMessages,
|
||||||
} from '@renderer/store/slices/teamSlice';
|
} from '@renderer/store/slices/teamSlice';
|
||||||
|
|
@ -80,6 +83,20 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort {
|
||||||
|
|
||||||
const commentReadState = useSyncExternalStore(subscribe, getSnapshot);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!teamName || !teamData) {
|
if (!teamName || !teamData) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -102,7 +119,7 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort {
|
||||||
commentReadState,
|
commentReadState,
|
||||||
provisioningProgress,
|
provisioningProgress,
|
||||||
memberSpawnSnapshot,
|
memberSpawnSnapshot,
|
||||||
slotAssignments
|
effectiveSlotAssignments
|
||||||
),
|
),
|
||||||
[
|
[
|
||||||
teamData,
|
teamData,
|
||||||
|
|
@ -117,7 +134,7 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort {
|
||||||
commentReadState,
|
commentReadState,
|
||||||
provisioningProgress,
|
provisioningProgress,
|
||||||
memberSpawnSnapshot,
|
memberSpawnSnapshot,
|
||||||
slotAssignments,
|
effectiveSlotAssignments,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { ACTIVITY_ANCHOR_LAYOUT, ACTIVITY_LANE } from '@claude-teams/agent-graph';
|
import { ACTIVITY_LANE } from '@claude-teams/agent-graph';
|
||||||
import { ActivityItem } from '@renderer/components/team/activity/ActivityItem';
|
import { ActivityItem } from '@renderer/components/team/activity/ActivityItem';
|
||||||
import {
|
import {
|
||||||
buildMessageContext,
|
buildMessageContext,
|
||||||
|
|
@ -26,18 +26,26 @@ import type {
|
||||||
} from '@renderer/components/team/members/memberDetailTypes';
|
} from '@renderer/components/team/members/memberDetailTypes';
|
||||||
import type { ResolvedTeamMember } from '@shared/types/team';
|
import type { ResolvedTeamMember } from '@shared/types/team';
|
||||||
|
|
||||||
|
const ACTIVITY_SHELL_HEIGHT =
|
||||||
|
ACTIVITY_LANE.headerHeight +
|
||||||
|
ACTIVITY_LANE.maxVisibleItems * ACTIVITY_LANE.rowHeight +
|
||||||
|
ACTIVITY_LANE.overflowHeight;
|
||||||
|
|
||||||
interface GraphActivityHudProps {
|
interface GraphActivityHudProps {
|
||||||
teamName: string;
|
teamName: string;
|
||||||
nodes: GraphNode[];
|
nodes: GraphNode[];
|
||||||
getActivityAnchorScreenPlacement: (
|
getActivityWorldRect?: (ownerNodeId: string) => {
|
||||||
ownerNodeId: string
|
left: number;
|
||||||
) => { x: number; y: number; scale: number; visible: boolean } | null;
|
top: number;
|
||||||
getActivityAnchorWorldPosition?: (ownerNodeId: string) => { x: number; y: number } | null;
|
right: number;
|
||||||
|
bottom: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
} | null;
|
||||||
getCameraZoom?: () => number;
|
getCameraZoom?: () => number;
|
||||||
worldToScreen?: (x: number, y: number) => { x: number; y: number };
|
worldToScreen?: (x: number, y: number) => { x: number; y: number };
|
||||||
getNodeWorldPosition?: (nodeId: string) => { x: number; y: number } | null;
|
getNodeWorldPosition?: (nodeId: string) => { x: number; y: number } | null;
|
||||||
getViewportSize?: () => { width: number; height: number };
|
getViewportSize?: () => { width: number; height: number };
|
||||||
getNodeScreenPosition?: (nodeId: string) => { x: number; y: number; visible: boolean } | null;
|
|
||||||
focusNodeIds: ReadonlySet<string> | null;
|
focusNodeIds: ReadonlySet<string> | null;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
onOpenTaskDetail?: (taskId: string) => void;
|
onOpenTaskDetail?: (taskId: string) => void;
|
||||||
|
|
@ -53,13 +61,11 @@ interface GraphActivityHudProps {
|
||||||
export const GraphActivityHud = ({
|
export const GraphActivityHud = ({
|
||||||
teamName,
|
teamName,
|
||||||
nodes,
|
nodes,
|
||||||
getActivityAnchorScreenPlacement,
|
getActivityWorldRect = () => null,
|
||||||
getActivityAnchorWorldPosition = () => null,
|
|
||||||
getCameraZoom = () => 1,
|
getCameraZoom = () => 1,
|
||||||
worldToScreen,
|
worldToScreen,
|
||||||
getNodeWorldPosition = () => null,
|
getNodeWorldPosition = () => null,
|
||||||
getViewportSize,
|
getViewportSize,
|
||||||
getNodeScreenPosition = () => null,
|
|
||||||
focusNodeIds,
|
focusNodeIds,
|
||||||
enabled = true,
|
enabled = true,
|
||||||
onOpenTaskDetail,
|
onOpenTaskDetail,
|
||||||
|
|
@ -134,7 +140,9 @@ export const GraphActivityHud = ({
|
||||||
overflowCount,
|
overflowCount,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter((lane) => lane.entries.length > 0 || lane.overflowCount > 0);
|
.filter(
|
||||||
|
(lane) => lane.node.kind === 'member' || lane.entries.length > 0 || lane.overflowCount > 0
|
||||||
|
);
|
||||||
}, [entryMapByOwnerNodeId, ownerNodes]);
|
}, [entryMapByOwnerNodeId, ownerNodes]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
|
|
@ -166,7 +174,7 @@ export const GraphActivityHud = ({
|
||||||
shell: HTMLDivElement;
|
shell: HTMLDivElement;
|
||||||
connector: SVGSVGElement | null;
|
connector: SVGSVGElement | null;
|
||||||
connectorPath: SVGPathElement | null;
|
connectorPath: SVGPathElement | null;
|
||||||
laneTopLeft: { x: number; y: number };
|
laneRect: NonNullable<ReturnType<typeof getActivityWorldRect>>;
|
||||||
nodeWorld: { x: number; y: number };
|
nodeWorld: { x: number; y: number };
|
||||||
}[] = [];
|
}[] = [];
|
||||||
|
|
||||||
|
|
@ -178,10 +186,9 @@ export const GraphActivityHud = ({
|
||||||
const connector = connectorRefs.current.get(lane.node.id) ?? null;
|
const connector = connectorRefs.current.get(lane.node.id) ?? null;
|
||||||
const connectorPath = connectorPathRefs.current.get(lane.node.id) ?? null;
|
const connectorPath = connectorPathRefs.current.get(lane.node.id) ?? null;
|
||||||
|
|
||||||
const placement = getActivityAnchorScreenPlacement(lane.node.id);
|
const laneRect = getActivityWorldRect(lane.node.id);
|
||||||
const laneTopLeft = getActivityAnchorWorldPosition(lane.node.id);
|
|
||||||
const nodeWorld = getNodeWorldPosition(lane.node.id);
|
const nodeWorld = getNodeWorldPosition(lane.node.id);
|
||||||
if (!placement || !laneTopLeft || !nodeWorld) {
|
if (!laneRect || !nodeWorld || !worldToScreen) {
|
||||||
shell.style.opacity = '0';
|
shell.style.opacity = '0';
|
||||||
if (connector) {
|
if (connector) {
|
||||||
connector.style.opacity = '0';
|
connector.style.opacity = '0';
|
||||||
|
|
@ -189,19 +196,18 @@ export const GraphActivityHud = ({
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const scale = Math.max(getCameraZoom(), 0.001);
|
const zoom = Math.max(getCameraZoom(), 0.001);
|
||||||
const widthScreen = Math.max(1, (shell.offsetWidth || ACTIVITY_LANE.width) * scale);
|
const screenTopLeft = worldToScreen(laneRect.left, laneRect.top);
|
||||||
const heightScreen = Math.max(1, (shell.offsetHeight || 220) * scale);
|
const widthScreen = Math.max(1, laneRect.width * zoom);
|
||||||
|
const heightScreen = Math.max(1, laneRect.height * zoom);
|
||||||
const viewport = getViewportSize?.();
|
const viewport = getViewportSize?.();
|
||||||
const laneVisible = viewport
|
const laneVisible =
|
||||||
? placement.x + widthScreen > -80 &&
|
!viewport ||
|
||||||
placement.x < viewport.width + 80 &&
|
(screenTopLeft.x + widthScreen > -80 &&
|
||||||
placement.y + heightScreen > -80 &&
|
screenTopLeft.x < viewport.width + 80 &&
|
||||||
placement.y < viewport.height + 80
|
screenTopLeft.y + heightScreen > -80 &&
|
||||||
: placement.visible;
|
screenTopLeft.y < viewport.height + 80);
|
||||||
|
if (!laneVisible) {
|
||||||
const nodeScreen = getNodeScreenPosition(lane.node.id);
|
|
||||||
if (!nodeScreen?.visible || !laneVisible) {
|
|
||||||
shell.style.opacity = '0';
|
shell.style.opacity = '0';
|
||||||
if (connector) {
|
if (connector) {
|
||||||
connector.style.opacity = '0';
|
connector.style.opacity = '0';
|
||||||
|
|
@ -214,31 +220,23 @@ export const GraphActivityHud = ({
|
||||||
shell,
|
shell,
|
||||||
connector,
|
connector,
|
||||||
connectorPath,
|
connectorPath,
|
||||||
laneTopLeft,
|
laneRect,
|
||||||
nodeWorld,
|
nodeWorld,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const entry of measurableLanes) {
|
for (const entry of measurableLanes) {
|
||||||
const { lane, shell, connector, connectorPath, laneTopLeft, nodeWorld } = entry;
|
const { lane, shell, connector, connectorPath, laneRect, nodeWorld } = entry;
|
||||||
const baseOpacity = focusNodeIds && !focusNodeIds.has(lane.node.id) ? 0.25 : 1;
|
const baseOpacity = focusNodeIds && !focusNodeIds.has(lane.node.id) ? 0.25 : 1;
|
||||||
const widthWorld = shell.offsetWidth || ACTIVITY_LANE.width;
|
|
||||||
const heightWorld = shell.offsetHeight || 220;
|
|
||||||
const ownerBottomLimit =
|
|
||||||
nodeWorld.y +
|
|
||||||
(lane.node.kind === 'lead'
|
|
||||||
? ACTIVITY_ANCHOR_LAYOUT.leadOffsetY + ACTIVITY_ANCHOR_LAYOUT.reservedHeight
|
|
||||||
: ACTIVITY_ANCHOR_LAYOUT.memberOffsetY + ACTIVITY_ANCHOR_LAYOUT.reservedHeight);
|
|
||||||
const adjustedLaneTop = Math.min(laneTopLeft.y, ownerBottomLimit - heightWorld);
|
|
||||||
|
|
||||||
shell.style.opacity = String(baseOpacity);
|
shell.style.opacity = String(baseOpacity);
|
||||||
shell.style.left = `${Math.round(laneTopLeft.x)}px`;
|
shell.style.left = `${Math.round(laneRect.left)}px`;
|
||||||
shell.style.top = `${Math.round(adjustedLaneTop)}px`;
|
shell.style.top = `${Math.round(laneRect.top)}px`;
|
||||||
shell.style.transform = '';
|
shell.style.transform = '';
|
||||||
|
|
||||||
if (connector && connectorPath) {
|
if (connector && connectorPath) {
|
||||||
const endX = laneTopLeft.x + widthWorld / 2;
|
const endX = laneRect.left + laneRect.width / 2;
|
||||||
const endY = adjustedLaneTop + heightWorld - 6;
|
const endY = laneRect.top >= nodeWorld.y ? laneRect.top + 10 : laneRect.bottom - 10;
|
||||||
const startX = nodeWorld.x;
|
const startX = nodeWorld.x;
|
||||||
const startY = nodeWorld.y - 18;
|
const startY = nodeWorld.y - 18;
|
||||||
const minX = Math.min(startX, endX);
|
const minX = Math.min(startX, endX);
|
||||||
|
|
@ -282,11 +280,9 @@ export const GraphActivityHud = ({
|
||||||
}, [
|
}, [
|
||||||
enabled,
|
enabled,
|
||||||
focusNodeIds,
|
focusNodeIds,
|
||||||
getActivityAnchorScreenPlacement,
|
getActivityWorldRect,
|
||||||
getActivityAnchorWorldPosition,
|
|
||||||
getCameraZoom,
|
getCameraZoom,
|
||||||
getNodeWorldPosition,
|
getNodeWorldPosition,
|
||||||
getNodeScreenPosition,
|
|
||||||
getViewportSize,
|
getViewportSize,
|
||||||
worldToScreen,
|
worldToScreen,
|
||||||
visibleLanes,
|
visibleLanes,
|
||||||
|
|
@ -350,7 +346,9 @@ export const GraphActivityHud = ({
|
||||||
if (!(canvas instanceof HTMLCanvasElement)) {
|
if (!(canvas instanceof HTMLCanvasElement)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
event.preventDefault();
|
if (event.cancelable) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
canvas.dispatchEvent(
|
canvas.dispatchEvent(
|
||||||
new WheelEvent('wheel', {
|
new WheelEvent('wheel', {
|
||||||
deltaX: event.deltaX,
|
deltaX: event.deltaX,
|
||||||
|
|
@ -404,91 +402,118 @@ export const GraphActivityHud = ({
|
||||||
>
|
>
|
||||||
{visibleLanes.map((lane) => (
|
{visibleLanes.map((lane) => (
|
||||||
<div key={lane.node.id}>
|
<div key={lane.node.id}>
|
||||||
<svg
|
{(() => {
|
||||||
ref={(element) => {
|
const laneRect = getActivityWorldRect(lane.node.id);
|
||||||
connectorRefs.current.set(lane.node.id, element);
|
const laneWidth = laneRect?.width ?? ACTIVITY_LANE.width;
|
||||||
}}
|
const laneHeight = laneRect?.height ?? ACTIVITY_SHELL_HEIGHT;
|
||||||
className="pointer-events-none absolute z-[9] overflow-visible opacity-0"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
ref={(element) => {
|
|
||||||
connectorPathRefs.current.set(lane.node.id, element);
|
|
||||||
}}
|
|
||||||
d=""
|
|
||||||
fill="none"
|
|
||||||
stroke="rgba(148, 163, 184, 0.3)"
|
|
||||||
strokeWidth="1.25"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeDasharray="3 4"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<div
|
|
||||||
ref={(element) => {
|
|
||||||
shellRefs.current.set(lane.node.id, element);
|
|
||||||
}}
|
|
||||||
className="pointer-events-auto absolute z-10 origin-top-left opacity-0"
|
|
||||||
style={{ width: `${ACTIVITY_LANE.width}px`, maxWidth: `${ACTIVITY_LANE.width}px` }}
|
|
||||||
>
|
|
||||||
<div className="mb-1 px-1 text-[10px] font-semibold tracking-[0.2em] text-slate-400/70">
|
|
||||||
Activity
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 max-w-full space-y-2 overflow-hidden">
|
|
||||||
{lane.entries.map((entry, index) => {
|
|
||||||
const messageKey = toMessageKey(entry.message);
|
|
||||||
const renderProps = resolveMessageRenderProps(entry.message, messageContext);
|
|
||||||
const timelineItem: TimelineItem = { type: 'message', message: entry.message };
|
|
||||||
const isUnread = !entry.message.read && !readSet.has(messageKey);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<>
|
||||||
key={entry.graphItem.id}
|
<svg
|
||||||
className="min-w-0 max-w-full cursor-pointer overflow-hidden"
|
ref={(element) => {
|
||||||
role="button"
|
connectorRefs.current.set(lane.node.id, element);
|
||||||
tabIndex={0}
|
}}
|
||||||
onClick={() => handleMessageClick(timelineItem)}
|
className="pointer-events-none absolute z-[9] overflow-visible opacity-0"
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === 'Enter' || event.key === ' ') {
|
|
||||||
event.preventDefault();
|
|
||||||
handleMessageClick(timelineItem);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ActivityItem
|
|
||||||
message={entry.message}
|
|
||||||
teamName={teamName}
|
|
||||||
compactHeader
|
|
||||||
collapseMode="managed"
|
|
||||||
isCollapsed
|
|
||||||
canToggleCollapse={false}
|
|
||||||
isUnread={isUnread}
|
|
||||||
expandItemKey={messageKey}
|
|
||||||
onExpand={handleExpandItem}
|
|
||||||
memberRole={renderProps.memberRole}
|
|
||||||
memberColor={renderProps.memberColor}
|
|
||||||
recipientColor={renderProps.recipientColor}
|
|
||||||
memberColorMap={messageContext.colorMap}
|
|
||||||
localMemberNames={messageContext.localMemberNames}
|
|
||||||
onMemberNameClick={handleMemberNameClick}
|
|
||||||
onTaskIdClick={onOpenTaskDetail}
|
|
||||||
zebraShade={index % 2 === 1}
|
|
||||||
teamNames={teamNames}
|
|
||||||
teamColorByName={teamColorByName}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{lane.overflowCount > 0 ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="w-full rounded-md border border-white/10 bg-[rgba(8,14,28,0.64)] px-3 py-1 text-center text-[11px] font-medium text-slate-300 transition-colors hover:border-white/20 hover:bg-[rgba(12,20,40,0.78)]"
|
|
||||||
onClick={() => handleOpenOwnerActivity(lane.node)}
|
|
||||||
>
|
>
|
||||||
+{lane.overflowCount} more
|
<path
|
||||||
</button>
|
ref={(element) => {
|
||||||
) : null}
|
connectorPathRefs.current.set(lane.node.id, element);
|
||||||
</div>
|
}}
|
||||||
</div>
|
d=""
|
||||||
|
fill="none"
|
||||||
|
stroke="rgba(148, 163, 184, 0.3)"
|
||||||
|
strokeWidth="1.25"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeDasharray="3 4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div
|
||||||
|
ref={(element) => {
|
||||||
|
shellRefs.current.set(lane.node.id, element);
|
||||||
|
}}
|
||||||
|
className="pointer-events-auto absolute z-10 origin-top-left opacity-0"
|
||||||
|
style={{
|
||||||
|
width: `${laneWidth}px`,
|
||||||
|
maxWidth: `${laneWidth}px`,
|
||||||
|
height: `${laneHeight}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex h-full min-w-0 max-w-full flex-col overflow-hidden">
|
||||||
|
<div className="mb-1 px-1 text-[10px] font-semibold tracking-[0.2em] text-slate-400/70">
|
||||||
|
Activity
|
||||||
|
</div>
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden">
|
||||||
|
{lane.entries.length === 0 && lane.overflowCount === 0 ? (
|
||||||
|
<div className="flex h-[72px] min-h-[72px] items-center rounded-md border border-dashed border-white/10 bg-[rgba(8,14,28,0.28)] px-3 text-[11px] text-slate-400/60">
|
||||||
|
No recent activity
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{lane.entries.map((entry, index) => {
|
||||||
|
const messageKey = toMessageKey(entry.message);
|
||||||
|
const renderProps = resolveMessageRenderProps(
|
||||||
|
entry.message,
|
||||||
|
messageContext
|
||||||
|
);
|
||||||
|
const timelineItem: TimelineItem = {
|
||||||
|
type: 'message',
|
||||||
|
message: entry.message,
|
||||||
|
};
|
||||||
|
const isUnread = !entry.message.read && !readSet.has(messageKey);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={entry.graphItem.id}
|
||||||
|
className="h-[72px] min-h-[72px] min-w-0 max-w-full cursor-pointer overflow-hidden"
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => handleMessageClick(timelineItem)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault();
|
||||||
|
handleMessageClick(timelineItem);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ActivityItem
|
||||||
|
message={entry.message}
|
||||||
|
teamName={teamName}
|
||||||
|
compactHeader
|
||||||
|
collapseMode="managed"
|
||||||
|
isCollapsed
|
||||||
|
canToggleCollapse={false}
|
||||||
|
isUnread={isUnread}
|
||||||
|
expandItemKey={messageKey}
|
||||||
|
onExpand={handleExpandItem}
|
||||||
|
memberRole={renderProps.memberRole}
|
||||||
|
memberColor={renderProps.memberColor}
|
||||||
|
recipientColor={renderProps.recipientColor}
|
||||||
|
memberColorMap={messageContext.colorMap}
|
||||||
|
localMemberNames={messageContext.localMemberNames}
|
||||||
|
onMemberNameClick={handleMemberNameClick}
|
||||||
|
onTaskIdClick={onOpenTaskDetail}
|
||||||
|
zebraShade={index % 2 === 1}
|
||||||
|
teamNames={teamNames}
|
||||||
|
teamColorByName={teamColorByName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{lane.overflowCount > 0 ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="h-8 min-h-8 w-full rounded-md border border-white/10 bg-[rgba(8,14,28,0.64)] px-3 py-1 text-center text-[11px] font-medium text-slate-300 transition-colors hover:border-white/20 hover:bg-[rgba(12,20,40,0.78)]"
|
||||||
|
onClick={() => handleOpenOwnerActivity(lane.node)}
|
||||||
|
>
|
||||||
|
+{lane.overflowCount} more
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { DISPLAY_STEPS } from '@renderer/components/team/provisioningSteps';
|
import { DISPLAY_STEPS } from '@renderer/components/team/provisioningSteps';
|
||||||
import { StepProgressBar } from '@renderer/components/team/StepProgressBar';
|
import { StepProgressBar } from '@renderer/components/team/StepProgressBar';
|
||||||
|
|
@ -13,7 +13,7 @@ import {
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@renderer/components/ui/dialog';
|
} from '@renderer/components/ui/dialog';
|
||||||
import { cn } from '@renderer/lib/utils';
|
import { cn } from '@renderer/lib/utils';
|
||||||
import { AlertTriangle, CheckCircle2, ExternalLink, Loader2, X } from 'lucide-react';
|
import { AlertTriangle, CheckCircle2, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
import type { TeamProvisioningPresentation } from '@renderer/utils/teamProvisioningPresentation';
|
import type { TeamProvisioningPresentation } from '@renderer/utils/teamProvisioningPresentation';
|
||||||
import type { CSSProperties } from 'react';
|
import type { CSSProperties } from 'react';
|
||||||
|
|
@ -51,28 +51,28 @@ function getToneClasses(tone: TeamProvisioningPresentation['compactTone']): {
|
||||||
return {
|
return {
|
||||||
border: 'border-red-400/35 bg-[rgba(26,10,16,0.92)]',
|
border: 'border-red-400/35 bg-[rgba(26,10,16,0.92)]',
|
||||||
badge: 'border-red-500/30 text-red-300',
|
badge: 'border-red-500/30 text-red-300',
|
||||||
icon: <AlertTriangle size={13} />,
|
icon: <AlertTriangle size={12} />,
|
||||||
iconClassName: 'text-red-400',
|
iconClassName: 'text-red-400',
|
||||||
};
|
};
|
||||||
case 'warning':
|
case 'warning':
|
||||||
return {
|
return {
|
||||||
border: 'border-amber-400/35 bg-[rgba(31,18,8,0.92)]',
|
border: 'border-amber-400/35 bg-[rgba(31,18,8,0.92)]',
|
||||||
badge: 'border-amber-500/30 text-amber-200',
|
badge: 'border-amber-500/30 text-amber-200',
|
||||||
icon: <AlertTriangle size={13} />,
|
icon: <AlertTriangle size={12} />,
|
||||||
iconClassName: 'text-amber-400',
|
iconClassName: 'text-amber-400',
|
||||||
};
|
};
|
||||||
case 'success':
|
case 'success':
|
||||||
return {
|
return {
|
||||||
border: 'border-emerald-400/35 bg-[rgba(8,24,18,0.92)]',
|
border: 'border-emerald-400/35 bg-[rgba(8,24,18,0.92)]',
|
||||||
badge: 'border-emerald-500/30 text-emerald-200',
|
badge: 'border-emerald-500/30 text-emerald-200',
|
||||||
icon: <CheckCircle2 size={13} />,
|
icon: <CheckCircle2 size={12} />,
|
||||||
iconClassName: 'text-emerald-400',
|
iconClassName: 'text-emerald-400',
|
||||||
};
|
};
|
||||||
default:
|
default:
|
||||||
return {
|
return {
|
||||||
border: 'border-cyan-400/25 bg-[rgba(8,14,26,0.92)]',
|
border: 'border-cyan-400/25 bg-[rgba(8,14,26,0.92)]',
|
||||||
badge: 'border-cyan-500/20 text-cyan-200',
|
badge: 'border-cyan-500/20 text-cyan-200',
|
||||||
icon: <Loader2 size={13} className="animate-spin" />,
|
icon: <Loader2 size={12} className="animate-spin" />,
|
||||||
iconClassName: 'text-cyan-300',
|
iconClassName: 'text-cyan-300',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -80,26 +80,17 @@ function getToneClasses(tone: TeamProvisioningPresentation['compactTone']): {
|
||||||
|
|
||||||
export interface GraphProvisioningHudProps {
|
export interface GraphProvisioningHudProps {
|
||||||
teamName: string;
|
teamName: string;
|
||||||
leadNodeId: string | null;
|
|
||||||
getLaunchAnchorScreenPlacement: (
|
|
||||||
leadNodeId: string
|
|
||||||
) => { x: number; y: number; scale: number; visible: boolean } | null;
|
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GraphProvisioningHud = ({
|
export const GraphProvisioningHud = ({
|
||||||
teamName,
|
teamName,
|
||||||
leadNodeId,
|
|
||||||
getLaunchAnchorScreenPlacement,
|
|
||||||
enabled = true,
|
enabled = true,
|
||||||
}: GraphProvisioningHudProps): React.JSX.Element | null => {
|
}: GraphProvisioningHudProps): React.JSX.Element | null => {
|
||||||
const { presentation, runInstanceKey } = useTeamProvisioningPresentation(teamName);
|
const { presentation, runInstanceKey } = useTeamProvisioningPresentation(teamName);
|
||||||
const shellRef = useRef<HTMLDivElement>(null);
|
|
||||||
const lastActiveStepRef = useRef(-1);
|
const lastActiveStepRef = useRef(-1);
|
||||||
const [detailsOpen, setDetailsOpen] = useState(false);
|
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||||
const [dismissed, setDismissed] = useState(false);
|
const shouldRender = enabled && shouldRenderLaunchHud(presentation);
|
||||||
const shouldRender =
|
|
||||||
enabled && shouldRenderLaunchHud(presentation) && !dismissed && Boolean(leadNodeId);
|
|
||||||
const tone = presentation ? getToneClasses(presentation.compactTone) : null;
|
const tone = presentation ? getToneClasses(presentation.compactTone) : null;
|
||||||
const errorStepIndex = presentation?.isFailed
|
const errorStepIndex = presentation?.isFailed
|
||||||
? lastActiveStepRef.current >= 0
|
? lastActiveStepRef.current >= 0
|
||||||
|
|
@ -109,63 +100,21 @@ export const GraphProvisioningHud = ({
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDetailsOpen(false);
|
setDetailsOpen(false);
|
||||||
setDismissed(false);
|
|
||||||
lastActiveStepRef.current = -1;
|
lastActiveStepRef.current = -1;
|
||||||
}, [runInstanceKey, teamName]);
|
}, [runInstanceKey, teamName]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!shouldRender || !leadNodeId) {
|
|
||||||
setDetailsOpen(false);
|
|
||||||
}
|
|
||||||
}, [leadNodeId, shouldRender]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (presentation && !presentation.isFailed && presentation.currentStepIndex >= 0) {
|
if (presentation && !presentation.isFailed && presentation.currentStepIndex >= 0) {
|
||||||
lastActiveStepRef.current = presentation.currentStepIndex;
|
lastActiveStepRef.current = presentation.currentStepIndex;
|
||||||
}
|
}
|
||||||
}, [presentation]);
|
}, [presentation]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
if (!shouldRender || !leadNodeId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let frameId = 0;
|
|
||||||
const updatePosition = (): void => {
|
|
||||||
const shell = shellRef.current;
|
|
||||||
if (!shell) {
|
|
||||||
frameId = window.requestAnimationFrame(updatePosition);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const placement = getLaunchAnchorScreenPlacement(leadNodeId);
|
|
||||||
if (!placement) {
|
|
||||||
shell.style.opacity = '0';
|
|
||||||
frameId = window.requestAnimationFrame(updatePosition);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!placement.visible) {
|
|
||||||
shell.style.opacity = '0';
|
|
||||||
frameId = window.requestAnimationFrame(updatePosition);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
shell.style.opacity = '1';
|
|
||||||
shell.style.transform = `translate(${Math.round(placement.x)}px, ${Math.round(placement.y)}px) scale(${placement.scale.toFixed(3)})`;
|
|
||||||
frameId = window.requestAnimationFrame(updatePosition);
|
|
||||||
};
|
|
||||||
|
|
||||||
updatePosition();
|
|
||||||
return () => {
|
|
||||||
window.cancelAnimationFrame(frameId);
|
|
||||||
};
|
|
||||||
}, [getLaunchAnchorScreenPlacement, leadNodeId, shouldRender]);
|
|
||||||
|
|
||||||
const compactLabel = useMemo(() => {
|
const compactLabel = useMemo(() => {
|
||||||
if (!presentation?.compactDetail) {
|
if (!presentation?.compactDetail) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return presentation.compactDetail.length > 88
|
return presentation.compactDetail.length > 54
|
||||||
? `${presentation.compactDetail.slice(0, 88)}...`
|
? `${presentation.compactDetail.slice(0, 54)}...`
|
||||||
: presentation.compactDetail;
|
: presentation.compactDetail;
|
||||||
}, [presentation?.compactDetail]);
|
}, [presentation?.compactDetail]);
|
||||||
|
|
||||||
|
|
@ -174,21 +123,21 @@ export const GraphProvisioningHud = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<>
|
||||||
ref={shellRef}
|
<button
|
||||||
className="pointer-events-auto absolute z-10 w-[336px] origin-top-left opacity-0 transition-opacity"
|
type="button"
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded-xl border p-3 text-slate-100 shadow-[0_18px_48px_rgba(5,5,16,0.38)] backdrop-blur-xl',
|
'w-full rounded-xl border px-3 py-2 text-left text-slate-100 shadow-[0_14px_34px_rgba(5,5,16,0.24)] backdrop-blur-xl transition-colors hover:bg-[rgba(12,18,32,0.96)]',
|
||||||
tone.border
|
tone.border
|
||||||
)}
|
)}
|
||||||
|
onClick={() => setDetailsOpen(true)}
|
||||||
|
aria-label="Open launch details"
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<div className="min-w-0">
|
<span className={cn('shrink-0', tone.iconClassName)}>{tone.icon}</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="min-w-0 flex-1">
|
||||||
<span className={cn('shrink-0', tone.iconClassName)}>{tone.icon}</span>
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<div className="truncate text-sm font-semibold text-slate-50">
|
<div className="truncate text-[11px] font-semibold text-slate-50">
|
||||||
{presentation.compactTitle}
|
{presentation.compactTitle}
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="outline" className={cn('px-1.5 py-0 text-[10px]', tone.badge)}>
|
<Badge variant="outline" className={cn('px-1.5 py-0 text-[10px]', tone.badge)}>
|
||||||
|
|
@ -202,35 +151,16 @@ export const GraphProvisioningHud = ({
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
{compactLabel ? (
|
{compactLabel ? (
|
||||||
<div className="mt-1 text-[11px] leading-5 text-slate-300">{compactLabel}</div>
|
<div className="mt-0.5 truncate text-[10px] leading-4 text-slate-300">
|
||||||
|
{compactLabel}
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="inline-flex size-7 items-center justify-center rounded-md border border-white/10 bg-white/5 text-slate-400 transition-colors hover:bg-white/10 hover:text-slate-100"
|
|
||||||
onClick={() => setDetailsOpen(true)}
|
|
||||||
aria-label="Open launch details"
|
|
||||||
>
|
|
||||||
<ExternalLink size={14} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="inline-flex size-7 items-center justify-center rounded-md border border-white/10 bg-white/5 text-slate-400 transition-colors hover:bg-white/10 hover:text-slate-100"
|
|
||||||
onClick={() => setDismissed(true)}
|
|
||||||
aria-label="Dismiss launch overlay"
|
|
||||||
>
|
|
||||||
<X size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<div
|
||||||
type="button"
|
className="border-cyan-300/12 mt-2 overflow-hidden rounded-lg border bg-[rgba(4,10,20,0.58)] px-2 py-1.5"
|
||||||
className="border-cyan-300/12 mt-3 w-full rounded-xl border bg-[rgba(4,10,20,0.58)] p-3 text-left transition-colors hover:bg-[rgba(8,18,32,0.76)]"
|
|
||||||
style={HUD_STEPPER_STYLE}
|
style={HUD_STEPPER_STYLE}
|
||||||
onClick={() => setDetailsOpen(true)}
|
|
||||||
aria-label="Open full launch details"
|
|
||||||
>
|
>
|
||||||
<StepProgressBar
|
<StepProgressBar
|
||||||
steps={MINI_STEPS}
|
steps={MINI_STEPS}
|
||||||
|
|
@ -238,8 +168,8 @@ export const GraphProvisioningHud = ({
|
||||||
errorIndex={errorStepIndex}
|
errorIndex={errorStepIndex}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
</button>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
|
|
||||||
<Dialog open={detailsOpen} onOpenChange={setDetailsOpen}>
|
<Dialog open={detailsOpen} onOpenChange={setDetailsOpen}>
|
||||||
<DialogContent className="w-[min(1120px,92vw)] max-w-5xl p-0">
|
<DialogContent className="w-[min(1120px,92vw)] max-w-5xl p-0">
|
||||||
|
|
@ -254,6 +184,6 @@ export const GraphProvisioningHud = ({
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,12 @@
|
||||||
* Follows the exact ProjectEditorOverlay pattern (lazy-loaded, fixed z-50).
|
* 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 { GraphView } from '@claude-teams/agent-graph';
|
||||||
import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHost';
|
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 { useGraphCreateTaskDialog } from '../hooks/useGraphCreateTaskDialog';
|
||||||
import { useGraphSidebarVisibility } from '../hooks/useGraphSidebarVisibility';
|
import { useGraphSidebarVisibility } from '../hooks/useGraphSidebarVisibility';
|
||||||
|
|
@ -53,15 +55,14 @@ export const TeamGraphOverlay = ({
|
||||||
}: TeamGraphOverlayProps): React.JSX.Element => {
|
}: TeamGraphOverlayProps): React.JSX.Element => {
|
||||||
const graphData = useTeamGraphAdapter(teamName);
|
const graphData = useTeamGraphAdapter(teamName);
|
||||||
const { openTeamPage: openTeamTab, commitOwnerSlotDrop } = useTeamGraphSurfaceActions(teamName);
|
const { openTeamPage: openTeamTab, commitOwnerSlotDrop } = useTeamGraphSurfaceActions(teamName);
|
||||||
|
const resetTeamGraphSlotAssignmentsToDefaults = useStore(
|
||||||
|
(s) => s.resetTeamGraphSlotAssignmentsToDefaults
|
||||||
|
);
|
||||||
const { sidebarVisible: persistedSidebarVisible, toggleSidebarVisible } =
|
const { sidebarVisible: persistedSidebarVisible, toggleSidebarVisible } =
|
||||||
useGraphSidebarVisibility();
|
useGraphSidebarVisibility();
|
||||||
const { dialog: createTaskDialog, openCreateTaskDialog } = useGraphCreateTaskDialog(teamName);
|
const { dialog: createTaskDialog, openCreateTaskDialog } = useGraphCreateTaskDialog(teamName);
|
||||||
const effectiveSidebarVisible = sidebarVisible ?? persistedSidebarVisible;
|
const effectiveSidebarVisible = sidebarVisible ?? persistedSidebarVisible;
|
||||||
const handleToggleSidebar = onToggleSidebar ?? toggleSidebarVisible;
|
const handleToggleSidebar = onToggleSidebar ?? toggleSidebarVisible;
|
||||||
const leadNodeId = useMemo(
|
|
||||||
() => graphData.nodes.find((node) => node.kind === 'lead')?.id ?? null,
|
|
||||||
[graphData.nodes]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Task action dispatchers (same pattern as TeamGraphTab)
|
// Task action dispatchers (same pattern as TeamGraphTab)
|
||||||
const dispatchTaskAction = useCallback(
|
const dispatchTaskAction = useCallback(
|
||||||
|
|
@ -90,6 +91,13 @@ export const TeamGraphOverlay = ({
|
||||||
openCreateTaskDialog('');
|
openCreateTaskDialog('');
|
||||||
}, [openCreateTaskDialog]);
|
}, [openCreateTaskDialog]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!isTeamGraphSlotPersistenceDisabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resetTeamGraphSlotAssignmentsToDefaults(teamName);
|
||||||
|
}, [resetTeamGraphSlotAssignmentsToDefaults, teamName]);
|
||||||
|
|
||||||
const events: GraphEventPort = {
|
const events: GraphEventPort = {
|
||||||
onNodeDoubleClick: useCallback(
|
onNodeDoubleClick: useCallback(
|
||||||
(ref: GraphDomainRef) => {
|
(ref: GraphDomainRef) => {
|
||||||
|
|
@ -120,53 +128,47 @@ export const TeamGraphOverlay = ({
|
||||||
<GraphView
|
<GraphView
|
||||||
data={graphData}
|
data={graphData}
|
||||||
events={events}
|
events={events}
|
||||||
|
isSurfaceActive
|
||||||
onRequestClose={onClose}
|
onRequestClose={onClose}
|
||||||
onRequestPinAsTab={onPinAsTab}
|
onRequestPinAsTab={onPinAsTab}
|
||||||
onOpenTeamPage={openTeamPage}
|
onOpenTeamPage={openTeamPage}
|
||||||
onCreateTask={openCreateTask}
|
onCreateTask={openCreateTask}
|
||||||
onToggleSidebar={handleToggleSidebar}
|
onToggleSidebar={handleToggleSidebar}
|
||||||
isSidebarVisible={effectiveSidebarVisible}
|
isSidebarVisible={effectiveSidebarVisible}
|
||||||
|
renderTopToolbarContent={() => <GraphProvisioningHud teamName={teamName} />}
|
||||||
onOwnerSlotDrop={commitOwnerSlotDrop}
|
onOwnerSlotDrop={commitOwnerSlotDrop}
|
||||||
className="team-graph-view min-w-0 flex-1"
|
className="team-graph-view min-w-0 flex-1"
|
||||||
renderHud={(hudProps) => {
|
renderHud={(hudProps) => {
|
||||||
const extraHudProps = hudProps as typeof hudProps & {
|
const extraHudProps = hudProps as typeof hudProps & {
|
||||||
getViewportSize?: () => { width: number; height: number };
|
getViewportSize?: () => { width: number; height: number };
|
||||||
getActivityAnchorWorldPosition?: (
|
getActivityWorldRect?: (ownerNodeId: string) => {
|
||||||
ownerNodeId: string
|
left: number;
|
||||||
) => { x: number; y: number } | null;
|
top: number;
|
||||||
|
right: number;
|
||||||
|
bottom: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
} | null;
|
||||||
getCameraZoom?: () => number;
|
getCameraZoom?: () => number;
|
||||||
worldToScreen?: (x: number, y: number) => { x: number; y: number };
|
worldToScreen?: (x: number, y: number) => { x: number; y: number };
|
||||||
getNodeWorldPosition?: (nodeId: string) => { x: number; y: number } | null;
|
getNodeWorldPosition?: (nodeId: string) => { x: number; y: number } | null;
|
||||||
};
|
};
|
||||||
const {
|
const { getViewportSize, focusNodeIds } = extraHudProps;
|
||||||
getLaunchAnchorScreenPlacement,
|
|
||||||
getActivityAnchorScreenPlacement,
|
|
||||||
getViewportSize,
|
|
||||||
getNodeScreenPosition,
|
|
||||||
focusNodeIds,
|
|
||||||
} = extraHudProps;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<GraphActivityHud
|
<GraphActivityHud
|
||||||
teamName={teamName}
|
teamName={teamName}
|
||||||
nodes={graphData.nodes}
|
nodes={graphData.nodes}
|
||||||
getActivityAnchorScreenPlacement={getActivityAnchorScreenPlacement}
|
getActivityWorldRect={extraHudProps.getActivityWorldRect}
|
||||||
getActivityAnchorWorldPosition={extraHudProps.getActivityAnchorWorldPosition}
|
|
||||||
getCameraZoom={extraHudProps.getCameraZoom}
|
getCameraZoom={extraHudProps.getCameraZoom}
|
||||||
worldToScreen={extraHudProps.worldToScreen}
|
worldToScreen={extraHudProps.worldToScreen}
|
||||||
getNodeWorldPosition={extraHudProps.getNodeWorldPosition}
|
getNodeWorldPosition={extraHudProps.getNodeWorldPosition}
|
||||||
getViewportSize={getViewportSize}
|
getViewportSize={getViewportSize}
|
||||||
getNodeScreenPosition={getNodeScreenPosition}
|
|
||||||
focusNodeIds={focusNodeIds}
|
focusNodeIds={focusNodeIds}
|
||||||
onOpenTaskDetail={onOpenTaskDetail}
|
onOpenTaskDetail={onOpenTaskDetail}
|
||||||
onOpenMemberProfile={onOpenMemberProfile}
|
onOpenMemberProfile={onOpenMemberProfile}
|
||||||
/>
|
/>
|
||||||
<GraphProvisioningHud
|
|
||||||
teamName={teamName}
|
|
||||||
leadNodeId={leadNodeId}
|
|
||||||
getLaunchAnchorScreenPlacement={getLaunchAnchorScreenPlacement}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,12 @@
|
||||||
* Provides Fullscreen button that opens the overlay.
|
* 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 { GraphView } from '@claude-teams/agent-graph';
|
||||||
import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHost';
|
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 { useGraphCreateTaskDialog } from '../hooks/useGraphCreateTaskDialog';
|
||||||
import { useGraphSidebarVisibility } from '../hooks/useGraphSidebarVisibility';
|
import { useGraphSidebarVisibility } from '../hooks/useGraphSidebarVisibility';
|
||||||
|
|
@ -46,9 +48,8 @@ export const TeamGraphTab = ({
|
||||||
}: TeamGraphTabProps): React.JSX.Element => {
|
}: TeamGraphTabProps): React.JSX.Element => {
|
||||||
const graphData = useTeamGraphAdapter(teamName);
|
const graphData = useTeamGraphAdapter(teamName);
|
||||||
const { openTeamPage, commitOwnerSlotDrop } = useTeamGraphSurfaceActions(teamName);
|
const { openTeamPage, commitOwnerSlotDrop } = useTeamGraphSurfaceActions(teamName);
|
||||||
const leadNodeId = useMemo(
|
const resetTeamGraphSlotAssignmentsToDefaults = useStore(
|
||||||
() => graphData.nodes.find((node) => node.kind === 'lead')?.id ?? null,
|
(s) => s.resetTeamGraphSlotAssignmentsToDefaults
|
||||||
[graphData.nodes]
|
|
||||||
);
|
);
|
||||||
const [fullscreen, setFullscreen] = useState(false);
|
const [fullscreen, setFullscreen] = useState(false);
|
||||||
const { sidebarVisible, toggleSidebarVisible } = useGraphSidebarVisibility();
|
const { sidebarVisible, toggleSidebarVisible } = useGraphSidebarVisibility();
|
||||||
|
|
@ -80,6 +81,13 @@ export const TeamGraphTab = ({
|
||||||
openCreateTaskDialog('');
|
openCreateTaskDialog('');
|
||||||
}, [openCreateTaskDialog]);
|
}, [openCreateTaskDialog]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!isTeamGraphSlotPersistenceDisabled() || !isActive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resetTeamGraphSlotAssignmentsToDefaults(teamName);
|
||||||
|
}, [isActive, resetTeamGraphSlotAssignmentsToDefaults, teamName]);
|
||||||
|
|
||||||
// Task action dispatchers
|
// Task action dispatchers
|
||||||
const dispatchTaskAction = useCallback(
|
const dispatchTaskAction = useCallback(
|
||||||
(action: string) => (taskId: string) =>
|
(action: string) => (taskId: string) =>
|
||||||
|
|
@ -144,53 +152,48 @@ export const TeamGraphTab = ({
|
||||||
events={events}
|
events={events}
|
||||||
className="team-graph-view size-full"
|
className="team-graph-view size-full"
|
||||||
suspendAnimation={!isActive}
|
suspendAnimation={!isActive}
|
||||||
|
isSurfaceActive={isActive}
|
||||||
onRequestFullscreen={() => setFullscreen(true)}
|
onRequestFullscreen={() => setFullscreen(true)}
|
||||||
onOpenTeamPage={openTeamPage}
|
onOpenTeamPage={openTeamPage}
|
||||||
onCreateTask={openCreateTask}
|
onCreateTask={openCreateTask}
|
||||||
onToggleSidebar={toggleSidebarVisible}
|
onToggleSidebar={toggleSidebarVisible}
|
||||||
isSidebarVisible={sidebarVisible}
|
isSidebarVisible={sidebarVisible}
|
||||||
|
renderTopToolbarContent={() => (
|
||||||
|
<GraphProvisioningHud teamName={teamName} enabled={isActive} />
|
||||||
|
)}
|
||||||
onOwnerSlotDrop={commitOwnerSlotDrop}
|
onOwnerSlotDrop={commitOwnerSlotDrop}
|
||||||
renderHud={(hudProps) => {
|
renderHud={(hudProps) => {
|
||||||
const extraHudProps = hudProps as typeof hudProps & {
|
const extraHudProps = hudProps as typeof hudProps & {
|
||||||
getViewportSize?: () => { width: number; height: number };
|
getViewportSize?: () => { width: number; height: number };
|
||||||
getActivityAnchorWorldPosition?: (
|
getActivityWorldRect?: (ownerNodeId: string) => {
|
||||||
ownerNodeId: string
|
left: number;
|
||||||
) => { x: number; y: number } | null;
|
top: number;
|
||||||
|
right: number;
|
||||||
|
bottom: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
} | null;
|
||||||
getCameraZoom?: () => number;
|
getCameraZoom?: () => number;
|
||||||
worldToScreen?: (x: number, y: number) => { x: number; y: number };
|
worldToScreen?: (x: number, y: number) => { x: number; y: number };
|
||||||
getNodeWorldPosition?: (nodeId: string) => { x: number; y: number } | null;
|
getNodeWorldPosition?: (nodeId: string) => { x: number; y: number } | null;
|
||||||
};
|
};
|
||||||
const {
|
const { getViewportSize, focusNodeIds } = extraHudProps;
|
||||||
getLaunchAnchorScreenPlacement,
|
|
||||||
getActivityAnchorScreenPlacement,
|
|
||||||
getViewportSize,
|
|
||||||
getNodeScreenPosition,
|
|
||||||
focusNodeIds,
|
|
||||||
} = extraHudProps;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<GraphActivityHud
|
<GraphActivityHud
|
||||||
teamName={teamName}
|
teamName={teamName}
|
||||||
nodes={graphData.nodes}
|
nodes={graphData.nodes}
|
||||||
getActivityAnchorScreenPlacement={getActivityAnchorScreenPlacement}
|
getActivityWorldRect={extraHudProps.getActivityWorldRect}
|
||||||
getActivityAnchorWorldPosition={extraHudProps.getActivityAnchorWorldPosition}
|
|
||||||
getCameraZoom={extraHudProps.getCameraZoom}
|
getCameraZoom={extraHudProps.getCameraZoom}
|
||||||
worldToScreen={extraHudProps.worldToScreen}
|
worldToScreen={extraHudProps.worldToScreen}
|
||||||
getNodeWorldPosition={extraHudProps.getNodeWorldPosition}
|
getNodeWorldPosition={extraHudProps.getNodeWorldPosition}
|
||||||
getViewportSize={getViewportSize}
|
getViewportSize={getViewportSize}
|
||||||
getNodeScreenPosition={getNodeScreenPosition}
|
|
||||||
focusNodeIds={focusNodeIds}
|
focusNodeIds={focusNodeIds}
|
||||||
enabled={isActive}
|
enabled={isActive}
|
||||||
onOpenTaskDetail={dispatchOpenTask}
|
onOpenTaskDetail={dispatchOpenTask}
|
||||||
onOpenMemberProfile={dispatchOpenProfile}
|
onOpenMemberProfile={dispatchOpenProfile}
|
||||||
/>
|
/>
|
||||||
<GraphProvisioningHud
|
|
||||||
teamName={teamName}
|
|
||||||
leadNodeId={leadNodeId}
|
|
||||||
getLaunchAnchorScreenPlacement={getLaunchAnchorScreenPlacement}
|
|
||||||
enabled={isActive}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc';
|
||||||
import { mergeTeamMessages } from '@renderer/utils/mergeTeamMessages';
|
import { mergeTeamMessages } from '@renderer/utils/mergeTeamMessages';
|
||||||
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
||||||
import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team';
|
import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team';
|
||||||
|
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
|
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
|
||||||
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||||
|
|
@ -61,6 +62,7 @@ import type {
|
||||||
import type { StateCreator } from 'zustand';
|
import type { StateCreator } from 'zustand';
|
||||||
|
|
||||||
const GRAPH_STABLE_SLOT_LAYOUT_VERSION = 'stable-slots-v1' as const;
|
const GRAPH_STABLE_SLOT_LAYOUT_VERSION = 'stable-slots-v1' as const;
|
||||||
|
const DISABLE_PERSISTED_TEAM_GRAPH_SLOT_ASSIGNMENTS = true;
|
||||||
const logger = createLogger('teamSlice');
|
const logger = createLogger('teamSlice');
|
||||||
|
|
||||||
const TEAM_GET_DATA_TIMEOUT_MS = 30_000;
|
const TEAM_GET_DATA_TIMEOUT_MS = 30_000;
|
||||||
|
|
@ -98,11 +100,33 @@ const teamRefreshBurstDiagnostics = new Map<
|
||||||
{ windowStartedAt: number; count: number; lastWarnAt: number }
|
{ windowStartedAt: number; count: number; lastWarnAt: number }
|
||||||
>();
|
>();
|
||||||
const memberSpawnUiEqualLastWarnAtByTeam = new Map<string, number>();
|
const memberSpawnUiEqualLastWarnAtByTeam = new Map<string, number>();
|
||||||
|
const sessionDefaultGraphSlotAssignmentsAppliedByTeam = new Set<string>();
|
||||||
interface RefreshTeamDataOptions {
|
interface RefreshTeamDataOptions {
|
||||||
withDedup?: boolean;
|
withDedup?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type TeamGraphSlotAssignments = Record<string, GraphOwnerSlotAssignment>;
|
type TeamGraphSlotAssignments = Record<string, GraphOwnerSlotAssignment>;
|
||||||
|
type TeamGraphMemberSeedInput = Pick<TeamMemberSnapshot, 'name' | 'agentId' | 'removedAt'>;
|
||||||
|
|
||||||
|
const SMALL_TEAM_CARDINAL_SLOT_PRESETS: ReadonlyArray<ReadonlyArray<GraphOwnerSlotAssignment>> = [
|
||||||
|
[],
|
||||||
|
[{ ringIndex: 0, sectorIndex: 0 }],
|
||||||
|
[
|
||||||
|
{ ringIndex: 0, sectorIndex: 0 },
|
||||||
|
{ ringIndex: 0, sectorIndex: 1 },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ ringIndex: 0, sectorIndex: 0 },
|
||||||
|
{ ringIndex: 0, sectorIndex: 1 },
|
||||||
|
{ 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 {
|
export function isTeamDataRefreshPending(teamName: string): boolean {
|
||||||
return (
|
return (
|
||||||
|
|
@ -147,6 +171,7 @@ export function __resetTeamSliceModuleStateForTests(): void {
|
||||||
resolvedMemberSelectorCache.clear();
|
resolvedMemberSelectorCache.clear();
|
||||||
mergedMessagesSelectorCache.clear();
|
mergedMessagesSelectorCache.clear();
|
||||||
memberMessagesSelectorCache.clear();
|
memberMessagesSelectorCache.clear();
|
||||||
|
sessionDefaultGraphSlotAssignmentsAppliedByTeam.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
function nowIso(): string {
|
function nowIso(): string {
|
||||||
|
|
@ -1307,7 +1332,7 @@ export function selectTeamDataForName(
|
||||||
|
|
||||||
function migrateStableSlotAssignmentsForMembers(
|
function migrateStableSlotAssignmentsForMembers(
|
||||||
assignments: TeamGraphSlotAssignments | undefined,
|
assignments: TeamGraphSlotAssignments | undefined,
|
||||||
members: readonly Pick<TeamViewSnapshot['members'][number], 'name' | 'agentId'>[]
|
members: readonly TeamGraphMemberSeedInput[]
|
||||||
): { assignments: TeamGraphSlotAssignments; changed: boolean } {
|
): { assignments: TeamGraphSlotAssignments; changed: boolean } {
|
||||||
const nextAssignments: TeamGraphSlotAssignments = { ...(assignments ?? {}) };
|
const nextAssignments: TeamGraphSlotAssignments = { ...(assignments ?? {}) };
|
||||||
let changed = false;
|
let changed = false;
|
||||||
|
|
@ -1485,6 +1510,74 @@ function isMemberActivityMetaStale(
|
||||||
return meta.feedRevision !== feedRevision;
|
return meta.feedRevision !== feedRevision;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function seedStableSlotAssignmentsForMembers(
|
||||||
|
assignments: TeamGraphSlotAssignments,
|
||||||
|
members: readonly TeamGraphMemberSeedInput[]
|
||||||
|
): { assignments: TeamGraphSlotAssignments; changed: boolean } {
|
||||||
|
const visibleMembers = members.filter((member) => !member.removedAt && !isLeadMember(member));
|
||||||
|
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 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(
|
function isVisibleInActiveTeamSurface(
|
||||||
state: Pick<AppState, 'paneLayout'>,
|
state: Pick<AppState, 'paneLayout'>,
|
||||||
teamName: string | null | undefined
|
teamName: string | null | undefined
|
||||||
|
|
@ -1594,7 +1687,7 @@ export interface TeamSlice {
|
||||||
clearKanbanFilter: () => void;
|
clearKanbanFilter: () => void;
|
||||||
ensureTeamGraphSlotAssignments: (
|
ensureTeamGraphSlotAssignments: (
|
||||||
teamName: string,
|
teamName: string,
|
||||||
members: readonly Pick<TeamViewSnapshot['members'][number], 'name' | 'agentId'>[]
|
members: readonly TeamGraphMemberSeedInput[]
|
||||||
) => void;
|
) => void;
|
||||||
setTeamGraphOwnerSlotAssignment: (
|
setTeamGraphOwnerSlotAssignment: (
|
||||||
teamName: string,
|
teamName: string,
|
||||||
|
|
@ -2301,15 +2394,41 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
if (state.slotLayoutVersion !== GRAPH_STABLE_SLOT_LAYOUT_VERSION) {
|
if (state.slotLayoutVersion !== GRAPH_STABLE_SLOT_LAYOUT_VERSION) {
|
||||||
nextState.slotLayoutVersion = GRAPH_STABLE_SLOT_LAYOUT_VERSION;
|
nextState.slotLayoutVersion = GRAPH_STABLE_SLOT_LAYOUT_VERSION;
|
||||||
nextSlotAssignmentsByTeam = {};
|
nextSlotAssignmentsByTeam = {};
|
||||||
|
sessionDefaultGraphSlotAssignmentsAppliedByTeam.clear();
|
||||||
changed = true;
|
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 currentAssignments = nextSlotAssignmentsByTeam[teamName];
|
||||||
const migrated = migrateStableSlotAssignmentsForMembers(currentAssignments, members);
|
const migrated = migrateStableSlotAssignmentsForMembers(currentAssignments, members);
|
||||||
if (migrated.changed) {
|
const seeded = seedStableSlotAssignmentsForMembers(migrated.assignments, members);
|
||||||
|
if (migrated.changed || seeded.changed) {
|
||||||
nextSlotAssignmentsByTeam = {
|
nextSlotAssignmentsByTeam = {
|
||||||
...nextSlotAssignmentsByTeam,
|
...nextSlotAssignmentsByTeam,
|
||||||
[teamName]: migrated.assignments,
|
[teamName]: seeded.assignments,
|
||||||
};
|
};
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
|
|
@ -2450,6 +2569,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
clearTeamGraphSlotAssignments: (teamName) => {
|
clearTeamGraphSlotAssignments: (teamName) => {
|
||||||
set((state) => {
|
set((state) => {
|
||||||
if (!teamName) {
|
if (!teamName) {
|
||||||
|
sessionDefaultGraphSlotAssignmentsAppliedByTeam.clear();
|
||||||
if (
|
if (
|
||||||
Object.keys(state.slotAssignmentsByTeam).length === 0 &&
|
Object.keys(state.slotAssignmentsByTeam).length === 0 &&
|
||||||
state.slotLayoutVersion === GRAPH_STABLE_SLOT_LAYOUT_VERSION
|
state.slotLayoutVersion === GRAPH_STABLE_SLOT_LAYOUT_VERSION
|
||||||
|
|
@ -2468,6 +2588,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
|
|
||||||
const nextAssignmentsByTeam = { ...state.slotAssignmentsByTeam };
|
const nextAssignmentsByTeam = { ...state.slotAssignmentsByTeam };
|
||||||
delete nextAssignmentsByTeam[teamName];
|
delete nextAssignmentsByTeam[teamName];
|
||||||
|
sessionDefaultGraphSlotAssignmentsAppliedByTeam.delete(teamName);
|
||||||
return {
|
return {
|
||||||
slotLayoutVersion: GRAPH_STABLE_SLOT_LAYOUT_VERSION,
|
slotLayoutVersion: GRAPH_STABLE_SLOT_LAYOUT_VERSION,
|
||||||
slotAssignmentsByTeam: nextAssignmentsByTeam,
|
slotAssignmentsByTeam: nextAssignmentsByTeam,
|
||||||
|
|
@ -2477,13 +2598,48 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
|
|
||||||
resetTeamGraphSlotAssignmentsToDefaults: (teamName) => {
|
resetTeamGraphSlotAssignmentsToDefaults: (teamName) => {
|
||||||
set((state) => {
|
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];
|
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 {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextAssignmentsByTeam = { ...state.slotAssignmentsByTeam };
|
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 {
|
return {
|
||||||
slotLayoutVersion: GRAPH_STABLE_SLOT_LAYOUT_VERSION,
|
slotLayoutVersion: GRAPH_STABLE_SLOT_LAYOUT_VERSION,
|
||||||
slotAssignmentsByTeam: nextAssignmentsByTeam,
|
slotAssignmentsByTeam: nextAssignmentsByTeam,
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import React, { act } from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { ACTIVITY_ANCHOR_LAYOUT } from '@claude-teams/agent-graph';
|
|
||||||
import { GraphActivityHud } from '@features/agent-graph/renderer/ui/GraphActivityHud';
|
import { GraphActivityHud } from '@features/agent-graph/renderer/ui/GraphActivityHud';
|
||||||
|
|
||||||
import type { GraphNode } from '@claude-teams/agent-graph';
|
import type { GraphNode } from '@claude-teams/agent-graph';
|
||||||
|
|
@ -231,7 +230,18 @@ describe('GraphActivityHud', () => {
|
||||||
React.createElement(GraphActivityHud, {
|
React.createElement(GraphActivityHud, {
|
||||||
teamName: 'demo-team',
|
teamName: 'demo-team',
|
||||||
nodes: [node],
|
nodes: [node],
|
||||||
getActivityAnchorScreenPlacement: () => ({ x: 40, y: 80, scale: 1, visible: true }),
|
getActivityWorldRect: () => ({
|
||||||
|
left: 40,
|
||||||
|
top: 80,
|
||||||
|
right: 336,
|
||||||
|
bottom: 372,
|
||||||
|
width: 296,
|
||||||
|
height: 292,
|
||||||
|
}),
|
||||||
|
getCameraZoom: () => 1,
|
||||||
|
worldToScreen: (x: number, y: number) => ({ x, y }),
|
||||||
|
getNodeWorldPosition: () => ({ x: 120, y: 40 }),
|
||||||
|
getViewportSize: () => ({ width: 1200, height: 800 }),
|
||||||
focusNodeIds: null,
|
focusNodeIds: null,
|
||||||
onOpenMemberProfile,
|
onOpenMemberProfile,
|
||||||
})
|
})
|
||||||
|
|
@ -260,7 +270,7 @@ describe('GraphActivityHud', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps the activity lane above the owner label area when packed anchor drifts too low', async () => {
|
it('pins the activity lane to the provided world rect without post-hoc repositioning', async () => {
|
||||||
const message: InboxMessage = {
|
const message: InboxMessage = {
|
||||||
from: 'team-lead',
|
from: 'team-lead',
|
||||||
to: 'jack',
|
to: 'jack',
|
||||||
|
|
@ -311,17 +321,23 @@ describe('GraphActivityHud', () => {
|
||||||
document.body.appendChild(host);
|
document.body.appendChild(host);
|
||||||
const root = createRoot(host);
|
const root = createRoot(host);
|
||||||
const nodeWorld = { x: 320, y: 300 };
|
const nodeWorld = { x: 320, y: 300 };
|
||||||
const packedAnchor = { x: 120, y: 260 };
|
const laneRect = {
|
||||||
|
left: 120,
|
||||||
|
top: 340,
|
||||||
|
right: 416,
|
||||||
|
bottom: 632,
|
||||||
|
width: 296,
|
||||||
|
height: 292,
|
||||||
|
};
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
root.render(
|
root.render(
|
||||||
React.createElement(GraphActivityHud, {
|
React.createElement(GraphActivityHud, {
|
||||||
teamName: 'demo-team',
|
teamName: 'demo-team',
|
||||||
nodes: [node],
|
nodes: [node],
|
||||||
getActivityAnchorScreenPlacement: () => ({ x: 40, y: 80, scale: 1, visible: true }),
|
getActivityWorldRect: () => laneRect,
|
||||||
getActivityAnchorWorldPosition: () => packedAnchor,
|
getCameraZoom: () => 1,
|
||||||
getNodeWorldPosition: () => nodeWorld,
|
getNodeWorldPosition: () => nodeWorld,
|
||||||
getNodeScreenPosition: () => ({ x: 400, y: 300, visible: true }),
|
|
||||||
getViewportSize: () => ({ width: 1200, height: 800 }),
|
getViewportSize: () => ({ width: 1200, height: 800 }),
|
||||||
worldToScreen: (x: number, y: number) => ({ x, y }),
|
worldToScreen: (x: number, y: number) => ({ x, y }),
|
||||||
focusNodeIds: null,
|
focusNodeIds: null,
|
||||||
|
|
@ -332,12 +348,8 @@ describe('GraphActivityHud', () => {
|
||||||
|
|
||||||
const shell = host.querySelector('.z-10');
|
const shell = host.querySelector('.z-10');
|
||||||
expect(shell).not.toBeNull();
|
expect(shell).not.toBeNull();
|
||||||
const expectedTop =
|
expect((shell as HTMLDivElement).style.left).toBe(`${laneRect.left}px`);
|
||||||
nodeWorld.y +
|
expect((shell as HTMLDivElement).style.top).toBe(`${laneRect.top}px`);
|
||||||
ACTIVITY_ANCHOR_LAYOUT.memberOffsetY +
|
|
||||||
ACTIVITY_ANCHOR_LAYOUT.reservedHeight -
|
|
||||||
220;
|
|
||||||
expect((shell as HTMLDivElement).style.top).toBe(`${Math.round(expectedTop)}px`);
|
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
root.unmount();
|
root.unmount();
|
||||||
|
|
|
||||||
|
|
@ -46,8 +46,6 @@ vi.mock('@renderer/components/team/TeamProvisioningPanel', () => ({
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const placement = { x: 120, y: 80, scale: 1, visible: true };
|
|
||||||
|
|
||||||
describe('GraphProvisioningHud', () => {
|
describe('GraphProvisioningHud', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
document.body.innerHTML = '';
|
document.body.innerHTML = '';
|
||||||
|
|
@ -77,8 +75,6 @@ describe('GraphProvisioningHud', () => {
|
||||||
root.render(
|
root.render(
|
||||||
React.createElement(GraphProvisioningHud, {
|
React.createElement(GraphProvisioningHud, {
|
||||||
teamName: 'northstar-core',
|
teamName: 'northstar-core',
|
||||||
leadNodeId: 'lead:northstar-core',
|
|
||||||
getLaunchAnchorScreenPlacement: () => placement,
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
|
|
@ -117,14 +113,12 @@ describe('GraphProvisioningHud', () => {
|
||||||
root.render(
|
root.render(
|
||||||
React.createElement(GraphProvisioningHud, {
|
React.createElement(GraphProvisioningHud, {
|
||||||
teamName: 'northstar-core',
|
teamName: 'northstar-core',
|
||||||
leadNodeId: 'lead:northstar-core',
|
|
||||||
getLaunchAnchorScreenPlacement: () => placement,
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
});
|
});
|
||||||
|
|
||||||
const openButton = host.querySelector('button[aria-label="Open full launch details"]');
|
const openButton = host.querySelector('button[aria-label="Open launch details"]');
|
||||||
expect(openButton).not.toBeNull();
|
expect(openButton).not.toBeNull();
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
|
|
@ -165,8 +159,6 @@ describe('GraphProvisioningHud', () => {
|
||||||
root.render(
|
root.render(
|
||||||
React.createElement(GraphProvisioningHud, {
|
React.createElement(GraphProvisioningHud, {
|
||||||
teamName: 'northstar-core',
|
teamName: 'northstar-core',
|
||||||
leadNodeId: 'lead:northstar-core',
|
|
||||||
getLaunchAnchorScreenPlacement: () => placement,
|
|
||||||
enabled: false,
|
enabled: false,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -740,6 +740,80 @@ describe('TeamGraphAdapter particles', () => {
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('resolves task and process owners by stable owner id aliases, not only member names', () => {
|
||||||
|
const adapter = TeamGraphAdapter.create();
|
||||||
|
|
||||||
|
const graph = adapter.adapt(
|
||||||
|
createBaseTeamData({
|
||||||
|
config: {
|
||||||
|
name: 'My Team',
|
||||||
|
members: [
|
||||||
|
{ name: 'team-lead', agentId: 'lead-agent' },
|
||||||
|
{ name: 'alice', agentId: 'agent-alice' },
|
||||||
|
],
|
||||||
|
projectPath: '/repo',
|
||||||
|
},
|
||||||
|
members: [
|
||||||
|
{
|
||||||
|
name: 'team-lead',
|
||||||
|
status: 'active',
|
||||||
|
currentTaskId: null,
|
||||||
|
taskCount: 0,
|
||||||
|
lastActiveAt: null,
|
||||||
|
messageCount: 0,
|
||||||
|
agentType: 'team-lead',
|
||||||
|
agentId: 'lead-agent',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'alice',
|
||||||
|
status: 'active',
|
||||||
|
currentTaskId: null,
|
||||||
|
taskCount: 1,
|
||||||
|
lastActiveAt: null,
|
||||||
|
messageCount: 0,
|
||||||
|
agentId: 'agent-alice',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tasks: [
|
||||||
|
{
|
||||||
|
id: 'task-owned-by-stable-id',
|
||||||
|
displayId: '#42',
|
||||||
|
subject: 'Stable owner task',
|
||||||
|
owner: 'agent-alice',
|
||||||
|
status: 'completed',
|
||||||
|
comments: [],
|
||||||
|
reviewState: 'none',
|
||||||
|
} as TeamTaskWithKanban,
|
||||||
|
],
|
||||||
|
processes: [
|
||||||
|
{
|
||||||
|
id: 'proc-owned-by-stable-id',
|
||||||
|
label: 'Stable owner process',
|
||||||
|
pid: 4242,
|
||||||
|
registeredBy: 'agent-alice',
|
||||||
|
registeredAt: '2026-03-28T19:00:02.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
'my-team'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(findNode(graph, 'task:my-team:task-owned-by-stable-id')).toMatchObject({
|
||||||
|
ownerId: 'member:my-team:agent-alice',
|
||||||
|
taskStatus: 'completed',
|
||||||
|
});
|
||||||
|
expect(findNode(graph, 'process:my-team:proc-owned-by-stable-id')).toMatchObject({
|
||||||
|
ownerId: 'member:my-team:agent-alice',
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
graph.edges.some(
|
||||||
|
(edge) =>
|
||||||
|
edge.id ===
|
||||||
|
'edge:own:member:my-team:agent-alice:task:my-team:task-owned-by-stable-id'
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('skips noisy idle inbox rows in the activity feed while keeping cross-team traffic on the lead lane', () => {
|
it('skips noisy idle inbox rows in the activity feed while keeping cross-team traffic on the lead lane', () => {
|
||||||
const adapter = TeamGraphAdapter.create();
|
const adapter = TeamGraphAdapter.create();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -191,4 +191,72 @@ describe('buildInlineActivityEntries', () => {
|
||||||
taskRefs: [{ taskId: 'task-1', displayId: '#8fdd6803', teamName: 'my-team' }],
|
taskRefs: [{ taskId: 'task-1', displayId: '#8fdd6803', teamName: 'my-team' }],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('routes comment activity to a member lane when task.owner is stored as stable owner id', () => {
|
||||||
|
const data = createBaseTeamData({
|
||||||
|
config: {
|
||||||
|
name: 'My Team',
|
||||||
|
members: [{ name: 'team-lead', agentId: 'lead-agent' }, { name: 'jack', agentId: 'agent-jack' }],
|
||||||
|
projectPath: '/repo',
|
||||||
|
},
|
||||||
|
tasks: [
|
||||||
|
{
|
||||||
|
id: 'task-stable-owner',
|
||||||
|
displayId: '#91',
|
||||||
|
subject: 'Stable owner routing',
|
||||||
|
owner: 'agent-jack',
|
||||||
|
status: 'in_progress',
|
||||||
|
comments: [
|
||||||
|
{
|
||||||
|
id: 'comment-stable-owner',
|
||||||
|
author: 'team-lead',
|
||||||
|
text: 'Проверь финальную сводку перед merge',
|
||||||
|
createdAt: '2026-03-28T19:00:03.000Z',
|
||||||
|
type: 'regular',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
reviewState: 'none',
|
||||||
|
} as unknown as TeamTaskWithKanban,
|
||||||
|
],
|
||||||
|
members: [
|
||||||
|
{
|
||||||
|
name: 'team-lead',
|
||||||
|
status: 'active',
|
||||||
|
currentTaskId: null,
|
||||||
|
taskCount: 0,
|
||||||
|
lastActiveAt: null,
|
||||||
|
messageCount: 0,
|
||||||
|
agentType: 'team-lead',
|
||||||
|
agentId: 'lead-agent',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'jack',
|
||||||
|
status: 'active',
|
||||||
|
currentTaskId: null,
|
||||||
|
taskCount: 1,
|
||||||
|
lastActiveAt: null,
|
||||||
|
messageCount: 0,
|
||||||
|
agentId: 'agent-jack',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const entries = buildInlineActivityEntries({
|
||||||
|
data,
|
||||||
|
teamName: 'my-team',
|
||||||
|
leadId: 'lead:my-team',
|
||||||
|
leadName: getGraphLeadMemberName(data, 'my-team'),
|
||||||
|
ownerNodeIds: new Set(['lead:my-team', 'member:my-team:agent-jack']),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(entries.get('member:my-team:agent-jack')).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
graphItem: expect.objectContaining({
|
||||||
|
id: 'activity:comment:my-team:task-stable-owner:comment-stable-owner',
|
||||||
|
title: '#91 Stable owner routing',
|
||||||
|
taskId: 'task-stable-owner',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,19 @@ import { describe, expect, it, vi } from 'vitest';
|
||||||
import {
|
import {
|
||||||
buildStableSlotLayoutSnapshot,
|
buildStableSlotLayoutSnapshot,
|
||||||
computeOwnerFootprints,
|
computeOwnerFootprints,
|
||||||
|
computeProcessBandWidth,
|
||||||
resolveNearestSlotAssignment,
|
resolveNearestSlotAssignment,
|
||||||
snapshotToWorldBounds,
|
snapshotToWorldBounds,
|
||||||
|
translateSlotFrame,
|
||||||
validateStableSlotLayout,
|
validateStableSlotLayout,
|
||||||
} from '../../../../packages/agent-graph/src/layout/stableSlots';
|
} from '../../../../packages/agent-graph/src/layout/stableSlots';
|
||||||
import { STABLE_SLOT_GEOMETRY } from '../../../../packages/agent-graph/src/layout/stableSlotGeometry';
|
import { KanbanLayoutEngine } from '../../../../packages/agent-graph/src/layout/kanbanLayout';
|
||||||
import { ACTIVITY_ANCHOR_LAYOUT } from '../../../../packages/agent-graph/src/layout/activityLane';
|
import { TASK_PILL } from '../../../../packages/agent-graph/src/constants/canvas-constants';
|
||||||
|
import { ACTIVITY_LANE } from '../../../../packages/agent-graph/src/layout/activityLane';
|
||||||
|
import {
|
||||||
|
STABLE_SLOT_GEOMETRY,
|
||||||
|
STABLE_SLOT_SECTOR_VECTORS,
|
||||||
|
} from '../../../../packages/agent-graph/src/layout/stableSlotGeometry';
|
||||||
|
|
||||||
import type { GraphLayoutPort, GraphNode } from '@claude-teams/agent-graph';
|
import type { GraphLayoutPort, GraphNode } from '@claude-teams/agent-graph';
|
||||||
|
|
||||||
|
|
@ -51,6 +58,29 @@ function createTask(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createProcess(teamName: string, processId: string, ownerId: string): GraphNode {
|
||||||
|
return {
|
||||||
|
id: `process:${teamName}:${processId}`,
|
||||||
|
kind: 'process',
|
||||||
|
label: processId,
|
||||||
|
state: 'active',
|
||||||
|
ownerId,
|
||||||
|
domainRef: { kind: 'process', teamName, processId },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function rectsOverlap(
|
||||||
|
left: { left: number; right: number; top: number; bottom: number },
|
||||||
|
right: { left: number; right: number; top: number; bottom: number }
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
left.left < right.right &&
|
||||||
|
left.right > right.left &&
|
||||||
|
left.top < right.bottom &&
|
||||||
|
left.bottom > right.top
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
describe('stable slot layout planner', () => {
|
describe('stable slot layout planner', () => {
|
||||||
it('does not build a stable slot snapshot when the lead is missing', () => {
|
it('does not build a stable slot snapshot when the lead is missing', () => {
|
||||||
const snapshot = buildStableSlotLayoutSnapshot({
|
const snapshot = buildStableSlotLayoutSnapshot({
|
||||||
|
|
@ -68,7 +98,7 @@ describe('stable slot layout planner', () => {
|
||||||
expect(snapshot).toBeNull();
|
expect(snapshot).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('builds launch and activity geometry around the central lead block', () => {
|
it('builds lead activity inside the same central owner slot topology', () => {
|
||||||
const teamName = 'team-a';
|
const teamName = 'team-a';
|
||||||
const lead = createLead(teamName);
|
const lead = createLead(teamName);
|
||||||
const alice = createMember(teamName, 'agent-alice', 'alice');
|
const alice = createMember(teamName, 'agent-alice', 'alice');
|
||||||
|
|
@ -88,15 +118,17 @@ describe('stable slot layout planner', () => {
|
||||||
|
|
||||||
expect(snapshot).not.toBeNull();
|
expect(snapshot).not.toBeNull();
|
||||||
expect(snapshot?.leadNodeId).toBe(lead.id);
|
expect(snapshot?.leadNodeId).toBe(lead.id);
|
||||||
expect(snapshot?.launchAnchor).not.toBeNull();
|
expect(snapshot?.launchAnchor).toBeNull();
|
||||||
|
expect(snapshot?.leadSlotFrame.ownerId).toBe(lead.id);
|
||||||
expect(snapshot?.memberSlotFrames).toHaveLength(1);
|
expect(snapshot?.memberSlotFrames).toHaveLength(1);
|
||||||
expect(snapshot?.memberSlotFrames[0]?.ownerId).toBe(alice.id);
|
expect(snapshot?.memberSlotFrames[0]?.ownerId).toBe(alice.id);
|
||||||
expect(snapshot?.leadActivityRect.left).toBeLessThan(snapshot?.leadCoreRect.left ?? 0);
|
expect(snapshot?.leadActivityRect.top).toBeGreaterThan(snapshot?.leadCoreRect.bottom ?? 0);
|
||||||
expect(snapshot?.fitBounds.right).toBeGreaterThan(snapshot?.leadCoreRect.right ?? 0);
|
expect(snapshot?.leadSlotFrame.activityColumnRect.left).toBe(snapshot?.leadActivityRect.left);
|
||||||
|
expect(snapshot?.leadSlotFrame.activityColumnRect.top).toBe(snapshot?.leadActivityRect.top);
|
||||||
expect(validateStableSlotLayout(snapshot!)).toEqual({ valid: true });
|
expect(validateStableSlotLayout(snapshot!)).toEqual({ valid: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps a fixed process rail width centered inside the owner slot', () => {
|
it('builds a board band that contains both the activity column and kanban band', () => {
|
||||||
const teamName = 'team-process-width';
|
const teamName = 'team-process-width';
|
||||||
const lead = createLead(teamName);
|
const lead = createLead(teamName);
|
||||||
const alice = createMember(teamName, 'agent-alice', 'alice');
|
const alice = createMember(teamName, 'agent-alice', 'alice');
|
||||||
|
|
@ -116,11 +148,194 @@ describe('stable slot layout planner', () => {
|
||||||
|
|
||||||
const frame = snapshot?.memberSlotFrames[0];
|
const frame = snapshot?.memberSlotFrames[0];
|
||||||
expect(frame).toBeDefined();
|
expect(frame).toBeDefined();
|
||||||
expect(frame?.processBandRect.width).toBe(STABLE_SLOT_GEOMETRY.processRailWidth);
|
expect(frame?.boardBandRect.top).toBe(frame?.activityColumnRect.top);
|
||||||
expect(frame?.processBandRect.left).toBeCloseTo(
|
expect(frame?.boardBandRect.top).toBe(frame?.kanbanBandRect.top);
|
||||||
(frame?.bounds.left ?? 0) + ((frame?.bounds.width ?? 0) - STABLE_SLOT_GEOMETRY.processRailWidth) / 2,
|
expect(frame?.activityColumnRect.left).toBe(frame?.boardBandRect.left);
|
||||||
6
|
expect(frame?.kanbanBandRect.left).toBeGreaterThan(frame?.activityColumnRect.right ?? 0);
|
||||||
|
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);
|
||||||
|
const alice = createMember(teamName, 'agent-alice', 'alice');
|
||||||
|
const layout: GraphLayoutPort = {
|
||||||
|
version: 'stable-slots-v1',
|
||||||
|
ownerOrder: [alice.id],
|
||||||
|
slotAssignments: {
|
||||||
|
[alice.id]: { ringIndex: 0, sectorIndex: 1 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const [footprint] = computeOwnerFootprints([lead, alice], layout);
|
||||||
|
|
||||||
|
expect(footprint).toBeDefined();
|
||||||
|
expect(footprint?.activityColumnWidth).toBe(ACTIVITY_LANE.width);
|
||||||
|
expect(footprint?.activityColumnHeight).toBe(
|
||||||
|
ACTIVITY_LANE.headerHeight +
|
||||||
|
ACTIVITY_LANE.maxVisibleItems * ACTIVITY_LANE.rowHeight +
|
||||||
|
ACTIVITY_LANE.overflowHeight
|
||||||
);
|
);
|
||||||
|
expect(footprint?.kanbanBandWidth).toBe(TASK_PILL.width);
|
||||||
|
expect(footprint?.boardBandHeight).toBe(
|
||||||
|
Math.max(footprint?.activityColumnHeight ?? 0, footprint?.kanbanBandHeight ?? 0)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps diagonal ring-zero sectors closer than the legacy coarse central box radius', () => {
|
||||||
|
const teamName = 'team-directional-radius';
|
||||||
|
const lead = createLead(teamName);
|
||||||
|
const alice = createMember(teamName, 'agent-alice', 'alice');
|
||||||
|
const layout: GraphLayoutPort = {
|
||||||
|
version: 'stable-slots-v1',
|
||||||
|
ownerOrder: [alice.id],
|
||||||
|
slotAssignments: {
|
||||||
|
[alice.id]: { ringIndex: 0, sectorIndex: 1 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const snapshot = buildStableSlotLayoutSnapshot({
|
||||||
|
teamName,
|
||||||
|
nodes: [lead, alice],
|
||||||
|
layout,
|
||||||
|
});
|
||||||
|
const [footprint] = computeOwnerFootprints([lead, alice], layout);
|
||||||
|
const frame = snapshot?.memberSlotFrames[0];
|
||||||
|
const sectorVector = STABLE_SLOT_SECTOR_VECTORS[1];
|
||||||
|
|
||||||
|
expect(snapshot).not.toBeNull();
|
||||||
|
expect(frame).toBeDefined();
|
||||||
|
expect(footprint).toBeDefined();
|
||||||
|
|
||||||
|
const legacyHorizontalExtent = snapshot!.runtimeCentralExclusion.right;
|
||||||
|
const legacyVerticalExtent = Math.abs(snapshot!.runtimeCentralExclusion.top);
|
||||||
|
const legacyRequiredX =
|
||||||
|
(legacyHorizontalExtent + footprint!.slotWidth / 2 + STABLE_SLOT_GEOMETRY.ringPadding) /
|
||||||
|
Math.abs(sectorVector.x);
|
||||||
|
const legacyRequiredY =
|
||||||
|
(legacyVerticalExtent + footprint!.slotHeight / 2 + STABLE_SLOT_GEOMETRY.ringPadding) /
|
||||||
|
Math.abs(sectorVector.y);
|
||||||
|
const legacyMinRadius = Math.max(legacyRequiredX, legacyRequiredY, 0);
|
||||||
|
const actualRadius = Math.abs(frame!.ownerX / sectorVector.x);
|
||||||
|
|
||||||
|
expect(actualRadius).toBeLessThan(legacyMinRadius);
|
||||||
|
expect(
|
||||||
|
snapshot!.centralCollisionRects.some((rect) => rectsOverlap(frame!.bounds, rect))
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('grows process band width when an owner has multiple visible process nodes', () => {
|
||||||
|
const teamName = 'team-process-growth';
|
||||||
|
const lead = createLead(teamName);
|
||||||
|
const alice = createMember(teamName, 'agent-alice', 'alice');
|
||||||
|
const processes = Array.from({ length: 7 }, (_, index) =>
|
||||||
|
createProcess(teamName, `proc-${index + 1}`, alice.id)
|
||||||
|
);
|
||||||
|
const layout: GraphLayoutPort = {
|
||||||
|
version: 'stable-slots-v1',
|
||||||
|
ownerOrder: [alice.id],
|
||||||
|
slotAssignments: {
|
||||||
|
[alice.id]: { ringIndex: 0, sectorIndex: 1 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const [footprint] = computeOwnerFootprints([lead, alice, ...processes], layout);
|
||||||
|
|
||||||
|
expect(footprint).toBeDefined();
|
||||||
|
expect(footprint?.processCount).toBe(7);
|
||||||
|
expect(footprint?.processBandWidth).toBe(computeProcessBandWidth(7));
|
||||||
|
expect((footprint?.processBandWidth ?? 0) > STABLE_SLOT_GEOMETRY.processRailWidth).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('includes full topology bounds for fit, not only activity overlays', () => {
|
it('includes full topology bounds for fit, not only activity overlays', () => {
|
||||||
|
|
@ -187,6 +402,40 @@ describe('stable slot layout planner', () => {
|
||||||
expect(validateStableSlotLayout(invalid).valid).toBe(false);
|
expect(validateStableSlotLayout(invalid).valid).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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');
|
||||||
|
const layout: GraphLayoutPort = {
|
||||||
|
version: 'stable-slots-v1',
|
||||||
|
ownerOrder: [alice.id],
|
||||||
|
slotAssignments: {
|
||||||
|
[alice.id]: { ringIndex: 0, sectorIndex: 1 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const snapshot = buildStableSlotLayoutSnapshot({
|
||||||
|
teamName,
|
||||||
|
nodes: [lead, alice],
|
||||||
|
layout,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(snapshot).not.toBeNull();
|
||||||
|
const [frame] = snapshot!.memberSlotFrames;
|
||||||
|
const overlappingLeadBlock = translateSlotFrame(
|
||||||
|
frame,
|
||||||
|
snapshot!.leadCentralReservedBlock.left - frame.bounds.left + 1,
|
||||||
|
snapshot!.leadCentralReservedBlock.top - frame.bounds.top + 1
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
validateStableSlotLayout({
|
||||||
|
...snapshot!,
|
||||||
|
memberSlotFrames: [overlappingLeadBlock],
|
||||||
|
}).valid
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it('prefers the occupied target slot when dragging near another owner anchor', () => {
|
it('prefers the occupied target slot when dragging near another owner anchor', () => {
|
||||||
const teamName = 'team-b';
|
const teamName = 'team-b';
|
||||||
const lead = createLead(teamName);
|
const lead = createLead(teamName);
|
||||||
|
|
@ -226,6 +475,106 @@ describe('stable slot layout planner', () => {
|
||||||
expect(nearest?.displacedAssignment).toEqual({ ringIndex: 0, sectorIndex: 1 });
|
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);
|
||||||
|
const alice = createMember(teamName, 'agent-alice', 'alice');
|
||||||
|
const bob = createMember(teamName, 'agent-bob', 'bob');
|
||||||
|
const layout: GraphLayoutPort = {
|
||||||
|
version: 'stable-slots-v1',
|
||||||
|
ownerOrder: [alice.id, bob.id],
|
||||||
|
slotAssignments: {
|
||||||
|
[alice.id]: { ringIndex: 0, sectorIndex: 1 },
|
||||||
|
[bob.id]: { ringIndex: 0, sectorIndex: 2 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const snapshot = buildStableSlotLayoutSnapshot({
|
||||||
|
teamName,
|
||||||
|
nodes: [lead, alice, bob],
|
||||||
|
layout,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(snapshot).not.toBeNull();
|
||||||
|
const nearest = resolveNearestSlotAssignment({
|
||||||
|
ownerId: alice.id,
|
||||||
|
ownerX: snapshot!.leadActivityRect.left + snapshot!.leadActivityRect.width / 2,
|
||||||
|
ownerY: snapshot!.leadActivityRect.top + snapshot!.leadActivityRect.height / 2,
|
||||||
|
nodes: [lead, alice, bob],
|
||||||
|
snapshot: snapshot!,
|
||||||
|
layout,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(nearest).not.toBeNull();
|
||||||
|
const replannedSnapshot = buildStableSlotLayoutSnapshot({
|
||||||
|
teamName,
|
||||||
|
nodes: [lead, alice, bob],
|
||||||
|
layout: {
|
||||||
|
...layout,
|
||||||
|
slotAssignments: {
|
||||||
|
...layout.slotAssignments,
|
||||||
|
[alice.id]: nearest!.assignment,
|
||||||
|
...(nearest?.displacedOwnerId && nearest.displacedAssignment
|
||||||
|
? { [nearest.displacedOwnerId]: nearest.displacedAssignment }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const replannedFrame = replannedSnapshot?.memberSlotFrames.find(
|
||||||
|
(frame) => frame.ownerId === alice.id
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(replannedSnapshot).not.toBeNull();
|
||||||
|
expect(replannedFrame).toBeDefined();
|
||||||
|
expect(
|
||||||
|
replannedSnapshot!.centralCollisionRects.some((rect) =>
|
||||||
|
rectsOverlap(replannedFrame!.bounds, rect)
|
||||||
|
)
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it('treats tasks with missing owner nodes as unassigned topology actors', () => {
|
it('treats tasks with missing owner nodes as unassigned topology actors', () => {
|
||||||
const teamName = 'team-orphan-task';
|
const teamName = 'team-orphan-task';
|
||||||
const lead = createLead(teamName);
|
const lead = createLead(teamName);
|
||||||
|
|
@ -249,43 +598,76 @@ describe('stable slot layout planner', () => {
|
||||||
expect(snapshot?.unassignedTaskRect).not.toBeNull();
|
expect(snapshot?.unassignedTaskRect).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('computes the next ring radius from previous ring depth, not member count', () => {
|
it('rejects member frames that overlap the unassigned central collision rect', () => {
|
||||||
const teamName = 'team-ring-depth';
|
const teamName = 'team-unassigned-central-rect';
|
||||||
const lead = createLead(teamName);
|
const lead = createLead(teamName);
|
||||||
const members = Array.from({ length: 7 }, (_, index) =>
|
const alice = createMember(teamName, 'agent-alice', 'alice');
|
||||||
createMember(teamName, `agent-${index + 1}`, `member-${index + 1}`)
|
const orphanTask = createTask(
|
||||||
|
teamName,
|
||||||
|
'task-orphan',
|
||||||
|
'member:team-unassigned-central-rect:ghost'
|
||||||
);
|
);
|
||||||
const layout: GraphLayoutPort = {
|
const layout: GraphLayoutPort = {
|
||||||
version: 'stable-slots-v1',
|
version: 'stable-slots-v1',
|
||||||
ownerOrder: members.map((member) => member.id),
|
ownerOrder: [alice.id],
|
||||||
slotAssignments: Object.fromEntries(
|
slotAssignments: {
|
||||||
members.map((member, index) => [
|
[alice.id]: { ringIndex: 0, sectorIndex: 1 },
|
||||||
member.id,
|
},
|
||||||
{
|
|
||||||
ringIndex: index < 6 ? 0 : 1,
|
|
||||||
sectorIndex: index % 6,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const snapshot = buildStableSlotLayoutSnapshot({
|
const snapshot = buildStableSlotLayoutSnapshot({
|
||||||
teamName,
|
teamName,
|
||||||
nodes: [lead, ...members],
|
nodes: [lead, alice, orphanTask],
|
||||||
layout,
|
layout,
|
||||||
});
|
});
|
||||||
const footprints = computeOwnerFootprints([lead, ...members], layout);
|
|
||||||
|
expect(snapshot?.unassignedTaskRect).not.toBeNull();
|
||||||
|
const [frame] = snapshot!.memberSlotFrames;
|
||||||
|
const overlappingUnassigned = translateSlotFrame(
|
||||||
|
frame,
|
||||||
|
snapshot!.unassignedTaskRect!.left - frame.bounds.left + 1,
|
||||||
|
snapshot!.unassignedTaskRect!.top - frame.bounds.top + 1
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
validateStableSlotLayout({
|
||||||
|
...snapshot!,
|
||||||
|
memberSlotFrames: [overlappingUnassigned],
|
||||||
|
}).valid
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('computes the next ring radius from previous ring depth, not member count', () => {
|
||||||
|
const teamName = 'team-ring-depth';
|
||||||
|
const lead = createLead(teamName);
|
||||||
|
const first = createMember(teamName, 'agent-first', 'member-1');
|
||||||
|
const second = createMember(teamName, 'agent-second', 'member-2');
|
||||||
|
const layout: GraphLayoutPort = {
|
||||||
|
version: 'stable-slots-v1',
|
||||||
|
ownerOrder: [first.id, second.id],
|
||||||
|
slotAssignments: {
|
||||||
|
[first.id]: { ringIndex: 0, sectorIndex: 1 },
|
||||||
|
[second.id]: { ringIndex: 1, sectorIndex: 1 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const snapshot = buildStableSlotLayoutSnapshot({
|
||||||
|
teamName,
|
||||||
|
nodes: [lead, first, second],
|
||||||
|
layout,
|
||||||
|
});
|
||||||
|
const footprints = computeOwnerFootprints([lead, first, second], layout);
|
||||||
const firstRingFrame = snapshot?.memberSlotFrames.find(
|
const firstRingFrame = snapshot?.memberSlotFrames.find(
|
||||||
(frame) => frame.ringIndex === 0 && frame.sectorIndex === 0
|
(frame) => frame.ownerId === first.id
|
||||||
);
|
);
|
||||||
const secondRingFrame = snapshot?.memberSlotFrames.find(
|
const secondRingFrame = snapshot?.memberSlotFrames.find(
|
||||||
(frame) => frame.ringIndex === 1 && frame.sectorIndex === 0
|
(frame) => frame.ownerId === second.id
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(snapshot).not.toBeNull();
|
expect(snapshot).not.toBeNull();
|
||||||
expect(firstRingFrame).toBeDefined();
|
expect(firstRingFrame).toBeDefined();
|
||||||
expect(secondRingFrame).toBeDefined();
|
expect(secondRingFrame).toBeDefined();
|
||||||
const firstFootprint = footprints[0];
|
const firstFootprint = footprints.find((footprint) => footprint.ownerId === first.id);
|
||||||
expect(firstFootprint).toBeDefined();
|
expect(firstFootprint).toBeDefined();
|
||||||
if (!firstFootprint) {
|
if (!firstFootprint) {
|
||||||
throw new Error('expected first footprint for ring-depth test');
|
throw new Error('expected first footprint for ring-depth test');
|
||||||
|
|
@ -293,17 +675,126 @@ describe('stable slot layout planner', () => {
|
||||||
|
|
||||||
const ringDelta = Math.hypot(secondRingFrame!.ownerX, secondRingFrame!.ownerY)
|
const ringDelta = Math.hypot(secondRingFrame!.ownerX, secondRingFrame!.ownerY)
|
||||||
- Math.hypot(firstRingFrame!.ownerX, firstRingFrame!.ownerY);
|
- Math.hypot(firstRingFrame!.ownerX, firstRingFrame!.ownerY);
|
||||||
const ownerAnchorOffsetY =
|
const sectorVector = { x: 0.82, y: -0.57 };
|
||||||
STABLE_SLOT_GEOMETRY.memberSlotInnerPadding +
|
const ownerLocalY =
|
||||||
ACTIVITY_ANCHOR_LAYOUT.reservedHeight +
|
STABLE_SLOT_GEOMETRY.memberSlotInnerPadding + STABLE_SLOT_GEOMETRY.ownerBandHeight / 2;
|
||||||
STABLE_SLOT_GEOMETRY.slotVerticalGap +
|
const topOffset = -ownerLocalY;
|
||||||
STABLE_SLOT_GEOMETRY.ownerBandHeight / 2;
|
const bottomOffset = firstFootprint.slotHeight - ownerLocalY;
|
||||||
const expectedRingDelta =
|
const halfWidth = firstFootprint.slotWidth / 2;
|
||||||
ownerAnchorOffsetY +
|
const vectorLength = Math.hypot(sectorVector.x, sectorVector.y) || 1;
|
||||||
(firstFootprint.slotHeight - ownerAnchorOffsetY) +
|
const unitX = sectorVector.x / vectorLength;
|
||||||
STABLE_SLOT_GEOMETRY.ringGap;
|
const unitY = sectorVector.y / vectorLength;
|
||||||
|
const cornerProjections = [
|
||||||
|
{ x: -halfWidth, y: topOffset },
|
||||||
|
{ x: halfWidth, y: topOffset },
|
||||||
|
{ x: halfWidth, y: bottomOffset },
|
||||||
|
{ x: -halfWidth, y: bottomOffset },
|
||||||
|
].map((corner) => corner.x * unitX + corner.y * unitY);
|
||||||
|
const outwardDepth = Math.max(...cornerProjections);
|
||||||
|
const inwardDepth = Math.max(...cornerProjections.map((projection) => -projection));
|
||||||
|
const expectedRingDelta = outwardDepth + inwardDepth + STABLE_SLOT_GEOMETRY.ringGap;
|
||||||
|
|
||||||
expect(ringDelta).toBeCloseTo(expectedRingDelta, 6);
|
expect(Math.abs(ringDelta - expectedRingDelta)).toBeLessThan(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps owned tasks out of unassigned topology when default sector candidates near the lead are invalid', () => {
|
||||||
|
const teamName = 'team-owned-tasks';
|
||||||
|
const lead = createLead(teamName);
|
||||||
|
const members = [
|
||||||
|
createMember(teamName, 'agent-alice', 'alice'),
|
||||||
|
createMember(teamName, 'agent-bob', 'bob'),
|
||||||
|
createMember(teamName, 'agent-tom', 'tom'),
|
||||||
|
createMember(teamName, 'agent-jack', 'jack'),
|
||||||
|
];
|
||||||
|
const tasks = [
|
||||||
|
createTask(teamName, 'task-a', members[0].id, { taskStatus: 'completed' }),
|
||||||
|
createTask(teamName, 'task-b', members[1].id, { taskStatus: 'completed' }),
|
||||||
|
createTask(teamName, 'task-c', members[2].id, { taskStatus: 'completed' }),
|
||||||
|
createTask(teamName, 'task-d', members[3].id, { taskStatus: 'completed' }),
|
||||||
|
];
|
||||||
|
const layout: GraphLayoutPort = {
|
||||||
|
version: 'stable-slots-v1',
|
||||||
|
ownerOrder: members.map((member) => member.id),
|
||||||
|
slotAssignments: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const nodes = [lead, ...members, ...tasks];
|
||||||
|
const snapshot = buildStableSlotLayoutSnapshot({
|
||||||
|
teamName,
|
||||||
|
nodes,
|
||||||
|
layout,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(snapshot).not.toBeNull();
|
||||||
|
expect(validateStableSlotLayout(snapshot!)).toEqual({ valid: true });
|
||||||
|
expect(snapshot?.unassignedTaskRect).toBeNull();
|
||||||
|
|
||||||
|
const memberSlotFrames = snapshot!.memberSlotFrames;
|
||||||
|
for (const frame of memberSlotFrames) {
|
||||||
|
const ownerNode = nodes.find((node) => node.id === frame.ownerId);
|
||||||
|
if (!ownerNode) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
ownerNode.x = frame.ownerX;
|
||||||
|
ownerNode.y = frame.ownerY;
|
||||||
|
}
|
||||||
|
KanbanLayoutEngine.layout(nodes, {
|
||||||
|
memberSlotFrames,
|
||||||
|
unassignedTaskRect: snapshot!.unassignedTaskRect,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const task of tasks) {
|
||||||
|
const ownerFrame = memberSlotFrames.find((frame) => frame.ownerId === task.ownerId);
|
||||||
|
expect(ownerFrame).toBeDefined();
|
||||||
|
expect(task.x).toBeGreaterThanOrEqual(ownerFrame!.kanbanBandRect.left);
|
||||||
|
expect(task.x).toBeLessThanOrEqual(ownerFrame!.kanbanBandRect.right);
|
||||||
|
expect(task.y).toBeGreaterThanOrEqual(ownerFrame!.kanbanBandRect.top);
|
||||||
|
expect(task.y).toBeLessThanOrEqual(ownerFrame!.kanbanBandRect.bottom);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('positions lead-owned tasks inside the lead kanban band instead of unassigned', () => {
|
||||||
|
const teamName = 'team-lead-owned-tasks';
|
||||||
|
const lead = createLead(teamName);
|
||||||
|
const alice = createMember(teamName, 'agent-alice', 'alice');
|
||||||
|
const leadTasks = [
|
||||||
|
createTask(teamName, 'lead-a', lead.id, { taskStatus: 'completed' }),
|
||||||
|
createTask(teamName, 'lead-b', lead.id, { taskStatus: 'in_progress' }),
|
||||||
|
];
|
||||||
|
const layout: GraphLayoutPort = {
|
||||||
|
version: 'stable-slots-v1',
|
||||||
|
ownerOrder: [alice.id],
|
||||||
|
slotAssignments: {
|
||||||
|
[alice.id]: { ringIndex: 0, sectorIndex: 1 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const nodes = [lead, alice, ...leadTasks];
|
||||||
|
const snapshot = buildStableSlotLayoutSnapshot({
|
||||||
|
teamName,
|
||||||
|
nodes,
|
||||||
|
layout,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(snapshot).not.toBeNull();
|
||||||
|
expect(snapshot?.unassignedTaskRect).toBeNull();
|
||||||
|
lead.x = snapshot!.leadSlotFrame.ownerX;
|
||||||
|
lead.y = snapshot!.leadSlotFrame.ownerY;
|
||||||
|
alice.x = snapshot!.memberSlotFrames[0]?.ownerX;
|
||||||
|
alice.y = snapshot!.memberSlotFrames[0]?.ownerY;
|
||||||
|
|
||||||
|
KanbanLayoutEngine.layout(nodes, {
|
||||||
|
leadSlotFrame: snapshot!.leadSlotFrame,
|
||||||
|
memberSlotFrames: snapshot!.memberSlotFrames,
|
||||||
|
unassignedTaskRect: snapshot!.unassignedTaskRect,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const task of leadTasks) {
|
||||||
|
expect(task.x).toBeGreaterThanOrEqual(snapshot!.leadSlotFrame.kanbanBandRect.left);
|
||||||
|
expect(task.x).toBeLessThanOrEqual(snapshot!.leadSlotFrame.kanbanBandRect.right);
|
||||||
|
expect(task.y).toBeGreaterThanOrEqual(snapshot!.leadSlotFrame.kanbanBandRect.top);
|
||||||
|
expect(task.y).toBeLessThanOrEqual(snapshot!.leadSlotFrame.kanbanBandRect.bottom);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps the same sector and spills to the next outer ring when the saved slot is already occupied', () => {
|
it('keeps the same sector and spills to the next outer ring when the saved slot is already occupied', () => {
|
||||||
|
|
|
||||||
|
|
@ -264,7 +264,7 @@ describe('teamSlice actions', () => {
|
||||||
expect(store.getState().warmTaskChangeSummaries).not.toHaveBeenCalled();
|
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();
|
const store = createSliceStore();
|
||||||
|
|
||||||
store.getState().commitTeamGraphOwnerSlotDrop(
|
store.getState().commitTeamGraphOwnerSlotDrop(
|
||||||
|
|
@ -281,7 +281,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();
|
const store = createSliceStore();
|
||||||
store.setState({
|
store.setState({
|
||||||
slotLayoutVersion: 'stable-slots-v1',
|
slotLayoutVersion: 'stable-slots-v1',
|
||||||
|
|
@ -298,7 +298,68 @@ describe('teamSlice actions', () => {
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({
|
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 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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: 0 },
|
||||||
|
'agent-bob': { ringIndex: 0, sectorIndex: 1 },
|
||||||
|
'agent-tom': { ringIndex: 0, sectorIndex: 2 },
|
||||||
|
'agent-jack': { ringIndex: 0, sectorIndex: 3 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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',
|
||||||
|
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-alice': { ringIndex: 0, sectorIndex: 0 },
|
||||||
|
'agent-bob': { ringIndex: 0, sectorIndex: 1 },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -321,10 +382,14 @@ describe('teamSlice actions', () => {
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(store.getState().slotLayoutVersion).toBe('stable-slots-v1');
|
expect(store.getState().slotLayoutVersion).toBe('stable-slots-v1');
|
||||||
expect(store.getState().slotAssignmentsByTeam).toEqual({});
|
expect(store.getState().slotAssignmentsByTeam).toEqual({
|
||||||
|
'my-team': {
|
||||||
|
'agent-alice': { ringIndex: 0, sectorIndex: 0 },
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps hidden-member slot assignments so the same stable owner can reuse them later', () => {
|
it('ignores hidden-member persisted slot assignments while persistence is disabled', () => {
|
||||||
const store = createSliceStore();
|
const store = createSliceStore();
|
||||||
store.setState({
|
store.setState({
|
||||||
slotLayoutVersion: 'stable-slots-v1',
|
slotLayoutVersion: 'stable-slots-v1',
|
||||||
|
|
@ -341,8 +406,77 @@ describe('teamSlice actions', () => {
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({
|
expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({
|
||||||
'agent-hidden': { ringIndex: 1, sectorIndex: 5 },
|
'agent-visible': { ringIndex: 0, sectorIndex: 0 },
|
||||||
'agent-visible': { ringIndex: 0, sectorIndex: 2 },
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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