From aed08113e672cb7594324be8bfe078cb8378ed42 Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 15 Apr 2026 16:18:11 +0300 Subject: [PATCH] feat(agent-graph): integrate stable slot layout for improved node positioning and interaction - Added stable slot layout support in various components, enhancing the layout and interaction of nodes. - Updated TypeScript configuration to include new paths for the agent-graph package. - Refactored layout logic in activity lanes and kanban to accommodate stable slot assignments. - Enhanced GraphView and GraphControls to support sidebar visibility toggling and owner slot drop handling. - Introduced new types for layout management in GraphDataPort and related files. - Updated README to include stable slot layout documentation. --- .../src/constants/canvas-constants.ts | 6 +- .../src/hooks/useGraphInteraction.ts | 4 +- .../src/hooks/useGraphSimulation.ts | 978 +++--- packages/agent-graph/src/index.ts | 4 + .../agent-graph/src/layout/activityLane.ts | 102 +- .../agent-graph/src/layout/kanbanLayout.ts | 129 +- .../agent-graph/src/layout/launchAnchor.ts | 15 +- .../src/layout/stableSlotGeometry.ts | 158 + .../agent-graph/src/layout/stableSlots.ts | 1291 ++++++++ .../agent-graph/src/ports/GraphDataPort.ts | 4 +- packages/agent-graph/src/ports/index.ts | 4 + packages/agent-graph/src/ports/types.ts | 13 + packages/agent-graph/src/ui/GraphControls.tsx | 28 + packages/agent-graph/src/ui/GraphView.tsx | 136 +- src/features/agent-graph/README.md | 1 + .../agent-graph/STABLE_SLOT_LAYOUT_PLAN.md | 2846 +++++++++++++++++ .../core/domain/buildInlineActivityEntries.ts | 78 +- .../core/domain/graphOwnerIdentity.ts | 30 + .../renderer/adapters/TeamGraphAdapter.ts | 262 +- .../renderer/hooks/useGraphActivityContext.ts | 17 + .../hooks/useGraphSidebarVisibility.ts | 52 + .../renderer/hooks/useTeamGraphAdapter.ts | 17 +- .../hooks/useTeamGraphSurfaceActions.ts | 56 + .../renderer/ui/GraphActivityHud.tsx | 49 +- .../renderer/ui/TeamGraphOverlay.tsx | 23 +- .../agent-graph/renderer/ui/TeamGraphTab.tsx | 27 +- .../TmuxInstallerRunnerAdapter.test.ts | 16 +- .../__tests__/TmuxStatusSourceAdapter.test.ts | 39 +- .../installer/TmuxCommandRunner.ts | 2 +- .../renderer/ui/TmuxInstallerBannerView.tsx | 5 +- .../infrastructure/NotificationManager.ts | 36 +- src/main/services/team/TeamMemberResolver.ts | 31 +- src/renderer/api/httpClient.ts | 2 + .../team/members/MemberDetailDialog.tsx | 4 +- src/renderer/store/slices/teamSlice.ts | 260 ++ src/shared/types/team.ts | 2 + src/shared/utils/teamStableOwnerId.ts | 12 + .../utils/recentProjectsClientCache.test.ts | 18 +- .../dateGroupedSessionsSelection.test.ts | 87 +- .../team/teamProjectSelection.test.ts | 9 +- .../agent-graph/GraphActivityHud.test.ts | 119 + .../agent-graph/GraphControls.test.ts | 115 + .../agent-graph/TeamGraphAdapter.test.ts | 306 ++ .../buildInlineActivityEntries.test.ts | 37 + .../features/agent-graph/drawAgents.test.ts | 111 + .../features/agent-graph/kanbanLayout.test.ts | 165 +- .../useGraphSidebarVisibility.test.ts | 105 + .../agent-graph/useGraphSimulation.test.ts | 387 ++- test/renderer/store/teamSlice.test.ts | 82 + tsconfig.json | 1 + tsconfig.node.json | 1 + vitest.config.ts | 3 + 52 files changed, 7258 insertions(+), 1027 deletions(-) create mode 100644 packages/agent-graph/src/layout/stableSlotGeometry.ts create mode 100644 packages/agent-graph/src/layout/stableSlots.ts create mode 100644 src/features/agent-graph/STABLE_SLOT_LAYOUT_PLAN.md create mode 100644 src/features/agent-graph/core/domain/graphOwnerIdentity.ts create mode 100644 src/features/agent-graph/renderer/hooks/useGraphActivityContext.ts create mode 100644 src/features/agent-graph/renderer/hooks/useGraphSidebarVisibility.ts create mode 100644 src/features/agent-graph/renderer/hooks/useTeamGraphSurfaceActions.ts create mode 100644 src/shared/utils/teamStableOwnerId.ts create mode 100644 test/renderer/features/agent-graph/GraphControls.test.ts create mode 100644 test/renderer/features/agent-graph/drawAgents.test.ts create mode 100644 test/renderer/features/agent-graph/useGraphSidebarVisibility.test.ts diff --git a/packages/agent-graph/src/constants/canvas-constants.ts b/packages/agent-graph/src/constants/canvas-constants.ts index 59d32dcc..e45353da 100644 --- a/packages/agent-graph/src/constants/canvas-constants.ts +++ b/packages/agent-graph/src/constants/canvas-constants.ts @@ -1,3 +1,5 @@ +import { STABLE_SLOT_GEOMETRY } from '../layout/stableSlotGeometry'; + /** * Canvas rendering constants for the agent graph visualization. * Adapted from agent-flow's canvas-constants.ts (Apache 2.0). @@ -262,8 +264,8 @@ export const KANBAN_ZONE = { rowHeight: 46, /** Zone starts this far below member node center */ offsetY: 70, - /** Column order: todo → wip → done → review → approved */ + /** Column sequence: pending → wip → done → review → approved */ columns: ['todo', 'wip', 'done', 'review', 'approved'] as const, /** Max tasks shown per column (overflow hidden) */ - maxVisibleRows: 6, + maxVisibleRows: STABLE_SLOT_GEOMETRY.taskMaxVisibleRows, } as const; diff --git a/packages/agent-graph/src/hooks/useGraphInteraction.ts b/packages/agent-graph/src/hooks/useGraphInteraction.ts index 81cdf5be..33862ef3 100644 --- a/packages/agent-graph/src/hooks/useGraphInteraction.ts +++ b/packages/agent-graph/src/hooks/useGraphInteraction.ts @@ -33,9 +33,9 @@ export function useGraphInteraction( clickedNodeId.current = hit; if (hit) { - // Only allow drag on member/lead nodes, not tasks or processes + // Stable-slot layout keeps lead fixed in the center. Only members can be dragged between slots. const hitNode = nodes.find((n) => n.id === hit); - if (hitNode && (hitNode.kind === 'member' || hitNode.kind === 'lead')) { + if (hitNode?.kind === 'member') { dragNodeId.current = hit; } } diff --git a/packages/agent-graph/src/hooks/useGraphSimulation.ts b/packages/agent-graph/src/hooks/useGraphSimulation.ts index 2955c737..ae2e8e39 100644 --- a/packages/agent-graph/src/hooks/useGraphSimulation.ts +++ b/packages/agent-graph/src/hooks/useGraphSimulation.ts @@ -1,64 +1,21 @@ -/** - * Graph simulation hook using d3-force for MEMBER/LEAD nodes only. - * Task nodes are positioned by KanbanLayoutEngine (deterministic grid). - * - * CRITICAL: Animation state in useRef, NOT useState — no React re-renders at 60fps. - * This hook does NOT run its own RAF loop — the parent (GraphView) calls tick(). - */ +import { useCallback, useEffect, useRef } from 'react'; -import { useRef, useEffect, useCallback } from 'react'; -import { - forceSimulation, - forceCenter, - forceManyBody, - forceCollide, - forceLink, - type Simulation, - type SimulationNodeDatum, - type SimulationLinkDatum, -} from 'd3-force'; -import type { GraphNode, GraphEdge, GraphParticle, GraphNodeKind } from '../ports/types'; -import { FORCE, ANIM_SPEED, NODE } from '../constants/canvas-constants'; -import { getNodeStrategy } from '../strategies'; -import { createSpawnEffect, createCompleteEffect, type VisualEffect } from '../canvas/draw-effects'; +import { ANIM_SPEED, NODE } from '../constants/canvas-constants'; import { getStateColor } from '../constants/colors'; +import { + buildStableSlotLayoutSnapshot, + resolveNearestSlotAssignment, + snapshotToWorldBounds, + translateSlotFrame, + validateStableSlotLayout, + type StableSlotLayoutSnapshot, + type SlotFrame, +} from '../layout/stableSlots'; import { KanbanLayoutEngine } from '../layout/kanbanLayout'; -import { - LAUNCH_ANCHOR_LAYOUT, - getActivityAnchorId, - getLaunchAnchorBounds, - getLaunchAnchorId, - getLaunchAnchorTarget, - isActivityAnchorId, - isLaunchAnchorId, - type WorldBounds, -} from '../layout/launchAnchor'; -import { - ACTIVITY_ANCHOR_LAYOUT, - buildVisibleActivityLaneBounds, - getActivityLaneBounds, - getActivityAnchorTarget, - packActivityLaneWorldRects, - resolveActivityLaneSide, -} from '../layout/activityLane'; -// ─── Force Node/Link types (properly typed, no loose `string`) ────────────── - -type InternalNodeKind = GraphNodeKind | 'launch-anchor' | 'activity-anchor'; - -interface ForceNode extends SimulationNodeDatum { - id: string; - kind: InternalNodeKind; - anchorForLeadId?: string; - anchorForNodeId?: string; -} - -interface ForceLink extends SimulationLinkDatum { - id: string; - edgeType: string; -} - -// ─── Simulation State (in ref, not useState) ──────────────────────────────── +import type { GraphEdge, GraphLayoutPort, GraphNode, GraphOwnerSlotAssignment, GraphParticle } from '../ports/types'; +import type { WorldBounds } from '../layout/launchAnchor'; +import { createCompleteEffect, createSpawnEffect, type VisualEffect } from '../canvas/draw-effects'; export interface SimulationState { nodes: GraphNode[]; @@ -69,133 +26,31 @@ export interface SimulationState { } export interface UseGraphSimulationResult { - stateRef: React.MutableRefObject; - updateData: (nodes: GraphNode[], edges: GraphEdge[], particles: GraphParticle[]) => void; - /** Tick one simulation frame — called from parent's RAF loop */ + stateRef: { current: SimulationState }; + updateData: ( + nodes: GraphNode[], + edges: GraphEdge[], + particles: GraphParticle[], + teamName: string, + layout?: GraphLayoutPort + ) => void; tick: (dt: number) => void; setNodePosition: (nodeId: string, x: number, y: number) => void; + clearNodePosition: (nodeId: string) => void; + resolveNearestOwnerSlot: ( + nodeId: string, + x: number, + y: number + ) => { + assignment: GraphOwnerSlotAssignment; + displacedOwnerId?: string; + displacedAssignment?: GraphOwnerSlotAssignment; + } | null; getLaunchAnchorWorldPosition: (leadNodeId: string) => { x: number; y: number } | null; getActivityAnchorWorldPosition: (nodeId: string) => { x: number; y: number } | null; getExtraWorldBounds: () => WorldBounds[]; } -// ─── Deterministic hash for stable initial positions ───────────────────────── - -/** Returns a value in [-0.5, 0.5] deterministically from string + seed */ -function deterministicPosition(id: string, seed: number): number { - let hash = seed * 2654435761; - for (let i = 0; i < id.length; i++) { - hash = ((hash << 5) - hash + id.charCodeAt(i)) | 0; - } - return ((hash & 0x7fffffff) % 1000) / 1000 - 0.5; -} - -function syncLaunchAnchors(forceNodes: ForceNode[]): void { - const forceNodeMap = new Map(); - for (const node of forceNodes) { - forceNodeMap.set(node.id, node); - } - const leadNode = forceNodes.find((node) => node.kind === 'lead'); - const leadX = leadNode?.x ?? leadNode?.fx ?? null; - const pendingActivityAnchors: Array<{ - node: ForceNode; - target: { x: number; y: number }; - side: 'left' | 'right'; - }> = []; - - for (const node of forceNodes) { - if (node.kind === 'launch-anchor' && node.anchorForLeadId) { - const leadNode = forceNodeMap.get(node.anchorForLeadId); - if (!leadNode) continue; - const target = getLaunchAnchorTarget(leadNode.x ?? 0, leadNode.y ?? 0); - node.fx = target.x; - node.fy = target.y; - node.x = target.x; - node.y = target.y; - node.vx = 0; - node.vy = 0; - continue; - } - - if (node.kind === 'activity-anchor' && node.anchorForNodeId) { - const ownerNode = forceNodeMap.get(node.anchorForNodeId); - if (!ownerNode || (ownerNode.kind !== 'lead' && ownerNode.kind !== 'member')) continue; - const target = getActivityAnchorTarget({ - nodeX: ownerNode.x ?? 0, - nodeY: ownerNode.y ?? 0, - nodeKind: ownerNode.kind, - leadX, - }); - pendingActivityAnchors.push({ - node, - target, - side: resolveActivityLaneSide({ - nodeKind: ownerNode.kind, - nodeX: ownerNode.x ?? 0, - leadX, - }), - }); - } - } - - const packedActivityAnchors = packActivityLaneWorldRects( - pendingActivityAnchors.map(({ node, target, side }) => ({ - id: node.id, - side, - x: target.x, - y: target.y, - width: ACTIVITY_ANCHOR_LAYOUT.reservedWidth, - height: ACTIVITY_ANCHOR_LAYOUT.reservedHeight, - })), - 18, - ); - - for (const entry of pendingActivityAnchors) { - const packed = packedActivityAnchors.get(entry.node.id); - const centerX = (packed?.x ?? entry.target.x) - + ACTIVITY_ANCHOR_LAYOUT.reservedWidth / 2; - const centerY = (packed?.y ?? entry.target.y) - + ACTIVITY_ANCHOR_LAYOUT.reservedHeight / 2; - entry.node.fx = centerX; - entry.node.fy = centerY; - entry.node.x = centerX; - entry.node.y = centerY; - entry.node.vx = 0; - entry.node.vy = 0; - } -} - -function updateLaunchAnchorCaches( - forceNodes: ForceNode[], - launchPositions: Map, - activityPositions: Map, - bounds: WorldBounds[] -): void { - launchPositions.clear(); - activityPositions.clear(); - bounds.length = 0; - - for (const node of forceNodes) { - const x = node.x ?? node.fx ?? 0; - const y = node.y ?? node.fy ?? 0; - if (node.kind === 'launch-anchor' && node.anchorForLeadId) { - launchPositions.set(node.anchorForLeadId, { x, y }); - bounds.push(getLaunchAnchorBounds(x, y)); - continue; - } - if (node.kind === 'activity-anchor' && node.anchorForNodeId) { - const topLeft = { - x: x - ACTIVITY_ANCHOR_LAYOUT.reservedWidth / 2, - y: y - ACTIVITY_ANCHOR_LAYOUT.reservedHeight / 2, - }; - activityPositions.set(node.anchorForNodeId, topLeft); - bounds.push(getActivityLaneBounds(topLeft.x, topLeft.y)); - } - } -} - -// ─── Hook ─────────────────────────────────────────────────────────────────── - export function useGraphSimulation(): UseGraphSimulationResult { const stateRef = useRef({ nodes: [], @@ -204,313 +59,479 @@ export function useGraphSimulation(): UseGraphSimulationResult { effects: [], time: 0, }); - - const simRef = useRef | null>(null); + const teamNameRef = useRef(''); + const layoutRef = useRef(undefined); + const layoutSnapshotRef = useRef(null); + const lastValidSnapshotByTeamRef = useRef(new Map()); + const dragOwnerPositionsRef = useRef(new Map()); const launchAnchorPositionsRef = useRef(new Map()); const activityAnchorPositionsRef = useRef(new Map()); const extraWorldBoundsRef = useRef([]); - // Initialize d3-force simulation - const initSimulation = useCallback(() => { - if (simRef.current) simRef.current.stop(); - - const sim = forceSimulation([]) - .force('center', forceCenter(0, 0).strength(FORCE.centerStrength)) - .force('charge', forceManyBody().strength((d) => { - if (d.kind === 'launch-anchor' || d.kind === 'activity-anchor') { - return 0; - } - return getNodeStrategy(d.kind).getChargeStrength(); - })) - .force('collide', forceCollide().radius((d) => { - if (d.kind === 'launch-anchor') { - return LAUNCH_ANCHOR_LAYOUT.collisionRadius; - } - if (d.kind === 'activity-anchor') { - return ACTIVITY_ANCHOR_LAYOUT.collisionRadius; - } - return getNodeStrategy(d.kind).getCollisionRadius(); - })) - .force('link', forceLink([]).id((d) => d.id).distance((d) => { - return FORCE.linkDistance[d.edgeType as keyof typeof FORCE.linkDistance] ?? 200; - }).strength(FORCE.linkStrength)) - .alphaDecay(FORCE.alphaDecay) - .velocityDecay(FORCE.velocityDecay) - .stop(); // We tick manually - - simRef.current = sim; - return sim; - }, []); - - // Track node set identity to avoid re-running simulation when data reference changes but content is same - const lastNodeIdsHash = useRef(''); - - // Sync graph data to d3-force — ONLY when node set actually changes - const syncSimulation = useCallback((nodes: GraphNode[], edges: GraphEdge[]) => { - // Hash includes IDs + mutable fields (status, owner, review) to detect real changes - const hash = nodes.map((n) => `${n.id}:${n.state}:${n.ownerId ?? ''}:${n.taskStatus ?? ''}:${n.reviewState ?? ''}`).sort().join(','); - if (hash === lastNodeIdsHash.current) return; // same nodes — skip re-simulation - lastNodeIdsHash.current = hash; - - let sim = simRef.current; - if (!sim) sim = initSimulation(); - - const prevInternalPositions = new Map(); - for (const forceNode of sim.nodes()) { - if (!isLaunchAnchorId(forceNode.id) && !isActivityAnchorId(forceNode.id)) continue; - prevInternalPositions.set(forceNode.id, { - x: forceNode.x ?? forceNode.fx ?? 0, - y: forceNode.y ?? forceNode.fy ?? 0, - }); - } - - // Tasks excluded from d3-force — positioned by KanbanLayoutEngine - const forceNodes: ForceNode[] = nodes - .filter((n) => n.kind !== 'task') - .map((n) => ({ - id: n.id, - kind: n.kind, - // Deterministic initial positions from node ID hash — same layout every time - x: n.x ?? deterministicPosition(n.id, 0) * 500, - y: n.y ?? deterministicPosition(n.id, 1) * 500, - vx: n.vx ?? 0, - vy: n.vy ?? 0, - fx: n.fx, - fy: n.fy, - })); - - for (const leadNode of nodes.filter((node) => node.kind === 'lead')) { - const anchorId = getLaunchAnchorId(leadNode.id); - const cached = prevInternalPositions.get(anchorId); - const target = getLaunchAnchorTarget(leadNode.x ?? 0, leadNode.y ?? 0); - const position = cached ?? target; - forceNodes.push({ - id: anchorId, - kind: 'launch-anchor', - anchorForLeadId: leadNode.id, - x: position.x, - y: position.y, - vx: 0, - vy: 0, - fx: target.x, - fy: target.y, - }); - } - - const leadNode = nodes.find((node) => node.kind === 'lead'); - for (const ownerNode of nodes.filter( - (node): node is GraphNode & { kind: 'lead' | 'member' } => - node.kind === 'lead' || node.kind === 'member' - )) { - const anchorId = getActivityAnchorId(ownerNode.id); - const cached = prevInternalPositions.get(anchorId); - const target = getActivityAnchorTarget({ - nodeX: ownerNode.x ?? 0, - nodeY: ownerNode.y ?? 0, - nodeKind: ownerNode.kind, - leadX: leadNode?.x ?? null, - }); - const position = cached ?? target; - forceNodes.push({ - id: anchorId, - kind: 'activity-anchor', - anchorForNodeId: ownerNode.id, - x: position.x, - y: position.y, - vx: 0, - vy: 0, - fx: target.x, - fy: target.y, - }); - } - - // Links only between non-task nodes (parent-child: lead↔member) - const forceNodeIds = new Set(forceNodes.map((n) => n.id)); - const forceLinks: ForceLink[] = edges - .filter((e) => forceNodeIds.has(e.source) && forceNodeIds.has(e.target)) - .map((e) => ({ - id: e.id, - source: e.source, - target: e.target, - edgeType: e.type, - })); - - sim.nodes(forceNodes); - (sim.force('link') as ReturnType)?.links(forceLinks); - sim.alpha(1); - - // Run simulation to near-completion so nodes are settled on first render - for (let i = 0; i < 120; i++) { - syncLaunchAnchors(sim.nodes()); - sim.tick(); - } - sim.alpha(0); // fully settled — no more movement until new data - - // Copy settled positions BACK to GraphNode objects - const simNodeMap = new Map(); - for (const sn of sim.nodes()) simNodeMap.set(sn.id, sn); - for (const node of nodes) { - const sn = simNodeMap.get(node.id); - if (sn) { - node.x = sn.x; - node.y = sn.y; - node.vx = sn.vx; - node.vy = sn.vy; - } - } - - // Position tasks in kanban zones relative to their owners - KanbanLayoutEngine.layout(nodes, { - activityLaneBounds: buildVisibleActivityLaneBounds( - nodes, - activityAnchorPositionsRef.current - ), - }); - updateLaunchAnchorCaches( - sim.nodes(), - launchAnchorPositionsRef.current, - activityAnchorPositionsRef.current, - extraWorldBoundsRef.current - ); - }, [initSimulation]); - - // Track previous node IDs and states for effect spawning const prevNodeIdsRef = useRef(new Set()); const prevNodeStatesRef = useRef(new Map()); - // All node IDs ever seen — never shrinks. Prevents spawn effects replaying - // when nodes reappear after being filtered out (e.g. Tasks toggle OFF→ON). const allKnownNodeIdsRef = useRef(new Set()); - // Update data from adapter - const updateData = useCallback((nodes: GraphNode[], edges: GraphEdge[], particles: GraphParticle[]) => { + const applyCurrentLayout = useCallback(() => { const state = stateRef.current; - const prevStates = prevNodeStatesRef.current; + const nextSnapshot = buildStableSlotLayoutSnapshot({ + teamName: teamNameRef.current, + nodes: state.nodes, + layout: layoutRef.current, + }); - // Preserve positions from previous frame - const prevPositions = new Map(); - for (const n of state.nodes) { - if (n.x != null && n.y != null) { - prevPositions.set(n.id, { x: n.x, y: n.y, vx: n.vx ?? 0, vy: n.vy ?? 0 }); + if (nextSnapshot) { + const validation = validateStableSlotLayout(nextSnapshot); + if (validation.valid) { + commitSnapshotGeometry({ + nodes: state.nodes, + snapshot: nextSnapshot, + teamName: teamNameRef.current, + layoutSnapshotRef, + lastValidSnapshotByTeamRef, + dragOwnerPositionsRef, + launchAnchorPositionsRef, + activityAnchorPositionsRef, + extraWorldBoundsRef, + }); + return; + } + + console.warn( + `[agent-graph] invalid stable slot layout for team=${teamNameRef.current}: ${validation.reason ?? 'unknown reason'}` + ); + + const lastValidSnapshot = lastValidSnapshotByTeamRef.current.get(teamNameRef.current); + if (lastValidSnapshot) { + commitSnapshotGeometry({ + nodes: state.nodes, + snapshot: lastValidSnapshot, + teamName: teamNameRef.current, + layoutSnapshotRef, + lastValidSnapshotByTeamRef, + dragOwnerPositionsRef, + launchAnchorPositionsRef, + activityAnchorPositionsRef, + extraWorldBoundsRef, + fillMissingFallbackPositions: true, + }); + return; } } - for (const n of nodes) { - const prev = prevPositions.get(n.id); - if (prev && n.x == null) { - n.x = prev.x; - n.y = prev.y; - n.vx = prev.vx; - n.vy = prev.vy; - } - } + resetToFallbackLayout({ + nodes: state.nodes, + layoutSnapshotRef, + launchAnchorPositionsRef, + activityAnchorPositionsRef, + extraWorldBoundsRef, + }); + }, []); - // Detect state transitions → spawn visual effects - const allKnown = allKnownNodeIdsRef.current; - for (const node of nodes) { - // New node appeared → spawn effect (only if truly new, never seen before). - // Nodes returning from filter (e.g. Tasks toggle OFF→ON) are already in allKnown. - if (!allKnown.has(node.id) && node.x != null && node.y != null) { - const nodeR = node.kind === 'lead' ? NODE.radiusLead : node.kind === 'member' ? NODE.radiusMember : undefined; - state.effects.push(createSpawnEffect(node.x, node.y, node.color ?? getStateColor(node.state), nodeR)); - } + const updateData = useCallback( + ( + nodes: GraphNode[], + edges: GraphEdge[], + particles: GraphParticle[], + teamName: string, + layout?: GraphLayoutPort + ) => { + const state = stateRef.current; + teamNameRef.current = teamName; + layoutRef.current = layout; - // Task completed → shatter effect - const prevState = prevStates.get(node.id); - if (prevState && prevState !== 'complete' && node.state === 'complete' && node.x != null && node.y != null) { - state.effects.push(createCompleteEffect(node.x, node.y, node.color ?? getStateColor(node.state))); - } - } + preserveReusableNodePositions(nodes, state.nodes); + recordNodeLifecycleEffects(state.effects, nodes, prevNodeStatesRef.current, allKnownNodeIdsRef.current); + prevNodeIdsRef.current = new Set(nodes.map((node) => node.id)); + prevNodeStatesRef.current = new Map(nodes.map((node) => [node.id, node.state])); - // Update tracking refs — allKnown only grows, never shrinks - for (const n of nodes) allKnown.add(n.id); - prevNodeIdsRef.current = new Set(nodes.map((n) => n.id)); - prevNodeStatesRef.current = new Map(nodes.map((n) => [n.id, n.state])); + state.nodes = nodes; + state.edges = edges; + state.particles = mergeParticles(state.particles, particles); + applyCurrentLayout(); + }, + [applyCurrentLayout] + ); - state.nodes = nodes; - state.edges = edges; - state.particles = mergeParticles(state.particles, particles); - - syncSimulation(nodes, edges); - }, [syncSimulation]); - - // Tick one frame (called by parent's RAF loop) const tick = useCallback((dt: number) => { - tickFrame( - stateRef.current, - simRef.current, - dt, - launchAnchorPositionsRef.current, - activityAnchorPositionsRef.current, - extraWorldBoundsRef.current - ); + const state = stateRef.current; + state.time += dt; + + const nextParticles: GraphParticle[] = []; + for (const particle of state.particles) { + particle.progress += dt * ANIM_SPEED.particleSpeed * 0.5; + if (particle.progress < 1) { + nextParticles.push(particle); + } + } + state.particles = nextParticles; + + const nextEffects: VisualEffect[] = []; + for (const effect of state.effects) { + effect.age += dt; + if (effect.age < effect.duration) { + nextEffects.push(effect); + } + } + state.effects = nextEffects; }, []); - const setNodePosition = useCallback((nodeId: string, x: number, y: number) => { - const graphNode = stateRef.current.nodes.find((node) => node.id === nodeId); - if (graphNode) { - graphNode.fx = x; - graphNode.fy = y; - graphNode.x = x; - graphNode.y = y; - graphNode.vx = 0; - graphNode.vy = 0; - } + const setNodePosition = useCallback( + (nodeId: string, x: number, y: number) => { + const node = stateRef.current.nodes.find((candidate) => candidate.id === nodeId); + if (node?.kind !== 'member') { + return; + } + dragOwnerPositionsRef.current.set(nodeId, { x, y }); + applyCurrentLayout(); + }, + [applyCurrentLayout] + ); - const sim = simRef.current; - if (!sim) { - return; - } + const clearNodePosition = useCallback( + (nodeId: string) => { + if (!dragOwnerPositionsRef.current.delete(nodeId)) { + return; + } + applyCurrentLayout(); + }, + [applyCurrentLayout] + ); - const simNode = sim.nodes().find((node) => node.id === nodeId); - if (simNode) { - simNode.fx = x; - simNode.fy = y; - simNode.x = x; - simNode.y = y; - simNode.vx = 0; - simNode.vy = 0; - } + const resolveNearestOwnerSlot = useCallback( + (nodeId: string, x: number, y: number) => { + const snapshot = layoutSnapshotRef.current; + if (!snapshot) { + return null; + } + return resolveNearestSlotAssignment({ + ownerId: nodeId, + ownerX: x, + ownerY: y, + nodes: stateRef.current.nodes, + snapshot, + layout: layoutRef.current, + }); + }, + [] + ); - syncLaunchAnchors(sim.nodes()); - updateLaunchAnchorCaches( - sim.nodes(), - launchAnchorPositionsRef.current, - activityAnchorPositionsRef.current, - extraWorldBoundsRef.current - ); - }, []); - - // Cleanup useEffect(() => { return () => { - simRef.current?.stop(); + dragOwnerPositionsRef.current.clear(); + launchAnchorPositionsRef.current.clear(); + activityAnchorPositionsRef.current.clear(); + extraWorldBoundsRef.current = []; + layoutSnapshotRef.current = null; + lastValidSnapshotByTeamRef.current.clear(); }; }, []); - const getLaunchAnchorWorldPosition = useCallback((leadNodeId: string) => { - return launchAnchorPositionsRef.current.get(leadNodeId) ?? null; - }, []); - - const getExtraWorldBounds = useCallback(() => { - return extraWorldBoundsRef.current; - }, []); - return { stateRef, updateData, tick, setNodePosition, - getLaunchAnchorWorldPosition, + clearNodePosition, + resolveNearestOwnerSlot, + getLaunchAnchorWorldPosition: (leadNodeId: string) => + launchAnchorPositionsRef.current.get(leadNodeId) ?? null, getActivityAnchorWorldPosition: (nodeId: string) => activityAnchorPositionsRef.current.get(nodeId) ?? null, - getExtraWorldBounds, + getExtraWorldBounds: () => extraWorldBoundsRef.current, }; } -function mergeParticles( - existing: GraphParticle[], - incoming: GraphParticle[], -): GraphParticle[] { +function applySnapshotToNodes( + nodes: GraphNode[], + snapshot: StableSlotLayoutSnapshot, + dragOwnerPositions: ReadonlyMap +): void { + const translatedFrames = getTranslatedMemberFrames(snapshot, dragOwnerPositions); + const translatedFrameByOwnerId = new Map( + translatedFrames.map((frame) => [frame.ownerId, frame] as const) + ); + const leadId = snapshot.leadNodeId; + + for (const node of nodes) { + if (node.kind === 'lead' && node.id === leadId) { + node.x = 0; + node.y = 0; + node.fx = 0; + node.fy = 0; + node.vx = 0; + node.vy = 0; + continue; + } + + if (node.kind === 'member') { + const frame = translatedFrameByOwnerId.get(node.id); + if (!frame) { + continue; + } + node.x = frame.ownerX; + node.y = frame.ownerY; + node.fx = frame.ownerX; + node.fy = frame.ownerY; + node.vx = 0; + node.vy = 0; + } + } + + positionProcessNodes(nodes, translatedFrames); + KanbanLayoutEngine.layout(nodes, { + memberSlotFrames: translatedFrames, + unassignedTaskRect: snapshot.unassignedTaskRect, + }); + positionCrossTeamNodes(nodes, snapshot.fitBounds); +} + +function commitSnapshotGeometry(args: { + nodes: GraphNode[]; + snapshot: StableSlotLayoutSnapshot; + teamName: string; + layoutSnapshotRef: { current: StableSlotLayoutSnapshot | null }; + lastValidSnapshotByTeamRef: { current: Map }; + dragOwnerPositionsRef: { current: ReadonlyMap }; + launchAnchorPositionsRef: { current: Map }; + activityAnchorPositionsRef: { current: Map }; + extraWorldBoundsRef: { current: WorldBounds[] }; + fillMissingFallbackPositions?: boolean; +}): void { + const { + nodes, + snapshot, + teamName, + layoutSnapshotRef, + lastValidSnapshotByTeamRef, + dragOwnerPositionsRef, + launchAnchorPositionsRef, + activityAnchorPositionsRef, + extraWorldBoundsRef, + fillMissingFallbackPositions = false, + } = args; + + layoutSnapshotRef.current = snapshot; + lastValidSnapshotByTeamRef.current.set(teamName, snapshot); + applySnapshotToNodes(nodes, snapshot, dragOwnerPositionsRef.current); + if (fillMissingFallbackPositions) { + fallbackPositionNodes(nodes); + } + + launchAnchorPositionsRef.current.clear(); + activityAnchorPositionsRef.current.clear(); + extraWorldBoundsRef.current = snapshotToWorldBounds(snapshot); + + if (snapshot.leadNodeId && snapshot.launchAnchor) { + launchAnchorPositionsRef.current.set(snapshot.leadNodeId, snapshot.launchAnchor); + } + + for (const frame of getTranslatedMemberFrames(snapshot, dragOwnerPositionsRef.current)) { + activityAnchorPositionsRef.current.set(frame.ownerId, { + x: frame.activityRect.left, + y: frame.activityRect.top, + }); + } + + activityAnchorPositionsRef.current.set(`lead:${teamName}`, { + x: snapshot.leadActivityRect.left, + y: snapshot.leadActivityRect.top, + }); +} + +function resetToFallbackLayout(args: { + nodes: GraphNode[]; + layoutSnapshotRef: { current: StableSlotLayoutSnapshot | null }; + launchAnchorPositionsRef: { current: Map }; + activityAnchorPositionsRef: { current: Map }; + extraWorldBoundsRef: { current: WorldBounds[] }; +}): void { + const { + nodes, + layoutSnapshotRef, + launchAnchorPositionsRef, + activityAnchorPositionsRef, + extraWorldBoundsRef, + } = args; + + layoutSnapshotRef.current = null; + launchAnchorPositionsRef.current.clear(); + activityAnchorPositionsRef.current.clear(); + extraWorldBoundsRef.current = []; + fallbackPositionNodes(nodes); + KanbanLayoutEngine.layout(nodes); +} + +function preserveReusableNodePositions( + nodes: GraphNode[], + previousNodes: GraphNode[] +): void { + const previousPositionById = new Map( + previousNodes + .filter((node) => node.x != null && node.y != null) + .map((node) => [ + node.id, + { x: node.x!, y: node.y!, vx: node.vx ?? 0, vy: node.vy ?? 0 }, + ] as const) + ); + + for (const node of nodes) { + const previous = previousPositionById.get(node.id); + if ( + !previous || + node.kind === 'lead' || + node.kind === 'member' || + node.kind === 'task' || + node.kind === 'process' + ) { + continue; + } + node.x = previous.x; + node.y = previous.y; + node.vx = previous.vx; + node.vy = previous.vy; + } +} + +function recordNodeLifecycleEffects( + effects: VisualEffect[], + nodes: GraphNode[], + prevStates: ReadonlyMap, + allKnown: Set +): void { + for (const node of nodes) { + if (!allKnown.has(node.id) && node.x != null && node.y != null) { + const nodeRadius = resolveNodeEffectRadius(node); + effects.push( + createSpawnEffect(node.x, node.y, node.color ?? getStateColor(node.state), nodeRadius) + ); + } + + const prevState = prevStates.get(node.id); + if ( + prevState && + prevState !== 'complete' && + node.state === 'complete' && + node.x != null && + node.y != null + ) { + effects.push(createCompleteEffect(node.x, node.y, node.color ?? getStateColor(node.state))); + } + + allKnown.add(node.id); + } +} + +function resolveNodeEffectRadius(node: GraphNode): number | undefined { + if (node.kind === 'lead') { + return NODE.radiusLead; + } + if (node.kind === 'member') { + return NODE.radiusMember; + } + return undefined; +} + +function getTranslatedMemberFrames( + snapshot: StableSlotLayoutSnapshot, + dragOwnerPositions: ReadonlyMap +): SlotFrame[] { + return snapshot.memberSlotFrames.map((frame) => { + const dragPosition = dragOwnerPositions.get(frame.ownerId); + if (!dragPosition) { + return frame; + } + return translateSlotFrame(frame, dragPosition.x - frame.ownerX, dragPosition.y - frame.ownerY); + }); +} + +function positionProcessNodes(nodes: GraphNode[], frames: readonly SlotFrame[]): void { + const frameByOwnerId = new Map(frames.map((frame) => [frame.ownerId, frame] as const)); + const processNodesByOwnerId = new Map(); + + for (const node of nodes) { + if (node.kind !== 'process' || !node.ownerId) { + continue; + } + const existing = processNodesByOwnerId.get(node.ownerId) ?? []; + existing.push(node); + processNodesByOwnerId.set(node.ownerId, existing); + } + + for (const [ownerId, processNodes] of processNodesByOwnerId) { + const frame = frameByOwnerId.get(ownerId); + if (!frame) { + continue; + } + + const gap = 42; + const totalWidth = Math.max(0, (processNodes.length - 1) * gap); + for (const [index, node] of processNodes.entries()) { + const x = frame.ownerX - totalWidth / 2 + index * gap; + const y = frame.processBandRect.top + frame.processBandRect.height / 2; + node.x = x; + node.y = y; + node.fx = x; + node.fy = y; + node.vx = 0; + node.vy = 0; + } + } +} + +function positionCrossTeamNodes(nodes: GraphNode[], fitBounds: StableSlotLayoutSnapshot['fitBounds']): void { + const crossTeamNodes = nodes.filter((node) => node.kind === 'crossteam'); + if (crossTeamNodes.length === 0) { + return; + } + + const radius = + Math.max( + Math.abs(fitBounds.left), + Math.abs(fitBounds.right), + Math.abs(fitBounds.top), + Math.abs(fitBounds.bottom) + ) + 220; + const startAngle = (-150 * Math.PI) / 180; + const endAngle = (150 * Math.PI) / 180; + + crossTeamNodes.forEach((node, index) => { + const t = + crossTeamNodes.length === 1 ? 0.5 : index / Math.max(crossTeamNodes.length - 1, 1); + const angle = startAngle + (endAngle - startAngle) * t; + const x = Math.cos(angle) * radius; + const y = Math.sin(angle) * radius; + node.x = x; + node.y = y; + node.fx = x; + node.fy = y; + node.vx = 0; + node.vy = 0; + }); +} + +function fallbackPositionNodes(nodes: GraphNode[]): void { + nodes.forEach((node, index) => { + if (node.kind === 'task') { + return; + } + if (node.x != null && node.y != null) { + return; + } + const row = Math.floor(index / 4); + const col = index % 4; + const x = (col - 1.5) * 220; + const y = (row - 1) * 220; + node.x = x; + node.y = y; + node.fx = x; + node.fy = y; + node.vx = 0; + node.vy = 0; + }); +} + +function mergeParticles(existing: GraphParticle[], incoming: GraphParticle[]): GraphParticle[] { if (existing.length === 0) return incoming; if (incoming.length === 0) return existing; @@ -523,68 +544,3 @@ function mergeParticles( } return merged; } - -// ─── Frame Tick (pure function) ───────────────────────────────────────────── - -function tickFrame( - state: SimulationState, - sim: Simulation | null, - dt: number, - launchAnchorPositions: Map, - activityAnchorPositions: Map, - extraWorldBounds: WorldBounds[], -): void { - state.time += dt; - - // Tick d3-force (only when simulation is still active) - if (sim && sim.alpha() > 0.001) { - syncLaunchAnchors(sim.nodes()); - sim.tick(1); - - const simNodes = sim.nodes(); - const simNodeMap = new Map(); - for (const sn of simNodes) simNodeMap.set(sn.id, sn); - - for (const node of state.nodes) { - const sn = simNodeMap.get(node.id); - if (sn) { - node.x = sn.x; - node.y = sn.y; - node.vx = sn.vx; - node.vy = sn.vy; - } - } - updateLaunchAnchorCaches(simNodes, launchAnchorPositions, activityAnchorPositions, extraWorldBounds); - } else if (sim) { - syncLaunchAnchors(sim.nodes()); - updateLaunchAnchorCaches( - sim.nodes(), - launchAnchorPositions, - activityAnchorPositions, - extraWorldBounds - ); - } - - // Re-layout tasks in kanban zones — always run to handle new/moved tasks - KanbanLayoutEngine.layout(state.nodes, { - activityLaneBounds: buildVisibleActivityLaneBounds(state.nodes, activityAnchorPositions), - }); - - // Update particle progress — in-place removal (no new array allocation) - let pw = 0; - for (let i = 0; i < state.particles.length; i++) { - const p = state.particles[i]; - p.progress += dt * ANIM_SPEED.particleSpeed * 0.5; - if (p.progress < 1) state.particles[pw++] = p; - } - state.particles.length = pw; - - // Update effects — in-place removal - let ew = 0; - for (let i = 0; i < state.effects.length; i++) { - const fx = state.effects[i]; - fx.age += dt; - if (fx.age < fx.duration) state.effects[ew++] = fx; - } - state.effects.length = ew; -} diff --git a/packages/agent-graph/src/index.ts b/packages/agent-graph/src/index.ts index 27a12196..6eda9d4d 100644 --- a/packages/agent-graph/src/index.ts +++ b/packages/agent-graph/src/index.ts @@ -9,6 +9,7 @@ // ─── Components ────────────────────────────────────────────────────────────── export { GraphView } from './ui/GraphView'; export type { GraphViewProps } from './ui/GraphView'; +export { ACTIVITY_ANCHOR_LAYOUT, ACTIVITY_LANE } from './layout/activityLane'; // ─── Port Interfaces (for adapters in host project) ───────────────────────── export type { GraphDataPort } from './ports/GraphDataPort'; @@ -21,6 +22,9 @@ export type { GraphEdge, GraphParticle, GraphActivityItem, + GraphOwnerSlotAssignment, + GraphLayoutPort, + GraphLayoutVersion, GraphNodeKind, GraphNodeState, GraphLaunchVisualState, diff --git a/packages/agent-graph/src/layout/activityLane.ts b/packages/agent-graph/src/layout/activityLane.ts index 46b1810c..dfae2843 100644 --- a/packages/agent-graph/src/layout/activityLane.ts +++ b/packages/agent-graph/src/layout/activityLane.ts @@ -1,37 +1,20 @@ -import { CAMERA, KANBAN_ZONE, NODE, TASK_PILL } from '../constants/canvas-constants'; +import { CAMERA, NODE } from '../constants/canvas-constants'; import type { GraphActivityItem, GraphNode } from '../ports/types'; +import { createStableSlotActivityLane } from './stableSlotGeometry'; -export const ACTIVITY_LANE = { - width: 296, - itemHeight: 72, - rowHeight: 80, - maxVisibleItems: 3, - headerHeight: 20, - overflowHeight: 32, - horizontalGapLead: 76, - horizontalGapMember: 84, - ownerClearanceLead: 92, - ownerClearanceMember: 104, - viewportPadding: 12, - visiblePadding: 80, - minScale: CAMERA.minZoom, - maxScale: CAMERA.maxZoom, -} as const; +const STABLE_SLOT_ACTIVITY = createStableSlotActivityLane({ + nodeMetrics: { + radiusLead: NODE.radiusLead, + radiusMember: NODE.radiusMember, + }, + zoomRange: { + minZoom: CAMERA.minZoom, + maxZoom: CAMERA.maxZoom, + }, +}); -const RESERVED_HEIGHT = - ACTIVITY_LANE.headerHeight - + ACTIVITY_LANE.maxVisibleItems * ACTIVITY_LANE.rowHeight - + ACTIVITY_LANE.overflowHeight; - -export const ACTIVITY_ANCHOR_LAYOUT = { - reservedWidth: ACTIVITY_LANE.width, - reservedHeight: RESERVED_HEIGHT, - memberOffsetX: ACTIVITY_LANE.width / 2 + NODE.radiusMember + ACTIVITY_LANE.horizontalGapMember, - memberOffsetY: -(RESERVED_HEIGHT + NODE.radiusMember + ACTIVITY_LANE.ownerClearanceMember), - leadOffsetX: -(ACTIVITY_LANE.width / 2 + NODE.radiusLead + ACTIVITY_LANE.horizontalGapLead), - leadOffsetY: -(RESERVED_HEIGHT + NODE.radiusLead + ACTIVITY_LANE.ownerClearanceLead), - collisionRadius: Math.ceil(Math.hypot(ACTIVITY_LANE.width / 2, RESERVED_HEIGHT / 2)) + 56, -} as const; +export const ACTIVITY_LANE = STABLE_SLOT_ACTIVITY.lane; +export const ACTIVITY_ANCHOR_LAYOUT = STABLE_SLOT_ACTIVITY.anchor; export interface ActivityLaneWindow { items: GraphActivityItem[]; @@ -226,29 +209,14 @@ function packActivityLaneRects { const placements = new Map(); - - const sideGroups = groupBySide ? (['left', 'right'] as const) : (['left'] as const); - - for (const side of sideGroups) { + for (const side of resolvePackedActivitySides(groupBySide)) { const sideRects = rects .filter((rect) => !groupBySide || rect.side === side) .sort((a, b) => (a.y === b.y ? a.x - b.x : a.y - b.y)); - const placed: Array = []; + const placed: (T & { placedY: number })[] = []; for (const rect of sideRects) { - let placedY = rect.y; - - for (const prev of placed) { - if (!rangesOverlap(rect.x, rect.x + rect.width, prev.x, prev.x + prev.width)) { - continue; - } - - const prevBottom = prev.placedY + prev.height; - if (placedY < prevBottom + gap && placedY + rect.height > prev.placedY - gap) { - placedY = prevBottom + gap; - } - } - + const placedY = resolvePackedActivityY(rect, placed, gap); placed.push({ ...rect, placedY }); placements.set(rect.id, { x: rect.x, y: placedY }); } @@ -282,13 +250,17 @@ export function findActivityItemAt( for (let index = 0; index < items.length; index += 1) { const itemTop = itemsTop + index * ACTIVITY_LANE.rowHeight; + const item = items.at(index); + if (!item) { + continue; + } if ( worldX >= left && worldX <= left + ACTIVITY_LANE.width && worldY >= itemTop && worldY <= itemTop + ACTIVITY_LANE.itemHeight ) { - return { ownerNodeId: node.id, item: items[index] }; + return { ownerNodeId: node.id, item }; } } } @@ -303,3 +275,33 @@ export function isActivityOwner(node: GraphNode): node is GraphNode & { kind: 'l function rangesOverlap(aStart: number, aEnd: number, bStart: number, bEnd: number): boolean { return aStart < bEnd && aEnd > bStart; } + +function resolvePackedActivitySides(groupBySide: boolean): readonly ActivityLaneSide[] { + return groupBySide ? ['left', 'right'] : ['left']; +} + +function resolvePackedActivityY( + rect: T, + placed: readonly (T & { placedY: number })[], + gap: number +): number { + let placedY = rect.y; + + for (const prev of placed) { + if (!rangesOverlap(rect.x, rect.x + rect.width, prev.x, prev.x + prev.width)) { + continue; + } + + const prevBottom = prev.placedY + prev.height; + if (placedY < prevBottom + gap && placedY + rect.height > prev.placedY - gap) { + placedY = prevBottom + gap; + } + } + + return placedY; +} diff --git a/packages/agent-graph/src/layout/kanbanLayout.ts b/packages/agent-graph/src/layout/kanbanLayout.ts index 9618f491..9356f7c0 100644 --- a/packages/agent-graph/src/layout/kanbanLayout.ts +++ b/packages/agent-graph/src/layout/kanbanLayout.ts @@ -12,6 +12,7 @@ import { KANBAN_ZONE, TASK_PILL } from '../constants/canvas-constants'; import { COLORS } from '../constants/colors'; import { resolveActivityLaneSide } from './activityLane'; import type { ActivityLaneWorldBounds } from './activityLane'; +import type { SlotFrame, StableRect } from './stableSlots'; /** Column header info for rendering */ export interface KanbanColumnHeader { @@ -81,7 +82,7 @@ export class KanbanLayoutEngine { static readonly #colTasks = new Map(); /** Zone info for rendering column headers — updated each layout() call */ - static zones: KanbanZoneInfo[] = []; + static readonly zones: KanbanZoneInfo[] = []; /** * Position all task nodes in kanban columns relative to their owner. @@ -89,22 +90,42 @@ export class KanbanLayoutEngine { */ static layout( nodes: GraphNode[], - options?: { activityLaneBounds?: readonly ActivityLaneWorldBounds[] } + options?: { + activityLaneBounds?: readonly ActivityLaneWorldBounds[]; + memberSlotFrames?: readonly SlotFrame[]; + unassignedTaskRect?: StableRect | null; + } ): void { const nodeMap = this.#nodeMap; nodeMap.clear(); for (const n of nodes) nodeMap.set(n.id, n); const leadX = nodes.find((node) => node.kind === 'lead')?.x ?? null; const activityLaneBounds = options?.activityLaneBounds ?? []; + const memberSlotFrameByOwnerId = new Map( + (options?.memberSlotFrames ?? []).map((frame) => [frame.ownerId, frame] as const) + ); const tasksByOwner = this.#tasksByOwner; tasksByOwner.clear(); const unassigned = this.#unassigned; unassigned.length = 0; + const hasLayoutOwner = (ownerId: string): boolean => { + const owner = nodeMap.get(ownerId); + if (!owner) { + return false; + } + if (owner.kind === 'lead') { + return true; + } + if (owner.kind === 'member') { + return memberSlotFrameByOwnerId.has(ownerId); + } + return false; + }; for (const n of nodes) { if (n.kind !== 'task') continue; - if (n.ownerId) { + if (n.ownerId && hasLayoutOwner(n.ownerId)) { let group = tasksByOwner.get(n.ownerId); if (!group) { group = []; @@ -117,22 +138,23 @@ export class KanbanLayoutEngine { } // Reset zones - this.zones = []; + this.zones.length = 0; for (const [ownerId, tasks] of tasksByOwner) { const owner = nodeMap.get(ownerId); - if (!owner || owner.x == null || owner.y == null) continue; + if (owner?.x == null || owner?.y == null) continue; const zoneInfo = KanbanLayoutEngine.#layoutZone( tasks, owner, ownerId, leadX, - activityLaneBounds + activityLaneBounds, + memberSlotFrameByOwnerId.get(ownerId) ?? null ); if (zoneInfo) this.zones.push(zoneInfo); } - KanbanLayoutEngine.#layoutUnassigned(unassigned, nodes); + KanbanLayoutEngine.#layoutUnassigned(unassigned, nodes, options?.unassignedTaskRect ?? null); } // ─── Private ────────────────────────────────────────────────────────────── @@ -142,7 +164,8 @@ export class KanbanLayoutEngine { owner: GraphNode, ownerId: string, leadX: number | null, - activityLaneBounds: readonly ActivityLaneWorldBounds[] + activityLaneBounds: readonly ActivityLaneWorldBounds[], + slotFrame: SlotFrame | null ): KanbanZoneInfo | null { const { columnWidth, rowHeight, offsetY, columns } = KANBAN_ZONE; const headerHeight = 20; // space for column header label @@ -172,31 +195,38 @@ export class KanbanLayoutEngine { // 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. - const baseX = getOwnerKanbanBaseX({ + let baseX = getOwnerKanbanBaseX({ ownerX, ownerKind: owner.kind, activeColumnCount: activeColumns.length, columnWidth, leadX, }); - const taskZoneLeft = baseX - TASK_PILL.width / 2; - const taskZoneRight = - baseX + (activeColumns.length - 1) * columnWidth + TASK_PILL.width / 2; - const overlappingActivityBottom = activityLaneBounds.reduce((maxBottom, bounds) => { - if (bounds.ownerId === ownerId) { + let baseY: number; + + if (slotFrame) { + baseX = slotFrame.taskBandRect.left + TASK_PILL.width / 2; + baseY = slotFrame.taskBandRect.top; + } else { + const taskZoneLeft = baseX - TASK_PILL.width / 2; + 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); - } - if (!rangesOverlap(taskZoneLeft, taskZoneRight, bounds.left, bounds.right)) { - return maxBottom; - } - return Math.max(maxBottom, bounds.bottom); - }, -Infinity); - const baseY = Math.max( - ownerY + offsetY, - overlappingActivityBottom > -Infinity - ? overlappingActivityBottom + ACTIVITY_KANBAN_CLEARANCE - : -Infinity - ); + }, -Infinity); + baseY = Math.max( + ownerY + offsetY, + overlappingActivityBottom > -Infinity + ? overlappingActivityBottom + ACTIVITY_KANBAN_CLEARANCE + : -Infinity + ); + } // Build headers + position tasks const headers: KanbanColumnHeader[] = []; @@ -221,8 +251,8 @@ export class KanbanLayoutEngine { for (const [rowIdx, task] of col.tasks.entries()) { const targetX = colX; const targetY = baseY + headerHeight + rowIdx * rowHeight; - task.x = task.x != null ? task.x + (targetX - task.x) * 0.15 : targetX; - task.y = task.y != null ? task.y + (targetY - task.y) * 0.15 : targetY; + task.x = slotFrame ? targetX : task.x != null ? task.x + (targetX - task.x) * 0.15 : targetX; + task.y = slotFrame ? targetY : task.y != null ? task.y + (targetY - task.y) * 0.15 : targetY; task.fx = task.x; task.fy = task.y; task.vx = 0; @@ -246,11 +276,52 @@ export class KanbanLayoutEngine { } } - static #layoutUnassigned(tasks: GraphNode[], allNodes: GraphNode[]): void { + static #layoutUnassigned( + tasks: GraphNode[], + allNodes: GraphNode[], + unassignedTaskRect: StableRect | null + ): void { if (tasks.length === 0) return; const { columnWidth, rowHeight } = KANBAN_ZONE; + if (unassignedTaskRect) { + const cols = Math.min(Math.max(tasks.length, 1), 5); + const baseX = unassignedTaskRect.left + TASK_PILL.width / 2; + const baseY = unassignedTaskRect.top; + const overflowCount = tasks.reduce((sum, task) => sum + (task.overflowCount ?? 0), 0); + + this.zones.push({ + ownerId: '__unassigned__', + ownerX: 0, + ownerY: baseY - 48, + headers: [ + { + label: 'Unassigned', + x: 0, + y: baseY, + color: COLORS.taskPending, + overflowCount, + overflowY: baseY + KANBAN_ZONE.maxVisibleRows * rowHeight, + }, + ], + }); + + for (const [idx, task] of tasks.entries()) { + const col = idx % cols; + const row = Math.floor(idx / cols); + const targetX = baseX + col * columnWidth; + const targetY = baseY + row * rowHeight; + task.x = targetX; + task.y = targetY; + task.fx = targetX; + task.fy = targetY; + task.vx = 0; + task.vy = 0; + } + return; + } + // Find the lowest Y of ALL positioned nodes (members + their owned tasks) let sumX = 0; let maxY = -Infinity; diff --git a/packages/agent-graph/src/layout/launchAnchor.ts b/packages/agent-graph/src/layout/launchAnchor.ts index 4d3dad83..01bd4436 100644 --- a/packages/agent-graph/src/layout/launchAnchor.ts +++ b/packages/agent-graph/src/layout/launchAnchor.ts @@ -3,6 +3,7 @@ import { ACTIVITY_ANCHOR_LAYOUT, resolveActivityLaneSide, } from './activityLane'; +import { createStableSlotLaunchAnchorLayout } from './stableSlotGeometry'; export interface WorldBounds { left: number; @@ -18,17 +19,9 @@ export interface LaunchAnchorScreenPlacement { visible: boolean; } -export const LAUNCH_ANCHOR_LAYOUT = { - compactWidth: 336, - compactHeight: 132, - anchorCenterOffsetX: 336 / 2 + NODE.radiusLead + 40, - anchorCenterOffsetY: -(132 / 2 + NODE.radiusLead + 36), - collisionRadius: Math.ceil(Math.hypot(336 / 2, 132 / 2)) + 14, - viewportPadding: 12, - visiblePadding: 80, - minScale: 0, - maxScale: 1, -} as const; +export const LAUNCH_ANCHOR_LAYOUT = createStableSlotLaunchAnchorLayout({ + radiusLead: NODE.radiusLead, +}); const LAUNCH_ANCHOR_PREFIX = '__launch_anchor__:'; const ACTIVITY_ANCHOR_PREFIX = '__activity_anchor__:'; diff --git a/packages/agent-graph/src/layout/stableSlotGeometry.ts b/packages/agent-graph/src/layout/stableSlotGeometry.ts new file mode 100644 index 00000000..a9e58b13 --- /dev/null +++ b/packages/agent-graph/src/layout/stableSlotGeometry.ts @@ -0,0 +1,158 @@ +export const STABLE_SLOT_GEOMETRY = { + slotVerticalGap: 24, + slotHorizontalGap: 32, + ringGap: 140, + centralSafetyPadding: 48, + memberSlotInnerPadding: 16, + centralBlockGap: 56, + ringPadding: 32, + unassignedGap: 72, + maxGeneratedRings: 12, + ownerCollisionPadding: 28, + ownerBandHeight: 72, + ownerMinWidth: 200, + processBandHeight: 32, + processRailWidth: 220, + taskMaxVisibleRows: 5, +} as const; + +export const STABLE_SLOT_SECTOR_VECTORS = [ + { x: 0, y: -1 }, + { x: 0.82, y: -0.57 }, + { x: 0.82, y: 0.57 }, + { x: 0, y: 1 }, + { x: -0.82, y: 0.57 }, + { x: -0.82, y: -0.57 }, +] as const; + +export interface StableSlotNodeMetrics { + radiusLead: number; + radiusMember: number; +} + +export interface StableSlotZoomRange { + minZoom: number; + maxZoom: number; +} + +export interface StableSlotActivityLane { + width: number; + itemHeight: number; + rowHeight: number; + maxVisibleItems: number; + headerHeight: number; + overflowHeight: number; + horizontalGapLead: number; + horizontalGapMember: number; + ownerClearanceLead: number; + ownerClearanceMember: number; + viewportPadding: number; + visiblePadding: number; + minScale: number; + maxScale: number; +} + +export interface StableSlotActivityAnchorLayout { + reservedWidth: number; + reservedHeight: number; + memberOffsetX: number; + memberOffsetY: number; + leadOffsetX: number; + leadOffsetY: number; + collisionRadius: number; +} + +export interface StableSlotLaunchAnchorLayout { + compactWidth: number; + compactHeight: number; + anchorCenterOffsetX: number; + anchorCenterOffsetY: number; + collisionRadius: number; + viewportPadding: number; + visiblePadding: number; + minScale: number; + maxScale: number; +} + +const ACTIVITY_LANE_BASE = { + width: 296, + itemHeight: 72, + rowHeight: 80, + maxVisibleItems: 3, + headerHeight: 20, + overflowHeight: 32, + horizontalGapLead: 76, + horizontalGapMember: 84, + ownerClearanceLead: 92, + ownerClearanceMember: 104, + viewportPadding: 12, + visiblePadding: 80, +} as const; + +const LAUNCH_HUD_BASE = { + compactWidth: 336, + compactHeight: 132, + horizontalGap: 40, + verticalClearance: 36, + viewportPadding: 12, + visiblePadding: 80, + minScale: 0, + maxScale: 1, +} as const; + +export function createStableSlotActivityLane(args: { + nodeMetrics: StableSlotNodeMetrics; + zoomRange: StableSlotZoomRange; +}): { + lane: StableSlotActivityLane; + anchor: StableSlotActivityAnchorLayout; +} { + const { nodeMetrics, zoomRange } = args; + const lane: StableSlotActivityLane = { + ...ACTIVITY_LANE_BASE, + minScale: zoomRange.minZoom, + maxScale: zoomRange.maxZoom, + }; + const reservedHeight = + lane.headerHeight + + lane.maxVisibleItems * lane.rowHeight + + lane.overflowHeight; + + return { + lane, + anchor: { + reservedWidth: lane.width, + reservedHeight, + memberOffsetX: lane.width / 2 + nodeMetrics.radiusMember + lane.horizontalGapMember, + memberOffsetY: -(reservedHeight + nodeMetrics.radiusMember + lane.ownerClearanceMember), + leadOffsetX: -(lane.width / 2 + nodeMetrics.radiusLead + lane.horizontalGapLead), + leadOffsetY: -(reservedHeight + nodeMetrics.radiusLead + lane.ownerClearanceLead), + collisionRadius: Math.ceil(Math.hypot(lane.width / 2, reservedHeight / 2)) + 56, + }, + }; +} + +export function createStableSlotLaunchAnchorLayout( + nodeMetrics: Pick +): StableSlotLaunchAnchorLayout { + const { radiusLead } = nodeMetrics; + return { + compactWidth: LAUNCH_HUD_BASE.compactWidth, + compactHeight: LAUNCH_HUD_BASE.compactHeight, + anchorCenterOffsetX: + LAUNCH_HUD_BASE.compactWidth / 2 + radiusLead + LAUNCH_HUD_BASE.horizontalGap, + anchorCenterOffsetY: + -(LAUNCH_HUD_BASE.compactHeight / 2 + radiusLead + LAUNCH_HUD_BASE.verticalClearance), + collisionRadius: + Math.ceil( + Math.hypot( + LAUNCH_HUD_BASE.compactWidth / 2, + LAUNCH_HUD_BASE.compactHeight / 2 + ) + ) + 14, + viewportPadding: LAUNCH_HUD_BASE.viewportPadding, + visiblePadding: LAUNCH_HUD_BASE.visiblePadding, + minScale: LAUNCH_HUD_BASE.minScale, + maxScale: LAUNCH_HUD_BASE.maxScale, + }; +} diff --git a/packages/agent-graph/src/layout/stableSlots.ts b/packages/agent-graph/src/layout/stableSlots.ts new file mode 100644 index 00000000..cf11900f --- /dev/null +++ b/packages/agent-graph/src/layout/stableSlots.ts @@ -0,0 +1,1291 @@ +import { KANBAN_ZONE, TASK_PILL } from '../constants/canvas-constants'; +import type { GraphLayoutPort, GraphNode, GraphOwnerSlotAssignment } from '../ports/types'; +import { ACTIVITY_ANCHOR_LAYOUT, ACTIVITY_LANE } from './activityLane'; +import { LAUNCH_ANCHOR_LAYOUT, type WorldBounds } from './launchAnchor'; +import { + STABLE_SLOT_GEOMETRY, + STABLE_SLOT_SECTOR_VECTORS, +} from './stableSlotGeometry'; + +export type StableSlotWidthBucket = 'S' | 'M' | 'L'; + +export interface StableRect { + left: number; + top: number; + right: number; + bottom: number; + width: number; + height: number; +} + +export interface OwnerFootprint { + ownerId: string; + slotWidth: number; + slotHeight: number; + widthBucket: StableSlotWidthBucket; + radialDepth: number; + activityWidth: number; + activityHeight: number; + processRailWidth: number; + taskBandWidth: number; + taskBandHeight: number; + taskColumnCount: number; + processCount: number; +} + +export interface SlotFrame { + ownerId: string; + ringIndex: number; + sectorIndex: number; + widthBucket: StableSlotWidthBucket; + bounds: StableRect; + ownerX: number; + ownerY: number; + activityRect: StableRect; + processBandRect: StableRect; + taskBandRect: StableRect; + taskColumnCount: number; +} + +export interface StableSlotLayoutSnapshot { + version: GraphLayoutPort['version']; + teamName: string; + leadNodeId: string | null; + leadCoreRect: StableRect; + leadActivityRect: StableRect; + launchHudRect: StableRect; + launchAnchor: { x: number; y: number } | null; + leadCentralReservedBlock: StableRect; + runtimeCentralExclusion: StableRect; + memberSlotFrames: SlotFrame[]; + memberSlotFrameByOwnerId: Map; + unassignedTaskRect: StableRect | null; + fitBounds: StableRect; +} + +export interface StableSlotLayoutValidationResult { + valid: boolean; + reason?: string; +} + +interface NearestSlotAssignmentResult { + assignment: GraphOwnerSlotAssignment; + displacedOwnerId?: string; + displacedAssignment?: GraphOwnerSlotAssignment; +} + +interface RankedNearestSlotAssignmentResult extends NearestSlotAssignmentResult { + distanceSquared: number; +} + +interface LayoutBuildArgs { + teamName: string; + nodes: GraphNode[]; + layout?: GraphLayoutPort; +} + +interface RingLayoutState { + radius: number; + outwardDepth: number; +} + +type RingLayoutStateMap = ReadonlyMap; + +const SLOT_GEOMETRY = { + ...STABLE_SLOT_GEOMETRY, + activityHeight: ACTIVITY_ANCHOR_LAYOUT.reservedHeight, + activityWidth: ACTIVITY_LANE.width, + activityToOwnerGap: STABLE_SLOT_GEOMETRY.slotVerticalGap, + ownerToProcessGap: STABLE_SLOT_GEOMETRY.slotVerticalGap, + processToTaskGap: STABLE_SLOT_GEOMETRY.slotVerticalGap, + taskBandHeight: + ACTIVITY_LANE.headerHeight + + STABLE_SLOT_GEOMETRY.taskMaxVisibleRows * KANBAN_ZONE.rowHeight, + centralPadding: STABLE_SLOT_GEOMETRY.centralSafetyPadding, +} as const; + +const SECTOR_VECTORS = STABLE_SLOT_SECTOR_VECTORS; + +export function buildStableSlotLayoutSnapshot({ + teamName, + nodes, + layout, +}: LayoutBuildArgs): StableSlotLayoutSnapshot | null { + const leadNode = nodes.find((node) => node.kind === 'lead') ?? null; + if (!leadNode) { + return null; + } + + const leadCoreRect = createCenteredRect(0, 0, 200, 168); + const leadActivityRect = createRect( + leadCoreRect.left - SLOT_GEOMETRY.centralBlockGap - ACTIVITY_LANE.width, + -ACTIVITY_ANCHOR_LAYOUT.reservedHeight / 2, + ACTIVITY_LANE.width, + ACTIVITY_ANCHOR_LAYOUT.reservedHeight + ); + const launchHudRect = createRect( + leadCoreRect.right + SLOT_GEOMETRY.centralBlockGap, + -LAUNCH_ANCHOR_LAYOUT.compactHeight / 2, + LAUNCH_ANCHOR_LAYOUT.compactWidth, + LAUNCH_ANCHOR_LAYOUT.compactHeight + ); + const leadCentralReservedBlock = unionRects([leadCoreRect, leadActivityRect, launchHudRect]); + + const ownerFootprints = computeOwnerFootprints(nodes, layout); + const unassignedTaskRect = buildUnassignedTaskRect(nodes, leadCentralReservedBlock); + const runtimeCentralExclusion = padRect( + unionRects( + unassignedTaskRect + ? [leadCentralReservedBlock, unassignedTaskRect] + : [leadCentralReservedBlock] + ), + SLOT_GEOMETRY.centralPadding + ); + + const memberSlotFrames = planOwnerSlots(ownerFootprints, runtimeCentralExclusion, layout); + const memberSlotFrameByOwnerId = new Map( + memberSlotFrames.map((frame) => [frame.ownerId, frame] as const) + ); + const fitBounds = unionRects( + [ + leadCentralReservedBlock, + leadActivityRect, + launchHudRect, + runtimeCentralExclusion, + ...memberSlotFrames.map((frame) => frame.bounds), + ...(unassignedTaskRect ? [unassignedTaskRect] : []), + ].filter(Boolean) + ); + + return { + version: layout?.version ?? 'stable-slots-v1', + teamName, + leadNodeId: leadNode.id, + leadCoreRect, + leadActivityRect, + launchHudRect, + launchAnchor: { + x: launchHudRect.left + launchHudRect.width / 2, + y: launchHudRect.top + launchHudRect.height / 2, + }, + leadCentralReservedBlock, + runtimeCentralExclusion, + memberSlotFrames, + memberSlotFrameByOwnerId, + unassignedTaskRect, + fitBounds, + }; +} + +export function computeOwnerFootprints( + nodes: GraphNode[], + layout?: GraphLayoutPort +): OwnerFootprint[] { + const ownerNodes = nodes.filter((node) => node.kind === 'member'); + const ownerNodeById = new Map(ownerNodes.map((node) => [node.id, node] as const)); + const taskColumnsByOwnerId = new Map>(); + const processCountByOwnerId = new Map(); + + for (const node of nodes) { + if (node.kind === 'task' && node.ownerId) { + const existing = taskColumnsByOwnerId.get(node.ownerId) ?? new Set(); + existing.add(resolveTaskColumnKey(node)); + taskColumnsByOwnerId.set(node.ownerId, existing); + } + if (node.kind === 'process' && node.ownerId) { + processCountByOwnerId.set(node.ownerId, (processCountByOwnerId.get(node.ownerId) ?? 0) + 1); + } + } + + const orderedOwnerIds = [ + ...(layout?.ownerOrder ?? ownerNodes.map((node) => node.id)), + ...ownerNodes + .map((node) => node.id) + .filter((ownerId) => !(layout?.ownerOrder ?? []).includes(ownerId)), + ].filter((ownerId, index, array) => array.indexOf(ownerId) === index); + + return orderedOwnerIds.flatMap((ownerId) => { + const ownerNode = ownerNodeById.get(ownerId); + if (!ownerNode) { + return []; + } + + const taskColumnCount = taskColumnsByOwnerId.get(ownerId)?.size ?? 0; + const taskBandWidth = + taskColumnCount <= 1 + ? TASK_PILL.width + : TASK_PILL.width + (taskColumnCount - 1) * KANBAN_ZONE.columnWidth; + const innerContentWidth = Math.max( + SLOT_GEOMETRY.activityWidth, + SLOT_GEOMETRY.ownerMinWidth, + SLOT_GEOMETRY.processRailWidth, + taskBandWidth + ); + const slotWidth = innerContentWidth + SLOT_GEOMETRY.memberSlotInnerPadding * 2; + const slotHeight = + SLOT_GEOMETRY.memberSlotInnerPadding * 2 + + SLOT_GEOMETRY.activityHeight + + SLOT_GEOMETRY.activityToOwnerGap + + SLOT_GEOMETRY.ownerBandHeight + + SLOT_GEOMETRY.ownerToProcessGap + + SLOT_GEOMETRY.processBandHeight + + SLOT_GEOMETRY.processToTaskGap + + SLOT_GEOMETRY.taskBandHeight; + const radialDepth = Math.max( + SLOT_GEOMETRY.memberSlotInnerPadding + + SLOT_GEOMETRY.activityHeight + + SLOT_GEOMETRY.activityToOwnerGap + + SLOT_GEOMETRY.ownerBandHeight / 2, + SLOT_GEOMETRY.memberSlotInnerPadding + + SLOT_GEOMETRY.ownerBandHeight / 2 + + SLOT_GEOMETRY.ownerToProcessGap + + SLOT_GEOMETRY.processBandHeight + + SLOT_GEOMETRY.processToTaskGap + + SLOT_GEOMETRY.taskBandHeight + ); + + return [ + { + ownerId, + slotWidth, + slotHeight, + widthBucket: classifyWidthBucket(slotWidth), + radialDepth, + activityWidth: SLOT_GEOMETRY.activityWidth, + activityHeight: SLOT_GEOMETRY.activityHeight, + processRailWidth: SLOT_GEOMETRY.processRailWidth, + taskBandWidth, + taskBandHeight: SLOT_GEOMETRY.taskBandHeight, + taskColumnCount, + processCount: processCountByOwnerId.get(ownerId) ?? 0, + } satisfies OwnerFootprint, + ]; + }); +} + +export function classifyWidthBucket(width: number): StableSlotWidthBucket { + if (width <= 340) { + return 'S'; + } + if (width <= 560) { + return 'M'; + } + return 'L'; +} + +export function resolveNearestSlotAssignment(args: { + ownerId: string; + ownerX: number; + ownerY: number; + nodes: GraphNode[]; + snapshot: StableSlotLayoutSnapshot; + layout?: GraphLayoutPort; +}): NearestSlotAssignmentResult | null { + const allFootprints = computeOwnerFootprints(args.nodes, args.layout); + const footprintByOwnerId = new Map( + allFootprints.map((item) => [item.ownerId, item] as const) + ); + const footprint = footprintByOwnerId.get(args.ownerId); + if (!footprint) { + return null; + } + + const currentFrame = args.snapshot.memberSlotFrameByOwnerId.get(args.ownerId); + if (!currentFrame) { + return null; + } + + const existingFrames = args.snapshot.memberSlotFrames.filter((frame) => frame.ownerId !== args.ownerId); + const maxOccupiedRing = existingFrames.reduce((max, frame) => Math.max(max, frame.ringIndex), 0); + const candidateAssignments = buildCandidateAssignments( + Math.max(SLOT_GEOMETRY.maxGeneratedRings, maxOccupiedRing + allFootprints.length + 2) + ); + const ringStates = buildRingStatesFromFrames( + [...existingFrames, currentFrame], + footprintByOwnerId + ); + let best: RankedNearestSlotAssignmentResult | null = null; + + for (const assignment of candidateAssignments) { + const occupiedFrame = args.snapshot.memberSlotFrames.find( + (existing) => + existing.ownerId !== args.ownerId && + existing.ringIndex === assignment.ringIndex && + existing.sectorIndex === assignment.sectorIndex + ); + const rankedCandidate = rankNearestSlotAssignmentResult({ + assignment, + occupiedFrame, + footprint, + footprintByOwnerId, + currentFrame, + existingFrames, + runtimeCentralExclusion: args.snapshot.runtimeCentralExclusion, + ringStates, + pointerX: args.ownerX, + pointerY: args.ownerY, + }); + if (!rankedCandidate) { + continue; + } + + if (!best || rankedCandidate.distanceSquared < best.distanceSquared) { + best = rankedCandidate; + } + } + + return best + ? { + assignment: best.assignment, + displacedOwnerId: best.displacedOwnerId, + displacedAssignment: best.displacedAssignment, + } + : null; +} + +export function validateStableSlotLayout( + snapshot: StableSlotLayoutSnapshot +): StableSlotLayoutValidationResult { + if (!snapshot.leadNodeId) { + return { valid: false, reason: 'missing leadNodeId' }; + } + const staticRectValidation = validateStaticSnapshotRects(snapshot); + if (staticRectValidation) { + return staticRectValidation; + } + + const leadRectValidation = validateLeadSnapshotRects(snapshot); + if (leadRectValidation) { + return leadRectValidation; + } + + const seenOwnerIds = new Set(); + const seenAssignments = new Set(); + for (const frame of snapshot.memberSlotFrames) { + const frameValidation = validateMemberSlotFrame( + frame, + snapshot, + seenOwnerIds, + seenAssignments + ); + if (frameValidation) { + return frameValidation; + } + } + + const overlapValidation = validateMemberFrameOverlaps(snapshot.memberSlotFrames); + if (overlapValidation) { + return overlapValidation; + } + + return { valid: true }; +} + +function validateStaticSnapshotRects( + snapshot: StableSlotLayoutSnapshot +): StableSlotLayoutValidationResult | null { + const staticRects: [string, StableRect][] = [ + ['leadCoreRect', snapshot.leadCoreRect], + ['leadActivityRect', snapshot.leadActivityRect], + ['launchHudRect', snapshot.launchHudRect], + ['leadCentralReservedBlock', snapshot.leadCentralReservedBlock], + ['runtimeCentralExclusion', snapshot.runtimeCentralExclusion], + ['fitBounds', snapshot.fitBounds], + ]; + + if (snapshot.unassignedTaskRect) { + staticRects.push(['unassignedTaskRect', snapshot.unassignedTaskRect]); + } + + for (const [name, rect] of staticRects) { + if (!isFiniteRect(rect)) { + return { valid: false, reason: `${name} contains non-finite geometry` }; + } + } + + if (snapshot.fitBounds.width <= 0 || snapshot.fitBounds.height <= 0) { + return { valid: false, reason: 'fitBounds must be non-zero' }; + } + + return null; +} + +function validateLeadSnapshotRects( + snapshot: StableSlotLayoutSnapshot +): StableSlotLayoutValidationResult | null { + if (!rectContainsRect(snapshot.leadCentralReservedBlock, snapshot.leadCoreRect)) { + return { valid: false, reason: 'leadCoreRect must fit inside leadCentralReservedBlock' }; + } + if (!rectContainsRect(snapshot.leadCentralReservedBlock, snapshot.leadActivityRect)) { + return { valid: false, reason: 'leadActivityRect must fit inside leadCentralReservedBlock' }; + } + if (!rectContainsRect(snapshot.leadCentralReservedBlock, snapshot.launchHudRect)) { + return { valid: false, reason: 'launchHudRect must fit inside leadCentralReservedBlock' }; + } + if (!rectContainsRect(snapshot.runtimeCentralExclusion, snapshot.leadCentralReservedBlock)) { + return { valid: false, reason: 'runtimeCentralExclusion must contain leadCentralReservedBlock' }; + } + if ( + snapshot.unassignedTaskRect && + !rectContainsRect(snapshot.runtimeCentralExclusion, snapshot.unassignedTaskRect) + ) { + return { valid: false, reason: 'runtimeCentralExclusion must contain unassignedTaskRect' }; + } + + return null; +} + +function validateMemberSlotFrame( + frame: SlotFrame, + snapshot: StableSlotLayoutSnapshot, + seenOwnerIds: Set, + seenAssignments: Set +): StableSlotLayoutValidationResult | null { + if (!isFiniteRect(frame.bounds)) { + return { valid: false, reason: `slot frame for ${frame.ownerId} contains non-finite bounds` }; + } + if (!Number.isFinite(frame.ownerX) || !Number.isFinite(frame.ownerY)) { + return { valid: false, reason: `slot frame for ${frame.ownerId} contains non-finite anchor` }; + } + if (seenOwnerIds.has(frame.ownerId)) { + return { valid: false, reason: `duplicate owner frame for ${frame.ownerId}` }; + } + seenOwnerIds.add(frame.ownerId); + + const assignmentKey = `${frame.ringIndex}:${frame.sectorIndex}`; + if (seenAssignments.has(assignmentKey)) { + return { valid: false, reason: `duplicate slot assignment ${assignmentKey}` }; + } + seenAssignments.add(assignmentKey); + + if (rectsOverlap(frame.bounds, snapshot.runtimeCentralExclusion)) { + return { valid: false, reason: `slot frame for ${frame.ownerId} overlaps runtimeCentralExclusion` }; + } + if (!rectContainsRect(frame.bounds, frame.activityRect)) { + return { valid: false, reason: `activityRect escapes slot bounds for ${frame.ownerId}` }; + } + if (!rectContainsRect(frame.bounds, frame.processBandRect)) { + return { valid: false, reason: `processBandRect escapes slot bounds for ${frame.ownerId}` }; + } + if (!rectContainsRect(frame.bounds, frame.taskBandRect)) { + return { valid: false, reason: `taskBandRect escapes slot bounds for ${frame.ownerId}` }; + } + if (!pointInRect(frame.ownerX, frame.ownerY, frame.bounds)) { + return { valid: false, reason: `owner anchor escapes slot bounds for ${frame.ownerId}` }; + } + if (!rectContainsRect(snapshot.fitBounds, frame.bounds)) { + return { valid: false, reason: `slot frame for ${frame.ownerId} escapes fitBounds` }; + } + + return null; +} + +function validateMemberFrameOverlaps( + frames: readonly SlotFrame[] +): StableSlotLayoutValidationResult | null { + for (const [index, left] of frames.entries()) { + for (const right of frames.slice(index + 1)) { + if (rectsOverlap(left.bounds, right.bounds)) { + return { + valid: false, + reason: `slot frames overlap: ${left.ownerId} <-> ${right.ownerId}`, + }; + } + } + } + return null; +} + +export function translateSlotFrame(frame: SlotFrame, dx: number, dy: number): SlotFrame { + return { + ...frame, + bounds: translateRect(frame.bounds, dx, dy), + ownerX: frame.ownerX + dx, + ownerY: frame.ownerY + dy, + activityRect: translateRect(frame.activityRect, dx, dy), + processBandRect: translateRect(frame.processBandRect, dx, dy), + taskBandRect: translateRect(frame.taskBandRect, dx, dy), + }; +} + +export function snapshotToWorldBounds(snapshot: StableSlotLayoutSnapshot): WorldBounds[] { + const bounds: WorldBounds[] = [ + snapshot.fitBounds, + snapshot.leadCentralReservedBlock, + ...snapshot.memberSlotFrames.map((frame) => frame.bounds), + ].map((rect) => ({ + left: rect.left, + top: rect.top, + right: rect.right, + bottom: rect.bottom, + })); + + if (snapshot.unassignedTaskRect) { + bounds.push({ + left: snapshot.unassignedTaskRect.left, + top: snapshot.unassignedTaskRect.top, + right: snapshot.unassignedTaskRect.right, + bottom: snapshot.unassignedTaskRect.bottom, + }); + } + + return bounds; +} + +function buildUnassignedTaskRect( + nodes: GraphNode[], + leadCentralReservedBlock: StableRect +): StableRect | null { + const visibleOwnerIds = new Set( + nodes + .filter((node) => node.kind === 'lead' || node.kind === 'member') + .map((node) => node.id) + ); + const unassignedTasks = nodes.filter( + (node) => + node.kind === 'task' && (!node.ownerId || !visibleOwnerIds.has(node.ownerId)) + ); + if (unassignedTasks.length === 0) { + return null; + } + + const columnCount = new Set(unassignedTasks.map((node) => resolveTaskColumnKey(node))).size; + const width = + columnCount <= 1 + ? TASK_PILL.width + : TASK_PILL.width + (columnCount - 1) * KANBAN_ZONE.columnWidth; + const height = SLOT_GEOMETRY.taskBandHeight; + return createRect( + -width / 2, + leadCentralReservedBlock.bottom + SLOT_GEOMETRY.unassignedGap, + width, + height + ); +} + +function planOwnerSlots( + ownerFootprints: OwnerFootprint[], + centralExclusion: StableRect, + layout?: GraphLayoutPort +): SlotFrame[] { + const placedFrames: SlotFrame[] = []; + const preferredAssignments = buildPreferredAssignmentsMap(layout?.slotAssignments); + const usedSlotKeys = new Set(); + const ringStates = new Map(); + const maxRingExclusive = computePlannerRingLimit(ownerFootprints, layout?.slotAssignments); + + for (const footprint of ownerFootprints) { + const resolvedFrame = resolveOwnerSlotFrame({ + footprint, + centralExclusion, + ringStates, + preferredAssignment: preferredAssignments.get(footprint.ownerId), + usedSlotKeys, + placedFrames, + maxRingExclusive, + }); + placedFrames.push(resolvedFrame); + commitRingPlacement(ringStates, resolvedFrame, footprint); + } + + return placedFrames; +} + +function buildPreferredAssignmentsMap( + assignments?: Record +): Map { + const preferredAssignments = new Map(); + const assignmentOwnersBySlotKey = new Map(); + + for (const [ownerId, assignment] of Object.entries(assignments ?? {})) { + preferredAssignments.set(ownerId, assignment); + const slotKey = buildAssignmentKey(assignment); + const existingOwners = assignmentOwnersBySlotKey.get(slotKey) ?? []; + existingOwners.push(ownerId); + assignmentOwnersBySlotKey.set(slotKey, existingOwners); + } + + for (const [slotKey, owners] of assignmentOwnersBySlotKey) { + if (owners.length > 1) { + console.warn( + `[agent-graph] duplicate saved slot assignment ${slotKey} for owners: ${owners.join(', ')}` + ); + } + } + + return preferredAssignments; +} + +function resolveOwnerSlotFrame(args: { + footprint: OwnerFootprint; + centralExclusion: StableRect; + ringStates: RingLayoutStateMap; + preferredAssignment?: GraphOwnerSlotAssignment; + usedSlotKeys: Set; + placedFrames: readonly SlotFrame[]; + maxRingExclusive: number; +}): SlotFrame { + const { + footprint, + centralExclusion, + ringStates, + preferredAssignment, + usedSlotKeys, + placedFrames, + maxRingExclusive, + } = args; + + const candidates = preferredAssignment + ? buildPreferredCandidateAssignments(preferredAssignment, maxRingExclusive) + : buildCandidateAssignments(maxRingExclusive); + const directMatch = findFirstValidSlotFrame({ + candidateAssignments: candidates, + footprint, + centralExclusion, + ringStates, + usedSlotKeys, + placedFrames, + preferredAssignment, + }); + if (directMatch) { + return directMatch; + } + + const spilloverCandidates = buildCandidateAssignments( + maxRingExclusive + ownerFootprintsSpillBudget(placedFrames.length) + ).filter((assignment) => assignment.ringIndex >= maxRingExclusive); + const spilloverMatch = findFirstValidSlotFrame({ + candidateAssignments: spilloverCandidates, + footprint, + centralExclusion, + ringStates, + usedSlotKeys, + placedFrames, + }); + if (spilloverMatch) { + return spilloverMatch; + } + + return buildEmergencyFallbackSlotFrame({ + footprint, + centralExclusion, + ringStates, + usedSlotKeys, + placedOwnerCount: placedFrames.length, + baseRingIndex: maxRingExclusive + ownerFootprintsSpillBudget(placedFrames.length), + }); +} + +function buildSlotFrame( + footprint: OwnerFootprint, + assignment: GraphOwnerSlotAssignment, + centralExclusion: StableRect, + options: { ringStates: RingLayoutStateMap } +): SlotFrame | null { + const vector = SECTOR_VECTORS[assignment.sectorIndex % SECTOR_VECTORS.length] ?? SECTOR_VECTORS[0]; + const radius = resolveRingRadiusForAssignment({ + assignment, + footprint, + centralExclusion, + ringStates: options.ringStates, + }); + if (radius == null) { + return null; + } + const ownerX = vector.x * radius; + const ownerY = vector.y * radius; + const slotTop = + ownerY - + (SLOT_GEOMETRY.memberSlotInnerPadding + + SLOT_GEOMETRY.activityHeight + + SLOT_GEOMETRY.activityToOwnerGap + + SLOT_GEOMETRY.ownerBandHeight / 2); + const bounds = createRect(ownerX - footprint.slotWidth / 2, slotTop, footprint.slotWidth, footprint.slotHeight); + const activityRect = createRect( + bounds.left + (bounds.width - footprint.activityWidth) / 2, + bounds.top + SLOT_GEOMETRY.memberSlotInnerPadding, + footprint.activityWidth, + footprint.activityHeight + ); + const processBandRect = createRect( + bounds.left + (bounds.width - footprint.processRailWidth) / 2, + activityRect.bottom + + SLOT_GEOMETRY.activityToOwnerGap + + SLOT_GEOMETRY.ownerBandHeight + + SLOT_GEOMETRY.ownerToProcessGap, + footprint.processRailWidth, + SLOT_GEOMETRY.processBandHeight + ); + const taskBandRect = createRect( + bounds.left + (bounds.width - footprint.taskBandWidth) / 2, + processBandRect.bottom + SLOT_GEOMETRY.processToTaskGap, + footprint.taskBandWidth, + footprint.taskBandHeight + ); + + return { + ownerId: footprint.ownerId, + ringIndex: assignment.ringIndex, + sectorIndex: assignment.sectorIndex, + widthBucket: footprint.widthBucket, + bounds, + ownerX, + ownerY, + activityRect, + processBandRect, + taskBandRect, + taskColumnCount: footprint.taskColumnCount, + }; +} + +function buildCandidateAssignments(maxRingExclusive: number): GraphOwnerSlotAssignment[] { + const candidates: GraphOwnerSlotAssignment[] = []; + for (let ringIndex = 0; ringIndex < maxRingExclusive; ringIndex += 1) { + for (let sectorIndex = 0; sectorIndex < SECTOR_VECTORS.length; sectorIndex += 1) { + candidates.push({ ringIndex, sectorIndex }); + } + } + return candidates; +} + +function buildPreferredCandidateAssignments( + preferred: GraphOwnerSlotAssignment, + maxRingExclusive: number +): GraphOwnerSlotAssignment[] { + const ordered: GraphOwnerSlotAssignment[] = [preferred]; + const seen = new Set([`${preferred.ringIndex}:${preferred.sectorIndex}`]); + const sectorOrder = buildSectorPreferenceOrder(preferred.sectorIndex); + + appendSameSectorOuterRingCandidates(ordered, seen, preferred, maxRingExclusive); + appendRingSectorCandidates(ordered, seen, preferred.ringIndex, sectorOrder); + + for (let ringIndex = preferred.ringIndex + 1; ringIndex < maxRingExclusive; ringIndex += 1) { + appendRingSectorCandidates(ordered, seen, ringIndex, sectorOrder); + } + + for (let ringIndex = 0; ringIndex < preferred.ringIndex; ringIndex += 1) { + appendRingSectorCandidates(ordered, seen, ringIndex, sectorOrder); + } + + return ordered; +} + +function computePlannerRingLimit( + ownerFootprints: readonly OwnerFootprint[], + assignments?: Record +): number { + const maxAssignedRing = Object.values(assignments ?? {}).reduce( + (max, assignment) => Math.max(max, assignment.ringIndex), + 0 + ); + return Math.max( + SLOT_GEOMETRY.maxGeneratedRings, + maxAssignedRing + ownerFootprints.length + 2 + ); +} + +function ownerFootprintsSpillBudget(placedOwnerCount: number): number { + return Math.max(6, placedOwnerCount + 2); +} + +function buildEmergencyFallbackSlotFrame(args: { + footprint: OwnerFootprint; + centralExclusion: StableRect; + ringStates: RingLayoutStateMap; + usedSlotKeys: Set; + placedOwnerCount: number; + baseRingIndex: number; +}): SlotFrame { + const assignment = { + ringIndex: args.baseRingIndex + args.placedOwnerCount, + sectorIndex: 0, + }; + args.usedSlotKeys.add(buildAssignmentKey(assignment)); + const frame = buildSlotFrame(args.footprint, assignment, args.centralExclusion, { + ringStates: args.ringStates, + }); + if (!frame) { + throw new Error(`failed to build emergency fallback slot frame for ${args.footprint.ownerId}`); + } + return frame; +} + +function rankNearestSlotAssignmentResult(args: { + assignment: GraphOwnerSlotAssignment; + occupiedFrame: SlotFrame | undefined; + footprint: OwnerFootprint; + footprintByOwnerId: ReadonlyMap; + currentFrame: SlotFrame; + existingFrames: readonly SlotFrame[]; + runtimeCentralExclusion: StableRect; + ringStates: RingLayoutStateMap; + pointerX: number; + pointerY: number; +}): RankedNearestSlotAssignmentResult | null { + const { + assignment, + occupiedFrame, + footprint, + footprintByOwnerId, + currentFrame, + existingFrames, + runtimeCentralExclusion, + ringStates, + pointerX, + pointerY, + } = args; + const frame = buildSlotFrame(footprint, assignment, runtimeCentralExclusion, { + ringStates, + }); + if (!frame) { + return null; + } + + if (occupiedFrame) { + const displacedFrame = buildDisplacedFrameForNearestAssignment({ + occupiedFrame, + footprintByOwnerId, + currentFrame, + runtimeCentralExclusion, + ringStates, + }); + if (!displacedFrame) { + return null; + } + const otherFrames = existingFrames.filter((existing) => existing.ownerId !== occupiedFrame.ownerId); + if ( + !isSlotFramePlacementValid(frame, otherFrames, runtimeCentralExclusion) || + !isSlotFramePlacementValid(displacedFrame, otherFrames, runtimeCentralExclusion) || + rectsOverlapWithGap(frame.bounds, displacedFrame.bounds, SLOT_GEOMETRY.ringPadding) + ) { + return null; + } + return buildRankedNearestSlotAssignmentResult({ + assignment, + frame, + pointerX, + pointerY, + displacedOwnerId: occupiedFrame.ownerId, + displacedAssignment: { + ringIndex: currentFrame.ringIndex, + sectorIndex: currentFrame.sectorIndex, + }, + }); + } + + if (!isSlotFramePlacementValid(frame, existingFrames, runtimeCentralExclusion)) { + return null; + } + + return buildRankedNearestSlotAssignmentResult({ + assignment, + frame, + pointerX, + pointerY, + }); +} + +function buildDisplacedFrameForNearestAssignment(args: { + occupiedFrame: SlotFrame; + footprintByOwnerId: ReadonlyMap; + currentFrame: SlotFrame; + runtimeCentralExclusion: StableRect; + ringStates: RingLayoutStateMap; +}): SlotFrame | null { + const displacedFootprint = args.footprintByOwnerId.get(args.occupiedFrame.ownerId); + if (!displacedFootprint) { + return null; + } + return buildSlotFrame( + displacedFootprint, + { + ringIndex: args.currentFrame.ringIndex, + sectorIndex: args.currentFrame.sectorIndex, + }, + args.runtimeCentralExclusion, + { ringStates: args.ringStates } + ); +} + +function buildRankedNearestSlotAssignmentResult(args: { + assignment: GraphOwnerSlotAssignment; + frame: SlotFrame; + pointerX: number; + pointerY: number; + displacedOwnerId?: string; + displacedAssignment?: GraphOwnerSlotAssignment; +}): RankedNearestSlotAssignmentResult { + const dx = args.frame.ownerX - args.pointerX; + const dy = args.frame.ownerY - args.pointerY; + return { + assignment: args.assignment, + displacedOwnerId: args.displacedOwnerId, + displacedAssignment: args.displacedAssignment, + distanceSquared: dx * dx + dy * dy, + }; +} + +function findFirstValidSlotFrame(args: { + candidateAssignments: readonly GraphOwnerSlotAssignment[]; + footprint: OwnerFootprint; + centralExclusion: StableRect; + ringStates: RingLayoutStateMap; + usedSlotKeys: Set; + placedFrames: readonly SlotFrame[]; + preferredAssignment?: GraphOwnerSlotAssignment; +}): SlotFrame | null { + for (const assignment of args.candidateAssignments) { + const frame = tryBuildValidSlotFrame(args, assignment); + if (frame) { + return frame; + } + } + return null; +} + +function tryBuildValidSlotFrame( + args: { + footprint: OwnerFootprint; + centralExclusion: StableRect; + ringStates: RingLayoutStateMap; + usedSlotKeys: Set; + placedFrames: readonly SlotFrame[]; + preferredAssignment?: GraphOwnerSlotAssignment; + }, + assignment: GraphOwnerSlotAssignment +): SlotFrame | null { + const slotKey = buildAssignmentKey(assignment); + if (args.usedSlotKeys.has(slotKey) && !isSameAssignment(args.preferredAssignment, assignment)) { + return null; + } + const frame = buildSlotFrame(args.footprint, assignment, args.centralExclusion, { + ringStates: args.ringStates, + }); + if (!frame) { + return null; + } + if ( + args.placedFrames.some((existing) => + rectsOverlapWithGap(existing.bounds, frame.bounds, SLOT_GEOMETRY.ringPadding) + ) + ) { + return null; + } + args.usedSlotKeys.add(slotKey); + return frame; +} + +function appendSameSectorOuterRingCandidates( + ordered: GraphOwnerSlotAssignment[], + seen: Set, + preferred: GraphOwnerSlotAssignment, + maxRingExclusive: number +): void { + for (let ringIndex = preferred.ringIndex + 1; ringIndex < maxRingExclusive; ringIndex += 1) { + appendUniqueCandidate(ordered, seen, { ringIndex, sectorIndex: preferred.sectorIndex }); + } +} + +function appendRingSectorCandidates( + ordered: GraphOwnerSlotAssignment[], + seen: Set, + ringIndex: number, + sectorOrder: readonly number[] +): void { + for (const sectorIndex of sectorOrder) { + appendUniqueCandidate(ordered, seen, { ringIndex, sectorIndex }); + } +} + +function appendUniqueCandidate( + ordered: GraphOwnerSlotAssignment[], + seen: Set, + assignment: GraphOwnerSlotAssignment +): void { + const key = `${assignment.ringIndex}:${assignment.sectorIndex}`; + if (seen.has(key)) { + return; + } + ordered.push(assignment); + seen.add(key); +} + +function buildSectorPreferenceOrder(preferredSectorIndex: number): number[] { + const ordered = [preferredSectorIndex]; + for (let distance = 1; distance < SECTOR_VECTORS.length; distance += 1) { + const left = (preferredSectorIndex - distance + SECTOR_VECTORS.length) % SECTOR_VECTORS.length; + const right = (preferredSectorIndex + distance) % SECTOR_VECTORS.length; + if (!ordered.includes(left)) { + ordered.push(left); + } + if (!ordered.includes(right)) { + ordered.push(right); + } + } + return ordered; +} + +function buildRingStatesFromFrames( + frames: readonly SlotFrame[], + footprintByOwnerId: ReadonlyMap +): Map { + const ringStates = new Map(); + for (const frame of frames) { + const footprint = footprintByOwnerId.get(frame.ownerId); + if (!footprint) { + continue; + } + commitRingPlacement(ringStates, frame, footprint); + } + return ringStates; +} + +function commitRingPlacement( + ringStates: Map, + frame: SlotFrame, + footprint: OwnerFootprint +): void { + const radius = resolveFrameRingRadius(frame); + const vector = SECTOR_VECTORS[frame.sectorIndex % SECTOR_VECTORS.length] ?? SECTOR_VECTORS[0]; + const { outwardDepth } = computeSlotDirectionalDepths(footprint, vector); + const key = buildSectorRingStateKey(frame.sectorIndex, frame.ringIndex); + const existing = ringStates.get(key); + if (!existing) { + ringStates.set(key, { + radius, + outwardDepth, + }); + return; + } + + ringStates.set(key, { + radius: Math.max(existing.radius, radius), + outwardDepth: Math.max(existing.outwardDepth, outwardDepth), + }); +} + +function resolveFrameRingRadius(frame: SlotFrame): number { + const vector = SECTOR_VECTORS[frame.sectorIndex % SECTOR_VECTORS.length] ?? SECTOR_VECTORS[0]; + if (Math.abs(vector.x) >= Math.abs(vector.y) && Math.abs(vector.x) > 0.001) { + return Math.abs(frame.ownerX / vector.x); + } + if (Math.abs(vector.y) > 0.001) { + return Math.abs(frame.ownerY / vector.y); + } + return Math.hypot(frame.ownerX, frame.ownerY); +} + +function computeSlotDirectionalDepths( + footprint: OwnerFootprint, + vector: { x: number; y: number } +): { outwardDepth: number; inwardDepth: number } { + const ownerLocalY = + SLOT_GEOMETRY.memberSlotInnerPadding + + SLOT_GEOMETRY.activityHeight + + SLOT_GEOMETRY.activityToOwnerGap + + SLOT_GEOMETRY.ownerBandHeight / 2; + const topOffset = -ownerLocalY; + const bottomOffset = footprint.slotHeight - ownerLocalY; + const halfWidth = footprint.slotWidth / 2; + const vectorLength = Math.hypot(vector.x, vector.y) || 1; + const unitX = vector.x / vectorLength; + const unitY = vector.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); + + return { + outwardDepth: Math.max(...cornerProjections), + inwardDepth: Math.max(...cornerProjections.map((projection) => -projection)), + }; +} + +function resolveRingRadiusForAssignment(args: { + assignment: GraphOwnerSlotAssignment; + footprint: OwnerFootprint; + centralExclusion: StableRect; + ringStates: RingLayoutStateMap; +}): number | null { + const vector = + SECTOR_VECTORS[args.assignment.sectorIndex % SECTOR_VECTORS.length] ?? SECTOR_VECTORS[0]; + const minRadius = computeMinimumRingRadius(vector, args.footprint, args.centralExclusion); + const directionalDepths = computeSlotDirectionalDepths(args.footprint, vector); + const ringState = resolveVirtualRingState( + args.assignment.sectorIndex, + args.assignment.ringIndex, + minRadius, + directionalDepths, + args.ringStates + ); + + return minRadius <= ringState.radius + 0.001 ? ringState.radius : null; +} + +function resolveVirtualRingState( + sectorIndex: number, + ringIndex: number, + minRadius: number, + directionalDepths: { outwardDepth: number; inwardDepth: number }, + ringStates: RingLayoutStateMap +): RingLayoutState { + const existing = ringStates.get(buildSectorRingStateKey(sectorIndex, ringIndex)); + if (existing) { + return existing; + } + if (ringIndex === 0) { + return { + radius: minRadius, + outwardDepth: directionalDepths.outwardDepth, + }; + } + + const previous = resolveVirtualRingState( + sectorIndex, + ringIndex - 1, + minRadius, + directionalDepths, + ringStates + ); + return { + radius: Math.max( + minRadius, + previous.radius + + previous.outwardDepth + + directionalDepths.inwardDepth + + SLOT_GEOMETRY.ringGap + ), + outwardDepth: directionalDepths.outwardDepth, + }; +} + +function buildSectorRingStateKey(sectorIndex: number, ringIndex: number): string { + return `${sectorIndex}:${ringIndex}`; +} + +function computeMinimumRingRadius( + vector: { x: number; y: number }, + footprint: OwnerFootprint, + centralExclusion: StableRect +): number { + const horizontalExtent = + vector.x >= 0 ? centralExclusion.right : Math.abs(centralExclusion.left); + const verticalExtent = vector.y >= 0 ? centralExclusion.bottom : Math.abs(centralExclusion.top); + const requiredX = + Math.abs(vector.x) > 0.001 + ? (horizontalExtent + footprint.slotWidth / 2 + SLOT_GEOMETRY.ringPadding) / Math.abs(vector.x) + : 0; + const requiredY = + Math.abs(vector.y) > 0.001 + ? (verticalExtent + footprint.slotHeight / 2 + SLOT_GEOMETRY.ringPadding) / Math.abs(vector.y) + : 0; + return Math.max(requiredX, requiredY, 0); +} + +function resolveTaskColumnKey(task: GraphNode): string { + if (task.reviewState === 'approved') return 'approved'; + if (task.reviewState === 'review' || task.reviewState === 'needsFix') return 'review'; + if (task.taskStatus === 'completed') return 'done'; + if (task.taskStatus === 'in_progress') return 'wip'; + return 'todo'; +} + +function rectsOverlapWithGap(a: StableRect, b: StableRect, gap: number): boolean { + return ( + a.left - gap < b.right && + a.right + gap > b.left && + a.top - gap < b.bottom && + a.bottom + gap > b.top + ); +} + +function rectsOverlap(a: StableRect, b: StableRect): boolean { + return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top; +} + +function rectContainsRect(outer: StableRect, inner: StableRect): boolean { + return ( + inner.left >= outer.left && + inner.right <= outer.right && + inner.top >= outer.top && + inner.bottom <= outer.bottom + ); +} + +function pointInRect(x: number, y: number, rect: StableRect): boolean { + return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom; +} + +function isFiniteRect(rect: StableRect): boolean { + return ( + Number.isFinite(rect.left) && + Number.isFinite(rect.top) && + Number.isFinite(rect.right) && + Number.isFinite(rect.bottom) && + Number.isFinite(rect.width) && + Number.isFinite(rect.height) + ); +} + +function isSlotFramePlacementValid( + frame: SlotFrame, + existingFrames: readonly SlotFrame[], + runtimeCentralExclusion: StableRect +): boolean { + if (!isFiniteRect(frame.bounds)) { + return false; + } + if (rectsOverlap(frame.bounds, runtimeCentralExclusion)) { + return false; + } + return !existingFrames.some((existing) => + rectsOverlapWithGap(frame.bounds, existing.bounds, SLOT_GEOMETRY.ringPadding) + ); +} + +function buildAssignmentKey(assignment: GraphOwnerSlotAssignment): string { + return `${assignment.ringIndex}:${assignment.sectorIndex}`; +} + +function isSameAssignment( + left: GraphOwnerSlotAssignment | undefined, + right: GraphOwnerSlotAssignment +): boolean { + return ( + left?.ringIndex === right.ringIndex && + left?.sectorIndex === right.sectorIndex + ); +} + +function createRect(left: number, top: number, width: number, height: number): StableRect { + return { + left, + top, + right: left + width, + bottom: top + height, + width, + height, + }; +} + +function createCenteredRect(centerX: number, centerY: number, width: number, height: number): StableRect { + return createRect(centerX - width / 2, centerY - height / 2, width, height); +} + +function padRect(rect: StableRect, padding: number): StableRect { + return createRect(rect.left - padding, rect.top - padding, rect.width + padding * 2, rect.height + padding * 2); +} + +function translateRect(rect: StableRect, dx: number, dy: number): StableRect { + return createRect(rect.left + dx, rect.top + dy, rect.width, rect.height); +} + +function unionRects(rects: StableRect[]): StableRect { + const left = Math.min(...rects.map((rect) => rect.left)); + const top = Math.min(...rects.map((rect) => rect.top)); + const right = Math.max(...rects.map((rect) => rect.right)); + const bottom = Math.max(...rects.map((rect) => rect.bottom)); + return createRect(left, top, right - left, bottom - top); +} diff --git a/packages/agent-graph/src/ports/GraphDataPort.ts b/packages/agent-graph/src/ports/GraphDataPort.ts index ec4c5ce0..965cd8a1 100644 --- a/packages/agent-graph/src/ports/GraphDataPort.ts +++ b/packages/agent-graph/src/ports/GraphDataPort.ts @@ -1,4 +1,4 @@ -import type { GraphNode, GraphEdge, GraphParticle } from './types'; +import type { GraphNode, GraphEdge, GraphParticle, GraphLayoutPort } from './types'; /** * Data provider port — supplies graph state to the visualization. @@ -17,4 +17,6 @@ export interface GraphDataPort { teamColor?: string; /** Whether the team lead process is alive */ isAlive?: boolean; + /** Stable owner-slot layout hints supplied by the host app */ + layout?: GraphLayoutPort; } diff --git a/packages/agent-graph/src/ports/index.ts b/packages/agent-graph/src/ports/index.ts index 532bc497..8bdc01dd 100644 --- a/packages/agent-graph/src/ports/index.ts +++ b/packages/agent-graph/src/ports/index.ts @@ -5,9 +5,13 @@ export type { GraphNode, GraphEdge, GraphParticle, + GraphActivityItem, GraphNodeKind, GraphNodeState, GraphEdgeType, GraphParticleKind, GraphDomainRef, + GraphOwnerSlotAssignment, + GraphLayoutPort, + GraphLayoutVersion, } from './types'; diff --git a/packages/agent-graph/src/ports/types.ts b/packages/agent-graph/src/ports/types.ts index 931f38e2..df378f75 100644 --- a/packages/agent-graph/src/ports/types.ts +++ b/packages/agent-graph/src/ports/types.ts @@ -48,6 +48,19 @@ export interface GraphActivityItem { authorLabel?: string; } +export type GraphLayoutVersion = 'stable-slots-v1'; + +export interface GraphOwnerSlotAssignment { + ringIndex: number; + sectorIndex: number; +} + +export interface GraphLayoutPort { + version: GraphLayoutVersion; + ownerOrder: string[]; + slotAssignments: Record; +} + // ─── Graph Node ────────────────────────────────────────────────────────────── export interface GraphNode { diff --git a/packages/agent-graph/src/ui/GraphControls.tsx b/packages/agent-graph/src/ui/GraphControls.tsx index 415b13fd..4d0318f4 100644 --- a/packages/agent-graph/src/ui/GraphControls.tsx +++ b/packages/agent-graph/src/ui/GraphControls.tsx @@ -13,6 +13,8 @@ import { EyeOff, Maximize2, Pause, + PanelLeftClose, + PanelLeftOpen, Pin, Play, Plus, @@ -41,6 +43,8 @@ export interface GraphControlsProps { onRequestFullscreen?: () => void; onOpenTeamPage?: () => void; onCreateTask?: () => void; + onToggleSidebar?: () => void; + isSidebarVisible?: boolean; teamName: string; teamColor?: string; isAlive?: boolean; @@ -60,6 +64,8 @@ export function GraphControls({ onRequestFullscreen, onOpenTeamPage, onCreateTask, + onToggleSidebar, + isSidebarVisible = true, teamColor, }: GraphControlsProps): React.JSX.Element { const [isSettingsOpen, setIsSettingsOpen] = useState(false); @@ -100,6 +106,28 @@ export function GraphControls({ return ( <>
+ {onToggleSidebar ? ( +
+ + ) : ( + + ) + } + toolbar + title={isSidebarVisible ? 'Hide sidebar' : 'Show sidebar'} + /> +
+ ) : null} {onOpenTeamPage ? (
void; onOpenTeamPage?: () => void; onCreateTask?: () => void; + onToggleSidebar?: () => void; + isSidebarVisible?: boolean; + onOwnerSlotDrop?: (payload: { + nodeId: string; + assignment: GraphOwnerSlotAssignment; + displacedNodeId?: string; + displacedAssignment?: GraphOwnerSlotAssignment; + }) => void; /** Custom overlay renderer — replaces built-in GraphOverlay. Allows host app to reuse its own components. */ renderOverlay?: (props: { node: GraphNode; @@ -90,6 +98,9 @@ export function GraphView({ onRequestFullscreen, onOpenTeamPage, onCreateTask, + onToggleSidebar, + isSidebarVisible = true, + onOwnerSlotDrop, renderOverlay, renderEdgeOverlay, renderHud, @@ -142,18 +153,31 @@ export function GraphView({ ) ); + const getVisibleNodes = useCallback( + (nodes: GraphNode[]): GraphNode[] => + nodes.filter((node) => { + if (node.kind === 'task' && !filters.showTasks) return false; + if (node.kind === 'process' && !filters.showProcesses) return false; + return true; + }), + [filters.showProcesses, filters.showTasks] + ); + + const getVisibleEdges = useCallback( + (edges: GraphEdge[], visibleNodeIds: ReadonlySet): GraphEdge[] => + edges.filter((edge) => { + if (!filters.showEdges && edge.type !== 'parent-child') { + return false; + } + return visibleNodeIds.has(edge.source) && visibleNodeIds.has(edge.target); + }), + [filters.showEdges] + ); + // ─── Sync data from adapter → simulation ──────────────────────────────── useEffect(() => { - const filteredNodes = data.nodes.filter((n) => { - if (n.kind === 'task' && !filters.showTasks) return false; - if (n.kind === 'process' && !filters.showProcesses) return false; - return true; - }); - const filteredEdges = filters.showEdges - ? data.edges - : data.edges.filter((e) => e.type === 'parent-child'); - simulation.updateData(filteredNodes, filteredEdges, data.particles); - }, [data, filters.showTasks, filters.showProcesses, filters.showEdges, simulation]); + simulation.updateData(data.nodes, data.edges, data.particles, data.teamName, data.layout); + }, [data, simulation]); // ─── UNIFIED RAF LOOP: tick simulation + draw canvas ──────────────────── const focusState = useMemo( @@ -247,7 +271,7 @@ export function GraphView({ return null; } const node = simulationRef.current.stateRef.current.nodes.find((candidate) => candidate.id === nodeId); - if (!node || node.x == null || node.y == null) { + if (node?.x == null || node?.y == null) { return null; } const transform = cameraRef.current.transformRef.current; @@ -261,7 +285,7 @@ export function GraphView({ }, [getViewportSize]); const getNodeWorldPosition = useCallback((nodeId: string) => { const node = simulationRef.current.stateRef.current.nodes.find((candidate) => candidate.id === nodeId); - if (!node || node.x == null || node.y == null) { + if (node?.x == null || node?.y == null) { return null; } return { x: node.x, y: node.y }; @@ -285,12 +309,15 @@ export function GraphView({ // 3. Draw every frame: background stars and shooting stars need continuous motion. const state = simulationRef.current.stateRef.current; + const visibleNodes = getVisibleNodes(state.nodes); + const visibleNodeIds = new Set(visibleNodes.map((node) => node.id)); + const visibleEdges = getVisibleEdges(state.edges, visibleNodeIds); // 4. Draw canvas imperatively (NO React re-render) canvasHandle.current?.draw({ teamName: data.teamName, - nodes: state.nodes, - edges: state.edges, + nodes: visibleNodes, + edges: visibleEdges, particles: state.particles, effects: state.effects, time: state.time, @@ -304,8 +331,14 @@ export function GraphView({ }); rafRef.current = requestAnimationFrame(animate); - // eslint-disable-next-line react-hooks/exhaustive-deps -- all data read from .current refs - }, [focusState.focusEdgeIds, focusState.focusNodeIds, interaction.hoveredNodeId]); + }, [ + data.teamName, + focusState.focusEdgeIds, + focusState.focusNodeIds, + getVisibleEdges, + getVisibleNodes, + interaction.hoveredNodeId, + ]); // Start/stop RAF useEffect(() => { @@ -401,8 +434,9 @@ export function GraphView({ if (!canvas) return; const rect = canvas.getBoundingClientRect(); const world = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top); - const nodes = simulation.stateRef.current.nodes; - const edges = simulation.stateRef.current.edges; + 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 nodeMap = getNodeMap(nodes); const interactiveEdges = getInteractiveEdges(canvas, nodes, edges); @@ -433,7 +467,16 @@ export function GraphView({ } } }, - [camera, getInteractiveEdges, getNodeMap, interaction, markUserInteracted, simulation.stateRef] + [ + camera, + getInteractiveEdges, + getNodeMap, + getVisibleEdges, + getVisibleNodes, + interaction, + markUserInteracted, + simulation.stateRef, + ] ); const handleMouseMove = useCallback( @@ -448,7 +491,7 @@ export function GraphView({ if (!canvas) return; const rect = canvas.getBoundingClientRect(); const world = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top); - interaction.handleMouseMove(world.x, world.y, simulation.stateRef.current.nodes); + interaction.handleMouseMove(world.x, world.y, getVisibleNodes(simulation.stateRef.current.nodes)); return; } @@ -457,8 +500,9 @@ export function GraphView({ if (!canvas) return; const rect = canvas.getBoundingClientRect(); const world = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top); - const nodes = simulation.stateRef.current.nodes; - const edges = simulation.stateRef.current.edges; + 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; @@ -474,11 +518,14 @@ export function GraphView({ hoveredEdgeIdRef.current = findEdgeAt(world.x, world.y, interactiveEdges, nodeMap); canvas.style.cursor = hoveredEdgeIdRef.current ? 'pointer' : 'grab'; }, - [camera, getInteractiveEdges, getNodeMap, interaction, simulation.stateRef] + [camera, getInteractiveEdges, getNodeMap, getVisibleEdges, getVisibleNodes, interaction, simulation.stateRef] ); const handleMouseUp = useCallback( (e: React.MouseEvent) => { + const draggedNodeId = interaction.dragNodeId.current; + const wasDragging = interaction.isDragging.current; + if (isPanningRef.current) { camera.handlePanEnd(); isPanningRef.current = false; @@ -489,6 +536,33 @@ export function GraphView({ } const clickedId = interaction.handleMouseUp(); + if (wasDragging && draggedNodeId) { + const draggedNode = simulation.stateRef.current.nodes.find((node) => node.id === draggedNodeId); + if (draggedNode?.kind === 'member' && draggedNode.x != null && draggedNode.y != null) { + const nearest = simulation.resolveNearestOwnerSlot( + draggedNodeId, + draggedNode.x, + draggedNode.y + ); + if (nearest) { + onOwnerSlotDrop?.({ + nodeId: draggedNodeId, + assignment: nearest.assignment, + displacedNodeId: nearest.displacedOwnerId, + displacedAssignment: nearest.displacedAssignment, + }); + requestAnimationFrame(() => { + simulation.clearNodePosition(draggedNodeId); + }); + edgeMouseDownRef.current = null; + return; + } + } + simulation.clearNodePosition(draggedNodeId); + edgeMouseDownRef.current = null; + return; + } + if (clickedId) { setSelectedNodeId(clickedId); setSelectedEdgeId(null); @@ -526,7 +600,7 @@ export function GraphView({ } } }, - [interaction, simulation.stateRef, events, camera, data.teamName] + [camera, data.teamName, events, interaction, onOwnerSlotDrop, simulation] ); const handleDoubleClick = useCallback( @@ -538,7 +612,7 @@ export function GraphView({ const nodeId = interaction.handleDoubleClick( world.x, world.y, - simulation.stateRef.current.nodes + getVisibleNodes(simulation.stateRef.current.nodes) ); if (nodeId) { setSelectedEdgeId(null); @@ -553,7 +627,7 @@ export function GraphView({ } } }, - [camera, interaction, simulation.stateRef, events] + [camera, events, getVisibleNodes, interaction, simulation.stateRef] ); // ─── Keyboard ─────────────────────────────────────────────────────────── @@ -598,10 +672,6 @@ export function GraphView({ const selectedEdge: GraphEdge | null = selectedEdgeId ? (simulation.stateRef.current.edges.find((edge) => edge.id === selectedEdgeId) ?? null) : null; - const hasBlockingEdges = useMemo( - () => data.edges.some((edge) => edge.type === 'blocking'), - [data.edges] - ); const selectedEdgeNodeMap = useMemo( () => getNodeMap(simulation.stateRef.current.nodes), [data.nodes, getNodeMap, selectedEdgeId, simulation.stateRef] @@ -719,6 +789,8 @@ export function GraphView({ onRequestFullscreen={onRequestFullscreen} onOpenTeamPage={onOpenTeamPage} onCreateTask={onCreateTask} + onToggleSidebar={onToggleSidebar} + isSidebarVisible={isSidebarVisible} teamName={data.teamName} teamColor={data.teamColor} isAlive={data.isAlive} diff --git a/src/features/agent-graph/README.md b/src/features/agent-graph/README.md index 70101e02..0c3a4729 100644 --- a/src/features/agent-graph/README.md +++ b/src/features/agent-graph/README.md @@ -5,6 +5,7 @@ This feature is a thin renderer slice over the reusable graph engine in `package Read first: - [Feature Architecture Standard](../../docs/FEATURE_ARCHITECTURE_STANDARD.md) - [Feature root guidance](../CLAUDE.md) +- [Stable Slot Layout Plan](./STABLE_SLOT_LAYOUT_PLAN.md) Public entrypoint: - `@features/agent-graph/renderer` diff --git a/src/features/agent-graph/STABLE_SLOT_LAYOUT_PLAN.md b/src/features/agent-graph/STABLE_SLOT_LAYOUT_PLAN.md new file mode 100644 index 00000000..04402f6c --- /dev/null +++ b/src/features/agent-graph/STABLE_SLOT_LAYOUT_PLAN.md @@ -0,0 +1,2846 @@ +# Agent Graph - Stable Slot Layout Plan + +## Статус + +- Дата фиксации: `2026-04-15` +- Выбранный подход: `Variant 3 - stable sectors around lead` +- Оценка выбранного подхода: `🎯 8 🛡️ 9 🧠 8` +- Примерный объём: `500-1100 строк + тесты + cleanup` +- Тип документа: `implementation spec` + +Этот документ считается **нормативным** для следующей большой переделки layout графа. + +Если в документе что-то сформулировано как "фиксируем", "обязательно", "не делаем", это уже не brainstorming, а зафиксированное решение. Переоткрывать такие решения в процессе реализации не нужно, если только не найден новый критичный риск. + +## Нормативность разделов + +Чтобы не было путаницы между spec и advisory-текстом, фиксируем это явно. + +### Нормативные разделы + +Нормативными считаются все разделы, которые описывают: + +- model / topology +- stable identity +- storage/source of truth +- geometry contracts +- planner rules +- validation / commit behavior +- drag / fit / filter semantics +- acceptance criteria +- test plan + +Именно они определяют, как feature должна работать. + +### Advisory разделы + +Advisory-разделами считаются: + +- `Recommended PR split` +- `Что ещё можно тюнить без изменения архитектуры` +- примеры псевдокода, если они не противоречат более строгим правилам выше + +Они помогают выполнять refactor, но не имеют права переопределять core rules. + +### Правило при конфликте + +Если пример, псевдокод или PR-split визуально расходятся с более строгим invariant/rule/acceptance пунктом, побеждает более строгий invariant/rule/acceptance пункт. + +## TL;DR + +Мы уходим от текущей полусвободной схемы, где owner-зоны лечатся локальными packer-ами и overlay-хитростями, и переходим к более стабильной модели: + +- `lead` остаётся в центре +- каждый `member` получает **свой slot** +- slots распределяются **по стабильным секторам вокруг lead** +- если места не хватает, используется **second ring**, а при необходимости и следующие outer rings +- внутри каждого slot layout всегда один и тот же: + - `Activity` сверху + - `Member` в центре + - `Process` как attached sub-rail + - `Tasks` снизу +- drag сохраняет не `x/y`, а `slot assignment` +- `tasks` bounded по высоте до `5` visible rows +- `activity` bounded до `3` items +- все active non-empty kanban columns показываются, а `slot width` увеличивается, чтобы их вместить + +Главный результат, который должен почувствовать пользователь: + +- у каждого участника есть своё место +- `Activity` и `Tasks` больше не налезают на чужие зоны +- zoom/pan больше не создают ощущение, что owner-local UI "плавает" отдельно от диаграммы +- dense teams читаются стабильнее и предсказуемее + +## Quick decision table + +Чтобы implementer не листал весь документ ради очевидного ответа, фиксируем самые важные развилки прямо здесь. + +- `lead` - не обычный member slot, а `central reserved block` +- `member` - получает `member sector slot` +- `unassigned tasks` - получают `special slot` под lead только если такие задачи реально есть в dataset +- `showTasks/showProcesses/showEdges` - скрывают presentation, но не меняют layout topology +- `graph tab/fullscreen` - шарят layout state, но могут иметь разный camera state +- `slotAssignmentsByTeam` - хранит только member sector assignments +- `teamName` в `v1` - storage scope key для layout state +- `member.name -> agentId` - one-time migration, если version не сбрасывался +- `team rename` - автоматическая миграция layout state не входит в этот refactor +- `no lead in dataset` - новый planner не должен пытаться строить stable sectors без lead + +## State transition matrix + +Это краткая operational-шпаргалка: какое событие что именно имеет право менять. + +| Событие | Меняется owner set | Меняется slot assignment | Меняется camera state | Нужен planner run | Примечание | +|---|---|---:|---:|---:|---| +| `zoom / pan` | нет | нет | да | нет | только camera transform | +| `showTasks / showProcesses / showEdges` | нет | нет | нет | нет | меняется только presentation | +| новый `message/comment` без роста footprint | нет | нет | нет | нет | activity content update only | +| process content update без роста reserved band | нет | нет | нет | нет | process presentation only | +| growth owner footprint | нет | возможно | нет | да | partial replanning only | +| `member add` | да | да | нет | да | новый owner ищет первый valid slot | +| `member remove/hide` | да | нет для остальных | нет | возможно | без global compaction | +| hidden member reappears | да | обычно нет | нет | возможно | сначала пробуем старый assignment | +| drag/drop owner | нет | да | нет | да | snap/swap path | +| `member.name -> agentId` | нет | возможно | нет | возможно | one-time migration before planner | +| team switch | да, scoped | нет | да | нет | просто переключаем team-scoped state | +| team rename | зависит от storage key | как новый scope в `v1` | нет | возможно | auto-migration не входит | +| no lead transient state | dataset invalid для planner | нет | нет | нет | safe fallback only | + +## Coder Start Here + +Если начинать реализацию прямо сейчас, безопасный порядок такой: + +1. Сначала протянуть `agentId` и перевести owner identity на `stableOwnerId` +2. Потом вынести pure planner helpers и покрыть их unit tests +3. Потом добавить `slotAssignmentsByTeam` как новый source of truth +4. Потом интегрировать planner в owner placement +5. Потом привязать `Tasks`, `Process`, `Activity` к `slot frame` +6. Потом сделать drag/snap/swap +7. Потом удалить старые geometry paths + +Нельзя начинать с UI и рисования activity/task zones поверх старой geometry - это почти гарантированно снова создаст двусмысленность и временные баги. + +## Responsibility split by layer + +Чтобы реализация не размазала layout-логику по разным React paths, фиксируем ownership заранее. + +### Data / adapter layer отвечает только за content model + +Сюда относится: + +- `stableOwnerId` +- grouping задач, activity и process-данных по owner-у +- вычисление active non-empty kanban columns +- подготовка content metadata для task/activity/process bands + +Сюда **не** относится: + +- вычисление world positions +- slot assignment +- screen-space correction +- DOM measurement + +### Layout / planner layer отвечает только за topology и geometry + +Сюда относится: + +- `OwnerFootprint` +- `slotAssignmentsByTeam` +- `SlotFrame` +- ring / sector planning +- collision / exclusion validation +- fit bounds + +Сюда **не** относится: + +- отрисовка текста +- hover state +- tooltip placement +- post-render visual "подталкивание" lane-ов + +### Renderer / interaction layer отвечает только за presentation + +Сюда относится: + +- drawing canvas / DOM content внутри уже готового `SlotFrame` +- hit testing +- hover / selection / popover +- drag preview и commit нового assignment + +Сюда **не** относится: + +- решение cross-owner overlap +- самостоятельный пересчёт slot width/height +- отдельный layout path для fullscreen vs tab + +## Persistent vs derived state + +Одна из главных вещей, которую нельзя оставить "по ощущениям" - что именно мы храним, а что каждый раз честно пересчитываем. + +### Persistent state + +Храним только то, что реально должно переживать refresh и reopening: + +- `slotAssignmentsByTeam[teamName][stableOwnerId]` +- `slotLayoutVersion` + +Опционально можно хранить технические timestamp/debug markers, но они не должны становиться источником истины для geometry. + +### Derived state + +Каждый planner run заново считает: + +- `visible owner set` +- `OwnerFootprint` +- `lead central reserved block` +- `runtimeCentralExclusion` +- `SlotFrame[]` +- `fit bounds` +- band-local anchors + +### Session-local last valid snapshot cache + +Для `v1` дополнительно допускается **только session-local** cache последнего валидного `StableSlotLayoutSnapshot` по `teamName`. + +Это не persistent source of truth для placement, а технический safety/cache layer. + +#### Что это значит + +- assignments остаются source of truth для member placement +- snapshot cache не заменяет assignments +- snapshot cache не пишется в config/main/storage как обязательная часть этого refactor +- snapshot cache используется только для safe handoff между planner runs и для fail-closed поведения + +#### Что нельзя делать + +- использовать snapshot cache как новую альтернативу `slotAssignmentsByTeam` +- восстанавливать из него layout identity независимо от текущих assignments +- silently коммитить stale snapshot как будто это новый валидный planner result + +### Что принципиально не должно быть persistent + +- raw `x/y` +- screen-space coordinates +- post-render measured widths/heights +- activity/process/task band positions +- `lead central reserved block` +- `unassigned task slot` + +Иначе layout снова станет зависеть от случайного порядка рендера и старых геометрических хвостов. + +## Hard invariants + +Ниже набор правил, которые нельзя нарушать в реализации даже временно, если только это не изолированный промежуточный локальный WIP. + +- owner placement source of truth - это `slot assignment`, не raw `x/y` +- `lead` всегда центральный anchor +- slot contents всегда upright и не ротируются по sector angle +- `activity band`, `process band`, `task band` bounded по высоте +- все `active non-empty` kanban columns показываются +- slot width может расти, slot height по bands остаётся bounded +- вся slot geometry считается в `world coordinates`, а не в screen-space +- planner не читает размеры из DOM и не зависит от post-render measurement +- zoom/pan меняют только camera transform +- planner сохраняет existing placements whenever still valid +- старый pack/force/pinning path не должен параллельно переопределять новый planner + +## Что не входит в этот план + +Этот документ описывает именно **layout refactor для owner-local zones**. + +В этот pass не нужно заново переоткрывать или смешивать сюда: + +- redesign review semantics +- новые edge interactions +- minimap +- timeline +- particles redesign +- отдельные эксперименты с process content semantics +- отдельные refactor-ы не связанные с owner slot geometry + +Если что-то из этого потребуется, это должен быть отдельный spec, а не тихое расширение этого плана. + +## Зачем нужен новый layout + +Текущая схема уже лучше, чем старый overlay-path, но всё ещё имеет системную проблему: + +- `Activity` и `Tasks` конкурируют за одно и то же пространство +- owner-local зоны удерживаются постфактум через packer и визуальные коррекции +- при большом числе участников диаграмма становится слишком зависимой от текущей геометрии и порядка обновлений +- поведение при refresh, zoom, плотном графе и смене количества задач остаётся недостаточно предсказуемым + +Пользовательский приоритет в этой фиче уже явно выбран: + +- меньше "живой физики" +- больше стабильности +- больше читаемости +- больше правдивой резервации места под owner-local UI + +## Что зафиксировано окончательно + +Ниже список решений, которые уже приняты. + +## 1. Общая модель layout + +- Используем `stable sectors around lead` +- Не возвращаемся к полностью свободной раскладке owners +- Не делаем "одна жёсткая колонка строго вниз" +- `lead` остаётся в центре как отдельный central reserved block +- `members` располагаются вокруг `lead`, и каждый `member` живёт внутри собственного вертикального slot +- `unassigned tasks`, если они есть, живут в отдельном нижнем bounded slot под `lead` + +## 1.1. Кто получает layout-reserved место + +Чтобы не было разной трактовки состава layout actors, фиксируем это явно. + +### Central reserved block получает + +- `lead` + +`Lead` получает не обычный `member slot`, а свой отдельный центральный reserved block. + +### Sector slot получают + +- каждый **видимый активный member**, который участвует в текущем graph owner set + +### Conditional special slot получает + +- `unassigned tasks`, если в текущем visible graph dataset есть хотя бы одна такая задача + +### Slot не получают как самостоятельные owners + +- tasks +- processes +- particles +- edges +- removed members, если они не должны быть видимы по текущему graph visibility rule + +То есть: + +- у `member` есть sector slot +- у `lead` есть central reserved block +- у `unassigned tasks` есть отдельный special slot только когда он реально нужен +- `tasks/process/activity` - это внутренние band-ы slot-а или central block-а, а не самостоятельные owners + +## 2. Lead + +- `lead` остаётся центральным anchor +- `lead` не является обычным `member slot` +- `lead` не участвует в обычном drag/snap flow +- `lead` всегда остаётся в центре layout +- `launch HUD` для `lead` остаётся отдельной reserved zone +- `lead activity` тоже остаётся отдельной reserved zone, но входит в central exclusion +- `lead` не участвует в member ring planner как ещё один slot candidate +- `lead` не получает обычный member-style `task band` +- Для `v1` фиксируем default стороны: + - `lead activity` - слева от lead + - `launch HUD` - справа от lead + +### Важное уточнение + +Для `v1` `lead activity` использует те же bounded activity rules: + +- `3` visible items +- `+N more` +- `newest first` + +## 3. Activity + +- `Activity` является частью owner slot, а не отдельным свободным overlay-режимом +- Видимых элементов: `3` +- Порядок: **newest first** +- После них показывается `+N more` +- `+N more` открывает профиль участника сразу на вкладке `Activity` +- В activity feed попадают и `messages`, и `comments` +- У комментария target - это `task`, а не другой участник +- Для activity нужно переиспользовать уже существующий compact UI сообщений, а не придумывать новый визуальный язык + +## 4. Tasks + +- Task area живёт внутри owner slot +- Сохраняем current multi-column kanban semantics +- Видимая высота task band ограничена `5` rows +- Все **активные non-empty** kanban columns показываются +- Если колонок больше, `slot width` увеличивается +- Не вводим scroll внутри slot как основную механику +- Не скрываем колонки только ради того, чтобы "поместилось" +- Внутри колонки сохраняем текущую каноническую task order semantics, а не придумываем новый sort-rule в рамках этого refactor + +## 4.1. Unassigned tasks + +Так как в системе могут быть задачи без owner-а, это тоже нужно зафиксировать. + +### Правило для v1 + +- задачи без owner-а не теряются +- они не распределяются случайно по member slots +- для них используется отдельный `unassigned task slot` + +### Где он живёт + +- `unassigned task slot` располагается в нижней части central area, под lead +- он не участвует в member ring placement +- он не draggable +- он создаётся только если есть хотя бы одна видимая unassigned task + +### Что происходит при его появлении или исчезновении + +- это может расширить нижнюю central exclusion зону +- planner может локально вытолкнуть конфликтующие нижние member slots дальше +- это не должно вызывать глобальный reshuffle всех owners + +### Что внутри + +- только `task band` +- без `activity band` +- без `process band` +- bounded по тем же правилам: + - `5 rows` + - все active non-empty columns + - overflow stack per column + +Это отдельный pseudo-owner case, чтобы planner не терял нераспределённые задачи и не пытался притвориться, что их не существует. + +## 5. Process + +- Process остаётся в диаграмме +- Process становится owner-local +- Process отображается как маленький attached sub-rail участника +- Текущий визуальный стиль process хорош и должен быть переиспользован +- Process должен учитываться в slot footprint + +## 6. Drag + +- Drag owner разрешён +- После отпускания owner снапается в ближайший slot +- Если slot занят, по умолчанию делаем `swap` +- Raw `x/y` не сохраняем как источник истины +- Сохраняем именно `slot assignment` + +## 7. Stable identity + +- Основной стабильный идентификатор участника: `config.members[].agentId` +- Fallback: `member.name` +- `ResolvedTeamMember` должен получить `agentId?: string` +- Все layout-решения должны жить на stable owner id, а не на display name + +### Контракт уникальности + +План исходит из того, что внутри одной команды: + +- `agentId` уникален, если он присутствует +- `member.name` уникален в fallback-режиме + +Если это нарушено, реализация не должна тихо склеивать двух owners в один. + +Обязательное поведение: + +- в dev/test - явный assert или error +- в runtime - явный warning/error path без silent merge + +## 8. Width buckets + +- Используем `S / M / L` +- Buckets нужны для packing/planning +- Buckets не имеют права обрезать контент +- Buckets не подменяют реальную ширину выдуманной константой + +## 9. Second ring + +- `Second ring` нужен +- Ring capacity считаем по footprint / budget, а не по тупому member count +- Нельзя строить правило вида "до 10 участников один круг, потом второй" +- В `v1` у каждого ring есть `6` canonical sector anchors, то есть номинально один ring не может вместить больше `6` owners +- Реальная usable capacity ring-а может быть **меньше**, если какие-то slot frames не проходят по footprint / exclusion / gap constraints +- То есть spill на outer ring определяется не условием `memberCount > N`, а реальной валидностью candidate frames +- `Second ring` здесь - это shorthand для outer-ring model. Planner не должен останавливаться ровно на двух rings, если команда реально крупнее + +## 10. Сектора + +- Для `v1` фиксируем `6` секторных направлений вокруг lead +- Порядок секторов - по часовой стрелке, начиная сверху: + - `top` + - `upper-right` + - `lower-right` + - `bottom` + - `lower-left` + - `upper-left` +- Следующий ring использует те же sector ids + +## Уточнения, которые снимают двусмысленность + +Ниже решения, которые нужно понимать **однозначно**, чтобы не было разных трактовок при реализации. + +## 1. Что значит "видно 5 задач" + +Это **не** "5 задач суммарно на весь slot". + +Это значит: + +- task band имеет высоту `5 rows` +- каждая kanban column внутри этого band может использовать максимум `5 visible rows` +- если колонка переполнена, последний visible row превращается в overflow stack + +То есть для overflowing column действует правило: + +- `4 реальных pills + overflow stack` + +Высота task band остаётся постоянной. + +## 2. Что значит "видно 3 activity" + +Это значит: + +- в activity band показываются `3` реальных activity items +- ниже может быть отдельный footer `+N more` +- footer не считается "четвёртым activity item" + +## 3. Что значит "все колонки показываем" + +Это значит: + +- не ограничиваем число видимых **active non-empty** kanban columns +- не вводим horizontal scroll +- не делаем "покажи первые 3, остальные спрячь" +- если активных колонок стало больше, `slot width` должен вырасти + +Это **не** значит, что нужно внезапно начать показывать пустые canonical columns, которых раньше на доске не было видно. Текущее правило "показываем только active non-empty columns" сохраняется. + +### Что считается active non-empty column + +Для этого плана колонка считается `active non-empty`, если в ней есть хотя бы одна task, которая должна быть видима в owner task zone до применения overflow-stack подрезки. + +Проще говоря: + +- пустые канонические колонки не показываем +- колонка с задачами показывается, даже если часть задач ушла в overflow stack + +## 4. Что именно bounded + +Bounded должны быть: + +- высота activity band +- высота process band +- высота task band + +Не bounded: + +- число kanban columns +- общая slot width + +## 5. Когда layout может двигаться + +Layout **может** менять slot placement только в понятных случаях: + +- добавился новый участник +- удалился участник +- пользователь вручную перетащил участника +- owner slot реально вырос по footprint настолько, что текущий ring больше не может его честно вместить +- пользователь явно нажал reset layout + +Во всех остальных случаях layout placement меняться не должен. + +Во всех остальных случаях layout **не должен** перескакивать. + +## 6. Когда layout не должен двигаться + +Layout не должен менять slot placement из-за: + +- zoom +- pan +- нового message/comment +- смены unread count +- появления или исчезновения process rail, если footprint band остаётся в пределах резерва +- rename, если `agentId` тот же +- изменений данных внутри уже существующих visible rows, если slot footprint не меняется + +## Негативные решения - что сознательно не делаем + +Эти варианты сознательно отклонены и не должны "случайно вернуться" в код под видом быстрых фиксов. + +- Не продолжаем лечить owner overlap screen-space packer-ом как основным механизмом +- Не сохраняем raw `x/y` для owner layout +- Не возвращаем owner placement под свободный d3-force +- Не вводим local scroll внутри slot как первую механику +- Не скрываем kanban columns ради fit +- Не оставляем process floating отдельно от owner +- Не делаем агрессивный auto-compact всех owners при каждом member remove + +## Канонические термины + +Чтобы не путаться в названиях при реализации: + +- `owner` - `lead` или `member`, вокруг которого строится локальная зона +- `stableOwnerId` - стабильный id участника, построенный из `agentId` или fallback на `name` +- `slot` - общий разговорный термин для layout-reserved зоны, но в коде лучше не использовать его без уточнения +- `member sector slot` - обычный slot участника, который реально планируется ring/sector planner-ом +- `special slot` - специальная зарезервированная зона, не живущая в member assignments, например `unassigned task slot` +- `central reserved block` - специальная центральная зона lead-а +- `sector` - одно из 6 направлений вокруг lead +- `ring` - круг уровнем дальше от lead +- `slot assignment` - привязка **member sector slot** к `(ringIndex, sectorIndex)` +- `slot frame` - прямоугольник **member sector slot** в world coordinates, если отдельно не сказано иное +- `central exclusion` - запрещённая зона вокруг lead, учитывающая lead, launch HUD и lead activity +- `task band` - нижняя часть slot, где живёт kanban +- `activity band` - верхняя часть slot +- `process band` - узкая зона между owner и task band + +### Runtime central exclusion + +Чтобы не было разной трактовки в коде, под итоговым `runtime central exclusion` в этом документе понимается: + +```ts +runtimeCentralExclusion = + union( + leadCentralReservedBlock, + optionalUnassignedTaskSlot + ) + centralSafetyPadding +``` + +То есть нижний `unassigned task slot`, если он существует, становится частью итоговой центральной запрещённой зоны для member slot planner-а. + +## Целевая визуальная схема + +```text + [ Activity x3 ] + [ Member A ] + [ Process ] + [ Tasks x5 ] + + + [ Activity x3 ] [ Activity x3 ] + [ Member B ] [ Lead ] [ Member C ] + [ Process ] [ Launch HUD ] [ Process ] + [ Tasks x5 ] [ Tasks x5 ] + + + [ Activity x3 ] + [ Member D ] + [ Process ] + [ Tasks x5 ] +``` + +Если owner slots не помещаются в ring 1 честно, следующий owner уходит в ring 2, а не пытается "додавиться" между соседями. + +## Точная структура slot + +Каждый member slot строится по одинаковой схеме. + +### Политика резервации места + +Slot резервирует геометрию под все свои bands **всегда**, даже если в конкретный момент band визуально пустой. + +Это значит: + +- пустой `Activity` band может не рисовать карточки, но его высота зарезервирована +- пустой `Process` band может не рисовать rail, но его высота зарезервирована +- пустой `Task` band может не рисовать tasks, но его высота зарезервирована + +Это осознанный tradeoff ради того, чтобы slot не прыгал по высоте при каждом изменении данных. + +### Slot local anatomy + +1. `Activity band` +2. `Owner band` +3. `Process band` +4. `Task band` + +### Activity band + +- расположен сверху slot +- имеет фиксированную высоту под: + - `3 activity items` + - `+N more footer` +- footer height резервируется всегда, чтобы не было layout jump +- connector к owner рисуется короткий и локальный +- если activity entries сейчас нет, band остаётся пустым визуально, но не схлопывается геометрически + +### Owner band + +- содержит сам `member node` +- owner node является точкой привязки локального layout +- label / role / runtime status остаются на owner node + +### Process band + +- живёт между owner и tasks +- имеет фиксированную высоту +- резервируется всегда, даже если сейчас process не отображается +- это сознательный tradeoff ради стабильности + +### Task band + +- расположен внизу slot +- имеет фиксированную высоту `5 rows` +- содержит все активные non-empty kanban columns +- каждая колонка рендерится внутри общей высоты band +- если task-ов сейчас нет, band остаётся пустым визуально, но не схлопывается геометрически + +## Поведение task overflow + +Так как мы сохраняем multi-column kanban, overflow нужно трактовать строго. + +### Правило + +Для каждой колонки отдельно: + +- если задач `<= 5`, показываются реальные tasks +- если задач `> 5`, показывается: + - `4` реальных tasks + - `1` overflow stack в последнем row + +### Что это даёт + +- высота slot стабильна +- все columns видимы +- overflow честно обозначен +- не нужен scroll + +### Порядок задач внутри колонки + +Этот refactor **не меняет** текущую каноническую задачу порядка внутри колонки. + +Значит: + +- если сейчас в колонке есть уже существующий deterministic order, его сохраняем +- stable slot layout не должен попутно вводить новый sort по времени, title или id +- overflow stack строится поверх уже существующего column order + +## Поведение activity overflow + +Для activity lane: + +- показываем `3` реальных items +- потом показываем `+N more` +- `+N more` открывает профиль участника на вкладке `Activity` + +Внутри профиля участника уже должны продолжать работать: + +- `All` +- `Messages` +- `Comments` + +Это не часть новой layout-логики, но поведение нельзя потерять. + +## Activity item semantics + +### Messages + +- переиспользуют существующий compact message widget +- клики и hover-поведение должны остаться совместимыми с уже существующим UI +- reuse boundary здесь - существующий visual/component behavior, а не обязательная привязка к текущему screen-space positioning path + +### Comments + +- в visual target показывают `task display id` +- не маскируются под message между двумя участниками +- должны явно читаться как `comment on task` + +### Ordering + +- в slot: newest first +- в полном activity tab: текущий существующий порядок не должен деградировать + +### Tie-break для activity ordering + +Чтобы `newest first` не реализовали по-разному в двух местах, фиксируем: + +1. сначала сортируем по `timestamp desc` +2. если timestamps совпали, используем стабильный secondary key: + - existing source order, если он уже есть в данных + - иначе `id` + +Это нужно, чтобы activity lane не дрожала при одинаковых timestamps и повторных rebuild-ах. + +## Process semantics + +Для `v1` process rail показывает **один самый релевантный process**: + +1. сначала running process +2. если running нет, последний недавно завершённый visible process +3. если релевантного process нет, band остаётся пустым, но место под него остаётся зарезервированным + +## Lead-specific geometry + +Lead обрабатывается отдельно. + +### Lead central exclusion включает + +- сам `lead node` +- `launch HUD` +- `lead activity band` +- минимальный safety padding вокруг этого блока + +`launch HUD` reserved zone должна сохраняться анти-jump образом, даже если compact HUD сейчас скрыт или dismissed. Для planner-а это persistent часть central exclusion. + +### Важный инвариант + +Ни один member slot не должен строиться так, будто этих central zones не существует. + +Иначе первый ring снова залезет в центр. + +### Recommended central block anatomy + +Чтобы `lead central reserved block` не трактовали по-разному в разных местах, фиксируем рекомендованную структуру: + +1. `lead activity frame` слева +2. `lead core frame` по центру +3. `launch HUD frame` справа + +Где: + +- `lead activity frame` использует те же bounded rules, что и member activity lane +- `lead core frame` содержит сам `lead node` и минимальную label/status area +- `launch HUD frame` резервируется даже если compact HUD сейчас hidden/dismissed + +Итоговый `leadCentralReservedBlock` считается как union этих трёх частей плюс обязательные внутренние gap-ы и внешний safety padding. + +### Что важно для v1 + +- `lead activity frame` не схлопывается только потому, что сейчас у lead мало activity +- `launch HUD frame` не схлопывается только потому, что compact HUD сейчас не показан +- member planner не знает про внутренние части central block-а по отдельности, он видит уже собранный итоговый `leadCentralReservedBlock` + +Это нужно, чтобы центр оставался стабильным и не пересобирался от мелких UI-состояний. + +## Stable identity - обязательная часть + +Это самая критичная инфраструктурная часть плана. + +Без неё slot assignment будет хрупким и будет ломаться при rename и refresh. + +### Что меняем + +`ResolvedTeamMember` должен получить: + +```ts +agentId?: string; +``` + +### Правило stable owner id + +```ts +stableOwnerId = member.agentId ?? member.name +``` + +### Правило node id + +Member node id в графе должен строиться на `stableOwnerId`, а не на display name. + +То есть вместо старой name-based схемы нужен stable id path: + +```ts +memberNodeId = `member:${teamName}:${stableOwnerId}` +``` + +Display label при этом остаётся `member.name`. + +### Где stableOwnerId обязателен + +- member node id +- task.ownerId reference +- process ownership +- activity ownership +- slot assignment storage +- drag/snap +- ordering + +### Где name можно оставить только как display field + +- label на node +- human-readable popover title +- callbacks, где UI дальше открывает профиль по имени + +## Visibility / removed member policy + +Чтобы новый layout не начал внезапно показывать другой состав owners, фиксируем: + +- этот refactor не переопределяет сам по себе graph visibility policy для removed members +- если removed member сейчас не должен быть owner node в графе, он не получает slot +- если задача ссылается на owner, которого нет в текущем visible owner set, такая задача должна попадать в `unassigned task slot`, а не ломать planner + +### Важное уточнение + +`visible owner set` для planner-а строится из owner/node visibility policy, а не из presentation filters вида: + +- `showTasks` +- `showProcesses` +- `showEdges` + +То есть filters не должны внезапно менять состав member owners, которых planner пытается раскладывать. + +## Stable ordering - обязательное правило + +Initial placement должен быть детерминированным. + +Фиксируем такой порядок: + +1. Сначала уже сохранённые `slot assignments` +2. Затем `teamData.config.members[]`, сматченные по `stableOwnerId` +3. Затем owners, которых нет в config order +4. Final tie-break - `stableOwnerId` + +Это важно, чтобы: + +- layout не дёргался при refresh +- порядок был связан с конфигом команды +- ordering не ломался при rename + +## Owner set resolution - before planner + +Чтобы planner не собирал layout actors "по пути" из разных источников, фиксируем один этап до планирования. + +### До запуска planner-а должен быть построен + +- `lead central reserved block` +- ordered visible member owners +- условный `unassigned task slot`, если он нужен + +### Чего там быть не должно + +- tasks как самостоятельных owners +- process nodes как самостоятельных owners +- activity items как самостоятельных owners +- зависимостей от hover, selection, unread highlight или camera state + +То есть сначала строим **layout actors set**, и только потом планируем геометрию. + +## Source of truth by concern + +Чтобы в коде не появилось несколько "почти главных" источников истины, фиксируем это явно. + +### Identity + +- source of truth: `stableOwnerId = agentId ?? member.name` + +### Owner placement + +- source of truth для **member sector slots**: `slotAssignmentsByTeam[teamName][stableOwnerId]` +- `lead central reserved block` не хранится в `slotAssignmentsByTeam` +- `unassigned task slot` не хранится в `slotAssignmentsByTeam` и строится derivation-логикой от текущего dataset + +### Geometry + +- source of truth: planner helpers, которые из assignments и footprints строят `SlotFrame` + +### Active render geometry + +- source of truth: последний **валидный и текущий** `StableSlotLayoutSnapshot`, собранный из текущих inputs +- invalid candidate snapshot не становится active render geometry +- stale snapshot cache не должен подменять current render geometry, если для текущего pass активирован fallback path + +### Activity data + +- source of truth: существующий messages/comments data path +- activity lane не строится из particles и не строится из transient overlay state + +### Task ordering + +- source of truth: текущая каноническая kanban/column order semantics +- этот refactor её не переизобретает + +### Process selection + +- source of truth: текущий process data path +- новый layout меняет positioning, а не выдумывает новый параллельный источник process state + +## Runtime precedence matrix + +Чтобы во время интеграции не появилось несколько конкурирующих "почти верных" состояний, фиксируем runtime precedence явно. + +### 1. Placement identity precedence + +Для `member sector slots` порядок такой: + +1. `slotAssignmentsByTeam[teamName][stableOwnerId]` +2. planner default placement для owner без assignment + +Больше никаких placement identity sources у `v1` нет. + +### 2. Geometry build precedence + +Для текущего render-pass порядок такой: + +1. текущие inputs +2. current assignments +3. planner builds candidate snapshot +4. validator либо подтверждает snapshot, либо отклоняет его + +То есть geometry нельзя брать: + +- из старого DOM layout +- из raw pinning +- из screen-space overlay коррекций + +### 3. Active render precedence + +Для renderer порядок такой: + +1. если есть валидный current snapshot для этого pass - рендерим его +2. если current snapshot invalid - не делаем его active render geometry +3. если текущий path в `no-lead fallback`, рендерим fallback presentation path +4. session-local last valid snapshot cache не становится active render geometry автоматически + +### 4. Persistence precedence + +Для store/state: + +1. валидный committed assignment update пишет `slotAssignmentsByTeam` +2. invalid candidate geometry ничего не пишет в assignments +3. snapshot cache не пишет assignments сам по себе + +### Ключевая мысль + +`slotAssignmentsByTeam` отвечает за identity placement, `StableSlotLayoutSnapshot` отвечает за валидную текущую geometry-картину, fallback path отвечает за безопасный transient rendering, и эти роли нельзя смешивать. + +## Где хранить slot assignment + +Источник истины для slot placement должен жить **в renderer-side team UI state**, а не в graph package. + +Graph package должен получать уже готовые assignments / placement inputs. + +Нормативная структура `v1`: + +```ts +type OwnerSlotAssignment = { + ownerStableId: string; + ringIndex: number; + sectorIndex: number; +}; + +type TeamSlotAssignments = Record; +type TeamSlotAssignmentsByTeam = Record; +``` + +Где key верхнего уровня - `teamName`. + +### Почему верхний key именно `teamName` в `v1` + +Текущий team data / IPC / graph integration path в проекте в основном уже team-scoped через `teamName`. + +Поэтому для `v1` фиксируем: + +- layout state scoped по `teamName` +- team switch просто переключает текущий scoped layout state +- assignments одной команды не должны протекать в другую + +Если в будущем появится first-class stable `teamId`, это может стать отдельным улучшением, но не является обязательной частью этого refactor. + +### Что это значит для team rename + +Так как в `v1` верхний key именно `teamName`, фиксируем явный tradeoff: + +- если меняется именно storage identity команды, то layout state для неё считается новым scoped state +- автоматическая миграция layout state между старым и новым `teamName` в этот refactor не входит + +Важно не путать это с: + +- rename участника при том же `agentId` +- display-only label change, которая не меняет team storage key + +### Что именно хранится здесь + +Только assignments для **draggable member sector slots**. + +Здесь **не** храним: + +- `lead central reserved block` +- `launch HUD` +- `lead activity` +- `unassigned task slot` + +Они должны строиться derivation-логикой, а не жить как псевдо-persisted assignments. + +### Что важно + +- Не хранить здесь raw `x/y` +- Не смешивать это с existing pinning model +- Если в коде уже есть pinned positions, для owner layout их нужно: + - либо мигрировать в ближайший slot один раз + - либо игнорировать для lead/member и постепенно удалить owner-specific path + +## Snapshot lifecycle and precedence + +Это отдельный обязательный contract, чтобы renderer не оказался между "старой" и "новой" геометрией. + +### Normal path + +Если inputs валидны и planner собрал валидный snapshot: + +1. собираем новый `StableSlotLayoutSnapshot` +2. валидируем его +3. делаем его active render geometry +4. обновляем session-local last valid snapshot cache + +### Validation failure path + +Если inputs есть, но candidate snapshot невалиден: + +1. candidate snapshot не коммитится +2. active render geometry не обновляется этим candidate snapshot +3. `slotAssignmentsByTeam` не перезаписывается частично +4. session-local last valid snapshot cache остаётся прежним +5. пишется diagnostic warning + +### No-lead path + +Если текущий dataset не содержит `lead`: + +1. stable-slot planner path не строит новый snapshot +2. active render geometry для stable-slot path не обновляется +3. session-local last valid snapshot cache не очищается автоматически +4. renderer для этого pass использует fallback presentation path, а не stale stable-slot snapshot как активную картинку + +### Главное различие + +- last valid snapshot cache нужен для safety и continuity логики +- active render geometry должна соответствовать текущему валидному render path + +Нельзя подменять второе первым. + +### Persistence scope для v1 + +Для первой реализации достаточно renderer-side persistence в team UI state. + +В этом pass **не нужно**: + +- писать slot assignments в team config +- тащить их через main process +- делать cross-session durable migration как обязательную часть layout refactor + +Сначала нужен стабильный рабочий layout внутри текущего renderer state path. + +### Legacy pin migration policy + +Чтобы не оставлять это на усмотрение implementer-а, фиксируем стартовую политику: + +- для `lead/member` legacy raw pinned positions в новом режиме **не являются** источником истины +- если такие данные существуют, на первом входе в `stable-slots-v1` их нужно один раз: + - сматчить в ближайший валидный slot assignment + - после этого дальше жить уже только через slot assignment + +Не нужно бесконечно поддерживать два параллельных источника истины: + +- raw owner pinning +- slot assignment + +### Migration from fallback name to agentId + +Это отдельный переходный случай, который нельзя оставлять "как-нибудь само". + +Сценарий: + +- раньше member жил на fallback key `member.name` +- позже для него стал доступен `agentId` + +Для `v1` фиксируем такое правило: + +- если у текущего visible member появился `agentId` +- и assignment под новым `stableOwnerId` ещё не существует +- но есть старый assignment под его прежним fallback `member.name` + +то этот assignment нужно **один раз** перенести на новый `stableOwnerId` до запуска planner-а. + +Это нужно, чтобы член команды не "терял место" только потому, что identity стала более качественной. + +### Migration precedence - чтобы не было двойной трактовки + +Порядок для `v1` фиксируем такой: + +1. если `slotLayoutVersion` не совпадает - старые member assignments сбрасываем целиком +2. если version совпадает и assignment уже есть под новым `stableOwnerId` - используем его как source of truth +3. если assignment под новым `stableOwnerId` ещё нет, но есть старый assignment под fallback `member.name` - переносим его один раз +4. если существуют и старый fallback assignment, и новый stable assignment одновременно - побеждает новый stable assignment, fallback alias удаляется + +Это правило нужно, чтобы миграция не создавала две конкурирующие записи для одного и того же member-а. + +## Slot frame model + +Для planner-а нужен явный rectangular model. + +Нормативная внутренняя структура `v1`: + +```ts +type SlotFrame = { + ownerStableId: string; + ringIndex: number; + sectorIndex: number; + x: number; + y: number; + width: number; + height: number; +}; +``` + +`x/y` здесь - top-left slot frame в world coordinates. + +### Важное уточнение + +`SlotFrame` в этом документе означает именно frame для **member sector slot**. + +Для special geometry используем отдельные понятия: + +- `lead central reserved block` +- `UnassignedTaskSlotFrame` + +Они могут быть AABB-похожими по форме, но не должны участвовать как обычные member slot assignments. + +### Почему прямоугольник, а не только radius + +Потому что пользовательская проблема именно в прямоугольных зонах: + +- activity cards +- process rail +- task columns + +Значит planner должен работать не только с радиусом, а с **реальными AABB bounds**. + +## World-space geometry contract + +Это один из самых критичных пунктов плана, потому что прошлые баги были именно из-за смешивания world-space и screen-space. + +### Обязательное правило + +- planner, slot frames и local anchors живут в `world coordinates` +- camera zoom/pan применяется только как визуальный transform поверх уже готовой world geometry +- `GraphActivityHud` и похожие UI-слои не должны повторно "спасать" layout через screen-space repositioning + +### Что запрещено + +- post-render screen-space packing соседних owners +- DOM-based reflow logic, которая двигает slot zones независимо от planner-а +- вычисление layout из текущего zoom level + +Если какой-то элемент выглядит налезающим, исправлять нужно planner / footprint / slot bounds, а не screen-space коррекцией. + +## Owner anchor inside slot + +Чтобы implementer не трактовал slot как угодно, фиксируем owner anchor rule. + +### Правило + +- `(ringIndex, sectorIndex)` сначала определяют `ownerAnchor` на соответствующем sector ray +- `slot frame` задаёт outer bounds всей owner-local зоны +- `slot frame` строится вокруг `ownerAnchor`, а не выбирается произвольно постфактум +- сам `member node` располагается по горизонтальному центру slot +- по вертикали `member node` располагается в `owner band`, между `activity band` и `process band` +- `activity band` всегда выше owner node +- `task band` всегда ниже owner node + +### Каноническая формула для `SlotFrame` + +Чтобы не было двух разных трактовок top-left координаты, фиксируем canonical build rule: + +```ts +slotFrame.x = ownerAnchor.x - slotWidth / 2 +slotFrame.y = ownerAnchor.y - (activityBandHeight + ownerBandHeight / 2) +slotFrame.width = slotWidth +slotFrame.height = slotHeight +``` + +То есть: + +- `ownerAnchor.x` всегда совпадает с horizontal centerline slot-а +- `ownerAnchor.y` всегда совпадает с вертикальным центром `owner band` +- верхняя граница slot-а определяется от activity band, а не "как получится" + +Это делает geometry детерминированной и одинаковой для planner-а, hit testing и renderer-а. + +### Канонические локальные origins + +Локальные точки считаем только от `slotFrame`, а не от DOM/layout side effects: + +```ts +activityOrigin = { + x: slotFrame.x, + y: slotFrame.y, +} + +ownerBandOrigin = { + x: slotFrame.x, + y: slotFrame.y + activityBandHeight, +} + +processOrigin = { + x: slotFrame.x, + y: slotFrame.y + activityBandHeight + ownerBandHeight, +} + +taskOrigin = { + x: slotFrame.x, + y: slotFrame.y + activityBandHeight + ownerBandHeight + processBandHeight, +} +``` + +Если какой-то renderer-pathу нужен другой anchor, он должен вычисляться как производный от этих канонических origins, а не как отдельная независимая правда. + +### Практически + +Нужен один helper уровня domain/layout, который из `SlotFrame` возвращает локальные anchor points: + +- `ownerAnchor` +- `activityOrigin` +- `processOrigin` +- `taskOrigin` + +UI и canvas не должны заново высчитывать эти точки каждый по-своему. + +## Owner footprint contract + +`OwnerFootprint` должен считаться детерминированно из layout rules и данных, а не из уже отрендеренного DOM. + +### В `OwnerFootprint` входят + +- итоговый `slotWidth` +- итоговый `slotHeight` +- bucket `S / M / L` +- optional flags, влияющие на layout validity: + - есть ли `activity items` + - есть ли релевантный `process` + +### Важно + +- `OwnerFootprint` считается до рендера +- он должен быть pure и testable +- разные React paths не должны считать footprint по-разному + +## Slot orientation rule + +Чтобы не возникло двух разных реализаций "stable sectors", фиксируем это явно: + +- slot contents **не ротируются** по углу сектора +- text и UI внутри slot всегда остаются upright +- `Activity / Member / Process / Tasks` всегда идут в одном и том же вертикальном порядке сверху вниз + +То есть sector влияет на положение slot вокруг lead, но не на внутреннюю ориентацию карточек и текста. + +## Horizontal alignment inside slot + +Чтобы band-ы не выравнивались хаотично по-разному, фиксируем правило: + +- у slot есть общий горизонтальный centerline +- `member node` центрируется по этой линии +- `activity band` центрируется по этой линии +- `process rail` центрируется по этой линии +- `task band` центрируется по этой линии + +Если у band своя фактическая ширина меньше `slotWidth`, он не липнет к левому краю slot, а центрируется внутри slot frame. + +## Slot width rules + +`slotWidth` считается честно, от контента. + +### Формула + +```ts +slotWidth = max( + activityWidth, + ownerMinWidth, + processRailWidth, + kanbanWidth +) +``` + +### Где + +- `activityWidth` - ширина compact activity lane +- `ownerMinWidth` - минимальная ширина под owner node + label area +- `processRailWidth` - ширина attached process rail +- `kanbanWidth` - суммарная ширина всех активных columns с gutter-ами + +### Важно + +- bucket не определяет итоговую ширину +- bucket только классифицирует уже посчитанную ширину + +### Что влияет на slot width + +- число `active non-empty` kanban columns +- фиксированная ширина compact activity lane +- фиксированная ширина process rail +- минимальная owner label area + +### Что не должно влиять на slot width + +- случайная длина message preview текста +- случайная длина task subject, если pill уже имеет фиксированную ширину и truncation +- unread badge count +- hover / selection state + +Иными словами: slot width должен расти из-за реальной структурной ширины owner-local зон, а не из-за случайного текстового контента. + +## Slot height rules + +`slotHeight` bounded и предсказуем. + +### Формула + +```ts +slotHeight = + activityBandHeight + + ownerBandHeight + + processBandHeight + + taskBandHeight + + verticalGaps +``` + +### Важно + +- activity band height фиксирован +- process band height фиксирован +- task band height фиксирован (`5 rows`) +- значит рост задач меняет в первую очередь `slotWidth`, а не `slotHeight` + +Это ключевой выбор ради стабильности. + +## Width buckets - как трактовать правильно + +`S / M / L` нужны planner-у как packing heuristic, но не как UI limit. + +### Правильное правило + +1. Сначала считаем **реальный** `slotWidth` +2. Потом присваиваем bucket +3. Потом planner использует bucket и точный width + +### Что не делаем + +- не считаем bucket по display name +- не считаем bucket только по числу задач +- не используем bucket как замену реальной ширины + +### Стартовая bucket policy для v1 + +Чтобы первая реализация не разошлась в трактовках, фиксируем стартовое правило: + +- `S` - `1` active non-empty kanban column +- `M` - `2-3` active non-empty kanban columns +- `L` - `4+` active non-empty kanban columns + +Если `activityWidth` или `processRailWidth` делает slot шире ожидаемого bucket-а, planner всё равно должен опираться на **реальный `slotWidth`**, а bucket использовать только как coarse hint. + +## Геометрические defaults для v1 + +Чтобы implementer не подбирал layout "на глаз" каждый по-своему, фиксируем стартовые значения: + +- `slotVerticalGap = 24` +- `slotHorizontalGap = 32` +- `ringGap = 140` +- `centralSafetyPadding = 48` +- `memberSlotInnerPadding = 16` + +Это именно стартовые defaults. Их можно тюнить, но они должны жить в одном месте и изменяться осознанно, а не расползаться по коду. + +### Source of geometry constants + +Все такие значения должны жить в одном domain-level constants module для stable-slot layout. + +Нельзя: + +- дублировать их отдельно в planner +- отдельно в renderer +- отдельно в hit testing +- отдельно в fit helpers + +Иначе визуально одинаковый slot начнёт иметь разные размеры в разных частях системы. + +## Ring radius rule + +Радиус следующего ring нельзя считать "по ощущению". + +Для `v1` фиксируем стартовое правило: + +```ts +nextRingRadius = + previousRingRadius + + maxSlotDepthOnPreviousRing + + ringGap +``` + +Где `maxSlotDepthOnPreviousRing` - максимальный размер slot по радиальному направлению среди owners этого ring. + +## Ring planner - строгая логика + +Planner должен быть детерминированным и минимально разрушительным. + +### Что именно planner планирует + +Этот planner планирует только **member sector slots**. + +Он не должен пытаться раскладывать: + +- `lead central reserved block` +- `launch HUD` +- `lead activity` +- `unassigned task slot` + +Эти части должны быть построены заранее как fixed/special geometry, влияющая на exclusion и bounds. + +### Inputs + +- central exclusion bounds +- ordered visible member owners +- saved slot assignments +- slot footprints +- ring / sector constants + +### Output + +- `SlotFrame[]` для member sector slots + +### Базовый алгоритм + +```ts +for owner in orderedOwners: + if savedAssignment(owner) still valid: + keep it + reserve frame + continue + + place owner into first valid candidate: + by preferred ring + then by preferred sector + then by next available ring/sector + where candidate frame: + does not intersect central exclusion + does not intersect occupied slot frames + respects min gap +``` + +### Правило минимального разрушения + +Если owner уже был привязан к сектору, planner должен сначала попытаться: + +1. оставить тот же `sectorIndex` +2. если не помещается - оставить тот же сектор, но увести owner на внешний ring +3. только потом искать новый сектор + +Это даёт более стабильное поведение, чем немедленный полный reshuffle по всем секторам. + +### Порядок выбора candidate slots + +Чтобы planner не получился "похожим, но разным" в двух местах кода, фиксируем порядок выбора явно. + +#### Для owner с уже существующим assignment + +1. тот же `(ringIndex, sectorIndex)`, если он всё ещё valid +2. тот же `sectorIndex` на следующем внешнем ring +3. ближайшие соседние sectors на том же ring +4. ближайшие соседние sectors на внешних rings + +#### Для нового owner без assignment + +1. ring 1, sectors в фиксированном canonical order +2. если ничего не влезло - ring 2, тот же canonical order +3. и так далее + +#### Canonical sector order для planner-а + +Используем тот же порядок, что уже зафиксирован выше: + +1. `top` +2. `upper-right` +3. `lower-right` +4. `bottom` +5. `lower-left` +6. `upper-left` + +Это правило важно, чтобы initial placement, replanning и drag-target selection не расходились между собой. + +### Что значит nearest valid slot при drag + +Чтобы drag/snap не реализовали двумя разными способами, фиксируем: + +- nearest candidate считается по расстоянию от текущего dragged `ownerAnchor` до candidate `ownerAnchor` +- metric - обычное Euclidean distance в world coordinates +- если расстояние одинаковое, tie-break идёт по canonical sector order + +Это даёт детерминированное поведение и не зависит от текущего zoom/pan. + +### Что значит "still valid" + +Saved assignment считается valid, если: + +- slot frame для него можно построить +- frame не пересекает central exclusion +- frame не пересекает уже занятые slot frames +- current owner footprint всё ещё помещается + +Если assignment invalid, owner перепланируется, но **не весь layout с нуля**, а только конфликтующие части. + +## Reflow policy - когда planner имеет право перестраивать схему + +Чтобы не было хаотических reshuffle, вводим чёткое правило. + +### Layout нельзя перестраивать полностью из-за + +- новых activity items +- комментариев +- изменения unread badges +- process content update без роста footprint +- zoom / pan +- rename при том же `agentId` + +### Layout может перестраивать часть owners из-за + +- появления нового owner +- удаления owner +- drag/swap +- роста slot footprint, который делает current assignment invalid +- появления `unassigned task slot`, если он расширил lower central exclusion и создал реальный конфликт +- исчезновения `unassigned task slot`, только если пользователь явно сделал `reset layout` + +### Предпочтительный принцип + +`keep existing placements whenever still valid` + +Это обязательный инвариант для стабильности. + +## Post-plan validation and fail-closed behavior + +Даже хороший planner иногда можно сломать интеграцией. Поэтому после каждого planner run нужна отдельная deterministic validation pass. + +### Нужен отдельный pure helper + +Рекомендуемая форма: + +```ts +validateStableSlotLayout({ + slotFrames, + runtimeCentralExclusion, + ownerFootprints, + assignments, +}) +``` + +### Validator обязан проверять + +- все `SlotFrame` конечные и не содержат `NaN/Infinity` +- нет двух owners с одним и тем же assignment +- ни один `member sector slot` не пересекает `runtimeCentralExclusion` +- `member sector slots` не пересекаются между собой +- полный frame slot-а включает `Activity`, `Owner`, `Process` и `Task` bands +- локальные anchors лежат внутри своего `SlotFrame` +- итоговые fit bounds конечные и ненулевые + +### Fail-closed правило + +Если validation не прошла: + +- не рендерим "полусломанный" новый layout как будто он валиден +- оставляем предыдущий последний валидный layout snapshot для этой команды, если он есть +- если валидного snapshot нет, используем безопасный fallback без persistent overwrite +- пишем diagnostic warning в лог, чтобы баг можно было воспроизвести + +### Что нельзя делать при validation failure + +- silently чинить layout screen-space коррекцией +- частично коммитить невалидные assignments в store +- двигать только Activity/Process/Tasks отдельно от slot frame, пытаясь "спасти картинку" + +Это критично: broken layout должен ломаться явно и безопасно, а не превращаться в новый источник хаоса. + +## Conflict resolution order - when one slot stops fitting + +Это один из самых важных практических пунктов. Именно здесь чаще всего implementer случайно делает hidden global reshuffle. + +### Если один owner стал невалиден из-за роста footprint + +Planner должен идти по такому порядку: + +1. сохранить все unaffected owners на месте +2. попробовать оставить problem owner в том же `sectorIndex`, но увести на следующий outer ring +3. если этого недостаточно, попробовать минимальный локальный spill конфликтующего подмножества owners +4. не делать полный global reshuffle, если пользователь явно не вызвал `reset layout` + +### Что считается preferred behavior + +- cheapest valid local fix wins +- количество затронутых owners должно быть минимальным +- owner, который инициировал конфликт ростом footprint, должен двигаться первым, если это решает проблему + +Это правило важно, чтобы граф оставался предсказуемым и не "переезжал весь" из-за одной widened kanban zone. + +## Canonical layout build pipeline + +Чтобы новый path не был реализован в разных местах по-разному, фиксируем канонический порядок сборки layout. + +### Шаги + +1. Построить visible graph dataset +2. Разрешить `stableOwnerId` для members +3. Построить `lead central reserved block` +4. Если есть unassigned tasks - построить `unassigned task slot` +5. Из пунктов `3-4` собрать итоговый `central exclusion` +6. Построить ordered visible member owners +7. Для каждого member owner посчитать `OwnerFootprint` +8. Запустить member slot planner +9. Получить `SlotFrame[]` для member slots +10. Из `SlotFrame` построить local anchors: + - `ownerAnchor` + - `activityOrigin` + - `processOrigin` + - `taskOrigin` +11. Собрать цельный `StableSlotLayoutSnapshot` +12. Прогнать validation на полном snapshot +13. Только после этого передать world-space geometry в renderer / graph package +14. Только после этого применять camera zoom/pan + +### Что нельзя делать + +- сначала отрендерить Activity/Tasks, а потом ими "уточнить" layout +- сначала запустить screen-space pack, а потом пытаться сохранить это как source of truth +- считать planner output неполным и дозаполнять его DOM-side reposition логикой + +## Atomic layout transaction rule + +Это важный anti-bug contract. Layout update должен коммититься как одна транзакция, а не серией мелких частичных записей. + +### Правило + +Один graph update делает только такой путь: + +1. derive inputs +2. build full snapshot +3. validate snapshot +4. commit whole snapshot +5. render from committed snapshot + +### Что запрещено + +- сначала записать новые assignments, а `SlotFrame` пересчитать позже +- сначала обновить `memberSlotFrames`, а `fitBounds` и `runtimeCentralExclusion` дотянуть в следующем tick +- отдельно коммитить `Activity` geometry и отдельно `Task` geometry +- держать в renderer одновременно старые `fitBounds` и новые `SlotFrame` + +### Почему это обязательно + +Большая часть "странных" overlap и jump-багов рождается не из формулы planner-а, а из того, что разные части UI в течение одного render-cycle смотрят на разные поколения layout state. + +Новый path должен быть transaction-like: либо весь snapshot валиден и коммитнут, либо остаётся предыдущий валидный snapshot. + +## Debug / observability requirements + +Этот refactor слишком геометрический, чтобы оставлять отладку на `console.log` по месту. + +### Минимум, который нужен в `v1` + +- dev-only возможность вывести для owner: + - `stableOwnerId` + - `ringIndex` + - `sectorIndex` + - `slotWidth` + - `slotHeight` + - bucket `S/M/L` +- dev-only warning при validation failure с причиной +- dev-only возможность понять, какой owner был локально перепланирован и почему + +### Чего не нужно делать + +- тащить это в публичный product UI +- делать отдельный user-facing debug mode + +Это purely implementation aid, но он сильно снижает риск, что спорные overlap-cases будут разбираться "на глаз". + +## View modes - tab and fullscreen + +Новый layout не должен иметь две разные правды для разных способов открытия графа. + +### Правило + +- graph tab и fullscreen overlay используют один и тот же `slotAssignmentsByTeam` +- graph tab и fullscreen overlay используют один и тот же `slotLayoutVersion` +- открытие fullscreen не должно заново seed-ить owner placement +- drag в одном режиме должен сразу отражаться в другом режиме +- camera state при этом может быть разным и не обязан шариться между режимами + +Иными словами: разные view modes - это разные камеры и контейнеры, но не разные layout models. + +## Team switch behavior + +Layout state должен быть жёстко team-scoped. + +### Правило + +- переключение на другую команду читает только её `slotAssignmentsByTeam[teamName]` +- возврат назад использует ранее сохранённый scoped layout этой команды +- assignments и camera state одной команды не должны случайно применяться к другой +- если одна и та же команда открыта в нескольких pane-ах, layout state у неё общий +- при конкурентном обновлении layout state для одной команды действует обычное shared-state правило `last write wins` + +Это особенно важно для случаев, когда несколько graph tabs / panes открыты параллельно. + +## Hidden member -> reappear behavior + +Это полезно зафиксировать отдельно, чтобы реализация не делала лишний churn layout-а. + +### Правило + +- если member временно исчез из `visible owner set`, его slot не участвует в текущем planner run +- при этом его сохранённый member assignment может оставаться в `slotAssignmentsByTeam` +- если тот же member потом возвращается с тем же `stableOwnerId`, planner сначала пытается переиспользовать прежний assignment +- если прежний assignment больше не валиден, только тогда делается локальный replanning + +Это даёт более стабильное поведение, чем каждый раз забывать slot при любом временном исчезновении owner-а. + +## Graph filters - влияние на layout + +Это место обязательно нужно зафиксировать, иначе после рефактора легко вернуть layout jumps через UI toggles. + +### Для `v1` правило такое + +- `showTasks` +- `showProcesses` +- `showEdges` + +не являются входом для planner-а и не должны менять slot assignments. + +### Что они делают + +- `showTasks = false` скрывает task rendering, но не перестраивает member slots и не уничтожает reserved task band geometry +- `showProcesses = false` скрывает process rail rendering, но не перестраивает member slots и не убирает reserved process band geometry +- `showEdges = false` влияет только на edges + +### Отдельно про `unassigned task slot` + +Так как это special slot, состоящий только из task band-а, фиксируем отдельно: + +- если в dataset есть unassigned tasks, сам `unassigned task slot` продолжает учитываться в topology +- `showTasks = false` может скрыть его presentation, но не должен убирать его reserved topology footprint +- исчезновение `unassigned task slot` как layout actor происходит только когда unassigned tasks реально исчезли из dataset, а не из-за UI filter toggle + +Это осознанный tradeoff ради стабильности. Filters в `v1` скрывают presentation, а не перестраивают топологию layout. + +## No-lead fallback + +Stable sector planner в этом плане опирается на наличие `lead`. + +### Для `v1` фиксируем точное поведение + +- если в текущем visible dataset временно нет `lead` +- новый stable-slot planner не должен пытаться строить сектора "вокруг пустоты" +- новый `StableSlotLayoutSnapshot` в таком состоянии не строится и не коммитится +- persistent assignments не перезаписываются +- если для этой команды уже есть последний валидный stable-slot snapshot текущей сессии, он остаётся последним валидным snapshot и не заменяется случайной геометрией +- если валидного snapshot ещё не было, stable-slot presentation path для этого render-pass не активируется до возвращения lead + +### Чего нельзя делать + +- сохранять случайные placements как будто это валидный stable layout +- создавать fake lead только ради того, чтобы planner "отработал" + +Это transient safety rule, чтобы неполные данные не портили persistent layout state. + +## Drag and snap semantics + +Drag нужен, но он не должен возвращать нас к свободной физике. + +### Правила + +- Drag доступен только для `member slots` +- `lead` не draggable +- Во время drag можно подсвечивать ближайший candidate slot +- При drop owner снапается в nearest valid slot +- Если target занят: + - делаем `swap` + - не делаем overlap + - не делаем silent fallback в произвольную соседнюю точку + +### Что сохраняем после drop + +Только: + +- `ringIndex` +- `sectorIndex` + +Не сохраняем: + +- абсолютные координаты + +### Важное уточнение про swap + +Swap допустим только если обе итоговые позиции валидны после обмена. + +Если ближайший занятый slot приводит к невалидной паре placement-ов, нужно брать следующий nearest valid candidate, а не насильно выполнять swap. + +### Поведение при drop вне валидного target + +Если пользователь отпускает owner там, где нет валидного slot candidate: + +- owner возвращается в свой предыдущий assignment +- промежуточная невалидная world position не сохраняется + +Это обязательно, чтобы drag не оставлял граф в полусломанном состоянии. + +## Почему swap выбран по умолчанию + +Потому что это самое понятное поведение для пользователя: + +- место уже занято +- я кладу owner туда +- значит владельцы меняются местами + +Любая "умная" скрытая перестановка менее предсказуема. + +## Activity connector - как трактовать + +Connector нужен, но он должен быть локальным и cheap. + +### Правило + +- connector рисуется между activity band и owner band внутри одного slot +- connector не участвует в глобальном packing +- connector не должен тянуться через пол-графа + +Это просто визуальная связь, а не самостоятельный layout actor. + +## Process rendering - важное уточнение + +Для `v1` безопаснее **не выкидывать process nodes из графовой модели полностью**. + +Лучший путь: + +- оставить process data в graph domain +- но перестать раскладывать process nodes как независимые свободные entities +- вместо этого позиционировать process presentation внутри owner slot + +То есть меняем **геометрию и ownership**, а не обязательно весь data contract в один проход. + +## Activity rendering - важное уточнение + +`GraphActivityHud` в новой модели не должен сам быть planner-ом. + +Его роль после переделки: + +- взять уже посчитанные slot-local coordinates +- отрендерить compact activity UI +- отрендерить локальный connector + +Чего он делать больше не должен: + +- сам pack-ить owner lanes друг относительно друга +- сам спасать cross-owner overlap +- сам решать world geometry + +## Zoom / pan / fit - обязательные инварианты + +### Zoom и pan + +Zoom и pan должны менять только camera transform. + +Они не имеют права: + +- перераскладывать slots +- менять relative positions внутри slot +- менять размер slot в screen-space независимо от world model + +### Fit + +`zoomToFit` и initial fit обязаны учитывать: + +- member slot frames +- lead central exclusion +- launch HUD +- unassigned task slot +- activity bands +- task bands +- process rails + +Не только центры owner nodes. + +### Важное уточнение про filters + +Даже если `showTasks = false` или `showProcesses = false`, fit в `v1` должен учитывать **reserved topology bounds**, а не только текущие видимые DOM/canvas элементы. + +Иначе переключение filters снова будет вызывать визуальный jump layout-а и нарушит главный инвариант стабильности. + +## Member add/remove behavior + +### Add + +- existing valid assignments сохраняются +- новый owner получает первый валидный slot по planner rules +- если ring 1 full по footprint, новый owner идёт в ring 2 + +### Remove + +- slot освобождается +- остальные owners не auto-compact-ятся только из-за самого факта remove/hide + +Фиксируем правило: + +- без explicit reset-layout не делаем агрессивный global compaction + +Это уменьшает визуальные скачки. + +### Reset layout behavior + +Нужен явный reset path: + +- очистить `slotAssignmentsByTeam[teamName]` +- заново построить placements по planner rules + +Это нужно и для пользователя, и для отладки, и для быстрого выхода из редких неудачных layout-состояний. + +## Rename behavior + +Если: + +- `agentId` тот же +- изменился только `member.name` + +то layout обязан сохранить: + +- slot assignment +- drag placement +- activity ownership +- process ownership +- task ownership references + +Если `agentId` нет и используется fallback на имя, это known-weaker mode, и это нужно явно покрыть тестом. + +## Поведение при росте задач + +Это отдельный важный edge case, который уже обсуждался. + +### Сценарий + +У owner сначала мало задач, потом их становится много. + +### Что не делаем + +- не включаем scroll внутри slot +- не скрываем часть columns +- не даём tasks раздавить activity соседнего owner + +### Что делаем + +- task band остаётся высотой `5 rows` +- все active non-empty columns продолжают показываться +- slot width растёт +- если текущий ring больше не вмещает выросший slot, owner может уйти на более дальний ring + +### Ключевой инвариант + +Даже если owner уходит во внешний ring, это должно быть: + +- детерминированно +- минимально разрушительно +- без общего хаотичного reshuffle + +## Public / shared contract changes + +Ниже то, что нужно явно зафиксировать, чтобы не появлялись "невидимые" зависимости. + +## Shared types + +Обязательно: + +- `ResolvedTeamMember.agentId?: string` + +## Graph node identity + +Обязательно: + +- member node ids переходят на `stableOwnerId` + +Нормативный шаблон `v1`: + +```ts +member:${teamName}:${stableOwnerId} +``` + +### Что сознательно не меняем в этом refactor + +Существующие UI/event ports могут по-прежнему использовать: + +- `teamName` +- `memberName` +- `taskId` + +если это нужно для открытия профиля, task detail или других UI-paths. + +То есть stable ids обязательны для layout identity и planner storage, но не требуют в этом pass перепридумывать весь пользовательский navigation/event contract. + +## Renderer-side layout state + +Нужно добавить owner slot assignment storage по team. + +Дополнительно нужен технический marker текущего layout path, например: + +```ts +slotLayoutVersion = 'stable-slots-v1' +``` + +Он поможет безопасно отличать новый planner path от старого во время миграции и cleanup. + +### Versioning contract + +- `slotLayoutVersion` хранится рядом с member slot assignments +- если сохранённая версия не совпадает с текущей, старые member assignments нужно сбросить и пересчитать по planner defaults +- не нужно пытаться поддерживать неявную backwards-совместимость между разными planner semantics + +Лучше один явный reset assignments path, чем тихое использование устаревшей geometry-модели. + +## Internal planner types + +Нужны internal-only helper types: + +- `SlotFrame` +- `UnassignedTaskSlotFrame` +- `OwnerSlotAssignment` +- `OwnerFootprint` +- `RingPlanCandidate` +- `StableSlotLayoutSnapshot` + +Эти типы лучше держать в feature domain / graph package internals, а не тянуть в публичный API без необходимости. + +### Нормативный `StableSlotLayoutSnapshot` для `v1` + +Чтобы renderer и simulation не собирали layout из полусырых кусков, фиксируем aggregated result type: + +```ts +type StableSlotLayoutSnapshot = { + teamName: string; + slotLayoutVersion: string; + memberSlotFrames: SlotFrame[]; + leadCentralReservedBlock: Rect; + unassignedTaskSlot?: UnassignedTaskSlotFrame; + runtimeCentralExclusion: Rect; + fitBounds: Rect; +}; +``` + +### Зачем нужен snapshot + +- один planner run должен выдавать один цельный результат +- renderer не должен сам дособирать geometry из отдельных store-полей +- validation должна проверять именно полный snapshot, а не куски по отдельности + +Это уменьшает риск, что `Activity`, `Tasks`, `Process`, fit bounds и exclusion будут жить в слегка разных версиях одного и того же layout pass. + +## Слои и ответственности + +## Shared / main + +Точки внимания: + +- `src/shared/types/team.ts` +- `src/main/services/team/TeamMemberResolver.ts` + +Ответственность: + +- протащить `agentId` в `ResolvedTeamMember` +- сделать stable identity доступной renderer-слою + +## Feature domain + +Точки внимания: + +- `src/features/agent-graph/core/domain/` + +Ответственность: + +- `stableOwnerId` +- `slotWidth/slotHeight` +- width buckets +- ring planner +- slot validity checks +- drag/snap helpers + +## Adapter layer + +Точки внимания: + +- `src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts` + +Ответственность: + +- строить member node ids на stable owner id +- привязать tasks / processes / activity к stable owner identity +- не заниматься спасением geometry post-facto + +## Graph package layout + +Точки внимания: + +- `packages/agent-graph/src/hooks/useGraphSimulation.ts` +- `packages/agent-graph/src/layout/kanbanLayout.ts` +- `packages/agent-graph/src/layout/activityLane.ts` + +Ответственность: + +- owner placement идёт от slot planner, а не от free-force +- task layout получает `slot frame` +- activity получает `slot-local origin` +- process rail позиционируется owner-local + +## Renderer UI + +Точки внимания: + +- `src/features/agent-graph/renderer/ui/GraphActivityHud.tsx` +- `src/features/agent-graph/renderer/ui/GraphProvisioningHud.tsx` +- `src/features/agent-graph/renderer/ui/GraphNodePopover.tsx` + +Ответственность: + +- быть consumer-слоем готовой geometry +- не быть главным solver-ом layout +- сохранить существующие полезные interactions + +## Подробный implementation plan + +Ниже порядок, который уменьшает риск наломать багов. + +## Phase gates - когда можно идти дальше + +Это не просто список задач по порядку. Между фазами есть обязательные quality gates. + +### Нельзя переходить к slot UI integration, пока не выполнено + +- stable identity уже протянута до graph layer +- planner helpers уже pure и покрыты базовыми unit tests +- `slotAssignmentsByTeam` уже стал source of truth +- planner уже выдаёт валидный `StableSlotLayoutSnapshot` +- validation pass уже работает +- snapshot lifecycle и fail-closed commit behavior уже определены и подключены + +### Нельзя считать activity/process/tasks phase завершённой, если + +- для позиционирования всё ещё используется screen-space self-packing +- renderer сам высчитывает geometry вместо consumption `slot frame` и local anchors +- overlap "чинится" постфактум DOM-side коррекцией + +### Нельзя удалять старые geometry paths, пока не доказано + +- `Activity` +- `Process` +- `Tasks` +- fit / camera +- drag / snap / swap + +уже работают на новом planner path без регрессии ключевых UX-сценариев. + +## Phase 0 - audit and kill-switch cleanup + +### Цель + +Перед тем как писать новый planner, зафиксировать, какие текущие механизмы нужно убрать или перестать считать source of truth. + +### Сделать + +1. Найти текущие owner-related packers и manual reposition paths +2. Отдельно отметить: + - screen-space activity pack + - owner free-force assumptions + - raw pinning paths +3. Зафиксировать, что после migration source of truth будет slot planner + +### Результат + +Список устаревающих механизмов и мест, которые должны стать no-op или быть удалены на финальной фазе. + +## Phase 0.5 - temporary rollout guard + +### Цель + +Внедрять новый planner безопасно и иметь быстрый способ локально сравнить новое поведение со старым. + +### Сделать + +1. Добавить временный internal switch / feature flag для нового planner path +2. Оставить возможность локально переключать: + - current layout + - stable slot layout +3. После достижения parity удалить переключатель или сделать его технически неактивным + +### Rollout contract + +Feature flag должен переключать **целый layout mode**, а не отдельные куски. + +Если включён новый path: + +- owner placement идёт только от stable-slot planner +- activity/process/tasks берут geometry только из нового slot path +- старые owner pack/reposition механизмы не должны параллельно влиять на те же owner-local зоны + +Если включён старый path: + +- новый planner может жить только как dev/test path +- его geometry не должна подмешиваться в production rendering случайно + +### Что запрещено + +- mixed mode, где tasks уже от нового slot frame, а activity всё ещё пакуется старым screen-space способом +- mixed mode, где новый planner строит assignments, но старый renderer потом "допихивает" geometry +- включение feature flag только для одной owner-local подсистемы без согласованного переключения всей owner-local topology модели + +### Done when + +- новый planner можно изолированно проверять во время разработки, не ломая возможность сравнения + +## Phase 1 - stable identity plumbing + +### Цель + +Убрать хрупкую зависимость layout от display name. + +### Сделать + +1. Добавить `agentId?: string` в `ResolvedTeamMember` +2. Протащить `agentId` через `TeamMemberResolver` +3. Добавить helper `getStableOwnerId(member)` +4. Перевести member node ids на stable owner id +5. Проверить все ссылки `task.ownerId`, `process owner`, `activity owner` + +### Done when + +- rename при том же `agentId` больше не меняет graph identity owner node + +## Phase 2 - slot state and planner helpers + +### Цель + +Вынести геометрию и planner из UI-слоя в чистые helper-и. + +### Сделать + +1. Добавить типы: + - `OwnerSlotAssignment` + - `SlotFrame` + - `OwnerFootprint` + - `StableSlotLayoutSnapshot` +2. Реализовать pure helpers: + - `computeOwnerFootprint` + - `classifyWidthBucket` + - `buildCentralExclusion` + - `buildOwnerAnchor` + - `buildSlotFrameFromOwnerAnchor` + - `buildSlotLocalOrigins` + - `buildUnassignedTaskSlotFrame` + - `planOwnerSlots` + - `resolveNearestSlot` + - `isSlotAssignmentValid` + - `computeRingRadius` +3. Добавить min-gap / ring-gap constants в одном месте +4. Собрать planner result в единый snapshot и валидировать его до render/commit + +### Done when + +- planner можно покрыть unit tests без React и без canvas + +## Phase 3 - renderer-side slot assignment storage + +### Цель + +Сделать persistent source of truth для owner placement. + +### Сделать + +1. Добавить store-state `slotAssignmentsByTeam` +2. Хранить assignments по `stableOwnerId` +3. Добавить actions: + - `setOwnerSlotAssignment` + - `swapOwnerSlots` + - `clearTeamSlotAssignments` + - `resetTeamSlotAssignmentsToPlannedDefaults` +4. Продумать first-load migration для старых raw pinning paths +5. Явно зафиксировать, что storage относится только к member sector slots, а не к lead/unassigned geometry +6. Добавить compare-and-reset semantics для `slotLayoutVersion` +7. Добавить one-time migration path `fallback member.name -> agentId`, если assignment уже существовал +8. Зафиксировать precedence между version reset, existing stable assignment и fallback migration + +### Done when + +- owner placement переживает refresh и не зависит от случайной d3-стабилизации +- legacy owner pinning больше не конкурирует со slot assignment как второй source of truth +- `slotLayoutVersion` mismatch сбрасывает старые member assignments предсказуемо +- existing fallback assignment может сохраниться при переходе `member.name -> agentId` +- version reset и fallback migration не создают две конкурирующие записи для одного member-а + +## Phase 4 - integrate planner into graph simulation + +### Цель + +Сделать owner positions planner-derived. + +### Сделать + +1. `useGraphSimulation` получает slot frames +2. Lead фиксируется в центре +3. `unassigned task slot`, если нужен, строится как special fixed frame под lead +4. Member node positions берутся из slot frames +5. Free-force больше не определяет owner layout +6. Если d3-force остаётся, он не должен двигать owners вне slot planner-а + +### Done when + +- owner topology не меняется из-за zoom, pan или случайного re-tick + +## Phase 5 - task band integration + +### Цель + +Встроить kanban в slot frame без cross-owner overlap. + +### Сделать + +1. `KanbanLayoutEngine` начинает работать от `slot frame` +2. Сохраняем current column order / semantics +3. Считаем реальный `kanbanWidth` +4. Ограничиваем task band по `5 rows` +5. Реализуем overflow stack per column +6. Явно сохранить существующий column order source, не вводя новый sort-rule +7. Отдельно встроить `unassigned task slot` как special pseudo-owner case: + - без `activity band` + - без `process band` + - с теми же bounded task rules + +### Done when + +- tasks не залезают в activity zone соседнего owner +- unassigned tasks больше не теряются и не "прилипают" к случайным member slots + +## Phase 6 - process rail integration + +### Цель + +Прикрепить process к owner slot. + +### Сделать + +1. Убрать свободное process placement +2. Привязать process presentation к process band внутри slot +3. Сохранить current visual style +4. Ограничить до одного релевантного process rail для `v1` + +### Done when + +- process перестаёт быть отдельным плавающим источником хаоса + +## Phase 7 - activity band integration + +### Цель + +Сделать activity частью slot, а не внешним solver-ом. + +### Сделать + +1. `GraphActivityHud` перестаёт pack-ить owners +2. Получает slot-local coordinates +3. Рисует local connector +4. Сохраняет existing compact message UI +5. Сохраняет `+N more -> Activity tab` +6. Сохраняет deterministic `newest first` + tie-break path +7. Удаляет screen-space self-packing и DOM-measurement-driven repositioning из activity path + +### Done when + +- activity больше не может сместиться независимо от owner slot + +## Phase 8 - drag / snap / swap + +### Цель + +Сохранить manual control, не ломая стабильность. + +### Сделать + +1. Drag разрешён только для member slots +2. На drop находим nearest valid slot +3. Если slot занят, делаем `swap` +4. Сохраняем assignment +5. Обновляем planner state без full random reshuffle + +### Done when + +- drag меняет slot assignment, а не свободную world position + +## Phase 9 - fit / bounds / camera + +### Цель + +Чтобы camera видела реальный layout. + +### Сделать + +1. `zoomToFit` учитывает полные slot bounds +2. Учитывать: + - activity bands + - task bands + - process rails + - central exclusion + - unassigned task slot +3. Проверить initial fit и manual fit + +### Done when + +- fit больше не обрезает реальные owner-local зоны + +## Phase 10 - cleanup old geometry paths + +### Цель + +Убрать старые механизмы, которые будут конфликтовать с новым planner-ом. + +### Сделать + +1. Удалить или задизейблить устаревший owner overlap pack path +2. Удалить owner-specific reliance on raw pinning +3. Удалить activity self-packing logic, если она больше не нужна +4. Проверить, что остались только необходимые world transforms + +### Done when + +- в коде остаётся один понятный source of truth для owner layout + +## Phase 11 - parity review + +### Цель + +Перед финальным merge проверить, что новый planner не потерял уже работающие UX-paths. + +### Проверить + +1. `+N more -> Activity tab` +2. existing activity item click behavior +3. process visual styling +4. lead launch HUD +5. fit / zoom controls +6. graph tab / fullscreen shared layout behavior +7. filters не вызывают layout jumps + +### Done when + +- новый layout стабилен и не деградирует уже полезные interaction-ы + +## Recommended PR split + +Чтобы этот refactor было реально безопасно довезти, лучше не делать его одним гигантским diff. + +Рекомендуемая нарезка: + +### PR 1 - stable identity and slot state + +- `agentId -> ResolvedTeamMember` +- `stableOwnerId` +- новые member node ids +- `slotAssignmentsByTeam` +- migration policy для legacy pinning + +### PR 2 - pure planner and simulation integration + +- `OwnerFootprint` +- `SlotFrame` +- planner helpers +- `validateStableSlotLayout` +- `StableSlotLayoutSnapshot` +- ring/sector logic +- интеграция planner-а в `useGraphSimulation` + +### PR 3 - task/process/activity slot integration + +- `KanbanLayoutEngine` от `slot frame` +- process rail inside slot +- activity inside slot +- local anchors / connector path + +### PR 4 - drag, fit, cleanup, parity + +- drag/snap/swap +- reset-layout path +- fit bounds +- удаление старых geometry paths +- parity review и финальный cleanup + +Если в реальности придётся объединить PR 2 и PR 3 - это ещё допустимо. Но делать всё одним большим PR хуже по риску и по способности нормально проверить регрессии. + +## Тест-план + +Ниже обязательные тесты, без которых этот refactor нельзя считать завершённым. + +## Identity / ordering + +- `ResolvedTeamMember.agentId` проходит до graph layer +- member node id строится на `stableOwnerId` +- rename при том же `agentId` не меняет slot assignment +- fallback на `member.name` работает, если `agentId` отсутствует +- duplicate `agentId` или duplicate fallback `member.name` не приводят к silent merge owners +- existing fallback assignment корректно мигрирует на `agentId`, если `agentId` появился позже +- hidden member при возвращении с тем же `stableOwnerId` пытается переиспользовать прежний assignment +- initial ordering стабилен и совпадает с `config.members[]` + +## Planner + +- owner slots не пересекаются друг с другом +- owner slots не пересекают central exclusion +- `leadCentralReservedBlock` не схлопывается от hidden launch HUD или пустого lead activity state +- `SlotFrame` строится по канонической формуле от `ownerAnchor` +- `activityOrigin/processOrigin/taskOrigin` детерминированно выводятся из `SlotFrame` +- validator ловит `NaN/Infinity` в frame-ах и bounds +- validator ловит duplicate assignment-ы +- validator проверяет, что band-local anchors не вылезают из своего `SlotFrame` +- при validation failure новый broken layout не перетирает последний валидный snapshot +- один planner run собирает один цельный `StableSlotLayoutSnapshot` +- snapshot коммитится атомарно, без частичного обновления `SlotFrame`/`fitBounds`/`central exclusion` +- ring 1 overflow создаёт ring 2 +- один ring не принимает больше одного owner на один sector anchor +- existing valid assignments сохраняются +- invalid assignment перепланируется локально, без полного reshuffle +- planner сначала пытается сохранить сектор, потом увести owner на внешний ring +- planner работает в world coordinates и не зависит от camera zoom/pan +- planner не пытается сам разместить `lead` или `unassigned task slot` +- planner не запускается как полноценный stable-sector layout без `lead` +- при конфликте из-за роста одного owner footprint planner сначала пытается двигать именно этот owner, а не весь ring + +## Tasks + +- task band ограничен `5 rows` +- overflow stack работает per column +- все active non-empty columns показываются +- slot width растёт при росте числа columns +- current канонический order внутри column сохраняется +- задачи без owner-а попадают в отдельный `unassigned task slot` +- задачи с owner-ом, который сейчас не видим или не существует, тоже уходят в `unassigned task slot` + +## Activity + +- activity band показывает `3` items +- `+N more` считается корректно +- comments показывают target task, а не fake-recipient member +- activity order внутри slot - newest first +- tie-break при одинаковом timestamp детерминирован и стабилен +- `+N more` открывает профиль на вкладке `Activity` + +## Process + +- process rail остаётся owner-local +- running process имеет приоритет над finished +- пустой process band не вызывает layout jump + +## Drag / persistence + +- drop снапает в nearest valid slot +- занятый target slot делает `swap` +- невалидный drop возвращает owner в прежний assignment +- refresh сохраняет slot assignments +- zoom/pan не меняют owner placement +- graph tab и fullscreen overlay используют один и тот же member slot state +- graph tab и fullscreen overlay могут иметь разный camera state +- `slotLayoutVersion` mismatch сбрасывает устаревшие member assignments +- fallback migration `member.name -> agentId` не создаёт дубли assignment-ов +- team switch не смешивает assignments разных команд +- если одна команда открыта в нескольких pane-ах, layout state у них общий и синхронизируется через shared store +- no-lead transient state не портит persistent assignments +- validation failure не делает invalid snapshot active render geometry +- no-lead path не делает stale stable-slot snapshot активным render-path вместо fallback UI +- snapshot cache не подменяет `slotAssignmentsByTeam` как placement source of truth + +## Fit / camera + +- initial fit учитывает slot bounds +- manual fit учитывает slot bounds +- initial fit учитывает `unassigned task slot` +- manual fit учитывает `unassigned task slot` +- initial fit учитывает `lead central reserved block` +- manual fit учитывает `lead central reserved block` +- zoom меняет только camera transform +- pan меняет только camera transform +- filters не меняют topology bounds, которые использует fit + +## Filters + +- `showTasks` не меняет slot topology +- `showProcesses` не меняет slot topology +- `showEdges` не меняет slot topology +- скрытие tasks/processes не убирает reserved band geometry и не вызывает layout jump +- `showTasks = false` не удаляет topology footprint у `unassigned task slot`, если в dataset всё ещё есть unassigned tasks + +## Team scoping + +- `slotAssignmentsByTeam` изолирует layout state по `teamName` +- team switch не переиспользует assignments от другой команды +- параллельно открытые graph panes не должны ломать team-scoped layout друг друга + +## Rollout / flag behavior + +- feature flag не включает mixed mode между старым и новым owner-local layout path +- при включённом новом path activity/process/tasks используют один и тот же stable-slot geometry source +- старый owner pack/reposition path не влияет на новый stable-slot render path + +## Dense teams + +- граф с большим числом участников уходит во второй ring, а не превращается в overlap-chaos +- широкие slots не придавливают соседние owner zones +- lead central zone остаётся чистой +- появление `unassigned task slot` не ломает весь ring-layout глобальным reshuffle + +## Самые слабые места и что проверять особенно внимательно + +## 1. Stable identity + +Если тут останется хотя бы один тихий fallback на `member.name` в layout storage, всё снова станет хрупким. + +И отдельно: + +- если silent merge случится из-за неуникального `agentId` или `member.name`, это сломает сразу и slot assignments, и drag, и ownership links + +## 2. Slot width growth + +Это самый рискованный geometry block после identity, потому что пользователь сознательно выбрал: + +- показывать все columns +- не вводить scroll +- не cap-ить visible columns + +## 3. Partial replanning + +Самая большая UX-ошибка тут - случайно делать global reshuffle там, где достаточно локального spill на outer ring. + +## 4. Lead central exclusion + +Если planner забудет учесть хотя бы один из центральных блоков: + +- lead +- launch HUD +- lead activity + +то первый ring начнёт врезаться в центр. + +## 5. Old geometry paths + +Если оставить старые packers и force-assumptions живыми параллельно, новый planner будет "исправляться" чужой логикой, и баги станут трудноотлавливаемыми. + +## 6. World-space vs screen-space mixing + +Это самый коварный класс багов после identity. + +Если хотя бы один owner-local слой снова начнёт: + +- сам себя pack-ить в screen-space +- двигаться из DOM measurements +- учитывать текущий zoom как вход planner-а + +то визуально всё опять будет "плавать" отдельно от графа. + +## Запрещённые переинтерпретации плана + +Ниже короткий список вещей, которые implementer не должен "упростить по ходу", потому что именно так обычно и возвращаются старые баги. + +- нельзя трактовать `Activity` как свободный overlay поверх итогового layout +- нельзя трактовать `lead` как обычный member slot только с особыми стилями +- нельзя сохранять raw `x/y` "временно, пока не доделаем slot assignment" +- нельзя позволять filters менять topology, даже если визуально это кажется проще +- нельзя схлопывать `lead activity frame` или `launch HUD frame` только потому, что они сейчас не видимы +- нельзя строить `SlotFrame` отдельно в planner и отдельно в renderer с немного разными формулами +- нельзя заменять local replanning глобальным reshuffle только потому, что так проще написать первую версию +- нельзя допускать, чтобы fullscreen и tab считали layout независимо друг от друга +- нельзя лечить overlap постфактум screen-space сдвигами вместо исправления planner-а или footprint contract + +Если для какой-то задачи кажется, что одно из этих правил "мешает быстро доделать", значит меняется не реализация плана, а сам план. Это нужно сначала явно переоткрыть как product/architecture decision, а не менять молча по коду. + +## Acceptance criteria + +- [ ] lead остаётся центральным anchor +- [ ] lead использует central reserved block, а не обычный member slot +- [ ] `leadCentralReservedBlock` детерминированно собирается из lead activity frame, lead core frame и launch HUD frame +- [ ] member slots распределяются по стабильным секторам +- [ ] second ring работает по footprint-budget, а не по member count +- [ ] stable owner id построен на `agentId`, с fallback на `member.name` +- [ ] member node ids больше не name-based +- [ ] slot assignment хранится отдельно от raw `x/y` +- [ ] `slotAssignmentsByTeam` хранит только member sector assignments, а не lead/unassigned geometry +- [ ] persistent layout state ограничен assignment-ами и version marker-ом, а geometry остаётся derived +- [ ] `slotLayoutVersion` mismatch безопасно сбрасывает старые member assignments +- [ ] existing fallback assignment может мигрировать на `agentId`, если `agentId` появился позже +- [ ] precedence между version reset, stable assignment и fallback migration определена и не создаёт дублей +- [ ] slot geometry считается в world coordinates и не чинится screen-space packer-ом +- [ ] `SlotFrame` строится по одному каноническому правилу от `ownerAnchor`, без альтернативных renderer-side трактовок +- [ ] geometry constants живут в одном stable-slot layout module и не дублируются по коду +- [ ] renderer/simulation получают один цельный `StableSlotLayoutSnapshot`, а не набор несвязанных geometry-кусочков +- [ ] planner output проходит отдельную validation pass перед commit/render +- [ ] validation failure не приводит к partially-broken render и не записывает невалидные assignments в store +- [ ] один layout update коммитится атомарно, без смешивания старых и новых поколений geometry state +- [ ] session-local last valid snapshot cache не становится альтернативным source of truth для placement +- [ ] no-lead path не коммитит новый stable-slot snapshot и не подменяет fallback render stale snapshot-ом +- [ ] runtime precedence между assignments, active snapshot, snapshot cache и fallback path соблюдается без mixed-state +- [ ] tasks bounded по высоте до `5 rows` +- [ ] activity bounded до `3 items` +- [ ] все active non-empty kanban columns показываются +- [ ] slot width может расти +- [ ] unassigned tasks живут в отдельном bounded slot под lead +- [ ] process attached к owner slot +- [ ] drag работает через snap-to-slot +- [ ] occupied slot приводит к swap +- [ ] invalid drop возвращает owner в прежний slot +- [ ] zoom/pan не меняют owner topology +- [ ] graph tab и fullscreen overlay разделяют один и тот же layout state +- [ ] graph tab и fullscreen overlay могут иметь независимый camera state без расхождения layout +- [ ] graph filters не перестраивают slot topology +- [ ] `showTasks = false` не убирает topology footprint у `unassigned task slot`, если dataset не изменился +- [ ] team switch не смешивает layout state разных команд +- [ ] team rename не обязан мигрировать layout state в `v1` и это явно осознано как tradeoff +- [ ] одна и та же команда в нескольких pane-ах использует общий shared layout state +- [ ] hidden member при возвращении с тем же `stableOwnerId` может переиспользовать прежний assignment +- [ ] no-lead transient state не порождает случайный persistent layout +- [ ] fit использует topology bounds, а не только текущую видимую presentation +- [ ] fit учитывает полные slot bounds +- [ ] dense teams больше не превращают graph в overlay-chaos + +## Итоговое решение в одной секции + +Финально мы пришли вот к чему: + +- берём `Variant 3` +- делаем `stable sectors around lead` +- фиксируем `6` sector anchors в `v1` +- добавляем `second ring` +- считаем ring capacity по footprint-budget +- используем `S / M / L` только как packing heuristic +- показываем все active non-empty kanban columns +- ограничиваем task band по высоте до `5 rows` +- ограничиваем activity band до `3 items` +- unassigned tasks уводим в отдельный bounded slot под lead +- process делаем attached sub-rail owner-а +- drag делаем через snap и swap +- slot assignment храним отдельно +- stable owner identity строим на `agentId`, fallback на `member.name` + +Это и есть зафиксированный target-state для следующей большой переделки graph layout. + +## Что ещё можно тюнить без изменения архитектуры + +Блокирующих product-level вопросов не осталось. + +Разрешён только визуальный и micro-spacing tuning, который не меняет planner semantics: + +- padding внутри activity item shell +- точные visual offsets process rail +- glow / shadow / label spacing + +Нельзя под видом тюнинга менять: + +- source of truth +- slot assignment model +- ring planner semantics +- bounded band rules +- drag/snap/swap rules + +## IOF execution checklist + +Это короткий practical checklist, по которому удобно идти во время реализации, чтобы не потерять зафиксированные решения. + +1. Сначала убедиться, что `ResolvedTeamMember` реально получил `agentId` и что graph member node ids перестали зависеть от `member.name`. +2. До включения нового planner path зафиксировать все места, где старый код всё ещё двигает owners через force/pack/pinning. +3. Вынести slot geometry в pure helpers до переписывания UI. +4. Сначала сделать planner и его unit tests, и только потом подключать UI. +5. Прежде чем трогать `GraphActivityHud`, зафиксировать один канонический `ownerAnchor/activityOrigin/processOrigin/taskOrigin`. +6. Не трогать одновременно и data model, и visual tuning, пока planner не стал детерминированным. +7. После интеграции planner-а отдельно проверить, что zoom/pan больше не влияют на topology. +8. После интеграции task band отдельно проверить dense-team cases, где slot width растёт. +9. После интеграции drag отдельно проверить invalid drop, swap и reset-layout. +10. Отдельно проверить cases без owner-а и с "битым" owner reference, чтобы такие задачи гарантированно уходили в `unassigned task slot`. +11. Перед cleanup старых paths убедиться, что новый planner покрывает `Activity`, `Process`, `Tasks` и fit bounds. +12. Перед финальным merge вручную проверить `+N more -> Activity`, launch HUD и process visual parity. +13. Не мержить состояние, где в коде остаются два равноправных source of truth для owner placement. diff --git a/src/features/agent-graph/core/domain/buildInlineActivityEntries.ts b/src/features/agent-graph/core/domain/buildInlineActivityEntries.ts index 690c6f4f..13304ff3 100644 --- a/src/features/agent-graph/core/domain/buildInlineActivityEntries.ts +++ b/src/features/agent-graph/core/domain/buildInlineActivityEntries.ts @@ -3,6 +3,8 @@ import { getIdleGraphLabel } from '@shared/utils/idleNotificationSemantics'; import { isInboxNoiseMessage } from '@shared/utils/inboxNoise'; import { isLeadMember } from '@shared/utils/leadDetection'; +import { buildGraphMemberNodeIdForMember } from './graphOwnerIdentity'; + import type { GraphActivityItem } from '@claude-teams/agent-graph'; import type { AttachmentMeta, @@ -18,6 +20,8 @@ export interface InlineActivityEntry { ownerNodeId: string; graphItem: GraphActivityItem; message: InboxMessage; + sourceKind: 'message' | 'comment'; + sourceOrder: number | null; } export interface ActivityEntrySourceData { @@ -49,6 +53,11 @@ export function buildInlineActivityEntries({ ownerNodeIds, }: BuildInlineActivityEntriesArgs): Map { const entriesByOwnerNodeId = new Map(); + const memberNodeIdByName = new Map( + data.members + .filter((member) => !isLeadMember(member)) + .map((member) => [member.name, buildGraphMemberNodeIdForMember(teamName, member)] as const) + ); const appendEntry = (entry: InlineActivityEntry): void => { const targetOwnerNodeId = ownerNodeIds.has(entry.ownerNodeId) ? entry.ownerNodeId : leadId; @@ -65,6 +74,9 @@ export function buildInlineActivityEntries({ } const orderedMessages = [...data.messages].sort((a, b) => a.timestamp.localeCompare(b.timestamp)); + const messageSourceOrderByKey = new Map( + data.messages.map((message, index) => [getActivityMessageKey(message), index] as const) + ); for (const message of orderedMessages) { if (message.summary?.startsWith('Comment on ')) { continue; @@ -80,10 +92,10 @@ export function buildInlineActivityEntries({ const ownerNodeId = resolveMessageOwnerNodeId({ message, - teamName, leadId, leadName, ownerNodeIds, + memberNodeIdByName, }); if (!ownerNodeId) { continue; @@ -113,6 +125,8 @@ export function buildInlineActivityEntries({ ownerNodeId, graphItem, message, + sourceKind: 'message', + sourceOrder: messageSourceOrderByKey.get(getActivityMessageKey(message)) ?? null, }); } @@ -123,10 +137,10 @@ export function buildInlineActivityEntries({ const ownerNodeId = resolveCommentOwnerNodeId({ taskOwner: item.task.owner, author: item.comment.author, - teamName, leadId, leadName, ownerNodeIds, + memberNodeIdByName, }); if (!ownerNodeId) { continue; @@ -154,14 +168,13 @@ export function buildInlineActivityEntries({ task: item.task, comment: item.comment, }), + sourceKind: 'comment', + sourceOrder: item.sourceOrder, }); } for (const [ownerNodeId, entries] of entriesByOwnerNodeId) { - entriesByOwnerNodeId.set( - ownerNodeId, - entries.toSorted((a, b) => b.graphItem.timestamp.localeCompare(a.graphItem.timestamp)) - ); + entriesByOwnerNodeId.set(ownerNodeId, entries.toSorted(compareInlineActivityEntries)); } return entriesByOwnerNodeId; @@ -169,30 +182,55 @@ export function buildInlineActivityEntries({ function collectTaskComments( tasks: readonly TeamTaskWithKanban[] -): { task: TeamTaskWithKanban; comment: TaskComment }[] { - const items: { task: TeamTaskWithKanban; comment: TaskComment }[] = []; +): { task: TeamTaskWithKanban; comment: TaskComment; sourceOrder: number }[] { + const items: { task: TeamTaskWithKanban; comment: TaskComment; sourceOrder: number }[] = []; + let sourceOrder = 0; for (const task of tasks) { for (const comment of task.comments ?? []) { - items.push({ task, comment }); + items.push({ task, comment, sourceOrder }); + sourceOrder += 1; } } return items; } +function compareInlineActivityEntries( + left: InlineActivityEntry, + right: InlineActivityEntry +): number { + const timestampDiff = right.graphItem.timestamp.localeCompare(left.graphItem.timestamp); + if (timestampDiff !== 0) { + return timestampDiff; + } + + if ( + left.sourceKind === right.sourceKind && + left.sourceOrder != null && + right.sourceOrder != null && + left.sourceOrder !== right.sourceOrder + ) { + return left.sourceOrder - right.sourceOrder; + } + + return left.graphItem.id.localeCompare(right.graphItem.id); +} + function resolveMessageOwnerNodeId(args: { message: InboxMessage; - teamName: string; leadId: string; leadName: string; ownerNodeIds: ReadonlySet; + memberNodeIdByName: ReadonlyMap; }): string | null { - const { message, teamName, leadId, leadName, ownerNodeIds } = args; + const { message, leadId, leadName, ownerNodeIds, memberNodeIdByName } = args; if (message.source === 'cross_team' || message.source === 'cross_team_sent') { return leadId; } - const fromId = resolveParticipantId(message.from ?? '', teamName, leadId, leadName); - const toId = message.to ? resolveParticipantId(message.to, teamName, leadId, leadName) : leadId; + const fromId = resolveParticipantId(message.from ?? '', leadId, leadName, memberNodeIdByName); + const toId = message.to + ? resolveParticipantId(message.to, leadId, leadName, memberNodeIdByName) + : leadId; if (toId !== leadId && ownerNodeIds.has(toId)) { return toId; @@ -206,20 +244,20 @@ function resolveMessageOwnerNodeId(args: { function resolveCommentOwnerNodeId(args: { taskOwner: string | undefined; author: string; - teamName: string; leadId: string; leadName: string; ownerNodeIds: ReadonlySet; + memberNodeIdByName: ReadonlyMap; }): string | null { - const { taskOwner, author, teamName, leadId, leadName, ownerNodeIds } = args; + const { taskOwner, author, leadId, leadName, ownerNodeIds, memberNodeIdByName } = args; if (taskOwner) { - const ownerId = resolveParticipantId(taskOwner, teamName, leadId, leadName); + const ownerId = resolveParticipantId(taskOwner, leadId, leadName, memberNodeIdByName); if (ownerNodeIds.has(ownerId)) { return ownerId; } } - const authorId = resolveParticipantId(author, teamName, leadId, leadName); + const authorId = resolveParticipantId(author, leadId, leadName, memberNodeIdByName); if (ownerNodeIds.has(authorId)) { return authorId; } @@ -327,9 +365,9 @@ function getActivityMessageKey(message: InboxMessage): string { function resolveParticipantId( name: string, - teamName: string, leadId: string, - leadName?: string + leadName: string | undefined, + memberNodeIdByName: ReadonlyMap ): string { const normalized = name.trim().toLowerCase(); if (normalized === 'user' || normalized === 'team-lead') { @@ -338,7 +376,7 @@ function resolveParticipantId( if (normalized === leadName?.trim().toLowerCase()) { return leadId; } - return `member:${teamName}:${name}`; + return memberNodeIdByName.get(name) ?? leadId; } function buildParticipantLabel(name: string | undefined, leadName: string): string { diff --git a/src/features/agent-graph/core/domain/graphOwnerIdentity.ts b/src/features/agent-graph/core/domain/graphOwnerIdentity.ts new file mode 100644 index 00000000..bb050616 --- /dev/null +++ b/src/features/agent-graph/core/domain/graphOwnerIdentity.ts @@ -0,0 +1,30 @@ +import { getStableTeamOwnerId, type StableTeamOwnerLike } from '@shared/utils/teamStableOwnerId'; + +export const GRAPH_STABLE_SLOT_LAYOUT_VERSION = 'stable-slots-v1' as const; + +export function getGraphStableOwnerId(member: StableTeamOwnerLike): string { + return getStableTeamOwnerId(member); +} + +export function buildGraphMemberNodeId(teamName: string, stableOwnerId: string): string { + return `member:${teamName}:${stableOwnerId}`; +} + +export function buildGraphMemberNodeIdForMember( + teamName: string, + member: StableTeamOwnerLike +): string { + return buildGraphMemberNodeId(teamName, getGraphStableOwnerId(member)); +} + +export function parseGraphMemberNodeId(nodeId: string, teamName?: string): string | null { + const prefix = teamName ? `member:${teamName}:` : 'member:'; + if (!nodeId.startsWith(prefix)) { + return null; + } + if (teamName) { + return nodeId.slice(prefix.length) || null; + } + const [, , ...rest] = nodeId.split(':'); + return rest.length > 0 ? rest.join(':') : null; +} diff --git a/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts b/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts index a7fd0bf9..9586f4d0 100644 --- a/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts +++ b/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts @@ -29,6 +29,11 @@ import { getGraphLeadMemberName, } from '../../core/domain/buildInlineActivityEntries'; import { collapseOverflowStacksWithMeta } from '../../core/domain/collapseOverflowStacks'; +import { + buildGraphMemberNodeIdForMember, + getGraphStableOwnerId, + GRAPH_STABLE_SLOT_LAYOUT_VERSION, +} from '../../core/domain/graphOwnerIdentity'; import { isTaskBlocked, isTaskInReviewCycle, @@ -38,8 +43,10 @@ import { import type { GraphDataPort, GraphEdge, + GraphLayoutPort, GraphNode, GraphNodeState, + GraphOwnerSlotAssignment, GraphParticle, } from '@claude-teams/agent-graph'; import type { @@ -49,6 +56,7 @@ import type { MemberSpawnStatusEntry, MemberSpawnStatusesSnapshot, TeamData, + TeamProcess, TeamProvisioningProgress, } from '@shared/types/team'; import type { LeadContextUsage } from '@shared/types/team'; @@ -90,12 +98,23 @@ export class TeamGraphAdapter { toolHistory?: Record, commentReadState?: Record, provisioningProgress?: TeamProvisioningProgress | null, - memberSpawnSnapshot?: MemberSpawnStatusesSnapshot + memberSpawnSnapshot?: MemberSpawnStatusesSnapshot, + slotAssignments?: Record ): GraphDataPort { if (teamData?.teamName !== teamName) { return TeamGraphAdapter.#emptyResult(teamName); } + const duplicateStableOwnerIds = TeamGraphAdapter.#collectDuplicateStableOwnerIds( + teamData.members.filter((member) => !member.removedAt && !isLeadMember(member)) + ); + if (duplicateStableOwnerIds.length > 0) { + console.error( + `[agent-graph] duplicate stable owner ids in team=${teamName}: ${duplicateStableOwnerIds.join(', ')}` + ); + return TeamGraphAdapter.#emptyResult(teamName); + } + // Reset particle tracking when team changes if (teamName !== this.#lastTeamName) { this.#seenMessageIds.clear(); @@ -115,6 +134,7 @@ export class TeamGraphAdapter { const leadId = `lead:${teamName}`; const leadName = TeamGraphAdapter.#getLeadMemberName(teamData, teamName); + const memberNodeIdByName = TeamGraphAdapter.#buildMemberNodeIdByName(teamData, teamName); const provisioningPresentation = buildTeamProvisioningPresentation({ progress: provisioningProgress, members: teamData.members, @@ -144,6 +164,7 @@ export class TeamGraphAdapter { leadId, teamData, teamName, + memberNodeIdByName, spawnStatuses, pendingApprovalAgents, activeTools, @@ -152,8 +173,8 @@ export class TeamGraphAdapter { isTeamProvisioning, isLaunchSettling ); - this.#buildTaskNodes(nodes, edges, teamData, teamName, commentReadState); - this.#buildProcessNodes(nodes, edges, teamData, teamName); + this.#buildTaskNodes(nodes, edges, teamData, teamName, commentReadState, memberNodeIdByName); + this.#buildProcessNodes(nodes, edges, teamData, teamName, memberNodeIdByName); this.#attachActivityFeeds(nodes, teamData, teamName, leadId, leadName); this.#buildMessageParticles( particles, @@ -162,9 +183,18 @@ export class TeamGraphAdapter { teamName, leadId, leadName, - edges + edges, + memberNodeIdByName + ); + this.#buildCommentParticles( + particles, + teamData, + teamName, + leadId, + leadName, + edges, + memberNodeIdByName ); - this.#buildCommentParticles(particles, teamData, teamName, leadId, leadName, edges); return { nodes, @@ -173,6 +203,7 @@ export class TeamGraphAdapter { teamName, teamColor: teamData.config.color ?? undefined, isAlive: teamData.isAlive, + layout: TeamGraphAdapter.#buildLayoutPort(teamData, teamName, slotAssignments), }; } @@ -195,6 +226,115 @@ export class TeamGraphAdapter { return getGraphLeadMemberName(data, teamName); } + static #buildMemberNodeIdByName(data: TeamData, teamName: string): Map { + return new Map( + data.members + .filter((member) => !isLeadMember(member)) + .map((member) => [member.name, buildGraphMemberNodeIdForMember(teamName, member)] as const) + ); + } + + static #buildLayoutPort( + data: TeamData, + teamName: string, + slotAssignments?: Record + ): GraphLayoutPort { + const ownerOrder: string[] = []; + const seenOwnerNodeIds = new Set(); + const visibleMembers = data.members.filter( + (member) => !member.removedAt && !isLeadMember(member) + ); + const visibleMemberByStableOwnerId = new Map( + visibleMembers.map((member) => [getGraphStableOwnerId(member), member] as const) + ); + const assignedStableOwnerIds = new Set(Object.keys(slotAssignments ?? {})); + const configStableOwnerIds = new Set( + (data.config.members ?? []).map((member) => getGraphStableOwnerId(member)) + ); + + const pushMember = (member: TeamData['members'][number] | undefined): void => { + if (!member) { + return; + } + const nodeId = buildGraphMemberNodeIdForMember(teamName, member); + if (seenOwnerNodeIds.has(nodeId)) { + return; + } + seenOwnerNodeIds.add(nodeId); + ownerOrder.push(nodeId); + }; + + const assignedVisibleMembersOutsideConfig = visibleMembers + .filter((member) => { + const stableOwnerId = getGraphStableOwnerId(member); + return ( + assignedStableOwnerIds.has(stableOwnerId) && !configStableOwnerIds.has(stableOwnerId) + ); + }) + .toSorted((left, right) => + getGraphStableOwnerId(left).localeCompare(getGraphStableOwnerId(right)) + ); + + for (const configMember of data.config.members ?? []) { + const stableOwnerId = getGraphStableOwnerId(configMember); + if (!assignedStableOwnerIds.has(stableOwnerId)) { + continue; + } + pushMember(visibleMemberByStableOwnerId.get(stableOwnerId)); + } + + for (const member of assignedVisibleMembersOutsideConfig) { + pushMember(member); + } + + for (const configMember of data.config.members ?? []) { + const stableOwnerId = getGraphStableOwnerId(configMember); + if (assignedStableOwnerIds.has(stableOwnerId)) { + continue; + } + const visibleMember = visibleMemberByStableOwnerId.get(stableOwnerId); + pushMember(visibleMember); + } + + const remainingMembers = visibleMembers.toSorted((left, right) => + getGraphStableOwnerId(left).localeCompare(getGraphStableOwnerId(right)) + ); + + for (const member of remainingMembers) { + pushMember(member); + } + + const normalizedAssignments: Record = {}; + for (const member of visibleMembers) { + const stableOwnerId = getGraphStableOwnerId(member); + const assignment = slotAssignments?.[stableOwnerId]; + if (!assignment) { + continue; + } + normalizedAssignments[buildGraphMemberNodeIdForMember(teamName, member)] = assignment; + } + + return { + version: GRAPH_STABLE_SLOT_LAYOUT_VERSION, + ownerOrder, + slotAssignments: normalizedAssignments, + }; + } + + static #collectDuplicateStableOwnerIds( + members: readonly TeamData['members'][number][] + ): string[] { + const counts = new Map(); + for (const member of members) { + const stableOwnerId = getGraphStableOwnerId(member); + counts.set(stableOwnerId, (counts.get(stableOwnerId) ?? 0) + 1); + } + return Array.from(counts.entries()) + .filter(([, count]) => count > 1) + .map(([stableOwnerId]) => stableOwnerId) + .sort((left, right) => left.localeCompare(right)); + } + static #isBeforeParticleCutoff(timestamp: string | undefined, cutoffMs: number | null): boolean { if (!timestamp || cutoffMs == null) { return false; @@ -324,6 +464,7 @@ export class TeamGraphAdapter { leadId: string, data: TeamData, teamName: string, + memberNodeIdByName: ReadonlyMap, spawnStatuses?: Record, pendingApprovalAgents?: Set, activeTools?: Record>, @@ -336,7 +477,8 @@ export class TeamGraphAdapter { if (member.removedAt) continue; if (isLeadMember(member)) continue; - const memberId = `member:${teamName}:${member.name}`; + const memberId = + memberNodeIdByName.get(member.name) ?? buildGraphMemberNodeIdForMember(teamName, member); const spawn = spawnStatuses?.[member.name]; const activeTool = TeamGraphAdapter.#selectVisibleTool( activeTools?.[member.name], @@ -425,7 +567,8 @@ export class TeamGraphAdapter { edges: GraphEdge[], data: TeamData, teamName: string, - commentReadState?: Record + commentReadState?: Record, + memberNodeIdByName?: ReadonlyMap ): void { const taskStateById = new Map>(); const taskDisplayIds = new Map(); @@ -446,7 +589,7 @@ export class TeamGraphAdapter { for (const task of data.tasks) { if (task.status === 'deleted') continue; const taskId = `task:${teamName}:${task.id}`; - const ownerMemberId = task.owner ? `member:${teamName}:${task.owner}` : null; + const ownerMemberId = task.owner ? (memberNodeIdByName?.get(task.owner) ?? null) : null; const kanbanTaskState = data.kanbanState.tasks[task.id]; const reviewerName = resolveTaskReviewer(task, kanbanTaskState); const isReviewCycle = isTaskInReviewCycle(task); @@ -496,7 +639,7 @@ export class TeamGraphAdapter { } const { visibleNodes: visibleTaskNodes, visibleNodeIdByTaskId } = - collapseOverflowStacksWithMeta(rawTaskNodes, teamName, 6); + collapseOverflowStacksWithMeta(rawTaskNodes, teamName, 5); const visibleTaskIds = new Set( visibleTaskNodes.flatMap((taskNode) => taskNode.domainRef.kind === 'task' ? [taskNode.domainRef.taskId] : [] @@ -608,18 +751,21 @@ export class TeamGraphAdapter { nodes: GraphNode[], edges: GraphEdge[], data: TeamData, - teamName: string + teamName: string, + memberNodeIdByName?: ReadonlyMap ): void { - for (const proc of data.processes) { - if (proc.stoppedAt) continue; + for (const { process: proc, ownerId } of TeamGraphAdapter.#selectRelevantProcesses( + data.processes, + memberNodeIdByName + )) { const procId = `process:${teamName}:${proc.id}`; - const ownerId = proc.registeredBy ? `member:${teamName}:${proc.registeredBy}` : null; nodes.push({ id: procId, kind: 'process', label: proc.label, state: 'active', + ownerId, processUrl: proc.url ?? undefined, processRegisteredBy: proc.registeredBy ?? undefined, processCommand: proc.command ?? undefined, @@ -638,6 +784,48 @@ export class TeamGraphAdapter { } } + static #selectRelevantProcesses( + processes: readonly TeamProcess[], + memberNodeIdByName?: ReadonlyMap + ): { process: TeamProcess; ownerId: string }[] { + const selectedByOwnerId = new Map(); + + for (const process of processes) { + const ownerId = process.registeredBy + ? (memberNodeIdByName?.get(process.registeredBy) ?? null) + : null; + if (!ownerId) { + continue; + } + + const existing = selectedByOwnerId.get(ownerId); + if (!existing || TeamGraphAdapter.#compareProcessPriority(process, existing) < 0) { + selectedByOwnerId.set(ownerId, process); + } + } + + return Array.from(selectedByOwnerId.entries()).map(([ownerId, process]) => ({ + process, + ownerId, + })); + } + + static #compareProcessPriority(left: TeamProcess, right: TeamProcess): number { + const leftRank = left.stoppedAt ? 1 : 0; + const rightRank = right.stoppedAt ? 1 : 0; + if (leftRank !== rightRank) { + return leftRank - rightRank; + } + + const leftTimestamp = left.stoppedAt ?? left.registeredAt; + const rightTimestamp = right.stoppedAt ?? right.registeredAt; + if (leftTimestamp !== rightTimestamp) { + return rightTimestamp.localeCompare(leftTimestamp); + } + + return left.id.localeCompare(right.id); + } + #attachActivityFeeds( nodes: GraphNode[], data: TeamData, @@ -683,7 +871,8 @@ export class TeamGraphAdapter { teamName: string, leadId: string, leadName: string, - edges: GraphEdge[] + edges: GraphEdge[], + memberNodeIdByName: ReadonlyMap ): void { const ordered = [...messages].reverse(); @@ -766,16 +955,22 @@ export class TeamGraphAdapter { continue; } - const edgeId = TeamGraphAdapter.#resolveMessageEdge(msg, teamName, leadId, leadName, edges); + const edgeId = TeamGraphAdapter.#resolveMessageEdge( + msg, + leadId, + leadName, + edges, + memberNodeIdByName + ); if (!edgeId) continue; // Determine direction: messages FROM a teammate TO lead should reverse // (edges are always lead→member, but message goes member→lead) const fromId = TeamGraphAdapter.#resolveParticipantId( msg.from ?? '', - teamName, leadId, - leadName + leadName, + memberNodeIdByName ); const isFromTeammate = fromId !== leadId; @@ -815,7 +1010,8 @@ export class TeamGraphAdapter { teamName: string, leadId: string, leadName: string, - edges: GraphEdge[] + edges: GraphEdge[], + memberNodeIdByName: ReadonlyMap ): void { // First call: record current comment counts without creating particles. // This prevents pre-existing comments from spawning particles when the graph opens. @@ -854,9 +1050,9 @@ export class TeamGraphAdapter { } const authorNodeId = TeamGraphAdapter.#resolveParticipantId( newComment.author, - teamName, leadId, - leadName + leadName, + memberNodeIdByName ); const taskNodeId = `task:${teamName}:${task.id}`; const authorEdge = @@ -988,16 +1184,21 @@ export class TeamGraphAdapter { static #resolveMessageEdge( msg: InboxMessage, - teamName: string, leadId: string, leadName: string, - edges: GraphEdge[] + edges: GraphEdge[], + memberNodeIdByName: ReadonlyMap ): string | null { const { from, to } = msg; if (from && to) { - const fromId = TeamGraphAdapter.#resolveParticipantId(from, teamName, leadId, leadName); - const toId = TeamGraphAdapter.#resolveParticipantId(to, teamName, leadId, leadName); + const fromId = TeamGraphAdapter.#resolveParticipantId( + from, + leadId, + leadName, + memberNodeIdByName + ); + const toId = TeamGraphAdapter.#resolveParticipantId(to, leadId, leadName, memberNodeIdByName); return ( edges.find((e) => e.source === fromId && e.target === toId)?.id ?? edges.find((e) => e.source === toId && e.target === fromId)?.id ?? @@ -1006,7 +1207,12 @@ export class TeamGraphAdapter { } if (from && !to) { - const fromId = TeamGraphAdapter.#resolveParticipantId(from, teamName, leadId, leadName); + const fromId = TeamGraphAdapter.#resolveParticipantId( + from, + leadId, + leadName, + memberNodeIdByName + ); return ( edges.find( (e) => @@ -1021,14 +1227,14 @@ export class TeamGraphAdapter { static #resolveParticipantId( name: string, - teamName: string, leadId: string, - leadName?: string + leadName: string | undefined, + memberNodeIdByName: ReadonlyMap ): string { const normalized = name.trim().toLowerCase(); if (normalized === 'user' || normalized === 'team-lead') return leadId; if (normalized === leadName?.trim().toLowerCase()) return leadId; - return `member:${teamName}:${name}`; + return memberNodeIdByName.get(name) ?? leadId; } /** Extract external team name from cross-team "from" field like "team-b.alice" */ diff --git a/src/features/agent-graph/renderer/hooks/useGraphActivityContext.ts b/src/features/agent-graph/renderer/hooks/useGraphActivityContext.ts new file mode 100644 index 00000000..ae50b51d --- /dev/null +++ b/src/features/agent-graph/renderer/hooks/useGraphActivityContext.ts @@ -0,0 +1,17 @@ +import { useStore } from '@renderer/store'; +import { selectTeamDataForName } from '@renderer/store/slices/teamSlice'; +import { useShallow } from 'zustand/react/shallow'; + +import type { TeamData, TeamSummary } from '@shared/types/team'; + +export function useGraphActivityContext(teamName: string): { + teamData: TeamData | null; + teams: TeamSummary[]; +} { + return useStore( + useShallow((state) => ({ + teamData: selectTeamDataForName(state, teamName), + teams: state.teams, + })) + ); +} diff --git a/src/features/agent-graph/renderer/hooks/useGraphSidebarVisibility.ts b/src/features/agent-graph/renderer/hooks/useGraphSidebarVisibility.ts new file mode 100644 index 00000000..b88c983a --- /dev/null +++ b/src/features/agent-graph/renderer/hooks/useGraphSidebarVisibility.ts @@ -0,0 +1,52 @@ +import { useCallback, useEffect, useState } from 'react'; + +import { useStore } from '@renderer/store'; + +const GRAPH_SIDEBAR_VISIBILITY_STORAGE_KEY = 'team-graph-sidebar-visible'; + +function readInitialVisibility(): boolean { + if (typeof window === 'undefined') { + return true; + } + + try { + return window.localStorage.getItem(GRAPH_SIDEBAR_VISIBILITY_STORAGE_KEY) !== 'false'; + } catch { + return true; + } +} + +export function useGraphSidebarVisibility(): { + sidebarVisible: boolean; + toggleSidebarVisible: () => void; +} { + const [sidebarEnabled, setSidebarEnabled] = useState(readInitialVisibility); + const messagesPanelMode = useStore((state) => state.messagesPanelMode); + const setMessagesPanelMode = useStore((state) => state.setMessagesPanelMode); + const sidebarVisible = sidebarEnabled && messagesPanelMode === 'sidebar'; + + useEffect(() => { + try { + window.localStorage.setItem(GRAPH_SIDEBAR_VISIBILITY_STORAGE_KEY, String(sidebarEnabled)); + } catch { + // Ignore storage failures and keep UI responsive. + } + }, [sidebarEnabled]); + + const toggleSidebarVisible = useCallback(() => { + if (sidebarVisible) { + setSidebarEnabled(false); + return; + } + + setSidebarEnabled(true); + if (messagesPanelMode !== 'sidebar') { + setMessagesPanelMode('sidebar'); + } + }, [messagesPanelMode, setMessagesPanelMode, sidebarVisible]); + + return { + sidebarVisible, + toggleSidebarVisible, + }; +} diff --git a/src/features/agent-graph/renderer/hooks/useTeamGraphAdapter.ts b/src/features/agent-graph/renderer/hooks/useTeamGraphAdapter.ts index 346a7105..88e2127e 100644 --- a/src/features/agent-graph/renderer/hooks/useTeamGraphAdapter.ts +++ b/src/features/agent-graph/renderer/hooks/useTeamGraphAdapter.ts @@ -3,7 +3,7 @@ * Thin wrapper — instantiates the class adapter and calls adapt() with store data. */ -import { useMemo, useRef, useSyncExternalStore } from 'react'; +import { useEffect, useMemo, useRef, useSyncExternalStore } from 'react'; import { getSnapshot, subscribe } from '@renderer/services/commentReadStorage'; import { useStore } from '@renderer/store'; @@ -31,6 +31,8 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort { toolHistory, provisioningProgress, memberSpawnSnapshot, + slotAssignments, + ensureTeamGraphSlotAssignments, } = useStore( useShallow((s) => ({ teamData: selectTeamDataForName(s, teamName), @@ -43,6 +45,8 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort { toolHistory: teamName ? s.toolHistoryByTeam[teamName] : undefined, provisioningProgress: teamName ? getCurrentProvisioningProgressForTeam(s, teamName) : null, memberSpawnSnapshot: teamName ? s.memberSpawnSnapshotsByTeam[teamName] : undefined, + slotAssignments: teamName ? s.slotAssignmentsByTeam[teamName] : undefined, + ensureTeamGraphSlotAssignments: s.ensureTeamGraphSlotAssignments, })) ); @@ -58,6 +62,13 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort { const commentReadState = useSyncExternalStore(subscribe, getSnapshot); + useEffect(() => { + if (!teamName || !teamData) { + return; + } + ensureTeamGraphSlotAssignments(teamName, teamData.members); + }, [ensureTeamGraphSlotAssignments, teamData, teamName]); + return useMemo( () => adapterRef.current.adapt( @@ -72,7 +83,8 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort { toolHistory, commentReadState, provisioningProgress, - memberSpawnSnapshot + memberSpawnSnapshot, + slotAssignments ), [ teamData, @@ -87,6 +99,7 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort { commentReadState, provisioningProgress, memberSpawnSnapshot, + slotAssignments, ] ); } diff --git a/src/features/agent-graph/renderer/hooks/useTeamGraphSurfaceActions.ts b/src/features/agent-graph/renderer/hooks/useTeamGraphSurfaceActions.ts new file mode 100644 index 00000000..ed30a998 --- /dev/null +++ b/src/features/agent-graph/renderer/hooks/useTeamGraphSurfaceActions.ts @@ -0,0 +1,56 @@ +import { useCallback } from 'react'; + +import { useStore } from '@renderer/store'; + +import { parseGraphMemberNodeId } from '../../core/domain/graphOwnerIdentity'; + +import type { GraphOwnerSlotAssignment } from '@claude-teams/agent-graph'; + +export function useTeamGraphSurfaceActions(teamName: string): { + openTeamPage: () => void; + commitOwnerSlotDrop: (payload: { + nodeId: string; + assignment: GraphOwnerSlotAssignment; + displacedNodeId?: string; + displacedAssignment?: GraphOwnerSlotAssignment; + }) => void; +} { + const openTeamPage = useCallback(() => { + useStore.getState().openTeamTab(teamName); + }, [teamName]); + + const commitOwnerSlotDrop = useCallback( + (payload: { + nodeId: string; + assignment: GraphOwnerSlotAssignment; + displacedNodeId?: string; + displacedAssignment?: GraphOwnerSlotAssignment; + }) => { + const stableOwnerId = parseGraphMemberNodeId(payload.nodeId, teamName); + if (!stableOwnerId) { + return; + } + const displacedStableOwnerId = payload.displacedNodeId + ? parseGraphMemberNodeId(payload.displacedNodeId, teamName) + : null; + const store = useStore.getState(); + if (displacedStableOwnerId && payload.displacedAssignment) { + store.commitTeamGraphOwnerSlotDrop( + teamName, + stableOwnerId, + payload.assignment, + displacedStableOwnerId, + payload.displacedAssignment + ); + return; + } + store.setTeamGraphOwnerSlotAssignment(teamName, stableOwnerId, payload.assignment); + }, + [teamName] + ); + + return { + openTeamPage, + commitOwnerSlotDrop, + }; +} diff --git a/src/features/agent-graph/renderer/ui/GraphActivityHud.tsx b/src/features/agent-graph/renderer/ui/GraphActivityHud.tsx index 448ff848..b157fb78 100644 --- a/src/features/agent-graph/renderer/ui/GraphActivityHud.tsx +++ b/src/features/agent-graph/renderer/ui/GraphActivityHud.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { ACTIVITY_ANCHOR_LAYOUT, ACTIVITY_LANE } from '@claude-teams/agent-graph'; import { ActivityItem } from '@renderer/components/team/activity/ActivityItem'; import { buildMessageContext, @@ -8,16 +9,14 @@ import { import { MessageExpandDialog } from '@renderer/components/team/activity/MessageExpandDialog'; import { useStableTeamMentionMeta } from '@renderer/hooks/useStableTeamMentionMeta'; import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead'; -import { useStore } from '@renderer/store'; -import { selectTeamDataForName } from '@renderer/store/slices/teamSlice'; import { toMessageKey } from '@renderer/utils/teamMessageKey'; -import { useShallow } from 'zustand/react/shallow'; import { buildInlineActivityEntries, getGraphLeadMemberName, type InlineActivityEntry, } from '../../core/domain/buildInlineActivityEntries'; +import { useGraphActivityContext } from '../hooks/useGraphActivityContext'; import type { GraphNode } from '@claude-teams/agent-graph'; import type { TimelineItem } from '@renderer/components/team/activity/LeadThoughtsGroup'; @@ -66,18 +65,12 @@ export const GraphActivityHud = ({ onOpenTaskDetail, onOpenMemberProfile, }: GraphActivityHudProps): React.JSX.Element | null => { - const ACTIVITY_LANE_WIDTH = 296; const worldLayerRef = useRef(null); const shellRefs = useRef(new Map()); const connectorRefs = useRef(new Map()); const connectorPathRefs = useRef(new Map()); const [expandedItem, setExpandedItem] = useState(null); - const { teamData, teams } = useStore( - useShallow((state) => ({ - teamData: selectTeamDataForName(state, teamName), - teams: state.teams, - })) - ); + const { teamData, teams } = useGraphActivityContext(teamName); const ownerNodes = useMemo( () => @@ -159,15 +152,14 @@ export const GraphActivityHud = ({ worldLayer.style.transform = `translate(${Math.round(origin.x)}px, ${Math.round(origin.y)}px) scale(${zoom.toFixed(3)})`; } - const measurableLanes: Array<{ + const measurableLanes: { lane: (typeof visibleLanes)[number]; shell: HTMLDivElement; connector: SVGSVGElement | null; connectorPath: SVGPathElement | null; laneTopLeft: { x: number; y: number }; nodeWorld: { x: number; y: number }; - scale: number; - }> = []; + }[] = []; for (const lane of visibleLanes) { const shell = shellRefs.current.get(lane.node.id); @@ -189,7 +181,7 @@ export const GraphActivityHud = ({ } const scale = Math.max(getCameraZoom(), 0.001); - const widthScreen = Math.max(1, (shell.offsetWidth || ACTIVITY_LANE_WIDTH) * scale); + const widthScreen = Math.max(1, (shell.offsetWidth || ACTIVITY_LANE.width) * scale); const heightScreen = Math.max(1, (shell.offsetHeight || 220) * scale); const viewport = getViewportSize?.(); const laneVisible = viewport @@ -215,26 +207,31 @@ export const GraphActivityHud = ({ connectorPath, laneTopLeft, nodeWorld, - scale, }); } for (const entry of measurableLanes) { - const { lane, shell, connector, connectorPath, laneTopLeft, nodeWorld, scale } = entry; + const { lane, shell, connector, connectorPath, laneTopLeft, nodeWorld } = entry; 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.left = `${Math.round(laneTopLeft.x)}px`; - shell.style.top = `${Math.round(laneTopLeft.y)}px`; + shell.style.top = `${Math.round(adjustedLaneTop)}px`; shell.style.transform = ''; if (connector && connectorPath) { - const widthWorld = shell.offsetWidth || ACTIVITY_LANE_WIDTH; - const laneCenterX = laneTopLeft.x + widthWorld / 2; - const laneIsLeft = laneCenterX < nodeWorld.x; - const endX = laneIsLeft ? laneTopLeft.x + widthWorld - 8 : laneTopLeft.x + 8; - const endY = laneTopLeft.y + 10; + const endX = laneTopLeft.x + widthWorld / 2; + const endY = adjustedLaneTop + heightWorld - 6; const startX = nodeWorld.x; - const startY = nodeWorld.y - 10 / scale; + const startY = nodeWorld.y - 18; const minX = Math.min(startX, endX); const minY = Math.min(startY, endY); const connectorWidth = Math.max(1, Math.abs(endX - startX)); @@ -367,14 +364,14 @@ export const GraphActivityHud = ({ return; } - const listeners: Array<{ shell: HTMLDivElement; handler: (event: WheelEvent) => void }> = []; + const listeners: { shell: HTMLDivElement; handler: (event: WheelEvent) => void }[] = []; for (const lane of visibleLanes) { const shell = shellRefs.current.get(lane.node.id); if (!shell) { continue; } - const handler = (event: WheelEvent) => forwardWheelToGraph(event, shell); + const handler = (event: WheelEvent): void => forwardWheelToGraph(event, shell); shell.addEventListener('wheel', handler, { passive: false }); listeners.push({ shell, handler }); } @@ -421,7 +418,7 @@ export const GraphActivityHud = ({ 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` }} + style={{ width: `${ACTIVITY_LANE.width}px`, maxWidth: `${ACTIVITY_LANE.width}px` }} >
Activity diff --git a/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx b/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx index ee9f8e95..fbf6287e 100644 --- a/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx +++ b/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx @@ -7,10 +7,11 @@ import { useCallback, useMemo } from 'react'; import { GraphView } from '@claude-teams/agent-graph'; import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHost'; -import { useStore } from '@renderer/store'; import { useGraphCreateTaskDialog } from '../hooks/useGraphCreateTaskDialog'; +import { useGraphSidebarVisibility } from '../hooks/useGraphSidebarVisibility'; import { useTeamGraphAdapter } from '../hooks/useTeamGraphAdapter'; +import { useTeamGraphSurfaceActions } from '../hooks/useTeamGraphSurfaceActions'; import { GraphActivityHud } from './GraphActivityHud'; import { GraphBlockingEdgePopover } from './GraphBlockingEdgePopover'; @@ -27,6 +28,8 @@ export interface TeamGraphOverlayProps { teamName: string; onClose: () => void; onPinAsTab?: () => void; + sidebarVisible?: boolean; + onToggleSidebar?: () => void; onSendMessage?: (memberName: string) => void; onOpenTaskDetail?: (taskId: string) => void; onOpenMemberProfile?: ( @@ -42,12 +45,19 @@ export const TeamGraphOverlay = ({ teamName, onClose, onPinAsTab, + sidebarVisible, + onToggleSidebar, onSendMessage, onOpenTaskDetail, onOpenMemberProfile, }: TeamGraphOverlayProps): React.JSX.Element => { const graphData = useTeamGraphAdapter(teamName); + const { openTeamPage: openTeamTab, commitOwnerSlotDrop } = useTeamGraphSurfaceActions(teamName); + const { sidebarVisible: persistedSidebarVisible, toggleSidebarVisible } = + useGraphSidebarVisibility(); const { dialog: createTaskDialog, openCreateTaskDialog } = useGraphCreateTaskDialog(teamName); + const effectiveSidebarVisible = sidebarVisible ?? persistedSidebarVisible; + const handleToggleSidebar = onToggleSidebar ?? toggleSidebarVisible; const leadNodeId = useMemo( () => graphData.nodes.find((node) => node.kind === 'lead')?.id ?? null, [graphData.nodes] @@ -73,9 +83,9 @@ export const TeamGraphOverlay = ({ [dispatchTaskAction] ); const openTeamPage = useCallback(() => { - useStore.getState().openTeamTab(teamName); + openTeamTab(); onClose(); - }, [onClose, teamName]); + }, [onClose, openTeamTab]); const openCreateTask = useCallback(() => { openCreateTaskDialog(''); }, [openCreateTaskDialog]); @@ -104,7 +114,9 @@ export const TeamGraphOverlay = ({ return (
- + {effectiveSidebarVisible ? ( + + ) : null} { const extraHudProps = hudProps as typeof hudProps & { diff --git a/src/features/agent-graph/renderer/ui/TeamGraphTab.tsx b/src/features/agent-graph/renderer/ui/TeamGraphTab.tsx index 8df77c2f..18be6f19 100644 --- a/src/features/agent-graph/renderer/ui/TeamGraphTab.tsx +++ b/src/features/agent-graph/renderer/ui/TeamGraphTab.tsx @@ -7,10 +7,11 @@ import { lazy, Suspense, useCallback, useMemo, useState } from 'react'; import { GraphView } from '@claude-teams/agent-graph'; import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHost'; -import { useStore } from '@renderer/store'; import { useGraphCreateTaskDialog } from '../hooks/useGraphCreateTaskDialog'; +import { useGraphSidebarVisibility } from '../hooks/useGraphSidebarVisibility'; import { useTeamGraphAdapter } from '../hooks/useTeamGraphAdapter'; +import { useTeamGraphSurfaceActions } from '../hooks/useTeamGraphSurfaceActions'; import { GraphActivityHud } from './GraphActivityHud'; import { GraphBlockingEdgePopover } from './GraphBlockingEdgePopover'; @@ -44,11 +45,13 @@ export const TeamGraphTab = ({ isPaneFocused = false, }: TeamGraphTabProps): React.JSX.Element => { const graphData = useTeamGraphAdapter(teamName); + const { openTeamPage, commitOwnerSlotDrop } = useTeamGraphSurfaceActions(teamName); const leadNodeId = useMemo( () => graphData.nodes.find((node) => node.kind === 'lead')?.id ?? null, [graphData.nodes] ); const [fullscreen, setFullscreen] = useState(false); + const { sidebarVisible, toggleSidebarVisible } = useGraphSidebarVisibility(); const { dialog: createTaskDialog, openCreateTaskDialog } = useGraphCreateTaskDialog(teamName); // Typed event dispatchers (DRY — used in both events + renderOverlay) @@ -73,9 +76,6 @@ export const TeamGraphTab = ({ ), [teamName] ); - const openTeamPage = useCallback(() => { - useStore.getState().openTeamTab(teamName); - }, [teamName]); const openCreateTask = useCallback(() => { openCreateTaskDialog(''); }, [openCreateTaskDialog]); @@ -130,12 +130,14 @@ export const TeamGraphTab = ({ return (
- + {sidebarVisible ? ( + + ) : null}
setFullscreen(true)} onOpenTeamPage={openTeamPage} onCreateTask={openCreateTask} + onToggleSidebar={toggleSidebarVisible} + isSidebarVisible={sidebarVisible} + onOwnerSlotDrop={commitOwnerSlotDrop} renderHud={(hudProps) => { const extraHudProps = hudProps as typeof hudProps & { getViewportSize?: () => { width: number; height: number }; @@ -227,6 +232,8 @@ export const TeamGraphTab = ({ setFullscreen(false)} + sidebarVisible={sidebarVisible} + onToggleSidebar={toggleSidebarVisible} onSendMessage={dispatchSendMessage} onOpenTaskDetail={dispatchOpenTask} onOpenMemberProfile={dispatchOpenProfile} diff --git a/src/features/tmux-installer/main/adapters/output/runtime/__tests__/TmuxInstallerRunnerAdapter.test.ts b/src/features/tmux-installer/main/adapters/output/runtime/__tests__/TmuxInstallerRunnerAdapter.test.ts index 3c1fc042..fadaa318 100644 --- a/src/features/tmux-installer/main/adapters/output/runtime/__tests__/TmuxInstallerRunnerAdapter.test.ts +++ b/src/features/tmux-installer/main/adapters/output/runtime/__tests__/TmuxInstallerRunnerAdapter.test.ts @@ -168,16 +168,18 @@ describe('TmuxInstallerRunnerAdapter', () => { retryWithUpdateCommand: null, })), }; - let resolveTerminalRun: ((result: { exitCode: number }) => void) | null = null; + const resolveTerminalRunRef: { current: ((result: { exitCode: number }) => void) | null } = { + current: null, + }; const terminalSession = { run: vi.fn( () => new Promise<{ exitCode: number }>((resolve) => { - resolveTerminalRun = resolve; + resolveTerminalRunRef.current = resolve; }) ), writeLine: vi.fn((input: string) => { - resolveTerminalRun?.({ exitCode: 0 }); + resolveTerminalRunRef.current?.({ exitCode: 0 }); return input; }), cancel: vi.fn(), @@ -208,12 +210,14 @@ describe('TmuxInstallerRunnerAdapter', () => { getStatus: vi.fn(async () => createBaseStatus()), invalidateStatus: vi.fn(), }; - let resolveCommandRun: ((result: { exitCode: number }) => void) | null = null; + const resolveCommandRunRef: { current: ((result: { exitCode: number }) => void) | null } = { + current: null, + }; const commandRunner = { run: vi.fn( () => new Promise<{ exitCode: number }>((resolve) => { - resolveCommandRun = resolve; + resolveCommandRunRef.current = resolve; }) ), cancel: vi.fn(), @@ -244,7 +248,7 @@ describe('TmuxInstallerRunnerAdapter', () => { (snapshot) => snapshot.canCancel ); await runner.cancel(); - resolveCommandRun?.({ exitCode: 1 }); + resolveCommandRunRef.current?.({ exitCode: 1 }); await expect(installPromise).resolves.toBeUndefined(); diff --git a/src/features/tmux-installer/main/adapters/output/sources/__tests__/TmuxStatusSourceAdapter.test.ts b/src/features/tmux-installer/main/adapters/output/sources/__tests__/TmuxStatusSourceAdapter.test.ts index d93ab89e..8099e5bd 100644 --- a/src/features/tmux-installer/main/adapters/output/sources/__tests__/TmuxStatusSourceAdapter.test.ts +++ b/src/features/tmux-installer/main/adapters/output/sources/__tests__/TmuxStatusSourceAdapter.test.ts @@ -44,27 +44,30 @@ describe('TmuxStatusSourceAdapter', () => { it('does not reuse or recache a stale in-flight probe after invalidateStatus()', async () => { const childProcess = await import('node:child_process'); - let firstCallback: - | ((error: Error | null, stdout: string | Buffer, stderr: string | Buffer) => void) - | null = null; + type ExecFileCallback = ( + error: Error | null, + stdout: string | Buffer, + stderr: string | Buffer + ) => void; + const firstCallbackRef: { current: ExecFileCallback | null } = { + current: null, + }; const execFileMock = vi.mocked(childProcess.execFile); - execFileMock.mockImplementation( - ( - _command: string, - _args: string[], - _options: unknown, - callback: (error: Error | null, stdout: string | Buffer, stderr: string | Buffer) => void - ) => { - if (!firstCallback) { - firstCallback = callback; - return {} as never; - } - - callback(null, 'tmux second\n', ''); + execFileMock.mockImplementation((( + _command: string, + _args: readonly string[] | null | undefined, + _options: unknown, + callback: ExecFileCallback + ) => { + if (!firstCallbackRef.current) { + firstCallbackRef.current = callback; return {} as never; } - ); + + callback(null, 'tmux second\n', ''); + return {} as never; + }) as never); const adapter = new TmuxStatusSourceAdapter( { @@ -92,7 +95,7 @@ describe('TmuxStatusSourceAdapter', () => { expect(secondStatus.host.version).toBe('tmux second'); - firstCallback?.(null, 'tmux first\n', ''); + firstCallbackRef.current?.(null, 'tmux first\n', ''); await firstStatusPromise; await Promise.resolve(); diff --git a/src/features/tmux-installer/main/infrastructure/installer/TmuxCommandRunner.ts b/src/features/tmux-installer/main/infrastructure/installer/TmuxCommandRunner.ts index ed909b10..46d567be 100644 --- a/src/features/tmux-installer/main/infrastructure/installer/TmuxCommandRunner.ts +++ b/src/features/tmux-installer/main/infrastructure/installer/TmuxCommandRunner.ts @@ -40,7 +40,7 @@ export class TmuxCommandRunner { flush: () => void; } => { let pending = ''; - let pendingBytes = Buffer.alloc(0); + let pendingBytes: Buffer = Buffer.alloc(0); const emitLine = (line: string): void => { const normalizedLine = line.replace(/\r$/, ''); diff --git a/src/features/tmux-installer/renderer/ui/TmuxInstallerBannerView.tsx b/src/features/tmux-installer/renderer/ui/TmuxInstallerBannerView.tsx index 3ca6a288..4cbb1029 100644 --- a/src/features/tmux-installer/renderer/ui/TmuxInstallerBannerView.tsx +++ b/src/features/tmux-installer/renderer/ui/TmuxInstallerBannerView.tsx @@ -76,6 +76,7 @@ export function TmuxInstallerBannerView(): React.JSX.Element | null { const manualHintsVisible = viewModel.manualHints.length > 0 && (!viewModel.manualHintsCollapsible || manualHintsExpanded); + const primaryGuideUrl = viewModel.primaryGuideUrl; return (
)} - {viewModel.primaryGuideUrl && ( + {primaryGuideUrl && (